From 4f5678fac40dd59ecba75e096b4ba0ea7d0251b5 Mon Sep 17 00:00:00 2001 From: timkjr Date: Tue, 18 Aug 2026 23:17:58 -0500 Subject: [PATCH 01/55] 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/55] 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/55] 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 b85f7659b77ce05e9089d83e0acffc88728fbf44 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:38:38 +0800 Subject: [PATCH 04/55] feat(docker): add Compose deployment support --- .dockerignore | 13 +++ .gitignore | 3 + README.md | 2 + docker/.env.example | 69 +++++++++++++ docker/README.md | 96 +++++++++++++++++++ docker/Start-Codeman.sh | 73 ++++++++++++++ docker/docker-compose.yaml | 65 +++++++++++++ docker/server.Dockerfile | 94 ++++++++++++++++++ docs/docker-compose.md | 68 +++++++++++++ src/docker-hosts.ts | 33 ++++++- src/tmux-manager.ts | 31 +++++- src/web/route-helpers.ts | 4 +- src/web/routes/session-routes.ts | 6 +- test/docker-exec-options.test.ts | 20 ++++ test/docker-hosts.test.ts | 27 ++++++ .../session-routes-workspace-hooks.test.ts | 45 ++++++++- 16 files changed, 631 insertions(+), 18 deletions(-) create mode 100644 .dockerignore create mode 100644 docker/.env.example create mode 100644 docker/README.md create mode 100644 docker/Start-Codeman.sh create mode 100644 docker/docker-compose.yaml create mode 100644 docker/server.Dockerfile create mode 100644 docs/docker-compose.md diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..10602399 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.agents +.claude +.codex +.env +offload.md +node_modules +dist +coverage +out +test-results +tmp +*.log diff --git a/.gitignore b/.gitignore index 954c0144..d7aad08c 100644 --- a/.gitignore +++ b/.gitignore @@ -77,6 +77,9 @@ tmp/ # added there). No trailing slash so it still matches the symlink, not just dirs. /public +# Machine-specific Docker Compose handover notes +/offload.md + # Opt-in gesture overlay runtime assets: large MediaPipe wasm + model (~27 MB) # fetched at build/install by scripts/fetch-gesture-assets.mjs, kept out of git. # (The gesture bundle itself, gesture-codeman.js, IS tracked — built from diff --git a/README.md b/README.md index 2a728ebc..dd019a76 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,8 @@ 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). +Prefer Docker Compose? This repository includes a local-image Compose deployment: copy `docker/.env.example` to `docker/.env`, set `CODEMAN_PASSWORD`, then run `bash docker/Start-Codeman.sh` on Linux. See the [Docker deployment guide](docker/README.md) for direct Compose commands, storage, and networking options. + - **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) - **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 diff --git a/docker/.env.example b/docker/.env.example new file mode 100644 index 00000000..55000fc6 --- /dev/null +++ b/docker/.env.example @@ -0,0 +1,69 @@ +# ============================================================================= +# Codeman Docker Compose environment template +# Copy this file to .env and set the values for the Docker host. +# ============================================================================= + +TZ=Australia/Perth + +# Optional overrides for direct `docker compose` use. The Bash start script +# detects these values from CODEMAN_APPDATA_PATH automatically. Compose uses +# 1000:1000 when the variables are omitted. +# PUID=1000 +# PGID=1000 + +# Name of the account that runs Codeman and all local CLI sessions. Changing +# this value rebuilds the image with a matching account. +CODEMAN_RUNTIME_USER=opencode + +# Required. Persistent Codeman application data, CLI credentials, and session +# state are stored here on the host and mounted at the runtime account's home +# directory in the container. +CODEMAN_APPDATA_PATH=/mnt/user/appdata/Coding/codeman + +# Required for Docker cases. This must be an absolute path on the Docker host. +# Codeman and each isolated case use this same path, so it cannot be a +# container-only path such as /home/opencode/codeman-cases. +CODEMAN_CASES_PATH=/mnt/user/appdata/Coding/codeman/codeman-cases + +# Required. Network bind address, host port, and local image tag. +CODEMAN_HOST=0.0.0.0 +CODEMAN_PORT=3000 +CODEMAN_IMAGE=codeman:local + +# Required for any network-accessible Codeman instance. Use a unique, strong +# password. This file is safe to commit; copy it to .env and set the value. +CODEMAN_PASSWORD=changeme + +# Required. Username for Codeman HTTP Basic authentication. +CODEMAN_USERNAME=admin + +# Optional: authenticate Gemini CLI without an interactive login. +GEMINI_API_KEY= + +# Linux default. On Docker Desktop, use the socket path supported by your +# Docker installation when it differs from /var/run/docker.sock. +DOCKER_SOCKET=/var/run/docker.sock + +# Optional override for direct `docker compose` use. The Bash start script +# detects this from DOCKER_SOCKET automatically. The direct Compose default is +# 999, but the correct value depends on the Docker host. +# DOCKER_SOCKET_GID=999 + +# Set to 1 only when Docker-case hook callbacks are required. +CODEMAN_DOCKER_BRIDGE_HOOKS=0 + +# Set to 1 when `docker info` reports `SwapLimit=false`. The case memory limit +# remains active; Codeman omits --memory-swap and filters the daemon's exact +# unsupported-swap warning while preserving all other Docker create errors. +CODEMAN_DOCKER_DISABLE_SWAP_LIMIT=0 + +# Required only when applying the macvlan example in README.md. +CODEMAN_MACVLAN_NETWORK=br0.11 +CODEMAN_IPV4_ADDRESS=10.10.11.236 +CODEMAN_MAC_ADDRESS=02:10:11:00:00:EC + +# Required only when creating a new managed macvlan network, rather than using +# the external-network macvlan example. +CODEMAN_MACVLAN_PARENT=br0.11 +CODEMAN_MACVLAN_SUBNET=10.10.11.0/24 +CODEMAN_MACVLAN_GATEWAY=10.10.11.1 diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 00000000..190896ab --- /dev/null +++ b/docker/README.md @@ -0,0 +1,96 @@ +# Codeman Docker deployment + +This folder contains the Compose configuration, server image Dockerfile, and environment template for a locally built Codeman server. + +## Start + +From the repository root, create the runtime environment file and set the required values, especially `CODEMAN_PASSWORD`. + +```sh +cp docker/.env.example docker/.env +bash docker/Start-Codeman.sh +``` + +On PowerShell, use the following command instead. + +```powershell +Copy-Item docker/.env.example docker/.env +docker compose --env-file docker/.env -f docker/docker-compose.yaml up --build -d +``` + +Every required value is defined and explained in `.env.example`. `GEMINI_API_KEY` is intentionally optional and may remain blank. + +On Linux, `Start-Codeman.sh` stops with an error when required paths are missing. It creates the application-data directory when safe, detects its numeric owner as `PUID:PGID`, and detects `DOCKER_SOCKET_GID` from the configured Docker socket. It rejects a root-owned application-data directory because Codeman and its local CLI sessions must remain unprivileged. + +Codeman, Claude, OpenCode, and other local sessions run as the unprivileged account named by `CODEMAN_RUNTIME_USER`, which defaults to `opencode`. When Compose is run directly, `PUID` and `PGID` default to `1000:1000`; set them in `.env` when the application-data directory has a different owner. The Bash start script determines them automatically instead. + +To retain Docker-case support without root when running Compose directly, set `DOCKER_SOCKET_GID` to the numeric group ID of the host socket. On a standard Linux Docker host, obtain it with `stat -c '%g' /var/run/docker.sock`. The Bash start script detects it automatically. + +## Application data storage + +The default configuration uses a host-folder bind mount: + +```yaml +volumes: + - type: bind + source: ${CODEMAN_APPDATA_PATH} + target: /home/${CODEMAN_RUNTIME_USER} +``` + +Set `CODEMAN_APPDATA_PATH` in `.env` to a directory that the Docker daemon can access. The example value is `/mnt/user/appdata/Coding/codeman`. + +`CODEMAN_CASES_PATH` is the separate host directory for managed case workspaces. It is mounted into Codeman at the same absolute path, allowing the host Docker daemon to bind it into an isolated case container. Set it to a child directory of `CODEMAN_APPDATA_PATH` unless you deliberately store workspaces elsewhere. + +Compose also exposes `CODEMAN_APPDATA_PATH` to Codeman as `CODEMAN_DOCKER_HOST_HOME`. This lets Docker case seed files, CLI credentials and the hook secret be mounted using paths that exist in the host daemon's filesystem. Direct host installations do not set this variable and retain their existing behaviour. + +Set `CODEMAN_DOCKER_DISABLE_SWAP_LIMIT=1` when `docker info` reports `SwapLimit=false`. Codeman continues to apply the configured case memory limit, omits Docker's unsupported `--memory-swap` option, and filters only the daemon's exact swap-capability warning. Every other Docker create error and its exit status remain visible. + +For an existing installation created by a root-running image, change ownership of the application-data directory before upgrading so the configured `PUID` and `PGID` can read the saved credentials and state: + +```sh +chown -R 99:100 /mnt/user/appdata/Coding/codeman +``` + +Replace `99:100` and the path with the values from your `.env` file. + +Do not replace this bind mount with a Docker-managed named volume when Docker cases are enabled. Codeman passes seed, credential, transcript and hook-secret bind sources to the host Docker daemon, so their source files must have stable paths in the daemon's filesystem. A named volume does not provide the required host path mapping. + +## Static macvlan networking + +The default configuration publishes a host port. It does not use `network_mode: host`. To attach Codeman directly to an existing external macvlan network with a static IP address and MAC address, remove the `ports:` section and add the following to the `codeman` service: + +```yaml +mac_address: ${CODEMAN_MAC_ADDRESS} +networks: + codeman_lan: + ipv4_address: ${CODEMAN_IPV4_ADDRESS} +``` + +Then add this top-level network declaration: + +```yaml +networks: + codeman_lan: + external: true + name: ${CODEMAN_MACVLAN_NETWORK} +``` + +Set `CODEMAN_MACVLAN_NETWORK`, `CODEMAN_IPV4_ADDRESS`, and `CODEMAN_MAC_ADDRESS` in `.env`. The values in `.env.example` match the supplied Unraid example network and should be changed for other hosts. + +### Create a managed macvlan network + +If an external macvlan network does not already exist, use this top-level declaration instead. Do not use it together with the external-network declaration. + +```yaml +networks: + codeman_lan: + driver: macvlan + driver_opts: + parent: ${CODEMAN_MACVLAN_PARENT} + ipam: + config: + - subnet: ${CODEMAN_MACVLAN_SUBNET} + gateway: ${CODEMAN_MACVLAN_GATEWAY} +``` + +Macvlan containers are ordinarily not reachable from their Docker host without additional host-network routing. Confirm the selected address, MAC address, parent interface, and subnet are reserved and valid for the target network before starting the stack. diff --git a/docker/Start-Codeman.sh b/docker/Start-Codeman.sh new file mode 100644 index 00000000..cb7a677e --- /dev/null +++ b/docker/Start-Codeman.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +env_file="$script_dir/.env" +compose_file="$script_dir/docker-compose.yaml" + +if [[ ! -f "$env_file" ]]; then + printf 'Error: Docker environment file is missing: %s\n' "$env_file" >&2 + printf 'Create it from %s/.env.example before starting Codeman.\n' "$script_dir" >&2 + exit 1 +fi + +compose_command=(docker compose --env-file "$env_file" -f "$compose_file") +appdata_path=$( + "${compose_command[@]}" config --environment | + awk -F= '$1 == "CODEMAN_APPDATA_PATH" { sub(/^[^=]*=/, ""); print; exit }' +) +docker_socket=$( + "${compose_command[@]}" config --environment | + awk -F= '$1 == "DOCKER_SOCKET" { sub(/^[^=]*=/, ""); print; exit }' +) + +if [[ -z "$appdata_path" ]]; then + printf 'Error: CODEMAN_APPDATA_PATH is not set in %s\n' "$env_file" >&2 + exit 1 +fi + +if [[ ! -d "$appdata_path" ]]; then + if [[ "$EUID" == '0' ]]; then + printf 'Error: Refusing to create CODEMAN_APPDATA_PATH as root: %s\n' "$appdata_path" >&2 + printf 'Create it as the unprivileged account that should run Codeman, then retry.\n' >&2 + exit 1 + fi + mkdir -p -- "$appdata_path" +fi + +if owner_ids=$(stat -c '%u:%g' -- "$appdata_path" 2>/dev/null); then + : +elif owner_ids=$(stat -f '%u:%g' "$appdata_path" 2>/dev/null); then + : +else + printf 'Error: Cannot determine the owner of CODEMAN_APPDATA_PATH: %s\n' "$appdata_path" >&2 + exit 1 +fi + +export PUID=${owner_ids%%:*} +export PGID=${owner_ids##*:} + +if [[ "$PUID" == '0' ]]; then + printf 'Error: CODEMAN_APPDATA_PATH is owned by root: %s\n' "$appdata_path" >&2 + printf 'Change the directory ownership to the unprivileged account that should run Codeman.\n' >&2 + exit 1 +fi + +if [[ -z "$docker_socket" || ! -S "$docker_socket" ]]; then + printf 'Error: DOCKER_SOCKET is not a Unix socket: %s\n' "${docker_socket:-}" >&2 + exit 1 +fi + +if socket_ids=$(stat -c '%u:%g' -- "$docker_socket" 2>/dev/null); then + : +elif socket_ids=$(stat -f '%u:%g' "$docker_socket" 2>/dev/null); then + : +else + printf 'Error: Cannot determine the owner of DOCKER_SOCKET: %s\n' "$docker_socket" >&2 + exit 1 +fi + +export DOCKER_SOCKET_GID=${socket_ids##*:} + +exec docker compose --env-file "$env_file" -f "$compose_file" up --build -d diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml new file mode 100644 index 00000000..2548ccb5 --- /dev/null +++ b/docker/docker-compose.yaml @@ -0,0 +1,65 @@ +name: codeman + +services: + codeman: + build: + context: .. + dockerfile: docker/server.Dockerfile + args: + CODEMAN_RUNTIME_USER: ${CODEMAN_RUNTIME_USER} + PGID: ${PGID:-1000} + PUID: ${PUID:-1000} + image: ${CODEMAN_IMAGE} + init: true + restart: unless-stopped + ports: + - "${CODEMAN_PORT}:${CODEMAN_PORT}" + environment: + CODEMAN_DOCKER_BRIDGE_HOOKS: ${CODEMAN_DOCKER_BRIDGE_HOOKS} + # Host-side equivalent of the runtime user's HOME. Docker case seed, + # credential and hook mounts are translated into the daemon namespace. + CODEMAN_DOCKER_HOST_HOME: ${CODEMAN_APPDATA_PATH} + CODEMAN_DOCKER_DISABLE_SWAP_LIMIT: ${CODEMAN_DOCKER_DISABLE_SWAP_LIMIT} + CODEMAN_CASES_PATH: ${CODEMAN_CASES_PATH} + CODEMAN_HOST: ${CODEMAN_HOST} + CODEMAN_PASSWORD: ${CODEMAN_PASSWORD} + CODEMAN_PORT: ${CODEMAN_PORT} + CODEMAN_USERNAME: ${CODEMAN_USERNAME} + GEMINI_API_KEY: ${GEMINI_API_KEY} + PGID: ${PGID:-1000} + PUID: ${PUID:-1000} + TZ: ${TZ} + group_add: + # Retain access to the host Docker socket without running as root. + - ${DOCKER_SOCKET_GID:-999} + volumes: + # Application data and CLI credentials persist on the configured host + # path, rather than in a Docker-managed volume. + - type: bind + source: ${CODEMAN_APPDATA_PATH} + target: /home/${CODEMAN_RUNTIME_USER} + # Docker cases are sibling containers on the host daemon. Their workspace + # must be visible to Codeman at the same absolute path used by that daemon. + - type: bind + source: ${CODEMAN_CASES_PATH} + target: ${CODEMAN_CASES_PATH} + # Codeman uses the host daemon to create isolated Docker cases. This is + # Docker-outside-of-Docker, not Docker-in-Docker. + - type: bind + source: ${DOCKER_SOCKET} + target: /var/run/docker.sock + extra_hosts: + - "host.docker.internal:host-gateway" + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + healthcheck: + test: + - CMD-SHELL + - >- + node -e "fetch('http://127.0.0.1:${CODEMAN_PORT}/api/status').then((response) => process.exit(response.status < 500 ? 0 : 1)).catch(() => process.exit(1))" + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s diff --git a/docker/server.Dockerfile b/docker/server.Dockerfile new file mode 100644 index 00000000..bc6aaca7 --- /dev/null +++ b/docker/server.Dockerfile @@ -0,0 +1,94 @@ +# syntax=docker/dockerfile:1 + +# Build the application from the checkout supplied as the Docker build context. +# No published Codeman application image is required. +FROM node:22-bookworm-slim AS build + +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3 make g++ \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /opt/codeman + +COPY . . + +RUN npm ci \ + && npm run build \ + && npm prune --omit=dev --ignore-scripts \ + && npm cache clean --force + +# The Docker CLI talks to the host daemon through the socket mounted by +# docker/docker-compose.yaml. It does not run a Docker daemon in this container. +FROM node:22-bookworm-slim + +ARG CODEMAN_RUNTIME_USER=opencode +ARG PUID=1000 +ARG PGID=1000 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + docker.io \ + git \ + openssh-client \ + procps \ + ripgrep \ + tmux \ + && rm -rf /var/lib/apt/lists/* + +# Keep credentials out of the image. Users authenticate these CLIs at runtime +# through Codeman sessions, and the configured host bind mount retains state. +RUN npm install --global \ + @anthropic-ai/claude-code \ + @google/gemini-cli \ + @openai/codex \ + opencode-ai \ + && npm cache clean --force + +# Keep the web server and every local Codeman session unprivileged. PUID and +# PGID match the host-owned application-data directory mounted by Compose. The +# requested GID may not exist in the base image, and a host UID such as 1000 may +# already belong to the baked `node` account, so handle both cases explicitly. +RUN set -eux; \ + case "${PUID}" in ''|*[!0-9]*) echo "PUID must be numeric" >&2; exit 1;; esac; \ + case "${PGID}" in ''|*[!0-9]*) echo "PGID must be numeric" >&2; exit 1;; esac; \ + if [ "${PUID}" -eq 0 ]; then \ + echo "PUID must identify an unprivileged account, not root" >&2; \ + exit 1; \ + fi; \ + if ! getent group "${PGID}" >/dev/null; then \ + groupadd --gid "${PGID}" codeman-runtime; \ + fi; \ + existing_user="$(getent passwd "${PUID}" | cut -d: -f1 || true)"; \ + if [ -n "${existing_user}" ]; then \ + usermod \ + --login "${CODEMAN_RUNTIME_USER}" \ + --gid "${PGID}" \ + --home "/home/${CODEMAN_RUNTIME_USER}" \ + --move-home \ + --shell /bin/bash \ + "${existing_user}"; \ + else \ + useradd \ + --uid "${PUID}" \ + --gid "${PGID}" \ + --create-home \ + --home-dir "/home/${CODEMAN_RUNTIME_USER}" \ + --shell /bin/bash \ + "${CODEMAN_RUNTIME_USER}"; \ + fi + +WORKDIR /opt/codeman + +COPY --from=build /opt/codeman /opt/codeman + +ENV CODEMAN_PORT=3000 \ + HOME=/home/${CODEMAN_RUNTIME_USER} \ + NODE_ENV=production + +EXPOSE 3000 + +USER ${CODEMAN_RUNTIME_USER} + +CMD ["node", "dist/index.js", "web"] diff --git a/docs/docker-compose.md b/docs/docker-compose.md new file mode 100644 index 00000000..a86bdb00 --- /dev/null +++ b/docs/docker-compose.md @@ -0,0 +1,68 @@ +# Docker Compose deployment + +This configuration builds the Codeman application image locally from this checkout. It does not download or depend on a pre-built Codeman image. + +For the Compose configuration, environment settings, storage migration, and macvlan networking examples, see the [Docker deployment guide](../docker/README.md). + +The image includes Claude Code, Codex, Gemini CLI, and OpenCode. Authenticate a CLI from its Codeman session; credentials are never baked into the image. + +## Prerequisites + +- Docker Engine or Docker Desktop with Docker Compose v2 +- A reachable Docker daemon + +The application container mounts the Docker daemon socket so Codeman can create and manage its isolated Docker cases. Treat anyone who can administer this Compose project as having Docker-host-equivalent access. + +## Start + +Copy the environment template, set a strong password, and confirm `CODEMAN_APPDATA_PATH`. The example maps `/mnt/user/appdata/Coding/codeman` on the host to `/home/${CODEMAN_RUNTIME_USER}` in the container, preserving Codeman state and CLI credentials outside Docker-managed volumes. + +```sh +cp docker/.env.example docker/.env +``` + +On PowerShell, use the following command instead. + +```powershell +Copy-Item docker/.env.example docker/.env +``` + +On Linux, run the stack with the start script. It determines `PUID` and `PGID` from the owner of `CODEMAN_APPDATA_PATH`, and `DOCKER_SOCKET_GID` from the configured Docker socket, before invoking Compose. A root-owned application-data directory is rejected so the runtime account cannot become UID 0. + +```sh +bash docker/Start-Codeman.sh +``` + +On other platforms, run Compose directly. `PUID` and `PGID` default to `1000:1000`; set them in `docker/.env` when the application-data directory has a different owner. + +```sh +docker compose --env-file docker/.env -f docker/docker-compose.yaml up --build -d +``` + +Open `http://localhost:3000` and sign in with the username and password from `docker/.env`. + +## Operations + +The local image is tagged `codeman:local` by default. Change `CODEMAN_IMAGE` in `docker/.env` if a different local tag suits your environment. + +```sh +docker compose --env-file docker/.env -f docker/docker-compose.yaml logs -f codeman +bash docker/Start-Codeman.sh +docker compose --env-file docker/.env -f docker/docker-compose.yaml down +``` + +`CODEMAN_APPDATA_PATH` holds Codeman state and survives container recreation. Remove that host directory only when deliberately resetting the installation. + +`CODEMAN_CASES_PATH` must be an absolute path on the Docker host. Compose mounts it at the same path inside Codeman, so the host daemon can bind the managed workspace into isolated Docker cases. Do not set it to `/home/${CODEMAN_RUNTIME_USER}/codeman-cases`. + +Compose passes `CODEMAN_APPDATA_PATH` into Codeman as `CODEMAN_DOCKER_HOST_HOME`. Codeman uses that value to translate generated Docker seed, credential and hook-secret bind sources from the container's home path into paths visible to the host Docker daemon. + +If `docker info` reports `SwapLimit=false`, set `CODEMAN_DOCKER_DISABLE_SWAP_LIMIT=1`. Isolated cases retain their configured memory limit. Codeman omits the unsupported swap-limit option and filters only the daemon's exact swap-capability warning while retaining every other Docker create error. + +If that directory was created by an earlier root-running image, change its ownership to the configured `PUID:PGID` before starting this version. This preserves existing CLI credentials and session state while allowing the unprivileged runtime account to use them. + +## Docker cases + +The default socket path is `/var/run/docker.sock`, which works with a standard Linux Docker Engine. The Bash start script detects its numeric group ID. When running Compose directly, set `DOCKER_SOCKET_GID`, for example using `stat -c '%g' /var/run/docker.sock`, so the unprivileged `CODEMAN_RUNTIME_USER` account can create Docker cases. Docker Desktop users should set `DOCKER_SOCKET` in `docker/.env` only when their Docker installation exposes a different compatible socket path. + +Codeman Docker cases are sibling containers on the host daemon, not children of the application container. The Compose configuration handles their workspace bind mount through `CODEMAN_CASES_PATH`; the `/home/${CODEMAN_RUNTIME_USER}` application-data mapping is for Codeman state and ordinary in-container sessions, not sibling-case workspaces. diff --git a/src/docker-hosts.ts b/src/docker-hosts.ts index e2a92486..2a38d3d5 100644 --- a/src/docker-hosts.ts +++ b/src/docker-hosts.ts @@ -23,7 +23,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import fs from 'node:fs/promises'; -import { join, dirname } from 'node:path'; +import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { homedir } from 'node:os'; import { createHash } from 'node:crypto'; @@ -276,6 +276,24 @@ export interface DockerMount { readonly?: boolean; } +/** + * Resolve a bind source into the Docker daemon's filesystem namespace. + * + * A bare-host Codeman process and its Docker daemon see the same HOME, so the + * source is returned unchanged. In Docker-outside-of-Docker deployments, + * `runtimeHome` is the path inside Codeman while `daemonHome` is the host path + * bind-mounted there. Sources beneath HOME must therefore be translated before + * they are sent through the Docker socket. + */ +export function resolveDockerDaemonMountSource(source: string, runtimeHome: string, daemonHome?: string): string { + const configuredDaemonHome = daemonHome?.trim(); + if (!configuredDaemonHome) return source; + + const relativeSource = relative(resolve(runtimeHome), resolve(source)); + if (relativeSource.startsWith('..') || isAbsolute(relativeSource)) return source; + return resolve(configuredDaemonHome, relativeSource); +} + /** * Resolved, IO-free context for buildDockerCreateArgs. The caller (tmux-manager) * resolves the environment-dependent bits (host uid, existing cred mounts, the @@ -299,6 +317,8 @@ export interface DockerCreateContext { addHostGateway: boolean; /** Engine host-gateway alias (host.docker.internal / host.containers.internal). */ gatewayAlias: string; + /** Omit --memory-swap when the host kernel cannot enforce swap limits. */ + disableSwapLimit?: boolean; } /** @@ -316,12 +336,15 @@ function mountSpec(m: DockerMount): string { return `type=bind,src=${m.src},dst=${m.dst}${m.readonly ? ',readonly' : ''}`; } -function resourceFlags(resources?: DockerResourceLimits): string[] { +function resourceFlags(resources?: DockerResourceLimits, disableSwapLimit = false): string[] { if (!resources) return []; const flags: string[] = []; if (resources.memory) { - // memory-swap == memory disables swap, making --memory a REAL OOM cap. - flags.push('--memory', resources.memory, '--memory-swap', resources.memory); + flags.push('--memory', resources.memory); + // memory-swap == memory disables swap where the daemon supports swap + // accounting. Some kernels, including the deployed Unraid host, do not; + // requesting it there emits a warning and Docker ignores the value. + if (!disableSwapLimit) flags.push('--memory-swap', resources.memory); } if (resources.cpus) flags.push('--cpus', resources.cpus); if (resources.pidsLimit) flags.push('--pids-limit', String(resources.pidsLimit)); @@ -386,7 +409,7 @@ export function buildDockerCreateArgs(ctx: DockerCreateContext): string[] { if (addHostGateway) args.push('--add-host', `${gatewayAlias}:host-gateway`); args.push( - ...resourceFlags(docker.resources), + ...resourceFlags(docker.resources, ctx.disableSwapLimit), // GPU passthrough (needs the NVIDIA container toolkit on the host). No storage // cap is set, so the container's writable layer + volumes grow elastically as // data flows in (bounded only by host disk). diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 5088793d..188b279e 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -74,6 +74,7 @@ import { hostGatewayAlias, resolveDockerClaudeArtifacts, resolveDockerCredentialArtifacts, + resolveDockerDaemonMountSource, type DockerCreateContext, type DockerMount, type DockerSeedCopy, @@ -1319,8 +1320,23 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string { const startFailMsg = shellescape(`Codeman: container ${docker.containerName} failed to start (docker daemon down?)`); const imageCheck = `${base} image inspect ${image} >/dev/null 2>&1 || { echo ${imageMissingMsg}; exit 1; }`; - // create-if-missing (idempotent): reconnect / boot recovery re-runs this exact chain. - const ensure = `${base} inspect ${name} >/dev/null 2>&1 || ${base} ${createArgs}`; + // create-if-missing (idempotent): reconnect / boot recovery re-runs this exact + // chain. A daemon without swap accounting warns whenever --memory is present, + // even when --memory-swap is omitted. In compatibility mode, retain the memory + // cap and filter ONLY that exact warning; all other stdout/stderr and the real + // create exit status are preserved so mount/config failures remain visible. + // A session-unique file avoids shell variables and command substitution, both + // of which would be expanded too early by the nested bash/tmux launch layers. + const createOutputPath = shellescape(`/tmp/codeman-create-${sessionId}.log`); + const filteredCreateOutput = `sed '/^WARNING: Your kernel does not support swap limit capabilities or the cgroup is not mounted\\. Memory limited without swap\\.$/d' ${createOutputPath}`; + const removeCreateOutput = `rm -f ${createOutputPath}`; + const createCommand = createContext.disableSwapLimit + ? `{ if ${base} ${createArgs} >${createOutputPath} 2>&1; ` + + `then ${filteredCreateOutput}; ${removeCreateOutput}; ` + + `elif ${base} inspect ${name} >/dev/null 2>&1; then ${removeCreateOutput}; ` + + `else ${filteredCreateOutput} >&2; ${removeCreateOutput}; false; fi; }` + : `${base} ${createArgs}`; + const ensure = `${base} inspect ${name} >/dev/null 2>&1 || ${createCommand}`; const start = `${base} start ${name} >/dev/null 2>&1 || { echo ${startFailMsg}; exit 1; }`; // Seed writable credential config from read-only host mounts ONCE per container // (guarded by [ -e ] so reconnects never clobber in-container config; `cp -a` for @@ -1431,11 +1447,18 @@ export function resolveDockerLaunchOptions( sessionId, instance: CODEMAN_INSTANCE, userArgs, - credentialMounts, - extraMounts, + credentialMounts: credentialMounts.map((mount) => ({ + ...mount, + src: resolveDockerDaemonMountSource(mount.src, home, process.env.CODEMAN_DOCKER_HOST_HOME), + })), + extraMounts: extraMounts.map((mount) => ({ + ...mount, + src: resolveDockerDaemonMountSource(mount.src, home, process.env.CODEMAN_DOCKER_HOST_HOME), + })), envCreate, addHostGateway: !isDesktop, gatewayAlias, + disableSwapLimit: process.env.CODEMAN_DOCKER_DISABLE_SWAP_LIMIT === '1', }; const execEnv: Record = { diff --git a/src/web/route-helpers.ts b/src/web/route-helpers.ts index 8585358c..e56969b1 100644 --- a/src/web/route-helpers.ts +++ b/src/web/route-helpers.ts @@ -26,7 +26,9 @@ import { SYNTHETIC_ADMIN, findUser } from '../user-store.js'; // Shared path constants used across route modules. CASES_DIR (project folders) // stays shared across instances; SETTINGS_PATH is per-instance runtime state. -export const CASES_DIR = join(homedir(), 'codeman-cases'); +// Docker Compose deployments set CODEMAN_CASES_PATH to a host-absolute bind +// mount so Docker cases can share the same workspace path with the host daemon. +export const CASES_DIR = process.env.CODEMAN_CASES_PATH || join(homedir(), 'codeman-cases'); export const SETTINGS_PATH = dataPath('settings.json'); /** diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 3e7c258f..b4654d67 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -2994,9 +2994,9 @@ export function registerSessionRoutes( casePath = dockerCase.hostWorkspacePath; // a REAL host dir (bind-mounted into the container) docker = sessionDocker; - // Seed resume so a relaunch resumes the case's last conversation from the - // bind-mounted transcript (decision: resume-on-start default ON). - if (sessionDocker.resumeOnStart && dockerCase.lastClaudeSessionId) { + // Seed only Claude's resume id. Codex, Gemini, and the other CLIs have + // separate conversation stores and must never receive a Claude UUID. + if (mode === 'claude' && sessionDocker.resumeOnStart && dockerCase.lastClaudeSessionId) { dockerResumeId = dockerCase.lastClaudeSessionId; } } else { diff --git a/test/docker-exec-options.test.ts b/test/docker-exec-options.test.ts index e89fc0ac..ad1c061d 100644 --- a/test/docker-exec-options.test.ts +++ b/test/docker-exec-options.test.ts @@ -80,6 +80,26 @@ describe('buildDockerLaunchCommand', () => { expect(cmd).toContain("docker start 'codeman-case-myproj'"); }); + it('avoids eager create expansion, tolerates a concurrent creator, and preserves real failures in compatibility mode', () => { + const opts = launchOpts(); + opts.createContext.disableSwapLimit = true; + const cmd = buildDockerLaunchCommand(opts); + // No command substitution or shell variables: either could expand eagerly + // before the inspect side of || short-circuits in a nested launch shell. + expect(cmd).not.toContain('$('); + expect(cmd).not.toContain('codeman_create_output'); + expect(cmd).toContain('if docker create'); + expect(cmd).toContain("'/tmp/codeman-create-1a2b3c4d5e6f.log'"); + // If another session created the case between inspect and create, re-inspect + // succeeds and the losing creator continues without printing the conflict. + expect(cmd).toContain("elif docker inspect 'codeman-case-myproj' >/dev/null 2>&1; then rm -f"); + expect(cmd).toContain('Your kernel does not support swap limit capabilities'); + expect(cmd).toContain('else sed'); + expect(cmd).toContain('>&2; rm -f'); + expect(cmd).toContain('; false; fi;'); + expect(cmd).not.toContain('--memory-swap'); + }); + it('execs a TTY into the durable in-container tmux', () => { const cmd = buildDockerLaunchCommand(launchOpts()); expect(cmd).toContain("exec docker exec -it --workdir '/home/arkon/cases/myproj'"); diff --git a/test/docker-hosts.test.ts b/test/docker-hosts.test.ts index 3d848433..ac8257fb 100644 --- a/test/docker-hosts.test.ts +++ b/test/docker-hosts.test.ts @@ -32,6 +32,7 @@ import { resolveClaudeJsonSeedMount, resolveDockerClaudeArtifacts, resolveDockerCredentialArtifacts, + resolveDockerDaemonMountSource, toSessionDocker, writeDockerCases, writeDockerHosts, @@ -267,6 +268,32 @@ describe('buildDockerCreateArgs', () => { expect(s).not.toContain('--storage-opt'); expect(buildDockerCreateArgs(ctx()).join(' ')).not.toContain('--gpus'); }); + + it('omits the unsupported swap limit while retaining the memory limit when disabled', () => { + const s = buildDockerCreateArgs(ctx({ disableSwapLimit: true })).join(' '); + expect(s).toContain('--memory 4g'); + expect(s).not.toContain('--memory-swap'); + }); +}); + +describe('resolveDockerDaemonMountSource', () => { + const runtimeHome = join(tmpdir(), 'codeman-runtime-home'); + const daemonHome = join(tmpdir(), 'codeman-daemon-home'); + + it('maps paths beneath the runtime HOME into the daemon-visible HOME', () => { + const source = join(runtimeHome, '.codeman', 'docker-seeds', 'codeman-case-test1.json'); + expect(resolveDockerDaemonMountSource(source, runtimeHome, daemonHome)).toBe( + join(daemonHome, '.codeman', 'docker-seeds', 'codeman-case-test1.json') + ); + }); + + it('preserves direct-host and non-HOME sources', () => { + const source = join(runtimeHome, '.claude', 'settings.json'); + expect(resolveDockerDaemonMountSource(source, runtimeHome)).toBe(source); + + const outsideHome = join(tmpdir(), 'codeman-cases', 'test1'); + expect(resolveDockerDaemonMountSource(outsideHome, runtimeHome, daemonHome)).toBe(outsideHome); + }); }); describe('resolveDockerCredentialArtifacts (isolated codex/gemini/gcloud/opencode)', () => { diff --git a/test/routes/session-routes-workspace-hooks.test.ts b/test/routes/session-routes-workspace-hooks.test.ts index c6038aae..4957226d 100644 --- a/test/routes/session-routes-workspace-hooks.test.ts +++ b/test/routes/session-routes-workspace-hooks.test.ts @@ -19,19 +19,20 @@ * including the sweep's deleted-workspace guard. */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import Fastify, { type FastifyInstance } from 'fastify'; import fastifyCookie from '@fastify/cookie'; import { mkdtemp, rm, readFile, mkdir, writeFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { createMockRouteContext } from '../mocks/index.js'; +import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js'; import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; import { generateHooksConfig, applyWorkspaceHooks } from '../../src/hooks-config.js'; import { getDataDir } from '../../src/config/instance.js'; import { CASES_DIR } from '../../src/web/route-helpers.js'; +import { Session } from '../../src/session.js'; interface HooksFile { hooks?: Record }>>; @@ -223,6 +224,7 @@ describe('POST /api/sessions workspace hooks', () => { describe('POST /api/quick-start workspace hooks', () => { let app: FastifyInstance; + let ctx: MockRouteContext; const quickStart = (payload: Record) => app.inject({ method: 'POST', url: '/api/quick-start', payload }); @@ -230,15 +232,19 @@ describe('POST /api/quick-start workspace hooks', () => { const hooksFileIn = (dir: string) => join(dir, '.claude', 'settings.local.json'); beforeEach(async () => { + vi.spyOn(Session.prototype, 'startInteractive').mockResolvedValue(undefined); + vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined); app = Fastify({ logger: false }); await app.register(fastifyCookie); - registerSessionRoutes(app, createMockRouteContext()); + ctx = createMockRouteContext(); + registerSessionRoutes(app, ctx); installRouteErrorHandler(app); await app.ready(); }); afterEach(async () => { await app.close(); + vi.restoreAllMocks(); // Docker fixtures + case dirs must not leak into the next test. await rm(join(getDataDir(), 'docker-hosts.json'), { force: true }); await rm(join(getDataDir(), 'docker-cases.json'), { force: true }); @@ -260,7 +266,7 @@ describe('POST /api/quick-start workspace hooks', () => { }); /** Minimal docker host + case fixtures (docker IO is no-op'd under vitest). */ - const writeDockerFixtures = async (caseName: string, hostWorkspacePath: string) => { + const writeDockerFixtures = async (caseName: string, hostWorkspacePath: string, lastClaudeSessionId?: string) => { await mkdir(getDataDir(), { recursive: true }); await writeFile( join(getDataDir(), 'docker-hosts.json'), @@ -268,7 +274,7 @@ describe('POST /api/quick-start workspace hooks', () => { ); await writeFile( join(getDataDir(), 'docker-cases.json'), - JSON.stringify([{ name: caseName, type: 'docker', hostId: 'd1', hostWorkspacePath }]) + JSON.stringify([{ name: caseName, type: 'docker', hostId: 'd1', hostWorkspacePath, lastClaudeSessionId }]) ); }; @@ -300,6 +306,35 @@ describe('POST /api/quick-start workspace hooks', () => { await rm(ws, { recursive: true, force: true }); } }); + + it.each(['codex', 'gemini'] as const)('does not pass a saved Claude conversation id to Docker %s', async (mode) => { + const ws = await mkdtemp(join(tmpdir(), `codeman-docker-${mode}-`)); + try { + await writeDockerFixtures('dockexternal', ws, 'e83a9063-3cb4-44d2-a9a0-df153b81721f'); + + const res = await quickStart({ caseName: 'dockexternal', mode }); + expect(res.statusCode).toBe(200); + const session = ctx.sessions.get(JSON.parse(res.body).sessionId); + expect(session?.toState().resumeSessionId).toBeUndefined(); + } finally { + await rm(ws, { recursive: true, force: true }); + } + }); + + it('passes a saved Claude conversation id only to Docker Claude', async () => { + const ws = await mkdtemp(join(tmpdir(), 'codeman-docker-resume-')); + const resumeId = 'e83a9063-3cb4-44d2-a9a0-df153b81721f'; + try { + await writeDockerFixtures('dockresume', ws, resumeId); + + const res = await quickStart({ caseName: 'dockresume', mode: 'claude' }); + expect(res.statusCode).toBe(200); + const session = ctx.sessions.get(JSON.parse(res.body).sessionId); + expect(session?.toState().resumeSessionId).toBe(resumeId); + } finally { + await rm(ws, { recursive: true, force: true }); + } + }); }); describe('applyWorkspaceHooks (the shared decision core in hooks-config)', () => { From e2179bd5309cca4a6e4f9cc64db2fb577e8b1067 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:42:37 +0800 Subject: [PATCH 05/55] chore(docker): remove local handover references --- .dockerignore | 1 - .gitignore | 3 --- 2 files changed, 4 deletions(-) diff --git a/.dockerignore b/.dockerignore index 10602399..1fcd377d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,7 +3,6 @@ .claude .codex .env -offload.md node_modules dist coverage diff --git a/.gitignore b/.gitignore index d7aad08c..954c0144 100644 --- a/.gitignore +++ b/.gitignore @@ -77,9 +77,6 @@ tmp/ # added there). No trailing slash so it still matches the symlink, not just dirs. /public -# Machine-specific Docker Compose handover notes -/offload.md - # Opt-in gesture overlay runtime assets: large MediaPipe wasm + model (~27 MB) # fetched at build/install by scripts/fetch-gesture-assets.mjs, kept out of git. # (The gesture bundle itself, gesture-codeman.js, IS tracked — built from From 26b4ffbb0f8e4ef116bbc6c89eafdaa5ab241117 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:47:57 +0800 Subject: [PATCH 06/55] fix(docker): install pnpm for DeepSeek profile --- docker/agent.Dockerfile | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docker/agent.Dockerfile b/docker/agent.Dockerfile index 36213aaa..147feb41 100644 --- a/docker/agent.Dockerfile +++ b/docker/agent.Dockerfile @@ -75,8 +75,9 @@ RUN curl -fsSL https://x.ai/cli/install.sh | bash \ # profile itself is installed further down, into the `agent` HOME, because # Codeman deliberately does NOT seed `profiles/` from the host: it is a # per-profile node_modules tree, host-arch-specific and far too large to copy on -# every container start. -RUN npm install -g @deepseek-ai/dsh \ +# every container start. The harness delegates profile dependency management to +# pnpm, so pnpm is a build dependency rather than optional runtime tooling. +RUN npm install -g @deepseek-ai/dsh pnpm \ && npm cache clean --force \ && dsh --version @@ -110,6 +111,10 @@ 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 \ + && DSH_HOME=/home/agent/.dsh HOME=/home/agent \ + dsh plugin --profile dsh-tui install --ignore-scripts \ + && printf '%s\n' '' 'allowBuilds:' ' "@google/genai": true' ' protobufjs: true' \ + >> /home/agent/.dsh/profiles/dsh-tui/pnpm-workspace.yaml \ && 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 \ From d8688dc143f1f19da02a69a23cd0324364fb7048 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 28 Aug 2026 13:59:40 +0200 Subject: [PATCH 07/55] fix(web): drop the provider label from the plan-usage chip when there is only one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chip prefixes every row with the provider name, so a machine that only has Claude limits renders "CLAUDE 5H 60% 7D 23%" — a 46px label naming the only thing it could possibly be. The name exists to tell two rows apart, so it should only appear when there are two. updatePlanUsageChip() now checks whether both Claude and Codex actually have windows before building the rows, and emits the .pu-provider span only in that case. The tooltip keeps naming the provider in both cases: it has the room, and the chip no longer does. Verified in a browser on an isolated beta instance: Claude-only renders bare windows with no .pu-provider in the DOM, Codex-only the same, and the two-provider chip is byte-identical to before. Co-Authored-By: Claude Opus 5 (1M context) --- src/web/public/app.js | 7 ++++++- test/plan-usage-chip.test.ts | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/web/public/app.js b/src/web/public/app.js index 72898890..fc750d49 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -2638,10 +2638,15 @@ class CodemanApp { if (!Number.isFinite(n)) return ''; return `${label}${n}%`; }; + // The provider label only earns its space when there is more than one + // provider to tell apart: a machine with Claude alone shows bare windows. + const hasWindows = (usage) => pct(usage?.fiveHour) !== null || pct(usage?.sevenDay) !== null; + const labelled = hasWindows(data) && hasWindows(data.codex); const row = (provider, usage) => { const windows = [seg('5h', pct(usage?.fiveHour)), seg('7d', pct(usage?.sevenDay))].filter(Boolean); if (!windows.length) return ''; - return `${provider}${windows.join('·')}`; + const label = labelled ? `${provider}` : ''; + return `${label}${windows.join('·')}`; }; const rows = [row('Claude', data), row('Codex', data.codex)].filter(Boolean); chip.innerHTML = rows.length ? rows.join('') : '—'; diff --git a/test/plan-usage-chip.test.ts b/test/plan-usage-chip.test.ts index 09b379b5..4a801eed 100644 --- a/test/plan-usage-chip.test.ts +++ b/test/plan-usage-chip.test.ts @@ -79,4 +79,36 @@ describe('header plan usage chip', () => { expect(codexRow).not.toContain('5h'); expect(codexRow).toContain('7d'); }); + + it('drops the provider label when Claude is the only provider with limits', () => { + const { CodemanApp, chip } = loadCodemanAppClass(); + const app = Object.create((CodemanApp as { prototype: object }).prototype) as UsageApp; + + app.updatePlanUsageChip({ + fiveHour: { usedPercentage: 60, resetAt: 1000 }, + sevenDay: { usedPercentage: 23, resetAt: 2000 }, + }); + + expect(chip.innerHTML).toContain('class="pu-row"'); + expect(chip.innerHTML).not.toContain('pu-provider'); + expect(chip.innerHTML).not.toContain('Claude'); + expect(chip.innerHTML).toContain('60%'); + expect(chip.innerHTML).toContain('23%'); + // The tooltip still names the provider — it has room, and the chip no longer does. + expect(chip.title).toContain('Claude plan usage'); + }); + + it('drops the provider label when Codex is the only provider with limits', () => { + const { CodemanApp, chip } = loadCodemanAppClass(); + const app = Object.create((CodemanApp as { prototype: object }).prototype) as UsageApp; + + app.updatePlanUsageChip({ + codex: { fiveHour: { usedPercentage: 12, resetAt: 3000 } }, + }); + + expect(chip.innerHTML).toContain('class="pu-row"'); + expect(chip.innerHTML).not.toContain('pu-provider'); + expect(chip.innerHTML).toContain('12%'); + expect(chip.title).toContain('Codex plan usage'); + }); }); From 9841f4ffb93c97fe8398569342854496d857299c Mon Sep 17 00:00:00 2001 From: timkjr Date: Thu, 20 Aug 2026 20:54:53 -0500 Subject: [PATCH 08/55] 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 09/55] 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 10/55] 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 11/55] 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 12/55] 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 13/55] 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 14/55] 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 15/55] 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 16/55] 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 17/55] 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 18/55] 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 19/55] 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 20/55] 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 21/55] 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 22/55] 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 23/55] 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 From da5f5447d016813e0889a10457c7037ea2606cc6 Mon Sep 17 00:00:00 2001 From: timkjr Date: Sat, 29 Aug 2026 15:21:33 -0500 Subject: [PATCH 24/55] fix(remote): never auto-revive a remote session after a clean agent exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The COD-108 reconnect watcher treated any dead local pane as a dropped transport and re-ran the pane command — so a normal ctrl-c/ctrl-d on a remote claude/opencode/omp auto-spawned a FRESH agent (claude only looked correct because its '--session-id || --resume' fallback resumed, with a loud 'already in use' error first). Distinguish a transport drop from an intentional exit: only reconnect when the durable remote tmux session (codeman-ssh-*) is verifiably still alive on the remote host. A clean exit tears that session down; the watcher now probes it via ssh has-session and skips (remote-gone) when it is gone OR unknown (fail closed). The probe is cached per-session and fired async so the 5s tick never blocks on ssh. Tests: 3 new cases pinning remote-gone / unknown / alive decisions. Verified live: all remote CLIs stay dead after ctrl-c/ctrl-d. --- src/remote-hosts.ts | 53 ++++++++++++++++++++++++++++++ src/remote-reconnect.ts | 20 ++++++++++- src/tmux-manager.ts | 47 ++++++++++++++++++++++++-- test/remote-auto-reconnect.test.ts | 41 +++++++++++++++++++++-- 4 files changed, 155 insertions(+), 6 deletions(-) diff --git a/src/remote-hosts.ts b/src/remote-hosts.ts index a82bdb37..2b9106f3 100644 --- a/src/remote-hosts.ts +++ b/src/remote-hosts.ts @@ -326,6 +326,59 @@ export async function probeRemoteCliVersion( } } +/** + * COD-108 — build the SSH command that asks whether THIS Codeman's durable + * remote tmux session (`-L codeman-remote -s codeman-ssh-`) is still alive + * on the remote host. + * + * `has-session` exits 0 when the session exists, non-zero otherwise (and + * stderr is swallowed). Connection options come from the shared + * `buildSshConnectionArgs` so this probe reaches exactly the hosts the launch + * can reach — same port/identity/proxy/jump-host as `buildRemoteLaunchCommand`. + */ +export function buildRemoteSessionAliveCommand( + host: Pick & RemoteSshOptions, + remoteSessionName: string +): string { + const [ssh, ...connectionArgs] = buildSshConnectionArgs(host); + const remoteCmd = `tmux -L codeman-remote has-session -t ${shellescape(remoteSessionName)} 2>/dev/null`; + return [ssh, ...connectionArgs, remoteSshTarget(host), shellescape(remoteCmd)].join(' '); +} + +/** + * COD-108 — resolve whether THIS Codeman's durable remote tmux session is still + * alive on the remote host, for the auto-reconnect watcher. + * + * Returns: + * - `true` → the remote tmux session exists (the agent is still running + * on the remote; the LOCAL pane died from a transport drop → + * safe to auto-reconnect). + * - `false` → the remote session is gone (the agent exited cleanly and + * the remote tmux tore down; reviving would relaunch a fresh + * agent — must NOT auto-reconnect). + * - `undefined` → probe failed (host unreachable, ssh error, tmux missing). + * Callers MUST treat this as "do not reconnect": an + * unreachable host is not a reason to relaunch the agent. + * + * VITEST guard — returns `true` under test so a real ssh never runs; the + * command construction is covered by `buildRemoteSessionAliveCommand`. + */ +export async function remoteTmuxSessionAlive( + remote: Pick & RemoteSshOptions, + remoteSessionName: string +): Promise { + if (process.env.VITEST) return true; + const command = buildRemoteSessionAliveCommand(remote, remoteSessionName); + try { + const { stdout } = await execAsync(command, { timeout: 15_000 }); + // has-session prints the session name on success (exit 0). Anything else is + // a non-zero exit → the session is gone. + return stdout.trim().length > 0; + } catch { + return undefined; + } +} + /** * COD-105 — build the SSH command that lists `codeman-*` tmux sessions on a * remote host's canonical `-L codeman` socket. diff --git a/src/remote-reconnect.ts b/src/remote-reconnect.ts index 6e5a709a..bbcfcd86 100644 --- a/src/remote-reconnect.ts +++ b/src/remote-reconnect.ts @@ -108,6 +108,15 @@ export interface ReconnectSessionView { isRemote: boolean; /** Result of `isPaneDead(muxName)` for this session. */ paneDead: boolean; + /** + * Whether the DURABLE remote tmux session is still alive on the remote host. + * Tri-state: `true` = transport drop with the agent still running (safe to + * reattach); `false` = the remote session is gone (the agent exited cleanly + * via ctrl-c/ctrl-d/exit and the remote tmux tore down); `undefined` = + * unknown/unresolvable. The watcher must NOT revive when the remote session + * is gone or unknown — a clean exit must never auto-relaunch the agent. + */ + remoteAlive: boolean | undefined; } /** @@ -130,7 +139,8 @@ export type ReconnectSkipReason = | 'in-flight' | 'not-due' | 'exhausted' - | 'disabled'; + | 'disabled' + | 'remote-gone'; export interface DecideReconnectInput { session: ReconnectSessionView; @@ -166,6 +176,14 @@ export function decideReconnect(input: DecideReconnectInput): ReconnectAction { if (!session.paneDead) return { kind: 'skip', reason: 'pane-alive' }; // Intentional kill / detach must NEVER be auto-revived. if (guarded) return { kind: 'skip', reason: 'guarded' }; + // A clean exit tears down the durable remote tmux (the session's only pane + // exiting destroys it). Reviving is ONLY correct for a transport drop: the + // agent is still running on the remote, so the durable session must still + // exist. When it is gone (or status is unknown — probe failed/unreachable), + // the agent exited intentionally and must not be auto-relaunched (found + // live 2026-08-29: remote omp/opencode ctrl-c/ctrl-d auto-respawned fresh + // sessions; only claude's `|| --resume` accidentally masked it). + if (session.remoteAlive !== true) return { kind: 'skip', reason: 'remote-gone' }; const s = state ?? freshReconnectState(); diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 5088793d..cae32c19 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -64,6 +64,7 @@ import { defaultRemoteCommandForMode, remoteLoginShellCommand, remoteSshTarget, + remoteTmuxSessionAlive, } from './remote-hosts.js'; import { buildDockerBaseArgs, @@ -1674,6 +1675,18 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { * torn down (killed/detached/stopping). A guarded session is NEVER revived. */ private reconnectGuard: Set = new Set(); + /** + * Cached result of the remote tmux `has-session` probe (sessionId → alive). + * `true` = the durable remote tmux session exists (transport drop → reconnect + * is safe); `false` = remote session gone (agent exited cleanly → do NOT + * reconnect); `undefined` = not yet probed / probe failed. Only sessions + * whose pane is otherwise dead+eligible get probed, so a clean exit tears + * down the remote tmux and the probe reports false — killing the auto-revive + * (found live 2026-08-29: remote omp/opencode ctrl-c/ctrl-d auto-respawned + * fresh agents because the watcher couldn't tell a clean exit from a + * transport drop). + */ + private remoteAliveCache: Map = new Map(); private trueColorConfigured = false; /** tmux 3.7+ can resize pane history after creation; older releases cannot. */ @@ -2134,7 +2147,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // Create tmux session in three steps to handle cold-start (no server running) // and avoid the race where the command exits before remain-on-exit is set: - // 1. Create session with default shell (starts tmux server, stays alive) // 2. Set remain-on-exit (server now exists, session won't vanish on exit) // 3. Replace shell with actual command via respawn-pane (no terminal echo) // Unset $TMUX so nested sessions work when the dev server itself runs inside tmux. @@ -3111,16 +3123,44 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { * applies the pure {@link decideReconnect} decision and translates the result * into events + backoff/state transitions. Public for tests + the watcher. */ + /** + * Refresh the cached remote-tmux liveness for a session whose pane is dead. + * Fire-and-forget (async, not awaited by the sync tick): the probe is a slow + * ssh round-trip, so it must not block the 5s watcher interval. On success it + * writes the cached result; the NEXT tick then makes the revive decision with + * fresh data. A clean exit makes the remote tmux session vanish, so the probe + * resolves false and the watcher stops reviving it (2026-08-29). + */ + private async refreshRemoteAlive(session: MuxSession): Promise { + if (!session.remote) return; + const remoteName = session.remote.remoteSessionName || remoteTmuxSessionName(session.sessionId); + try { + const alive = await remoteTmuxSessionAlive(session.remote, remoteName); + this.remoteAliveCache.set(session.sessionId, alive); + } catch { + this.remoteAliveCache.set(session.sessionId, undefined); + } + } + runRemoteReconnectTick(now: number, enabled: boolean): void { for (const session of this.sessions.values()) { if (!session.remote) continue; const sessionId = session.sessionId; const state = this.reconnectState.get(sessionId); + // Only probe when the pane is actually dead — otherwise the ssh round-trip + // would run every 5s for every healthy remote session. The cache is + // refreshed lazily so a clean exit (remote tmux gone) flips it to false + // on the next tick and stops the auto-revive. + const paneDead = this.isPaneDead(session.muxName); + if (paneDead && this.remoteAliveCache.get(sessionId) === undefined) { + void this.refreshRemoteAlive(session); + } const action = decideReconnect({ session: { sessionId, isRemote: true, - paneDead: this.isPaneDead(session.muxName), + paneDead, + remoteAlive: this.remoteAliveCache.get(sessionId), }, state, guarded: this.reconnectGuard.has(sessionId), @@ -3168,12 +3208,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { guardRemoteReconnect(sessionId: string): void { this.reconnectGuard.add(sessionId); this.reconnectState.delete(sessionId); + this.remoteAliveCache.delete(sessionId); } /** Clear all per-session reconnect + guard state (e.g. when a session is removed). */ clearRemoteReconnectState(sessionId: string): void { this.reconnectState.delete(sessionId); this.reconnectGuard.delete(sessionId); + this.remoteAliveCache.delete(sessionId); } destroy(): void { @@ -3182,6 +3224,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { this.stopRemoteReconnectWatcher(); this.reconnectState.clear(); this.reconnectGuard.clear(); + this.remoteAliveCache.clear(); } registerSession(session: MuxSession): void { diff --git a/test/remote-auto-reconnect.test.ts b/test/remote-auto-reconnect.test.ts index 6be1dc0b..b09d2d61 100644 --- a/test/remote-auto-reconnect.test.ts +++ b/test/remote-auto-reconnect.test.ts @@ -106,7 +106,7 @@ describe('reconnect backoff schedule (pure)', () => { // ──────────────────────────────────────────────────────────────────────────── describe('decideReconnect (pure eligibility)', () => { - const deadRemote: ReconnectSessionView = { sessionId: 's1', isRemote: true, paneDead: true }; + const deadRemote: ReconnectSessionView = { sessionId: 's1', isRemote: true, paneDead: true, remoteAlive: true }; it('emits for a dead remote pane that is not guarded and is due', () => { const action = decideReconnect({ @@ -132,7 +132,7 @@ describe('decideReconnect (pure eligibility)', () => { it('skips non-remote sessions', () => { const action = decideReconnect({ - session: { sessionId: 's1', isRemote: false, paneDead: true }, + session: { sessionId: 's1', isRemote: false, paneDead: true, remoteAlive: true }, state: freshReconnectState(), guarded: false, enabled: true, @@ -143,7 +143,7 @@ describe('decideReconnect (pure eligibility)', () => { it('skips when the pane is alive', () => { const action = decideReconnect({ - session: { sessionId: 's1', isRemote: true, paneDead: false }, + session: { sessionId: 's1', isRemote: true, paneDead: false, remoteAlive: true }, state: freshReconnectState(), guarded: false, enabled: true, @@ -152,6 +152,28 @@ describe('decideReconnect (pure eligibility)', () => { expect(action).toEqual({ kind: 'skip', reason: 'pane-alive' }); }); + it('NEVER revives when the durable remote tmux is GONE (clean exit — the 2026-08-29 fix)', () => { + const action = decideReconnect({ + session: { sessionId: 's1', isRemote: true, paneDead: true, remoteAlive: false }, + state: freshReconnectState(), + guarded: false, + enabled: true, + now: 0, + }); + expect(action).toEqual({ kind: 'skip', reason: 'remote-gone' }); + }); + + it('NEVER revives when remote liveness is unknown (probe failed — fail closed)', () => { + const action = decideReconnect({ + session: { sessionId: 's1', isRemote: true, paneDead: true, remoteAlive: undefined }, + state: freshReconnectState(), + guarded: false, + enabled: true, + now: 0, + }); + expect(action).toEqual({ kind: 'skip', reason: 'remote-gone' }); + }); + it('skips when the kill-switch is off', () => { const action = decideReconnect({ session: deadRemote, @@ -222,6 +244,11 @@ describe('TmuxManager remote reconnect watcher (integration)', () => { registerRemote('aaaa1111'); // Force the watcher to see a dead pane regardless of test-mode isPaneDead. vi.spyOn(manager, 'isPaneDead').mockReturnValue(true); + // The durable remote tmux is still alive (transport drop) → reconnect allowed. + (manager as unknown as { remoteAliveCache: Map }).remoteAliveCache.set( + 'aaaa1111', + true + ); const dropped: Array<{ sessionId: string; attempt: number }> = []; const exhausted: Array<{ sessionId: string }> = []; @@ -263,6 +290,10 @@ describe('TmuxManager remote reconnect watcher (integration)', () => { it('resets backoff on a successful reattach (noteRemoteReconnect)', () => { registerRemote('cccc3333'); vi.spyOn(manager, 'isPaneDead').mockReturnValue(true); + (manager as unknown as { remoteAliveCache: Map }).remoteAliveCache.set( + 'cccc3333', + true + ); const dropped: Array<{ attempt: number }> = []; manager.on('remoteSessionDropped', (d) => dropped.push(d)); @@ -290,6 +321,10 @@ describe('TmuxManager remote reconnect watcher (integration)', () => { manager.clearRemoteReconnectState('eeee5555'); // After clearing the guard, a fresh dead-pane observation should emit again. vi.spyOn(manager, 'isPaneDead').mockReturnValue(true); + (manager as unknown as { remoteAliveCache: Map }).remoteAliveCache.set( + 'eeee5555', + true + ); const dropped: unknown[] = []; manager.on('remoteSessionDropped', (d) => dropped.push(d)); manager.runRemoteReconnectTick(0, true); From 02bbf13b3c606b23fbb8fdeb9edd290e569a9565 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 30 Aug 2026 16:29:14 +0200 Subject: [PATCH 25/55] chore: version packages --- .changeset/omp-backend.md | 17 ----------------- CHANGELOG.md | 15 +++++++++++++++ CLAUDE.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 5 files changed, 19 insertions(+), 21 deletions(-) delete mode 100644 .changeset/omp-backend.md diff --git a/.changeset/omp-backend.md b/.changeset/omp-backend.md deleted file mode 100644 index f1804449..00000000 --- a/.changeset/omp-backend.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"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, 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 -- 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/CHANGELOG.md b/CHANGELOG.md index aaa8bd6d..2c616173 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # aicodeman +## 1.24.0 + +### Minor Changes + +- OMP (Oh My Pi) as a tenth run mode, mode-faithful Resume for external CLIs, and a cleaner plan-usage chip. + + **OMP (`omp`) run mode** (#353): Oh My Pi joins Claude Code, shell, OpenCode, Codex, Gemini, Antigravity, Pi, Grok Build and DeepSeek Harness as a run mode, in local, Docker and remote-SSH sessions: toolbar dropdown, welcome button, phone overview, command palette, clone-repo brain picker, cron agent types, tab badges and per-mode colours, plus `GET /api/omp/status`, a `codeman doctor` entry, install.sh detection and the docker agent image. The resolver leads with `~/.local/bin` (the upstream installer's real target) and demands `omp/` from `--version`, so an unrelated binary with the same three-letter name is never spawned. Past omp conversations appear in Past Sessions, read from omp's own session files (the header line carries the real working directory, so nothing has to reverse-engineer omp's directory mangling), and a respawned or resumed omp session is pinned to an exact conversation with `--resume ` instead of omp's newest-file `--continue`. Review hardening before merge: the pin is resolved only at the moment a respawn is actually confirmed (an eager resolve on boot recovery used to alias two omp tabs in one case directory onto one conversation), candidates are verified against their own header `cwd` and claimed process-wide so siblings cannot double-pin; `OMP_*` joins the env-override allowlist and `OMP_AUTH_BROKER_URL`/`OMP_AUTH_BROKER_TOKEN` are clamped for non-granted owners in multi-user mode, the same shape as `DEEPSEEK_BASE_URL`. Known and documented: omp's own knobs are mostly `PI_*` (it is a pi fork), its default `tools.approvalMode` is `yolo`, and in-container `--resume` pinning does not reach a Docker omp pane. + + **Resume keeps the row's own CLI** (#353): clicking Resume on an OpenCode, Pi, Grok, DeepSeek or OMP row used to create a plain Claude session, since the create request never carried the row's mode. Resume now relaunches in the row's own mode with that CLI's continue flag, and retires the stale row it came from so three clicks no longer leave three copies of the same name. Codex, Gemini and Antigravity rows have no continuation wired yet, so their rows are deliberately left in place. `DELETE /api/sessions/:id` accepts a persisted-only session (ownership enforced through the same helper as live lookups, 404 rather than 403 so nothing leaks) and broadcasts `session_deleted` so other tabs drop the row too. + + **Plan-usage chip drops the provider label when there is only one**: a machine with only Claude limits rendered `CLAUDE 5H 60% 7D 23%`, a 46px label naming the only thing it could be. The name exists to tell two rows apart, so it now appears only when both Claude and Codex have windows; the tooltip still names the provider either way. + + ### Thanks + - @timkjr for #353, and for turning every review finding around within a day + ## 1.23.2 ### Patch Changes diff --git a/CLAUDE.md b/CLAUDE.md index d7a0b7f6..d16cfd64 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,7 +75,7 @@ When user says "COM": CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed. -**Version**: 1.23.2 (must match `package.json`) +**Version**: 1.24.0 (must match `package.json`) ## Project Overview diff --git a/package-lock.json b/package-lock.json index c69c3a54..a5d0b344 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aicodeman", - "version": "1.23.2", + "version": "1.24.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aicodeman", - "version": "1.23.2", + "version": "1.24.0", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index 9c7f99cd..36150135 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aicodeman", - "version": "1.23.2", + "version": "1.24.0", "description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", From d5b5f8f6180ff45bfcb2a192ce57940a907d0485 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 31 Aug 2026 22:26:09 +0200 Subject: [PATCH 26/55] fix(docker): make the dsh profile install survive pnpm's build-script gate Follow-up to #350, which fixed the actual blocker (issue #352): `dsh plugin` is a thin forwarder that `spawnSync`s a literal `pnpm` with no npm fallback, so an image without pnpm dies at exit 127 and takes the whole build with it. That PR also pinned an allowlist of the two packages whose lifecycle scripts pnpm blocked at the time. Replace it with a policy that cannot go stale: pnpm, unlike npm, refuses dependency build scripts by default and FAILS the install over it (`ERR_PNPM_IGNORED_BUILDS`, exit 1, measured on pnpm 11.24), and the names to allow move between rebuilds because `@deepseek-harness-tui/dsh-tui` is resolved by dist-tag, not pinned: 0.9.3 pulled `@google/genai` (whose script is a literal `preinstall: no-op`), 0.10.0-beta.x does not. An allowlist of two names would have let the next tree break the build the same way. Allowing them wholesale is also the exposure this image already accepts three layers up, where `npm install -g` runs the install scripts of every transitive dep of the five CLIs above with no gate at all. Also correct a comment in the `/api/deepseek/install-profile` route that asserted the opposite of what #352 proved ("dsh bundles its own package manager, so no system pnpm is required"). The route's behavior is already right: dsh's own "pnpm not found on PATH" stderr reaches the caller as the OPERATION_FAILED detail, so the UI's "add a terminal profile" button names the fix. Documented the prerequisite in docs/deepseek-integration.md, and taught the docker-cases image smoke test about `dsh`/`omp` plus the profile check that `dsh --version` does NOT cover. --- docker/agent.Dockerfile | 29 +++++++++++++++++++++-------- docs/deepseek-integration.md | 8 ++++++++ docs/docker-cases.md | 16 ++++++++++++++-- src/web/routes/system-routes.ts | 9 +++++++-- 4 files changed, 50 insertions(+), 12 deletions(-) diff --git a/docker/agent.Dockerfile b/docker/agent.Dockerfile index 5214efb5..26885c50 100644 --- a/docker/agent.Dockerfile +++ b/docker/agent.Dockerfile @@ -75,11 +75,17 @@ RUN curl -fsSL https://x.ai/cli/install.sh | bash \ # profile itself is installed further down, into the `agent` HOME, because # Codeman deliberately does NOT seed `profiles/` from the host: it is a # per-profile node_modules tree, host-arch-specific and far too large to copy on -# every container start. The harness delegates profile dependency management to -# pnpm, so pnpm is a build dependency rather than optional runtime tooling. +# every container start. +# ⚠️ `pnpm` is a HARD dependency of `dsh plugin`, not optional tooling: the +# subcommand is a thin forwarder that `spawnSync`s a literal `pnpm` with no +# fallback to npm, so on an image without it the profile install below dies +# with `dsh: pnpm not found on PATH` / exit 127 and takes the whole build with +# it (issue #352). It stays on PATH at runtime too, so a container user can run +# `dsh plugin add` themselves. RUN npm install -g @deepseek-ai/dsh pnpm \ && npm cache clean --force \ - && dsh --version + && dsh --version \ + && pnpm --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 @@ -123,6 +129,16 @@ 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. +# ⚠️ `dangerouslyAllowAllBuilds` is what keeps that profile install from becoming +# the next #352. pnpm (unlike npm) blocks dependency lifecycle scripts by default +# and FAILS the install over it — `ERR_PNPM_IGNORED_BUILDS`, exit 1, measured on +# pnpm 11.24 — so any package in the tui's tree that ships one stops the build +# dead. An allowlist of the offenders rots: `@deepseek-harness-tui/dsh-tui` is +# resolved by dist-tag, not pinned, and 0.9.3 pulled `@google/genai` (a +# `preinstall: no-op`) where 0.10.0-beta.x does not, so the names to allow move +# under us between rebuilds. Allowing them wholesale is also the SAME exposure +# this image already accepts three layers up: `npm install -g` runs the install +# scripts of every transitive dep of the five CLIs above it, with no gate at all. # `.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 @@ -132,11 +148,8 @@ RUN useradd -g 0 -m -d /home/agent -s /bin/bash agent \ /home/agent/.claude/projects /home/agent/.codex/sessions /home/agent/.pi/agent /home/agent/.grok \ /home/agent/.dsh /home/agent/.omp/agent \ && DSH_HOME=/home/agent/.dsh HOME=/home/agent \ - dsh plugin --profile dsh-tui install --ignore-scripts \ - && printf '%s\n' '' 'allowBuilds:' ' "@google/genai": true' ' protobufjs: true' \ - >> /home/agent/.dsh/profiles/dsh-tui/pnpm-workspace.yaml \ - && DSH_HOME=/home/agent/.dsh HOME=/home/agent \ - dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui \ + dsh plugin --profile dsh-tui add --config.dangerouslyAllowAllBuilds=true \ + @deepseek-harness-tui/dsh-tui \ && test -f /home/agent/.dsh/profiles/dsh-tui/package.json \ && chgrp -R 0 /home/agent \ && chmod -R g=u /home/agent diff --git a/docs/deepseek-integration.md b/docs/deepseek-integration.md index de142648..8232af92 100644 --- a/docs/deepseek-integration.md +++ b/docs/deepseek-integration.md @@ -47,6 +47,14 @@ By hand, or to pick a different front door: dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui ``` +⚠️ **`pnpm` has to be on PATH for either route.** `dsh plugin` is a thin forwarder +that spawns a literal `pnpm` with no npm fallback, so without one it exits 127 with +`dsh: pnpm not found on PATH` — both by hand and behind the UI button, which +surfaces that same line as the install error. `npm install -g pnpm` (or +`corepack enable pnpm`) is the fix. This is what broke the Docker agent image in +[#352](https://github.com/Ark0N/Codeman/issues/352); the image now installs pnpm +alongside `dsh`. + Codeman's default is `@deepseek-harness-tui/dsh-tui` because it is by a wide margin the most used community TUI, it is MIT, and it implements the status contract described in §3. It is a **default, not a requirement**: any profile diff --git a/docs/docker-cases.md b/docs/docker-cases.md index 56ed06ac..841b8710 100644 --- a/docs/docker-cases.md +++ b/docs/docker-cases.md @@ -2,7 +2,7 @@ Run a case inside an **isolated Docker container** instead of directly on the host. Any number of Codeman sessions can share one container (it is scoped to the case, not the session), so a whole project lives in a sandbox with its own network, resource caps, and filesystem, and you can **export the container to move it to another machine**. -Docker mode is a **location overlay on cases**, the direct analog of [remote SSH cases](./remote-hosts.md): where a remote case runs a local tmux pane doing `ssh host` into a durable remote tmux server, a docker case runs a local tmux pane doing `docker exec -it` into a durable **in-container** tmux server. It is not a separate `SessionMode`, so `claude` / `shell` / `opencode` / `codex` / `gemini` / `antigravity` / `pi` / `grok` all work inside the container. +Docker mode is a **location overlay on cases**, the direct analog of [remote SSH cases](./remote-hosts.md): where a remote case runs a local tmux pane doing `ssh host` into a durable remote tmux server, a docker case runs a local tmux pane doing `docker exec -it` into a durable **in-container** tmux server. It is not a separate `SessionMode`, so `claude` / `shell` / `opencode` / `codex` / `gemini` / `antigravity` / `pi` / `grok` / `deepseek` / `omp` all work inside the container. ## One-time setup: build the base image @@ -25,9 +25,21 @@ A zero exit code only proves the layers ran, not that the toolchain works. Verif ```bash docker run --rm codeman/agent:base bash -lc \ - 'for c in claude codex gemini opencode agy pi grok; do printf "%-9s " $c; $c --version 2>&1 | head -1; done' + 'for c in claude codex gemini opencode agy pi grok dsh omp; do printf "%-9s " $c; $c --version 2>&1 | head -1; done' ``` +⚠️ `dsh --version` is the one line above that answers a different question than the +others: `dsh` is a profile launcher, so a working binary says nothing about whether +the image can actually run a DeepSeek session. Check the profile the Dockerfile +installs into the agent's HOME as well, or a `mode: 'deepseek'` case starts a pane +that dies on arrival: + +```bash +docker run --rm codeman/agent:base ls ~/.dsh/profiles/dsh-tui/package.json +``` + +Building that profile is also why `pnpm` is in the image: `dsh plugin` forwards straight to a literal `pnpm` and exits 127 without it (issue #352), and pnpm — unlike npm — blocks dependency lifecycle scripts by default and fails the install over it, so the profile step passes `--config.dangerouslyAllowAllBuilds=true`. + 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). 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). diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index c3e955a5..fbf72878 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -614,8 +614,13 @@ export function registerSystemRoutes( // same negative-pid signal as runGit() in git-clone.ts, which is the // synchronous-spawn precedent this endpoint is modelled on. detached: true, - // dsh bundles its own package manager, so no system pnpm is required — - // but it still needs a HOME to resolve $DSH_HOME against. + // Inherit the environment: this needs a HOME to resolve $DSH_HOME + // against, and a PATH carrying `pnpm`. ⚠️ `dsh plugin` does NOT bundle a + // package manager — it `spawnSync`s a literal `pnpm` with no npm + // fallback, so on a host without one this exits 127 and dsh's own + // stderr ("pnpm not found on PATH") is what reaches the caller through + // the OPERATION_FAILED detail below. That is the same missing + // dependency that broke the docker agent image in issue #352. env: process.env, }); } catch (err) { From e5c5d890aa8ff1221c7581938d87fa3cd4870933 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 31 Aug 2026 22:34:57 +0200 Subject: [PATCH 27/55] chore: version packages --- CHANGELOG.md | 16 ++++++++++++++++ CLAUDE.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c616173..ed8a1f9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # aicodeman +## 1.24.1 + +### Patch Changes + +- The Docker agent base image builds again. + + **`docker/agent.Dockerfile` could not be built from a fresh checkout** (#352, fix in #350): the DeepSeek Harness step died with `dsh: pnpm not found on PATH` and exit 127, which took the whole image with it and, because Codeman auto-builds this image on the first Docker case, left Docker mode unusable on a clean host. `dsh plugin` does not bundle a package manager; it spawns a literal `pnpm` with no npm fallback, so pnpm is now installed alongside `dsh` and the layer proves it with `pnpm --version`. + + The profile install also passes `--config.dangerouslyAllowAllBuilds=true`, because pnpm, unlike npm, refuses dependency lifecycle scripts by default and fails the install over it (`ERR_PNPM_IGNORED_BUILDS`, exit 1). Which packages that hits moves between rebuilds, since the terminal profile is resolved by dist-tag rather than pinned: the tree that broke the build in August pulled `@google/genai`, today's does not. An allowlist of those names would have gone stale rather than prevented the next break, and running those scripts is the same exposure the image already accepts three layers up, where `npm install -g` runs the install scripts of every transitive dependency of the five CLIs above it with no gate at all. + + Documentation caught up with two things it had wrong: the image smoke test in `docs/docker-cases.md` now covers `dsh` and `omp`, and checks the dsh **profile** rather than only the binary (`dsh` is a launcher, so `dsh --version` says nothing about whether a session can start), and `docs/deepseek-integration.md` names pnpm as a prerequisite for installing a terminal profile at all, by hand or through the UI button. A comment in the `/api/deepseek/install-profile` route claimed the opposite of what this bug proved, and is corrected; the route's behaviour was already right, surfacing dsh's own "pnpm not found on PATH" line as the install error. + + ### Thanks + - @opticon454 for #350, with a reproduction that made this a confirmation rather than a hunt + - @timkjr for reporting #352, and for finding it while verifying Docker support for someone else's PR + ## 1.24.0 ### Minor Changes diff --git a/CLAUDE.md b/CLAUDE.md index d16cfd64..d8aef6b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,7 +75,7 @@ When user says "COM": CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed. -**Version**: 1.24.0 (must match `package.json`) +**Version**: 1.24.1 (must match `package.json`) ## Project Overview diff --git a/package-lock.json b/package-lock.json index a5d0b344..8e81abdc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aicodeman", - "version": "1.24.0", + "version": "1.24.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aicodeman", - "version": "1.24.0", + "version": "1.24.1", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index 36150135..6615a5fd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aicodeman", - "version": "1.24.0", + "version": "1.24.1", "description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", From 3518af3a9f98e9e3d03c7fab024187dbfdc59c2a Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 1 Sep 2026 02:10:09 +0200 Subject: [PATCH 28/55] docs: correct CLAUDE.md drift and document four undocumented subsystems Audit of CLAUDE.md against the tree. Verified still accurate: the 31-module frontend load order (matches index.html exactly), SSE registry parity at 157 = 157 (confirmed by running the parity test), config/ 21 files, types/ 22 domain files, 136 mobile device profiles, the version line, and every Quick Reference command. Drift corrected: 24 route modules to 25, ~220 handlers to ~227, system-routes 51 to 56, app.js ~5K lines to ~6.7K and 30 modules to 31, install.sh 92KB to 104KB. Completed the CLI resolver inventory, which was missing deepseek-cli-resolver and omp-cli-resolver even though both modes are documented, and named the shared cli-executable-resolver lookup chain. Filled the gaps found by sweeping every src module against the file: - Owner tab layouts (COD-359) had 6 source modules, 7 test files, 2 routes, an SSE event and a state.json key, with zero mentions anywhere in CLAUDE.md or docs/. The paragraph records the four things a reader would otherwise get wrong: it is backend-only as of 1.24.1 with no frontend consumer, the service is the sole mutation boundary, it projects onto PUT /api/session-order rather than replacing it, and reconciliation is gated on a successful restore. - codeman doctor and codeman users were undocumented top-level CLI commands. - Four subsystems whose invariants lived only in their @fileoverview: the workspace-trust dialog recognizer, proc-tree's bounded walk (the 2026-07-30 incident that took a machine down), deepseek-web-server (one child process, deliberately not a shell session), and the Files panel search matcher (globs are never compiled to a RegExp). Also fixes a stale "156 event types" comment in constants.js (actual: 157) and a contradiction in AGENTS.md, which still carried the retired "never run the full suite inside a managed tmux session" rule against CLAUDE.md's current "npm test is the gate and is safe to run bare". Note: the trust-dialog paragraph documents trustDialogNextKey(), which is part of a sibling session's in-flight fix for the Claude Code 2.1.252 layout change (unnumbered, reversed options with "No, exit" highlighted, so a blind carriage return picks exit and kills the pane). That fix was uncommitted in the shared tree when this landed, so the doc leads the code until it is committed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NMN8UuvdBim3iM87reuQ9Z --- AGENTS.md | 3 ++- CLAUDE.md | 32 ++++++++++++++++++++++---------- src/web/public/constants.js | 2 +- 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ed6638df..49991d86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,8 @@ Canonical agent/contributor guidance for this repository lives in [CLAUDE.md](CLAUDE.md) — project structure, build/test/lint commands, code style, testing safety rules -(never run the full suite inside a managed tmux session), security notes, and +(`npm test` is the CI gate and is safe to run bare; the three excluded suites +have their own runners), security notes, and the deployment workflow are all maintained there. Please read it before making changes, and keep it the single source of truth rather than duplicating sections here. diff --git a/CLAUDE.md b/CLAUDE.md index d8aef6b1..d755ec0f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,6 +113,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph | Production logs | `journalctl --user -u codeman-web -f` | | Detached server | `codeman web -d` (`--status`, `--stop`; pidfile+log at `dataPath('web.pid'/'web.log')`). ⚠ Refuses to start a 2nd server on one data dir — see Instance isolation | | Install/remove the service | `codeman service install` / `status` / `uninstall` (systemd user unit on Linux, LaunchAgent on macOS; names from `config/service-names.ts`) | +| Dependency doctor | `codeman doctor` (alias `check-deps`; `--json`, `--category core\|office\|other`). Probes Node/Claude CLI/tmux/LibreOffice/MS Office against `config/dependency-registry.ts`; engine is pure given an injectable `ProbeHost` | +| Multi-user accounts | `codeman users add ` / `passwd ` / `list` / `rm ` (writes `~/.codeman/users.json`, mode 0600; see Multi-user mode) | **CI**: `.github/workflows/ci.yml` (push to master/main + PRs, Node 22) runs two jobs: **(1)** `check:lockfile`, `typecheck`, `lint`, `check:frontend-syntax`, `format:check`, then a **server boot smoke test** (`tsx src/index.ts web --port 3151` must answer `/api/status` within 30s); **(2)** the **unit/integration test suite** via `npm run test:ci` (`config/vitest.ci.config.ts` — excludes the browser-driven `test/mobile/**` suite, `perf-*` benchmarks, and 5 Playwright tests; globs live in `config/test-suites.ts`). `npm test` runs this same config, so local green == CI green. Tests are tmux-safe in CI: `TmuxManager` no-ops all shell commands under `VITEST` (see Testing). @@ -148,9 +150,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph | ---------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | | **Entry** | `src/index.ts`, `src/cli.ts`, `daemon-control`, `service-installer`, `config/service-names`, `cli-style` | The last three back `web -d` / `service install`; `cli-style` is the shared palette/table/spinner/confirm kit | | **TUI** | `src/tui/`: `tui-app` ★ + `tui-client` (the only IO) over a pure core (`-model`, `-layout`, `-render`, `-keys`, `-ansi`, `-composer`, `-approvals`, `-digest`, `-sse`, `-types`) | `codeman tui`, a CLIENT of the server, never a second brain. Design doc: `docs/tui-plan.md`; user guide `docs/tui.md` | -| **DeepSeek** | `src/utils/deepseek-cli-resolver.ts`, `src/deepseek-status-shim.ts` | `dsh` is a PROFILE LAUNCHER, not an agent; read `docs/deepseek-integration.md` first | -| **Session** | `src/session.ts` ★, `session-manager`, `session-auto-ops`, `session-cli-builder`, `session-task-cache`, `session-order` (pure), `session-pty-exit-breaker`, `usage-limit-patterns`, `usage-telemetry`; `src/services/unified-session-service.ts` | Pure/unit-tested helpers are split out of `session.ts` on purpose | -| **Mux** | `src/mux-interface.ts`, `src/mux-factory.ts`, `src/tmux-manager.ts` ★ | | +| **DeepSeek** | `src/utils/deepseek-cli-resolver.ts`, `src/deepseek-status-shim.ts`, `src/deepseek-web-server.ts` (background `dsh web`, not a session) | `dsh` is a PROFILE LAUNCHER, not an agent; read `docs/deepseek-integration.md` first | +| **Session** | `src/session.ts` ★, `session-manager`, `session-auto-ops`, `session-cli-builder`, `session-task-cache`, `session-order` (pure), `session-pty-exit-breaker`, `session-trust-dialog` (pure), `usage-limit-patterns`, `usage-telemetry`; `src/services/unified-session-service.ts` | Pure/unit-tested helpers are split out of `session.ts` on purpose | +| **Mux** | `src/mux-interface.ts`, `src/mux-factory.ts`, `src/tmux-manager.ts` ★, `src/proc-tree.ts` (pure, bounded descendant walk) | | | **Respawn** | `src/respawn-controller.ts` ★ + 4 helpers (`-adaptive-timing`, `-health`, `-metrics`, `-patterns`) | Read `docs/respawn-state-machine.md` first | | **Ralph** | `src/ralph-tracker.ts` ★, `src/ralph-loop.ts` + 5 helpers (`-config`, `-fix-plan-watcher`, `-plan-tracker`, `-stall-detector`, `-status-parser`) | Read `docs/ralph-wiggum-guide.md` first | | **Orchestrator** | `src/orchestrator-loop.ts`, `-planner`, `-verifier` | Read `docs/orchestrator-loop-architecture.md` first | @@ -158,14 +160,14 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph | **Agents** | `src/subagent-watcher.ts` ★, `team-watcher`, `bash-tool-parser`, `transcript-watcher`, `workflow-run-watcher` | `workflow-run-watcher` is STANDALONE and never touches `subagent-watcher` | | **AI** | `src/ai-checker-base.ts`, `ai-idle-checker.ts`, `ai-plan-checker.ts` | | | **Tasks** | `src/task.ts`, `task-queue.ts`, `task-tracker.ts` | | -| **State** | `src/state-store.ts`, `run-summary.ts`, `session-lifecycle-log.ts`, `intent-store.ts` | | +| **State** | `src/state-store.ts`, `run-summary.ts`, `session-lifecycle-log.ts`, `intent-store.ts`, `tab-layout.ts` (pure model) + `-service` (sole mutation boundary) + `-persistence` + `-legacy-order` | | | **Infra** | `src/hooks-config.ts`, `push-store`, `tunnel-manager`, `image-watcher`, `file-stream-manager`, `remote-hosts` + `remote-reconnect` (pure), `docker-hosts` + `docker-export` | Remote/docker case overlays; see Key Patterns | | **Web tabs** | `src/webview-store.ts`, `webview-capabilities.ts`, `src/web/webview-proxy.ts` (pure), `src/web/routes/webview-routes.ts` | Dashboard URLs as tabs; NOT a SessionMode | | **Search** | `src/search-service.ts` | Pure in-memory core for `GET /api/search` | | **Attachments** | `src/attachment-registry.ts`, `attachment-magic`, `generated-artifact-attachments`, `session-attachment-history`, `document-preview-cache`, `document-thumbnailer`, `document-conversion-limiter`, `config/attachment-guard` | See Key Patterns | | **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/` (`claude-md.ts` + `case-template.md`) | `templates/` holds the CLAUDE.md scaffold generated into new cases | -| **Web** | `src/web/server.ts` ★, `sse-events.ts`, `routes/*.ts` (24 modules + barrel; `session-routes.ts` ★), `route-helpers.ts`, `ports/*.ts`, `middleware/auth.ts`, `schemas.ts`, `self-update.ts`, `plan-usage-latest.ts`, `ws-connection-registry.ts`, `heic-jpeg-converter.ts` + `heic-jpeg-worker.ts` | | -| **Frontend** | `src/web/public/app.js` (~5K lines, core) + 30 modules + `sw.js` | See Frontend section for the load order, which is authoritative | +| **Web** | `src/web/server.ts` ★, `sse-events.ts`, `routes/*.ts` (25 modules + barrel; `session-routes.ts` ★), `route-helpers.ts`, `ports/*.ts`, `middleware/auth.ts`, `schemas.ts`, `self-update.ts`, `plan-usage-latest.ts`, `ws-connection-registry.ts`, `heic-jpeg-converter.ts` + `heic-jpeg-worker.ts` | | +| **Frontend** | `src/web/public/app.js` (~6.7K lines, core) + 31 modules + `sw.js` | See Frontend section for the load order, which is authoritative | | **Types** | `src/types/index.ts` (barrel) → 22 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts | ★ = Large, central file (>50KB) — read its `@fileoverview` first. All files have `@fileoverview` JSDoc — read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`. @@ -174,7 +176,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Config**: `src/config/` — 21 files, no barrel (`index.ts`) exists; import from the specific file. -**Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap` (⚠ NOT in the barrel — import from `./utils/lru-map.js` directly), `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`, `KeyedDebouncer`. Also: `claude-cli-resolver`/`opencode-cli-resolver`/`codex-cli-resolver`/`gemini-cli-resolver`/`antigravity-cli-resolver`/`pi-cli-resolver`/`grok-cli-resolver` (CLI path resolution; ⚠ `pi-cli-resolver` and `grok-cli-resolver` additionally version-probe the binary, since `pi` is a generic name and `grok` has npm squatters), `string-similarity` (fuzzy matching), `regex-patterns` (ANSI/token/spinner patterns), `assertNever` (exhaustive checks), `token-validation` (auth tokens), `nice-wrapper` (process priority). +**Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap` (⚠ NOT in the barrel — import from `./utils/lru-map.js` directly), `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`, `KeyedDebouncer`. Also: `claude-cli-resolver`/`opencode-cli-resolver`/`codex-cli-resolver`/`gemini-cli-resolver`/`antigravity-cli-resolver`/`pi-cli-resolver`/`grok-cli-resolver`/`deepseek-cli-resolver`/`omp-cli-resolver` (CLI path resolution, one per `SessionMode`, all nine sharing the lookup chain in `cli-executable-resolver`: server PATH, then that CLI's install dirs, then an interactive login shell LAST, since it is the only step that spawns anything and it is what finds nvm/Homebrew installs under a service manager's minimal PATH; ⚠ `pi-`, `grok-` and `deepseek-cli-resolver` additionally probe the binary's identity, since `pi` is a generic name, `grok` has npm squatters, and Debian ships an unrelated `dsh`), `file-query` (⚠ Files-panel search matcher, glob-by-two-pointer, never RegExp), `string-similarity` (fuzzy matching), `regex-patterns` (ANSI/token/spinner patterns), `assertNever` (exhaustive checks), `token-validation` (auth tokens), `nice-wrapper` (process priority), `shell-resolver` (⚠ resolves a real login shell for `mode: 'shell'`; the literal string `$SHELL` used to be expanded by the SERVER's shell, which is empty in a container), `event-loop-monitor` (a sync `execSync` freezes the port while the process stays alive, leaving no trace), `dependency-checker` + `dependency-report` (the `codeman doctor` probe engine, registry in `config/dependency-registry.ts`). ### Data Flow @@ -193,6 +195,10 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph ⚠️ **A `❯` sighting is NOT the end of a turn, and neither is silence.** Claude redraws the composer (`❯`) about once a second all through a turn, so the old "saw a ❯, wait 2s → idle" rule flipped every working session to idle two seconds in (measured: a session mid-tool-call at 17 minutes reporting `status:"idle"`). Its working indicator is `✻ Actualizing… (13m 23s · ↓ 47.5k tokens)`: the glyph animates through `· ✢ ✳ ∗ ✻ ✽`, the gerund is randomized, and the finished line (`✻ Cooked for 2m 49s`) carries the same glyph, so neither `SPINNER_PATTERN` (braille, not what current versions draw) nor a keyword list can see it. Matching the new line in the STREAM does not work either: tmux ships partial repaints, so the whole line reaches the PTY only every few tens of seconds. So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks the SCREEN via `capturePaneText()` + `CLAUDE_WORKING_LINE_PATTERN` before believing it; a sustained run of repaints (`session-activity.ts`, pure + unit tested) is what marks a turn as started, with the same screen probe vetoing keystroke echo. Idle now lands ~3-5s after a turn ends instead of 2s into one. Claude-mode only, since an external CLI has no `❯`, so nothing would ever arm the confirmation and the session would latch busy. +**Workspace-trust dialog auto-accept** (`session-trust-dialog.ts`, pure + unit tested): Claude Code asks once per directory ("Is this a project you created or one you trust?") before it will read or edit anything, and since Codeman sessions run permission-skipping or classifier-guarded modes the answer is always yes, so a session parked on that dialog is simply stuck. ⚠️ **Match the compacted SCREEN, never the stream.** tmux repaints a row by writing each word and then a cursor-forward (`\x1b[C`) instead of a space, and Ink colours each word separately, so the wire carries `I\x1b[Ctrust\x1b[Cthis\x1b[Cfolder`; stripping the escapes leaves `Itrustthisfolder`, because the spaces are not there to strip, they were never sent. A plain `includes('trust this folder')` therefore never matched a single chunk and the auto-accept was silently DEAD for every session that hit the dialog. `compactScreenText()` removes ALL whitespace instead (plus the `ESC ( B` charset selects that `stripAnsi` does not cover, which would otherwise land inside a phrase as a literal `(B`), which survives both that repaint style and the spaced full-screen redraw. ⚠️ **Never answer it with a blind `\r`.** The layout has changed under us at least twice, and Claude Code 2.1.252 dropped the option numbers, put "No, exit" FIRST and highlights IT by default, so the Enter that answered the old dialog now picks *exit* and the pane dies (`Pane is dead (status 1)`) seconds after the session starts. `trustDialogNextKey()` reads the `❯` marker and returns ONE step at a time (an arrow while the cursor is on the wrong option, Enter only once the screen shows it on the trust option), with the pane re-read between steps, so a dropped arrow costs a repaint instead of the session; a frame that does not say which option is highlighted returns null and waits for the next repaint. ⚠️ The LAST marked option in the text wins, because the direct-PTY fallback reads an append-only buffer where every repaint since launch is still present and an older frame must not out-vote the freshest one. ⚠️ Answering types into a live session, so THREE guards must all hold and none is redundant: a **startup-only window** (`TRUST_DIALOG_WINDOW_MS`, 90s, since the dialog renders before the main UI and leaving it open forever would let an agent transcript that merely QUOTES the dialog trigger an Enter, this file being an example), a **two-marker match** requiring a trust phrase AND one of the dialog's own confirm affordances (`isTrustDialogScreen`), and an **attempt cap** (`TRUST_DIALOG_MAX_ATTEMPTS`, 3: a keystroke can land while Ink is still mounting the widget and be dropped, which is the other half of why sessions got stuck here, but retrying forever would hammer keys into whatever came next). ⚠️ It reads `capturePaneText()` and falls back to a deliberately SHORT tail of the terminal buffer only on a direct-PTY session, which has no pane: that buffer is append-only, so a longer tail would keep re-matching a dialog answered minutes ago. + +**Process-tree walks are bounded** (`proc-tree.ts`, pure + unit tested): `collectDescendants(pid, byParent)` is the ONE descendant traversal, fed by a single cached `ps -eo pid=,ppid=` snapshot (`refreshProcSnapshot()` in tmux-manager.ts: in-flight-shared, async because `execSync`'s timeout cannot return at all while spawnSync waits on an unkillable child, and ANY error discards the result rather than caching a truncated `ps`, which would make whole subtrees invisible to the kill path). ⚠️ **The unbounded version took a machine down** (2026-07-30): it ran `pgrep -P ` once per node and recursed with no visited set, no depth limit and no node cap, so across ~28 adopted tmux trees the fan-out exploded while each `pgrep` blocked in the WSL kernel reading `/proc//cgroup`, ending at ~13,000 `pgrep` processes in D-state, a load average above 13,000, and a machine recoverable only by restarting WSL, which cost every running session. Three properties make that impossible and each has a test: a cycle terminates (a real tree has none, a stale snapshot can still produce one), depth is capped (`PROC_WALK_MAX_DEPTH`), node count is capped (`PROC_WALK_MAX_NODES`). The fourth is structural: the function takes a snapshot and cannot spawn anything at all. ⚠️ It lives in its own module because as a private method of `tmux-manager.ts` the regression test had to keep its own COPY of the algorithm, which is a test that passes while the shipped code rots. ⚠️ Truncation is reported through `onTruncated` rather than silently, with BOTH caps named: a silent depth cap hides a deep tree exactly as effectively as a silent node cap hides a wide one. + **Auto-resume on usage limit** (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time and `SessionAutoOps` arms a timer for reset+2min, then sends Esc + `continue`. ⚠️ Respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected`), which is what prevents `/clear` from wiping the paused conversation. Claude-mode only. → [architecture-invariants#auto-resume-on-usage-limit](docs/architecture-invariants.md#auto-resume-on-usage-limit) **Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF via the mobile block in `getDefaultSettings()`): resolve it ONLY through `planUsageChipEnabled()` in settings-ui.js, which backs all three call sites (the App Settings checkbox, the chip's visibility, and the Claude `statusLineTelemetry` flag on session create). It renders compact Claude and Codex provider rows. Claude data comes from Codeman's marked `statusLine.command` exporter, which POSTs `rate_limits` to `POST /api/status-telemetry`, never overwrites a user's hand-authored statusLine, and prints the footer through. Main Codex usage comes from a read-only host `account/rateLimits/read` app-server poll at startup and every 5 minutes; exclude model-specific buckets such as Spark, and omit the Codex row when no signed-in limit is available. Distinct from auto-resume, which reacts to Claude's limit *message* rather than showing live %. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md` @@ -207,12 +213,16 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **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) +**DeepSeek web UI** (`POST`/`GET`/`DELETE /api/deepseek/web`, `deepseek-web-server.ts`): the Run menu's "DeepSeek web UI..." entry supervises ONE background `dsh web` child process, deliberately **NOT a shell session**. The session version worked and was still wrong in use: it put a terminal tab on screen next to the web tab the user actually asked for, every single time, and nothing about a long-lived HTTP server needs to be a tab. ⚠️ What a session gave for free now has to be paid for explicitly, and every piece is load-bearing: **exactly one** server (a second click REUSES it rather than racing it for a port, which two sessions structurally could not do), **restarted when the browser authority changes** (`--trusted-host` fences dsh's `/api` against the browser authority, and a Codeman reachable at both loopback and a tailnet name has two, so whoever asks last wins: the asker is by definition the origin about to load the page), **killed on shutdown** (`stopDeepSeekWeb()` in the server teardown, because the child is detached so its whole plugin tree can be signalled at once, which also means it would OUTLIVE Codeman and hold its port against the next start), and **failures returned to the caller**, since with no tab there is nowhere for a stack trace to land. ⚠️ The port search starts at dsh's own default 3080 and walks 40, never fixed: that default is precisely the port most likely to be taken already by the user's own `dsh web`, and hardcoding it killed this feature with EADDRINUSE once. Free-port detection BINDS rather than connects (a connect probe cannot tell "free" from "listening but not answering yet"), so it is racy by nature and the caller still waits for the server to really answer before reporting success. ⚠️ Both `POST` and `DELETE` sit at the **same privilege bar as the profile installer** (`canUsernameRunPrivilegedCommands`) even though the action reads as "open a page": booting a dsh profile executes the plugin code in it, and the server is a single shared instance, so stopping it in multi-user mode takes it out from under other users' tabs. + **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) **Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name the session that spawned it, as a `parentSessionId` body field on `POST /api/sessions` / `POST /api/quick-start` or the `X-Codeman-Parent-Session` header (the agent skill sets that once on its shared curl invocation, so every spawn recipe carries it). `resolveParentSessionId()` (route-helpers.ts) **resolves rather than trusts** it: exact id, else a UNIQUE ≥8-char prefix (ids reach agents truncated), it must be a live session the caller can see AND carry the same owner, and **anything unresolvable is DROPPED, never a 400** — a cosmetic field must not be able to fail a worker spawn. It rides `toState()` into `session_created`, so there is no new SSE event. ⚠️ Rendering is an ADDITIONAL LAYER on the existing SVG pass (`_appendLineageConnectionLines` called at the tail of `_updateConnectionLinesImmediate()`, exactly like ultracode), sharing one batched read→write reflow and the `tab:` rect cache; geometry is pure in `computeLineagePath()` (constants.js). ⚠️ **ONE shape, and the second one was the bug**: every pair (flat strip or wrapped) gets a U-bridge hanging below the strip, anchored on both tabs' BOTTOM edges. A wrapped strip used to get a parent-bottom → child-TOP bezier with a ~14px row gap to bend in, which drew a flat line hidden in the gap with siblings overprinting. ⚠️ The dip is a **mis-tuned-in-both-directions corridor** (44px cap = straight thread at strip-wide spans, #285; 104px cap + full row offset = ~106px over-bow into the terminal, 2026-08-15): it now hangs from the **STRIP's bottom edge** (fallback: lower tab bottom), capped at 64px, with NO per-row offsets stacked on top — the strip-bottom baseline is also what keeps a row-1 pair's arc from drawing through row 2's tab labels. ⚠️ **Colors are keyed on the SPAWNING tab, not per child**: every arc leaving one tab is the same color however many workers it spawns, so the strip reads as "these five came from w1, those two came from w2" — per-child coloring gave one tab's own children a different color each, which is the distinction the colors exist to make. A child that spawns in turn is a parent in its own right and gets its own color for the arcs below it, so a chain changes color at each generation while each generation's fan-out stays uniform. Assignment cycles `CodemanLineage.COLORS` in first-seen order per parent id (first entry empty = the skin-tuned `--session-blue`, so the first spawning tab keeps it; the rest vivid fixed hexes), memoized rather than derived from draw index (the SVG is wiped and rebuilt constantly, so an index-based color would flicker), and set inline as `--lineage-color` so styles.css keeps owning opacity/glow/dash. `test/session-lineage-lines.test.ts` drives the real `_appendLineageConnectionLines()` and asserts the painted property, since testing the color function alone would pass just as happily with the child id passed back in. ⚠️ **Desktop only**: the overlay is `z-index: 999` and the desktop header is 100 (arcs paint over it, which is what lets them touch tab bottoms), but under 1024px mobile.css makes the header `fixed; z-index: 1200` and would bury them. ⚠️ Paths carry `data-agent-id="lineage:"` because that is what `_applyLineEntrances()` queries — that one attribute is what gives them the entrance animation and its negative-`animation-delay` resume across `svg.innerHTML=''`. ⚠️ `.session-tabs` is `overflow-x: auto`, so a scrolled-out tab still HAS a rect (over the logo); edges with an endpoint outside the strip are skipped, and a passive `scroll` listener re-anchors the rest. **Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history, and Claude transcript files into one deduped list (pure core in `src/services/unified-session-service.ts`). Transcript rows fold into their owning session via a `claudeSessionId → Codeman id` alias map, so resumed and `/clear`-respawned sessions do not appear twice. No terminal buffers in the response, unlike `/api/sessions`. Backs the Cmd+K Session Manager, plus pinning and cross-device tab order (`PUT /api/session-order`; pure merge helpers in `src/session-order.ts`, pushing device wins and server-only ids are never dropped). → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager) +**Owner tab layouts** (COD-359, `tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat tab strip, scoped per owner (`SINGLE_USER_LAYOUT_OWNER` = `@single` when multi-user is off), persisted under the `tabLayouts` key in state.json. A layout is `{version, groups[], ungrouped[], updatedAt}` whose refs point at either a session or a saved webview (`TabRefKind`), capped at 32 groups / 512 refs. ⚠️ **BACKEND ONLY as of 1.24.1**: nothing in `src/web/public/` calls these routes yet, so a UI built on top is new frontend work, not a rewiring job. ⚠️ **`TabLayoutService` is the single mutation boundary** and every lifecycle caller (session created/removed, webview created/deleted, a legacy order PUT) describes ONE completed server action and gets AT MOST ONE versioned write; writing layout state from a route or a manager directly is what the service exists to prevent. ⚠️ The layout does not replace `PUT /api/session-order`, it PROJECTS onto it: `tab-layout-legacy-order.ts` is the pure translation both ways (`putLegacyOrder()` recomposes a global order from the owner's groups), so changing one side without the other silently desyncs the tab strip from the stored layout. ⚠️ **Reconciliation is gated on a SUCCESSFUL restore** (`markRestorationComplete` / `markRestorationFailed` / `markRestorationSkipped`, plus `assertDeletionReady()`): pruning refs against a session list that failed to load would delete live tabs, so a failed restore must leave the layout untouched. `PUT` takes exactly `{baseVersion, layout}` (any other key shape is a validation error), answers a stale `baseVersion` with the current layout rather than clobbering, and is capped at 128 KiB. Broadcasts `tab:layoutChanged`, owner-routed via `deriveTabLayoutSseHint`. + **Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`. See `src/hooks-config.ts`; upstream hook semantics mirrored in `docs/claude-code-hooks-reference.md`. ⚠️ **Every claude session INSTALLS the hooks block into its workspace** (`applyWorkspaceHooks` in hooks-config.ts → `ensureCodemanHooks`, an add-only merge that keeps a user's own handlers), from EVERY claude create path — both interactive routes, cron fires, legacy scheduled runs, the plan-orchestrator one-shots — and from `restoreMuxSessions()` for sessions recovered on server start (that boot sweep skips a workspace that no longer exists, so a deleted repo with a surviving tmux session is never resurrected as an empty dir). Before 2026-08-15 hooks were written ONLY when Codeman created the case DIRECTORY, so a linked case / cloned repo — where most sessions actually run — had no hooks at all and every hook-driven surface was silently dead there: an AskUserQuestion dialog blocked the pane while the tab and the phone overview both read a calm `idle`, with no Approvals Inbox item, no push, no definitive `stop`/`idle_prompt` for respawn and no `stop`/`blocked` for the wait endpoints. The escape hatch is the synced `workspaceHooksEnabled` setting (App Settings → Agents & CLIs → Claude, **default ON**); OFF restores the old behavior, where a Codeman block that is already there is still refreshed when stale (COD-91) but one is never added. ⚠️ Route the decision through `applyWorkspaceHooks` rather than calling `ensureCodemanHooks` at a new site, or the setting silently stops applying to that path. ⚠️ Claude Code RE-READS `settings.local.json`, so an already-running session starts firing hooks without a restart (measured 2026-08-15) — and the notification for a blocking dialog is delayed by Claude Code (~30s), so the alert trails the dialog. ⚠️ An AskUserQuestion / plan-selection dialog arrives as **`permission_prompt`**, not `elicitation_dialog` (that one is MCP elicitation), so it renders as the RED "needs you" alert, not the yellow idle one. **Approvals Inbox** (cross-session queue of prompts waiting on a human; `approvalsInboxEnabled`, SYNCED, default OFF: every surface is opt-in; only the store and answer endpoints run regardless, so flipping it ON shows anything already pending): `web/approval-inbox.ts` is a `sessionWaits`-style singleton fed by `/api/hook-event`, holding at most ONE item per session (a new prompt supersedes), claude-mode only, in-memory. Cards are answered via `POST /api/approvals/:id/answer`, which sends a digit / Esc / idle-prompt text through `writeViaMux` (menu answers never carry `\r`). ⚠️ `option` digits are accepted ONLY when they match options parsed from the captured pane frame, and the answer path RE-CAPTURES the pane first (a dialog that no longer parses on screen means the keystroke would land in the composer, so refuse with 409). ⚠️ Resolution on the heuristic `working` signal ALONE is restricted to `idle` items; a permission/question item gets the pane-VERIFIED variant on that same signal (`resolveIfDialogGone()` → `verifyStillAnswerable()`), so the heuristic only decides when to LOOK and the screen decides the outcome. That is what clears a dialog answered in the terminal mid-turn; the other definitive signals are `stop`, `elicitation_complete`/`elicitation_response`, exit/delete, answer, supersede and the 12h TTL. ⚠️ **Viewing a session ACKNOWLEDGES its idle item, it does not resolve it** (`POST /api/approvals/session/:sessionId/viewed` → `acknowledgedAt` → `approval:updated`): the item stays pending (still answerable, still Read My Mind context) and only stops arming the yellow tab alert. That flag is what makes the clear durable, since the view-clears-idle rule used to live in one browser's memory and `seedApprovals()` re-armed the alert on the next reload while other devices never heard about it at all; the local half is `markIdleAlertSeen()` (app.js), called from BOTH `selectSession` paths, including the already-active early return, where a click could otherwise never clear the alert. ⚠️ **Only a HUMAN opening a session acknowledges**: `selectSession(id, { auto: true })` marks the three selections the APP makes (boot restore, a solo window opening its target, the fallback after the active session is closed) and skips the acknowledgement, so a page load cannot silently spend an alert the user never saw. The flag defaults to user-initiated, so an untagged call site fails toward acknowledging rather than toward an alert nothing can clear; `test/session-select-ack-gate.test.ts` pins both the gate and the tagged call sites. Idle-only by construction (`acknowledge()` defaults to `['idle']`): looking at a permission/question dialog does not answer it. ⚠️ Same rule on the input path: `_ackDelivery` (app.js) spends the IDLE alert only, via that same `markIdleAlertSeen()`. It used to `clearPendingHooks(sessionId)` with no kind, so one keystroke wiped a RED alert on that device while the dialog was still up, the other devices stayed red, and a reload re-seeded it. ⚠️ Claude Code fires no "permission answered" hook (only `elicitation_complete`/`elicitation_response`, i.e. the question flavor), so an answered-in-the-terminal dialog would otherwise sit pending until `stop`: `GET /api/approvals` therefore runs a **staleness sweep** over the caller's own items via `verifyStillAnswerable()`, which is deliberately the conservative check the answer path uses (only an item whose ORIGINAL frame parsed options can be dropped, so an unreadable capture keeps the alert rather than losing a live one). ⚠️ **`applyCapture()` is therefore ADD-ONLY for `options`**: a re-capture that parses nothing must never erase a parse an earlier one found. Claude Code delays the Notification hook behind the dialog (measured 6s, documented ~30s), so the 600ms re-capture routinely lands on a frame the user has ALREADY answered; clearing the field there made the item permanently unsweepable, because `verifyStillAnswerable()` reads a MISSING `options` as "we never could read this dialog" and keeps such items answerable by design. The red "needs you" then survived every sweep AND every page reload, went away only on `stop` (2026-08-20: a confirmed question left a tab flowing red for ~8 minutes while the turn ran on), and the stale card still accepted an answer, typing a bare `1` into a composer with no dialog under it. Pinned by `test/approval-inbox.test.ts`. ⚠️ A frame that parses no options is CONCLUSIVE in exactly two cases, and the second one closes the late-hook hole: the item once parsed options (they cannot vanish while the dialog is up), or the frame shows Claude actively running a turn. A modal dialog BLOCKS the turn, so the two cannot coexist — measured on v2.1.237, a live-dialog frame carries neither the `… (13s` timer NOR the `esc to interrupt` footer, which the dialog replaces with `Enter to select · ↑/↓ to navigate · Esc to cancel`. Anything else stays answerable, so an unreadable capture still keeps the alert. That second signal is reached by a delayed staleness pass (`STALE_CHECK_DELAY_MS`, 3s) scheduled alongside the re-capture, because a prompt answered BEFORE the hook lands creates an item whose FIRST capture already has no dialog in it: nothing ever parsed, `stop` may have fired already, and the alert then outlived reloads until the 12h TTL. ⚠️ That pass must stay comfortably LATER than `RECAPTURE_DELAY_MS`, whose whole reason for existing is that the hook can beat Ink to the screen — resolving inside the paint window would clear the alert for a dialog that was about to appear. The frontend seeds from `GET /api/approvals` in `handleInit` **regardless of the setting**: the seed re-arms the tab-alert state machine (`setPendingHook`) unconditionally, and only populating `this.approvals` (the inbox surfaces) is gated — seeding used to be gated wholesale, which left a reloaded page with NO red tab while a permission dialog sat blocking a session (2026-08-15); `_onApprovalResolved` clears the pending-hook alert unconditionally for the same reason. ⚠️ The red/yellow tab alert itself is a STEADY border/background/dot with a pulse on top: the original keyframes swung to transparent at 0%/100%, so half of every cycle looked like a normal tab. Push Approve/Deny buttons stay gated on the setting (`sendPushNotifications` strips `actions`/`approvalId` when OFF) and are answered from `sw.js` directly so they work with no tab open. Surfaces (all gated on the setting): header bell (marker-hidden until count > 0, phones never show it) + drawer (`approvals-ui.js`), phone overview NEEDS YOU answer strips (`mobile-overview.js`). Design: `docs/approvals-inbox-plan.md`. @@ -244,6 +254,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place — `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that WRITES**: read-path confinement (realpath + workspace + ownership) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md` +**Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure, no IO, so it unit-tests directly) compiles the query into a reusable predicate, which is what lets the server-side walk prune instead of streaming the whole tree. ⚠️ **A query turns that endpoint into a FLAT match list rather than a nested tree**, and the walk deliberately recurses past non-matching directories, since the whole point of searching is to reach a file whose ancestors do not match. An empty or whitespace-only query compiles to `null`, which is what keeps the default tree response byte-identical when no search is requested. ⚠️ **Globs are never compiled into a RegExp**: `*a*a*a…` translated to `^.*a.*a.*a…$` is a classic backtracking blowup evaluated synchronously against every walked path, so one pathological query would freeze the event loop for the whole server (the same reason `search-service.ts` is regex-free). `globMatch()` is a two-pointer wildcard walk instead, O(text · pattern) with both operands short by construction, and an overlong query (`MAX_QUERY_LENGTH`, 256) also compiles to `null` rather than running. A query containing `/` matches the relative path, otherwise the bare entry name; globs match anchored and case-insensitively (`*` spans any run, slashes included, `?` exactly one character), everything else is a plain case-insensitive substring. + **Raw file bodies are streamed and range-aware**: `file-raw` and the attachments `/raw` route always advertise `Accept-Ranges: bytes` and answer a `Range` header with `206` + `Content-Range` (single-range only; parser is pure + unit-tested in `src/web/http-range.ts`, a malformed spec is ignored → 200 while an out-of-bounds one is a 416). ⚠️ A 200-only response is what made the File Viewer's `