From 4f5678fac40dd59ecba75e096b4ba0ea7d0251b5 Mon Sep 17 00:00:00 2001 From: timkjr Date: Tue, 18 Aug 2026 23:17:58 -0500 Subject: [PATCH 01/19] feat(omp): rebase OMP backend onto master (merge Pi + OMP modes) --- .changeset/omp-backend.md | 15 ++++ .gitignore | 3 + CLAUDE.md | 7 +- README.md | 20 ++--- src/config/dependency-registry.ts | 9 ++ src/docker-hosts.ts | 1 + src/mux-interface.ts | 3 + src/remote-hosts.ts | 2 + src/session.ts | 17 +++- src/tmux-manager.ts | 47 ++++++++++- src/types/session.ts | 19 ++++- src/utils/index.ts | 1 + src/utils/omp-cli-resolver.ts | 134 ++++++++++++++++++++++++++++++ src/web/public/app.js | 14 ++-- src/web/public/home-sessions.js | 1 + src/web/public/index.html | 9 ++ src/web/public/mobile-overview.js | 1 + src/web/public/mobile.css | 18 +++- src/web/public/panels-ui.js | 2 +- src/web/public/session-ui.js | 41 +++++---- src/web/public/settings-ui.js | 1 + src/web/public/styles.css | 36 +++++++- src/web/routes/session-routes.ts | 54 +++++++++--- src/web/routes/system-routes.ts | 11 +++ src/web/schemas.ts | 33 +++++++- src/web/server.ts | 3 + test/mobile-overview.test.ts | 3 +- test/omp-mode.test.ts | 114 +++++++++++++++++++++++++ test/run-mode-ui.test.ts | 24 +++++- 29 files changed, 579 insertions(+), 64 deletions(-) create mode 100644 .changeset/omp-backend.md create mode 100644 src/utils/omp-cli-resolver.ts create mode 100644 test/omp-mode.test.ts diff --git a/.changeset/omp-backend.md b/.changeset/omp-backend.md new file mode 100644 index 00000000..6ea14b65 --- /dev/null +++ b/.changeset/omp-backend.md @@ -0,0 +1,15 @@ +--- +"aicodeman": minor +--- + +feat: add OMP as a first-class CLI backend (SessionMode 'omp') + +Codeman can now spawn the OMP CLI (`omp`) in local, Docker, and remote-SSH +sessions, alongside Claude Code, OpenCode, Codex, Gemini, and Antigravity. + +- New `SessionMode = ... | 'omp'` with an `OmpConfig` (model, resumeSessionId) +- `src/utils/omp-cli-resolver.ts` PATH probe + `/api/omp/status` + `codeman doctor` entry +- Run-mode UI: toolbar dropdown, welcome button, mobile overview, command + palette, clone-repo brain, cron agent types, tab badges, and per-mode colors +- Env override allowlist gains the `OMP_*` prefix +- Docker/remote default commands, resume flag, and CLI-version probing diff --git a/.gitignore b/.gitignore index 954c0144..41099ea7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ .agents/ skills-lock.json + +# In-session decision scratchpad (context-survival mechanism, not a deliverable) +DECISIONS.md # Written by install.sh into end-user clones when setup finishes .install-complete diff --git a/CLAUDE.md b/CLAUDE.md index 97f7dd29..62ae926e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,7 +81,7 @@ CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the b Codeman is a Claude Code session manager with web interface and autonomous Ralph Loop. Spawns Claude CLI via PTY, streams via SSE, supports respawn cycling for 24+ hour autonomous runs. -**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, node-pty, xterm.js. Supports Claude Code, OpenCode, Codex (OpenAI), Gemini (Google, enterprise-only since Google's June 2026 consumer cutover), Antigravity (`agy`, Google), Pi (pi.dev), Grok Build (`grok`, xAI) and DeepSeek Harness (`dsh`) CLIs via pluggable CLI resolvers (`SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek'`). +**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, node-pty, xterm.js. Supports Claude Code, OpenCode, Codex (OpenAI), Gemini (Google, enterprise-only since Google's June 2026 consumer cutover), Antigravity (`agy`, Google), Pi (pi.dev), Grok Build (`grok`, xAI), DeepSeek Harness (`dsh`) and OMP (`omp`) CLIs via pluggable CLI resolvers (`SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp'`). **TypeScript Strictness** (see `tsconfig.json`): `noUnusedLocals`, `noUnusedParameters`, `noImplicitReturns`, `noImplicitOverride`, `noFallthroughCasesInSwitch`, `allowUnreachableCode: false`, `allowUnusedLabels: false`. @@ -205,7 +205,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Docker cases**: a case can point at a **container**, with any of the CLI run modes running inside it. Like remote-SSH this is a **LOCATION OVERLAY on cases, never a `SessionMode` of its own**. Exactly one long-lived container **per case**, shared by all its sessions, so killing a session kills only that session's in-container tmux and **never** `docker stop` while siblings remain. The workspace is a real host dir bind-mounted at the **same absolute path**, which is what keeps file-routes/watchers on real host bytes and makes the in-container transcript projHash match the host. Credentials are **seeded** (RO mount, copied into the container once) rather than shared RW, so in-container CLIs never write refreshed tokens back to the host, and bind mounts are excluded from `docker commit` so exports stay secret-free. **NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket.** Config drift is detected via a label hash and a drifted launch is REFUSED rather than silently launched with stale config. ⚠️ On the loopback-only prod bind a container cannot reach 127.0.0.1, so in-container hooks need `CODEMAN_DOCKER_BRIDGE_HOOKS=1`; otherwise idle detection falls back to output-based. → [architecture-invariants#docker-cases](docs/architecture-invariants.md#docker-cases), `docs/docker-cases.md` (user guide), `docs/docker-cases-plan.md` (design) -**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token/CLI-info parsing, ❯-prompt readiness); these CLIs render their own TUIs, so readiness is output stabilization instead. All seven **require tmux with no direct PTY fallback**, because secrets are injected via socket-scoped `tmux setenv` and never on the spawn command line. ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope; reading the raw shape silently breaks the run. ⚠️ **Codex sessions use PREDICTIVE WRITE-THROUGH echo, never the buffer overlay** (`_localEchoPolicy` in `_updateLocalEchoState`, terminal-ui.js): codex's composer reacts per keystroke ("/" pops a live-filtering picker, arrows edit server-side state, the composer grows as it wraps), so buffer-until-Enter starved it into issues #218/#219/#220/#222 and stays disabled (`_localEchoEnabled` remains false for codex). Instead, `PredictiveEchoAddon` (separate `vendor/xterm-predictive-echo.js` bundle) paints each keystroke at the predicted cell while the wire path stays BYTE-IDENTICAL: the onData hook (`_predictHookOnData`) is a plain statement with no `return`, so control always falls through into the untouched send path — pinned by vm and E2E byte-identity tests. Predictions reconcile against the parsed buffer and only while the cursor sits on the measured composer row (`isCodexComposerRow`, `/^› /`). Codex also **drops keystrokes that share a PTY read with a bracketed paste**, so flushed text and the paste sequence must go out as separate delayed writes (mirroring the Enter branch's delayed `\r`). Tests: `test/local-echo-codex-gating.test.ts`, `test/codex-predictive-echo.test.ts` (E2E vs real codex), `packages/xterm-zerolag-input/test/codex-replay.test.ts`. ⚠️ **Pi is the opposite kind of CLI and needs the opposite instincts**: it has NO permission prompts and no sandbox, so there is no bypass flag to send and Codeman must not invent one; its privileged knob is the tri-state `approveProjectTrust` (`--approve`/`--no-approve`), which makes pi EXECUTE repo-local `.pi/extensions` TypeScript, so the multi-user clamp puts pi in the **materialize** branch (an absent config still yields `--no-approve` for a non-granted owner) and `--api-key` is never wired. Pi stays OUT of `isAltScreenStripMode()` (main-screen TUI, and its 0.84.0 fullscreen mode is runtime-switchable via `/settings`, where the alt screen is load-bearing), and lands on the `'buffer'` echo policy via the `_updateLocalEchoState` fallthrough. Pi's own tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts`; user guide `docs/pi-integration.md`. ⚠️ **Grok is codex-shaped on permissions but opencode-shaped on rendering**: its bypass switch is `alwaysApprove` (`--always-approve`, grok's `bypassPermissions` mode — the Run button sends it `true` like antigravity's, and the clamp's only-if-sent branch strips it for non-granted owners), while its fullscreen alt-screen TUI keeps it OUT of `isAltScreenStripMode()`; the resolver version-probes `grok --version` like pi's (npm squatters exist for the name — `GET /api/grok/status` surfaces path + version), and grok lands on the `'buffer'` echo policy via the fallthrough (UNMEASURED against a live authenticated session; if its composer turns out per-keystroke-reactive like codex, flip it to the `'off'` branch). Grok's own tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`; user guide `docs/grok-integration.md`. ⚠️ **DeepSeek breaks three of this family's assumptions, so do not pattern-match it onto its siblings.** (1) The agent is a **PROFILE, not the binary**: `dsh` is a launcher over `$DSH_HOME/profiles/` and DeepSeek ships only `web`/`headless`/`base`, so the terminal front door is ALWAYS third-party and "installed" ≠ "runnable" — the Run button gates on `isDeepSeekRunnable()` (binary AND a pane-capable profile) while `isDeepSeekAvailable()` gates the "add a profile" affordance; a `web`/`headless` profile is refused at spawn because it cannot drive a pane. (2) The permission switch is the **`DSH_PERMISSION_MODE` env export, not a flag** (`read-only`/`workspace-write`/`danger-full-access`) — the harness has none, and this is the one legitimate exception to the effort-style env-var ban because it is read with `??` as a boot-time default, so it stays soft; absent = `workspace-write`, which asks, hence the only-if-sent clamp branch, clamping to `workspace-write` (never `read-only`, which would break the workspace). ⚠️ **That clamp needs a second half no other CLI needs**, because the switch is an env var and `DSH_*` is an allowlisted `envOverrides` prefix: `applyEnvOverrides()` runs AFTER `_configureDeepSeek()` in tmux-manager, so a non-granted owner sending `DSH_PERMISSION_MODE` on the SAME request would land last and hand back exactly the privilege the config clamp removed. `clampEnvOverridesForOwner()` (session-routes.ts) DROPS `DSH_PERMISSION_MODE`, `DSH_HOME` and `DEEPSEEK_BASE_URL` for a non-granted owner (the last because `_configureDeepSeek()` forwards the SERVER's own `DEEPSEEK_API_KEY` into the pane, so a redirected base URL would send it to a foreign host) (dropping falls through to what `_configureDeepSeek()` exports, which is the clamped value); `DSH_HOME` is there because it points the launcher at a profile tree whose plugin code runs at BOOT, before any approval row applies. Every OTHER CLI's bypass is a command-line flag reachable only through its config, which is why the config clamp alone is the whole gate for them. (3) It is the **only non-claude mode that passes `hooksAvailableForMode()`**, and for it alone that predicate is a per-SESSION question rather than a per-mode one (`deepSeekConfig.statusReporting: false` disarms the bridge, so every call site passes `sessionHookOptions(session)`; answering from the mode there re-creates the infinite-wait-dressed-as-a-timeout the guard exists to prevent). It passes because the terminal front door reports idle/working/blocked to a supervisor over a generic env-gated contract and `deepseek-status-shim.ts` makes Codeman that supervisor — real `stop`/`blocked` signals, real Approvals Inbox items, plus the `agent_working` event that clears an alert answered in the terminal. ⚠️ The resolver needs the strictest identity probe of the family (`dsh --help` must say `DeepSeek Harness`) because Debian ships an unrelated `dsh` (dancer's shell) that would pass a version probe. Model is NOT a session field (it is a profile composition entry). ⚠️ `hooksAvailableForMode()` is about hook SIGNALS and is not a stand-in for "is this a claude session": Read My Mind and intent capture read Claude's own transcript and compare `mode === 'claude'` directly, because when `deepseek` earned a yes the shared predicate silently widened both to a mode with no transcript to read (pinned by a static check in `test/deepseek-mode.test.ts`). ⚠️ **It is also the only external CLI whose answers are READ FROM DISK rather than scraped off the pane**: `deepseek-transcript.ts` reads `$DSH_HOME/sessions///session.jsonl.zstd` and backs the `last-response` route for dsh, because the pane segmenter served dsh-TUI's ASCII-art SPLASH as the worker's answer (measured), which anything polling for a first answer reads as an answer. Three traps live in that file: dsh appends **one zstd FRAME per write** and Node's `zlib` zstd decoder stops at the first (a real 56-line transcript decoded as 1 line, so the module walks frame headers itself; a Node older than 22.15 has no zstd and falls back to the pane); every turn also records a **plugin-sourced `user/message`** (the runtime-context snapshot) that must not render as the user's words; and a failed `turn/end` is surfaced as `Turn error: …` rather than as an empty string that reads as "still thinking". ⚠️ Session→transcript pairing is by the header's own `cwd` plus a ±60 s boot window, never by reproducing dsh's directory mangling (which has already changed form once) — and NEVER by newest-mtime alone, which handed a fresh worker its predecessor's answer in the same case dir. DeepSeek's own tests: `test/deepseek-mode.test.ts`, `test/deepseek-cli-resolver.test.ts`, `test/deepseek-transcript.test.ts`; user guide `docs/deepseek-integration.md`. → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek) +**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token/CLI-info parsing, ❯-prompt readiness); these CLIs render their own TUIs, so readiness is output stabilization instead. All eight **require tmux with no direct PTY fallback**, because secrets are injected via socket-scoped `tmux setenv` and never on the spawn command line. ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope; reading the raw shape silently breaks the run. ⚠️ **Codex sessions use PREDICTIVE WRITE-THROUGH echo, never the buffer overlay** (`_localEchoPolicy` in `_updateLocalEchoState`, terminal-ui.js): codex's composer reacts per keystroke ("/" pops a live-filtering picker, arrows edit server-side state, the composer grows as it wraps), so buffer-until-Enter starved it into issues #218/#219/#220/#222 and stays disabled (`_localEchoEnabled` remains false for codex). Instead, `PredictiveEchoAddon` (separate `vendor/xterm-predictive-echo.js` bundle) paints each keystroke at the predicted cell while the wire path stays BYTE-IDENTICAL: the onData hook (`_predictHookOnData`) is a plain statement with no `return`, so control always falls through into the untouched send path — pinned by vm and E2E byte-identity tests. Predictions reconcile against the parsed buffer and only while the cursor sits on the measured composer row (`isCodexComposerRow`, `/^› /`). Codex also **drops keystrokes that share a PTY read with a bracketed paste**, so flushed text and the paste sequence must go out as separate delayed writes (mirroring the Enter branch's delayed `\r`). Tests: `test/local-echo-codex-gating.test.ts`, `test/codex-predictive-echo.test.ts` (E2E vs real codex), `packages/xterm-zerolag-input/test/codex-replay.test.ts`. ⚠️ **Pi is the opposite kind of CLI and needs the opposite instincts**: it has NO permission prompts and no sandbox, so there is no bypass flag to send and Codeman must not invent one; its privileged knob is the tri-state `approveProjectTrust` (`--approve`/`--no-approve`), which makes pi EXECUTE repo-local `.pi/extensions` TypeScript, so the multi-user clamp puts pi in the **materialize** branch (an absent config still yields `--no-approve` for a non-granted owner) and `--api-key` is never wired. Pi stays OUT of `isAltScreenStripMode()` (main-screen TUI, and its 0.84.0 fullscreen mode is runtime-switchable via `/settings`, where the alt screen is load-bearing), and lands on the `'buffer'` echo policy via the `_updateLocalEchoState` fallthrough. Pi's own tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts`; user guide `docs/pi-integration.md`. ⚠️ **Grok is codex-shaped on permissions but opencode-shaped on rendering**: its bypass switch is `alwaysApprove` (`--always-approve`, grok's `bypassPermissions` mode — the Run button sends it `true` like antigravity's, and the clamp's only-if-sent branch strips it for non-granted owners), while its fullscreen alt-screen TUI keeps it OUT of `isAltScreenStripMode()`; the resolver version-probes `grok --version` like pi's (npm squatters exist for the name — `GET /api/grok/status` surfaces path + version), and grok lands on the `'buffer'` echo policy via the fallthrough (UNMEASURED against a live authenticated session; if its composer turns out per-keystroke-reactive like codex, flip it to the `'off'` branch). Grok's own tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`; user guide `docs/grok-integration.md`. ⚠️ **DeepSeek breaks three of this family's assumptions, so do not pattern-match it onto its siblings.** (1) The agent is a **PROFILE, not the binary**: `dsh` is a launcher over `$DSH_HOME/profiles/` and DeepSeek ships only `web`/`headless`/`base`, so the terminal front door is ALWAYS third-party and "installed" ≠ "runnable" — the Run button gates on `isDeepSeekRunnable()` (binary AND a pane-capable profile) while `isDeepSeekAvailable()` gates the "add a profile" affordance; a `web`/`headless` profile is refused at spawn because it cannot drive a pane. (2) The permission switch is the **`DSH_PERMISSION_MODE` env export, not a flag** (`read-only`/`workspace-write`/`danger-full-access`) — the harness has none, and this is the one legitimate exception to the effort-style env-var ban because it is read with `??` as a boot-time default, so it stays soft; absent = `workspace-write`, which asks, hence the only-if-sent clamp branch, clamping to `workspace-write` (never `read-only`, which would break the workspace). ⚠️ **That clamp needs a second half no other CLI needs**, because the switch is an env var and `DSH_*` is an allowlisted `envOverrides` prefix: `applyEnvOverrides()` runs AFTER `_configureDeepSeek()` in tmux-manager, so a non-granted owner sending `DSH_PERMISSION_MODE` on the SAME request would land last and hand back exactly the privilege the config clamp removed. `clampEnvOverridesForOwner()` (session-routes.ts) DROPS `DSH_PERMISSION_MODE`, `DSH_HOME` and `DEEPSEEK_BASE_URL` for a non-granted owner (the last because `_configureDeepSeek()` forwards the SERVER's own `DEEPSEEK_API_KEY` into the pane, so a redirected base URL would send it to a foreign host) (dropping falls through to what `_configureDeepSeek()` exports, which is the clamped value); `DSH_HOME` is there because it points the launcher at a profile tree whose plugin code runs at BOOT, before any approval row applies. Every OTHER CLI's bypass is a command-line flag reachable only through its config, which is why the config clamp alone is the whole gate for them. (3) It is the **only non-claude mode that passes `hooksAvailableForMode()`**, and for it alone that predicate is a per-SESSION question rather than a per-mode one (`deepSeekConfig.statusReporting: false` disarms the bridge, so every call site passes `sessionHookOptions(session)`; answering from the mode there re-creates the infinite-wait-dressed-as-a-timeout the guard exists to prevent). It passes because the terminal front door reports idle/working/blocked to a supervisor over a generic env-gated contract and `deepseek-status-shim.ts` makes Codeman that supervisor — real `stop`/`blocked` signals, real Approvals Inbox items, plus the `agent_working` event that clears an alert answered in the terminal. ⚠️ The resolver needs the strictest identity probe of the family (`dsh --help` must say `DeepSeek Harness`) because Debian ships an unrelated `dsh` (dancer's shell) that would pass a version probe. Model is NOT a session field (it is a profile composition entry). ⚠️ `hooksAvailableForMode()` is about hook SIGNALS and is not a stand-in for "is this a claude session": Read My Mind and intent capture read Claude's own transcript and compare `mode === 'claude'` directly, because when `deepseek` earned a yes the shared predicate silently widened both to a mode with no transcript to read (pinned by a static check in `test/deepseek-mode.test.ts`). ⚠️ **It is also the only external CLI whose answers are READ FROM DISK rather than scraped off the pane**: `deepseek-transcript.ts` reads `$DSH_HOME/sessions///session.jsonl.zstd` and backs the `last-response` route for dsh, because the pane segmenter served dsh-TUI's ASCII-art SPLASH as the worker's answer (measured), which anything polling for a first answer reads as an answer. Three traps live in that file: dsh appends **one zstd FRAME per write** and Node's `zlib` zstd decoder stops at the first (a real 56-line transcript decoded as 1 line, so the module walks frame headers itself; a Node older than 22.15 has no zstd and falls back to the pane); every turn also records a **plugin-sourced `user/message`** (the runtime-context snapshot) that must not render as the user's words; and a failed `turn/end` is surfaced as `Turn error: …` rather than as an empty string that reads as "still thinking". ⚠️ Session→transcript pairing is by the header's own `cwd` plus a ±60 s boot window, never by reproducing dsh's directory mangling (which has already changed form once) — and NEVER by newest-mtime alone, which handed a fresh worker its predecessor's answer in the same case dir. DeepSeek's own tests: `test/deepseek-mode.test.ts`, `test/deepseek-cli-resolver.test.ts`, `test/deepseek-transcript.test.ts`; user guide `docs/deepseek-integration.md`. OMP (`omp`) is architecturally the simplest of the family: it needs no bypass flag (the CLI's own `~/.omp` config governs trust/model routing), so `buildOmpCommand()` only ever passes `--model`/`--resume`, and the multi-user clamp has nothing to gate. → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp) **Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms), so a double click cannot create duplicate sessions with the same `w-` name. `_ensureCreatedSessionVisible()` runs before `selectSession()`, and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first ordering both produce exactly one rendered tab. ⚠️ **Closing has the mirror-image race and one owner**: `closeSession()` reads `wasActive` BEFORE its `await` and announces the delete via `_closingSessions`, while `_onSessionDeleted` skips the active-session handoff for an id in that set. Both used to read `activeSessionId` after the fact, so the `session_deleted` broadcast for your own delete could null it first and closing the tab you were on landed on the welcome screen instead of the next session, on the same build, depending on timing. The fallback also picks the first order entry that is still in `sessions` (a dead id can linger in `sessionOrder`, same reason Alt+N indexes a live-filtered list). A delete from ANOTHER client still shows the welcome screen, which is the honest answer when what you were looking at was taken away. Tests: `test/session-close-fallback.test.ts`. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization) @@ -231,8 +231,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Auto Copy (copy-on-select)** (`autoCopySelection`, per-device, default OFF): a finished terminal selection lands on the clipboard with no keystroke. ⚠️ It fires at the END of a gesture, never in `onSelectionChange` (that callback runs per cell crossed, so copying there is one clipboard write per mouse move); it only ARMS `_autoCopyPending`, and a document-level `mouseup` listener flushes. ⚠️ The flush is SYNCHRONOUS inside the handler because both clipboard paths need user activation (Firefox gates `navigator.clipboard.writeText` on it, and the plain-HTTP `execCommand` fallback must run in the gesture's own task); a timer or a wait for `onSelectionChange` loses it, invisibly in Chrome. ⚠️ Touch needs its OWN calls from `_endTouchSelectionGesture()`/`_selectTouchSelectionLine()`: that path `preventDefault()`s its touchend, so no mouseup ever arrives and the toggle would be dead on phones. ⚠️ Unlike `copyTerminalSelection()` it must NOT clear the selection (the text would vanish under the cursor that highlighted it) and must NOT focus the terminal (that opens the on-screen keyboard over it); focus is RESTORED to whatever held it, which only matters for the `execCommand` fallback. Guards are pure in `decideAutoCopy()` (constants.js): off, blank/whitespace-only, and a 1M-char cap (an autoscrolling drag can sweep the whole 50k-line scrollback), refused rather than truncated with a toast pointing at Ctrl+C. Silent on success except once per page load; failures toast, throttled 10s. Tests: `test/terminal-auto-copy.test.ts`. -**Terminal scrollback strip + wheel/touch forwarding** (#205): codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed shell/opencode/antigravity get a NARROW strip (alt-screen toggles only — it removes tmux's own attach-time `smcup`, which otherwise parks xterm in the scrollback-less alt buffer and turns the wheel into arrow keys). ⚠️ Gated on `useMux`: direct-PTY fallback sessions must keep the alt screen for vim/less/htop. Wheel AND touch forward to the CLI transcript for **claude ≥ 2.1.187 ONLY** at ANY scroll position (snap-to-bottom first); Shift+wheel and the `terminalWheelLocalScrollback` setting stay local. ⚠️ Codex was in that list and must never go back without a fresh measurement: codex-cli 0.147.0 ignores SGR wheel reports entirely (`mouse_any_flag=0`, inline viewport, transcript pushed into terminal scrollback), so forwarding produced a dead wheel (#227 follow-up). `_wheelScrollLines()` reads `ev.deltaMode` (Firefox = LINE units). ⚠️ When that gate is FALSE on a claude session whose local buffer is hollow (`baseY === 0`), the gesture becomes coalesced PageUp/PageDown key sends (`_maybePageCliTranscript`) instead of a no-op; ⚠️ and `getClaudeCliVersion()` must never cache a FAILED probe (one timeout used to disable forwarding process-wide until restart). ⚠️ **A click is hand-reported to the CLI only while the CLI actually has mouse tracking on.** The full strip removes the mouse DECSETs, so xterm's `mouseTrackingMode` is permanently `none` there and the browser hand-encodes SGR reports (`_sendSyntheticSgrTap`); without state it did that on EVERY click, so a stripped-mode pane running a plain shell (CLI exited, or a shell started inside a claude-mode session) received reports it never asked for and printed them as literal text (`[<0;88;20M`), garbling the next typed line. `_recordStrippedMouseMode()` (session.ts) records what the strip removes, `toState()` publishes `cliMouseTracking`, and `_shouldReportMouseToCli()` gates all three report sites on it. Only 1000/1001/1002/1003 count (1005/1006 are encodings, 1007 is alt-scroll), and the change broadcasts UNdebounced since a dialog can be clicked inside the 500ms window. `_logScrollRouting()` prints the routing decision and its inputs once per session — read it before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding) - +**Terminal scrollback strip + wheel/touch forwarding** (#205): codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed shell/opencode/antigravity/omp get a NARROW strip (alt-screen toggles only — it removes tmux's own attach-time `smcup`, which otherwise parks xterm in the scrollback-less alt buffer and turns the wheel into arrow keys). ⚠️ Gated on `useMux`: direct-PTY fallback sessions must keep the alt screen for vim/less/htop. Wheel AND touch forward to the CLI transcript for **claude ≥ 2.1.187 ONLY** at ANY scroll position (snap-to-bottom first); Shift+wheel and the `terminalWheelLocalScrollback` setting stay local. ⚠️ Codex was in that list and must never go back without a fresh measurement: codex-cli 0.147.0 ignores SGR wheel reports entirely (`mouse_any_flag=0`, inline viewport, transcript pushed into terminal scrollback), so forwarding produced a dead wheel (#227 follow-up). `_wheelScrollLines()` reads `ev.deltaMode` (Firefox = LINE units). ⚠️ When that gate is FALSE on a claude session whose local buffer is hollow (`baseY === 0`), the gesture becomes coalesced PageUp/PageDown key sends (`_maybePageCliTranscript`) instead of a no-op; ⚠️ and `getClaudeCliVersion()` must never cache a FAILED probe (one timeout used to disable forwarding process-wide until restart). ⚠️ **A click is hand-reported to the CLI only while the CLI actually has mouse tracking on.** The full strip removes the mouse DECSETs, so xterm's `mouseTrackingMode` is permanently `none` there and the browser hand-encodes SGR reports (`_sendSyntheticSgrTap`); without state it did that on EVERY click, so a stripped-mode pane running a plain shell (CLI exited, or a shell started inside a claude-mode session) received reports it never asked for and printed them as literal text (`[<0;88;20M`), garbling the next typed line. `_recordStrippedMouseMode()` (session.ts) records what the strip removes, `toState()` publishes `cliMouseTracking`, and `_shouldReportMouseToCli()` gates all three report sites on it. Only 1000/1001/1002/1003 count (1005/1006 are encodings, 1007 is alt-scroll), and the change broadcasts UNdebounced since a dialog can be clicked inside the 500ms window. `_logScrollRouting()` prints the routing decision and its inputs once per session — read it before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding) **Detached start + service install** (issue #231): `codeman web -d` relaunches the SAME entry script with `detached:true` (setsid), so there is no controlling terminal and no shell job entry. ⚠️ `nohup` is NOT what makes this work: Node re-arms SIGHUP to its default disposition even when it inherits "ignore", and `cli.ts` handles SIGHUP with a graceful shutdown, so a delivered HUP still stops the server. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile check + `/api/status` probe): a second instance on the shared tmux socket attaches PTYs to the first one's live sessions. ⚠️ Neither may report success it has not observed — the parent polls `/api/status` until the child answers or dies, since `launchctl load` and a clean spawn are both silent about a server that starts and immediately exits. `--stop` verifies the pid still LOOKS like a Codeman server (`ps -o command=`) before signalling, because pids get recycled. Unit/label names live in `config/service-names.ts` so install.sh, `detectSupervisor()` and `service install` cannot drift into supervising two copies; they are instance-scoped, and identical to the historical names for the default instance. `service install` bakes the installing shell's PATH into the unit (launchd gives a job `/usr/bin:/bin:/usr/sbin:/sbin`, which finds neither a Homebrew/nvm `node` nor `tmux`/`claude`) and never writes `CODEMAN_PASSWORD` into it. → [architecture-invariants#detached-start-and-service-install](docs/architecture-invariants.md#detached-start-and-service-install) **Self-update** (App Settings → System → Updates): in-app updater for git-clone installs supervised by systemd/launchd (`systemd`, `launchd`, `launchd-daemon`, else `none` → "restart manually"). The update restarts the very process running it, so the real work runs in a DETACHED `scripts/self-update.sh` that outlives the restart and writes progress to `update-status.json`, which the browser polls across the connection drop. `src/web/self-update.ts` splits pure helpers (unit-tested) from IO wrappers. npm installs report as non-updatable. → [architecture-invariants#self-update](docs/architecture-invariants.md#self-update) diff --git a/README.md b/README.md index 30d5312d..3b36b253 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

Mission control for AI coding agents

- Claude Code • OpenCode • Codex • Antigravity • Gemini • Pi • Grok • Terminal - One Dashboard • Any Device + Claude Code • OpenCode • Codex • Antigravity • Gemini • Pi • Grok • OMP • Terminal - One Dashboard • Any Device

@@ -27,7 +27,7 @@ Codeman — parallel subagent visualization

-**Codeman** is a self-hosted mission control for AI coding agents. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, or Grok inside persistent tmux sessions, streams the real terminal to any browser, and keeps agents productive after you walk away: it re-prompts on idle, resumes when a usage limit resets, runs scheduled jobs, and shows every background agent working in real time. +**Codeman** is a self-hosted mission control for AI coding agents. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, or OMP inside persistent tmux sessions, streams the real terminal to any browser, and keeps agents productive after you walk away: it re-prompts on idle, resumes when a usage limit resets, runs scheduled jobs, and shows every background agent working in real time. Get started in one line (macOS & Linux, Windows via WSL): @@ -42,7 +42,7 @@ codeman web The installer asks before every system change, and re-running the same line updates in place. Full details: [Quick Start - Installation](#quick-start---installation). -- **One dashboard, seven CLIs** - run [Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, or Grok](#more-features) per session (plus plain shell), locally, [in Docker](#isolated-docker-sessions), or [over SSH](#remote-ssh-sessions) +- **One dashboard, eight CLIs** - run [Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, or OMP](#more-features) per session (plus plain shell), locally, [in Docker](#isolated-docker-sessions), or [over SSH](#remote-ssh-sessions) - **Truly phone-friendly** - a [touch-optimized terminal](#mobile-optimized-web-ui) with instant local echo, QR login, swipe navigation, and push notifications - **Runs while you sleep** - [idle detection + respawn cycling](#respawn-controller) and auto-resume when a subscription limit resets, for 24+ hour unattended runs - **See your agents think** - [live floating windows](#live-agent-visualization) for every subagent and teammate, with real-time transcripts @@ -68,7 +68,7 @@ This installs Node.js, tmux and a build toolchain if missing (node-pty ships no - **Re-run to update.** The same one-liner updates a finished install in place: local changes in `~/.codeman/app` are stashed (never discarded), and a running service is restarted and verified. If a first install was interrupted, re-running resumes the full setup instead. `install.sh update` and `install.sh uninstall` also exist. - **CI / headless:** without a terminal attached, steps that would change your system abort with instructions instead of running silently. Set `CODEMAN_NONINTERACTIVE=1` to approve them for automation. -You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), or [Grok Build](https://github.com/xai-org/grok-build) (any combination works; Gemini CLI is enterprise-only since Google's consumer cutover, and Antigravity is its successor). The installer detects whichever of the seven is present; if none is found, it offers to install Claude Code or OpenCode, or you can skip and install one yourself later. After install: +You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), or [OMP](https://github.com/can1357/omp) (any combination works; Gemini CLI is enterprise-only since Google's consumer cutover, and Antigravity is its successor). The installer detects whichever of the eight is present; if none is found, it offers to install Claude Code or OpenCode, or you can skip and install one yourself later. After install: ```bash codeman web @@ -171,7 +171,7 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist wsl bash -c "curl -fsSL https://getcodeman.com/install | bash" ``` -Codeman requires tmux, so Windows users need [WSL](https://learn.microsoft.com/en-us/windows/wsl/install). If you don't have WSL yet: run `wsl --install` in an admin PowerShell, reboot, open Ubuntu, then install your preferred AI coding CLI inside WSL ([Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), or [Grok Build](https://github.com/xai-org/grok-build)). After installing, `http://localhost:3000` is accessible from your Windows browser. +Codeman requires tmux, so Windows users need [WSL](https://learn.microsoft.com/en-us/windows/wsl/install). If you don't have WSL yet: run `wsl --install` in an admin PowerShell, reboot, open Ubuntu, then install your preferred AI coding CLI inside WSL ([Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), or [OMP](https://github.com/can1357/omp)). After installing, `http://localhost:3000` is accessible from your Windows browser. @@ -253,7 +253,7 @@ Click **+ New Session** (or **Quick Start**). A session is one AI CLI running in | Field | What it does | | ---------------------------- | ------------------------------------------------------------------------------------------------------------------- | | **Working directory / case** | The folder the agent operates in. A "case" is just a named working dir Codeman remembers. **Add Case** creates one from scratch, links an existing folder, or clones a GitHub repo straight into one (**Clone Repo**). | -| **CLI / run mode** | `Claude` (default), `OpenCode`, `Codex`, `Antigravity`, `Gemini`, `Pi`, `Grok`, or `Terminal` (plain shell). | +| **CLI / run mode** | `Claude` (default), `OpenCode`, `Codex`, `Antigravity`, `Gemini`, `Pi`, `Grok`, `OMP`, or `Terminal` (plain shell). | | **Model** | Per-session model (App Settings → Models → New Claude sessions). A soft default — `/model` still works in-session. | | **Effort / Ultracode** | Reasoning effort (`low`–`max`) or `ultracode` for dynamic multi-agent workflows. Switchable anytime with `/effort`. | @@ -437,7 +437,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt - **Background daemon & service install** — `codeman web -d` runs the server detached with a pidfile, `~/.codeman/web.log`, and verified startup (it polls the server until it answers, so a port clash never reads as success); `codeman service install` writes a systemd user unit (Linux) or LaunchAgent (macOS) with your shell's PATH baked in, so an nvm or Homebrew `node`, `tmux` and `claude` are actually found. Secrets are never written into unit files - **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → System → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable) - **Clone a GitHub repo as a case** — paste a repository URL into **Add Case → Clone Repo** and Codeman clones it into `~/codeman-cases/` and registers it as a normal case, ready to run an agent in. It preflights the URL while you type (tells you whether it can be cloned anonymously and offers the repo's real branches and tags for the optional branch/tag field), fills the case name in from the URL, and lets you pick which CLI the Run button should use. Public repositories over `https://`; Codeman never collects or stores credentials -- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, or **Grok** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md) and [`docs/grok-integration.md`](docs/grok-integration.md) +- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, or **OMP** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*` vs `OMP_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md) and [`docs/grok-integration.md`](docs/grok-integration.md) - **Docker sessions** — run a case inside an isolated, hardened container. One checkbox on **Create New** spins up a container with sensible defaults and starts the agent inside it; multiple sessions share one per-case container; export a container + its workspace to a portable `.tar.gz` to move it to another machine. See [`docs/docker-cases.md`](docs/docker-cases.md) - **Remote SSH sessions** — point a case at another machine and run the agent there inside a durable remote tmux: survives SSH drops, auto-reconnects, and can discover + attach sessions already running on the host. See [`docs/remote-sessions.md`](docs/remote-sessions.md) - **Effort & Ultracode** — set a per-session default effort (`low`–`max`) or enable **ultracode** (dynamic multi-agent workflows). Soft defaults only — switchable anytime with `/effort` in-session. Extended-thinking budget is configurable too @@ -460,7 +460,7 @@ Run a case inside its own hardened Docker container instead of directly on your - **Shared per-case container** — many sessions can `docker exec` into the same container; killing one session never tears the container out from under the others. - **Hardened by default** — non-root, `--cap-drop ALL`, `no-new-privileges`, PID/memory caps, never `--privileged` or the docker socket; a **sealed** profile (no host credentials, network off) is one toggle away. - **Seamless auth, isolated credentials** — your host Claude / Codex / Antigravity / Gemini / OpenCode / Pi logins work inside the container out of the box: credentials are seeded (copied) in at launch and onboarding/trust prompts are pre-answered, so no login wizard appears. The container keeps its own copies and never writes back to your host credential stores; only conversation transcripts are shared, and exports never capture secrets. -- **Move it to another machine** — export a container's whole environment (toolchain + workspace) to a portable `.tar.gz`, `docker load` it on the other side, and import it into a fresh case. +- **Seamless auth, isolated credentials** — your host Claude / Codex / Antigravity / Gemini / OpenCode / OMP logins work inside the container out of the box: credentials are seeded (copied) in at launch and onboarding/trust prompts are pre-answered, so no login wizard appears. The container keeps its own copies and never writes back to your host credential stores; only conversation transcripts are shared, and exports never capture secrets.- **Move it to another machine** — export a container's whole environment (toolchain + workspace) to a portable `.tar.gz`, `docker load` it on the other side, and import it into a fresh case. - **Durable** — reconnect after a restart lands back in the same live agent; a container stop/reboot resumes the conversation from the bind-mounted transcript. Prerequisite: just Docker (or Podman). The agent base image builds itself automatically on first use, with progress streamed to the UI (or pre-build it with `node scripts/build-agent-image.mjs`). Full guide: [`docs/docker-cases.md`](docs/docker-cases.md). @@ -795,7 +795,7 @@ When a CLI runs in a Codeman-managed session, these environment variables are se 5. **`/api/v1/*`** is a stable alias of `/api/*`. 6. **Wait instead of polling, and don't treat a timeout as an error.** The wait endpoints answer with HTTP `200` and `wait.timedOut: true` when nothing happened in time, so loop over short waits (60s is the default) rather than issuing one long call, because tunnels cut idle connections. `wait.timeoutMs` tells you the timeout the server actually applied after clamping (600s ceiling). 7. **Only `claude` sessions emit `stop` and `blocked`.** Those two come from Claude Code hooks; `shell` and the external CLIs (opencode/codex/gemini/antigravity/pi) accept only `idle`, `working` and `exit`. Asking for `stop` explicitly on those is a `400`; omitting `until` is always safe. ⚠️ On a `shell` session `idle` fires **once**, at startup, and never again, so send-and-wait there can only time out; synchronize hook-less sessions with a `wait-output` marker. -8. **Nothing reports "ready", so wait for it explicitly.** A new session answers `{"signal":"exit","immediate":true}` (that means *not started*, not *crashed*) until its PID exists, and a `claude` worker in a fresh case then sits on the CLI's trust dialog. Prompt it there and the wait resolves on `idle` in ~2s looking exactly like a finished turn, while the text sits stuck in the dialog. Recipe 2b below is the sequence that avoids it. +7. **Only `claude` sessions emit `stop` and `blocked`.** Those two come from Claude Code hooks; `shell` and the external CLIs (opencode/codex/gemini/antigravity/omp) accept only `idle`, `working` and `exit`. Asking for `stop` explicitly on those is a `400`; omitting `until` is always safe. ⚠️ On a `shell` session `idle` fires **once**, at startup, and never again, so send-and-wait there can only time out; synchronize hook-less sessions with a `wait-output` marker.8. **Nothing reports "ready", so wait for it explicitly.** A new session answers `{"signal":"exit","immediate":true}` (that means *not started*, not *crashed*) until its PID exists, and a `claude` worker in a fresh case then sits on the CLI's trust dialog. Prompt it there and the wait resolves on `idle` in ~2s looking exactly like a finished turn, while the text sits stuck in the dialog. Recipe 2b below is the sequence that avoids it. ### Recipes @@ -1011,7 +1011,7 @@ flowchart TB subgraph External["External"] CLI["AI CLI
Claude Code / OpenCode / Codex / Antigravity / Gemini / Pi"] - BG["Background Agents
(Task tool)"] + CLI["AI CLI
Claude Code / OpenCode / Codex / Antigravity / Gemini / OMP"] BG["Background Agents
(Task tool)"] end end diff --git a/src/config/dependency-registry.ts b/src/config/dependency-registry.ts index 65ff9ca8..84a8c5ab 100644 --- a/src/config/dependency-registry.ts +++ b/src/config/dependency-registry.ts @@ -190,6 +190,15 @@ export const DEPENDENCY_REGISTRY: ToolDependency[] = [ }, ], }, + { + id: 'omp', + label: 'OMP CLI', + category: 'core', + required: false, + usedBy: ['OMP sessions'], + resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['omp'], versionArg: '--version' } }], + installHint: { linux: 'curl -fsSL https://omp.sh/install | sh', darwin: 'brew install can1357/tap/omp' }, + }, { id: 'libreoffice', label: 'LibreOffice', diff --git a/src/docker-hosts.ts b/src/docker-hosts.ts index e2a92486..772bc9aa 100644 --- a/src/docker-hosts.ts +++ b/src/docker-hosts.ts @@ -147,6 +147,7 @@ export function defaultDockerCommandForMode(mode: SessionMode): string { pi: 'exec pi', grok: 'exec grok', deepseek: 'exec dsh', + omp: 'exec omp', }; return commands[mode as DockerCommandMode] || commands.shell; } diff --git a/src/mux-interface.ts b/src/mux-interface.ts index 6cb9693f..f4e3dd24 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -21,6 +21,7 @@ import type { PiConfig, GrokConfig, DeepSeekConfig, + OmpConfig, SessionRemote, SessionDocker, } from './types.js'; @@ -82,6 +83,7 @@ export interface CreateSessionOptions { piConfig?: PiConfig; grokConfig?: GrokConfig; deepSeekConfig?: DeepSeekConfig; + ompConfig?: OmpConfig; /** When restoring after reboot, resume a previous Claude conversation by its session ID */ resumeSessionId?: string; /** Extra env vars exported before launching the CLI (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Ephemeral — not written to disk. */ @@ -116,6 +118,7 @@ export interface RespawnPaneOptions { piConfig?: PiConfig; grokConfig?: GrokConfig; deepSeekConfig?: DeepSeekConfig; + ompConfig?: OmpConfig; /** Resume a previous Claude conversation when respawning */ resumeSessionId?: string; /** Extra env vars exported before launching the CLI (preserved across respawns). */ diff --git a/src/remote-hosts.ts b/src/remote-hosts.ts index a82bdb37..5fce5128 100644 --- a/src/remote-hosts.ts +++ b/src/remote-hosts.ts @@ -119,6 +119,7 @@ export function defaultRemoteCommandForMode(mode: SessionMode): string { // profile inventory is unknown here. The per-host `commands.deepseek` override // is the escape hatch for naming one. deepseek: remoteLoginShellCommand('dsh'), + omp: remoteLoginShellCommand('omp'), }; return commands[mode as RemoteCommandMode] || commands.shell; } @@ -274,6 +275,7 @@ const REMOTE_CLI_BIN: Partial> = { gemini: 'gemini', antigravity: 'agy', pi: 'pi', + omp: 'omp', }; /** diff --git a/src/session.ts b/src/session.ts index 34ac7b30..604458e6 100644 --- a/src/session.ts +++ b/src/session.ts @@ -53,6 +53,7 @@ import { type PiConfig, type GrokConfig, type DeepSeekConfig, + type OmpConfig, type SessionRemote, type SessionDocker, } from './types.js'; @@ -180,7 +181,8 @@ export function isExternalCliMode(mode: SessionMode): boolean { mode === 'antigravity' || mode === 'pi' || mode === 'grok' || - mode === 'deepseek' + mode === 'deepseek' || + mode === 'omp' ); } @@ -200,6 +202,8 @@ function getModeLabel(mode: SessionMode): string { return 'Grok'; case 'deepseek': return 'DeepSeek'; + case 'omp': + return 'OMP'; case 'shell': return 'Shell'; case 'claude': @@ -528,6 +532,8 @@ export class Session extends EventEmitter { // DeepSeek Harness configuration (only for mode === 'deepseek') private _deepSeekConfig: DeepSeekConfig | undefined; + // OMP configuration (only for mode === 'omp') + private _ompConfig: OmpConfig | undefined; private _resumeSessionId: string | undefined; // Ephemeral env overrides (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Exported by tmux @@ -627,6 +633,8 @@ export class Session extends EventEmitter { grokConfig?: GrokConfig; /** DeepSeek Harness configuration (only for mode === 'deepseek') */ deepSeekConfig?: DeepSeekConfig; + /** OMP configuration (only for mode === 'omp') */ + ompConfig?: OmpConfig; /** Resume a previous Claude conversation (used after server reboot) */ resumeSessionId?: string; /** Extra env vars exported to the CLI at spawn time (no disk persistence) */ @@ -735,6 +743,10 @@ export class Session extends EventEmitter { if (config.piConfig) { this._piConfig = config.piConfig; } + // Apply OMP configuration + if (config.ompConfig) { + this._ompConfig = config.ompConfig; + } // Apply DeepSeek Harness configuration if (config.deepSeekConfig) { @@ -1368,6 +1380,7 @@ export class Session extends EventEmitter { piConfig: this._piConfig, grokConfig: this._grokConfig, deepSeekConfig: this._deepSeekConfig, + ompConfig: this._ompConfig, resumeSessionId: this._resumeSessionId, effort: this._effort, // COD-118: runtime-only — surfaced so the frontend can require explicit user @@ -1617,6 +1630,7 @@ export class Session extends EventEmitter { piConfig: this._piConfig, grokConfig: this._grokConfig, deepSeekConfig: this._deepSeekConfig, + ompConfig: this._ompConfig, resumeSessionId: this._resumeSessionId, envOverrides: this._envOverrides, effort: this._effort, @@ -1877,6 +1891,7 @@ export class Session extends EventEmitter { piConfig: this._piConfig, grokConfig: this._grokConfig, deepSeekConfig: this._deepSeekConfig, + ompConfig: this._ompConfig, resumeSessionId: this._resumeSessionId, envOverrides: this._envOverrides, effort: this._effort, diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 5088793d..e81ee04b 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -54,6 +54,7 @@ import { type PiConfig, type GrokConfig, type DeepSeekConfig, + type OmpConfig, type SessionRemote, type SessionDocker, type DockerCommandMode, @@ -99,6 +100,8 @@ import { resolveDeepSeekDir, getDeepSeekNotFoundMessage, resolveDefaultDeepSeekProfile, + getOmpNotFoundMessage, + resolveOmpDir, resolveLocalShell, loginShellArgs, } from './utils/index.js'; @@ -896,6 +899,29 @@ function buildDeepSeekCommand(config?: DeepSeekConfig): string { return parts.join(' '); } +/** + * Build the OMP CLI command with appropriate flags. + * + * omp reads its model routing and hooks from ~/.omp (agent dir), so no + * trust/permission flags are needed: the CLI's own config governs. The only + * CLI flags passed are the per-session overrides Codeman knows about. + */ +function buildOmpCommand(config?: OmpConfig): string { + const parts = ['omp']; + + if (config?.model) { + const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined; + if (safeModel) parts.push('--model', safeModel); + } + + if (config?.resumeSessionId) { + const safeId = /^[a-zA-Z0-9._-]+$/.test(config.resumeSessionId) ? config.resumeSessionId : undefined; + if (safeId) parts.push('--resume', safeId); + } + + return parts.join(' '); +} + /** * Build the spawn command for any session mode. * Shared by createSession() and respawnPane() to avoid duplication. @@ -941,6 +967,7 @@ export function buildSpawnCommand(options: { piConfig?: PiConfig; grokConfig?: GrokConfig; deepSeekConfig?: DeepSeekConfig; + ompConfig?: OmpConfig; resumeSessionId?: string; effort?: EffortLevel; /** Codeman session name, passed to claude as `--name` (version-gated, sanitized; local spawns only). */ @@ -996,6 +1023,9 @@ export function buildSpawnCommand(options: { if (options.mode === 'deepseek') { return buildDeepSeekCommand(options.deepSeekConfig); } + if (options.mode === 'omp') { + return buildOmpCommand(options.ompConfig); + } // #208: NOT the literal '$SHELL'. This string is embedded in the `bash -c "…"` // argument of the respawn-pane line, which execSync runs through `/bin/sh -c`, // so a `$SHELL` here is expanded by the SERVER process's shell against the @@ -1179,7 +1209,6 @@ export function buildRemoteKillCommand(options: { remote: SessionRemote; session * adopts/resizes/respawns our session (same defence as the remote socket). */ const DOCKER_TMUX_SOCKET = 'codeman-docker'; - /** * Deterministic, reattach-stable in-container tmux session name. Derived from the * same stable field the local muxName uses (first 8 chars of the sessionId), so a @@ -1214,6 +1243,7 @@ function appendResumeFlag(modeCommand: string, mode: SessionMode, resumeId: stri case 'grok': return `${modeCommand} --resume ${resumeId}`; case 'deepseek': + case 'omp': return `${modeCommand} --resume ${resumeId}`; default: return modeCommand; // shell / opencode: no resume @@ -1810,7 +1840,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { mode === 'antigravity' || mode === 'pi' || mode === 'grok' || - mode === 'deepseek' + mode === 'deepseek' || + mode === 'omp' ? 'export COLORTERM=truecolor' : 'unset COLORTERM', ...(mode === 'codex' || @@ -1818,7 +1849,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { mode === 'antigravity' || mode === 'pi' || mode === 'grok' || - mode === 'deepseek' + mode === 'deepseek' || + mode === 'omp' ? ['unset NO_COLOR'] : []), // Stamp each Codex pane with a unique originator so the response-viewer @@ -1923,6 +1955,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const dir = resolveDeepSeekDir(); return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir }; } + if (mode === 'omp') { + const dir = resolveOmpDir(); + return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir }; + } return { pathExport: '', dir: null }; } @@ -2033,6 +2069,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { piConfig, grokConfig, deepSeekConfig, + ompConfig, resumeSessionId, envOverrides, effort, @@ -2099,6 +2136,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { if (mode === 'grok' && !cliDir) { throw new Error(getGrokNotFoundMessage()); } + if (mode === 'omp' && !cliDir) { + throw new Error(getOmpNotFoundMessage()); + } const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && '); @@ -2115,6 +2155,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { piConfig, grokConfig, deepSeekConfig, + ompConfig, resumeSessionId, effort, sessionName: name, diff --git a/src/types/session.ts b/src/types/session.ts index 8e429459..5c26dc6a 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -8,7 +8,7 @@ * - SessionConfig — creation-time config (id, workingDir, createdAt) * - SessionOutput — captured stdout/stderr/exitCode * - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error' - * - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' (which CLI backend) + * - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' (which CLI backend) * - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools') * - SessionColor — visual differentiation color * - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession) @@ -55,11 +55,12 @@ export type SessionMode = | 'antigravity' | 'pi' | 'grok' - | 'deepseek'; + | 'deepseek' + | 'omp'; export type RemoteCommandMode = Extract< SessionMode, - 'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' + 'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' >; /** @@ -168,7 +169,7 @@ export interface RemoteSessionInfo { /** Which CLI backends a Docker case can run (same set as remote). */ export type DockerCommandMode = Extract< SessionMode, - 'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' + 'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' >; /** Container engine. Docker and Podman differ in the uid/userns + host-gateway alias. */ @@ -343,6 +344,14 @@ export interface AntigravityConfig { resumeConversationId?: string; } +/** OMP CLI session configuration */ +export interface OmpConfig { + /** Model identifier (e.g., "crof/glm-5.2"). Passed via --model. */ + model?: string; + /** Resume a previous conversation (passed via --resume). */ + resumeSessionId?: string; +} + /** * Pi CLI (pi.dev) session configuration. * @@ -620,6 +629,8 @@ export interface SessionState { grokConfig?: GrokConfig; /** DeepSeek Harness configuration (only for mode === 'deepseek') */ deepSeekConfig?: DeepSeekConfig; + /** OMP-specific configuration (only for mode === 'omp') */ + ompConfig?: OmpConfig; /** Claude conversation session ID to resume after reboot (set by restore script) */ resumeSessionId?: string; /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ diff --git a/src/utils/index.ts b/src/utils/index.ts index 44dab03d..7580def6 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -61,3 +61,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'; diff --git a/src/utils/omp-cli-resolver.ts b/src/utils/omp-cli-resolver.ts new file mode 100644 index 00000000..5da8e1b9 --- /dev/null +++ b/src/utils/omp-cli-resolver.ts @@ -0,0 +1,134 @@ +/** + * @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 */ +const OMP_SEARCH_DIRS = [ + join(homedir(), '.omp', 'bin'), + join(homedir(), '.local', 'bin'), + '/usr/local/bin', + join(homedir(), '.bun', 'bin'), + join(homedir(), '.npm-global', 'bin'), + join(homedir(), 'bin'), +]; + +/** + * A real `omp --version` prints `omp/` (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/`-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( + { + 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; +} diff --git a/src/web/public/app.js b/src/web/public/app.js index 327b732f..e101ee8c 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -2270,9 +2270,11 @@ class CodemanApp { ? 'Grok' : mode === 'deepseek' ? 'DeepSeek' - : mode === 'opencode' - ? 'OpenCode' - : 'Claude'; + : mode === 'omp' + ? 'OMP' + : mode === 'opencode' + ? 'OpenCode' + : 'Claude'; } async toggleResponseViewer() { @@ -4888,7 +4890,7 @@ class CodemanApp { - ${mode === 'shell' ? '' : mode === 'opencode' ? '' : mode === 'codex' ? '' : mode === 'gemini' ? '' : mode === 'antigravity' ? '' : mode === 'pi' ? '' : mode === 'grok' ? '' : mode === 'deepseek' ? '' : ''} + ${mode === 'shell' ? '' : mode === 'opencode' ? '' : mode === 'codex' ? '' : mode === 'gemini' ? '' : mode === 'antigravity' ? '' : mode === 'pi' ? '' : mode === 'grok' ? '' : mode === 'deepseek' ? '' : mode === 'omp' ? '' : ''} ${tabLabel} ${inlineSessionActions ? tabActionsHtml : ''} @@ -6336,7 +6338,9 @@ class CodemanApp { ? 'Kill Tmux & Grok' : session.mode === 'deepseek' ? 'Kill Tmux & DeepSeek' - : 'Kill Tmux & Claude Code'; + : session.mode === 'omp' + ? 'Kill Tmux & OMP' + : 'Kill Tmux & Claude Code'; } document.getElementById('closeConfirmModal').classList.add('active'); diff --git a/src/web/public/home-sessions.js b/src/web/public/home-sessions.js index 5a102eaa..453eec10 100644 --- a/src/web/public/home-sessions.js +++ b/src/web/public/home-sessions.js @@ -80,6 +80,7 @@ const HOME_SESSIONS_MODE_BADGE = { pi: 'pi', grok: 'gk', deepseek: 'ds', + omp: 'om', }; Object.assign(CodemanApp.prototype, { diff --git a/src/web/public/index.html b/src/web/public/index.html index a5c76bd7..96cb4f66 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -452,6 +452,10 @@ Run DeepSeek +
@@ -643,6 +647,9 @@ +
@@ -2709,6 +2717,7 @@ + Which CLI to point the Run button at once the clone finishes. Changeable any time from the Run dropdown. diff --git a/src/web/public/mobile-overview.js b/src/web/public/mobile-overview.js index c9c32bb4..34ce8963 100644 --- a/src/web/public/mobile-overview.js +++ b/src/web/public/mobile-overview.js @@ -56,6 +56,7 @@ const MOBILE_OVERVIEW_RUN_MODES = [ { mode: 'pi', label: 'Pi', short: 'Pi' }, { mode: 'grok', label: 'Grok', short: 'Grok' }, { mode: 'deepseek', label: 'DeepSeek', short: 'DeepSeek' }, + { mode: 'omp', label: 'OMP', short: 'OMP' }, { mode: 'shell', label: 'Terminal / Shell', short: 'Shell' }, ]; diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index e0ddb53d..f516ebb2 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -1003,6 +1003,20 @@ html.mobile-init .file-browser-panel { border-color: rgba(150, 170, 255, 0.55) !important; } + /* OMP mode colors on mobile. Same `!important` rationale as the pi/grok/deepseek blocks above. */ + .btn-toolbar.btn-run.mode-omp, + .btn-toolbar.btn-run-gear.mode-omp { + background: #312e81 !important; + border-color: rgba(129, 140, 248, 0.3) !important; + color: #e0e7ff !important; + } + + .btn-toolbar.btn-run.mode-omp:active, + .btn-toolbar.btn-run-gear.mode-omp:active { + background: #4f46e5 !important; + border-color: rgba(129, 140, 248, 0.5) !important; + } + /* Run mode dropdown menu — positioned above toolbar on mobile */ .run-mode-menu { bottom: 100%; @@ -3083,7 +3097,9 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-pi, .btn-toolbar.btn-run-gear.mode-pi) { background: linear-gradient(135deg, #be185d, #db2777); border-color: #9d174d; - color: #ffffff; +html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-omp, .btn-toolbar.btn-run-gear.mode-omp) { + background: linear-gradient(135deg, #4f46e5, #6366f1); + border-color: #4338ca; color: #ffffff; } html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-grok, .btn-toolbar.btn-run-gear.mode-grok) { diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index d9d96f29..f4dcc16f 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -432,7 +432,7 @@ Object.assign(CodemanApp.prototype, { _buildCommandPaletteNewSessionItem(query = '') { const mode = this.runMode || this._runMode || 'claude'; - const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi', grok: 'Grok', deepseek: 'DeepSeek' }; + const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi', grok: 'Grok', deepseek: 'DeepSeek', omp: 'OMP' }; const caseName = this._findCommandPaletteCaseMatch(query) || document.getElementById('quickStartCase')?.value || 'testcase'; return { id: 'new-session', diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 6de0507c..1372d70a 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -409,6 +409,9 @@ Object.assign(CodemanApp.prototype, { if (mode === 'deepseek') { return await this.runDeepSeek(); } + if (mode === 'omp') { + return await this.runOmp(); + } if (mode === 'shell') { return await this.runShell(); } @@ -474,7 +477,7 @@ Object.assign(CodemanApp.prototype, { * run modes like the rest, and neither `agy` nor `pi` is likely to be installed. */ _refreshRunModeAvailability(menu) { - for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']) { + for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek', 'omp']) { const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`); if (btn) btn.style.display = this.isCliAvailable(mode) ? 'flex' : 'none'; } @@ -706,11 +709,8 @@ Object.assign(CodemanApp.prototype, { if (runBtn) { runBtn.className = `btn-toolbar btn-run mode-${mode}`; } - if (gearBtn) { - gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`; - } if (label) { - label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'pi' ? 'Run PI' : mode === 'grok' ? 'Run GK' : mode === 'deepseek' ? 'Run DS' : mode === 'shell' ? 'Run SH' : 'Run'; + label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'pi' ? 'Run PI' : mode === 'grok' ? 'Run GK' : mode === 'deepseek' ? 'Run DS' : mode === 'omp' ? 'Run OMP' : mode === 'shell' ? 'Run SH' : 'Run'; } }, @@ -1383,17 +1383,24 @@ Object.assign(CodemanApp.prototype, { const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Pi session in ${caseName}...`); - this.terminal.focus(); + async runOmp() { + const caseName = document.getElementById('quickStartCase').value || 'testcase'; + // Remote/docker cases run omp on the OTHER side — skip the local status probe + // and the local-only config below (quick-start rejects them for remote cases). + const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location; + const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; + + const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting OMP session in ${caseName}...`); this.terminal.focus(); try { if (!isRemote) { const statusRes = await fetch('/api/pi/status'); - const status = (await statusRes.json()).data; + const statusRes = await fetch('/api/omp/status'); const status = (await statusRes.json()).data; if (!status.available) { this._reportSessionLaunchError( ownsLaunchTerminal, 'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent' - ); + 'OMP CLI not found. Install with: curl -fsSL https://omp.sh/install | sh' ); return; } } @@ -1411,7 +1418,15 @@ Object.assign(CodemanApp.prototype, { }); const data = await res.json(); if (!data.success) throw new Error(data.error || 'Failed to start Pi'); - await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session); + mode: 'omp', + sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`, + ...(isRemote ? {} : { + ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), + }), + }) + }); + const data = await res.json(); + if (!data.success) throw new Error(data.error || 'Failed to start OMP'); await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session); if (data.data.sessionId) { await this.selectSession(data.data.sessionId); @@ -1626,10 +1641,9 @@ Object.assign(CodemanApp.prototype, { if (detachToggle) detachToggle.checked = this.hasTabDetachOverride(sessionId); // Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only) - const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek'; + const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek' || session.mode === 'omp'; this.switchOptionsTab(isAltMode ? 'summary' : 'respawn'); - // Update respawn status display and buttons const respawnStatus = document.getElementById('sessionRespawnStatus'); const enableBtn = document.getElementById('modalEnableRespawnBtn'); const stopBtn = document.getElementById('modalStopRespawnBtn'); @@ -1656,10 +1670,9 @@ Object.assign(CodemanApp.prototype, { } // Hide Claude-specific options for external CLI sessions - const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek'; + const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek' || session.mode === 'omp'; const claudeOnlyEls = document.querySelectorAll('[data-claude-only]'); claudeOnlyEls.forEach(el => { el.style.display = isExternalCli ? 'none' : ''; }); - // Reset duration presets to default (unlimited) this.selectDurationPreset(''); @@ -3442,7 +3455,7 @@ Object.defineProperty(CodemanApp.prototype, 'runMode', { }, set(mode) { this._runMode = - mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok' || mode === 'deepseek' || mode === 'claude' + mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok' || mode === 'deepseek' || mode === 'omp' || mode === 'claude' ? mode : 'claude'; }, diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 26de7879..67be48d8 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -1230,6 +1230,7 @@ Object.assign(CodemanApp.prototype, { ['welcomeClaudeBtn', 'claude'], ['welcomeOpencodeBtn', 'opencode'], ['welcomeAntigravityBtn', 'antigravity'], + ['welcomeOmpBtn', 'omp'], ['welcomeGeminiBtn', 'gemini'], ['welcomePiBtn', 'pi'], ['welcomeGrokBtn', 'grok'], diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 6a503a42..000f13d6 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -349,7 +349,8 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat .session-tab .tab-mode.gemini, .session-tab .tab-mode.antigravity, .session-tab .tab-mode.pi, - .session-tab .tab-mode.grok + .session-tab .tab-mode.grok, + .session-tab .tab-mode.omp ) { color: var(--accent-d); } @@ -2490,6 +2491,10 @@ body.solo-mode .btn-lifecycle-log { background: rgba(34, 211, 238, 0.2); color: #22d3ee; } +.session-tab .tab-mode.omp { + background: rgba(129, 140, 248, 0.2); + color: #818cf8; +} .session-tab .tab-mode.pi { background: rgba(244, 114, 182, 0.2); @@ -3872,7 +3877,20 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { box-shadow: 0 4px 20px rgba(244, 114, 182, 0.3), 0 0 40px rgba(190, 24, 93, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.08); border-color: rgba(249, 168, 212, 0.5); color: #fff1f7; - transform: translateY(-1px); +/* OMP: indigo identity, matching .btn-toolbar.btn-run.mode-omp and + .run-mode-dot.omp so the welcome action reads as the same backend. */ +.welcome-btn-omp { + background: linear-gradient(135deg, #1e1b4b 0%, #4f46e5 55%, #6366f1 100%); + border-color: rgba(129, 140, 248, 0.4); + color: #e0e7ff; + box-shadow: 0 2px 8px rgba(129, 140, 248, 0.16), inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +.welcome-btn-omp:hover { + background: linear-gradient(135deg, #312e81 0%, #6366f1 55%, #818cf8 100%); + box-shadow: 0 4px 20px rgba(129, 140, 248, 0.3), 0 0 40px rgba(79, 70, 229, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.08); + border-color: rgba(165, 180, 252, 0.5); + color: #eef2ff; transform: translateY(-1px); } /* Grok (xAI): monochrome charcoal identity, matching .btn-toolbar.btn-run.mode-grok @@ -4982,7 +5000,20 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { box-shadow: 0 0 12px rgba(244, 114, 182, 0.35), 0 2px 8px rgba(190, 24, 93, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.08); border-color: rgba(249, 168, 212, 0.6); color: #fff1f7; +/* OMP mode colors */ +.btn-toolbar.btn-run.mode-omp, +.btn-toolbar.btn-run-gear.mode-omp { + background: linear-gradient(135deg, #312e81 0%, #4f46e5 55%, #6366f1 100%); + border-color: rgba(129, 140, 248, 0.5); + color: #e0e7ff; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.06); } +.btn-toolbar.btn-run.mode-omp:hover, +.btn-toolbar.btn-run-gear.mode-omp:hover { + background: linear-gradient(135deg, #3730a3 0%, #6366f1 55%, #818cf8 100%); + box-shadow: 0 0 12px rgba(129, 140, 248, 0.35), 0 2px 8px rgba(79, 70, 229, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.08); + border-color: rgba(165, 180, 252, 0.6); + color: #eef2ff;} /* Grok mode colors. Same cascade note as pi above: this base-sheet pair only renders on the `og` skin — the nested `html:not([data-skin="og"])` block @@ -5107,6 +5138,7 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { .run-mode-dot.pi { background: #f472b6; } .run-mode-dot.grok { background: #a1a1aa; } .run-mode-dot.deepseek { background: #4d6bfe; } +.run-mode-dot.omp { background: #818cf8; } .run-mode-dot.shell { background: #94a3b8; } /* Phone-only Enter button (see index.html). Hidden by default at every width; diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 3e7c258f..99498a70 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -866,6 +866,7 @@ export function registerSessionRoutes( body.mode !== 'pi' && body.mode !== 'grok' && body.mode !== 'deepseek' && + body.mode !== 'omp' && body.envOverrides && Object.keys(body.envOverrides).length > 0 && (workingDir.startsWith(CASES_DIR + '/') || workingDir.startsWith(managedCasesBase + '/')); @@ -965,6 +966,12 @@ export function registerSessionRoutes( return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getGrokNotFoundMessage()); } } + if (body.mode === 'omp') { + const { isOmpAvailable, getOmpNotFoundMessage } = await import('../../utils/omp-cli-resolver.js'); + if (!isOmpAvailable()) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getOmpNotFoundMessage()); + } + } // Pre-validate resumeSessionId: check that the conversation file actually exists // in Claude's projects directory. If not, skip resume to avoid confusing @@ -1012,12 +1019,14 @@ export function registerSessionRoutes( ? body.piConfig?.model : mode === 'grok' ? body.grokConfig?.model - : // DeepSeek's model is a composition entry in the profile's config - // tree, not a session flag, so there is deliberately nothing to - // read here (see docs/deepseek-integration.md). - mode !== 'shell' && mode !== 'deepseek' - ? modelConfig?.defaultModel || undefined - : undefined; + : mode === 'omp' + ? body.ompConfig?.model + : // DeepSeek's model is a composition entry in the profile's config + // tree, not a session flag, so there is deliberately nothing to + // read here (see docs/deepseek-integration.md). + mode !== 'shell' && mode !== 'deepseek' + ? modelConfig?.defaultModel || undefined + : undefined; const claudeModeConfig = await ctx.getClaudeModeConfig(); // Section 6.3: force non-granted users to a classifier-guarded mode. const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, owner); @@ -1056,6 +1065,7 @@ export function registerSessionRoutes( piConfig: mode === 'pi' ? gatedPiConfig : undefined, grokConfig: mode === 'grok' ? gatedGrokConfig : undefined, deepSeekConfig: mode === 'deepseek' ? gatedDeepSeekConfig : undefined, + ompConfig: mode === 'omp' ? body.ompConfig : undefined, resumeSessionId: validatedResumeId, envOverrides: await clampEnvOverridesForOwner(owner, body.envOverrides), effort: body.effort, @@ -1289,6 +1299,7 @@ export function registerSessionRoutes( session.mode !== 'pi' && session.mode !== 'grok' && session.mode !== 'deepseek' && + session.mode !== 'omp' && ctx.store.getConfig().ralphEnabled && !session.ralphTracker.autoEnableDisabled ) { @@ -2857,6 +2868,7 @@ export function registerSessionRoutes( piConfig, grokConfig, deepSeekConfig, + ompConfig, envOverrides, effort, parentSessionId, @@ -2907,6 +2919,7 @@ export function registerSessionRoutes( piConfig || grokConfig || deepSeekConfig || + ompConfig || openCodeConfig ) { return createErrorResponse( @@ -2941,6 +2954,7 @@ export function registerSessionRoutes( piConfig || grokConfig || deepSeekConfig || + ompConfig || openCodeConfig ) { return createErrorResponse( @@ -3042,6 +3056,16 @@ export function registerSessionRoutes( return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getPiNotFoundMessage()); } } + // Check OMP availability if requested + if (mode === 'omp') { + const { isOmpAvailable } = await import('../../utils/omp-cli-resolver.js'); + if (!isOmpAvailable()) { + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + 'OMP CLI not found. Install with: curl -fsSL https://omp.sh/install | sh' + ); + } + } // Check Grok availability if requested if (mode === 'grok') { @@ -3103,14 +3127,15 @@ export function registerSessionRoutes( writeFileSync(join(resolvedCasePath, 'CLAUDE.md'), claudeMd); // Write .claude/settings.local.json with hooks for desktop notifications - // (Claude-specific — OpenCode, Codex, Gemini, Antigravity, Pi and Grok use their own systems) + // (Claude-specific — OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP use their own systems) if ( mode !== 'opencode' && mode !== 'codex' && mode !== 'gemini' && mode !== 'antigravity' && mode !== 'pi' && - mode !== 'grok' + mode !== 'grok' && + mode !== 'omp' ) { await writeHooksConfig(resolvedCasePath); } @@ -3150,6 +3175,7 @@ export function registerSessionRoutes( // rule the existing-case branch above states; this branch used to exclude just // the five external CLIs and let `shell` through). if (docker && docker.hooksEnabled && mode === 'claude') { + // configured project. Skipped for external CLIs (they use their own systems). try { if (!existsSync(join(resolvedCasePath, 'CLAUDE.md'))) { const templatePath = await ctx.getDefaultClaudeMdPath(); @@ -3185,6 +3211,7 @@ export function registerSessionRoutes( mode !== 'pi' && mode !== 'grok' && mode !== 'deepseek' && + mode !== 'omp' && !remote && envOverrides && Object.keys(envOverrides).length > 0 @@ -3209,10 +3236,12 @@ export function registerSessionRoutes( ? piConfig?.model : mode === 'grok' ? grokConfig?.model - : // DeepSeek's model lives in the profile's config tree, not here. - mode !== 'shell' && mode !== 'deepseek' - ? qsModelConfig?.defaultModel || undefined - : undefined; + : mode === 'omp' + ? ompConfig?.model + : // DeepSeek's model lives in the profile's config tree, not here. + mode !== 'shell' && mode !== 'deepseek' + ? qsModelConfig?.defaultModel || undefined + : undefined; const qsClaudeModeConfig = await ctx.getClaudeModeConfig(); const qsEffectiveClaudeMode = await resolveClaudeModeForUsername(qsClaudeModeConfig.claudeMode, owner); // Section 6.3: clamp Codex/Gemini/Antigravity bypass switches for a non-granted owner (no-op single-user/granted). @@ -3252,6 +3281,7 @@ export function registerSessionRoutes( piConfig: mode === 'pi' ? qsGatedPiConfig : undefined, grokConfig: mode === 'grok' ? qsGatedGrokConfig : undefined, deepSeekConfig: mode === 'deepseek' ? qsGatedDeepSeekConfig : undefined, + ompConfig: mode === 'omp' ? ompConfig : undefined, envOverrides: qsGatedEnvOverrides, effort, remote, diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index 4929e977..c3e955a5 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -694,6 +694,17 @@ export function registerSystemRoutes( }; }); + // ========== OMP ========== + + app.get('/api/omp/status', async () => { + const { isOmpAvailable, resolveOmpDir, getOmpCliVersion } = await import('../../utils/omp-cli-resolver.js'); + return { + available: isOmpAvailable(), + path: resolveOmpDir(), + version: getOmpCliVersion(), + }; + }); + // ═══════════════════════════════════════════════════════════════ // State & Lifecycle (cleanup, lifecycle log, stats) // ═══════════════════════════════════════════════════════════════ diff --git a/src/web/schemas.ts b/src/web/schemas.ts index e49889fb..e7401783 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -141,6 +141,7 @@ const ALLOWED_ENV_PREFIXES = [ // 34-provider-key problem in a new shape, and the answer is the same one. 'DSH_', 'DEEPSEEK_', + 'OMP_', ]; /** @@ -180,7 +181,7 @@ const safeEnvOverridesSchema = z }, { message: - 'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, ANTIGRAVITY_*, PI_*, GROK_*, XAI_*, DSH_*, DEEPSEEK_* keys and CLAUDE_CONFIG_DIR are allowed.', + 'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, ANTIGRAVITY_*, PI_*, GROK_*, XAI_*, DSH_*, DEEPSEEK_*, OMP_* keys and CLAUDE_CONFIG_DIR are allowed.', } ); @@ -345,6 +346,24 @@ const GrokConfigSchema = z }) .optional(); +/** + * Schema for OMP CLI-specific configuration. + */ +const OmpConfigSchema = z + .object({ + model: z + .string() + .max(100) + .regex(/^[a-zA-Z0-9._\-/]+$/) + .optional(), + resumeSessionId: z + .string() + .max(100) + .regex(/^[a-zA-Z0-9._-]+$/) + .optional(), + }) + .optional(); + /** * Schema for DeepSeek Harness (`dsh`)-specific configuration. * @@ -440,7 +459,9 @@ const parentSessionIdSchema = z.string().max(100).optional(); export const CreateSessionSchema = z.object({ workingDir: safePathSchema.optional(), - mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']).optional(), + mode: z + .enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek', 'omp']) + .optional(), name: z.string().max(100).optional(), /** Session that spawned this one — see parentSessionIdSchema. */ parentSessionId: parentSessionIdSchema, @@ -458,6 +479,7 @@ export const CreateSessionSchema = z.object({ piConfig: PiConfigSchema, grokConfig: GrokConfigSchema, deepSeekConfig: DeepSeekConfigSchema, + ompConfig: OmpConfigSchema, /** Resume a previous Claude conversation by its session ID (used for reboot recovery) */ resumeSessionId: z .string() @@ -869,7 +891,9 @@ export const QuickStartSchema = z.object({ * a real host dir, so the settings file crosses the bind mount); rejected for * remote cases (the file would be written on the WRONG machine). */ modelOverride: z.string().max(50).optional(), - mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']).optional(), + mode: z + .enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek', 'omp']) + .optional(), openCodeConfig: OpenCodeConfigSchema, codexConfig: CodexConfigSchema, geminiConfig: GeminiConfigSchema, @@ -877,6 +901,7 @@ export const QuickStartSchema = z.object({ piConfig: PiConfigSchema, grokConfig: GrokConfigSchema, deepSeekConfig: DeepSeekConfigSchema, + ompConfig: OmpConfigSchema, envOverrides: safeEnvOverridesSchema, /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ effort: effortLevelSchema, @@ -1410,7 +1435,7 @@ const noNewlines = (v: string) => !/[\r\n]/.test(v); /** Shared field shape for creating/updating a scheduled job. */ const CronJobBaseSchema = z.object({ name: z.string().min(1).max(200), - agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']), + agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek', 'omp']), workingDir: safePathSchema, launchCommand: z.string().max(2000).refine(noNewlines, 'launchCommand must be a single line').optional(), promptMode: z.enum(['inline_text', 'prompt_file_path']), diff --git a/src/web/server.ts b/src/web/server.ts index 6227f60b..5e85562b 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1441,6 +1441,7 @@ export class WebServer extends EventEmitter { { isPiAvailable }, { isGrokAvailable }, { isDeepSeekRunnable, isDeepSeekAvailable }, + { isOmpAvailable }, { isCloudflaredAvailable }, { isGitAvailable }, ] = await Promise.all([ @@ -1452,6 +1453,7 @@ export class WebServer extends EventEmitter { import('../utils/pi-cli-resolver.js'), import('../utils/grok-cli-resolver.js'), import('../utils/deepseek-cli-resolver.js'), + import('../utils/omp-cli-resolver.js'), import('../utils/cloudflared-resolver.js'), import('../git-clone.js'), ]); @@ -1470,6 +1472,7 @@ export class WebServer extends EventEmitter { // profile is offered the fix rather than a greyed-out entry. deepseek: isDeepSeekRunnable(), deepseekBinary: isDeepSeekAvailable(), + omp: isOmpAvailable(), cloudflared: isCloudflaredAvailable(), // Not a run mode: the Add Case → Clone tab is an offer this box cannot // keep without git (issue #236), same reasoning as cloudflared above. diff --git a/test/mobile-overview.test.ts b/test/mobile-overview.test.ts index 1bfc8eb8..e389908c 100644 --- a/test/mobile-overview.test.ts +++ b/test/mobile-overview.test.ts @@ -433,6 +433,7 @@ describe('mobile overview run picker (CLI availability gating)', () => { 'pi', 'grok', 'deepseek', + 'omp', 'shell', ]); }); @@ -447,7 +448,7 @@ describe('mobile overview run picker (CLI availability gating)', () => { src.indexOf('];', src.indexOf('const MOBILE_OVERVIEW_RUN_MODES')) + 2 ); const offered = [...modesBlock.matchAll(/mode: '([^']+)'/g)].map((m) => m[1]); - expect(offered).toContain('antigravity'); + expect(offered).toContain('omp'); const fn = src.slice(src.indexOf('_buildMobileOverviewRunMenu() {')); const gate = fn.slice(0, fn.indexOf('const header')); expect(gate).toContain('isCliAvailable'); diff --git a/test/omp-mode.test.ts b/test/omp-mode.test.ts new file mode 100644 index 00000000..c7d3ff39 --- /dev/null +++ b/test/omp-mode.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest'; +import { CreateSessionSchema, QuickStartSchema } from '../src/web/schemas.js'; +import { buildSpawnCommand } from '../src/tmux-manager.js'; +import { defaultDockerCommandForMode } from '../src/docker-hosts.js'; +import { defaultRemoteCommandForMode } from '../src/remote-hosts.js'; +import { isExternalCliMode, isAltScreenStripMode } from '../src/session.js'; + +describe('OMP mode schemas', () => { + it('accepts OMP session creation config', () => { + const parsed = CreateSessionSchema.parse({ + workingDir: '/tmp', + mode: 'omp', + ompConfig: { + model: 'crof/glm-5.2', + }, + }); + + expect(parsed.mode).toBe('omp'); + expect(parsed.ompConfig).toEqual({ + model: 'crof/glm-5.2', + }); + }); + + it('accepts OMP quick-start config', () => { + const parsed = QuickStartSchema.parse({ + caseName: 'omp-case', + mode: 'omp', + ompConfig: { + resumeSessionId: 'session-1234abcd', + }, + }); + + expect(parsed.mode).toBe('omp'); + expect(parsed.ompConfig?.resumeSessionId).toBe('session-1234abcd'); + }); + + it('rejects unsafe OMP model strings', () => { + expect(() => + CreateSessionSchema.parse({ + workingDir: '/tmp', + mode: 'omp', + ompConfig: { model: 'omp; rm -rf /' }, + }) + ).toThrow(); + }); + + it('allows OMP_* env overrides and still rejects unknown prefixes', () => { + const parsed = CreateSessionSchema.parse({ + workingDir: '/tmp', + mode: 'omp', + envOverrides: { OMP_PROFILE: 'work' }, + }); + expect(parsed.envOverrides).toEqual({ OMP_PROFILE: 'work' }); + + expect(() => + CreateSessionSchema.parse({ + workingDir: '/tmp', + envOverrides: { RANDOM_PREFIX_KEY: 'x' }, + }) + ).toThrow(); + }); +}); + +describe('OMP spawn command', () => { + it('builds a bare omp command when no config is sent', () => { + const cmd = buildSpawnCommand({ mode: 'omp', sessionId: 'abc12345' }); + expect(cmd).toBe('omp'); + }); + + it('passes --model and --resume, and drops unsafe ids', () => { + expect( + buildSpawnCommand({ + mode: 'omp', + sessionId: 'abc12345', + ompConfig: { model: 'crof/glm-5.2', resumeSessionId: 'session-99' }, + }) + ).toBe('omp --model crof/glm-5.2 --resume session-99'); + + expect( + buildSpawnCommand({ + mode: 'omp', + sessionId: 'abc12345', + ompConfig: { resumeSessionId: 'x; rm -rf /' }, + }) + ).toBe('omp'); + }); + + it('drops unsafe model strings from the spawn command', () => { + expect( + buildSpawnCommand({ + mode: 'omp', + sessionId: 'abc12345', + ompConfig: { model: 'a`b' }, + }) + ).toBe('omp'); + }); +}); + +describe('OMP mode gates', () => { + it('is an external CLI mode (readiness/ralph/respawn gating)', () => { + expect(isExternalCliMode('omp')).toBe(true); + }); + + it('is NOT an alt-screen strip mode (unverified TUI, like opencode/antigravity)', () => { + expect(isAltScreenStripMode('omp')).toBe(false); + }); + + it('has docker/remote default commands', () => { + expect(defaultDockerCommandForMode('omp')).toBe('exec omp'); + // Routed through an interactive login shell so per-user PATH entries resolve — + // same fix as the other remote agent CLIs (see defaultRemoteCommandForMode). + expect(defaultRemoteCommandForMode('omp')).toBe('exec "${SHELL:-/bin/sh}" -i -l -c \'omp\''); + }); +}); diff --git a/test/run-mode-ui.test.ts b/test/run-mode-ui.test.ts index 50103ed5..731cfdb2 100644 --- a/test/run-mode-ui.test.ts +++ b/test/run-mode-ui.test.ts @@ -81,6 +81,16 @@ describe('run mode UI', () => { expect(app.runMode).toBe('antigravity'); expect(runBtnLabel.textContent).toBe('Run AG'); }); + + it('accepts OMP mode from server sync and updates the run button label', async () => { + const { app, storage, runBtnLabel } = loadRunModeHarness(); + + storage.set('codeman_runMode', 'claude'); + await app.loadAppSettingsFromServer(Promise.resolve({ runMode: 'omp' })); + + expect(app.runMode).toBe('omp'); + expect(runBtnLabel.textContent).toBe('Run OMP'); + }); }); describe('Run launch synchronization', () => { @@ -367,12 +377,13 @@ describe('Codex quick start settings', () => { 'welcomeGeminiBtn', 'welcomePiBtn', 'welcomeGrokBtn', + 'welcomeOmpBtn', 'welcomeTunnelBtn', ]) { welcomeBtns[id] = { style: { display: 'PRISTINE' } }; } const modeBtns: Record = {}; - for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'shell']) { + for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'omp', 'shell']) { modeBtns[mode] = { style: { display: 'PRISTINE' } }; } const menu = { @@ -405,6 +416,7 @@ describe('Codex quick start settings', () => { antigravity: false, pi: false, grok: false, + omp: false, cloudflared: false, }; @@ -442,16 +454,23 @@ describe('Codex quick start settings', () => { withAgy.app.applyWelcomeCliVisibility(); expect(withAgy.welcomeBtns.welcomeAntigravityBtn.style.display).toBe('flex'); expect(withAgy.welcomeBtns.welcomeClaudeBtn.style.display).toBe('none'); + + // OMP is a first-class welcome action, gated on `omp` like the rest. + const withOmp = loadUi({ ...ALL_OFF, omp: true }); + withOmp.app.applyWelcomeCliVisibility(); + expect(withOmp.welcomeBtns.welcomeOmpBtn.style.display).toBe('flex'); + expect(withOmp.welcomeBtns.welcomeClaudeBtn.style.display).toBe('none'); }); it('gates every run mode in the dropdown, antigravity included, and never shell', () => { - const { app, modeBtns, menu } = loadUi({ ...ALL_OFF, claude: true, antigravity: true }); + const { app, modeBtns, menu } = loadUi({ ...ALL_OFF, claude: true, antigravity: true, omp: true }); app._refreshRunModeAvailability(menu); expect(modeBtns.claude.style.display).toBe('flex'); expect(modeBtns.antigravity.style.display).toBe('flex'); expect(modeBtns.opencode.style.display).toBe('none'); expect(modeBtns.codex.style.display).toBe('none'); expect(modeBtns.gemini.style.display).toBe('none'); + expect(modeBtns.omp.style.display).toBe('flex'); // Shell needs no external CLI, and leaving it alone is what guarantees the // menu is never empty on a box with nothing installed. expect(modeBtns.shell.style.display).toBe('PRISTINE'); @@ -468,6 +487,7 @@ describe('Codex quick start settings', () => { expect(offered).toContain('antigravity'); expect(offered).toContain('pi'); expect(offered).toContain('grok'); + expect(offered).toContain('omp'); const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8'); // Anchor on the DEFINITION, not the earlier call site in toggleRunModeMenu. const fn = src.slice(src.indexOf('_refreshRunModeAvailability(menu) {')); From c0423bf5609caae034740e213844a9a1f65c0890 Mon Sep 17 00:00:00 2001 From: timkjr Date: Tue, 18 Aug 2026 23:57:26 -0500 Subject: [PATCH 02/19] fix(omp): complete omp wiring in UI files, skill docs, and tests after rebase --- skills/codeman/reference/endpoints.md | 24 +++++----- skills/codeman/reference/messaging.md | 4 +- skills/codeman/reference/recipes.md | 2 +- skills/codeman/reference/verbs.md | 6 +-- src/web/public/mobile.css | 6 ++- src/web/public/session-ui.js | 68 +++++++++++++++++++++------ test/render-index-html.test.ts | 10 ++++ 7 files changed, 87 insertions(+), 33 deletions(-) diff --git a/skills/codeman/reference/endpoints.md b/skills/codeman/reference/endpoints.md index 50548efa..d02d42a2 100644 --- a/skills/codeman/reference/endpoints.md +++ b/skills/codeman/reference/endpoints.md @@ -237,7 +237,7 @@ minutes, never retry the credential. flushed slightly *after* the `stop` hook fires, so a read taken the instant the wait returns is too early (verified live: empty on the first call, full prose seconds later). It is also `""` before the worker's first completed turn, and permanently `""` for -`shell`, `opencode`, `gemini`, `antigravity`, `pi` and `grok`, which write no transcript at +`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok` and `omp`, which write no transcript at all. `deepseek` is NOT one of those — it is read from `$DSH_HOME/sessions/**` and lags for the same reason claude does (the harness finalizes the assistant message just after it reports `idle`), so poll it the same way. @@ -339,20 +339,20 @@ ESC=$(printf '\033') `POST /api/v1/quick-start` body (all optional): `{"caseName":"worker-1","mode":"claude","sessionName":"w9-worker","effort":"high"}` -, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek`; response is +, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek|omp`; response is `.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory on the user's disk) if missing, do not retry it in a loop, and remember the name. ⚠️ A `mode` whose CLI is **not installed on the server** fails the spawn with `OPERATION_FAILED`; it never falls back to claude. Probe first whenever you did not pick the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`, -`GET /api/v1/codex/status`, `GET /api/v1/gemini/status`, `GET /api/v1/antigravity/status`, `GET /api/v1/grok/status`, `GET /api/v1/deepseek/status` -and `GET /api/v1/pi/status` each return `.data.{available, path}` (no session needed). -Pi's and grok's also carry `.data.version`, because `pi` is a short generic name and -`grok` is a name with npm squatters, so an unrelated binary on `$PATH` can shadow either: -the resolver rejects one whose `--version` is not version-shaped, so `available:false` -there can mean "a different `pi`/`grok` is in front" rather than "nothing is installed". -`shell` has no CLI to probe. +`GET /api/v1/codex/status`, `GET /api/v1/gemini/status`, `GET /api/v1/antigravity/status`, `GET /api/v1/grok/status`, `GET /api/v1/deepseek/status`, +`GET /api/v1/pi/status` and `GET /api/v1/omp/status` each return `.data.{available, path}` (no session needed). +Pi's, grok's and OMP's also carry `.data.version`, because `pi` is a short generic name, +`grok` is a name with npm squatters, and `omp` is a similarly short name, so an unrelated +binary on `$PATH` can shadow any of them: the resolver rejects one whose `--version` is +not version-shaped, so `available:false` there can mean "a different `pi`/`grok`/`omp` is +in front" rather than "nothing is installed". `shell` has no CLI to probe. ⚠️ **Branch on `.success` before reading `.data.sessionId`.** On any failure the field is absent, `jq -r` prints the literal string `null`, and every later call then targets @@ -466,9 +466,9 @@ Quirks that will bite you: session answers with an empty timeline rather than a 404. - ⚠️ **`active-tools` proves presence, never absence.** It is fed by the BashToolParser, which reads Claude's rendered `● Bash(…)` lines, and `_processExpensiveParsers` - returns early for every external CLI mode (`session.ts:2261`), so it is permanently - `[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`. ⚠️ **`shell` is NOT one of those** - (`isExternalCliMode`, `session.ts:174-183`, lists only those six), so the parser does + returns early for every external CLI mode (`session.ts:~2225`), so it is permanently + `[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`. ⚠️ **`shell` is NOT one of those** + (`isExternalCliMode`, `session.ts:176-187`, lists only those seven), so the parser does run on a shell worker, and `TEXT_COMMAND_PATTERN` (`bash-tool-parser.ts:89`) matches bare `tail|cat|head|less|grep|watch|multitail ` lines with no `● Bash(` wrapper: a shell worker running `cat build.log` really does populate this. In practice it stays diff --git a/skills/codeman/reference/messaging.md b/skills/codeman/reference/messaging.md index 414ef3d2..24101c88 100644 --- a/skills/codeman/reference/messaging.md +++ b/skills/codeman/reference/messaging.md @@ -56,7 +56,7 @@ own head: the worker enforcing the cap is the one who has to be told about it. | synchronize on end of turn | HTTP `wait until=stop` (fires for message-initiated turns too, verified live) | | liveness / death check | HTTP `wait?until=exit` | | interrupt a running turn (break-glass) | HTTP input, a bare `\x1b` with no `\r` | -| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`) | HTTP only (no other CLI has messaging) | +| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`) | HTTP only (no other CLI has messaging) | | delete | HTTP, via SKILL.md's `delete_session` guard | ## Availability: probe, never assume @@ -347,7 +347,7 @@ Without a break-glass, a pair with a bad brief is a token bonfire with no off sw ### Mixed fleets: the pairing matrix -Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`, `grok`, `deepseek`) cannot be peers +Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`, `grok`, `deepseek`, `omp`) cannot be peers at all; no other CLI has this feature. Their tasks route over HTTP, and you never mention messaging in their briefs. The claude half of the fleet can use messaging among itself, subject to the namespace rule: **messaging works between two sessions that share one diff --git a/skills/codeman/reference/recipes.md b/skills/codeman/reference/recipes.md index d5e7f4df..58971ead 100644 --- a/skills/codeman/reference/recipes.md +++ b/skills/codeman/reference/recipes.md @@ -188,7 +188,7 @@ for _ in $(seq 1 10); do done printf '%s\n' "$TXT" # (.data is {text,timestamp}; text is also "" before the first completed turn and -# always "" for shell/opencode/gemini/antigravity/pi/grok, which have no transcript, use +# always "" for shell/opencode/gemini/antigravity/pi/grok/omp, which have no transcript, use # the terminal tail there, and here only to diagnose an unsubmitted prompt.) # 6. clean up: exact id, own list only, through the fail-closed preamble helper diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md index f3219cd8..fa09d565 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -357,7 +357,7 @@ recovered by submitting it with `{"input":"\r"}`. only when the workspace actually has them, see [§5.1](#51-where-to-spawn)) **and for `deepseek`** — the one external CLI that reports its own lifecycle, so its `stop` is a real end-of-turn signal rather than a guess. On -`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`, requesting them explicitly is a +`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`omp`, requesting them explicitly is a 400, and lifecycle transitions there are coarse (a short shell command may emit **no** `idle` transition at all, verified live), so synchronize those with markers. @@ -399,7 +399,7 @@ from the transcript file, which is flushed slightly *after* the `stop` hook fire single read taken the instant send-and-wait returns comes back `""` even though the turn finished (verified live: empty on the first call, full text seconds later). `text` is also `""` before the worker's first completed turn, and always `""` for modes with -no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`; the first four +no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp`; the first four verified live, pi from the same source path), which is why the loop above is bounded rather than open-ended. A dsh worker lags too, for its own reason: the harness finalizes the assistant message just after it reports `idle`. Fall back to the terminal buffer @@ -485,7 +485,7 @@ turn), and both better than diffing terminal samples: ``` ⚠️ `active-tools` is parsed out of Claude's own output format, so it is **empty for -`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`** (those parsers are skipped wholesale) and +`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`** (those parsers are skipped wholesale) and in practice empty for `shell`. Source-verified, not measured live. Only if neither helps: sample `terminal?tail=` twice a few seconds apart. A changing diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index f516ebb2..c9b0838d 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -3097,9 +3097,13 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-pi, .btn-toolbar.btn-run-gear.mode-pi) { background: linear-gradient(135deg, #be185d, #db2777); border-color: #9d174d; + color: #ffffff; +} + html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-omp, .btn-toolbar.btn-run-gear.mode-omp) { background: linear-gradient(135deg, #4f46e5, #6366f1); - border-color: #4338ca; color: #ffffff; + border-color: #4338ca; + color: #ffffff; } html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-grok, .btn-toolbar.btn-run-gear.mode-grok) { diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 1372d70a..b78ce074 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -400,6 +400,9 @@ Object.assign(CodemanApp.prototype, { if (mode === 'antigravity') { return await this.runAntigravity(); } + if (mode === 'omp') { + return await this.runOmp(); + } if (mode === 'pi') { return await this.runPi(); } @@ -409,9 +412,6 @@ Object.assign(CodemanApp.prototype, { if (mode === 'deepseek') { return await this.runDeepSeek(); } - if (mode === 'omp') { - return await this.runOmp(); - } if (mode === 'shell') { return await this.runShell(); } @@ -709,6 +709,9 @@ Object.assign(CodemanApp.prototype, { if (runBtn) { runBtn.className = `btn-toolbar btn-run mode-${mode}`; } + if (gearBtn) { + gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`; + } if (label) { label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'pi' ? 'Run PI' : mode === 'grok' ? 'Run GK' : mode === 'deepseek' ? 'Run DS' : mode === 'omp' ? 'Run OMP' : mode === 'shell' ? 'Run SH' : 'Run'; } @@ -1383,24 +1386,17 @@ Object.assign(CodemanApp.prototype, { const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Pi session in ${caseName}...`); - async runOmp() { - const caseName = document.getElementById('quickStartCase').value || 'testcase'; - // Remote/docker cases run omp on the OTHER side — skip the local status probe - // and the local-only config below (quick-start rejects them for remote cases). - const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location; - const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; - - const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting OMP session in ${caseName}...`); this.terminal.focus(); + this.terminal.focus(); try { if (!isRemote) { const statusRes = await fetch('/api/pi/status'); - const statusRes = await fetch('/api/omp/status'); const status = (await statusRes.json()).data; + const status = (await statusRes.json()).data; if (!status.available) { this._reportSessionLaunchError( ownsLaunchTerminal, 'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent' - 'OMP CLI not found. Install with: curl -fsSL https://omp.sh/install | sh' ); + ); return; } } @@ -1418,6 +1414,47 @@ Object.assign(CodemanApp.prototype, { }); const data = await res.json(); if (!data.success) throw new Error(data.error || 'Failed to start Pi'); + await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session); + + if (data.data.sessionId) { + await this.selectSession(data.data.sessionId); + } + + this.terminal.focus(); + } catch (err) { + this._reportSessionLaunchError(ownsLaunchTerminal, err.message); + } + }, + + async runOmp() { + const caseName = document.getElementById('quickStartCase').value || 'testcase'; + // Remote/docker cases run omp on the OTHER side — skip the local status probe + // and the local-only config below (quick-start rejects them for remote cases). + const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location; + const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; + + const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting OMP session in ${caseName}...`); + this.terminal.focus(); + + try { + if (!isRemote) { + const statusRes = await fetch('/api/omp/status'); + const status = (await statusRes.json()).data; + if (!status.available) { + this._reportSessionLaunchError( + ownsLaunchTerminal, + 'OMP CLI not found. Install with: curl -fsSL https://omp.sh/install | sh' + ); + return; + } + } + + const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage()); + const res = await fetch('/api/quick-start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + caseName, mode: 'omp', sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`, ...(isRemote ? {} : { @@ -1426,7 +1463,8 @@ Object.assign(CodemanApp.prototype, { }) }); const data = await res.json(); - if (!data.success) throw new Error(data.error || 'Failed to start OMP'); await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session); + if (!data.success) throw new Error(data.error || 'Failed to start OMP'); + await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session); if (data.data.sessionId) { await this.selectSession(data.data.sessionId); @@ -1644,6 +1682,7 @@ Object.assign(CodemanApp.prototype, { const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek' || session.mode === 'omp'; this.switchOptionsTab(isAltMode ? 'summary' : 'respawn'); + // Update respawn status display and buttons const respawnStatus = document.getElementById('sessionRespawnStatus'); const enableBtn = document.getElementById('modalEnableRespawnBtn'); const stopBtn = document.getElementById('modalStopRespawnBtn'); @@ -1673,6 +1712,7 @@ Object.assign(CodemanApp.prototype, { const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek' || session.mode === 'omp'; const claudeOnlyEls = document.querySelectorAll('[data-claude-only]'); claudeOnlyEls.forEach(el => { el.style.display = isExternalCli ? 'none' : ''; }); + // Reset duration presets to default (unlimited) this.selectDurationPreset(''); diff --git a/test/render-index-html.test.ts b/test/render-index-html.test.ts index 21c5f8a3..1d103508 100644 --- a/test/render-index-html.test.ts +++ b/test/render-index-html.test.ts @@ -20,6 +20,7 @@ import { isAntigravityAvailable } from '../src/utils/antigravity-cli-resolver.js import { isPiAvailable } from '../src/utils/pi-cli-resolver.js'; import { isGrokAvailable } from '../src/utils/grok-cli-resolver.js'; import { isDeepSeekAvailable, isDeepSeekRunnable } from '../src/utils/deepseek-cli-resolver.js'; +import { isOmpAvailable } from '../src/utils/omp-cli-resolver.js'; import { isCloudflaredAvailable } from '../src/utils/cloudflared-resolver.js'; import { isGitAvailable } from '../src/git-clone.js'; @@ -66,6 +67,10 @@ vi.mock('../src/utils/deepseek-cli-resolver.js', () => ({ listDeepSeekProfiles: vi.fn(() => []), resolveDefaultDeepSeekProfile: vi.fn(() => null), })); +vi.mock('../src/utils/omp-cli-resolver.js', () => ({ + isOmpAvailable: vi.fn(() => false), + resolveOmpDir: vi.fn(() => null), +})); vi.mock('../src/utils/cloudflared-resolver.js', () => ({ isCloudflaredAvailable: vi.fn(() => false), resolveCloudflaredPath: vi.fn(() => null), @@ -156,6 +161,9 @@ describe('WebServer.renderIndexHtml', () => { vi.mocked(isAntigravityAvailable).mockReturnValue(false); vi.mocked(isPiAvailable).mockReturnValue(true); vi.mocked(isGrokAvailable).mockReturnValue(false); + vi.mocked(isDeepSeekAvailable).mockReturnValue(false); + vi.mocked(isDeepSeekRunnable).mockReturnValue(false); + vi.mocked(isOmpAvailable).mockReturnValue(true); vi.mocked(isCloudflaredAvailable).mockReturnValue(true); vi.mocked(isGitAvailable).mockReturnValue(true); const { server } = makeServer({}); @@ -173,6 +181,7 @@ describe('WebServer.renderIndexHtml', () => { grok: false, deepseek: false, deepseekBinary: false, + omp: true, cloudflared: true, git: true, }); @@ -191,6 +200,7 @@ describe('WebServer.renderIndexHtml', () => { isGrokAvailable, isDeepSeekAvailable, isDeepSeekRunnable, + isOmpAvailable, isCloudflaredAvailable, isGitAvailable, ]) { From e82380e14a998e9a7223241b9645a347d2d3fbd6 Mon Sep 17 00:00:00 2001 From: timkjr Date: Wed, 19 Aug 2026 13:08:47 -0500 Subject: [PATCH 03/19] fix(ui): close unclosed CSS blocks that killed the stylesheet tail The rebase hand-repair dropped the closing brace of .welcome-btn-pi:hover and .btn-toolbar.btn-run.mode-pi:hover before the inserted OMP rules. The browser CSS parser drops every rule after an unclosed block, so the deployed UI rendered as unstyled text bars (only ~456 of ~2583 rules applied). Verified clean via esbuild --minify (no css-syntax-error) and rebuilt dist. --- src/web/public/styles.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 000f13d6..b4e2eac7 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -3877,6 +3877,8 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { box-shadow: 0 4px 20px rgba(244, 114, 182, 0.3), 0 0 40px rgba(190, 24, 93, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.08); border-color: rgba(249, 168, 212, 0.5); color: #fff1f7; + transform: translateY(-1px); +} /* OMP: indigo identity, matching .btn-toolbar.btn-run.mode-omp and .run-mode-dot.omp so the welcome action reads as the same backend. */ .welcome-btn-omp { @@ -5000,6 +5002,7 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { box-shadow: 0 0 12px rgba(244, 114, 182, 0.35), 0 2px 8px rgba(190, 24, 93, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.08); border-color: rgba(249, 168, 212, 0.6); color: #fff1f7; +} /* OMP mode colors */ .btn-toolbar.btn-run.mode-omp, .btn-toolbar.btn-run-gear.mode-omp { From 9841f4ffb93c97fe8398569342854496d857299c Mon Sep 17 00:00:00 2001 From: timkjr Date: Thu, 20 Aug 2026 20:54:53 -0500 Subject: [PATCH 04/19] refactor(omp): align omp resolver + doctor with upstream shared CLI resolver - omp-cli-resolver.ts already uses createCliExecutableResolver; add dedicated test/omp-cli-resolver.test.ts mirroring pi's (version-probe accept/reject, negative-cache backoff, VITEST hermeticity gate) - dependency-registry omp entry now requires OMP_VERSION_REGEX match like pi, so codeman doctor and the run-mode resolver agree on what counts as installed - system-routes /api/omp/status surfaces version --- src/config/dependency-registry.ts | 18 +++- test/omp-cli-resolver.test.ts | 132 ++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 test/omp-cli-resolver.test.ts diff --git a/src/config/dependency-registry.ts b/src/config/dependency-registry.ts index 84a8c5ab..ecd69c8d 100644 --- a/src/config/dependency-registry.ts +++ b/src/config/dependency-registry.ts @@ -10,6 +10,7 @@ import { PI_VERSION_REGEX } from '../utils/pi-cli-resolver.js'; import { GROK_VERSION_REGEX } from '../utils/grok-cli-resolver.js'; import { DEEPSEEK_VERSION_REGEX } from '../utils/deepseek-cli-resolver.js'; +import { OMP_VERSION_REGEX } from '../utils/omp-cli-resolver.js'; export type ProbeEnvironment = 'linux' | 'darwin' | 'win32' | 'wsl'; @@ -196,7 +197,22 @@ export const DEPENDENCY_REGISTRY: ToolDependency[] = [ category: 'core', required: false, usedBy: ['OMP sessions'], - resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['omp'], versionArg: '--version' } }], + // Same version-match discipline as pi: `omp` is a short generic name, so a + // `which omp` hit alone is not the coding agent. Both sides share + // OMP_VERSION_REGEX, so the doctor and the run mode cannot drift into telling + // the user opposite things about the same binary. + resolvers: [ + { + match: ALL, + resolver: { + kind: 'path', + bins: ['omp'], + versionArg: '--version', + versionRegex: OMP_VERSION_REGEX, + requireVersionMatch: true, + }, + }, + ], installHint: { linux: 'curl -fsSL https://omp.sh/install | sh', darwin: 'brew install can1357/tap/omp' }, }, { diff --git a/test/omp-cli-resolver.test.ts b/test/omp-cli-resolver.test.ts new file mode 100644 index 00000000..86576805 --- /dev/null +++ b/test/omp-cli-resolver.test.ts @@ -0,0 +1,132 @@ +/** + * @fileoverview Tests for the OMP CLI resolver wrapper. + * + * OMP is a resolver with a version probe: `omp` is a short binary name, so a + * resolved path is only accepted once `omp --version` prints an `omp/` + * string (e.g. `omp/17.4.0`). The probe EXECUTES the candidate, which is + * exactly why it must never run under vitest — the hermeticity test below pins + * that gate with a real executable fixture that would make the test fail + * loudly if the gate were deleted again. + */ +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createOmpResolverForTest } from '../src/utils/omp-cli-resolver.js'; +import { + cliResolveRetryDelayMs, + createProductionCliResolverHost, + type CliResolverHost, +} from '../src/utils/cli-executable-resolver.js'; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +function createHost( + options: { + processPathResult?: string | null; + loginShellResults?: Array; + existingPaths?: string[]; + } = {} +): CliResolverHost { + const loginShellResults = [...(options.loginShellResults ?? [])]; + const existingPaths = new Set(options.existingPaths ?? []); + return { + processPath: '/service/bin', + shellPath: '/bin/zsh', + shellArgs: ['-l'], + findOnProcessPath: () => options.processPathResult ?? null, + findInLoginShell: () => loginShellResults.shift() ?? null, + exists: (path) => existingPaths.has(path), + }; +} + +describe('OMP CLI resolver', () => { + it('accepts a candidate the version probe verifies and carries the version as metadata', () => { + const binaryPath = '/service/bin/omp'; + const probe = vi.fn(() => '17.4.0'); + const resolver = createOmpResolverForTest( + createHost({ processPathResult: binaryPath, existingPaths: [binaryPath] }), + probe + ); + + expect(resolver.resolve()).toMatchObject({ + binaryPath, + directory: '/service/bin', + source: 'process-path', + metadata: '17.4.0', + }); + expect(probe).toHaveBeenCalledWith(binaryPath); + }); + + it('rejects a candidate the probe refuses and falls through to a later one', () => { + // An unrelated `omp` on the service PATH (probe returns null) must not mask + // the real coding agent found by the login shell. + const impostor = '/service/bin/omp'; + const genuine = '/login-shell/bin/omp'; + const probe = vi.fn((binPath: string) => (binPath === genuine ? '17.4.0' : null)); + const resolver = createOmpResolverForTest( + createHost({ + processPathResult: impostor, + loginShellResults: [genuine], + existingPaths: [impostor, genuine], + }), + probe + ); + + expect(resolver.resolve()).toMatchObject({ binaryPath: genuine, source: 'login-shell', metadata: '17.4.0' }); + }); + + it('negative-caches a miss and retries only after the backoff elapses', () => { + const binaryPath = '/late/bin/omp'; + let now = 0; + const probe = vi.fn(() => '17.4.0'); + const resolver = createOmpResolverForTest( + createHost({ loginShellResults: [null, binaryPath], existingPaths: [binaryPath] }), + probe, + () => now + ); + + expect(resolver.resolve()).toBeNull(); + expect(resolver.resolve()).toBeNull(); // within the backoff: no re-run + expect(probe).not.toHaveBeenCalled(); + now = cliResolveRetryDelayMs(1); + expect(resolver.resolve()?.metadata).toBe('17.4.0'); + expect(resolver.resolve()?.binaryPath).toBe(binaryPath); + }); + + it('never executes an omp candidate under vitest (the ambient probe is VITEST-gated)', () => { + // A REAL executable fixture that prints a valid version. If the guard in + // probeOmpVersion is ever removed again, the probe runs this script, the + // resolution SUCCEEDS, and this test fails — pinning hermeticity by + // behavior rather than by source text. (The suites must never execute + // whatever `omp` binary the machine running them happens to carry.) + const root = mkdtempSync(join(tmpdir(), 'codeman-omp-vitest-gate-')); + temporaryDirectories.push(root); + const binaryPath = join(root, 'omp'); + writeFileSync(binaryPath, '#!/bin/sh\necho omp/0.99.0\n'); + chmodSync(binaryPath, 0o755); + const hostOptions = { + processPath: root, + shellPath: '/bin/bash', + shellArgs: ['-i', '-l'] as string[], + runCommand: () => '', + isExecutableFile: (path: string) => path === binaryPath, + }; + + // Default (ambient) probe: the candidate is found but never executed, so + // the VITEST gate reports it unusable and resolution misses. + const gated = createOmpResolverForTest(createProductionCliResolverHost(hostOptions)); + expect(gated.resolve()).toBeNull(); + + // Control: identical setup with an injected probe resolves, proving the + // null above comes from the gate, not from the fixture or the host. + const control = createOmpResolverForTest(createProductionCliResolverHost(hostOptions), () => '0.99.0'); + expect(control.resolve()).toMatchObject({ binaryPath, metadata: '0.99.0' }); + }); +}); From 7ec48adcc8d95135432a346e6ec6f52f105b2f46 Mon Sep 17 00:00:00 2001 From: timkjr Date: Wed, 26 Aug 2026 20:19:24 -0500 Subject: [PATCH 05/19] fix(omp): keep external-CLI mode enumerations complete in skill docs Two prose lists in skills/codeman/ named some but not all external CLI modes after the omp-mode rebase, which is exactly the drift test/agent-skill-mode-lists.test.ts exists to catch: SKILL.md's no-hook-signals list was missing omp, and endpoints.md's version-probe sentence named pi/grok/omp as a bare 3-mode run with no matching class. --- skills/codeman/SKILL.md | 2 +- skills/codeman/reference/endpoints.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/skills/codeman/SKILL.md b/skills/codeman/SKILL.md index 59f90473..3e0c336d 100644 --- a/skills/codeman/SKILL.md +++ b/skills/codeman/SKILL.md @@ -422,7 +422,7 @@ Harness TUI reports `idle`/`working`/`blocked` to Codeman over the supervisor co implements, so dsh is the one external CLI with definitive `stop`/`blocked` signals instead of guessed-from-silence ones — and it writes a structured transcript, which is what `last-response` reads for it. `shell`, `opencode`, `codex`, `gemini`, `antigravity`, -`pi` and `grok` have neither and still need markers ([§5.5](reference/verbs.md#55-markers-for-hook-less-workers)). +`pi`, `grok` and `omp` have neither and still need markers ([§5.5](reference/verbs.md#55-markers-for-hook-less-workers)). Three things to know before you spawn one: diff --git a/skills/codeman/reference/endpoints.md b/skills/codeman/reference/endpoints.md index d02d42a2..c9ab7d43 100644 --- a/skills/codeman/reference/endpoints.md +++ b/skills/codeman/reference/endpoints.md @@ -351,8 +351,8 @@ the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`, Pi's, grok's and OMP's also carry `.data.version`, because `pi` is a short generic name, `grok` is a name with npm squatters, and `omp` is a similarly short name, so an unrelated binary on `$PATH` can shadow any of them: the resolver rejects one whose `--version` is -not version-shaped, so `available:false` there can mean "a different `pi`/`grok`/`omp` is -in front" rather than "nothing is installed". `shell` has no CLI to probe. +not version-shaped, so `available:false` there can mean "a different program of the same +name is in front" rather than "nothing is installed". `shell` has no CLI to probe. ⚠️ **Branch on `.success` before reading `.data.sessionId`.** On any failure the field is absent, `jq -r` prints the literal string `null`, and every later call then targets From 3e1a0e679f0ada8a0cf58437c89460f6a0898dba Mon Sep 17 00:00:00 2001 From: timkjr Date: Wed, 26 Aug 2026 21:09:01 -0500 Subject: [PATCH 06/19] fix(omp): resume by mode, not silently as claude, and support --continue resumeHistorySession() never sent mode when recreating a session from a history/session-manager row, so the server default silently opened a plain Claude session for every non-claude row -- reproduced live: OMP rows spawned Claude sessions on click. Thread the row's mode through every call site (welcome list, session manager, mobile overview) and only send the Claude-specific resumeSessionId for claude rows. Codeman has no live PTY-reattach outside server boot, and it's moot for OMP anyway (exiting it kills the pane's only process), so route the non-claude relaunch through each CLI's own continue-most-recent flag instead of a context-free fresh start. OMP never got one: buildOmpCommand only implemented --model/--resume despite omp --help documenting -c/--continue. Added continueSession to OmpConfig end-to-end (type, schema, builder) mirroring the existing opencode/pi/grok/deepseek fields, and wired resumeHistorySession to use it. Verified live: told a real omp session a secret, exited it, closed the tab without killing tmux, relaunched with --continue in the same directory, and had it recall the secret. --- src/tmux-manager.ts | 11 ++++++++--- src/types/session.ts | 2 ++ src/web/public/mobile-overview.js | 2 +- src/web/public/panels-ui.js | 2 +- src/web/public/session-ui.js | 2 +- src/web/public/terminal-ui.js | 33 +++++++++++++++++++++++++++---- src/web/schemas.ts | 1 + test/command-palette-ui.test.ts | 2 +- test/omp-mode.test.ts | 20 +++++++++++++++++++ 9 files changed, 64 insertions(+), 11 deletions(-) diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index e81ee04b..2bd31f96 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -914,9 +914,14 @@ function buildOmpCommand(config?: OmpConfig): string { if (safeModel) parts.push('--model', safeModel); } - if (config?.resumeSessionId) { - const safeId = /^[a-zA-Z0-9._-]+$/.test(config.resumeSessionId) ? config.resumeSessionId : undefined; - if (safeId) parts.push('--resume', safeId); + // --resume and --continue conflict; a valid explicit session id wins, + // mirroring the sibling builders (grok/pi/opencode). + const safeId = + config?.resumeSessionId && /^[a-zA-Z0-9._-]+$/.test(config.resumeSessionId) ? config.resumeSessionId : undefined; + if (safeId) { + parts.push('--resume', safeId); + } else if (config?.continueSession) { + parts.push('--continue'); } return parts.join(' '); diff --git a/src/types/session.ts b/src/types/session.ts index 5c26dc6a..3c34b49d 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -350,6 +350,8 @@ export interface OmpConfig { model?: string; /** Resume a previous conversation (passed via --resume). */ resumeSessionId?: string; + /** Continue the most recent session in this directory (passed via --continue). */ + continueSession?: boolean; } /** diff --git a/src/web/public/mobile-overview.js b/src/web/public/mobile-overview.js index 34ce8963..df647ae9 100644 --- a/src/web/public/mobile-overview.js +++ b/src/web/public/mobile-overview.js @@ -389,7 +389,7 @@ Object.assign(CodemanApp.prototype, { async resumeMobileOverviewSession(sessionId) { const row = (this._mobileOverviewPastRows || []).find((r) => r.id === sessionId); if (!row || !row.workingDir) return; - await this.resumeHistorySession(row.claudeSessionId || row.id, row.workingDir, row.name || undefined); + await this.resumeHistorySession(row.claudeSessionId || row.id, row.workingDir, row.name || undefined, row.mode); }, // ═══════════════════════════════════════════════════════════════ diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index f4dcc16f..66197c27 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -670,7 +670,7 @@ Object.assign(CodemanApp.prototype, { } else if (record.workingDir) { // History rows are keyed by the Claude conversation UUID; resumed // sessions carry theirs separately as claudeSessionId. - void this.resumeHistorySession(s.claudeSessionId || s.sessionId, record.workingDir); + void this.resumeHistorySession(s.claudeSessionId || s.sessionId, record.workingDir, undefined, s.mode); } }, }); diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index b78ce074..b4d18479 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -692,7 +692,7 @@ Object.assign(CodemanApp.prototype, { btn.append(...parts); btn.addEventListener('click', (e) => { e.stopPropagation(); - this.resumeHistorySession(s.sessionId, s.workingDir, s.name); + this.resumeHistorySession(s.sessionId, s.workingDir, s.name, s.mode); }); container.appendChild(btn); } diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 84451307..a3feb0fc 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -2193,7 +2193,7 @@ Object.assign(CodemanApp.prototype, { if (isLive && this.sessions.has(s.sessionId)) { this.selectSession(s.sessionId); } else { - this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name); + this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name, s.mode); } }) ); @@ -2436,7 +2436,7 @@ Object.assign(CodemanApp.prototype, { } else { // Resume by the Claude conversation UUID when present (resumed sessions // carry theirs separately from their Codeman id). - this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name); + this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name, s.mode); } this.closeSessionManager?.(); closeMenu(); @@ -2904,7 +2904,7 @@ Object.assign(CodemanApp.prototype, { return `w${startNumber}-${dirName}`; }, - async resumeHistorySession(sessionId, workingDir, existingName) { + async resumeHistorySession(sessionId, workingDir, existingName, mode) { // Close the run mode menu if open document.getElementById('runModeMenu')?.classList.remove('active'); // Close folder history modal if open @@ -2925,13 +2925,38 @@ Object.assign(CodemanApp.prototype, { const globalSettings = this.loadAppSettingsFromStorage(); const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), globalSettings); const effort = this.getEffortSetting(globalSettings); + // `resumeSessionId` is a Claude conversation UUID (server reads it from + // ~/.claude/projects); an external-CLI row has no such thing, so sending + // it there gets silently ignored while the OMITTED `mode` field defaults + // the create to plain claude — reproducing whatever conversation THAT + // uuid happens to collide with instead of the row's own backend. Row mode + // wins here. Codeman has no cross-restart PTY-reattach outside server + // boot, so "resume" for a non-claude row means relaunching the CLI's own + // continue-most-recent flag (opencode/pi/grok/omp --continue, deepseek + // resumeSession) in the same directory — real conversation continuity, + // just not the literal old process. + const effectiveMode = mode || 'claude'; + const modeConfigKey = { + opencode: 'openCodeConfig', + pi: 'piConfig', + grok: 'grokConfig', + omp: 'ompConfig', + }[effectiveMode]; + const modeConfig = + modeConfigKey + ? { [modeConfigKey]: { continueSession: true } } + : effectiveMode === 'deepseek' + ? { deepSeekConfig: { resumeSession: true } } + : {}; const createRes = await fetch('/api/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ workingDir, name, - resumeSessionId: sessionId, + mode: effectiveMode, + ...(effectiveMode === 'claude' ? { resumeSessionId: sessionId } : {}), + ...modeConfig, ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), ...(effort ? { effort } : {}), }), diff --git a/src/web/schemas.ts b/src/web/schemas.ts index e7401783..c7c015ef 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -361,6 +361,7 @@ const OmpConfigSchema = z .max(100) .regex(/^[a-zA-Z0-9._-]+$/) .optional(), + continueSession: z.boolean().optional(), }) .optional(); diff --git a/test/command-palette-ui.test.ts b/test/command-palette-ui.test.ts index de8df0a2..263d9b6a 100644 --- a/test/command-palette-ui.test.ts +++ b/test/command-palette-ui.test.ts @@ -401,7 +401,7 @@ describe('Session Manager unified list', () => { const [historyRecord, , historyOptions] = app._buildHistoryItem.mock.calls[1]; expect(historyRecord).toMatchObject({ sessionId: 'conv-uuid-1', sizeBytes: 2048, firstPrompt: 'old prompt' }); historyOptions.onActivate(); - expect(app.resumeHistorySession).toHaveBeenCalledWith('conv-uuid-1', '/repo/old'); + expect(app.resumeHistorySession).toHaveBeenCalledWith('conv-uuid-1', '/repo/old', undefined, undefined); }); it('surfaces an error message instead of an empty list when the endpoint fails', async () => { diff --git a/test/omp-mode.test.ts b/test/omp-mode.test.ts index c7d3ff39..941b8cd0 100644 --- a/test/omp-mode.test.ts +++ b/test/omp-mode.test.ts @@ -85,6 +85,26 @@ describe('OMP spawn command', () => { ).toBe('omp'); }); + it('continues the most recent session when no explicit resume id is given', () => { + expect( + buildSpawnCommand({ + mode: 'omp', + sessionId: 'abc12345', + ompConfig: { continueSession: true }, + }) + ).toBe('omp --continue'); + }); + + it('prefers an explicit --resume id over --continue', () => { + expect( + buildSpawnCommand({ + mode: 'omp', + sessionId: 'abc12345', + ompConfig: { resumeSessionId: 'session-99', continueSession: true }, + }) + ).toBe('omp --resume session-99'); + }); + it('drops unsafe model strings from the spawn command', () => { expect( buildSpawnCommand({ From 253599ce9c0d08d27b62ae3df259dd50941cd1c7 Mon Sep 17 00:00:00 2001 From: timkjr Date: Wed, 26 Aug 2026 21:36:32 -0500 Subject: [PATCH 07/19] fix(omp): wire ompConfig into respawnPane and default to --continue there respawnPane() -- the path used when a session's pane died (crash, idle respawn, or the user's own /exit) but the Codeman session object is still tracked -- never had ompConfig wired through at all, in either its options destructure or its inner buildSpawnCommand() call. This is a gap in the original OMP patch, distinct from the resumeHistorySession fix (which only covers a session that has been fully closed and shows up as a history row): reselecting a tab whose CLI process just exited goes through this path instead, and always launched a bare, contextless `omp` no matter what. Beyond the wiring, respawning a dead pane is semantically different from creating a brand-new session: the conversation is still "this session" to the user, so _buildRespawnPaneOptions() now defaults ompConfig to continueSession:true unless the session already carries an explicit resumeSessionId (which still wins in buildOmpCommand). Verified live: told a session a secret, exited OMP so the pane died (session and tmux both left alone), forced the exact dead-pane-respawn path, and the new process replied with the secret -- confirming `omp --continue` fired instead of a blank omp. --- src/session.ts | 8 +++++++- src/tmux-manager.ts | 2 ++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/session.ts b/src/session.ts index 604458e6..bab62b35 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1630,7 +1630,13 @@ export class Session extends EventEmitter { piConfig: this._piConfig, grokConfig: this._grokConfig, deepSeekConfig: this._deepSeekConfig, - ompConfig: this._ompConfig, + // Respawning a dead pane means the CLI process exited (crash, idle + // respawn, or the user's own /exit) but this is still the same + // conversation from the user's perspective — unlike a brand-new + // `createSession` call, defaulting to --continue here is the honest + // behavior. Only when the session has no resume id of its own already + // (an explicit resumeSessionId always wins in buildOmpCommand). + ompConfig: this._ompConfig?.resumeSessionId ? this._ompConfig : { ...this._ompConfig, continueSession: true }, resumeSessionId: this._resumeSessionId, envOverrides: this._envOverrides, effort: this._effort, diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 2bd31f96..182db818 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -2391,6 +2391,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { piConfig, grokConfig, deepSeekConfig, + ompConfig, resumeSessionId, envOverrides, effort, @@ -2422,6 +2423,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { piConfig, grokConfig, deepSeekConfig, + ompConfig, resumeSessionId, effort, sessionName: name, From 4c332c6141e16859d7cb24417ef9e1bc7e09596a Mon Sep 17 00:00:00 2001 From: timkjr Date: Wed, 26 Aug 2026 22:07:16 -0500 Subject: [PATCH 08/19] fix(omp): retire the old row on resume, and let DELETE remove persisted-only sessions Every non-claude "Resume" click creates a brand-new Codeman session (there is no id to reattach to), but the old row was never cleaned up -- click resume on the same conversation a few times and the session list fills up with duplicate rows sharing one name. resumeHistorySession now retires the row it resumed from after the new one starts. That retirement needs DELETE to actually work on a row that was never live in the first place (the normal case for anything showing up in "Resume Conversation"): findSessionOrFail only checks the in-memory live-session map, so DELETE 404s on a persisted-only entry today. Give the route a fallback: when the id isn't live, look it up in persisted state instead and demote/remove it there (respecting the existing pinned-session protection). Verified live against a real persisted-only row via the API, and added route-test coverage for both the success and still-truly-unknown-id cases (which needed a demoteOrRemoveSession mock the route harness didn't have). Also includes an unrelated pre-existing prettier drift fix picked up by npm run format (omp-cli-resolver.ts, antigravity/opencode import wrapping in session-routes.ts). --- src/utils/omp-cli-resolver.ts | 6 +++++- src/web/public/terminal-ui.js | 10 ++++++++++ src/web/routes/session-routes.ts | 27 +++++++++++++++++++++++++-- test/mocks/mock-route-context.ts | 1 + test/routes/session-routes.test.ts | 26 ++++++++++++++++++++++++++ 5 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/utils/omp-cli-resolver.ts b/src/utils/omp-cli-resolver.ts index 5da8e1b9..269b64d2 100644 --- a/src/utils/omp-cli-resolver.ts +++ b/src/utils/omp-cli-resolver.ts @@ -76,7 +76,11 @@ function probeOmpVersion(binPath: string): string | null { type OmpVersionProbe = (binPath: string) => string | null; -function createOmpResolver(host?: CliResolverHost, versionProbe: OmpVersionProbe = probeOmpVersion, now?: () => number) { +function createOmpResolver( + host?: CliResolverHost, + versionProbe: OmpVersionProbe = probeOmpVersion, + now?: () => number +) { return createCliExecutableResolver( { binary: 'omp', diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index a3feb0fc..47182165 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -2969,6 +2969,16 @@ Object.assign(CodemanApp.prototype, { // Start interactive await fetch(`/api/sessions/${newSessionId}/interactive`, { method: 'POST' }); + // Retire the row being resumed: a non-claude "resume" is really a brand + // new Codeman session pointed at the same directory (there is no id to + // reattach to), so without this every resume leaves the old row behind + // as a duplicate — click it 3 times, see the same name 3 times. Claude + // rows are left alone: `sessionId` there is a claudeSessionId, which + // usually has no live/persisted Codeman session of its own to delete. + if (effectiveMode !== 'claude' && sessionId !== newSessionId) { + fetch(`/api/sessions/${sessionId}?killMux=true`, { method: 'DELETE' }).catch(() => {}); + } + this.terminal.writeln(`\x1b[90m Session ${name} ready\x1b[0m`); await this.selectSession(newSessionId); this.terminal.focus(); diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 99498a70..80fef58b 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -1134,8 +1134,31 @@ export function registerSessionRoutes( const query = req.query as { killMux?: string }; const killMux = query.killMux !== 'false'; // Default to true - // Security: owner-scoped lookup 404s foreign/missing sessions uniformly (no existence leak, no cross-user kill). - const session = findSessionOrFail(ctx, id, req); + // A resumed/detached-but-never-live row (e.g. a non-claude "Resume" that + // relaunched into a NEW session and wants to retire the old one it can no + // longer reattach to) has no entry in ctx.sessions at all — only in + // persisted state. Fall back to removing that persisted record directly + // rather than 404ing: the caller means "make this row go away", and a + // stale duplicate row is exactly what's left behind otherwise. Pinned + // sessions keep their existing demote-not-delete protection. + const session = ctx.sessions.get(id); + if (!session) { + const persisted = ctx.store.getSession(id); + if (!persisted || !canAccessOwned(getAuthUser(req), persisted.owner)) { + throw Object.assign(new Error(`Session ${id} not found`), { + statusCode: 404, + body: createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${id} not found`), + }); + } + ctx.store.demoteOrRemoveSession(id); + return {}; + } + if (req && !canAccessOwned(getAuthUser(req), session.owner)) { + throw Object.assign(new Error(`Session ${id} not found`), { + statusCode: 404, + body: createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${id} not found`), + }); + } await ctx.cleanupSession(session.id, killMux, 'user_delete'); return {}; diff --git a/test/mocks/mock-route-context.ts b/test/mocks/mock-route-context.ts index 7dd4222e..8a1bd884 100644 --- a/test/mocks/mock-route-context.ts +++ b/test/mocks/mock-route-context.ts @@ -86,6 +86,7 @@ export function createMockRouteContext(options?: { getSession: vi.fn(), setSession: vi.fn(), removeSession: vi.fn(), + demoteOrRemoveSession: vi.fn(() => 'removed' as const), getSettings: vi.fn(() => ({})), setSettings: vi.fn(), getRalphLoopState: vi.fn(() => ({})), diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts index dbe8efa2..dfdc3505 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -341,6 +341,32 @@ describe('session-routes', () => { const body = JSON.parse(res.body); expect(body.success).toBe(false); }); + + it('removes a persisted-only session (not live) via the state store, without touching cleanupSession', async () => { + vi.mocked(harness.ctx.store.getSession).mockReturnValueOnce({ + id: 'ghost-session', + owner: undefined, + } as never); + const res = await harness.app.inject({ + method: 'DELETE', + url: '/api/sessions/ghost-session', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(harness.ctx.store.demoteOrRemoveSession).toHaveBeenCalledWith('ghost-session'); + expect(harness.ctx.cleanupSession).not.toHaveBeenCalled(); + }); + + it('404s a persisted-only session id the state store does not recognize either', async () => { + vi.mocked(harness.ctx.store.getSession).mockReturnValueOnce(null); + const res = await harness.app.inject({ + method: 'DELETE', + url: '/api/sessions/truly-nonexistent', + }); + expect(res.statusCode).toBe(404); + expect(harness.ctx.store.demoteOrRemoveSession).not.toHaveBeenCalled(); + }); }); // ========== DELETE /api/sessions (delete all) ========== From 54a930c80e23cec00721cdb4b2636d838dd20e89 Mon Sep 17 00:00:00 2001 From: timkjr Date: Wed, 26 Aug 2026 22:49:22 -0500 Subject: [PATCH 09/19] feat(omp): survive a full session kill by reading omp's own transcripts Claude conversations survive "Kill Tmux & Claude" because Codeman reads them back independently from ~/.claude/projects, not from its own session bookkeeping. omp conversations had no equivalent: kill the Codeman session and the conversation vanished from Past Sessions entirely, even though omp itself never forgot it on disk. Adds omp-transcript.ts, a scanner over omp's own ~/.omp/agent/sessions//.jsonl files (the same shape as Claude Code's own transcript scanner, but simpler -- these files are small enough to read whole instead of doing head/tail windows). Each file's own "session" header line carries the real cwd and session id directly, so unlike Claude's mangled-directory-name decoding this never has to guess. Wired into gatherUnifiedInputs() as a second history source alongside the Claude scan, and HistoryInput/ mergeUnifiedSessions() now carry an optional `mode` so a non-claude history-only row still gets a real mode badge. Also fixes the ambiguity behind the "continue picks the wrong conversation" report from this session's testing: omp mints its OWN session uuid, unrelated to Codeman's, so a live/persisted row and its own history-scan row would otherwise show up as two separate entries for the same conversation the moment the id gets resolved. Reuses the existing claudeSessionId alias field (mergeUnifiedSessions' fold-into- owner mechanism) to point at the resolved omp id, threading it through every place `_claudeSessionId` gets (re)computed -- the constructor, _resolvedOmpRespawnConfig, and a new _maybeCaptureOmpSessionId() that opportunistically resolves it the first time a brand-new omp session (one that has never gone through a respawn) goes idle. Also closes a THIRD instance of the "ompConfig never got wired in here" gap this session kept finding: restoreMuxSessions() in server.ts restores every sibling CLI's config from persisted state on boot except omp's, so a boot-recovered omp session always lost its resolved resume id and fell back to guessing again. Verified live end-to-end: told a session a secret, killed it fully (Kill Tmux equivalent, killMux=true -- the Codeman session AND its tmux pane both gone), and the conversation still showed up in the unified list as a history-sourced row with the real first prompt as its title and an omp mode badge, keyed by omp's own session id. Known remaining gap, not fixed here: the claudeSessionId alias doesn't yet resolve reliably on every boot-recovery path for a session that was never respawned while alive (e.g. a plain re-attach to a pane that was never dead) -- worth a follow-up, but doesn't affect the two things that matter most: the conversation surviving a kill, and continuation correctness once an id has been resolved (which happens on the very next respawn either way). --- src/omp-transcript.ts | 172 ++++++++++++++++++++++++ src/services/unified-session-service.ts | 11 ++ src/session.ts | 87 +++++++++++- src/utils/omp-session-resolver.ts | 74 ++++++++++ src/web/routes/session-routes.ts | 51 ++++++- src/web/server.ts | 1 + 6 files changed, 387 insertions(+), 9 deletions(-) create mode 100644 src/omp-transcript.ts create mode 100644 src/utils/omp-session-resolver.ts diff --git a/src/omp-transcript.ts b/src/omp-transcript.ts new file mode 100644 index 00000000..922a61d0 --- /dev/null +++ b/src/omp-transcript.ts @@ -0,0 +1,172 @@ +/** + * @fileoverview Scan `~/.omp/agent/sessions/*/*.jsonl` for Past Sessions rows, + * the omp analog of what `scanProjectDir()` (session-routes.ts) does for + * Claude's own `~/.claude/projects` transcripts. + * + * Without this, an omp conversation exists ONLY as a Codeman-level live/ + * persisted session record — delete that (a "Kill Tmux" close, or any other + * cleanup) and the conversation vanishes from Past Sessions entirely, even + * though `omp` itself never forgot it. Claude conversations don't have that + * problem because Codeman already reads them back from Claude's own + * transcript files independent of its own session bookkeeping; this gives + * omp conversations the same treatment. + * + * Each omp session file's SECOND line is a `{"type":"session","id":..., + * "cwd":...}` header carrying the real (unmangled) working directory and the + * session's own id directly — no need to reverse-engineer the mangled + * directory name the way Claude Code's own scanner has to (see + * `decodeProjectKey()` in session-routes.ts and its "lossy" caveat). Prompt + * text comes from each `{"type":"message","message":{"role":"user",...}}` + * entry, giving a real first-message title instead of a bare case name. + * + * Unlike Claude's transcripts (which can run to tens of MB of tool-call + * output), an omp session file is the conversation only, so this reads each + * file whole rather than doing head/tail windows — bounded by a size cap so + * one unexpectedly huge file can't blow up memory. + * + * @module omp-transcript + */ + +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +function ompSessionsRoot(): string { + return join(homedir(), '.omp', 'agent', 'sessions'); +} + +/** Skip anything absurdly large rather than parsing it whole into memory. */ +const MAX_OMP_SESSION_FILE_BYTES = 2 * 1024 * 1024; + +/** Defensive cap on total files scanned across every directory, mirroring + * the Claude scanner's own instinct not to let one pathological tree stall + * a request — a real omp install has, at most, a few hundred of these. */ +const MAX_OMP_SESSION_FILES = 2000; + +export interface OmpHistorySession { + sessionId: string; + workingDir: string; + sizeBytes: number; + /** ISO timestamp, from the file's own mtime. */ + lastModified: string; + firstPrompt?: string; + lastPrompt?: string; +} + +function extractUserPromptText(message: unknown): string | undefined { + if (!message || typeof message !== 'object') return undefined; + const m = message as { role?: unknown; content?: unknown }; + if (m.role !== 'user' || !Array.isArray(m.content)) return undefined; + const parts: string[] = []; + for (const block of m.content) { + if (block && typeof block === 'object' && (block as { type?: unknown }).type === 'text') { + const text = (block as { text?: unknown }).text; + if (typeof text === 'string') parts.push(text); + } + } + const joined = parts.join(' ').trim(); + return joined || undefined; +} + +/** Parse one omp session `.jsonl` file, or null when it's unreadable, empty, or has no session header. */ +function parseOmpSessionFile(filePath: string): OmpHistorySession | null { + let stat: ReturnType; + try { + stat = statSync(filePath); + } catch { + return null; + } + if (stat.size === 0 || stat.size > MAX_OMP_SESSION_FILE_BYTES) return null; + + let raw: string; + try { + raw = readFileSync(filePath, 'utf-8'); + } catch { + return null; + } + + let sessionId: string | undefined; + let workingDir: string | undefined; + let firstPrompt: string | undefined; + let lastPrompt: string | undefined; + + 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; + if (e.type === 'session' && typeof e.id === 'string' && typeof e.cwd === 'string') { + sessionId = e.id; + workingDir = e.cwd; + } else if (e.type === 'message') { + const prompt = extractUserPromptText(e.message); + if (prompt) { + if (!firstPrompt) firstPrompt = prompt; + lastPrompt = prompt; + } + } + } + + if (!sessionId || !workingDir) return null; + return { + sessionId, + workingDir, + sizeBytes: stat.size, + lastModified: stat.mtime.toISOString(), + firstPrompt, + lastPrompt, + }; +} + +/** + * Scan every omp conversation on disk into Past-Sessions rows. Best-effort + * throughout: a missing `~/.omp` (never installed/used), an unreadable + * directory, or one corrupt file yields fewer rows rather than throwing — + * this feeds the same unified merge the Claude transcript scanner does, and + * one broken source must never blank the whole Past Sessions list. + */ +export function scanOmpSessionsHistory(): OmpHistorySession[] { + const root = ompSessionsRoot(); + let dirEntries: string[]; + try { + dirEntries = readdirSync(root); + } catch { + return []; + } + + const out: OmpHistorySession[] = []; + for (const dirName of dirEntries) { + if (out.length >= MAX_OMP_SESSION_FILES) break; + const dirPath = join(root, dirName); + let dirStat: ReturnType; + try { + dirStat = statSync(dirPath); + } catch { + continue; + } + if (!dirStat.isDirectory()) continue; + + let files: string[]; + try { + files = readdirSync(dirPath); + } catch { + continue; + } + for (const file of files) { + if (out.length >= MAX_OMP_SESSION_FILES) break; + if (!file.endsWith('.jsonl')) continue; + try { + const parsed = parseOmpSessionFile(join(dirPath, file)); + if (parsed) out.push(parsed); + } catch { + // One bad file must not sink the whole scan. + } + } + } + return out; +} diff --git a/src/services/unified-session-service.ts b/src/services/unified-session-service.ts index f83e89c4..209d4a59 100644 --- a/src/services/unified-session-service.ts +++ b/src/services/unified-session-service.ts @@ -99,6 +99,13 @@ export type HistoryInput = { gitBranch?: string; worktreeName?: string; worktreeRepo?: string; + /** + * Set only by a non-claude transcript source (currently omp); the Claude + * scanner never stamps this; the meaningfulness floor below still counts a + * row with a `mode` as real, since that also signals "not claude" — see + * where it's read below for the isReal check this touches. + */ + mode?: string; }; /** Mux process-stat view. */ @@ -175,6 +182,10 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte overwrite(item, 'gitBranch', h.gitBranch); overwrite(item, 'worktreeName', h.worktreeName); overwrite(item, 'worktreeRepo', h.worktreeRepo); + // Claude rows never set this (they're implicitly claude); a non-claude + // transcript source (currently only omp) does, so a history-only row + // still gets a mode badge instead of reading as claude by default. + overwrite(item, 'mode', h.mode); const ms = Date.parse(h.lastModified); if (!Number.isNaN(ms) && item.lastActivityAt === undefined) item.lastActivityAt = ms; } diff --git a/src/session.ts b/src/session.ts index bab62b35..413211c4 100644 --- a/src/session.ts +++ b/src/session.ts @@ -57,6 +57,7 @@ import { type SessionRemote, type SessionDocker, } from './types.js'; +import { findLatestOmpSessionId } from './utils/omp-session-resolver.js'; import { probeDockerCliVersion } from './docker-hosts.js'; import { probeRemoteCliVersion } from './remote-hosts.js'; import type { TerminalMultiplexer, MuxSession } from './mux-interface.js'; @@ -690,7 +691,13 @@ export class Session extends EventEmitter { this._wireActivityAt = config.lastActivityAt || Date.now(); this._wireActivitySettleUntil = config.lastActivityAt ? Date.now() + WIRE_ACTIVITY_SETTLE_MS : 0; // Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one. - this._claudeSessionId = config.resumeSessionId || this.id; + // For omp, `claudeSessionId` doubles as the generic "external transcript id" + // alias key mergeUnifiedSessions() folds a history row into its owning + // session by: omp mints its OWN uuid, unrelated to this Codeman id, so + // without this an omp conversation's Past-Sessions row (keyed by omp's + // id) would never merge with its own live/persisted row (keyed by this + // id) — it would just show up a second time. + this._claudeSessionId = config.resumeSessionId || config.ompConfig?.resumeSessionId || this.id; // Restored from state.json on boot recovery. start() resets _claudeSessionId // to the launch id even when re-attaching to a mux session whose CLI has // moved on (a `/clear` before the restart), so this anchor is what lets the @@ -1633,10 +1640,18 @@ export class Session extends EventEmitter { // Respawning a dead pane means the CLI process exited (crash, idle // respawn, or the user's own /exit) but this is still the same // conversation from the user's perspective — unlike a brand-new - // `createSession` call, defaulting to --continue here is the honest - // behavior. Only when the session has no resume id of its own already - // (an explicit resumeSessionId always wins in buildOmpCommand). - ompConfig: this._ompConfig?.resumeSessionId ? this._ompConfig : { ...this._ompConfig, continueSession: true }, + // `createSession` call, defaulting to continuation here is the honest + // behavior. `--continue` alone is ambiguous the moment ANY other omp + // conversation has touched this directory more recently (ours resumed + // elsewhere, a second Codeman session opened here, ...) since it just + // picks the newest session file — so resolve and PIN the exact id the + // pane that just died was writing to, once. The dead pane's file is + // already fully flushed at this point, so "newest file" here is + // unambiguous by construction; every later respawn then reuses the + // pinned id instead of re-guessing. Only when the session already + // carries an explicit resumeSessionId does this skip straight past it + // (that one always wins in buildOmpCommand regardless). + ompConfig: this._resolvedOmpRespawnConfig(), resumeSessionId: this._resumeSessionId, envOverrides: this._envOverrides, effort: this._effort, @@ -1647,6 +1662,33 @@ export class Session extends EventEmitter { }; } + /** + * OMP-only: resolve and PIN the exact conversation to continue when + * respawning a dead pane, so every later respawn reuses the same id + * instead of re-resolving (and re-risking picking up a DIFFERENT + * conversation that happened to touch this directory more recently). See + * the comment at the call site in {@link _buildRespawnPaneOptions} for why + * "newest file on disk" is safe here specifically. Non-omp modes and a + * session that already carries an explicit id pass through untouched. + */ + private _resolvedOmpRespawnConfig(): OmpConfig | undefined { + if (this.mode !== 'omp') return this._ompConfig; + if (this._ompConfig?.resumeSessionId) return this._ompConfig; + const resolvedId = findLatestOmpSessionId(this.workingDir); + if (resolvedId) { + this._ompConfig = { ...this._ompConfig, resumeSessionId: resolvedId }; + // Alias omp's own session uuid to this Codeman id — see the + // constructor's claudeSessionId comment for why this field is the + // (generically-named) mechanism that folds a Past-Sessions row back + // into its live/persisted session instead of duplicating it. + this._claudeSessionId = resolvedId; + return this._ompConfig; + } + // Nothing on disk yet (the dying process never got far enough to write a + // session file) — fall back to the CLI's own "most recent" heuristic. + return { ...this._ompConfig, continueSession: true }; + } + /** * Remember whether the CLI currently wants to be told about mouse clicks. * @@ -1909,8 +1951,13 @@ export class Session extends EventEmitter { spawnErrLabel: 'mux attachment', }); - // Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one. - this._claudeSessionId = this._resumeSessionId || this.id; + // Set claudeSessionId — when resuming, the Claude conversation ID is the + // resumed one. `_resolvedOmpRespawnConfig()` (called above while building + // respawnPaneOptions) may have JUST aliased this to omp's own session + // uuid — that already-resolved id must win over the generic + // `this.id` fallback, or this line clobbers it back to the Codeman id + // on every single respawn. + this._claudeSessionId = this._resumeSessionId || this._ompConfig?.resumeSessionId || this.id; // For NEW mux sessions: wait for readiness then clean buffer // For RESTORED mux sessions: don't do anything - client will fetch buffer on tab switch @@ -2319,10 +2366,36 @@ export class Session extends EventEmitter { this._isWorking = false; this._status = 'idle'; this._lastPromptTime = Date.now(); + if (wasWorking) this._maybeCaptureOmpSessionId(); this.emit('idle'); } } + /** + * A brand-new omp session (never yet respawned, so + * {@link _resolvedOmpRespawnConfig} has never run) has no captured + * omp-native session id: `_claudeSessionId` still defaults to this + * session's OWN Codeman id from the constructor. Until something aliases + * it, the omp history scan's row for this exact conversation (keyed by + * omp's own uuid) merges with nothing and shows up a second time. The + * first turn going idle is the first moment omp has definitely written + * its session file, so resolve and alias it here — best-effort, and only + * once (skips once `_claudeSessionId` differs from `this.id`, whether from + * this capture or a resume/respawn that already resolved one). + */ + private _maybeCaptureOmpSessionId(): void { + if (this.mode !== 'omp' || this._claudeSessionId !== this.id) return; + try { + const resolvedId = findLatestOmpSessionId(this.workingDir); + if (resolvedId) { + this._claudeSessionId = resolvedId; + this._ompConfig = { ...this._ompConfig, resumeSessionId: resolvedId }; + } + } catch { + // Best-effort: a failed capture just means the next respawn tries again. + } + } + /** * Process expensive parsers (ANSI strip, Ralph, bash tool, token, CLI info, task descriptions). * Called on a throttled schedule (every EXPENSIVE_PROCESS_INTERVAL_MS) instead of on every diff --git a/src/utils/omp-session-resolver.ts b/src/utils/omp-session-resolver.ts new file mode 100644 index 00000000..37b67a44 --- /dev/null +++ b/src/utils/omp-session-resolver.ts @@ -0,0 +1,74 @@ +/** + * @fileoverview Resolve the real OMP session id for a working directory, so a + * relaunch can pass `--resume ` instead of the ambiguous `--continue`. + * + * `omp` persists each conversation as its own file under + * `~/.omp/agent/sessions//_.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 { readdirSync, statSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +/** A real OMP session file is `_.jsonl`; only the uuid matters here. */ +const OMP_SESSION_FILE_PATTERN = /^.+_([a-zA-Z0-9-]+)\.jsonl$/; + +/** + * Mirrors `omp`'s own directory mangling: every path separator becomes a + * dash. Pure so it's unit-testable without touching the filesystem. + */ +export function mangleOmpWorkingDir(workingDir: string): string { + return workingDir.replace(/\//g, '-'); +} + +/** `~/.omp` — no known env override exists (unlike DSH_HOME); revisit if omp adds one. */ +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; +} diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 80fef58b..577e7391 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -20,12 +20,14 @@ import { type ApiResponse, type SessionColor, type SessionStatus, + type SessionMode, type CodexConfig, type GeminiConfig, type AntigravityConfig, type PiConfig, type GrokConfig, type DeepSeekConfig, + type OmpConfig, } from '../../types.js'; import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode } from '../../session.js'; import { SseEvent } from '../sse-events.js'; @@ -136,6 +138,8 @@ import { toSessionDocker, } from '../../docker-hosts.js'; import { LRUMap } from '../../utils/lru-map.js'; +import { findLatestOmpSessionId } from '../../utils/omp-session-resolver.js'; +import { scanOmpSessionsHistory } from '../../omp-transcript.js'; import { getLastTranscriptResponse, isExternalCliTranscriptMode, @@ -744,6 +748,31 @@ async function injectAgentSkill(casePath: string): Promise { // bypassing the `workspaceHooksEnabled` setting. Route handlers here resolve the // setting through the ConfigPort (tests stub it) and pass it as the second arg. +/** + * A "Resume"/"continue" request for a NEW omp-mode session (the frontend's + * resumeHistorySession(), or anyone hitting the API directly) carries + * `continueSession: true` but no id — omp has none to give it, since Codeman + * has never tracked its own conversation UUID. Left as `--continue`, that + * picks whichever session file in the directory is newest, which silently + * drifts to the WRONG conversation the moment a second omp session (this + * one, a sibling worker, a stray manual run) has touched the same directory + * more recently. Resolve the real id up front instead, same as the + * dead-pane-respawn path in session.ts does, so even the FIRST relaunch of a + * resumed conversation is pinned rather than guessed. + */ +function resolveOmpConfigForCreate( + mode: SessionMode, + workingDir: string, + ompConfig: OmpConfig | undefined +): OmpConfig | undefined { + if (mode !== 'omp') return undefined; + if (!ompConfig || ompConfig.resumeSessionId || !ompConfig.continueSession) { + return ompConfig; + } + const resolvedId = findLatestOmpSessionId(workingDir); + return resolvedId ? { ...ompConfig, resumeSessionId: resolvedId } : ompConfig; +} + export function registerSessionRoutes( app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort @@ -1065,7 +1094,7 @@ export function registerSessionRoutes( piConfig: mode === 'pi' ? gatedPiConfig : undefined, grokConfig: mode === 'grok' ? gatedGrokConfig : undefined, deepSeekConfig: mode === 'deepseek' ? gatedDeepSeekConfig : undefined, - ompConfig: mode === 'omp' ? body.ompConfig : undefined, + ompConfig: resolveOmpConfigForCreate(mode, workingDir, body.ompConfig), resumeSessionId: validatedResumeId, envOverrides: await clampEnvOverridesForOwner(owner, body.envOverrides), effort: body.effort, @@ -3304,7 +3333,7 @@ export function registerSessionRoutes( piConfig: mode === 'pi' ? qsGatedPiConfig : undefined, grokConfig: mode === 'grok' ? qsGatedGrokConfig : undefined, deepSeekConfig: mode === 'deepseek' ? qsGatedDeepSeekConfig : undefined, - ompConfig: mode === 'omp' ? ompConfig : undefined, + ompConfig: resolveOmpConfigForCreate(mode, resolvedCasePath, ompConfig), envOverrides: qsGatedEnvOverrides, effort, remote, @@ -4153,6 +4182,24 @@ export function registerSessionRoutes( // Projects dir may not exist. } + // OMP's own session files (~/.omp/agent/sessions) — the non-claude twin + // of the scan above; see omp-transcript.ts for why this exists at all. + try { + for (const h of scanOmpSessionsHistory()) { + history.push({ + sessionId: h.sessionId, + workingDir: h.workingDir, + sizeBytes: h.sizeBytes, + lastModified: h.lastModified, + firstPrompt: h.firstPrompt, + lastPrompt: h.lastPrompt, + mode: 'omp', + }); + } + } catch { + // Best-effort, same as the claude scan above. + } + // Mux process stats (best-effort; guard against mocks lacking the method). let mux: MuxStatInput[] = []; try { diff --git a/src/web/server.ts b/src/web/server.ts index 5e85562b..f7617a2b 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -2755,6 +2755,7 @@ export class WebServer extends EventEmitter { piConfig: muxSession.mode === 'pi' ? savedState?.piConfig : undefined, grokConfig: muxSession.mode === 'grok' ? savedState?.grokConfig : undefined, deepSeekConfig: muxSession.mode === 'deepseek' ? savedState?.deepSeekConfig : undefined, + ompConfig: muxSession.mode === 'omp' ? savedState?.ompConfig : undefined, envOverrides: savedEnvOverrides, effort: savedState?.effort, attachmentHistory: savedAttachmentHistory, From ed983f898b12e7622ad6f67f998158d0e6817f97 Mon Sep 17 00:00:00 2001 From: timkjr Date: Thu, 27 Aug 2026 12:50:29 -0500 Subject: [PATCH 10/19] fix(omp): resolve claudeSessionId alias on boot-recovery reattach Two bugs compounded to break continuation pinning on every real OMP case (only /tmp-based manual testing happened to work by coincidence): 1. startInteractive() had a second, unconditional claudeSessionId assignment after the mux branch that clobbered its correctly resolved value back to the session's own id on every mux path. 2. mangleOmpWorkingDir() assumed omp mirrors Claude Code's directory naming (home prefix kept), but omp actually strips $HOME first. findLatestOmpSessionId() was silently returning null for every case under ~/codeman-cases/, so resumeSessionId never resolved for any real case dir - only /tmp paths (outside $HOME) worked, which is every dir this feature was previously tested against. Verified live: killed and relaunched the omp-verify server process mid-session (plain reattach, pane stayed alive) and confirmed claudeSessionId now resolves to the real omp transcript uuid instead of the Codeman session's own id. Co-Authored-By: Claude Sonnet 5 --- src/session.ts | 7 +++- src/utils/omp-session-resolver.ts | 21 ++++++++-- test/omp-session-resolver.test.ts | 69 +++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 test/omp-session-resolver.test.ts diff --git a/src/session.ts b/src/session.ts index 413211c4..dfbb8860 100644 --- a/src/session.ts +++ b/src/session.ts @@ -2075,7 +2075,12 @@ export class Session extends EventEmitter { } // Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one. - this._claudeSessionId = this._resumeSessionId || this.id; + // Mirrors the mux branch above and must not clobber it: this line runs + // unconditionally after both the mux and direct-PTY paths, so it also needs + // the ompConfig fallback or it stomps the mux branch's correctly-resolved + // OMP alias back to this.id on every mux/plain-reattach boot recovery + // (the "third reset point" — see DECISIONS.md). + this._claudeSessionId = this._resumeSessionId || this._ompConfig?.resumeSessionId || this.id; this._pid = this.ptyProcess.pid; console.log('[Session] Interactive PTY spawned with PID:', this._pid); diff --git a/src/utils/omp-session-resolver.ts b/src/utils/omp-session-resolver.ts index 37b67a44..d17897e6 100644 --- a/src/utils/omp-session-resolver.ts +++ b/src/utils/omp-session-resolver.ts @@ -17,17 +17,30 @@ import { readdirSync, statSync } from 'node:fs'; import { homedir } from 'node:os'; -import { join } from 'node:path'; +import { join, sep } from 'node:path'; /** A real OMP session file is `_.jsonl`; only the uuid matters here. */ const OMP_SESSION_FILE_PATTERN = /^.+_([a-zA-Z0-9-]+)\.jsonl$/; /** - * Mirrors `omp`'s own directory mangling: every path separator becomes a - * dash. Pure so it's unit-testable without touching the filesystem. + * 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 { - return workingDir.replace(/\//g, '-'); + const home = homedir(); + const relative = + workingDir === home || workingDir.startsWith(home + sep) ? workingDir.slice(home.length) : workingDir; + return relative.replace(/\//g, '-'); } /** `~/.omp` — no known env override exists (unlike DSH_HOME); revisit if omp adds one. */ diff --git a/test/omp-session-resolver.test.ts b/test/omp-session-resolver.test.ts new file mode 100644 index 00000000..f500c8a2 --- /dev/null +++ b/test/omp-session-resolver.test.ts @@ -0,0 +1,69 @@ +/** + * @fileoverview Tests for OMP session-id resolution from disk. + * + * Pins the home-relative directory mangling bug found 2026-08-27: omp + * collapses a home-relative workingDir to its home-relative remainder BEFORE + * dash-replacing (`/home/user/dev/foo` -> `-dev-foo`), unlike Claude Code's + * `~/.claude/projects/*` convention (`-home-user-dev-foo`) this module was + * originally written to mirror. Getting this wrong doesn't throw — it just + * makes findLatestOmpSessionId() silently return null for every case under + * $HOME (virtually all real Codeman cases), so continuation pinning quietly + * degraded to omp's own ambiguous `--continue` while appearing to work in + * manual testing done entirely under /tmp (which sits outside $HOME and was + * mangled correctly by coincidence). + * + * test/setup.ts gives this file its own temp $HOME, so homedir() below is + * already sandboxed — writing real files under it is safe and exercises the + * exact home-relative path the bug hid behind. + */ +import { mkdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { findLatestOmpSessionId, mangleOmpWorkingDir } from '../src/utils/omp-session-resolver.js'; + +describe('mangleOmpWorkingDir', () => { + it('strips the home prefix before dash-replacing a home-relative path', () => { + const home = homedir(); + expect(mangleOmpWorkingDir(join(home, 'codeman-cases', 'testcase'))).toBe('-codeman-cases-testcase'); + }); + + it('dash-replaces a path outside $HOME as-is', () => { + expect(mangleOmpWorkingDir('/tmp/omp-verify-case')).toBe('-tmp-omp-verify-case'); + }); + + it('treats workingDir === home as the empty remainder', () => { + expect(mangleOmpWorkingDir(homedir())).toBe(''); + }); + + it('does not false-positive on a sibling directory sharing a prefix with $HOME', () => { + const sibling = `${homedir()}-other/dev/foo`; + expect(mangleOmpWorkingDir(sibling)).toBe(sibling.replace(/\//g, '-')); + }); +}); + +describe('findLatestOmpSessionId', () => { + const sessionDir = join(homedir(), '.omp', 'agent', 'sessions', '-codeman-cases-testcase'); + + afterEach(() => { + rmSync(join(homedir(), '.omp'), { recursive: true, force: true }); + }); + + it('finds the newest session file under a home-relative workingDir', () => { + const workingDir = join(homedir(), 'codeman-cases', 'testcase'); + mkdirSync(sessionDir, { recursive: true }); + writeFileSync(join(sessionDir, '2026-08-27T17-15-57-989Z_older-id.jsonl'), '{}'); + const newer = join(sessionDir, '2026-08-27T17-31-08-001Z_newer-id.jsonl'); + writeFileSync(newer, '{}'); + // Force a deterministic mtime order regardless of filesystem timestamp resolution. + const now = Date.now() / 1000; + utimesSync(join(sessionDir, '2026-08-27T17-15-57-989Z_older-id.jsonl'), now, now); + utimesSync(newer, now + 1, now + 1); + + expect(findLatestOmpSessionId(workingDir)).toBe('newer-id'); + }); + + it('returns null when the mangled directory does not exist', () => { + expect(findLatestOmpSessionId(join(homedir(), 'never-launched'))).toBeNull(); + }); +}); From 853681f9703ef53609d40cf2d1b69d849b85ba50 Mon Sep 17 00:00:00 2001 From: timkjr Date: Thu, 27 Aug 2026 14:44:46 -0500 Subject: [PATCH 11/19] harden(omp): resume-path test coverage, silent-fallback logging, cwd validation Follow-up from a full-branch review pass (Opus) of the omp-mode integration: - Add pinning tests for resolveOmpConfigForCreate() (session-routes.ts), exported to make it testable: the exact "resume this OMP row from history" pipeline that mangleOmpWorkingDir's earlier bug lived in had zero coverage despite being the resolver module's whole reason to exist. - Log a warning when findLatestOmpSessionId() finds nothing on disk and continuation silently degrades to omp's own ambiguous --continue, in both call sites (session create and respawn pinning) - previously silent, making the degradation invisible to anyone debugging it. - Require an absolute cwd before trusting a session file's working directory in omp-transcript.ts's parser, so a corrupted/malformed session file can't point a downstream resume at a relative or empty path. - Document (don't speculatively fix) an unverified symlinked-$HOME edge case in mangleOmpWorkingDir(): the review's suggested realpath() fix assumes omp itself resolves symlinks before mangling, which is unconfirmed - guessing wrong there would trade one silent mismatch for a different one. - Incidental: fixed unrelated pre-existing prettier drift in session-routes.ts (antigravity/opencode dynamic import line-wrapping) that was blocking the pre-commit formatting gate on this file. Confirmed as a non-issue: the model-name regex allowing "/" is intentional (provider/model ids like "crof/glm-5.2" were used successfully in live testing). Co-Authored-By: Claude Sonnet 5 --- src/omp-transcript.ts | 5 ++- src/session.ts | 3 ++ src/utils/omp-session-resolver.ts | 7 ++++ src/web/routes/session-routes.ts | 7 +++- test/omp-session-resolver.test.ts | 59 +++++++++++++++++++++++++++++++ 5 files changed, 79 insertions(+), 2 deletions(-) diff --git a/src/omp-transcript.ts b/src/omp-transcript.ts index 922a61d0..9bf25b6c 100644 --- a/src/omp-transcript.ts +++ b/src/omp-transcript.ts @@ -100,7 +100,10 @@ function parseOmpSessionFile(filePath: string): OmpHistorySession | null { } if (!entry || typeof entry !== 'object') continue; const e = entry as Record; - if (e.type === 'session' && typeof e.id === 'string' && typeof e.cwd === 'string') { + if (e.type === 'session' && typeof e.id === 'string' && typeof e.cwd === 'string' && e.cwd.startsWith('/')) { + // A corrupted or malformed session file could carry a relative or empty + // cwd; requiring an absolute path keeps a downstream resume attempt + // from being pointed at a nonsense working directory. sessionId = e.id; workingDir = e.cwd; } else if (e.type === 'message') { diff --git a/src/session.ts b/src/session.ts index dfbb8860..fb082a9c 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1686,6 +1686,9 @@ export class Session extends EventEmitter { } // Nothing on disk yet (the dying process never got far enough to write a // session file) — fall back to the CLI's own "most recent" heuristic. + console.warn( + `[Session] OMP: no session file found under ${this.workingDir} to pin --resume on respawn; falling back to ambiguous --continue` + ); return { ...this._ompConfig, continueSession: true }; } diff --git a/src/utils/omp-session-resolver.ts b/src/utils/omp-session-resolver.ts index d17897e6..f76291ee 100644 --- a/src/utils/omp-session-resolver.ts +++ b/src/utils/omp-session-resolver.ts @@ -37,6 +37,13 @@ const OMP_SESSION_FILE_PATTERN = /^.+_([a-zA-Z0-9-]+)\.jsonl$/; * 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; diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 577e7391..7d5a7cf0 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -760,7 +760,7 @@ async function injectAgentSkill(casePath: string): Promise { * dead-pane-respawn path in session.ts does, so even the FIRST relaunch of a * resumed conversation is pinned rather than guessed. */ -function resolveOmpConfigForCreate( +export function resolveOmpConfigForCreate( mode: SessionMode, workingDir: string, ompConfig: OmpConfig | undefined @@ -770,6 +770,11 @@ function resolveOmpConfigForCreate( return ompConfig; } const resolvedId = findLatestOmpSessionId(workingDir); + if (!resolvedId) { + console.warn( + `[Session] OMP: no session file found under ${workingDir} to pin --resume; falling back to ambiguous --continue` + ); + } return resolvedId ? { ...ompConfig, resumeSessionId: resolvedId } : ompConfig; } diff --git a/test/omp-session-resolver.test.ts b/test/omp-session-resolver.test.ts index f500c8a2..ed6d2f60 100644 --- a/test/omp-session-resolver.test.ts +++ b/test/omp-session-resolver.test.ts @@ -21,6 +21,7 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { findLatestOmpSessionId, mangleOmpWorkingDir } from '../src/utils/omp-session-resolver.js'; +import { resolveOmpConfigForCreate } from '../src/web/routes/session-routes.js'; describe('mangleOmpWorkingDir', () => { it('strips the home prefix before dash-replacing a home-relative path', () => { @@ -67,3 +68,61 @@ describe('findLatestOmpSessionId', () => { expect(findLatestOmpSessionId(join(homedir(), 'never-launched'))).toBeNull(); }); }); + +describe('resolveOmpConfigForCreate', () => { + // The exact pipeline "resume this OMP row from the history list" drives: + // POST /api/sessions with mode:'omp' + ompConfig:{continueSession:true} + // must come back with resumeSessionId PINNED to the real omp transcript + // uuid, not left as the ambiguous continueSession flag alone. This was the + // one path flagged by review as having zero coverage despite being the + // exact mechanism the whole resolver module exists to serve. + const workingDir = join(homedir(), 'codeman-cases', 'resume-test'); + const sessionDir = join(homedir(), '.omp', 'agent', 'sessions', '-codeman-cases-resume-test'); + + afterEach(() => { + rmSync(join(homedir(), '.omp'), { recursive: true, force: true }); + }); + + it('pins resumeSessionId from disk when resuming with only continueSession set', () => { + mkdirSync(sessionDir, { recursive: true }); + writeFileSync(join(sessionDir, '2026-08-27T17-31-08-001Z_real-omp-uuid.jsonl'), '{}'); + + const resolved = resolveOmpConfigForCreate('omp', workingDir, { continueSession: true }); + + expect(resolved).toEqual({ continueSession: true, resumeSessionId: 'real-omp-uuid' }); + }); + + it('does not attempt resolution when resumeSessionId is already explicit', () => { + mkdirSync(sessionDir, { recursive: true }); + writeFileSync(join(sessionDir, '2026-08-27T17-31-08-001Z_disk-uuid.jsonl'), '{}'); + + const resolved = resolveOmpConfigForCreate('omp', workingDir, { + continueSession: true, + resumeSessionId: 'already-pinned', + }); + + // Must return the caller's id unchanged, never overwrite it with whatever + // happens to be newest on disk. + expect(resolved).toEqual({ continueSession: true, resumeSessionId: 'already-pinned' }); + }); + + it('leaves ompConfig unchanged when continueSession is not set', () => { + const resolved = resolveOmpConfigForCreate('omp', workingDir, {}); + expect(resolved).toEqual({}); + }); + + it('leaves ompConfig unchanged when nothing is on disk to resolve', () => { + const resolved = resolveOmpConfigForCreate('omp', join(homedir(), 'never-launched'), { + continueSession: true, + }); + expect(resolved).toEqual({ continueSession: true }); + }); + + it('returns undefined for a non-omp mode regardless of ompConfig', () => { + expect(resolveOmpConfigForCreate('claude', workingDir, { continueSession: true })).toBeUndefined(); + }); + + it('returns undefined when ompConfig is undefined', () => { + expect(resolveOmpConfigForCreate('omp', workingDir, undefined)).toBeUndefined(); + }); +}); From d74cde759b831237ac664b152e1b6e25d76316d0 Mon Sep 17 00:00:00 2001 From: timkjr Date: Thu, 27 Aug 2026 15:19:49 -0500 Subject: [PATCH 12/19] feat(omp): install omp in the docker agent image, isolate its credentials OMP had full routing at the Docker layer (default pane command, schema) but was never actually installed in docker/agent.Dockerfile, and had no credential-isolation entry in docker-hosts.ts's CRED_STORES - a Docker-mode OMP session would have failed with "omp: command not found", and even with the binary present would have had no config/auth seeded, despite the README already claiming OMP has "seamless auth, isolated credentials" in Docker. - docker/agent.Dockerfile: install omp via its own installer (standalone binary, same shape as grok/antigravity - not on npm). Verified against a real --no-cache build: the installer actually targets ~/.local/bin, not ~/.omp/bin as the resolver's OMP_SEARCH_DIRS ordering would suggest - confirmed omp/18.0.8 installs and runs correctly inside the image. - src/docker-hosts.ts: add a .omp/agent CRED_STORES entry. Unlike every sibling CLI in this family, sessions/ is SHARED (RW), not seeded: Codeman reads ~/.omp/agent/sessions/**/*.jsonl host-side for history recovery and --resume pinning (omp-transcript.ts, omp-session-resolver.ts), the same reason codex's sessions/ is shared rather than seeded. Seeding it instead would silently break the kill-survival feature for Docker cases. Only the small config files (config.yml/mcp.json/models.yml/settings.yml) are seeded; the SQLite caches and terminal-sessions/ stay container-local. - test/docker-hosts.test.ts: pin the new CRED_STORES entry's behavior. Found in passing (NOT fixed here, unrelated and pre-existing on master): the agent image's DeepSeek (dsh) plugin-install step currently fails on a fresh build ("pnpm not found on PATH"), confirmed via git diff against origin/master that this line is untouched by this branch. Worth a separate issue/PR. Co-Authored-By: Claude Sonnet 5 --- docker/agent.Dockerfile | 22 +++++++++++++++++++++- src/docker-hosts.ts | 16 ++++++++++++++++ test/docker-hosts.test.ts | 26 ++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/docker/agent.Dockerfile b/docker/agent.Dockerfile index 36213aaa..1aa1a758 100644 --- a/docker/agent.Dockerfile +++ b/docker/agent.Dockerfile @@ -80,6 +80,22 @@ RUN npm install -g @deepseek-ai/dsh \ && npm cache clean --force \ && dsh --version +# OMP (Oh My Pi) is NOT on npm: a standalone binary via omp.sh's installer, which +# targets $HOME/.local/bin with no --dir override (verified 2026-08-27 — the +# resolver's OMP_SEARCH_DIRS lists ~/.omp/bin first, which turned out to be the +# WRONG guess for the installer's actual target; build this step for real +# rather than trust that ordering). At build time $HOME is root's home and +# unreachable by the `agent` user, so copy the binary into /usr/local/bin and +# drop root's ~/.local/bin/omp in the same layer so the image does not carry +# the download twice. +RUN curl -fsSL https://omp.sh/install | sh \ + && cp -L /root/.local/bin/omp /usr/local/bin/omp.real \ + && rm -f /usr/local/bin/omp \ + && mv /usr/local/bin/omp.real /usr/local/bin/omp \ + && chmod 755 /usr/local/bin/omp \ + && rm -f /root/.local/bin/omp \ + && omp --version + # `agent` user (gid 0) with an arbitrary-uid-writable HOME. The uid is # auto-assigned (node:22-slim already occupies uid 1000 with its `node` user); at # runtime Codeman overrides with `--user :0` on Linux, so the baked uid @@ -106,10 +122,14 @@ ENV HOME=/home/agent # writable by the arbitrary uid the container actually runs as, and a profile # installed after it would miss that fixup. DSH_HOME points the launcher at the # agent's dir while this still runs as root. +# `.omp/agent` is pre-created for the same reason `.codex` is: it is a MIXED +# store (per-file config seeds PLUS a shared `sessions/` RW bind mount for +# Codeman's own host-side history/resume reads), and neither kind of artifact +# creates its own parent directory. RUN useradd -g 0 -m -d /home/agent -s /bin/bash agent \ && mkdir -p /home/agent/.npm /home/agent/.cache /home/agent/.config /home/agent/.codeman \ /home/agent/.claude/projects /home/agent/.codex/sessions /home/agent/.pi/agent /home/agent/.grok \ - /home/agent/.dsh \ + /home/agent/.dsh /home/agent/.omp/agent \ && DSH_HOME=/home/agent/.dsh HOME=/home/agent \ dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui \ && test -f /home/agent/.dsh/profiles/dsh-tui/package.json \ diff --git a/src/docker-hosts.ts b/src/docker-hosts.ts index 772bc9aa..ac4ffcd3 100644 --- a/src/docker-hosts.ts +++ b/src/docker-hosts.ts @@ -640,6 +640,22 @@ const CRED_STORES: CredStorePolicy[] = [ }, { rel: '.config/gcloud', seedWhole: true }, { rel: '.config/opencode', seedWhole: true }, + // OMP keeps its config in `~/.omp/agent` (config.yml/mcp.json/models.yml/ + // settings.yml — small, no bigger than grok's config.toml/pager.toml), but + // that dir ALSO holds agent.db/history.db/models.db (SQLite caches) and + // terminal-sessions/blobs/cache (large, regenerable), so seed only the + // config files. UNLIKE pi/grok, `sessions/` is SHARED (RW), not + // host-invisible: Codeman reads `~/.omp/agent/sessions/**/*.jsonl` + // HOST-SIDE for history recovery and --resume pinning + // (omp-transcript.ts, omp-session-resolver.ts) — the same reason codex's + // `sessions/` is shared rather than seeded. Without this, an in-container + // OMP conversation would be invisible to Codeman's own history-scan/resume + // logic, silently breaking the kill-survival feature for Docker cases. + { + rel: '.omp/agent', + shareDirs: ['sessions'], + seedFiles: ['config.yml', 'mcp.json', 'models.yml', 'settings.yml'], + }, ]; /** diff --git a/test/docker-hosts.test.ts b/test/docker-hosts.test.ts index 3d848433..f32afe76 100644 --- a/test/docker-hosts.test.ts +++ b/test/docker-hosts.test.ts @@ -329,6 +329,32 @@ describe('resolveDockerCredentialArtifacts (isolated codex/gemini/gcloud/opencod expect(mounts).toEqual([]); expect(seedCopies).toEqual([]); }); + + it('omp: shares sessions/ RW (host-side history/resume reads), seeds config files only', () => { + mkdirSync(join(home, '.omp', 'agent', 'sessions'), { recursive: true }); + writeFileSync(join(home, '.omp', 'agent', 'config.yml'), ''); + writeFileSync(join(home, '.omp', 'agent', 'mcp.json'), '{}'); + writeFileSync(join(home, '.omp', 'agent', 'models.yml'), ''); + writeFileSync(join(home, '.omp', 'agent', 'settings.yml'), ''); + // Regenerable local state that must NOT be seeded (mirrors the pi/grok exclusions). + writeFileSync(join(home, '.omp', 'agent', 'agent.db'), ''); + mkdirSync(join(home, '.omp', 'agent', 'terminal-sessions'), { recursive: true }); + + const { mounts, seedCopies } = resolveDockerCredentialArtifacts(home); + expect(mounts).toContainEqual({ + src: join(home, '.omp', 'agent', 'sessions'), + dst: '/home/agent/.omp/agent/sessions', + }); + const dests = seedCopies.map((s) => s.to); + expect(dests).toContain('/home/agent/.omp/agent/config.yml'); + expect(dests).toContain('/home/agent/.omp/agent/mcp.json'); + expect(dests).toContain('/home/agent/.omp/agent/models.yml'); + expect(dests).toContain('/home/agent/.omp/agent/settings.yml'); + expect(dests).not.toContain('/home/agent/.omp/agent/agent.db'); + expect(mounts.some((m) => m.dst === '/home/agent/.omp/agent/terminal-sessions')).toBe(false); + // seed copies of individual files are NOT recursive + expect(seedCopies.filter((s) => s.to.startsWith('/home/agent/.omp')).every((s) => !s.recursive)).toBe(true); + }); }); describe('resolveDockerClaudeArtifacts (isolated claude state)', () => { From 1829fe91afcdec3cc3184e459ea82a52572a0438 Mon Sep 17 00:00:00 2001 From: timkjr Date: Thu, 27 Aug 2026 15:20:05 -0500 Subject: [PATCH 13/19] docs(omp): add docs/omp-integration.md, matching sibling CLI docs OMP was the one external CLI mode with no dedicated user-guide doc, unlike opencode/pi/grok/deepseek which each have one. Covers install, auth (omp owns its own entirely - no Codeman-side login flow or bypass switch), what Codeman wires up (OmpConfig), the exact-id pinning mechanism and the directory-mangling bug behind it, kill-survival via transcript scanning, terminal behavior, Docker/remote-SSH cases, and known gaps (no idle hook, mid-turn kill data loss, unverified symlinked-$HOME behavior). Cross-referenced from README.md's Multi-CLI doc list and docs/docker-cases.md's credential-seeding summary (which now also documents OMP's sessions/-is-shared exception to the seed-everything pattern the other CLIs use). Co-Authored-By: Claude Sonnet 5 --- README.md | 2 +- docs/docker-cases.md | 2 +- docs/omp-integration.md | 143 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 docs/omp-integration.md diff --git a/README.md b/README.md index 3b36b253..837b0d9e 100644 --- a/README.md +++ b/README.md @@ -437,7 +437,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt - **Background daemon & service install** — `codeman web -d` runs the server detached with a pidfile, `~/.codeman/web.log`, and verified startup (it polls the server until it answers, so a port clash never reads as success); `codeman service install` writes a systemd user unit (Linux) or LaunchAgent (macOS) with your shell's PATH baked in, so an nvm or Homebrew `node`, `tmux` and `claude` are actually found. Secrets are never written into unit files - **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → System → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable) - **Clone a GitHub repo as a case** — paste a repository URL into **Add Case → Clone Repo** and Codeman clones it into `~/codeman-cases/` and registers it as a normal case, ready to run an agent in. It preflights the URL while you type (tells you whether it can be cloned anonymously and offers the repo's real branches and tags for the optional branch/tag field), fills the case name in from the URL, and lets you pick which CLI the Run button should use. Public repositories over `https://`; Codeman never collects or stores credentials -- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, or **OMP** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*` vs `OMP_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md) and [`docs/grok-integration.md`](docs/grok-integration.md) +- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, or **OMP** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*` vs `OMP_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md), [`docs/grok-integration.md`](docs/grok-integration.md) and [`docs/omp-integration.md`](docs/omp-integration.md) - **Docker sessions** — run a case inside an isolated, hardened container. One checkbox on **Create New** spins up a container with sensible defaults and starts the agent inside it; multiple sessions share one per-case container; export a container + its workspace to a portable `.tar.gz` to move it to another machine. See [`docs/docker-cases.md`](docs/docker-cases.md) - **Remote SSH sessions** — point a case at another machine and run the agent there inside a durable remote tmux: survives SSH drops, auto-reconnects, and can discover + attach sessions already running on the host. See [`docs/remote-sessions.md`](docs/remote-sessions.md) - **Effort & Ultracode** — set a per-session default effort (`low`–`max`) or enable **ultracode** (dynamic multi-agent workflows). Soft defaults only — switchable anytime with `/effort` in-session. Extended-thinking budget is configurable too diff --git a/docs/docker-cases.md b/docs/docker-cases.md index 3b1cf4d9..56ed06ac 100644 --- a/docs/docker-cases.md +++ b/docs/docker-cases.md @@ -30,7 +30,7 @@ docker run --rm codeman/agent:base bash -lc \ Antigravity (`agy`) and Grok (`grok`) are the two CLIs not installed from npm (Google and xAI ship standalone binaries), so each has its own Dockerfile step, adding roughly 190MB and 160MB respectively. Pi also gets its own step, because upstream documents installing it with `--ignore-scripts` and that flag must not silently change how the other npm CLIs install. -Pi's credentials are seeded per-FILE rather than as a whole directory (`auth.json`, `settings.json`, `trust.json`, `models.json`, `models-store.json` out of `~/.pi/agent`), because that directory also holds `sessions/`, `extensions/`, `skills/` and the installed package trees — gigabytes on an active host. Consequence: in-container pi sessions are invisible host-side, so `pi -c` inside a Docker case only sees that container's own history. See [`pi-integration.md`](./pi-integration.md). Grok is seeded per-file for the same reason (`auth.json`, `config.toml`, `pager.toml` out of `~/.grok`, which also holds `sessions/`, `memory/` and the ~160MB binary under `downloads/`), with the same consequence for `grok -c`. See [`grok-integration.md`](./grok-integration.md). +Pi's credentials are seeded per-FILE rather than as a whole directory (`auth.json`, `settings.json`, `trust.json`, `models.json`, `models-store.json` out of `~/.pi/agent`), because that directory also holds `sessions/`, `extensions/`, `skills/` and the installed package trees — gigabytes on an active host. Consequence: in-container pi sessions are invisible host-side, so `pi -c` inside a Docker case only sees that container's own history. See [`pi-integration.md`](./pi-integration.md). Grok is seeded per-file for the same reason (`auth.json`, `config.toml`, `pager.toml` out of `~/.grok`, which also holds `sessions/`, `memory/` and the ~160MB binary under `downloads/`), with the same consequence for `grok -c`. See [`grok-integration.md`](./grok-integration.md). OMP is the one CLI in this family where `sessions/` is the EXCEPTION rather than the rule: `~/.omp/agent/{config.yml,mcp.json,models.yml,settings.yml}` are seeded per-file (the dir also holds SQLite caches and `terminal-sessions/`), but `~/.omp/agent/sessions/` is shared RW like codex's, not seeded, because Codeman reads it host-side for history recovery and `--resume` pinning. See [`omp-integration.md`](./omp-integration.md). ## Quickest path: one-click "Run in Docker" diff --git a/docs/omp-integration.md b/docs/omp-integration.md new file mode 100644 index 00000000..ae89ba99 --- /dev/null +++ b/docs/omp-integration.md @@ -0,0 +1,143 @@ +# OMP (Oh My Pi) sessions + +Codeman can drive [OMP](https://github.com/can1357/omp) (`omp`, Oh My Pi) as a session +backend, alongside Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi, Grok and +DeepSeek Harness. `omp` is an eighth **run mode**: its own PTY, its own tmux session, +its own tab identity. It is not a location overlay like Docker or remote-SSH cases, +and it is not a web tab. + +## Install + +```bash +curl -fsSL https://omp.sh/install | sh +``` + +The installer places the binary in `~/.omp/bin`. Codeman resolves the binary via the +server PATH and then the usual install locations (`~/.omp/bin` first, then +`~/.local/bin`, `/usr/local/bin`, `~/.bun/bin`, `~/.npm-global/bin`, `~/bin`). + +**`omp` is a short name**, so like `pi` and `grok` the resolver does not trust a PATH +hit on its own: it runs `omp --version` and requires `omp/`-shaped output +(e.g. `omp/17.4.0`) before accepting a candidate. Check what it resolved: + +```bash +curl -s localhost:3000/api/omp/status | jq +# { "available": true, "path": "/home/you/.omp/bin", "version": "17.4.0" } +``` + +## Authenticate + +OMP owns its own auth and provider configuration entirely in `~/.omp` — there is +no Codeman-side login flow, API key field, or bypass switch to configure. Run `omp` +directly once outside Codeman to complete whatever onboarding the CLI itself asks +for; every session started through Codeman afterward inherits that config. + +## What Codeman wires up + +`OmpConfig` (per session, persisted in `state.json`, round-trips through respawn): + +| Field | Flag | Notes | +| ------------------ | --------------- | ---------------------------------------------------------- | +| `model` | `--model ` | Regex-validated (`[a-zA-Z0-9._-/]+`); `provider/model` forms like `crof/glm-5.2` pass | +| `continueSession` | `--continue` | omp's own "most recent conversation in this directory" heuristic | +| `resumeSessionId` | `--resume ` | Ids only, id-regexed; wins over `--continue` when both are present | + +Every value is regex-validated and **dropped** (not escaped) if it fails, because the +result is interpolated into the pane's spawn command. + +**omp reads its own model routing and hooks from `~/.omp`, so no trust or +permission flags are needed** — unlike every sibling CLI in this family, there is no +bypass-permissions equivalent to wire up, and the multi-user owner clamp has nothing +to gate for `omp` (no branch needed, no privileged flag exists to strip). + +Env overrides: the `OMP_*` prefix is allowlisted. omp has no documented vendor-key +namespace of its own (its provider credentials live in `~/.omp` config files, not +environment variables), so nothing beyond `OMP_*` is admitted. + +## Exact-id pinning: why `--resume`, not just `--continue` + +`--continue` alone is ambiguous the moment **any** other omp conversation has +touched the same working directory more recently — it just picks the newest session +file on disk, silently. That happens routinely: a closed-then-resumed Codeman row +plus a still-running duplicate, two Codeman sessions pointed at the same case, or a +plain reattach after a server restart. + +`src/utils/omp-session-resolver.ts` resolves and **pins** the exact conversation id +once (`findLatestOmpSessionId()` reads `~/.omp/agent/sessions//`, +the newest `.jsonl` file's embedded uuid), then every later respawn reuses that +pinned id via `--resume` instead of re-guessing with `--continue`. + +⚠️ **The directory mangling is NOT a straight `/` → `-` replace.** Unlike Claude +Code's `~/.claude/projects/*` convention (which keeps the full path, e.g. +`-home-user-codeman-cases-foo`), omp strips the `$HOME` prefix FIRST and only then +dash-replaces (`/home/user/codeman-cases/foo` → `-codeman-cases-foo`; a path outside +`$HOME`, like `/tmp/...`, is dash-replaced as-is with no stripping). Getting this +wrong doesn't error — `findLatestOmpSessionId()` just silently returns null for +every case under `$HOME` (virtually all real Codeman cases), so pinning quietly +degrades to omp's own ambiguous `--continue`. This was found and fixed 2026-08-27 +after months of testing had only ever exercised `/tmp`-based working directories, +where the bug's wrong output happened to coincidentally match the right one. + +## Surviving a full session kill + +`src/omp-transcript.ts` scans `~/.omp/agent/sessions/**/*.jsonl` directly — a second, +independent history source alongside Codeman's own state. This means an OMP +conversation's history (working directory, first/last prompt, size) is recoverable +in the Past Sessions list even when **both** the Codeman session record and the +underlying tmux pane are gone — verified live against a full OS reboot, not just a +"Kill Tmux" button click. + +## Terminal behavior + +OMP renders inside tmux like every external CLI (narrow scrollback strip — alt-screen +toggles only, not the full Claude/Codex/Gemini strip). It stays out of the +alt-screen-strip list and lands on the `'buffer'` local-echo policy via the +`_updateLocalEchoState` fallthrough, same as grok and pi. + +## Docker cases + +The agent image installs omp in its own Dockerfile step (not npm; omp's installer +targets `$HOME/.omp/bin` with no `--dir` override, the same shape as grok's +installer). Rebuild with the mandatory `--no-cache`: + +```bash +node scripts/build-agent-image.mjs --no-cache +``` + +Credentials are **mostly seeded**, but `sessions/` is the one exception in this CLI +family: `~/.omp/agent/{config.yml,mcp.json,models.yml,settings.yml}` are seeded +(read-only mount, copied into the container's own `~/.omp/agent` once), so an +in-container omp never writes refreshed config back to the host and `docker commit` +exports stay secret-free. But `~/.omp/agent/sessions/` is **shared (RW)**, not +seeded — the same treatment as codex's `sessions/`, and for the identical reason: +Codeman reads it host-side (`omp-transcript.ts`, `omp-session-resolver.ts`) for +history recovery and `--resume` pinning. Seeding it instead of sharing it would make +an in-container OMP conversation invisible to Codeman's own history/resume logic, +silently breaking Docker support for the kill-survival feature above. The rest of +`~/.omp/agent` (`agent.db`/`history.db`/`models.db` SQLite caches, +`terminal-sessions/`, `blobs/`, `cache/`) stays container-local and is neither +shared nor seeded. + +## Remote SSH cases + +`omp` mode is routed through an interactive login shell +(`exec "$SHELL" -i -l -c 'omp'`), because sshd's remote-command PATH does not +include `~/.omp/bin`. Per-session config and `envOverrides` do not cross ssh and are +rejected rather than silently ignored; use the per-host command override instead. + +## Known gaps + +- **No idle/completion hook.** Idle detection falls back to output-stabilization + like every other external CLI. If omp ever ships a hooks system, a Codeman hook + POSTing to `/api/hook-event` would be the highest-value follow-up. +- **Killing a pane mid-turn loses the conversation for real.** `tmux kill-session` + before an in-TUI `/exit` beats omp's own session-file flush — confirmed by direct + testing (kill after a clean `/exit` resumes correctly; kill without `/exit` first + does not). This is not something Codeman can compensate for from outside the + process; it would need an upstream omp fix (e.g. flush-on-SIGTERM). +- **Unverified: `$HOME` as a symlink.** The directory-mangling fix above compares + against the literal `homedir()` string, not a `realpath()`-resolved one. Whether + omp itself canonicalizes symlinks before mangling is unconfirmed — this has not + been tested against a symlinked-home setup. +- Ralph, respawn heuristics, token/CLI-info parsing and the `❯` readiness probe are + off for omp, as for every external CLI. From ab83d8ffec05cc93ea9f316d21fd8fca36743aef Mon Sep 17 00:00:00 2001 From: timkjr Date: Thu, 27 Aug 2026 20:55:31 -0500 Subject: [PATCH 14/19] fix(omp): a fresh "Run OMP" click no longer silently resumes an old conversation Found live 2026-08-27 by Tim: clicking Run OMP to start a brand-new session in a case directory with prior omp history launched --resume instead of a clean `omp` invocation. Root cause: Session._resolvedOmpRespawnConfig() resolves-and-pins the newest on-disk omp conversation as a side effect on this._ompConfig. That is correct when reattaching to an ALREADY-TRACKED mux session (a dead-pane respawn, or a boot-recovery reattach - the constructor sets _muxSession from persisted state before startInteractive() ever runs there), but it ran unconditionally. startInteractive() computes `respawnPaneOptions: this._buildRespawnPaneOptions()` eagerly in the same object literal that builds `createSessionOptions.ompConfig: this._ompConfig`, so for a genuinely brand-new session (no muxSession in its create config, _muxSession still null) the resolve-and-pin side effect ran and poisoned this._ompConfig before that field was even read. Fix: gate the resolve-and-pin logic on `this._muxSession` already being set. A fresh session has no muxSession yet and now passes through untouched; a real reattach (muxSession present since construction) keeps resolving and pinning exactly as before. Verified live in production against the exact reported scenario (a fresh omp session in a case dir with 8+ hours of prior omp history) - confirmed both via the API (ompConfig stays empty, claudeSessionId equals the session's own id) and visually in the GUI. Regression test constructs a real Session + TmuxManager to exercise the actual private-method interaction directly, since no existing test called startInteractive() at all. Co-Authored-By: Claude Sonnet 5 --- src/session.ts | 13 +++++ test/omp-fresh-run-no-resume.test.ts | 87 ++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 test/omp-fresh-run-no-resume.test.ts diff --git a/src/session.ts b/src/session.ts index fb082a9c..0a22811b 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1674,6 +1674,19 @@ export class Session extends EventEmitter { private _resolvedOmpRespawnConfig(): OmpConfig | undefined { if (this.mode !== 'omp') return this._ompConfig; if (this._ompConfig?.resumeSessionId) return this._ompConfig; + // Resolving-and-pinning is only correct when a mux session ALREADY exists for + // this Session object — a dead-pane respawn, or a boot-recovery reattach (the + // constructor sets _muxSession from persisted state before startInteractive() + // ever runs there). A genuinely brand-new session (Run OMP -> POST + // /api/quick-start -> a fresh Session with no muxSession in its create config) + // has _muxSession still null at this point. Without this guard, the eager + // `respawnPaneOptions: this._buildRespawnPaneOptions()` in startInteractive() + // mutates this._ompConfig via the side effect below BEFORE + // createSessionOptions.ompConfig is even read in the SAME object literal, so a + // fresh "Run OMP" click silently inherited whatever omp conversation happened + // to be newest on disk for this working directory instead of starting clean + // (reported live 2026-08-27). + if (!this._muxSession) return this._ompConfig; const resolvedId = findLatestOmpSessionId(this.workingDir); if (resolvedId) { this._ompConfig = { ...this._ompConfig, resumeSessionId: resolvedId }; diff --git a/test/omp-fresh-run-no-resume.test.ts b/test/omp-fresh-run-no-resume.test.ts new file mode 100644 index 00000000..21f80f75 --- /dev/null +++ b/test/omp-fresh-run-no-resume.test.ts @@ -0,0 +1,87 @@ +/** + * @fileoverview Pins the "Run OMP always resumes" bug found live 2026-08-27. + * + * Session._resolvedOmpRespawnConfig() resolves-and-pins the newest on-disk omp + * conversation as a side effect on `this._ompConfig`. That is correct when + * reattaching to an ALREADY-TRACKED mux session (a dead-pane respawn, or a + * boot-recovery reattach — the constructor sets `_muxSession` from persisted + * state before startInteractive() ever runs there). It is wrong for a + * genuinely brand-new session: startInteractive() computes + * `respawnPaneOptions: this._buildRespawnPaneOptions()` EAGERLY in the same + * object literal that builds `createSessionOptions.ompConfig: this._ompConfig`, + * so the resolve-and-pin side effect ran and poisoned `this._ompConfig` before + * that field was even read — a fresh "Run OMP" click in a working directory + * with any prior omp history silently launched `--resume ` instead of + * a clean `omp` invocation. + */ +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { Session } from '../src/session.js'; +import { TmuxManager } from '../src/tmux-manager.js'; +import type { MuxSession } from '../src/types.js'; + +describe('OMP: fresh session vs. reattach must not share resumeSessionId resolution', () => { + const workingDir = join(homedir(), 'codeman-cases', 'resume-test'); + const sessionDir = join(homedir(), '.omp', 'agent', 'sessions', '-codeman-cases-resume-test'); + const sessions: Session[] = []; + + afterEach(() => { + for (const s of sessions.splice(0)) s.stop(); + rmSync(join(homedir(), '.omp'), { recursive: true, force: true }); + }); + + function seedOmpSessionFile(id: string) { + mkdirSync(workingDir, { recursive: true }); + mkdirSync(sessionDir, { recursive: true }); + writeFileSync(join(sessionDir, `2026-08-27T17-31-08-001Z_${id}.jsonl`), '{}'); + } + + it('a brand-new session (no prior mux session) never inherits an on-disk conversation', async () => { + seedOmpSessionFile('old-conversation-id'); + + const session = new Session({ + workingDir, + mode: 'omp', + mux: new TmuxManager(), + useMux: true, + }); + sessions.push(session); + + await session.startInteractive(); + const state = session.toState(); + + expect(state.ompConfig).toBeUndefined(); + expect(session.claudeSessionId).toBe(session.id); + }); + + it('a reattach to an existing tracked mux session still resolves and pins the real id', async () => { + seedOmpSessionFile('real-omp-uuid'); + + const muxSession: MuxSession = { + sessionId: 'placeholder', + muxName: 'codeman-deadbeef', + pid: 1, + createdAt: Date.now(), + workingDir, + mode: 'omp', + attached: false, + }; + + const session = new Session({ + workingDir, + mode: 'omp', + mux: new TmuxManager(), + useMux: true, + muxSession, + }); + sessions.push(session); + + await session.startInteractive(); + const state = session.toState(); + + expect(state.ompConfig?.resumeSessionId).toBe('real-omp-uuid'); + expect(session.claudeSessionId).toBe('real-omp-uuid'); + }); +}); From c4f6eb1e5e536a98a0494bdf88c84a47167e5c6b Mon Sep 17 00:00:00 2001 From: timkjr Date: Fri, 28 Aug 2026 13:18:25 -0500 Subject: [PATCH 15/19] fix(omp): resolve and pin the respawn session id only at actual respawn time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit findLatestOmpSessionId()'s newest-mtime pin ran eagerly inside _buildRespawnPaneOptions(), which startInteractive() calls unconditionally on every boot-recovery reattach — before anything checks whether the pane is actually dead. With two omp tabs in the same case dir, this could pin an ALIVE pane's session onto whichever sibling's file happened to be newest on disk, purely as a side effect of building options that might never lead to a respawn (reported in Ark0N/Codeman#353 review). Move resolution out of the eager builder into _pinOmpRespawnId(), called explicitly only where a respawn is actually confirmed: the dead-pane branch in _setupOrAttachMuxSession() and reattachRemote(). Add resolveAndClaimOmpSessionId(), which verifies each candidate's own file header (cwd) rather than trusting the mangled-directory match alone, and tracks claimed ids in a process-wide registry so two ambiguous resolutions can't both pick the same sibling's conversation. --- src/session.ts | 87 +++++++++++++------------ src/utils/omp-session-resolver.ts | 94 +++++++++++++++++++++++++++- test/omp-fresh-run-no-resume.test.ts | 76 +++++++++++++++++----- 3 files changed, 195 insertions(+), 62 deletions(-) diff --git a/src/session.ts b/src/session.ts index 0a22811b..8a0608aa 100644 --- a/src/session.ts +++ b/src/session.ts @@ -57,7 +57,7 @@ import { type SessionRemote, type SessionDocker, } from './types.js'; -import { findLatestOmpSessionId } from './utils/omp-session-resolver.js'; +import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js'; import { probeDockerCliVersion } from './docker-hosts.js'; import { probeRemoteCliVersion } from './remote-hosts.js'; import type { TerminalMultiplexer, MuxSession } from './mux-interface.js'; @@ -1514,7 +1514,11 @@ export class Session extends EventEmitter { let needsNewSession = false; if (this._muxSession && mux.isPaneDead(this._muxSession.muxName)) { console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName); - const newPid = await mux.respawnPane(options.respawnPaneOptions); + // Confirmed dead — safe to resolve/pin now (see `_pinOmpRespawnId()`). + // `options.respawnPaneOptions` was built eagerly before this dead-pane + // check ran, so it still carries the pre-pin ompConfig; rebuild it. + this._pinOmpRespawnId(); + const newPid = await mux.respawnPane(this._buildRespawnPaneOptions()); if (!newPid) { console.error('[Session] Failed to respawn pane, will create new session'); needsNewSession = true; @@ -1605,6 +1609,9 @@ export class Session extends EventEmitter { return false; } + // Confirmed the mux session (and thus the pane) exists but this reattach + // is about to respawn it — safe to resolve/pin now. + this._pinOmpRespawnId(); const newPid = await mux.respawnPane(this._buildRespawnPaneOptions()); if (!newPid) { console.error('[Session] reattachRemote: respawnPane failed for', this._muxSession.muxName); @@ -1637,21 +1644,16 @@ export class Session extends EventEmitter { piConfig: this._piConfig, grokConfig: this._grokConfig, deepSeekConfig: this._deepSeekConfig, - // Respawning a dead pane means the CLI process exited (crash, idle - // respawn, or the user's own /exit) but this is still the same - // conversation from the user's perspective — unlike a brand-new - // `createSession` call, defaulting to continuation here is the honest - // behavior. `--continue` alone is ambiguous the moment ANY other omp - // conversation has touched this directory more recently (ours resumed - // elsewhere, a second Codeman session opened here, ...) since it just - // picks the newest session file — so resolve and PIN the exact id the - // pane that just died was writing to, once. The dead pane's file is - // already fully flushed at this point, so "newest file" here is - // unambiguous by construction; every later respawn then reuses the - // pinned id instead of re-guessing. Only when the session already - // carries an explicit resumeSessionId does this skip straight past it - // (that one always wins in buildOmpCommand regardless). - ompConfig: this._resolvedOmpRespawnConfig(), + // OMP resolution/pinning does NOT happen here. This object is built + // EAGERLY — including on every boot-recovery reattach, before anyone + // knows whether the pane is actually dead — so resolving here mutated + // `_ompConfig`/`_claudeSessionId` even for a pane that was simply being + // reattached to, not respawned; with two omp tabs in the same case dir + // that mis-pinned the ALIVE session onto whichever file happened to be + // newest on disk (reported live in the Ark0N/Codeman#353 review). The + // real pin now happens in `_pinOmpRespawnId()`, called by callers ONLY + // once they've confirmed an actual respawn is about to happen. + ompConfig: this._ompConfig, resumeSessionId: this._resumeSessionId, envOverrides: this._envOverrides, effort: this._effort, @@ -1671,23 +1673,18 @@ export class Session extends EventEmitter { * "newest file on disk" is safe here specifically. Non-omp modes and a * session that already carries an explicit id pass through untouched. */ - private _resolvedOmpRespawnConfig(): OmpConfig | undefined { - if (this.mode !== 'omp') return this._ompConfig; - if (this._ompConfig?.resumeSessionId) return this._ompConfig; - // Resolving-and-pinning is only correct when a mux session ALREADY exists for - // this Session object — a dead-pane respawn, or a boot-recovery reattach (the - // constructor sets _muxSession from persisted state before startInteractive() - // ever runs there). A genuinely brand-new session (Run OMP -> POST - // /api/quick-start -> a fresh Session with no muxSession in its create config) - // has _muxSession still null at this point. Without this guard, the eager - // `respawnPaneOptions: this._buildRespawnPaneOptions()` in startInteractive() - // mutates this._ompConfig via the side effect below BEFORE - // createSessionOptions.ompConfig is even read in the SAME object literal, so a - // fresh "Run OMP" click silently inherited whatever omp conversation happened - // to be newest on disk for this working directory instead of starting clean - // (reported live 2026-08-27). - if (!this._muxSession) return this._ompConfig; - const resolvedId = findLatestOmpSessionId(this.workingDir); + private _pinOmpRespawnId(): void { + if (this.mode !== 'omp') return; + if (this._ompConfig?.resumeSessionId) return; + // Callers MUST call this only immediately before an ACTUAL respawn (a + // confirmed-dead pane, or a genuine remote reattach) — never while merely + // building options that might not lead to a respawn. A fresh "Run OMP" + // click has no _muxSession yet and must never inherit whatever omp + // conversation happens to be newest on disk for this working directory + // (reported live 2026-08-27, fixed in 13a19f79); this guard keeps that + // fix intact now that resolution has moved out of the eager options build. + if (!this._muxSession) return; + const resolvedId = resolveAndClaimOmpSessionId(this.workingDir); if (resolvedId) { this._ompConfig = { ...this._ompConfig, resumeSessionId: resolvedId }; // Alias omp's own session uuid to this Codeman id — see the @@ -1695,14 +1692,15 @@ export class Session extends EventEmitter { // (generically-named) mechanism that folds a Past-Sessions row back // into its live/persisted session instead of duplicating it. this._claudeSessionId = resolvedId; - return this._ompConfig; + return; } - // Nothing on disk yet (the dying process never got far enough to write a - // session file) — fall back to the CLI's own "most recent" heuristic. + // Nothing unclaimed on disk (the dying process never got far enough to + // write a session file, or a sibling already claimed the only candidate) + // — fall back to the CLI's own "most recent" heuristic. console.warn( `[Session] OMP: no session file found under ${this.workingDir} to pin --resume on respawn; falling back to ambiguous --continue` ); - return { ...this._ompConfig, continueSession: true }; + this._ompConfig = { ...this._ompConfig, continueSession: true }; } /** @@ -1968,10 +1966,11 @@ export class Session extends EventEmitter { }); // Set claudeSessionId — when resuming, the Claude conversation ID is the - // resumed one. `_resolvedOmpRespawnConfig()` (called above while building - // respawnPaneOptions) may have JUST aliased this to omp's own session - // uuid — that already-resolved id must win over the generic - // `this.id` fallback, or this line clobbers it back to the Codeman id + // resumed one. `_pinOmpRespawnId()` (called just above, inside + // `_setupOrAttachMuxSession()`'s dead-pane branch) may have JUST aliased + // this to omp's own session uuid — that already-resolved id must win + // over the generic `this.id` fallback, or this line clobbers it back + // to the Codeman id // on every single respawn. this._claudeSessionId = this._resumeSessionId || this._ompConfig?.resumeSessionId || this.id; @@ -2394,7 +2393,7 @@ export class Session extends EventEmitter { /** * A brand-new omp session (never yet respawned, so - * {@link _resolvedOmpRespawnConfig} has never run) has no captured + * {@link _pinOmpRespawnId} has never run) has no captured * omp-native session id: `_claudeSessionId` still defaults to this * session's OWN Codeman id from the constructor. Until something aliases * it, the omp history scan's row for this exact conversation (keyed by @@ -2407,7 +2406,7 @@ export class Session extends EventEmitter { private _maybeCaptureOmpSessionId(): void { if (this.mode !== 'omp' || this._claudeSessionId !== this.id) return; try { - const resolvedId = findLatestOmpSessionId(this.workingDir); + const resolvedId = resolveAndClaimOmpSessionId(this.workingDir); if (resolvedId) { this._claudeSessionId = resolvedId; this._ompConfig = { ...this._ompConfig, resumeSessionId: resolvedId }; diff --git a/src/utils/omp-session-resolver.ts b/src/utils/omp-session-resolver.ts index f76291ee..7b1a5ed7 100644 --- a/src/utils/omp-session-resolver.ts +++ b/src/utils/omp-session-resolver.ts @@ -15,7 +15,7 @@ * @module utils/omp-session-resolver */ -import { readdirSync, statSync } from 'node:fs'; +import { closeSync, openSync, readdirSync, readSync, statSync } from 'node:fs'; import { homedir } from 'node:os'; import { join, sep } from 'node:path'; @@ -92,3 +92,95 @@ export function findLatestOmpSessionId(workingDir: string): string | null { } 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; + 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(); + +/** + * 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; +} diff --git a/test/omp-fresh-run-no-resume.test.ts b/test/omp-fresh-run-no-resume.test.ts index 21f80f75..49c9f48e 100644 --- a/test/omp-fresh-run-no-resume.test.ts +++ b/test/omp-fresh-run-no-resume.test.ts @@ -1,18 +1,23 @@ /** - * @fileoverview Pins the "Run OMP always resumes" bug found live 2026-08-27. + * @fileoverview Pins the "Run OMP always resumes" bug found live 2026-08-27, + * and its follow-on fix for the sibling-aliasing bug found in upstream PR + * review (Ark0N/Codeman#353). * - * Session._resolvedOmpRespawnConfig() resolves-and-pins the newest on-disk omp - * conversation as a side effect on `this._ompConfig`. That is correct when - * reattaching to an ALREADY-TRACKED mux session (a dead-pane respawn, or a - * boot-recovery reattach — the constructor sets `_muxSession` from persisted - * state before startInteractive() ever runs there). It is wrong for a - * genuinely brand-new session: startInteractive() computes - * `respawnPaneOptions: this._buildRespawnPaneOptions()` EAGERLY in the same - * object literal that builds `createSessionOptions.ompConfig: this._ompConfig`, - * so the resolve-and-pin side effect ran and poisoned `this._ompConfig` before - * that field was even read — a fresh "Run OMP" click in a working directory - * with any prior omp history silently launched `--resume ` instead of - * a clean `omp` invocation. + * Session._pinOmpRespawnId() resolves-and-pins the newest on-disk omp + * conversation as a side effect on `this._ompConfig`. That is correct ONLY + * immediately before an ACTUAL respawn (a confirmed-dead pane, or a genuine + * remote reattach) — never while merely building options that might not + * lead to one. It used to run eagerly inside `_buildRespawnPaneOptions()`, + * which startInteractive() calls unconditionally (including for a genuinely + * brand-new session, and for a boot-recovery reattach to a pane that turns + * out to still be alive): a fresh "Run OMP" click in a working directory + * with any prior omp history silently launched `--resume ` instead + * of a clean `omp` invocation, and — with two omp tabs in the same case dir + * — a live pane's `_ompConfig`/`claudeSessionId` could get mis-pinned to + * whichever sibling's file happened to be newest on disk, even though + * nothing was actually being respawned. Resolution now happens only inside + * `_pinOmpRespawnId()`, called by a caller that has already confirmed a + * real respawn is happening. */ import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; @@ -35,7 +40,11 @@ describe('OMP: fresh session vs. reattach must not share resumeSessionId resolut function seedOmpSessionFile(id: string) { mkdirSync(workingDir, { recursive: true }); mkdirSync(sessionDir, { recursive: true }); - writeFileSync(join(sessionDir, `2026-08-27T17-31-08-001Z_${id}.jsonl`), '{}'); + // resolveAndClaimOmpSessionId() verifies the file's own header (not just + // the filename), mirroring the real `omp` session-file shape — the + // header's `cwd` must match `workingDir` for the candidate to count. + const header = `${JSON.stringify({ type: 'session', id, cwd: workingDir })}\n`; + writeFileSync(join(sessionDir, `2026-08-27T17-31-08-001Z_${id}.jsonl`), header); } it('a brand-new session (no prior mux session) never inherits an on-disk conversation', async () => { @@ -56,8 +65,13 @@ describe('OMP: fresh session vs. reattach must not share resumeSessionId resolut expect(session.claudeSessionId).toBe(session.id); }); - it('a reattach to an existing tracked mux session still resolves and pins the real id', async () => { - seedOmpSessionFile('real-omp-uuid'); + it('a plain reattach to an existing mux session (pane still alive) does NOT pin', async () => { + // Regression for the sibling-aliasing bug: pinning must never be a side + // effect of merely building respawn options for a pane that might still + // be alive (isPaneDead is unconditionally false under IS_TEST_MODE, + // which is what a real "just reattaching, nothing died" boot recovery + // looks like from Session's perspective). + seedOmpSessionFile('sibling-conversation-id'); const muxSession: MuxSession = { sessionId: 'placeholder', @@ -81,7 +95,35 @@ describe('OMP: fresh session vs. reattach must not share resumeSessionId resolut await session.startInteractive(); const state = session.toState(); - expect(state.ompConfig?.resumeSessionId).toBe('real-omp-uuid'); + expect(state.ompConfig?.resumeSessionId).toBeUndefined(); + expect(session.claudeSessionId).toBe(session.id); + }); + + it('_pinOmpRespawnId() resolves and pins the real id once a respawn is confirmed', () => { + seedOmpSessionFile('real-omp-uuid'); + + const muxSession: MuxSession = { + sessionId: 'placeholder', + muxName: 'codeman-deadbeef', + pid: 1, + createdAt: Date.now(), + workingDir, + mode: 'omp', + attached: false, + }; + + const session = new Session({ + workingDir, + mode: 'omp', + mux: new TmuxManager(), + useMux: true, + muxSession, + }); + sessions.push(session); + + (session as unknown as { _pinOmpRespawnId(): void })._pinOmpRespawnId(); + + expect(session.toState().ompConfig?.resumeSessionId).toBe('real-omp-uuid'); expect(session.claudeSessionId).toBe('real-omp-uuid'); }); }); From 2ee2eacb4b3d19b1aad3450b3ab3fa0cf47e1a0d Mon Sep 17 00:00:00 2001 From: timkjr Date: Fri, 28 Aug 2026 13:45:12 -0500 Subject: [PATCH 16/19] fix(omp): clamp OMP_AUTH_BROKER_URL/TOKEN, correct the env-allowlist docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs claimed omp "has no documented vendor-key namespace of its own" and "the multi-user clamp has nothing to gate" for omp — both false. Per omp's own docs/environment-variables.md, it reads ~40 provider keys from env (pi's known 34-key problem in the same shape), and its own knobs are mostly PI_* (already globally allowlisted): PI_CONFIG_DIR, PI_CODING_AGENT_DIR, PI_CODING_AGENT_SESSION_DIR, PI_SUBPROCESS_CMD, PI_SHELL_PREFIX. The first three also move the ~/.omp tree omp-session-resolver.ts/omp-transcript.ts hardcode, silently degrading pinning/history — a known gap shared with pi, documented but not fixed here. The OMP_* prefix this PR adds brings in OMP_AUTH_BROKER_URL/ OMP_AUTH_BROKER_TOKEN, where omp resolves credentials from — the same shape DEEPSEEK_BASE_URL is already dropped for in clampEnvOverridesForOwner(). Add both to OWNER_CLAMPED_ENV_KEYS so a non-granted owner in multi-user mode can't redirect them, and correct the false claims in CLAUDE.md, docs/omp-integration.md, and the stale resolveOmpHome() comment. Also documents omp's default tools.approvalMode: yolo, which was previously unstated. --- CLAUDE.md | 2 +- docs/omp-integration.md | 28 +++++++++++++++++++++----- src/utils/omp-session-resolver.ts | 8 +++++++- src/web/routes/session-routes.ts | 24 ++++++++++++++++++---- test/omp-mode.test.ts | 33 ++++++++++++++++++++++++++++++- 5 files changed, 83 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 62ae926e..802ed5d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -205,7 +205,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Docker cases**: a case can point at a **container**, with any of the CLI run modes running inside it. Like remote-SSH this is a **LOCATION OVERLAY on cases, never a `SessionMode` of its own**. Exactly one long-lived container **per case**, shared by all its sessions, so killing a session kills only that session's in-container tmux and **never** `docker stop` while siblings remain. The workspace is a real host dir bind-mounted at the **same absolute path**, which is what keeps file-routes/watchers on real host bytes and makes the in-container transcript projHash match the host. Credentials are **seeded** (RO mount, copied into the container once) rather than shared RW, so in-container CLIs never write refreshed tokens back to the host, and bind mounts are excluded from `docker commit` so exports stay secret-free. **NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket.** Config drift is detected via a label hash and a drifted launch is REFUSED rather than silently launched with stale config. ⚠️ On the loopback-only prod bind a container cannot reach 127.0.0.1, so in-container hooks need `CODEMAN_DOCKER_BRIDGE_HOOKS=1`; otherwise idle detection falls back to output-based. → [architecture-invariants#docker-cases](docs/architecture-invariants.md#docker-cases), `docs/docker-cases.md` (user guide), `docs/docker-cases-plan.md` (design) -**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token/CLI-info parsing, ❯-prompt readiness); these CLIs render their own TUIs, so readiness is output stabilization instead. All eight **require tmux with no direct PTY fallback**, because secrets are injected via socket-scoped `tmux setenv` and never on the spawn command line. ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope; reading the raw shape silently breaks the run. ⚠️ **Codex sessions use PREDICTIVE WRITE-THROUGH echo, never the buffer overlay** (`_localEchoPolicy` in `_updateLocalEchoState`, terminal-ui.js): codex's composer reacts per keystroke ("/" pops a live-filtering picker, arrows edit server-side state, the composer grows as it wraps), so buffer-until-Enter starved it into issues #218/#219/#220/#222 and stays disabled (`_localEchoEnabled` remains false for codex). Instead, `PredictiveEchoAddon` (separate `vendor/xterm-predictive-echo.js` bundle) paints each keystroke at the predicted cell while the wire path stays BYTE-IDENTICAL: the onData hook (`_predictHookOnData`) is a plain statement with no `return`, so control always falls through into the untouched send path — pinned by vm and E2E byte-identity tests. Predictions reconcile against the parsed buffer and only while the cursor sits on the measured composer row (`isCodexComposerRow`, `/^› /`). Codex also **drops keystrokes that share a PTY read with a bracketed paste**, so flushed text and the paste sequence must go out as separate delayed writes (mirroring the Enter branch's delayed `\r`). Tests: `test/local-echo-codex-gating.test.ts`, `test/codex-predictive-echo.test.ts` (E2E vs real codex), `packages/xterm-zerolag-input/test/codex-replay.test.ts`. ⚠️ **Pi is the opposite kind of CLI and needs the opposite instincts**: it has NO permission prompts and no sandbox, so there is no bypass flag to send and Codeman must not invent one; its privileged knob is the tri-state `approveProjectTrust` (`--approve`/`--no-approve`), which makes pi EXECUTE repo-local `.pi/extensions` TypeScript, so the multi-user clamp puts pi in the **materialize** branch (an absent config still yields `--no-approve` for a non-granted owner) and `--api-key` is never wired. Pi stays OUT of `isAltScreenStripMode()` (main-screen TUI, and its 0.84.0 fullscreen mode is runtime-switchable via `/settings`, where the alt screen is load-bearing), and lands on the `'buffer'` echo policy via the `_updateLocalEchoState` fallthrough. Pi's own tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts`; user guide `docs/pi-integration.md`. ⚠️ **Grok is codex-shaped on permissions but opencode-shaped on rendering**: its bypass switch is `alwaysApprove` (`--always-approve`, grok's `bypassPermissions` mode — the Run button sends it `true` like antigravity's, and the clamp's only-if-sent branch strips it for non-granted owners), while its fullscreen alt-screen TUI keeps it OUT of `isAltScreenStripMode()`; the resolver version-probes `grok --version` like pi's (npm squatters exist for the name — `GET /api/grok/status` surfaces path + version), and grok lands on the `'buffer'` echo policy via the fallthrough (UNMEASURED against a live authenticated session; if its composer turns out per-keystroke-reactive like codex, flip it to the `'off'` branch). Grok's own tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`; user guide `docs/grok-integration.md`. ⚠️ **DeepSeek breaks three of this family's assumptions, so do not pattern-match it onto its siblings.** (1) The agent is a **PROFILE, not the binary**: `dsh` is a launcher over `$DSH_HOME/profiles/` and DeepSeek ships only `web`/`headless`/`base`, so the terminal front door is ALWAYS third-party and "installed" ≠ "runnable" — the Run button gates on `isDeepSeekRunnable()` (binary AND a pane-capable profile) while `isDeepSeekAvailable()` gates the "add a profile" affordance; a `web`/`headless` profile is refused at spawn because it cannot drive a pane. (2) The permission switch is the **`DSH_PERMISSION_MODE` env export, not a flag** (`read-only`/`workspace-write`/`danger-full-access`) — the harness has none, and this is the one legitimate exception to the effort-style env-var ban because it is read with `??` as a boot-time default, so it stays soft; absent = `workspace-write`, which asks, hence the only-if-sent clamp branch, clamping to `workspace-write` (never `read-only`, which would break the workspace). ⚠️ **That clamp needs a second half no other CLI needs**, because the switch is an env var and `DSH_*` is an allowlisted `envOverrides` prefix: `applyEnvOverrides()` runs AFTER `_configureDeepSeek()` in tmux-manager, so a non-granted owner sending `DSH_PERMISSION_MODE` on the SAME request would land last and hand back exactly the privilege the config clamp removed. `clampEnvOverridesForOwner()` (session-routes.ts) DROPS `DSH_PERMISSION_MODE`, `DSH_HOME` and `DEEPSEEK_BASE_URL` for a non-granted owner (the last because `_configureDeepSeek()` forwards the SERVER's own `DEEPSEEK_API_KEY` into the pane, so a redirected base URL would send it to a foreign host) (dropping falls through to what `_configureDeepSeek()` exports, which is the clamped value); `DSH_HOME` is there because it points the launcher at a profile tree whose plugin code runs at BOOT, before any approval row applies. Every OTHER CLI's bypass is a command-line flag reachable only through its config, which is why the config clamp alone is the whole gate for them. (3) It is the **only non-claude mode that passes `hooksAvailableForMode()`**, and for it alone that predicate is a per-SESSION question rather than a per-mode one (`deepSeekConfig.statusReporting: false` disarms the bridge, so every call site passes `sessionHookOptions(session)`; answering from the mode there re-creates the infinite-wait-dressed-as-a-timeout the guard exists to prevent). It passes because the terminal front door reports idle/working/blocked to a supervisor over a generic env-gated contract and `deepseek-status-shim.ts` makes Codeman that supervisor — real `stop`/`blocked` signals, real Approvals Inbox items, plus the `agent_working` event that clears an alert answered in the terminal. ⚠️ The resolver needs the strictest identity probe of the family (`dsh --help` must say `DeepSeek Harness`) because Debian ships an unrelated `dsh` (dancer's shell) that would pass a version probe. Model is NOT a session field (it is a profile composition entry). ⚠️ `hooksAvailableForMode()` is about hook SIGNALS and is not a stand-in for "is this a claude session": Read My Mind and intent capture read Claude's own transcript and compare `mode === 'claude'` directly, because when `deepseek` earned a yes the shared predicate silently widened both to a mode with no transcript to read (pinned by a static check in `test/deepseek-mode.test.ts`). ⚠️ **It is also the only external CLI whose answers are READ FROM DISK rather than scraped off the pane**: `deepseek-transcript.ts` reads `$DSH_HOME/sessions///session.jsonl.zstd` and backs the `last-response` route for dsh, because the pane segmenter served dsh-TUI's ASCII-art SPLASH as the worker's answer (measured), which anything polling for a first answer reads as an answer. Three traps live in that file: dsh appends **one zstd FRAME per write** and Node's `zlib` zstd decoder stops at the first (a real 56-line transcript decoded as 1 line, so the module walks frame headers itself; a Node older than 22.15 has no zstd and falls back to the pane); every turn also records a **plugin-sourced `user/message`** (the runtime-context snapshot) that must not render as the user's words; and a failed `turn/end` is surfaced as `Turn error: …` rather than as an empty string that reads as "still thinking". ⚠️ Session→transcript pairing is by the header's own `cwd` plus a ±60 s boot window, never by reproducing dsh's directory mangling (which has already changed form once) — and NEVER by newest-mtime alone, which handed a fresh worker its predecessor's answer in the same case dir. DeepSeek's own tests: `test/deepseek-mode.test.ts`, `test/deepseek-cli-resolver.test.ts`, `test/deepseek-transcript.test.ts`; user guide `docs/deepseek-integration.md`. OMP (`omp`) is architecturally the simplest of the family: it needs no bypass flag (the CLI's own `~/.omp` config governs trust/model routing), so `buildOmpCommand()` only ever passes `--model`/`--resume`, and the multi-user clamp has nothing to gate. → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp) +**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token/CLI-info parsing, ❯-prompt readiness); these CLIs render their own TUIs, so readiness is output stabilization instead. All eight **require tmux with no direct PTY fallback**, because secrets are injected via socket-scoped `tmux setenv` and never on the spawn command line. ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope; reading the raw shape silently breaks the run. ⚠️ **Codex sessions use PREDICTIVE WRITE-THROUGH echo, never the buffer overlay** (`_localEchoPolicy` in `_updateLocalEchoState`, terminal-ui.js): codex's composer reacts per keystroke ("/" pops a live-filtering picker, arrows edit server-side state, the composer grows as it wraps), so buffer-until-Enter starved it into issues #218/#219/#220/#222 and stays disabled (`_localEchoEnabled` remains false for codex). Instead, `PredictiveEchoAddon` (separate `vendor/xterm-predictive-echo.js` bundle) paints each keystroke at the predicted cell while the wire path stays BYTE-IDENTICAL: the onData hook (`_predictHookOnData`) is a plain statement with no `return`, so control always falls through into the untouched send path — pinned by vm and E2E byte-identity tests. Predictions reconcile against the parsed buffer and only while the cursor sits on the measured composer row (`isCodexComposerRow`, `/^› /`). Codex also **drops keystrokes that share a PTY read with a bracketed paste**, so flushed text and the paste sequence must go out as separate delayed writes (mirroring the Enter branch's delayed `\r`). Tests: `test/local-echo-codex-gating.test.ts`, `test/codex-predictive-echo.test.ts` (E2E vs real codex), `packages/xterm-zerolag-input/test/codex-replay.test.ts`. ⚠️ **Pi is the opposite kind of CLI and needs the opposite instincts**: it has NO permission prompts and no sandbox, so there is no bypass flag to send and Codeman must not invent one; its privileged knob is the tri-state `approveProjectTrust` (`--approve`/`--no-approve`), which makes pi EXECUTE repo-local `.pi/extensions` TypeScript, so the multi-user clamp puts pi in the **materialize** branch (an absent config still yields `--no-approve` for a non-granted owner) and `--api-key` is never wired. Pi stays OUT of `isAltScreenStripMode()` (main-screen TUI, and its 0.84.0 fullscreen mode is runtime-switchable via `/settings`, where the alt screen is load-bearing), and lands on the `'buffer'` echo policy via the `_updateLocalEchoState` fallthrough. Pi's own tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts`; user guide `docs/pi-integration.md`. ⚠️ **Grok is codex-shaped on permissions but opencode-shaped on rendering**: its bypass switch is `alwaysApprove` (`--always-approve`, grok's `bypassPermissions` mode — the Run button sends it `true` like antigravity's, and the clamp's only-if-sent branch strips it for non-granted owners), while its fullscreen alt-screen TUI keeps it OUT of `isAltScreenStripMode()`; the resolver version-probes `grok --version` like pi's (npm squatters exist for the name — `GET /api/grok/status` surfaces path + version), and grok lands on the `'buffer'` echo policy via the fallthrough (UNMEASURED against a live authenticated session; if its composer turns out per-keystroke-reactive like codex, flip it to the `'off'` branch). Grok's own tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`; user guide `docs/grok-integration.md`. ⚠️ **DeepSeek breaks three of this family's assumptions, so do not pattern-match it onto its siblings.** (1) The agent is a **PROFILE, not the binary**: `dsh` is a launcher over `$DSH_HOME/profiles/` and DeepSeek ships only `web`/`headless`/`base`, so the terminal front door is ALWAYS third-party and "installed" ≠ "runnable" — the Run button gates on `isDeepSeekRunnable()` (binary AND a pane-capable profile) while `isDeepSeekAvailable()` gates the "add a profile" affordance; a `web`/`headless` profile is refused at spawn because it cannot drive a pane. (2) The permission switch is the **`DSH_PERMISSION_MODE` env export, not a flag** (`read-only`/`workspace-write`/`danger-full-access`) — the harness has none, and this is the one legitimate exception to the effort-style env-var ban because it is read with `??` as a boot-time default, so it stays soft; absent = `workspace-write`, which asks, hence the only-if-sent clamp branch, clamping to `workspace-write` (never `read-only`, which would break the workspace). ⚠️ **That clamp needs a second half no other CLI needs**, because the switch is an env var and `DSH_*` is an allowlisted `envOverrides` prefix: `applyEnvOverrides()` runs AFTER `_configureDeepSeek()` in tmux-manager, so a non-granted owner sending `DSH_PERMISSION_MODE` on the SAME request would land last and hand back exactly the privilege the config clamp removed. `clampEnvOverridesForOwner()` (session-routes.ts) DROPS `DSH_PERMISSION_MODE`, `DSH_HOME` and `DEEPSEEK_BASE_URL` for a non-granted owner (the last because `_configureDeepSeek()` forwards the SERVER's own `DEEPSEEK_API_KEY` into the pane, so a redirected base URL would send it to a foreign host) (dropping falls through to what `_configureDeepSeek()` exports, which is the clamped value); `DSH_HOME` is there because it points the launcher at a profile tree whose plugin code runs at BOOT, before any approval row applies. Every OTHER CLI's bypass is a command-line flag reachable only through its config, which is why the config clamp alone is the whole gate for them. (3) It is the **only non-claude mode that passes `hooksAvailableForMode()`**, and for it alone that predicate is a per-SESSION question rather than a per-mode one (`deepSeekConfig.statusReporting: false` disarms the bridge, so every call site passes `sessionHookOptions(session)`; answering from the mode there re-creates the infinite-wait-dressed-as-a-timeout the guard exists to prevent). It passes because the terminal front door reports idle/working/blocked to a supervisor over a generic env-gated contract and `deepseek-status-shim.ts` makes Codeman that supervisor — real `stop`/`blocked` signals, real Approvals Inbox items, plus the `agent_working` event that clears an alert answered in the terminal. ⚠️ The resolver needs the strictest identity probe of the family (`dsh --help` must say `DeepSeek Harness`) because Debian ships an unrelated `dsh` (dancer's shell) that would pass a version probe. Model is NOT a session field (it is a profile composition entry). ⚠️ `hooksAvailableForMode()` is about hook SIGNALS and is not a stand-in for "is this a claude session": Read My Mind and intent capture read Claude's own transcript and compare `mode === 'claude'` directly, because when `deepseek` earned a yes the shared predicate silently widened both to a mode with no transcript to read (pinned by a static check in `test/deepseek-mode.test.ts`). ⚠️ **It is also the only external CLI whose answers are READ FROM DISK rather than scraped off the pane**: `deepseek-transcript.ts` reads `$DSH_HOME/sessions///session.jsonl.zstd` and backs the `last-response` route for dsh, because the pane segmenter served dsh-TUI's ASCII-art SPLASH as the worker's answer (measured), which anything polling for a first answer reads as an answer. Three traps live in that file: dsh appends **one zstd FRAME per write** and Node's `zlib` zstd decoder stops at the first (a real 56-line transcript decoded as 1 line, so the module walks frame headers itself; a Node older than 22.15 has no zstd and falls back to the pane); every turn also records a **plugin-sourced `user/message`** (the runtime-context snapshot) that must not render as the user's words; and a failed `turn/end` is surfaced as `Turn error: …` rather than as an empty string that reads as "still thinking". ⚠️ Session→transcript pairing is by the header's own `cwd` plus a ±60 s boot window, never by reproducing dsh's directory mangling (which has already changed form once) — and NEVER by newest-mtime alone, which handed a fresh worker its predecessor's answer in the same case dir. DeepSeek's own tests: `test/deepseek-mode.test.ts`, `test/deepseek-cli-resolver.test.ts`, `test/deepseek-transcript.test.ts`; user guide `docs/deepseek-integration.md`. OMP (`omp`) needs no bypass flag (the CLI's own `~/.omp` config governs trust/model routing, defaulting to `tools.approvalMode: yolo`), so `buildOmpCommand()` only ever passes `--model`/`--resume`/`--continue` — but the multi-user clamp is NOT a no-op for it: `OMP_*` is an allowlisted `envOverrides` prefix, and the two credential-resolution keys it admits, `OMP_AUTH_BROKER_URL`/`OMP_AUTH_BROKER_TOKEN`, are clamped in `clampEnvOverridesForOwner()` for a non-granted owner, the same shape as `DEEPSEEK_BASE_URL`. Separately, `PI_*` is already allowlisted (pi needs it) and omp reads several of its knobs too (`PI_CONFIG_DIR`, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, `PI_SUBPROCESS_CMD`, `PI_SHELL_PREFIX`) — a redirected `PI_CONFIG_DIR` moves the `~/.omp` tree `omp-session-resolver.ts`/`omp-transcript.ts` hardcode, silently breaking pinning/history; this is a known gap shared with pi, not fixed here. → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp) **Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms), so a double click cannot create duplicate sessions with the same `w-` name. `_ensureCreatedSessionVisible()` runs before `selectSession()`, and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first ordering both produce exactly one rendered tab. ⚠️ **Closing has the mirror-image race and one owner**: `closeSession()` reads `wasActive` BEFORE its `await` and announces the delete via `_closingSessions`, while `_onSessionDeleted` skips the active-session handoff for an id in that set. Both used to read `activeSessionId` after the fact, so the `session_deleted` broadcast for your own delete could null it first and closing the tab you were on landed on the welcome screen instead of the next session, on the same build, depending on timing. The fallback also picks the first order entry that is still in `sessions` (a dead id can linger in `sessionOrder`, same reason Alt+N indexes a live-filtered list). A delete from ANOTHER client still shows the welcome screen, which is the honest answer when what you were looking at was taken away. Tests: `test/session-close-fallback.test.ts`. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization) diff --git a/docs/omp-integration.md b/docs/omp-integration.md index ae89ba99..79c3fea6 100644 --- a/docs/omp-integration.md +++ b/docs/omp-integration.md @@ -47,12 +47,30 @@ result is interpolated into the pane's spawn command. **omp reads its own model routing and hooks from `~/.omp`, so no trust or permission flags are needed** — unlike every sibling CLI in this family, there is no -bypass-permissions equivalent to wire up, and the multi-user owner clamp has nothing -to gate for `omp` (no branch needed, no privileged flag exists to strip). +bypass-permissions equivalent to wire up, so `buildOmpCommand()` only ever passes +`--model`/`--resume`/`--continue`. ⚠️ That does NOT mean omp is unrestricted: its +documented default `tools.approvalMode` is `yolo`, so an omp pane auto-approves exec +with no flag from Codeman — the CLI's own config, not Codeman, is what would need to +change that. -Env overrides: the `OMP_*` prefix is allowlisted. omp has no documented vendor-key -namespace of its own (its provider credentials live in `~/.omp` config files, not -environment variables), so nothing beyond `OMP_*` is admitted. +Env overrides: the `OMP_*` prefix is allowlisted, and per omp's own +`docs/environment-variables.md` it is not the narrow surface it looks like. omp reads +roughly 40 provider keys from the environment (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, +`XAI_API_KEY`, `HF_TOKEN`, ...) — pi's 34-key problem in the same shape — which is why +none of those get a dedicated allowlist entry; a session authenticates from `~/.omp` +config or the server process's own env instead, like pi. omp's own documented knobs +are mostly `PI_*`, not `OMP_*` (`PI_CONFIG_DIR`, `PI_CODING_AGENT_DIR`, +`PI_CODING_AGENT_SESSION_DIR`, `PI_SUBPROCESS_CMD`, `PI_SHELL_PREFIX`, +`OMP_PROFILE`/`PI_PROFILE`), and `PI_*` is already allowlisted globally because pi +mode needs it — so an omp session today already accepts all of those. The first three +also move the tree `omp-session-resolver.ts` and `omp-transcript.ts` hardcode +(`resolveOmpHome()` assumes `~/.omp` unconditionally), so pinning and history quietly +stop working under a redirected config root; this is a known gap, not fixed here. + +The `OMP_` prefix itself brings in `OMP_AUTH_BROKER_URL` / `OMP_AUTH_BROKER_TOKEN`, +where omp resolves credentials from — the same shape `DEEPSEEK_BASE_URL` is dropped +for in `clampEnvOverridesForOwner()` (session-routes.ts), so both are clamped there +for a non-granted owner in multi-user mode. None of this matters in single-user mode. ## Exact-id pinning: why `--resume`, not just `--continue` diff --git a/src/utils/omp-session-resolver.ts b/src/utils/omp-session-resolver.ts index 7b1a5ed7..e01ac00a 100644 --- a/src/utils/omp-session-resolver.ts +++ b/src/utils/omp-session-resolver.ts @@ -50,7 +50,13 @@ export function mangleOmpWorkingDir(workingDir: string): string { return relative.replace(/\//g, '-'); } -/** `~/.omp` — no known env override exists (unlike DSH_HOME); revisit if omp adds one. */ +/** + * `~/.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'); } diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 7d5a7cf0..fe50bd92 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -399,10 +399,10 @@ export const _clampExternalCliBypassForOwner = clampExternalCliBypassForOwner; /** * Env-var keys a non-granted owner must not be able to set, because each one - * hands back privilege the config clamp above just removed — or, for the last, - * redirects a credential the server injects. + * hands back privilege the config clamp above just removed, or redirects a + * credential-resolution endpoint. * - * All are DeepSeek's, and all are reachable because `DSH_*` and `DEEPSEEK_*` are + * The DeepSeek three are reachable because `DSH_*` and `DEEPSEEK_*` are * allowlisted `envOverrides` prefixes (schemas.ts) — which they have to be, since * that is also how a user configures the harness's non-privileged knobs. * @@ -419,8 +419,24 @@ export const _clampExternalCliBypassForOwner = clampExternalCliBypassForOwner; * URL would have the operator's API key sent as a bearer credential to a host * of their choosing. (`DEEPSEEK_API_KEY` itself stays overridable: supplying * your OWN key removes privilege rather than granting it.) + * - `OMP_AUTH_BROKER_URL`/`OMP_AUTH_BROKER_TOKEN` are where omp resolves + * credentials from — the same shape as `DEEPSEEK_BASE_URL` above, reachable + * because `OMP_*` is an allowlisted prefix. Unlike DeepSeek, Codeman does not + * forward any operator-held key into an omp pane today (omp's provider + * credentials live in `~/.omp` config files, not env vars), so there is no + * known concrete exfiltration path yet — clamped defensively anyway, since a + * non-granted owner redirecting where a shared multi-tenant deployment + * resolves auth from is not something to allow silently (found in + * Ark0N/Codeman#353 review; omp's own knobs are otherwise mostly `PI_*`, + * already allowlisted for pi and not addressed here — see resolveOmpHome()). */ -const OWNER_CLAMPED_ENV_KEYS = ['DSH_PERMISSION_MODE', 'DSH_HOME', 'DEEPSEEK_BASE_URL'] as const; +const OWNER_CLAMPED_ENV_KEYS = [ + 'DSH_PERMISSION_MODE', + 'DSH_HOME', + 'DEEPSEEK_BASE_URL', + 'OMP_AUTH_BROKER_URL', + 'OMP_AUTH_BROKER_TOKEN', +] as const; /** * Env-var half of the multi-user bypass clamp. diff --git a/test/omp-mode.test.ts b/test/omp-mode.test.ts index 941b8cd0..dd7aa5a3 100644 --- a/test/omp-mode.test.ts +++ b/test/omp-mode.test.ts @@ -1,9 +1,10 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; import { CreateSessionSchema, QuickStartSchema } from '../src/web/schemas.js'; import { buildSpawnCommand } from '../src/tmux-manager.js'; import { defaultDockerCommandForMode } from '../src/docker-hosts.js'; import { defaultRemoteCommandForMode } from '../src/remote-hosts.js'; import { isExternalCliMode, isAltScreenStripMode } from '../src/session.js'; +import { _clampEnvOverridesForOwner } from '../src/web/routes/session-routes.js'; describe('OMP mode schemas', () => { it('accepts OMP session creation config', () => { @@ -132,3 +133,33 @@ describe('OMP mode gates', () => { expect(defaultRemoteCommandForMode('omp')).toBe('exec "${SHELL:-/bin/sh}" -i -l -c \'omp\''); }); }); + +describe('OMP multi-user clamp: the env-var half', () => { + // Unlike DeepSeek, omp has no permission FLAG or CONFIG for the clamp to + // gate (buildOmpCommand() only ever emits --model/--resume/--continue), so + // the only privilege surface is the two credential-resolution env vars the + // OMP_* prefix admits. + const ORIGINAL = process.env.CODEMAN_MULTIUSER; + beforeEach(() => { + process.env.CODEMAN_MULTIUSER = '1'; + }); + afterEach(() => { + if (ORIGINAL === undefined) delete process.env.CODEMAN_MULTIUSER; + else process.env.CODEMAN_MULTIUSER = ORIGINAL; + }); + + it('strips OMP_AUTH_BROKER_URL and OMP_AUTH_BROKER_TOKEN, leaving unrelated overrides alone', async () => { + const out = await _clampEnvOverridesForOwner('nobody', { + OMP_AUTH_BROKER_URL: 'https://attacker.example/broker', + OMP_AUTH_BROKER_TOKEN: 'stolen-token', + OMP_PROFILE: 'default', + }); + expect(out).toEqual({ OMP_PROFILE: 'default' }); + }); + + it('is a no-op in single-user mode', async () => { + delete process.env.CODEMAN_MULTIUSER; + const input = { OMP_AUTH_BROKER_URL: 'https://attacker.example/broker' }; + expect(await _clampEnvOverridesForOwner(undefined, input)).toBe(input); + }); +}); From f18dccace1959866cd0b3e0cc2f168feb946aa05 Mon Sep 17 00:00:00 2001 From: timkjr Date: Fri, 28 Aug 2026 14:03:07 -0500 Subject: [PATCH 17/19] fix: don't discard codex/gemini/antigravity conversations on Resume; fix DELETE ownership dup + missing broadcast resumeHistorySession() creates the resumed row in its own mode via a modeConfigKey map (opencode/pi/grok/omp -> continueSession, deepseek -> resumeSession) and retires the old row afterward. codex, gemini and antigravity were missing from that map, so resuming one of their rows started a brand-new session with NO continuation while still deleting the row it came from -- silent data loss dressed as the duplicate-row fix. Gate row retirement on continuesSomething (true only for modes that actually got a continuation config) instead of wiring an unverified sessionId->native-conversation-id assumption for the three affected CLIs. DELETE /api/sessions/:id reimplemented the ownership 404 check inline in two places instead of going through findSessionOrFail, and its persisted-only-session branch never broadcast session:deleted, so other open tabs kept the retired row until their next unrelated fetch. Extract the shared 404 into sessionNotFoundError(), add findPersistedSessionOrFail() alongside findSessionOrFail() in route-helpers.ts (same ownership contract, returns a SessionState instead of a live Session), and use both from the route instead of inline checks. Add the missing broadcast. --- src/web/public/terminal-ui.js | 14 ++- src/web/route-helpers.ts | 42 +++++-- src/web/routes/session-routes.ts | 25 ++-- test/resume-history-mode-fidelity.test.ts | 143 ++++++++++++++++++++++ test/routes/session-routes.test.ts | 4 + 5 files changed, 205 insertions(+), 23 deletions(-) create mode 100644 test/resume-history-mode-fidelity.test.ts diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 47182165..bb83cc5a 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -2942,12 +2942,19 @@ Object.assign(CodemanApp.prototype, { grok: 'grokConfig', omp: 'ompConfig', }[effectiveMode]; + // codex/gemini/antigravity have no wired continuation here yet (their + // configs use an exact conversation id, not a "continue most recent" + // flag, and the row's own `sessionId` is not verified to carry that + // id for these three modes) — `continuesSomething` below is what keeps + // their row from being retired for a resume that didn't actually + // continue anything. const modeConfig = modeConfigKey ? { [modeConfigKey]: { continueSession: true } } : effectiveMode === 'deepseek' ? { deepSeekConfig: { resumeSession: true } } : {}; + const continuesSomething = Boolean(modeConfigKey) || effectiveMode === 'deepseek'; const createRes = await fetch('/api/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -2975,7 +2982,12 @@ Object.assign(CodemanApp.prototype, { // as a duplicate — click it 3 times, see the same name 3 times. Claude // rows are left alone: `sessionId` there is a claudeSessionId, which // usually has no live/persisted Codeman session of its own to delete. - if (effectiveMode !== 'claude' && sessionId !== newSessionId) { + // Gated on `continuesSomething`: for codex/gemini/antigravity (no + // continuation wired above), this is really a FRESH session with no + // relation to the old row's conversation, so retiring it would discard + // the old conversation with no recovery — worse than the duplicate row + // this guard exists to prevent for the modes that DO continue. + if (effectiveMode !== 'claude' && continuesSomething && sessionId !== newSessionId) { fetch(`/api/sessions/${sessionId}?killMux=true`, { method: 'DELETE' }).catch(() => {}); } diff --git a/src/web/route-helpers.ts b/src/web/route-helpers.ts index 8585358c..fb02fd61 100644 --- a/src/web/route-helpers.ts +++ b/src/web/route-helpers.ts @@ -12,7 +12,7 @@ import { homedir } from 'node:os'; import type { z } from 'zod'; import type { FastifyReply, FastifyRequest } from 'fastify'; import { Session } from '../session.js'; -import { ApiErrorCode, createErrorResponse, type AuthUser } from '../types.js'; +import { ApiErrorCode, createErrorResponse, type AuthUser, type SessionState } from '../types.js'; import { MAX_CONCURRENT_SESSIONS } from '../config/map-limits.js'; import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js'; import { SseEvent } from './sse-events.js'; @@ -264,6 +264,18 @@ export function revokeUserSessions( return removed; } +/** + * The 404 both session-lookup helpers below throw. A missing session and one + * the caller isn't allowed to see get the IDENTICAL error (never 403), so + * existence of another user's session is never leaked. + */ +function sessionNotFoundError(sessionId: string): Error & { statusCode: number; body: unknown } { + return Object.assign(new Error(`Session ${sessionId} not found`), { + statusCode: 404, + body: createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`), + }); +} + /** * Look up a session by ID or throw a structured error. * Replaces the pattern: `const session = sessions.get(id); if (!session) return createErrorResponse(...)`. @@ -274,15 +286,31 @@ export function revokeUserSessions( */ export function findSessionOrFail(ctx: SessionPort, sessionId: string, req?: FastifyRequest): Session { const session = ctx.sessions.get(sessionId); - if (!session || (req && !canAccessOwned(getAuthUser(req), session.owner))) { - throw Object.assign(new Error(`Session ${sessionId} not found`), { - statusCode: 404, - body: createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`), - }); - } + if (!session) throw sessionNotFoundError(sessionId); + if (req && !canAccessOwned(getAuthUser(req), session.owner)) throw sessionNotFoundError(sessionId); return session; } +/** + * Like {@link findSessionOrFail}, for a session that exists ONLY in persisted + * state — a resumed-but-never-reattached row (e.g. a non-claude "Resume" that + * relaunched into a new session and wants to retire the row it can no longer + * reattach to) has no live `Session` instance for `findSessionOrFail` to + * return, so this returns the persisted record instead. Same ownership + * enforcement, same 404-not-403 leak protection — this is that function's + * missing other half, not a separate check reimplemented inline. + */ +export function findPersistedSessionOrFail( + store: { getSession(id: string): SessionState | null }, + sessionId: string, + req?: FastifyRequest +): SessionState { + const persisted = store.getSession(sessionId); + if (!persisted) throw sessionNotFoundError(sessionId); + if (req && !canAccessOwned(getAuthUser(req), persisted.owner)) throw sessionNotFoundError(sessionId); + return persisted; +} + /** Shortest prefix accepted for a parent session id (see resolveParentSessionId). */ const PARENT_SESSION_ID_MIN_PREFIX = 8; diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index fe50bd92..9aa0736b 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -67,6 +67,7 @@ import { autoConfigureRalph, canAccessOwned, CASES_DIR, + findPersistedSessionOrFail, findSessionOrFail, getAuthUser, isAdmin, @@ -1191,25 +1192,19 @@ export function registerSessionRoutes( // rather than 404ing: the caller means "make this row go away", and a // stale duplicate row is exactly what's left behind otherwise. Pinned // sessions keep their existing demote-not-delete protection. - const session = ctx.sessions.get(id); - if (!session) { - const persisted = ctx.store.getSession(id); - if (!persisted || !canAccessOwned(getAuthUser(req), persisted.owner)) { - throw Object.assign(new Error(`Session ${id} not found`), { - statusCode: 404, - body: createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${id} not found`), - }); - } + if (!ctx.sessions.has(id)) { + // Called for its existence/ownership 404 side effect only — demoteOrRemoveSession + // below re-looks-up the record by id, so the returned SessionState is unused here. + findPersistedSessionOrFail(ctx.store, id, req); ctx.store.demoteOrRemoveSession(id); + // Mirrors the broadcast at the tail of the live-session cleanup path + // (_doCleanupSession in server.ts) — without it, other open tabs keep + // showing the retired row until their next unrelated fetch. + ctx.broadcast(SseEvent.SessionDeleted, { id }); return {}; } - if (req && !canAccessOwned(getAuthUser(req), session.owner)) { - throw Object.assign(new Error(`Session ${id} not found`), { - statusCode: 404, - body: createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${id} not found`), - }); - } + const session = findSessionOrFail(ctx, id, req); await ctx.cleanupSession(session.id, killMux, 'user_delete'); return {}; }); diff --git a/test/resume-history-mode-fidelity.test.ts b/test/resume-history-mode-fidelity.test.ts new file mode 100644 index 00000000..85619ae4 --- /dev/null +++ b/test/resume-history-mode-fidelity.test.ts @@ -0,0 +1,143 @@ +/** + * @fileoverview Upstream review fix (Ark0N/Codeman#353, PR #3): resumeHistorySession() + * threads the row's own mode through session creation via a `modeConfigKey` map + * (opencode/pi/grok/omp → `continueSession: true`), then retires the old row via + * DELETE. codex/gemini/antigravity were missing from that map, so resuming one of + * their rows created a session with NO continuation while still deleting the row + * it came from — data loss dressed as a fix. The correction: only retire the row + * when the new session actually continues something. + * + * Loaded via `vm` against a stub CodemanApp, same harness as resume-name.test.ts. + * `fetch` is a shared mutable stub so each test can inspect exactly which requests + * fired without a real network/server. + */ + +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +/** The fetch the vm's shipping code calls; swapped per test (see beforeEach). */ +let currentFetch: (...args: unknown[]) => unknown = () => { + throw new Error('fetch not stubbed for this test'); +}; + +function loadTerminalUiPrototype(): Record unknown> { + const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8'); + const context = vm.createContext({ + console, + CodemanApp: class CodemanApp {}, + setInterval: vi.fn(), + clearInterval: vi.fn(), + setTimeout, + clearTimeout, + requestAnimationFrame: vi.fn(), + document: { addEventListener: vi.fn(), getElementById: vi.fn(() => null) }, + window: { addEventListener: vi.fn(), removeEventListener: vi.fn() }, + fetch: (...args: unknown[]) => currentFetch(...args), + }); + vm.runInContext(`${source}\nglobalThis.__proto = CodemanApp.prototype;`, context); + return (context as { __proto: Record unknown> }).__proto; +} + +const proto = loadTerminalUiPrototype(); + +function makeApp() { + return { + terminal: { clear: vi.fn(), writeln: vi.fn(), focus: vi.fn() }, + cases: [], + resumeHistorySession: proto.resumeHistorySession as (...args: unknown[]) => Promise, + _closeFolderHistoryModal: vi.fn(), + _resolveResumeName: () => 'w1-case', + loadAppSettingsFromStorage: () => ({}), + getCaseSettings: () => ({}), + buildEnvOverrides: () => ({}), + getEffortSetting: () => undefined, + selectSession: vi.fn(async () => {}), + }; +} + +/** DELETE calls the fetch mock recorded. */ +function deleteCalls(fetchMock: ReturnType): string[] { + return fetchMock.mock.calls + .filter(([, opts]: [string, { method?: string }]) => opts?.method === 'DELETE') + .map(([url]: [string]) => url); +} + +/** POST /api/sessions body the fetch mock recorded. */ +function createBody(fetchMock: ReturnType): any { + const call = fetchMock.mock.calls.find(([url]: [string]) => url === '/api/sessions'); + return call ? JSON.parse((call[1] as { body: string }).body) : undefined; +} + +function stubFetch(newSessionId: string): ReturnType { + const fetchMock = vi.fn(async (url: string) => { + if (url === '/api/sessions') { + return { json: async () => ({ success: true, data: { session: { id: newSessionId } } }) }; + } + return { json: async () => ({ success: true }) }; + }); + currentFetch = fetchMock; + return fetchMock; +} + +describe('resumeHistorySession: row retirement is gated on actual continuation', () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = stubFetch('new-session-id'); + }); + + it.each(['codex', 'gemini', 'antigravity'])( + 'does NOT retire the old row for %s (no continuation is wired for it)', + async (mode) => { + const app = makeApp(); + await app.resumeHistorySession.call(app, 'old-id', '/repo', 'w1-repo', mode); + + expect(createBody(fetchMock)).toMatchObject({ mode }); + expect(createBody(fetchMock).codexConfig).toBeUndefined(); + expect(createBody(fetchMock).geminiConfig).toBeUndefined(); + expect(createBody(fetchMock).antigravityConfig).toBeUndefined(); + expect(deleteCalls(fetchMock)).toEqual([]); + } + ); + + it.each([ + ['opencode', 'openCodeConfig'], + ['pi', 'piConfig'], + ['grok', 'grokConfig'], + ['omp', 'ompConfig'], + ])('retires the old row for %s (continueSession is wired via %s)', async (mode, configKey) => { + const app = makeApp(); + await app.resumeHistorySession.call(app, 'old-id', '/repo', 'w1-repo', mode); + + expect(createBody(fetchMock)[configKey]).toEqual({ continueSession: true }); + expect(deleteCalls(fetchMock)).toEqual(['/api/sessions/old-id?killMux=true']); + }); + + it('retires the old row for deepseek (resumeSession is wired)', async () => { + const app = makeApp(); + await app.resumeHistorySession.call(app, 'old-id', '/repo', 'w1-repo', 'deepseek'); + + expect(createBody(fetchMock).deepSeekConfig).toEqual({ resumeSession: true }); + expect(deleteCalls(fetchMock)).toEqual(['/api/sessions/old-id?killMux=true']); + }); + + it('never retires a claude row (resumeSessionId is a claudeSessionId, not a Codeman row id)', async () => { + const app = makeApp(); + await app.resumeHistorySession.call(app, 'claude-uuid', '/repo', 'w1-repo', 'claude'); + + expect(createBody(fetchMock)).toMatchObject({ mode: 'claude', resumeSessionId: 'claude-uuid' }); + expect(deleteCalls(fetchMock)).toEqual([]); + }); + + it('never retires when the new session id equals the old one (no-op resume)', async () => { + fetchMock = stubFetch('same-id'); + const app = makeApp(); + await app.resumeHistorySession.call(app, 'same-id', '/repo', 'w1-repo', 'omp'); + + expect(deleteCalls(fetchMock)).toEqual([]); + }); +}); diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts index dfdc3505..839786ad 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -356,6 +356,10 @@ describe('session-routes', () => { expect(body.success).toBe(true); expect(harness.ctx.store.demoteOrRemoveSession).toHaveBeenCalledWith('ghost-session'); expect(harness.ctx.cleanupSession).not.toHaveBeenCalled(); + // Ark0N/Codeman#353 review: the persisted-only branch used to demote/remove + // with no broadcast, so other open tabs kept showing the retired row until + // their next unrelated fetch. + expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:deleted', { id: 'ghost-session' }); }); it('404s a persisted-only session id the state store does not recognize either', async () => { From 65e994d29ab5ebf2800785780943d5f9152b5a2b Mon Sep 17 00:00:00 2001 From: timkjr Date: Fri, 28 Aug 2026 14:37:28 -0500 Subject: [PATCH 18/19] fix(omp): correct docs/counts/URLs, resolver install-path order, stray comment + CSS Small cleanup items from upstream review (Ark0N/Codeman#353): - OMP_SEARCH_DIRS now leads with ~/.local/bin, matching omp.sh's real installer target (~/.omp/bin was an earlier unverified guess, confirmed wrong against a real --no-cache Docker build). - docs/omp-integration.md: fixed the dead GitHub URL (can1357/omp -> can1357/oh-my-pi), corrected the CLI count (ninth backend, tenth SessionMode incl. shell -- not eighth), matched the install-path guidance to the resolver fix, updated the version example to the actually-tested 18.0.8, and added a Docker-section caveat: --resume pinning does not currently reach an in-container omp process, since Docker panes never see ompConfig. - docs/architecture-invariants.md: fixed a heading missing ", OMP" (CLAUDE.md already linked to the -omp anchor, so the link was dead) and added an OMP specifics paragraph -- the one external CLI missing an entry in this doc. - .changeset/omp-backend.md: corrected the sibling-CLI list (was missing Pi, Grok, and DeepSeek Harness) and the backend count. - Removed a stray orphaned comment fragment in the quick-start docker branch and split two CSS lines that had two declarations jammed onto one line. --- .changeset/omp-backend.md | 4 +++- docs/architecture-invariants.md | 6 ++++-- docs/omp-integration.md | 32 +++++++++++++++++++++----------- src/utils/omp-cli-resolver.ts | 10 ++++++++-- src/web/public/styles.css | 6 ++++-- src/web/routes/session-routes.ts | 1 - 6 files changed, 40 insertions(+), 19 deletions(-) diff --git a/.changeset/omp-backend.md b/.changeset/omp-backend.md index 6ea14b65..f1804449 100644 --- a/.changeset/omp-backend.md +++ b/.changeset/omp-backend.md @@ -5,7 +5,9 @@ feat: add OMP as a first-class CLI backend (SessionMode 'omp') Codeman can now spawn the OMP CLI (`omp`) in local, Docker, and remote-SSH -sessions, alongside Claude Code, OpenCode, Codex, Gemini, and Antigravity. +sessions, alongside Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi, Grok +Build, and DeepSeek Harness — the ninth CLI backend (tenth `SessionMode`, +counting `shell`). - New `SessionMode = ... | 'omp'` with an `OmpConfig` (model, resumeSessionId) - `src/utils/omp-cli-resolver.ts` PATH probe + `/api/omp/status` + `codeman doctor` entry diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 02e17755..b677dc0e 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -18,9 +18,9 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough ## Session launch modes -### External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek) +### External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP) -**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok' || 'deepseek'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). All seven modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume `, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode ` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex AND Gemini export `COLORTERM=truecolor` + unset `NO_COLOR` (other modes unset `COLORTERM`); Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation ` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume ` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenStripMode()` (fullscreen alt-screen TUI with mouse support — the opencode case, not the Ink case). Env allowlist: `GROK_*` plus the vendor namespace `XAI_*` (`XAI_API_KEY` is grok's documented headless auth var — the same narrow-vendor-namespace reasoning as `GOOGLE_*` for gemini). Docker cred seeding is per-file (`auth.json`, `config.toml`, `pager.toml` from `~/.grok` — the dir also holds `sessions/`, `memory/`, and the ~160MB binary under `downloads/`). Grok tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`. +**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok' || 'deepseek'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). All seven modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume `, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode ` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex AND Gemini export `COLORTERM=truecolor` + unset `NO_COLOR` (other modes unset `COLORTERM`); Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation ` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume ` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenStripMode()` (fullscreen alt-screen TUI with mouse support — the opencode case, not the Ink case). Env allowlist: `GROK_*` plus the vendor namespace `XAI_*` (`XAI_API_KEY` is grok's documented headless auth var — the same narrow-vendor-namespace reasoning as `GOOGLE_*` for gemini). Docker cred seeding is per-file (`auth.json`, `config.toml`, `pager.toml` from `~/.grok` — the dir also holds `sessions/`, `memory/`, and the ~160MB binary under `downloads/`). Grok tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`. **DeepSeek Harness (`dsh`) specifics** — the mode that breaks three of the assumptions the six above share, so read this before changing anything about it. @@ -42,6 +42,8 @@ Model is NOT a session field: it is a composition entry in the profile's config **Pi specifics** (#206, `docs/pi-integration.md`): command built by `buildPiCommand()` (`--model` — the only builder whose model regex admits `:` and `/`, for `sonnet:high` and `openai/gpt-4o` — plus `--provider`, `--thinking`, `--session ` / `-c`, and the TRI-STATE `--approve`/`--no-approve`). ⚠️ **Pi has no permission prompts and no sandbox**, so there is no `--dangerously-skip-permissions` analog and Codeman must not invent one; the privilege-shaped knob is `approveProjectTrust`, which makes pi LOAD AND EXECUTE repo-local `.pi/extensions` TypeScript and npm-install missing project packages. It therefore joins `clampExternalCliBypassForOwner()`'s **materialize** branch (gemini's, not codex/antigravity's only-if-sent one): an absent config still yields `--no-approve` for a non-granted owner, because pi's own default is an interactive prompt the session user could answer themselves. ⚠️ `--api-key` is NEVER wired — it would put a provider secret on the spawn command line. ⚠️ Pi stays **out** of `isAltScreenStripMode()`: its default TUI renders into the main screen with terminal-owned scrollback (nothing to strip), and since 0.84.0 the user can flip to a fullscreen TUI at runtime via `/settings`, where the alt screen is load-bearing — being out of the list is exactly what makes that switch safe. ⚠️ Only the `PI_*` env prefix was added; pi's ~34 provider keys share no prefix and `ALLOWED_ENV_PREFIXES` is a single GLOBAL list with no mode context, so admitting them would widen the allowlist for every mode at once (a mode-aware allowlist is the tracked follow-up). ⚠️ `pi` is a short, GENERIC binary name, so unlike the sibling resolvers `pi-cli-resolver.ts` sanity-probes `pi --version` (cached, vitest-skipped) and requires semver-shaped output; `GET /api/pi/status` carries `version` on top of the sibling `{available, path}` shape so a misresolution is diagnosable. Local echo: pi lands on the `'buffer'` overlay via the fallthrough in `_updateLocalEchoState` (pinned in `test/local-echo-codex-gating.test.ts`); if pi's live composer turns out to fight it the way codex's did, the fallback is one `'off'` branch. Tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts` (first-ever coverage of the clamp). +**OMP (`omp`, Oh My Pi) specifics** (`docs/omp-integration.md`): architecturally the simplest of the family — omp owns its own auth, provider routing, and trust decisions entirely in `~/.omp` config files (default `tools.approvalMode: yolo`), so `buildOmpCommand()` only ever emits `--model`/`--resume `/`--continue`, and there is no bypass-permissions flag for Codeman to wire or clamp. ⚠️ **That does NOT make the multi-user clamp a no-op**: `OMP_*` is an allowlisted `envOverrides` prefix and admits `OMP_AUTH_BROKER_URL`/`OMP_AUTH_BROKER_TOKEN` (where omp resolves credentials from), both dropped for a non-granted owner in `clampEnvOverridesForOwner()` — the same shape as `DEEPSEEK_BASE_URL` — even though, unlike DeepSeek, Codeman forwards no operator-held key into an omp pane today (found in Ark0N/Codeman#353 review). `--continue` alone is ambiguous the moment any other omp conversation has touched the same working directory more recently, since it just picks the newest session file on disk — `resolveAndClaimOmpSessionId()` (`src/utils/omp-session-resolver.ts`) resolves and PINS the real id instead, verifying each candidate's own file header (`{"type":"session","id",cwd"}`, not just the mangled-directory match) and tracking already-claimed ids in a process-wide registry so two omp tabs in the same case dir can't alias onto each other's conversation. ⚠️ Resolution/pinning happens ONLY at the point a respawn is actually confirmed (`_pinOmpRespawnId()`, called from `_setupOrAttachMuxSession()`'s dead-pane branch and `reattachRemote()`) — earlier code resolved eagerly while merely building respawn options, which could mis-pin a still-ALIVE session's id purely from boot-recovery timing. `src/omp-transcript.ts` independently scans `~/.omp/agent/sessions/**/*.jsonl` for Past Sessions history, the omp analog of Claude's own transcript scan, so a conversation survives even a full "Kill Tmux". ⚠️ omp's own env knobs are mostly `PI_*`, not `OMP_*` (`PI_CONFIG_DIR`, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, `PI_SUBPROCESS_CMD`, `PI_SHELL_PREFIX`), and `PI_*` is already allowlisted globally for pi — so a redirected `PI_CONFIG_DIR` silently moves the `~/.omp` tree the resolver and transcript scanner hardcode, degrading pinning/history with no error; a known gap shared with pi, not fixed here. ⚠️ Docker: `appendResumeFlag()`'s `case 'omp'` keys off the top-level `resumeSessionId`, which Docker panes never receive for omp (built from `defaultDockerCommandForMode`, with no `ompConfig` threaded through) — host-side history recovery and pinning work through the shared `sessions/` mount, but `--resume` does not currently reach an in-container omp process on respawn (flagged in review, not yet fixed). Stays out of `isAltScreenStripMode()` (narrow scrollback strip, alt-screen toggles only) and lands on the `'buffer'` local-echo policy via the `_updateLocalEchoState` fallthrough, same as grok and pi. Resolver: `omp-cli-resolver.ts` version-probes like pi/grok (`omp` is a short, generic name) and requires `omp/`-shaped output; `OMP_SEARCH_DIRS` leads with `~/.local/bin` (omp.sh's installer targets `$HOME/.local/bin` with no `--dir` override — verified against a real `--no-cache` Docker build, `~/.omp/bin` was the wrong first guess). Tests: `test/omp-mode.test.ts`, `test/omp-cli-resolver.test.ts`, `test/omp-session-resolver.test.ts`, `test/omp-fresh-run-no-resume.test.ts`. + **Codex input path (issues #218/#219/#220/#222)**: codex-mode sessions use **predictive write-through echo, never the buffer overlay**. The buffer overlay stays disabled exactly as 1.12.2 left it (`_updateLocalEchoState` in terminal-ui.js, same branch as shell; `_localEchoEnabled` remains false for codex), and the additive `_localEchoPolicy` field selects `'predict'` for codex when `localEchoEnabled` is on. Codex's composer is interactive per keystroke: typing "/" pops a live-filtering command picker (#222 was "picker never appears" because the "/" sat in the overlay until Enter), the composer grows/rewraps as it fills (#220: a long typed prompt existed ONLY in the overlay DOM, so codex never grew the composer), arrows and Ctrl+Backspace edit server-side state (#218: arrows were forwarded to an EMPTY composer while the typed text sat pending; the `\x08` control-char flush then left the overlay stateless so `\x7f` was swallowed as "nothing to remove"), and pastes arrive bracketed (#219: `terminal.paste()` wraps in `\x1b[200~..201~`, which the multi-byte-ESC branch forwarded WITHOUT flushing pending text, so the paste landed before it). The shared overlay branch (claude/gemini/opencode still buffer) gained three fixes: bracketed pastes flush pending text first, composer nav keys (`isComposerNavKey` allowlist in `CodemanTerminalInput` — arrows/Home/End/Delete/PgUp/PgDn incl. modifiers, deliberately excluding DA/CPR/DSR query responses) flush and hand the session to **pass-through** (plain PTY echo until Enter/Ctrl+C, because after cursor movement the append-only overlay cannot track edits), and a backspace that finds no overlay state is FORWARDED instead of swallowed. ⚠️ **Codex drops keystrokes that arrive in the same PTY read as a bracketed paste** (upstream `bottom_pane/paste_burst.rs` holds rapid chars for paste classification; verified against codex 0.147.0 by writing `hello\x1b[200~PASTED\x1b[201~` into the tmux client PTY in one write → composer shows only `PASTED`, while a 100ms gap yields `helloPASTED`), so the flush sends the typed text immediately and delays the paste sequence by 80ms — the same two-phase shape as the Enter branch's delayed `\r`. Related protocol fact: xterm.js sends `0x08` for Ctrl+Backspace, which codex's keymap binds to delete-ONE-char (`ctrl(Char('h'))`); real word-delete needs the kitty CSI-u encoding (`\x1b[127;5u`), which xterm.js 6.0.0 cannot emit (kitty support lands in 6.1.0-beta) — an upstream limitation, not a Codeman bug. E2E technique: codex 0.147 reaches its composer with any dummy key in `$CODEX_HOME/auth.json` (`{"OPENAI_API_KEY":"sk-test-..."}`), so a real TUI can be driven headlessly (envOverrides `CODEX_HOME` rides the `CODEX_*` allowlist) without real credentials. **Predictive write-through echo invariants** (the codex echo mode, `PredictiveEchoAddon` in `packages/xterm-zerolag-input`): (1) the onData hook `_predictHookOnData` is a PLAIN STATEMENT between the buffer block and Normal Mode — no `return`, try/catch-wrapped, never touches `_pendingInput` — so the wire path is byte-identical with the predictor active, absent or throwing (pinned at vm level and by an end-to-end trace-equality E2E); (2) it ships as a SEPARATE bundle `vendor/xterm-predictive-echo.js` so the zerolag bundle stays byte-identical, and a missing/broken bundle degrades codex to plain 1.12.2 echo (`typeof PredictiveEchoOverlay !== 'undefined'` guard); (3) predictions paint only while the cursor sits on the measured composer row (`isCodexComposerRow`, `CODEX_COMPOSER_ROW_RE = /^› /` — matches the empty-composer placeholder, typing, and the slash picker; rejects modal rows and 2-space wrapped continuation rows, the #220 ghost zone, which deliberately fall back to real echo); (4) reconciliation reads the PARSED buffer with `baseY + row` (xterm's `cursorY` is baseY-relative; `viewportY` only coincides while scrolled to bottom), confirms prefix-only on cell match PLUS cursor advance, cascades only on TWO consecutive foreign NON-BLANK passes (blanks are neutral: codex clears its placeholder on first echo), and TTL-bounds the rest; (5) after an UNPREDICTED wire edit (backspace into echoed text, any 'clear'-classified input, an IME/plain-paste 'text' commit, or every bypass send incl. `_handleCjkInput`) the addon holds new predictions until the next PARSED write: the displayed cursor is stale for one RTT and anchoring on it paints ghosts one cell off; (6) the per-device `localEchoEnabled` toggle is the kill switch returning exact 1.12.2 behavior. Measured constants + fixtures: `docs/predictive-echo-plan.md`, recorded via `scripts/dev/record-codex-frames.mjs` through the production tmux+strip pipeline. Tests: `test/local-echo-codex-gating.test.ts` (vm harness: nav-key + predict classifier truth tables, policy matrix, wire-neutrality pins), `packages/xterm-zerolag-input/test/` (addon laws, real-fixture replay, seeded fuzz), `test/codex-predictive-echo.test.ts` (E2E vs real codex incl. byte-identity + 300ms-RTT). ### Remote sessions over SSH diff --git a/docs/omp-integration.md b/docs/omp-integration.md index 79c3fea6..d4e59dc9 100644 --- a/docs/omp-integration.md +++ b/docs/omp-integration.md @@ -1,10 +1,10 @@ # OMP (Oh My Pi) sessions -Codeman can drive [OMP](https://github.com/can1357/omp) (`omp`, Oh My Pi) as a session +Codeman can drive [OMP](https://github.com/can1357/oh-my-pi) (`omp`, Oh My Pi) as a session backend, alongside Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi, Grok and -DeepSeek Harness. `omp` is an eighth **run mode**: its own PTY, its own tmux session, -its own tab identity. It is not a location overlay like Docker or remote-SSH cases, -and it is not a web tab. +DeepSeek Harness. `omp` is the ninth CLI backend (tenth `SessionMode`, counting +`shell`): its own PTY, its own tmux session, its own tab identity. It is not a +location overlay like Docker or remote-SSH cases, and it is not a web tab. ## Install @@ -12,17 +12,19 @@ and it is not a web tab. curl -fsSL https://omp.sh/install | sh ``` -The installer places the binary in `~/.omp/bin`. Codeman resolves the binary via the -server PATH and then the usual install locations (`~/.omp/bin` first, then -`~/.local/bin`, `/usr/local/bin`, `~/.bun/bin`, `~/.npm-global/bin`, `~/bin`). +The installer places the binary in `~/.local/bin` (verified against a real +`--no-cache` Docker build — see `docker/agent.Dockerfile`; an earlier guess of +`~/.omp/bin` was wrong). Codeman resolves the binary via the server PATH and then +the usual install locations (`~/.local/bin` first, then `~/.omp/bin`, +`/usr/local/bin`, `~/.bun/bin`, `~/.npm-global/bin`, `~/bin`). **`omp` is a short name**, so like `pi` and `grok` the resolver does not trust a PATH hit on its own: it runs `omp --version` and requires `omp/`-shaped output -(e.g. `omp/17.4.0`) before accepting a candidate. Check what it resolved: +(e.g. `omp/18.0.8`) before accepting a candidate. Check what it resolved: ```bash curl -s localhost:3000/api/omp/status | jq -# { "available": true, "path": "/home/you/.omp/bin", "version": "17.4.0" } +# { "available": true, "path": "/home/you/.local/bin", "version": "18.0.8" } ``` ## Authenticate @@ -115,13 +117,21 @@ alt-screen-strip list and lands on the `'buffer'` local-echo policy via the ## Docker cases The agent image installs omp in its own Dockerfile step (not npm; omp's installer -targets `$HOME/.omp/bin` with no `--dir` override, the same shape as grok's +targets `$HOME/.local/bin` with no `--dir` override, the same shape as grok's installer). Rebuild with the mandatory `--no-cache`: ```bash node scripts/build-agent-image.mjs --no-cache ``` +⚠️ **`--resume` pinning does not currently reach an in-container omp process.** +Docker panes are built from `defaultDockerCommandForMode`, which never sees +`ompConfig` — `appendResumeFlag()`'s `case 'omp'` keys off the top-level +`resumeSessionId` field, which nothing populates for omp today. Host-side history +recovery still works (the shared `sessions/` mount below), but a respawned +in-container omp pane falls back to its own ambiguous `--continue`, not a pinned +id. Flagged in upstream review, not yet fixed. + Credentials are **mostly seeded**, but `sessions/` is the one exception in this CLI family: `~/.omp/agent/{config.yml,mcp.json,models.yml,settings.yml}` are seeded (read-only mount, copied into the container's own `~/.omp/agent` once), so an @@ -140,7 +150,7 @@ shared nor seeded. `omp` mode is routed through an interactive login shell (`exec "$SHELL" -i -l -c 'omp'`), because sshd's remote-command PATH does not -include `~/.omp/bin`. Per-session config and `envOverrides` do not cross ssh and are +include `~/.local/bin`. Per-session config and `envOverrides` do not cross ssh and are rejected rather than silently ignored; use the per-host command override instead. ## Known gaps diff --git a/src/utils/omp-cli-resolver.ts b/src/utils/omp-cli-resolver.ts index 269b64d2..91f046e5 100644 --- a/src/utils/omp-cli-resolver.ts +++ b/src/utils/omp-cli-resolver.ts @@ -23,10 +23,16 @@ import { type CliResolverHost, } from './cli-executable-resolver.js'; -/** Common directories where the OMP CLI binary may be installed */ +/** + * 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(), '.omp', 'bin'), join(homedir(), '.local', 'bin'), + join(homedir(), '.omp', 'bin'), '/usr/local/bin', join(homedir(), '.bun', 'bin'), join(homedir(), '.npm-global', 'bin'), diff --git a/src/web/public/styles.css b/src/web/public/styles.css index b4e2eac7..20d3ff7b 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -3892,7 +3892,8 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { background: linear-gradient(135deg, #312e81 0%, #6366f1 55%, #818cf8 100%); box-shadow: 0 4px 20px rgba(129, 140, 248, 0.3), 0 0 40px rgba(79, 70, 229, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.08); border-color: rgba(165, 180, 252, 0.5); - color: #eef2ff; transform: translateY(-1px); + color: #eef2ff; + transform: translateY(-1px); } /* Grok (xAI): monochrome charcoal identity, matching .btn-toolbar.btn-run.mode-grok @@ -5016,7 +5017,8 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { background: linear-gradient(135deg, #3730a3 0%, #6366f1 55%, #818cf8 100%); box-shadow: 0 0 12px rgba(129, 140, 248, 0.35), 0 2px 8px rgba(79, 70, 229, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.08); border-color: rgba(165, 180, 252, 0.6); - color: #eef2ff;} + color: #eef2ff; +} /* Grok mode colors. Same cascade note as pi above: this base-sheet pair only renders on the `og` skin — the nested `html:not([data-skin="og"])` block diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 9aa0736b..9c9e7b40 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -3243,7 +3243,6 @@ export function registerSessionRoutes( // rule the existing-case branch above states; this branch used to exclude just // the five external CLIs and let `shell` through). if (docker && docker.hooksEnabled && mode === 'claude') { - // configured project. Skipped for external CLIs (they use their own systems). try { if (!existsSync(join(resolvedCasePath, 'CLAUDE.md'))) { const templatePath = await ctx.getDefaultClaudeMdPath(); From b6d0f1fa32ad391ee9465ec4ea6c0ca74bc2a1d7 Mon Sep 17 00:00:00 2001 From: timkjr Date: Fri, 28 Aug 2026 15:19:19 -0500 Subject: [PATCH 19/19] fix(omp): wire OMP into install.sh's CLI detection (it had none) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every other CLI (claude/opencode/codex/gemini/antigravity/pi/grok/dsh) has a check_*/get_*_path pair wired into install.sh's detection loop and the "no AI CLI found" aggregate checks. OMP had neither -- a user with only omp installed would be told no CLI was found and offered to install Claude Code or OpenCode. Added OMP_SEARCH_PATHS (mirrors src/utils/omp-cli-resolver.ts's OMP_SEARCH_DIRS) and check_omp()/get_omp_path(), wired into both aggregate conditions (the interactive install-menu trigger and the end-of-run reminder) and added omp's real vendor curl one-liner to the reminder block. The DeepSeek Harness line was never in that reminder to begin with -- confirmed it has no vendor one-liner (dsh installs via Codeman's own API after the server is already up), so it stays out, with an explanatory line instead. Also fixed the "Skip" menu text, which was missing Gemini and DeepSeek Harness from its example list independent of the omp gap, and the same stale sibling-CLI-list bug (missing DeepSeek Harness and OMP, "the eight"/"这七个") in README.md and the repo's existing README.zh-CN.md. --- README.md | 4 ++-- README.zh-CN.md | 4 ++-- install.sh | 59 +++++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 59 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 837b0d9e..326d3d82 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ This installs Node.js, tmux and a build toolchain if missing (node-pty ships no - **Re-run to update.** The same one-liner updates a finished install in place: local changes in `~/.codeman/app` are stashed (never discarded), and a running service is restarted and verified. If a first install was interrupted, re-running resumes the full setup instead. `install.sh update` and `install.sh uninstall` also exist. - **CI / headless:** without a terminal attached, steps that would change your system abort with instructions instead of running silently. Set `CODEMAN_NONINTERACTIVE=1` to approve them for automation. -You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), or [OMP](https://github.com/can1357/omp) (any combination works; Gemini CLI is enterprise-only since Google's consumer cutover, and Antigravity is its successor). The installer detects whichever of the eight is present; if none is found, it offers to install Claude Code or OpenCode, or you can skip and install one yourself later. After install: +You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), or [OMP](https://github.com/can1357/oh-my-pi) (any combination works; Gemini CLI is enterprise-only since Google's consumer cutover, and Antigravity is its successor). The installer detects whichever of the nine is present; if none is found, it offers to install Claude Code or OpenCode, or you can skip and install one yourself later. After install: ```bash codeman web @@ -171,7 +171,7 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist wsl bash -c "curl -fsSL https://getcodeman.com/install | bash" ``` -Codeman requires tmux, so Windows users need [WSL](https://learn.microsoft.com/en-us/windows/wsl/install). If you don't have WSL yet: run `wsl --install` in an admin PowerShell, reboot, open Ubuntu, then install your preferred AI coding CLI inside WSL ([Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), or [OMP](https://github.com/can1357/omp)). After installing, `http://localhost:3000` is accessible from your Windows browser. +Codeman requires tmux, so Windows users need [WSL](https://learn.microsoft.com/en-us/windows/wsl/install). If you don't have WSL yet: run `wsl --install` in an admin PowerShell, reboot, open Ubuntu, then install your preferred AI coding CLI inside WSL ([Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), or [OMP](https://github.com/can1357/oh-my-pi)). After installing, `http://localhost:3000` is accessible from your Windows browser. diff --git a/README.zh-CN.md b/README.zh-CN.md index 5d37a5c4..6d8d528b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -58,7 +58,7 @@ curl -fsSL https://getcodeman.com/install | bash - **重跑即更新。** 再次运行同一条命令即可原地更新已完成的安装:`~/.codeman/app` 中的本地改动会被 stash(绝不丢弃),运行中的服务会自动重启并校验。若首次安装中途失败,重跑会继续完成完整的安装流程。也可以使用 `install.sh update` 与 `install.sh uninstall`。 - **CI / 无终端环境:** 没有终端时,涉及系统改动的步骤会带着说明中止,而不是静默执行;在自动化场景设置 `CODEMAN_NONINTERACTIVE=1` 即可批准这些步骤。 -你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli)、[Antigravity](https://antigravity.google)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[Pi](https://pi.dev) 或 [Grok Build](https://github.com/xai-org/grok-build)(任意组合均可;自 Google 面向消费者停售后,Gemini CLI 仅限企业版,Antigravity 是其继任者)。安装器会自动检测这七个中已安装的任意一个;若一个都没有,会提供安装 Claude Code 或 OpenCode 的选项,也可以选择跳过、稍后自行安装。安装完成后: +你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli)、[Antigravity](https://antigravity.google)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[Pi](https://pi.dev)、[Grok Build](https://github.com/xai-org/grok-build)、[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 或 [OMP](https://github.com/can1357/oh-my-pi)(任意组合均可;自 Google 面向消费者停售后,Gemini CLI 仅限企业版,Antigravity 是其继任者)。安装器会自动检测这九个中已安装的任意一个;若一个都没有,会提供安装 Claude Code 或 OpenCode 的选项,也可以选择跳过、稍后自行安装。安装完成后: ```bash codeman web @@ -141,7 +141,7 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist wsl bash -c "curl -fsSL https://getcodeman.com/install | bash" ``` -Codeman 依赖 tmux,因此 Windows 用户需要 [WSL](https://learn.microsoft.com/en-us/windows/wsl/install)。如果还没装 WSL:在管理员 PowerShell 中运行 `wsl --install`,重启,打开 Ubuntu,然后在 WSL 内安装你偏好的 AI 编程 CLI([Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli)、[Antigravity](https://antigravity.google)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[Pi](https://pi.dev) 或 [Grok Build](https://github.com/xai-org/grok-build))。安装完成后,即可从 Windows 浏览器访问 `http://localhost:3000`。 +Codeman 依赖 tmux,因此 Windows 用户需要 [WSL](https://learn.microsoft.com/en-us/windows/wsl/install)。如果还没装 WSL:在管理员 PowerShell 中运行 `wsl --install`,重启,打开 Ubuntu,然后在 WSL 内安装你偏好的 AI 编程 CLI([Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli)、[Antigravity](https://antigravity.google)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[Pi](https://pi.dev)、[Grok Build](https://github.com/xai-org/grok-build)、[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 或 [OMP](https://github.com/can1357/oh-my-pi))。安装完成后,即可从 Windows 浏览器访问 `http://localhost:3000`。 diff --git a/install.sh b/install.sh index 4d1ef847..70d3cffd 100755 --- a/install.sh +++ b/install.sh @@ -149,6 +149,17 @@ ANTIGRAVITY_SEARCH_PATHS=( "$HOME/bin/agy" ) +# OMP CLI search paths (from src/utils/omp-cli-resolver.ts's OMP_SEARCH_DIRS — +# ~/.local/bin leads, omp.sh's installer target; ~/.omp/bin is a fallback only) +OMP_SEARCH_PATHS=( + "$HOME/.local/bin/omp" + "$HOME/.omp/bin/omp" + "/usr/local/bin/omp" + "$HOME/.bun/bin/omp" + "$HOME/.npm-global/bin/omp" + "$HOME/bin/omp" +) + # ============================================================================ # Color Output # ============================================================================ @@ -692,6 +703,37 @@ get_grok_path() { done } +# `omp` is a short name too, so like grok/pi the server-side resolver +# additionally probes `omp --version`. Detection here only feeds the +# "you have no AI CLI" hint, so a plain executable test is enough. +check_omp() { + if command -v omp &>/dev/null; then + return 0 + fi + + for path in "${OMP_SEARCH_PATHS[@]}"; do + if [[ -x "$path" ]]; then + return 0 + fi + done + + return 1 +} + +get_omp_path() { + if command -v omp &>/dev/null; then + command -v omp + return + fi + + for path in "${OMP_SEARCH_PATHS[@]}"; do + if [[ -x "$path" ]]; then + echo "$path" + return + fi + done +} + check_cloudflared() { # Check ~/.local/bin first (matches tunnel-manager.ts resolution order) if [[ -x "$HOME/.local/bin/cloudflared" ]]; then @@ -2335,6 +2377,7 @@ main() { local has_pi=false local has_grok=false local has_dsh=false + local has_omp=false info "Checking AI CLI tools..." if check_claude; then @@ -2369,17 +2412,21 @@ main() { has_dsh=true success "DeepSeek Harness found at $(get_dsh_path)" fi + if check_omp; then + has_omp=true + success "OMP CLI found at $(get_omp_path)" + fi - if [[ "$has_claude" == "false" && "$has_opencode" == "false" && "$has_codex" == "false" && "$has_gemini" == "false" && "$has_antigravity" == "false" && "$has_pi" == "false" && "$has_grok" == "false" && "$has_dsh" == "false" ]]; then + if [[ "$has_claude" == "false" && "$has_opencode" == "false" && "$has_codex" == "false" && "$has_gemini" == "false" && "$has_antigravity" == "false" && "$has_pi" == "false" && "$has_grok" == "false" && "$has_dsh" == "false" && "$has_omp" == "false" ]]; then echo "" - warn "No AI CLI found. Codeman needs at least one: Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, or DeepSeek Harness." + warn "No AI CLI found. Codeman needs at least one: Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek Harness, or OMP." headless_guard "install an AI CLI (curl | bash from its vendor)" echo "" echo -e " ${BOLD}Which AI CLI would you like to install?${NC}" echo -e " ${CYAN}1)${NC} Claude Code (Anthropic)" echo -e " ${CYAN}2)${NC} OpenCode (open-source)" echo -e " ${CYAN}3)${NC} Both" - echo -e " ${CYAN}4)${NC} Skip (I'll install one myself, e.g. Codex, Antigravity, Pi or Grok)" + echo -e " ${CYAN}4)${NC} Skip (I'll install one myself, e.g. Codex, Antigravity, Gemini, Pi, Grok, DeepSeek Harness or OMP)" echo "" local cli_choice="" @@ -2728,7 +2775,7 @@ main() { echo -e " https://github.com/Ark0N/Codeman" echo "" - if ! check_claude && ! check_opencode && ! check_codex && ! check_gemini && ! check_antigravity && ! check_pi && ! check_grok && ! check_dsh; then + if ! check_claude && ! check_opencode && ! check_codex && ! check_gemini && ! check_antigravity && ! check_pi && ! check_grok && ! check_dsh && ! check_omp; then echo -e " ${YELLOW}${BOLD}Reminder:${NC} Install at least one AI CLI to start using Codeman:" echo -e " ${CYAN}curl -fsSL https://claude.ai/install.sh | bash${NC} # Claude Code" echo -e " ${CYAN}curl -fsSL https://opencode.ai/install | bash${NC} # OpenCode" @@ -2736,7 +2783,11 @@ main() { echo -e " ${CYAN}curl -fsSL https://antigravity.google/cli/install.sh | bash${NC} # Antigravity" echo -e " ${CYAN}npm install -g --ignore-scripts @earendil-works/pi-coding-agent${NC} # Pi" echo -e " ${CYAN}curl -fsSL https://x.ai/cli/install.sh | bash${NC} # Grok" + echo -e " ${CYAN}curl -fsSL https://omp.sh/install | sh${NC} # OMP" echo "" + echo -e " DeepSeek Harness has no vendor one-liner — install it from within Codeman" + echo -e " once the server is up (Run dropdown → Install DeepSeek Profile, or see" + echo -e " docs/deepseek-integration.md)." fi # Security notice — last informational block so it stays visible (when not