Merge pull request #353 from timkjr/omp-mode

feat: add OMP (Oh My Pi) as a new session backend
This commit is contained in:
Codeman maintainer
2026-08-30 16:16:26 +02:00
54 changed files with 2258 additions and 108 deletions
+1
View File
@@ -67,3 +67,4 @@ export {
export type { DeepSeekProfile, DeepSeekProfileKind } from './deepseek-cli-resolver.js';
export { compileFileQuery, matchFileQuery } from './file-query.js';
export type { FileQueryMatcher } from './file-query.js';
export { resolveOmpDir, isOmpAvailable, getOmpNotFoundMessage, getOmpCliVersion } from './omp-cli-resolver.js';
+144
View File
@@ -0,0 +1,144 @@
/**
* @fileoverview Resolve the OMP CLI binary across common install paths.
*
* Uses the shared `createCliExecutableResolver` (cli-executable-resolver.ts),
* same as the sibling claude/opencode/codex/gemini/antigravity/pi resolvers:
* server process PATH first, then common install directories, then — last,
* because it is the only step that spawns anything — an interactive login
* shell, which is what finds nvm/Homebrew/user-npm installs when Codeman runs
* as a systemd/launchd service with a minimal PATH.
*
* Provides an augmented PATH directory for tmux sessions.
*
* @module utils/omp-cli-resolver
*/
import { execFileSync } from 'node:child_process';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
import {
createCliExecutableResolver,
formatCliNotFoundMessage,
type CliResolverHost,
} from './cli-executable-resolver.js';
/**
* Common directories where the OMP CLI binary may be installed. `~/.local/bin`
* leads: omp.sh's installer targets `$HOME/.local/bin` with no `--dir`
* override (verified against a real `--no-cache` Docker build — see
* docker/agent.Dockerfile); `~/.omp/bin` was an unverified guess that turned
* out wrong, kept after `~/.local/bin` only as a defensive fallback.
*/
const OMP_SEARCH_DIRS = [
join(homedir(), '.local', 'bin'),
join(homedir(), '.omp', 'bin'),
'/usr/local/bin',
join(homedir(), '.bun', 'bin'),
join(homedir(), '.npm-global', 'bin'),
join(homedir(), 'bin'),
];
/**
* A real `omp --version` prints `omp/<semver>` (e.g. `omp/17.4.0`).
*
* Shape mirrors PI_VERSION_REGEX: a capturing group and a leading boundary so
* `omp/17.4.0` matches while an unrelated `omp` (some other program) does not.
*/
export const OMP_VERSION_REGEX = /(?:^|\s)omp\/(\d+\.\d+\.\d+)/;
const OMP_NOT_FOUND = 'OMP CLI not found. Install with: curl -fsSL https://omp.sh/install | sh';
/**
* Run `omp --version` on a candidate path and return the trimmed version when
* it looks like the coding agent. Returns null for anything else — a missing
* binary, a non-zero exit, a hang (timeout), or output that is not
* `omp/<semver>`-shaped (which is how an unrelated `omp` on PATH gets rejected).
*
* Never runs under vitest: the suites must stay hermetic and must not depend on
* whether the dev box happens to have omp installed. The shared resolver host
* is already inert under vitest, so this gate is defense in depth for any
* opted-in host that still carries the default probe.
*/
function probeOmpVersion(binPath: string): string | null {
if (process.env.VITEST) return null;
try {
const out = execFileSync(binPath, ['--version'], {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
stdio: ['ignore', 'pipe', 'ignore'],
// A stuck or hostile `omp` that ignores SIGTERM would survive the timeout
// and block the server (execFileSync keeps waiting after the signal).
killSignal: 'SIGKILL',
}).trim();
const candidate = OMP_VERSION_REGEX.exec(out)?.[1];
if (candidate) return candidate;
console.warn(`[OmpResolver] Ignoring ${binPath}: "omp --version" printed ${JSON.stringify(out.slice(0, 80))}`);
} catch (err) {
console.warn(`[OmpResolver] Ignoring ${binPath}: "omp --version" failed (${(err as Error).message})`);
}
return null;
}
type OmpVersionProbe = (binPath: string) => string | null;
function createOmpResolver(
host?: CliResolverHost,
versionProbe: OmpVersionProbe = probeOmpVersion,
now?: () => number
) {
return createCliExecutableResolver<string>(
{
binary: 'omp',
searchDirs: OMP_SEARCH_DIRS,
validateCandidate: (binPath) => {
const version = versionProbe(binPath);
return version ? { accepted: true, metadata: version } : { accepted: false };
},
now,
},
host
);
}
/**
* Creates an isolated OMP wrapper around an injected host, version probe and
* clock. Omitting `versionProbe` keeps the ambient (VITEST-gated) probe, which
* is exactly what the hermeticity test exercises.
*/
export function createOmpResolverForTest(host: CliResolverHost, versionProbe?: OmpVersionProbe, now?: () => number) {
return createOmpResolver(host, versionProbe ?? probeOmpVersion, now);
}
const ompResolver = createOmpResolver();
/**
* Finds the directory containing a verified `omp` binary.
* Checks `which omp` first, then falls back to common install locations. Every
* candidate must pass the `omp --version` sanity probe before it is accepted.
*
* @returns Directory path, or null if not found
*/
export function resolveOmpDir(): string | null {
return ompResolver.resolve()?.directory ?? null;
}
/**
* Check if the OMP CLI is available on the system.
*/
export function isOmpAvailable(): boolean {
return resolveOmpDir() !== null;
}
export function getOmpNotFoundMessage(): string {
return formatCliNotFoundMessage(OMP_NOT_FOUND, ompResolver.diagnostics());
}
/**
* Version reported by the resolved `omp` binary, or null when omp is
* unavailable. Surfaced through `GET /api/omp/status` so a misresolution is
* diagnosable from the UI.
*/
export function getOmpCliVersion(): string | null {
return ompResolver.resolve()?.metadata ?? null;
}
+192
View File
@@ -0,0 +1,192 @@
/**
* @fileoverview Resolve the real OMP session id for a working directory, so a
* relaunch can pass `--resume <id>` instead of the ambiguous `--continue`.
*
* `omp` persists each conversation as its own file under
* `~/.omp/agent/sessions/<mangled-workingDir>/<ISO-timestamp>_<session-uuid>.jsonl`
* (workingDir mangled the same way Claude Code mangles `~/.claude/projects/*`:
* every `/` replaced with `-`). `--continue` picks whichever file in that
* directory is newest, which silently drifts to the WRONG conversation the
* moment two Codeman sessions ever touch the same directory — exactly what a
* closed-then-resumed row plus a still-running duplicate produces. Resolving
* the id once and pinning it with `--resume` removes that ambiguity for every
* later relaunch of the same Codeman session.
*
* @module utils/omp-session-resolver
*/
import { closeSync, openSync, readdirSync, readSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
import { join, sep } from 'node:path';
/** A real OMP session file is `<ISO-ish-timestamp>_<uuid>.jsonl`; only the uuid matters here. */
const OMP_SESSION_FILE_PATTERN = /^.+_([a-zA-Z0-9-]+)\.jsonl$/;
/**
* Mirrors `omp`'s own directory mangling. Confirmed empirically against real
* `~/.omp/agent/sessions/` directory names (2026-08-27): unlike Claude Code's
* `~/.claude/projects/*`, which keeps the home prefix (`-home-user-dev-foo`),
* omp collapses a home-relative workingDir to its home-relative remainder
* FIRST (`/home/user/dev/foo` -> `/dev/foo`) and only then dash-replaces
* (`-dev-foo`) — a path outside $HOME (e.g. `/tmp/...`) is dash-replaced as-is.
* Getting this wrong doesn't error, it just silently returns an empty
* directory listing: findLatestOmpSessionId() below then always falls through
* to null, so continuation pinning quietly degrades to omp's own ambiguous
* `--continue` for every case under $HOME (i.e. virtually all real Codeman
* cases) while appearing to work in `/tmp`-based manual testing.
* Pure so it's unit-testable without touching the filesystem.
*/
export function mangleOmpWorkingDir(workingDir: string): string {
// UNVERIFIED EDGE CASE: if $HOME is itself a symlink, this compares against
// the literal homedir() string, not a realpath()-resolved one. Whether that
// matches omp's own behavior is unconfirmed — we only empirically verified
// omp strips a literal $HOME prefix (2026-08-27), not that it canonicalizes
// symlinks first. Do not "fix" this with realpathSync() without confirming
// omp's actual behavior on a symlinked-home setup; guessing wrong here would
// trade one silent mismatch for a different one.
const home = homedir();
const relative =
workingDir === home || workingDir.startsWith(home + sep) ? workingDir.slice(home.length) : workingDir;
return relative.replace(/\//g, '-');
}
/**
* `~/.omp` — omp's own env overrides are mostly `PI_*` (shared with pi mode, already
* allowlisted in schemas.ts), and `PI_CONFIG_DIR` in particular can move this root.
* That is not honored here: a session with a redirected `PI_CONFIG_DIR` silently
* degrades pinning/history to omp's own ambiguous `--continue` instead of erroring,
* a known gap (found in Ark0N/Codeman#353 review) shared with pi and not fixed here.
*/
function resolveOmpHome(): string {
return join(homedir(), '.omp');
}
/**
* Newest OMP session id for this working directory, or null when the
* directory doesn't exist yet (never launched) or holds no session files.
*
* Deliberately "newest file, full stop" rather than a time-windowed match:
* callers only invoke this at a moment where that's unambiguous by
* construction — right after the file that answers it was the only thing
* that could have just been written (a dead pane's process already exited,
* or a session being resumed has no live sibling in the same directory yet).
*/
export function findLatestOmpSessionId(workingDir: string): string | null {
const dir = join(resolveOmpHome(), 'agent', 'sessions', mangleOmpWorkingDir(workingDir));
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return null;
}
let newestMtime = -Infinity;
let newestId: string | null = null;
for (const entry of entries) {
const match = OMP_SESSION_FILE_PATTERN.exec(entry);
if (!match) continue;
let mtimeMs: number;
try {
mtimeMs = statSync(join(dir, entry)).mtimeMs;
} catch {
continue;
}
if (mtimeMs > newestMtime) {
newestMtime = mtimeMs;
newestId = match[1];
}
}
return newestId;
}
/**
* The session header line is always near the top of the file (the
* transcript's own "second line" — see omp-transcript.ts), so identifying a
* file never needs reading the whole thing (up to multi-MB, per that same
* module's size cap). Bounded read only.
*/
const HEADER_READ_BYTES = 8 * 1024;
function readOmpSessionHeader(filePath: string): { id: string; cwd: string } | null {
let raw: string;
try {
const fd = openSync(filePath, 'r');
try {
const buf = Buffer.alloc(HEADER_READ_BYTES);
const bytesRead = readSync(fd, buf, 0, HEADER_READ_BYTES, 0);
raw = buf.toString('utf-8', 0, bytesRead);
} finally {
closeSync(fd);
}
} catch {
return null;
}
for (const line of raw.split('\n')) {
if (!line) continue;
let entry: unknown;
try {
entry = JSON.parse(line);
} catch {
continue;
}
if (!entry || typeof entry !== 'object') continue;
const e = entry as Record<string, unknown>;
if (e.type === 'session' && typeof e.id === 'string' && typeof e.cwd === 'string') {
return { id: e.id, cwd: e.cwd };
}
}
return null;
}
/**
* Process-wide registry of OMP session ids already pinned to a live Codeman
* session. Two omp tabs in the same case dir (`w1-foo`, `w2-foo`) resolve
* against the SAME directory on disk — without this, both could pick the
* newest file and alias onto each other's conversation (found in upstream PR
* review, Ark0N/Codeman#353). Never released: this holds at most a handful of
* short ids per real omp conversation ever pinned in this process's lifetime,
* immaterial memory even after weeks of uptime — correctness here matters
* more than reclaiming it.
*/
const claimedOmpSessionIds = new Set<string>();
/**
* Safe variant of {@link findLatestOmpSessionId} for callers where two omp
* sessions CAN share the same case directory — a dead-pane respawn, a
* boot-recovery reattach, or a first-idle capture — instead of the narrower
* cases where "newest file" is unambiguous by construction. Verifies each
* candidate's own header `cwd` against `workingDir` (mangling is a lossy
* one-way transform — see {@link mangleOmpWorkingDir} — so trusting the
* filename-derived id alone isn't enough) and skips any id a sibling session
* has already claimed. Claims the id it returns so a concurrent caller
* resolving the same directory in the same tick can't double-claim it.
*/
export function resolveAndClaimOmpSessionId(workingDir: string): string | null {
const dir = join(resolveOmpHome(), 'agent', 'sessions', mangleOmpWorkingDir(workingDir));
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return null;
}
let newestMtime = -Infinity;
let newestId: string | null = null;
for (const entry of entries) {
if (!OMP_SESSION_FILE_PATTERN.test(entry)) continue;
const filePath = join(dir, entry);
let mtimeMs: number;
try {
mtimeMs = statSync(filePath).mtimeMs;
} catch {
continue;
}
if (mtimeMs <= newestMtime) continue;
const header = readOmpSessionHeader(filePath);
if (!header || header.cwd !== workingDir || claimedOmpSessionIds.has(header.id)) continue;
newestMtime = mtimeMs;
newestId = header.id;
}
if (newestId) claimedOmpSessionIds.add(newestId);
return newestId;
}