diff --git a/.changeset/omp-backend.md b/.changeset/omp-backend.md new file mode 100644 index 00000000..f1804449 --- /dev/null +++ b/.changeset/omp-backend.md @@ -0,0 +1,17 @@ +--- +"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/.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 cee65117..d7a0b7f6 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`) 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) @@ -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 bab942c3..2e58ee5e 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), [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), 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), [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. @@ -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), [`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 @@ -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/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/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/docs/architecture-invariants.md b/docs/architecture-invariants.md index eae4198d..e34e7cd5 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/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..d4e59dc9 --- /dev/null +++ b/docs/omp-integration.md @@ -0,0 +1,171 @@ +# OMP (Oh My Pi) sessions + +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 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 + +```bash +curl -fsSL https://omp.sh/install | sh +``` + +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/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/.local/bin", "version": "18.0.8" } +``` + +## 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, 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, 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` + +`--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/.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 +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 `~/.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 + +- **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. 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 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 50548efa..c9ab7d43 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 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 @@ -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/config/dependency-registry.ts b/src/config/dependency-registry.ts index 65ff9ca8..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'; @@ -190,6 +191,30 @@ export const DEPENDENCY_REGISTRY: ToolDependency[] = [ }, ], }, + { + id: 'omp', + label: 'OMP CLI', + category: 'core', + required: false, + usedBy: ['OMP sessions'], + // 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' }, + }, { id: 'libreoffice', label: 'LibreOffice', diff --git a/src/docker-hosts.ts b/src/docker-hosts.ts index e2a92486..ac4ffcd3 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; } @@ -639,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/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/omp-transcript.ts b/src/omp-transcript.ts new file mode 100644 index 00000000..9bf25b6c --- /dev/null +++ b/src/omp-transcript.ts @@ -0,0 +1,175 @@ +/** + * @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' && 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') { + 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/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/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 34ac7b30..8a0608aa 100644 --- a/src/session.ts +++ b/src/session.ts @@ -53,9 +53,11 @@ import { type PiConfig, type GrokConfig, type DeepSeekConfig, + type OmpConfig, type SessionRemote, type SessionDocker, } from './types.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'; @@ -180,7 +182,8 @@ export function isExternalCliMode(mode: SessionMode): boolean { mode === 'antigravity' || mode === 'pi' || mode === 'grok' || - mode === 'deepseek' + mode === 'deepseek' || + mode === 'omp' ); } @@ -200,6 +203,8 @@ function getModeLabel(mode: SessionMode): string { return 'Grok'; case 'deepseek': return 'DeepSeek'; + case 'omp': + return 'OMP'; case 'shell': return 'Shell'; case 'claude': @@ -528,6 +533,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 +634,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) */ @@ -682,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 @@ -735,6 +750,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 +1387,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 @@ -1494,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; @@ -1585,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); @@ -1617,6 +1644,16 @@ export class Session extends EventEmitter { piConfig: this._piConfig, grokConfig: this._grokConfig, deepSeekConfig: this._deepSeekConfig, + // 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, @@ -1627,6 +1664,45 @@ 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 _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 + // 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; + } + // 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` + ); + this._ompConfig = { ...this._ompConfig, continueSession: true }; + } + /** * Remember whether the CLI currently wants to be told about mouse clicks. * @@ -1877,6 +1953,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, @@ -1888,8 +1965,14 @@ 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. `_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; // 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 @@ -2007,7 +2090,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); @@ -2298,10 +2386,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 _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 + * 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 = resolveAndClaimOmpSessionId(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/tmux-manager.ts b/src/tmux-manager.ts index 5088793d..182db818 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,34 @@ 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); + } + + // --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(' '); +} + /** * Build the spawn command for any session mode. * Shared by createSession() and respawnPane() to avoid duplication. @@ -941,6 +972,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 +1028,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 +1214,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 +1248,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 +1845,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 +1854,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 +1960,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 +2074,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { piConfig, grokConfig, deepSeekConfig, + ompConfig, resumeSessionId, envOverrides, effort, @@ -2099,6 +2141,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 +2160,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { piConfig, grokConfig, deepSeekConfig, + ompConfig, resumeSessionId, effort, sessionName: name, @@ -2345,6 +2391,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { piConfig, grokConfig, deepSeekConfig, + ompConfig, resumeSessionId, envOverrides, effort, @@ -2376,6 +2423,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..3c34b49d 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,16 @@ 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; + /** Continue the most recent session in this directory (passed via --continue). */ + continueSession?: boolean; +} + /** * Pi CLI (pi.dev) session configuration. * @@ -620,6 +631,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 9b64d63f..502c5c35 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -67,3 +67,4 @@ export { export type { DeepSeekProfile, DeepSeekProfileKind } from './deepseek-cli-resolver.js'; export { compileFileQuery, matchFileQuery } from './file-query.js'; export type { FileQueryMatcher } from './file-query.js'; +export { resolveOmpDir, isOmpAvailable, getOmpNotFoundMessage, getOmpCliVersion } from './omp-cli-resolver.js'; diff --git a/src/utils/omp-cli-resolver.ts b/src/utils/omp-cli-resolver.ts new file mode 100644 index 00000000..91f046e5 --- /dev/null +++ b/src/utils/omp-cli-resolver.ts @@ -0,0 +1,144 @@ +/** + * @fileoverview Resolve the OMP CLI binary across common install paths. + * + * Uses the shared `createCliExecutableResolver` (cli-executable-resolver.ts), + * same as the sibling claude/opencode/codex/gemini/antigravity/pi resolvers: + * server process PATH first, then common install directories, then — last, + * because it is the only step that spawns anything — an interactive login + * shell, which is what finds nvm/Homebrew/user-npm installs when Codeman runs + * as a systemd/launchd service with a minimal PATH. + * + * Provides an augmented PATH directory for tmux sessions. + * + * @module utils/omp-cli-resolver + */ + +import { execFileSync } from 'node:child_process'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; +import { + createCliExecutableResolver, + formatCliNotFoundMessage, + type CliResolverHost, +} from './cli-executable-resolver.js'; + +/** + * Common directories where the OMP CLI binary may be installed. `~/.local/bin` + * leads: omp.sh's installer targets `$HOME/.local/bin` with no `--dir` + * override (verified against a real `--no-cache` Docker build — see + * docker/agent.Dockerfile); `~/.omp/bin` was an unverified guess that turned + * out wrong, kept after `~/.local/bin` only as a defensive fallback. + */ +const OMP_SEARCH_DIRS = [ + join(homedir(), '.local', 'bin'), + join(homedir(), '.omp', 'bin'), + '/usr/local/bin', + join(homedir(), '.bun', 'bin'), + join(homedir(), '.npm-global', 'bin'), + join(homedir(), 'bin'), +]; + +/** + * A real `omp --version` prints `omp/` (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/utils/omp-session-resolver.ts b/src/utils/omp-session-resolver.ts new file mode 100644 index 00000000..e01ac00a --- /dev/null +++ b/src/utils/omp-session-resolver.ts @@ -0,0 +1,192 @@ +/** + * @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 { closeSync, openSync, readdirSync, readSync, statSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join, sep } from 'node:path'; + +/** A real OMP session file is `_.jsonl`; only the uuid matters here. */ +const OMP_SESSION_FILE_PATTERN = /^.+_([a-zA-Z0-9-]+)\.jsonl$/; + +/** + * Mirrors `omp`'s own directory mangling. Confirmed empirically against real + * `~/.omp/agent/sessions/` directory names (2026-08-27): unlike Claude Code's + * `~/.claude/projects/*`, which keeps the home prefix (`-home-user-dev-foo`), + * omp collapses a home-relative workingDir to its home-relative remainder + * FIRST (`/home/user/dev/foo` -> `/dev/foo`) and only then dash-replaces + * (`-dev-foo`) — a path outside $HOME (e.g. `/tmp/...`) is dash-replaced as-is. + * Getting this wrong doesn't error, it just silently returns an empty + * directory listing: findLatestOmpSessionId() below then always falls through + * to null, so continuation pinning quietly degrades to omp's own ambiguous + * `--continue` for every case under $HOME (i.e. virtually all real Codeman + * cases) while appearing to work in `/tmp`-based manual testing. + * Pure so it's unit-testable without touching the filesystem. + */ +export function mangleOmpWorkingDir(workingDir: string): string { + // UNVERIFIED EDGE CASE: if $HOME is itself a symlink, this compares against + // the literal homedir() string, not a realpath()-resolved one. Whether that + // matches omp's own behavior is unconfirmed — we only empirically verified + // omp strips a literal $HOME prefix (2026-08-27), not that it canonicalizes + // symlinks first. Do not "fix" this with realpathSync() without confirming + // omp's actual behavior on a symlinked-home setup; guessing wrong here would + // trade one silent mismatch for a different one. + const home = homedir(); + const relative = + workingDir === home || workingDir.startsWith(home + sep) ? workingDir.slice(home.length) : workingDir; + return relative.replace(/\//g, '-'); +} + +/** + * `~/.omp` — omp's own env overrides are mostly `PI_*` (shared with pi mode, already + * allowlisted in schemas.ts), and `PI_CONFIG_DIR` in particular can move this root. + * That is not honored here: a session with a redirected `PI_CONFIG_DIR` silently + * degrades pinning/history to omp's own ambiguous `--continue` instead of erroring, + * a known gap (found in Ark0N/Codeman#353 review) shared with pi and not fixed here. + */ +function resolveOmpHome(): string { + return join(homedir(), '.omp'); +} + +/** + * Newest OMP session id for this working directory, or null when the + * directory doesn't exist yet (never launched) or holds no session files. + * + * Deliberately "newest file, full stop" rather than a time-windowed match: + * callers only invoke this at a moment where that's unambiguous by + * construction — right after the file that answers it was the only thing + * that could have just been written (a dead pane's process already exited, + * or a session being resumed has no live sibling in the same directory yet). + */ +export function findLatestOmpSessionId(workingDir: string): string | null { + const dir = join(resolveOmpHome(), 'agent', 'sessions', mangleOmpWorkingDir(workingDir)); + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return null; + } + + let newestMtime = -Infinity; + let newestId: string | null = null; + for (const entry of entries) { + const match = OMP_SESSION_FILE_PATTERN.exec(entry); + if (!match) continue; + let mtimeMs: number; + try { + mtimeMs = statSync(join(dir, entry)).mtimeMs; + } catch { + continue; + } + if (mtimeMs > newestMtime) { + newestMtime = mtimeMs; + newestId = match[1]; + } + } + return newestId; +} + +/** + * The session header line is always near the top of the file (the + * transcript's own "second line" — see omp-transcript.ts), so identifying a + * file never needs reading the whole thing (up to multi-MB, per that same + * module's size cap). Bounded read only. + */ +const HEADER_READ_BYTES = 8 * 1024; + +function readOmpSessionHeader(filePath: string): { id: string; cwd: string } | null { + let raw: string; + try { + const fd = openSync(filePath, 'r'); + try { + const buf = Buffer.alloc(HEADER_READ_BYTES); + const bytesRead = readSync(fd, buf, 0, HEADER_READ_BYTES, 0); + raw = buf.toString('utf-8', 0, bytesRead); + } finally { + closeSync(fd); + } + } catch { + return null; + } + for (const line of raw.split('\n')) { + if (!line) continue; + let entry: unknown; + try { + entry = JSON.parse(line); + } catch { + continue; + } + if (!entry || typeof entry !== 'object') continue; + const e = entry as Record; + 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/src/web/public/app.js b/src/web/public/app.js index fc750d49..d256f436 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() { @@ -4901,7 +4903,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 : ''} @@ -6349,7 +6351,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 d9491d45..78eff9d3 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..df647ae9 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' }, ]; @@ -388,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/mobile.css b/src/web/public/mobile.css index e0ddb53d..c9b0838d 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%; @@ -3086,6 +3100,12 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat 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) { background: linear-gradient(135deg, #27272a, #52525b); border-color: #18181b; diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index d9d96f29..66197c27 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', @@ -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 6de0507c..b4d18479 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(); } @@ -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'; } @@ -689,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); } @@ -710,7 +713,7 @@ Object.assign(CodemanApp.prototype, { 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'; } }, @@ -1423,6 +1426,56 @@ Object.assign(CodemanApp.prototype, { } }, + 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 ? {} : { + ...(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); + } + + this.terminal.focus(); + } catch (err) { + this._reportSessionLaunchError(ownsLaunchTerminal, err.message); + } + }, + /** * Launch a Grok Build (xAI `grok`) session. * @@ -1626,7 +1679,7 @@ 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 @@ -1656,7 +1709,7 @@ 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' : ''; }); @@ -3442,7 +3495,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 8efb776e..a517b3c0 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); } @@ -2499,6 +2500,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); @@ -3883,6 +3888,22 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { 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 and .run-mode-dot.grok so the welcome action reads as the same backend. */ @@ -4992,6 +5013,21 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { 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 @@ -5116,6 +5152,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/public/terminal-ui.js b/src/web/public/terminal-ui.js index 84451307..bb83cc5a 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,45 @@ 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]; + // 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' }, body: JSON.stringify({ workingDir, name, - resumeSessionId: sessionId, + mode: effectiveMode, + ...(effectiveMode === 'claude' ? { resumeSessionId: sessionId } : {}), + ...modeConfig, ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), ...(effort ? { effort } : {}), }), @@ -2944,6 +2976,21 @@ 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. + // 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(() => {}); + } + this.terminal.writeln(`\x1b[90m Session ${name} ready\x1b[0m`); await this.selectSession(newSessionId); this.terminal.focus(); 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 3e7c258f..9c9e7b40 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'; @@ -65,6 +67,7 @@ import { autoConfigureRalph, canAccessOwned, CASES_DIR, + findPersistedSessionOrFail, findSessionOrFail, getAuthUser, isAdmin, @@ -136,6 +139,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, @@ -395,10 +400,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. * @@ -415,8 +420,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. @@ -744,6 +765,36 @@ 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. + */ +export 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); + 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; +} + export function registerSessionRoutes( app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort @@ -866,6 +917,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 +1017,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 +1070,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 +1116,7 @@ export function registerSessionRoutes( piConfig: mode === 'pi' ? gatedPiConfig : undefined, grokConfig: mode === 'grok' ? gatedGrokConfig : undefined, deepSeekConfig: mode === 'deepseek' ? gatedDeepSeekConfig : undefined, + ompConfig: resolveOmpConfigForCreate(mode, workingDir, body.ompConfig), resumeSessionId: validatedResumeId, envOverrides: await clampEnvOverridesForOwner(owner, body.envOverrides), effort: body.effort, @@ -1124,9 +1185,26 @@ 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. + 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 {}; + } + const session = findSessionOrFail(ctx, id, req); await ctx.cleanupSession(session.id, killMux, 'user_delete'); return {}; }); @@ -1289,6 +1367,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 +2936,7 @@ export function registerSessionRoutes( piConfig, grokConfig, deepSeekConfig, + ompConfig, envOverrides, effort, parentSessionId, @@ -2907,6 +2987,7 @@ export function registerSessionRoutes( piConfig || grokConfig || deepSeekConfig || + ompConfig || openCodeConfig ) { return createErrorResponse( @@ -2941,6 +3022,7 @@ export function registerSessionRoutes( piConfig || grokConfig || deepSeekConfig || + ompConfig || openCodeConfig ) { return createErrorResponse( @@ -3042,6 +3124,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 +3195,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); } @@ -3185,6 +3278,7 @@ export function registerSessionRoutes( mode !== 'pi' && mode !== 'grok' && mode !== 'deepseek' && + mode !== 'omp' && !remote && envOverrides && Object.keys(envOverrides).length > 0 @@ -3209,10 +3303,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 +3348,7 @@ export function registerSessionRoutes( piConfig: mode === 'pi' ? qsGatedPiConfig : undefined, grokConfig: mode === 'grok' ? qsGatedGrokConfig : undefined, deepSeekConfig: mode === 'deepseek' ? qsGatedDeepSeekConfig : undefined, + ompConfig: resolveOmpConfigForCreate(mode, resolvedCasePath, ompConfig), envOverrides: qsGatedEnvOverrides, effort, remote, @@ -4100,6 +4197,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/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 1641e61f..a78b9def 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,25 @@ 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(), + continueSession: z.boolean().optional(), + }) + .optional(); + /** * Schema for DeepSeek Harness (`dsh`)-specific configuration. * @@ -440,7 +460,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 +480,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 +892,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 +902,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 +1436,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 8ef0b24a..0cf318d6 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1446,6 +1446,7 @@ export class WebServer extends EventEmitter { { isPiAvailable }, { isGrokAvailable }, { isDeepSeekRunnable, isDeepSeekAvailable }, + { isOmpAvailable }, { isCloudflaredAvailable }, { isGitAvailable }, ] = await Promise.all([ @@ -1457,6 +1458,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'), ]); @@ -1475,6 +1477,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. @@ -2783,6 +2786,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, 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/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)', () => { 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/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/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' }); + }); +}); 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..49c9f48e --- /dev/null +++ b/test/omp-fresh-run-no-resume.test.ts @@ -0,0 +1,129 @@ +/** + * @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._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'; +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 }); + // 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 () => { + 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 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', + 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).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'); + }); +}); diff --git a/test/omp-mode.test.ts b/test/omp-mode.test.ts new file mode 100644 index 00000000..dd7aa5a3 --- /dev/null +++ b/test/omp-mode.test.ts @@ -0,0 +1,165 @@ +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', () => { + 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('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({ + 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\''); + }); +}); + +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); + }); +}); diff --git a/test/omp-session-resolver.test.ts b/test/omp-session-resolver.test.ts new file mode 100644 index 00000000..ed6d2f60 --- /dev/null +++ b/test/omp-session-resolver.test.ts @@ -0,0 +1,128 @@ +/** + * @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'; +import { resolveOmpConfigForCreate } from '../src/web/routes/session-routes.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(); + }); +}); + +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(); + }); +}); 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, ]) { 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 dbe8efa2..839786ad 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -341,6 +341,36 @@ 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(); + // 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 () => { + 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) ========== 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) {'));