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

Mission control for AI coding agents

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

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

-**Codeman** is a self-hosted mission control for AI coding agents. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, or Grok inside persistent tmux sessions, streams the real terminal to any browser, and keeps agents productive after you walk away: it re-prompts on idle, resumes when a usage limit resets, runs scheduled jobs, and shows every background agent working in real time. +**Codeman** is a self-hosted mission control for AI coding agents. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, or OMP inside persistent tmux sessions, streams the real terminal to any browser, and keeps agents productive after you walk away: it re-prompts on idle, resumes when a usage limit resets, runs scheduled jobs, and shows every background agent working in real time. Get started in one line (macOS & Linux, Windows via WSL): @@ -42,7 +42,7 @@ codeman web The installer asks before every system change, and re-running the same line updates in place. Full details: [Quick Start - Installation](#quick-start---installation). -- **One dashboard, seven CLIs** - run [Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, or Grok](#more-features) per session (plus plain shell), locally, [in Docker](#isolated-docker-sessions), or [over SSH](#remote-ssh-sessions) +- **One dashboard, eight CLIs** - run [Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, or OMP](#more-features) per session (plus plain shell), locally, [in Docker](#isolated-docker-sessions), or [over SSH](#remote-ssh-sessions) - **Truly phone-friendly** - a [touch-optimized terminal](#mobile-optimized-web-ui) with instant local echo, QR login, swipe navigation, and push notifications - **Runs while you sleep** - [idle detection + respawn cycling](#respawn-controller) and auto-resume when a subscription limit resets, for 24+ hour unattended runs - **See your agents think** - [live floating windows](#live-agent-visualization) for every subagent and teammate, with real-time transcripts @@ -68,7 +68,7 @@ This installs Node.js, tmux and a build toolchain if missing (node-pty ships no - **Re-run to update.** The same one-liner updates a finished install in place: local changes in `~/.codeman/app` are stashed (never discarded), and a running service is restarted and verified. If a first install was interrupted, re-running resumes the full setup instead. `install.sh update` and `install.sh uninstall` also exist. - **CI / headless:** without a terminal attached, steps that would change your system abort with instructions instead of running silently. Set `CODEMAN_NONINTERACTIVE=1` to approve them for automation. -You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), or [Grok Build](https://github.com/xai-org/grok-build) (any combination works; Gemini CLI is enterprise-only since Google's consumer cutover, and Antigravity is its successor). The installer detects whichever of the seven is present; if none is found, it offers to install Claude Code or OpenCode, or you can skip and install one yourself later. After install: +You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), or [OMP](https://github.com/can1357/omp) (any combination works; Gemini CLI is enterprise-only since Google's consumer cutover, and Antigravity is its successor). The installer detects whichever of the eight is present; if none is found, it offers to install Claude Code or OpenCode, or you can skip and install one yourself later. After install: ```bash codeman web @@ -171,7 +171,7 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist wsl bash -c "curl -fsSL https://getcodeman.com/install | bash" ``` -Codeman requires tmux, so Windows users need [WSL](https://learn.microsoft.com/en-us/windows/wsl/install). If you don't have WSL yet: run `wsl --install` in an admin PowerShell, reboot, open Ubuntu, then install your preferred AI coding CLI inside WSL ([Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), or [Grok Build](https://github.com/xai-org/grok-build)). After installing, `http://localhost:3000` is accessible from your Windows browser. +Codeman requires tmux, so Windows users need [WSL](https://learn.microsoft.com/en-us/windows/wsl/install). If you don't have WSL yet: run `wsl --install` in an admin PowerShell, reboot, open Ubuntu, then install your preferred AI coding CLI inside WSL ([Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), or [OMP](https://github.com/can1357/omp)). After installing, `http://localhost:3000` is accessible from your Windows browser. @@ -253,7 +253,7 @@ Click **+ New Session** (or **Quick Start**). A session is one AI CLI running in | Field | What it does | | ---------------------------- | ------------------------------------------------------------------------------------------------------------------- | | **Working directory / case** | The folder the agent operates in. A "case" is just a named working dir Codeman remembers. **Add Case** creates one from scratch, links an existing folder, or clones a GitHub repo straight into one (**Clone Repo**). | -| **CLI / run mode** | `Claude` (default), `OpenCode`, `Codex`, `Antigravity`, `Gemini`, `Pi`, `Grok`, or `Terminal` (plain shell). | +| **CLI / run mode** | `Claude` (default), `OpenCode`, `Codex`, `Antigravity`, `Gemini`, `Pi`, `Grok`, `OMP`, or `Terminal` (plain shell). | | **Model** | Per-session model (App Settings → Models → New Claude sessions). A soft default — `/model` still works in-session. | | **Effort / Ultracode** | Reasoning effort (`low`–`max`) or `ultracode` for dynamic multi-agent workflows. Switchable anytime with `/effort`. | @@ -437,7 +437,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt - **Background daemon & service install** — `codeman web -d` runs the server detached with a pidfile, `~/.codeman/web.log`, and verified startup (it polls the server until it answers, so a port clash never reads as success); `codeman service install` writes a systemd user unit (Linux) or LaunchAgent (macOS) with your shell's PATH baked in, so an nvm or Homebrew `node`, `tmux` and `claude` are actually found. Secrets are never written into unit files - **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → System → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable) - **Clone a GitHub repo as a case** — paste a repository URL into **Add Case → Clone Repo** and Codeman clones it into `~/codeman-cases/` and registers it as a normal case, ready to run an agent in. It preflights the URL while you type (tells you whether it can be cloned anonymously and offers the repo's real branches and tags for the optional branch/tag field), fills the case name in from the URL, and lets you pick which CLI the Run button should use. Public repositories over `https://`; Codeman never collects or stores credentials -- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, or **Grok** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md) and [`docs/grok-integration.md`](docs/grok-integration.md) +- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, or **OMP** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*` vs `OMP_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md) and [`docs/grok-integration.md`](docs/grok-integration.md) - **Docker sessions** — run a case inside an isolated, hardened container. One checkbox on **Create New** spins up a container with sensible defaults and starts the agent inside it; multiple sessions share one per-case container; export a container + its workspace to a portable `.tar.gz` to move it to another machine. See [`docs/docker-cases.md`](docs/docker-cases.md) - **Remote SSH sessions** — point a case at another machine and run the agent there inside a durable remote tmux: survives SSH drops, auto-reconnects, and can discover + attach sessions already running on the host. See [`docs/remote-sessions.md`](docs/remote-sessions.md) - **Effort & Ultracode** — set a per-session default effort (`low`–`max`) or enable **ultracode** (dynamic multi-agent workflows). Soft defaults only — switchable anytime with `/effort` in-session. Extended-thinking budget is configurable too @@ -460,7 +460,7 @@ Run a case inside its own hardened Docker container instead of directly on your - **Shared per-case container** — many sessions can `docker exec` into the same container; killing one session never tears the container out from under the others. - **Hardened by default** — non-root, `--cap-drop ALL`, `no-new-privileges`, PID/memory caps, never `--privileged` or the docker socket; a **sealed** profile (no host credentials, network off) is one toggle away. - **Seamless auth, isolated credentials** — your host Claude / Codex / Antigravity / Gemini / OpenCode / Pi logins work inside the container out of the box: credentials are seeded (copied) in at launch and onboarding/trust prompts are pre-answered, so no login wizard appears. The container keeps its own copies and never writes back to your host credential stores; only conversation transcripts are shared, and exports never capture secrets. -- **Move it to another machine** — export a container's whole environment (toolchain + workspace) to a portable `.tar.gz`, `docker load` it on the other side, and import it into a fresh case. +- **Seamless auth, isolated credentials** — your host Claude / Codex / Antigravity / Gemini / OpenCode / OMP logins work inside the container out of the box: credentials are seeded (copied) in at launch and onboarding/trust prompts are pre-answered, so no login wizard appears. The container keeps its own copies and never writes back to your host credential stores; only conversation transcripts are shared, and exports never capture secrets.- **Move it to another machine** — export a container's whole environment (toolchain + workspace) to a portable `.tar.gz`, `docker load` it on the other side, and import it into a fresh case. - **Durable** — reconnect after a restart lands back in the same live agent; a container stop/reboot resumes the conversation from the bind-mounted transcript. Prerequisite: just Docker (or Podman). The agent base image builds itself automatically on first use, with progress streamed to the UI (or pre-build it with `node scripts/build-agent-image.mjs`). Full guide: [`docs/docker-cases.md`](docs/docker-cases.md). @@ -795,7 +795,7 @@ When a CLI runs in a Codeman-managed session, these environment variables are se 5. **`/api/v1/*`** is a stable alias of `/api/*`. 6. **Wait instead of polling, and don't treat a timeout as an error.** The wait endpoints answer with HTTP `200` and `wait.timedOut: true` when nothing happened in time, so loop over short waits (60s is the default) rather than issuing one long call, because tunnels cut idle connections. `wait.timeoutMs` tells you the timeout the server actually applied after clamping (600s ceiling). 7. **Only `claude` sessions emit `stop` and `blocked`.** Those two come from Claude Code hooks; `shell` and the external CLIs (opencode/codex/gemini/antigravity/pi) accept only `idle`, `working` and `exit`. Asking for `stop` explicitly on those is a `400`; omitting `until` is always safe. ⚠️ On a `shell` session `idle` fires **once**, at startup, and never again, so send-and-wait there can only time out; synchronize hook-less sessions with a `wait-output` marker. -8. **Nothing reports "ready", so wait for it explicitly.** A new session answers `{"signal":"exit","immediate":true}` (that means *not started*, not *crashed*) until its PID exists, and a `claude` worker in a fresh case then sits on the CLI's trust dialog. Prompt it there and the wait resolves on `idle` in ~2s looking exactly like a finished turn, while the text sits stuck in the dialog. Recipe 2b below is the sequence that avoids it. +7. **Only `claude` sessions emit `stop` and `blocked`.** Those two come from Claude Code hooks; `shell` and the external CLIs (opencode/codex/gemini/antigravity/omp) accept only `idle`, `working` and `exit`. Asking for `stop` explicitly on those is a `400`; omitting `until` is always safe. ⚠️ On a `shell` session `idle` fires **once**, at startup, and never again, so send-and-wait there can only time out; synchronize hook-less sessions with a `wait-output` marker.8. **Nothing reports "ready", so wait for it explicitly.** A new session answers `{"signal":"exit","immediate":true}` (that means *not started*, not *crashed*) until its PID exists, and a `claude` worker in a fresh case then sits on the CLI's trust dialog. Prompt it there and the wait resolves on `idle` in ~2s looking exactly like a finished turn, while the text sits stuck in the dialog. Recipe 2b below is the sequence that avoids it. ### Recipes @@ -1011,7 +1011,7 @@ flowchart TB subgraph External["External"] CLI["AI CLI
Claude Code / OpenCode / Codex / Antigravity / Gemini / Pi"] - BG["Background Agents
(Task tool)"] + CLI["AI CLI
Claude Code / OpenCode / Codex / Antigravity / Gemini / OMP"] BG["Background Agents
(Task tool)"] end end diff --git a/src/config/dependency-registry.ts b/src/config/dependency-registry.ts index 65ff9ca8..84a8c5ab 100644 --- a/src/config/dependency-registry.ts +++ b/src/config/dependency-registry.ts @@ -190,6 +190,15 @@ export const DEPENDENCY_REGISTRY: ToolDependency[] = [ }, ], }, + { + id: 'omp', + label: 'OMP CLI', + category: 'core', + required: false, + usedBy: ['OMP sessions'], + resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['omp'], versionArg: '--version' } }], + installHint: { linux: 'curl -fsSL https://omp.sh/install | sh', darwin: 'brew install can1357/tap/omp' }, + }, { id: 'libreoffice', label: 'LibreOffice', diff --git a/src/docker-hosts.ts b/src/docker-hosts.ts index e2a92486..772bc9aa 100644 --- a/src/docker-hosts.ts +++ b/src/docker-hosts.ts @@ -147,6 +147,7 @@ export function defaultDockerCommandForMode(mode: SessionMode): string { pi: 'exec pi', grok: 'exec grok', deepseek: 'exec dsh', + omp: 'exec omp', }; return commands[mode as DockerCommandMode] || commands.shell; } diff --git a/src/mux-interface.ts b/src/mux-interface.ts index 6cb9693f..f4e3dd24 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -21,6 +21,7 @@ import type { PiConfig, GrokConfig, DeepSeekConfig, + OmpConfig, SessionRemote, SessionDocker, } from './types.js'; @@ -82,6 +83,7 @@ export interface CreateSessionOptions { piConfig?: PiConfig; grokConfig?: GrokConfig; deepSeekConfig?: DeepSeekConfig; + ompConfig?: OmpConfig; /** When restoring after reboot, resume a previous Claude conversation by its session ID */ resumeSessionId?: string; /** Extra env vars exported before launching the CLI (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Ephemeral — not written to disk. */ @@ -116,6 +118,7 @@ export interface RespawnPaneOptions { piConfig?: PiConfig; grokConfig?: GrokConfig; deepSeekConfig?: DeepSeekConfig; + ompConfig?: OmpConfig; /** Resume a previous Claude conversation when respawning */ resumeSessionId?: string; /** Extra env vars exported before launching the CLI (preserved across respawns). */ diff --git a/src/remote-hosts.ts b/src/remote-hosts.ts index a82bdb37..5fce5128 100644 --- a/src/remote-hosts.ts +++ b/src/remote-hosts.ts @@ -119,6 +119,7 @@ export function defaultRemoteCommandForMode(mode: SessionMode): string { // profile inventory is unknown here. The per-host `commands.deepseek` override // is the escape hatch for naming one. deepseek: remoteLoginShellCommand('dsh'), + omp: remoteLoginShellCommand('omp'), }; return commands[mode as RemoteCommandMode] || commands.shell; } @@ -274,6 +275,7 @@ const REMOTE_CLI_BIN: Partial> = { gemini: 'gemini', antigravity: 'agy', pi: 'pi', + omp: 'omp', }; /** diff --git a/src/session.ts b/src/session.ts index 34ac7b30..604458e6 100644 --- a/src/session.ts +++ b/src/session.ts @@ -53,6 +53,7 @@ import { type PiConfig, type GrokConfig, type DeepSeekConfig, + type OmpConfig, type SessionRemote, type SessionDocker, } from './types.js'; @@ -180,7 +181,8 @@ export function isExternalCliMode(mode: SessionMode): boolean { mode === 'antigravity' || mode === 'pi' || mode === 'grok' || - mode === 'deepseek' + mode === 'deepseek' || + mode === 'omp' ); } @@ -200,6 +202,8 @@ function getModeLabel(mode: SessionMode): string { return 'Grok'; case 'deepseek': return 'DeepSeek'; + case 'omp': + return 'OMP'; case 'shell': return 'Shell'; case 'claude': @@ -528,6 +532,8 @@ export class Session extends EventEmitter { // DeepSeek Harness configuration (only for mode === 'deepseek') private _deepSeekConfig: DeepSeekConfig | undefined; + // OMP configuration (only for mode === 'omp') + private _ompConfig: OmpConfig | undefined; private _resumeSessionId: string | undefined; // Ephemeral env overrides (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Exported by tmux @@ -627,6 +633,8 @@ export class Session extends EventEmitter { grokConfig?: GrokConfig; /** DeepSeek Harness configuration (only for mode === 'deepseek') */ deepSeekConfig?: DeepSeekConfig; + /** OMP configuration (only for mode === 'omp') */ + ompConfig?: OmpConfig; /** Resume a previous Claude conversation (used after server reboot) */ resumeSessionId?: string; /** Extra env vars exported to the CLI at spawn time (no disk persistence) */ @@ -735,6 +743,10 @@ export class Session extends EventEmitter { if (config.piConfig) { this._piConfig = config.piConfig; } + // Apply OMP configuration + if (config.ompConfig) { + this._ompConfig = config.ompConfig; + } // Apply DeepSeek Harness configuration if (config.deepSeekConfig) { @@ -1368,6 +1380,7 @@ export class Session extends EventEmitter { piConfig: this._piConfig, grokConfig: this._grokConfig, deepSeekConfig: this._deepSeekConfig, + ompConfig: this._ompConfig, resumeSessionId: this._resumeSessionId, effort: this._effort, // COD-118: runtime-only — surfaced so the frontend can require explicit user @@ -1617,6 +1630,7 @@ export class Session extends EventEmitter { piConfig: this._piConfig, grokConfig: this._grokConfig, deepSeekConfig: this._deepSeekConfig, + ompConfig: this._ompConfig, resumeSessionId: this._resumeSessionId, envOverrides: this._envOverrides, effort: this._effort, @@ -1877,6 +1891,7 @@ export class Session extends EventEmitter { piConfig: this._piConfig, grokConfig: this._grokConfig, deepSeekConfig: this._deepSeekConfig, + ompConfig: this._ompConfig, resumeSessionId: this._resumeSessionId, envOverrides: this._envOverrides, effort: this._effort, diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 5088793d..e81ee04b 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -54,6 +54,7 @@ import { type PiConfig, type GrokConfig, type DeepSeekConfig, + type OmpConfig, type SessionRemote, type SessionDocker, type DockerCommandMode, @@ -99,6 +100,8 @@ import { resolveDeepSeekDir, getDeepSeekNotFoundMessage, resolveDefaultDeepSeekProfile, + getOmpNotFoundMessage, + resolveOmpDir, resolveLocalShell, loginShellArgs, } from './utils/index.js'; @@ -896,6 +899,29 @@ function buildDeepSeekCommand(config?: DeepSeekConfig): string { return parts.join(' '); } +/** + * Build the OMP CLI command with appropriate flags. + * + * omp reads its model routing and hooks from ~/.omp (agent dir), so no + * trust/permission flags are needed: the CLI's own config governs. The only + * CLI flags passed are the per-session overrides Codeman knows about. + */ +function buildOmpCommand(config?: OmpConfig): string { + const parts = ['omp']; + + if (config?.model) { + const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined; + if (safeModel) parts.push('--model', safeModel); + } + + if (config?.resumeSessionId) { + const safeId = /^[a-zA-Z0-9._-]+$/.test(config.resumeSessionId) ? config.resumeSessionId : undefined; + if (safeId) parts.push('--resume', safeId); + } + + return parts.join(' '); +} + /** * Build the spawn command for any session mode. * Shared by createSession() and respawnPane() to avoid duplication. @@ -941,6 +967,7 @@ export function buildSpawnCommand(options: { piConfig?: PiConfig; grokConfig?: GrokConfig; deepSeekConfig?: DeepSeekConfig; + ompConfig?: OmpConfig; resumeSessionId?: string; effort?: EffortLevel; /** Codeman session name, passed to claude as `--name` (version-gated, sanitized; local spawns only). */ @@ -996,6 +1023,9 @@ export function buildSpawnCommand(options: { if (options.mode === 'deepseek') { return buildDeepSeekCommand(options.deepSeekConfig); } + if (options.mode === 'omp') { + return buildOmpCommand(options.ompConfig); + } // #208: NOT the literal '$SHELL'. This string is embedded in the `bash -c "…"` // argument of the respawn-pane line, which execSync runs through `/bin/sh -c`, // so a `$SHELL` here is expanded by the SERVER process's shell against the @@ -1179,7 +1209,6 @@ export function buildRemoteKillCommand(options: { remote: SessionRemote; session * adopts/resizes/respawns our session (same defence as the remote socket). */ const DOCKER_TMUX_SOCKET = 'codeman-docker'; - /** * Deterministic, reattach-stable in-container tmux session name. Derived from the * same stable field the local muxName uses (first 8 chars of the sessionId), so a @@ -1214,6 +1243,7 @@ function appendResumeFlag(modeCommand: string, mode: SessionMode, resumeId: stri case 'grok': return `${modeCommand} --resume ${resumeId}`; case 'deepseek': + case 'omp': return `${modeCommand} --resume ${resumeId}`; default: return modeCommand; // shell / opencode: no resume @@ -1810,7 +1840,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { mode === 'antigravity' || mode === 'pi' || mode === 'grok' || - mode === 'deepseek' + mode === 'deepseek' || + mode === 'omp' ? 'export COLORTERM=truecolor' : 'unset COLORTERM', ...(mode === 'codex' || @@ -1818,7 +1849,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { mode === 'antigravity' || mode === 'pi' || mode === 'grok' || - mode === 'deepseek' + mode === 'deepseek' || + mode === 'omp' ? ['unset NO_COLOR'] : []), // Stamp each Codex pane with a unique originator so the response-viewer @@ -1923,6 +1955,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const dir = resolveDeepSeekDir(); return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir }; } + if (mode === 'omp') { + const dir = resolveOmpDir(); + return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir }; + } return { pathExport: '', dir: null }; } @@ -2033,6 +2069,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { piConfig, grokConfig, deepSeekConfig, + ompConfig, resumeSessionId, envOverrides, effort, @@ -2099,6 +2136,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { if (mode === 'grok' && !cliDir) { throw new Error(getGrokNotFoundMessage()); } + if (mode === 'omp' && !cliDir) { + throw new Error(getOmpNotFoundMessage()); + } const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && '); @@ -2115,6 +2155,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { piConfig, grokConfig, deepSeekConfig, + ompConfig, resumeSessionId, effort, sessionName: name, diff --git a/src/types/session.ts b/src/types/session.ts index 8e429459..5c26dc6a 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -8,7 +8,7 @@ * - SessionConfig — creation-time config (id, workingDir, createdAt) * - SessionOutput — captured stdout/stderr/exitCode * - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error' - * - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' (which CLI backend) + * - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' (which CLI backend) * - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools') * - SessionColor — visual differentiation color * - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession) @@ -55,11 +55,12 @@ export type SessionMode = | 'antigravity' | 'pi' | 'grok' - | 'deepseek'; + | 'deepseek' + | 'omp'; export type RemoteCommandMode = Extract< SessionMode, - 'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' + 'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' >; /** @@ -168,7 +169,7 @@ export interface RemoteSessionInfo { /** Which CLI backends a Docker case can run (same set as remote). */ export type DockerCommandMode = Extract< SessionMode, - 'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' + 'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' >; /** Container engine. Docker and Podman differ in the uid/userns + host-gateway alias. */ @@ -343,6 +344,14 @@ export interface AntigravityConfig { resumeConversationId?: string; } +/** OMP CLI session configuration */ +export interface OmpConfig { + /** Model identifier (e.g., "crof/glm-5.2"). Passed via --model. */ + model?: string; + /** Resume a previous conversation (passed via --resume). */ + resumeSessionId?: string; +} + /** * Pi CLI (pi.dev) session configuration. * @@ -620,6 +629,8 @@ export interface SessionState { grokConfig?: GrokConfig; /** DeepSeek Harness configuration (only for mode === 'deepseek') */ deepSeekConfig?: DeepSeekConfig; + /** OMP-specific configuration (only for mode === 'omp') */ + ompConfig?: OmpConfig; /** Claude conversation session ID to resume after reboot (set by restore script) */ resumeSessionId?: string; /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ diff --git a/src/utils/index.ts b/src/utils/index.ts index 44dab03d..7580def6 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -61,3 +61,4 @@ export { export type { DeepSeekProfile, DeepSeekProfileKind } from './deepseek-cli-resolver.js'; export { compileFileQuery, matchFileQuery } from './file-query.js'; export type { FileQueryMatcher } from './file-query.js'; +export { resolveOmpDir, isOmpAvailable, getOmpNotFoundMessage, getOmpCliVersion } from './omp-cli-resolver.js'; diff --git a/src/utils/omp-cli-resolver.ts b/src/utils/omp-cli-resolver.ts new file mode 100644 index 00000000..5da8e1b9 --- /dev/null +++ b/src/utils/omp-cli-resolver.ts @@ -0,0 +1,134 @@ +/** + * @fileoverview Resolve the OMP CLI binary across common install paths. + * + * Uses the shared `createCliExecutableResolver` (cli-executable-resolver.ts), + * same as the sibling claude/opencode/codex/gemini/antigravity/pi resolvers: + * server process PATH first, then common install directories, then — last, + * because it is the only step that spawns anything — an interactive login + * shell, which is what finds nvm/Homebrew/user-npm installs when Codeman runs + * as a systemd/launchd service with a minimal PATH. + * + * Provides an augmented PATH directory for tmux sessions. + * + * @module utils/omp-cli-resolver + */ + +import { execFileSync } from 'node:child_process'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; +import { + createCliExecutableResolver, + formatCliNotFoundMessage, + type CliResolverHost, +} from './cli-executable-resolver.js'; + +/** Common directories where the OMP CLI binary may be installed */ +const OMP_SEARCH_DIRS = [ + join(homedir(), '.omp', 'bin'), + join(homedir(), '.local', 'bin'), + '/usr/local/bin', + join(homedir(), '.bun', 'bin'), + join(homedir(), '.npm-global', 'bin'), + join(homedir(), 'bin'), +]; + +/** + * A real `omp --version` prints `omp/` (e.g. `omp/17.4.0`). + * + * Shape mirrors PI_VERSION_REGEX: a capturing group and a leading boundary so + * `omp/17.4.0` matches while an unrelated `omp` (some other program) does not. + */ +export const OMP_VERSION_REGEX = /(?:^|\s)omp\/(\d+\.\d+\.\d+)/; + +const OMP_NOT_FOUND = 'OMP CLI not found. Install with: curl -fsSL https://omp.sh/install | sh'; + +/** + * Run `omp --version` on a candidate path and return the trimmed version when + * it looks like the coding agent. Returns null for anything else — a missing + * binary, a non-zero exit, a hang (timeout), or output that is not + * `omp/`-shaped (which is how an unrelated `omp` on PATH gets rejected). + * + * Never runs under vitest: the suites must stay hermetic and must not depend on + * whether the dev box happens to have omp installed. The shared resolver host + * is already inert under vitest, so this gate is defense in depth for any + * opted-in host that still carries the default probe. + */ +function probeOmpVersion(binPath: string): string | null { + if (process.env.VITEST) return null; + try { + const out = execFileSync(binPath, ['--version'], { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + stdio: ['ignore', 'pipe', 'ignore'], + // A stuck or hostile `omp` that ignores SIGTERM would survive the timeout + // and block the server (execFileSync keeps waiting after the signal). + killSignal: 'SIGKILL', + }).trim(); + const candidate = OMP_VERSION_REGEX.exec(out)?.[1]; + if (candidate) return candidate; + console.warn(`[OmpResolver] Ignoring ${binPath}: "omp --version" printed ${JSON.stringify(out.slice(0, 80))}`); + } catch (err) { + console.warn(`[OmpResolver] Ignoring ${binPath}: "omp --version" failed (${(err as Error).message})`); + } + return null; +} + +type OmpVersionProbe = (binPath: string) => string | null; + +function createOmpResolver(host?: CliResolverHost, versionProbe: OmpVersionProbe = probeOmpVersion, now?: () => number) { + return createCliExecutableResolver( + { + binary: 'omp', + searchDirs: OMP_SEARCH_DIRS, + validateCandidate: (binPath) => { + const version = versionProbe(binPath); + return version ? { accepted: true, metadata: version } : { accepted: false }; + }, + now, + }, + host + ); +} + +/** + * Creates an isolated OMP wrapper around an injected host, version probe and + * clock. Omitting `versionProbe` keeps the ambient (VITEST-gated) probe, which + * is exactly what the hermeticity test exercises. + */ +export function createOmpResolverForTest(host: CliResolverHost, versionProbe?: OmpVersionProbe, now?: () => number) { + return createOmpResolver(host, versionProbe ?? probeOmpVersion, now); +} + +const ompResolver = createOmpResolver(); + +/** + * Finds the directory containing a verified `omp` binary. + * Checks `which omp` first, then falls back to common install locations. Every + * candidate must pass the `omp --version` sanity probe before it is accepted. + * + * @returns Directory path, or null if not found + */ +export function resolveOmpDir(): string | null { + return ompResolver.resolve()?.directory ?? null; +} + +/** + * Check if the OMP CLI is available on the system. + */ +export function isOmpAvailable(): boolean { + return resolveOmpDir() !== null; +} + +export function getOmpNotFoundMessage(): string { + return formatCliNotFoundMessage(OMP_NOT_FOUND, ompResolver.diagnostics()); +} + +/** + * Version reported by the resolved `omp` binary, or null when omp is + * unavailable. Surfaced through `GET /api/omp/status` so a misresolution is + * diagnosable from the UI. + */ +export function getOmpCliVersion(): string | null { + return ompResolver.resolve()?.metadata ?? null; +} diff --git a/src/web/public/app.js b/src/web/public/app.js index 327b732f..e101ee8c 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -2270,9 +2270,11 @@ class CodemanApp { ? 'Grok' : mode === 'deepseek' ? 'DeepSeek' - : mode === 'opencode' - ? 'OpenCode' - : 'Claude'; + : mode === 'omp' + ? 'OMP' + : mode === 'opencode' + ? 'OpenCode' + : 'Claude'; } async toggleResponseViewer() { @@ -4888,7 +4890,7 @@ class CodemanApp { - ${mode === 'shell' ? '' : mode === 'opencode' ? '' : mode === 'codex' ? '' : mode === 'gemini' ? '' : mode === 'antigravity' ? '' : mode === 'pi' ? '' : mode === 'grok' ? '' : mode === 'deepseek' ? '' : ''} + ${mode === 'shell' ? '' : mode === 'opencode' ? '' : mode === 'codex' ? '' : mode === 'gemini' ? '' : mode === 'antigravity' ? '' : mode === 'pi' ? '' : mode === 'grok' ? '' : mode === 'deepseek' ? '' : mode === 'omp' ? '' : ''} ${tabLabel} ${inlineSessionActions ? tabActionsHtml : ''} @@ -6336,7 +6338,9 @@ class CodemanApp { ? 'Kill Tmux & Grok' : session.mode === 'deepseek' ? 'Kill Tmux & DeepSeek' - : 'Kill Tmux & Claude Code'; + : session.mode === 'omp' + ? 'Kill Tmux & OMP' + : 'Kill Tmux & Claude Code'; } document.getElementById('closeConfirmModal').classList.add('active'); diff --git a/src/web/public/home-sessions.js b/src/web/public/home-sessions.js index 5a102eaa..453eec10 100644 --- a/src/web/public/home-sessions.js +++ b/src/web/public/home-sessions.js @@ -80,6 +80,7 @@ const HOME_SESSIONS_MODE_BADGE = { pi: 'pi', grok: 'gk', deepseek: 'ds', + omp: 'om', }; Object.assign(CodemanApp.prototype, { diff --git a/src/web/public/index.html b/src/web/public/index.html index a5c76bd7..96cb4f66 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -452,6 +452,10 @@ Run DeepSeek +
@@ -643,6 +647,9 @@ +
@@ -2709,6 +2717,7 @@ + Which CLI to point the Run button at once the clone finishes. Changeable any time from the Run dropdown. diff --git a/src/web/public/mobile-overview.js b/src/web/public/mobile-overview.js index c9c32bb4..34ce8963 100644 --- a/src/web/public/mobile-overview.js +++ b/src/web/public/mobile-overview.js @@ -56,6 +56,7 @@ const MOBILE_OVERVIEW_RUN_MODES = [ { mode: 'pi', label: 'Pi', short: 'Pi' }, { mode: 'grok', label: 'Grok', short: 'Grok' }, { mode: 'deepseek', label: 'DeepSeek', short: 'DeepSeek' }, + { mode: 'omp', label: 'OMP', short: 'OMP' }, { mode: 'shell', label: 'Terminal / Shell', short: 'Shell' }, ]; diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index e0ddb53d..f516ebb2 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -1003,6 +1003,20 @@ html.mobile-init .file-browser-panel { border-color: rgba(150, 170, 255, 0.55) !important; } + /* OMP mode colors on mobile. Same `!important` rationale as the pi/grok/deepseek blocks above. */ + .btn-toolbar.btn-run.mode-omp, + .btn-toolbar.btn-run-gear.mode-omp { + background: #312e81 !important; + border-color: rgba(129, 140, 248, 0.3) !important; + color: #e0e7ff !important; + } + + .btn-toolbar.btn-run.mode-omp:active, + .btn-toolbar.btn-run-gear.mode-omp:active { + background: #4f46e5 !important; + border-color: rgba(129, 140, 248, 0.5) !important; + } + /* Run mode dropdown menu — positioned above toolbar on mobile */ .run-mode-menu { bottom: 100%; @@ -3083,7 +3097,9 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-pi, .btn-toolbar.btn-run-gear.mode-pi) { background: linear-gradient(135deg, #be185d, #db2777); border-color: #9d174d; - color: #ffffff; +html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-omp, .btn-toolbar.btn-run-gear.mode-omp) { + background: linear-gradient(135deg, #4f46e5, #6366f1); + border-color: #4338ca; color: #ffffff; } html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-grok, .btn-toolbar.btn-run-gear.mode-grok) { diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index d9d96f29..f4dcc16f 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -432,7 +432,7 @@ Object.assign(CodemanApp.prototype, { _buildCommandPaletteNewSessionItem(query = '') { const mode = this.runMode || this._runMode || 'claude'; - const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi', grok: 'Grok', deepseek: 'DeepSeek' }; + const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi', grok: 'Grok', deepseek: 'DeepSeek', omp: 'OMP' }; const caseName = this._findCommandPaletteCaseMatch(query) || document.getElementById('quickStartCase')?.value || 'testcase'; return { id: 'new-session', diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 6de0507c..1372d70a 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -409,6 +409,9 @@ Object.assign(CodemanApp.prototype, { if (mode === 'deepseek') { return await this.runDeepSeek(); } + if (mode === 'omp') { + return await this.runOmp(); + } if (mode === 'shell') { return await this.runShell(); } @@ -474,7 +477,7 @@ Object.assign(CodemanApp.prototype, { * run modes like the rest, and neither `agy` nor `pi` is likely to be installed. */ _refreshRunModeAvailability(menu) { - for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']) { + for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek', 'omp']) { const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`); if (btn) btn.style.display = this.isCliAvailable(mode) ? 'flex' : 'none'; } @@ -706,11 +709,8 @@ Object.assign(CodemanApp.prototype, { if (runBtn) { runBtn.className = `btn-toolbar btn-run mode-${mode}`; } - if (gearBtn) { - gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`; - } if (label) { - label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'pi' ? 'Run PI' : mode === 'grok' ? 'Run GK' : mode === 'deepseek' ? 'Run DS' : mode === 'shell' ? 'Run SH' : 'Run'; + label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'pi' ? 'Run PI' : mode === 'grok' ? 'Run GK' : mode === 'deepseek' ? 'Run DS' : mode === 'omp' ? 'Run OMP' : mode === 'shell' ? 'Run SH' : 'Run'; } }, @@ -1383,17 +1383,24 @@ Object.assign(CodemanApp.prototype, { const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Pi session in ${caseName}...`); - this.terminal.focus(); + async runOmp() { + const caseName = document.getElementById('quickStartCase').value || 'testcase'; + // Remote/docker cases run omp on the OTHER side — skip the local status probe + // and the local-only config below (quick-start rejects them for remote cases). + const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location; + const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; + + const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting OMP session in ${caseName}...`); this.terminal.focus(); try { if (!isRemote) { const statusRes = await fetch('/api/pi/status'); - const status = (await statusRes.json()).data; + const statusRes = await fetch('/api/omp/status'); const status = (await statusRes.json()).data; if (!status.available) { this._reportSessionLaunchError( ownsLaunchTerminal, 'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent' - ); + 'OMP CLI not found. Install with: curl -fsSL https://omp.sh/install | sh' ); return; } } @@ -1411,7 +1418,15 @@ Object.assign(CodemanApp.prototype, { }); const data = await res.json(); if (!data.success) throw new Error(data.error || 'Failed to start Pi'); - await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session); + mode: 'omp', + sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`, + ...(isRemote ? {} : { + ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), + }), + }) + }); + const data = await res.json(); + if (!data.success) throw new Error(data.error || 'Failed to start OMP'); await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session); if (data.data.sessionId) { await this.selectSession(data.data.sessionId); @@ -1626,10 +1641,9 @@ Object.assign(CodemanApp.prototype, { if (detachToggle) detachToggle.checked = this.hasTabDetachOverride(sessionId); // Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only) - const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek'; + const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek' || session.mode === 'omp'; this.switchOptionsTab(isAltMode ? 'summary' : 'respawn'); - // Update respawn status display and buttons const respawnStatus = document.getElementById('sessionRespawnStatus'); const enableBtn = document.getElementById('modalEnableRespawnBtn'); const stopBtn = document.getElementById('modalStopRespawnBtn'); @@ -1656,10 +1670,9 @@ Object.assign(CodemanApp.prototype, { } // Hide Claude-specific options for external CLI sessions - const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek'; + const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek' || session.mode === 'omp'; const claudeOnlyEls = document.querySelectorAll('[data-claude-only]'); claudeOnlyEls.forEach(el => { el.style.display = isExternalCli ? 'none' : ''; }); - // Reset duration presets to default (unlimited) this.selectDurationPreset(''); @@ -3442,7 +3455,7 @@ Object.defineProperty(CodemanApp.prototype, 'runMode', { }, set(mode) { this._runMode = - mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok' || mode === 'deepseek' || mode === 'claude' + mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok' || mode === 'deepseek' || mode === 'omp' || mode === 'claude' ? mode : 'claude'; }, diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 26de7879..67be48d8 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -1230,6 +1230,7 @@ Object.assign(CodemanApp.prototype, { ['welcomeClaudeBtn', 'claude'], ['welcomeOpencodeBtn', 'opencode'], ['welcomeAntigravityBtn', 'antigravity'], + ['welcomeOmpBtn', 'omp'], ['welcomeGeminiBtn', 'gemini'], ['welcomePiBtn', 'pi'], ['welcomeGrokBtn', 'grok'], diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 6a503a42..000f13d6 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -349,7 +349,8 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat .session-tab .tab-mode.gemini, .session-tab .tab-mode.antigravity, .session-tab .tab-mode.pi, - .session-tab .tab-mode.grok + .session-tab .tab-mode.grok, + .session-tab .tab-mode.omp ) { color: var(--accent-d); } @@ -2490,6 +2491,10 @@ body.solo-mode .btn-lifecycle-log { background: rgba(34, 211, 238, 0.2); color: #22d3ee; } +.session-tab .tab-mode.omp { + background: rgba(129, 140, 248, 0.2); + color: #818cf8; +} .session-tab .tab-mode.pi { background: rgba(244, 114, 182, 0.2); @@ -3872,7 +3877,20 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { box-shadow: 0 4px 20px rgba(244, 114, 182, 0.3), 0 0 40px rgba(190, 24, 93, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.08); border-color: rgba(249, 168, 212, 0.5); color: #fff1f7; - transform: translateY(-1px); +/* OMP: indigo identity, matching .btn-toolbar.btn-run.mode-omp and + .run-mode-dot.omp so the welcome action reads as the same backend. */ +.welcome-btn-omp { + background: linear-gradient(135deg, #1e1b4b 0%, #4f46e5 55%, #6366f1 100%); + border-color: rgba(129, 140, 248, 0.4); + color: #e0e7ff; + box-shadow: 0 2px 8px rgba(129, 140, 248, 0.16), inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +.welcome-btn-omp:hover { + background: linear-gradient(135deg, #312e81 0%, #6366f1 55%, #818cf8 100%); + box-shadow: 0 4px 20px rgba(129, 140, 248, 0.3), 0 0 40px rgba(79, 70, 229, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.08); + border-color: rgba(165, 180, 252, 0.5); + color: #eef2ff; transform: translateY(-1px); } /* Grok (xAI): monochrome charcoal identity, matching .btn-toolbar.btn-run.mode-grok @@ -4982,7 +5000,20 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { box-shadow: 0 0 12px rgba(244, 114, 182, 0.35), 0 2px 8px rgba(190, 24, 93, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.08); border-color: rgba(249, 168, 212, 0.6); color: #fff1f7; +/* OMP mode colors */ +.btn-toolbar.btn-run.mode-omp, +.btn-toolbar.btn-run-gear.mode-omp { + background: linear-gradient(135deg, #312e81 0%, #4f46e5 55%, #6366f1 100%); + border-color: rgba(129, 140, 248, 0.5); + color: #e0e7ff; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.06); } +.btn-toolbar.btn-run.mode-omp:hover, +.btn-toolbar.btn-run-gear.mode-omp:hover { + background: linear-gradient(135deg, #3730a3 0%, #6366f1 55%, #818cf8 100%); + box-shadow: 0 0 12px rgba(129, 140, 248, 0.35), 0 2px 8px rgba(79, 70, 229, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.08); + border-color: rgba(165, 180, 252, 0.6); + color: #eef2ff;} /* Grok mode colors. Same cascade note as pi above: this base-sheet pair only renders on the `og` skin — the nested `html:not([data-skin="og"])` block @@ -5107,6 +5138,7 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { .run-mode-dot.pi { background: #f472b6; } .run-mode-dot.grok { background: #a1a1aa; } .run-mode-dot.deepseek { background: #4d6bfe; } +.run-mode-dot.omp { background: #818cf8; } .run-mode-dot.shell { background: #94a3b8; } /* Phone-only Enter button (see index.html). Hidden by default at every width; diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 3e7c258f..99498a70 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -866,6 +866,7 @@ export function registerSessionRoutes( body.mode !== 'pi' && body.mode !== 'grok' && body.mode !== 'deepseek' && + body.mode !== 'omp' && body.envOverrides && Object.keys(body.envOverrides).length > 0 && (workingDir.startsWith(CASES_DIR + '/') || workingDir.startsWith(managedCasesBase + '/')); @@ -965,6 +966,12 @@ export function registerSessionRoutes( return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getGrokNotFoundMessage()); } } + if (body.mode === 'omp') { + const { isOmpAvailable, getOmpNotFoundMessage } = await import('../../utils/omp-cli-resolver.js'); + if (!isOmpAvailable()) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getOmpNotFoundMessage()); + } + } // Pre-validate resumeSessionId: check that the conversation file actually exists // in Claude's projects directory. If not, skip resume to avoid confusing @@ -1012,12 +1019,14 @@ export function registerSessionRoutes( ? body.piConfig?.model : mode === 'grok' ? body.grokConfig?.model - : // DeepSeek's model is a composition entry in the profile's config - // tree, not a session flag, so there is deliberately nothing to - // read here (see docs/deepseek-integration.md). - mode !== 'shell' && mode !== 'deepseek' - ? modelConfig?.defaultModel || undefined - : undefined; + : mode === 'omp' + ? body.ompConfig?.model + : // DeepSeek's model is a composition entry in the profile's config + // tree, not a session flag, so there is deliberately nothing to + // read here (see docs/deepseek-integration.md). + mode !== 'shell' && mode !== 'deepseek' + ? modelConfig?.defaultModel || undefined + : undefined; const claudeModeConfig = await ctx.getClaudeModeConfig(); // Section 6.3: force non-granted users to a classifier-guarded mode. const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, owner); @@ -1056,6 +1065,7 @@ export function registerSessionRoutes( piConfig: mode === 'pi' ? gatedPiConfig : undefined, grokConfig: mode === 'grok' ? gatedGrokConfig : undefined, deepSeekConfig: mode === 'deepseek' ? gatedDeepSeekConfig : undefined, + ompConfig: mode === 'omp' ? body.ompConfig : undefined, resumeSessionId: validatedResumeId, envOverrides: await clampEnvOverridesForOwner(owner, body.envOverrides), effort: body.effort, @@ -1289,6 +1299,7 @@ export function registerSessionRoutes( session.mode !== 'pi' && session.mode !== 'grok' && session.mode !== 'deepseek' && + session.mode !== 'omp' && ctx.store.getConfig().ralphEnabled && !session.ralphTracker.autoEnableDisabled ) { @@ -2857,6 +2868,7 @@ export function registerSessionRoutes( piConfig, grokConfig, deepSeekConfig, + ompConfig, envOverrides, effort, parentSessionId, @@ -2907,6 +2919,7 @@ export function registerSessionRoutes( piConfig || grokConfig || deepSeekConfig || + ompConfig || openCodeConfig ) { return createErrorResponse( @@ -2941,6 +2954,7 @@ export function registerSessionRoutes( piConfig || grokConfig || deepSeekConfig || + ompConfig || openCodeConfig ) { return createErrorResponse( @@ -3042,6 +3056,16 @@ export function registerSessionRoutes( return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getPiNotFoundMessage()); } } + // Check OMP availability if requested + if (mode === 'omp') { + const { isOmpAvailable } = await import('../../utils/omp-cli-resolver.js'); + if (!isOmpAvailable()) { + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + 'OMP CLI not found. Install with: curl -fsSL https://omp.sh/install | sh' + ); + } + } // Check Grok availability if requested if (mode === 'grok') { @@ -3103,14 +3127,15 @@ export function registerSessionRoutes( writeFileSync(join(resolvedCasePath, 'CLAUDE.md'), claudeMd); // Write .claude/settings.local.json with hooks for desktop notifications - // (Claude-specific — OpenCode, Codex, Gemini, Antigravity, Pi and Grok use their own systems) + // (Claude-specific — OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP use their own systems) if ( mode !== 'opencode' && mode !== 'codex' && mode !== 'gemini' && mode !== 'antigravity' && mode !== 'pi' && - mode !== 'grok' + mode !== 'grok' && + mode !== 'omp' ) { await writeHooksConfig(resolvedCasePath); } @@ -3150,6 +3175,7 @@ export function registerSessionRoutes( // rule the existing-case branch above states; this branch used to exclude just // the five external CLIs and let `shell` through). if (docker && docker.hooksEnabled && mode === 'claude') { + // configured project. Skipped for external CLIs (they use their own systems). try { if (!existsSync(join(resolvedCasePath, 'CLAUDE.md'))) { const templatePath = await ctx.getDefaultClaudeMdPath(); @@ -3185,6 +3211,7 @@ export function registerSessionRoutes( mode !== 'pi' && mode !== 'grok' && mode !== 'deepseek' && + mode !== 'omp' && !remote && envOverrides && Object.keys(envOverrides).length > 0 @@ -3209,10 +3236,12 @@ export function registerSessionRoutes( ? piConfig?.model : mode === 'grok' ? grokConfig?.model - : // DeepSeek's model lives in the profile's config tree, not here. - mode !== 'shell' && mode !== 'deepseek' - ? qsModelConfig?.defaultModel || undefined - : undefined; + : mode === 'omp' + ? ompConfig?.model + : // DeepSeek's model lives in the profile's config tree, not here. + mode !== 'shell' && mode !== 'deepseek' + ? qsModelConfig?.defaultModel || undefined + : undefined; const qsClaudeModeConfig = await ctx.getClaudeModeConfig(); const qsEffectiveClaudeMode = await resolveClaudeModeForUsername(qsClaudeModeConfig.claudeMode, owner); // Section 6.3: clamp Codex/Gemini/Antigravity bypass switches for a non-granted owner (no-op single-user/granted). @@ -3252,6 +3281,7 @@ export function registerSessionRoutes( piConfig: mode === 'pi' ? qsGatedPiConfig : undefined, grokConfig: mode === 'grok' ? qsGatedGrokConfig : undefined, deepSeekConfig: mode === 'deepseek' ? qsGatedDeepSeekConfig : undefined, + ompConfig: mode === 'omp' ? ompConfig : undefined, envOverrides: qsGatedEnvOverrides, effort, remote, diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index 4929e977..c3e955a5 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -694,6 +694,17 @@ export function registerSystemRoutes( }; }); + // ========== OMP ========== + + app.get('/api/omp/status', async () => { + const { isOmpAvailable, resolveOmpDir, getOmpCliVersion } = await import('../../utils/omp-cli-resolver.js'); + return { + available: isOmpAvailable(), + path: resolveOmpDir(), + version: getOmpCliVersion(), + }; + }); + // ═══════════════════════════════════════════════════════════════ // State & Lifecycle (cleanup, lifecycle log, stats) // ═══════════════════════════════════════════════════════════════ diff --git a/src/web/schemas.ts b/src/web/schemas.ts index e49889fb..e7401783 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -141,6 +141,7 @@ const ALLOWED_ENV_PREFIXES = [ // 34-provider-key problem in a new shape, and the answer is the same one. 'DSH_', 'DEEPSEEK_', + 'OMP_', ]; /** @@ -180,7 +181,7 @@ const safeEnvOverridesSchema = z }, { message: - 'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, ANTIGRAVITY_*, PI_*, GROK_*, XAI_*, DSH_*, DEEPSEEK_* keys and CLAUDE_CONFIG_DIR are allowed.', + 'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, ANTIGRAVITY_*, PI_*, GROK_*, XAI_*, DSH_*, DEEPSEEK_*, OMP_* keys and CLAUDE_CONFIG_DIR are allowed.', } ); @@ -345,6 +346,24 @@ const GrokConfigSchema = z }) .optional(); +/** + * Schema for OMP CLI-specific configuration. + */ +const OmpConfigSchema = z + .object({ + model: z + .string() + .max(100) + .regex(/^[a-zA-Z0-9._\-/]+$/) + .optional(), + resumeSessionId: z + .string() + .max(100) + .regex(/^[a-zA-Z0-9._-]+$/) + .optional(), + }) + .optional(); + /** * Schema for DeepSeek Harness (`dsh`)-specific configuration. * @@ -440,7 +459,9 @@ const parentSessionIdSchema = z.string().max(100).optional(); export const CreateSessionSchema = z.object({ workingDir: safePathSchema.optional(), - mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']).optional(), + mode: z + .enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek', 'omp']) + .optional(), name: z.string().max(100).optional(), /** Session that spawned this one — see parentSessionIdSchema. */ parentSessionId: parentSessionIdSchema, @@ -458,6 +479,7 @@ export const CreateSessionSchema = z.object({ piConfig: PiConfigSchema, grokConfig: GrokConfigSchema, deepSeekConfig: DeepSeekConfigSchema, + ompConfig: OmpConfigSchema, /** Resume a previous Claude conversation by its session ID (used for reboot recovery) */ resumeSessionId: z .string() @@ -869,7 +891,9 @@ export const QuickStartSchema = z.object({ * a real host dir, so the settings file crosses the bind mount); rejected for * remote cases (the file would be written on the WRONG machine). */ modelOverride: z.string().max(50).optional(), - mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']).optional(), + mode: z + .enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek', 'omp']) + .optional(), openCodeConfig: OpenCodeConfigSchema, codexConfig: CodexConfigSchema, geminiConfig: GeminiConfigSchema, @@ -877,6 +901,7 @@ export const QuickStartSchema = z.object({ piConfig: PiConfigSchema, grokConfig: GrokConfigSchema, deepSeekConfig: DeepSeekConfigSchema, + ompConfig: OmpConfigSchema, envOverrides: safeEnvOverridesSchema, /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ effort: effortLevelSchema, @@ -1410,7 +1435,7 @@ const noNewlines = (v: string) => !/[\r\n]/.test(v); /** Shared field shape for creating/updating a scheduled job. */ const CronJobBaseSchema = z.object({ name: z.string().min(1).max(200), - agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']), + agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek', 'omp']), workingDir: safePathSchema, launchCommand: z.string().max(2000).refine(noNewlines, 'launchCommand must be a single line').optional(), promptMode: z.enum(['inline_text', 'prompt_file_path']), diff --git a/src/web/server.ts b/src/web/server.ts index 6227f60b..5e85562b 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1441,6 +1441,7 @@ export class WebServer extends EventEmitter { { isPiAvailable }, { isGrokAvailable }, { isDeepSeekRunnable, isDeepSeekAvailable }, + { isOmpAvailable }, { isCloudflaredAvailable }, { isGitAvailable }, ] = await Promise.all([ @@ -1452,6 +1453,7 @@ export class WebServer extends EventEmitter { import('../utils/pi-cli-resolver.js'), import('../utils/grok-cli-resolver.js'), import('../utils/deepseek-cli-resolver.js'), + import('../utils/omp-cli-resolver.js'), import('../utils/cloudflared-resolver.js'), import('../git-clone.js'), ]); @@ -1470,6 +1472,7 @@ export class WebServer extends EventEmitter { // profile is offered the fix rather than a greyed-out entry. deepseek: isDeepSeekRunnable(), deepseekBinary: isDeepSeekAvailable(), + omp: isOmpAvailable(), cloudflared: isCloudflaredAvailable(), // Not a run mode: the Add Case → Clone tab is an offer this box cannot // keep without git (issue #236), same reasoning as cloudflared above. diff --git a/test/mobile-overview.test.ts b/test/mobile-overview.test.ts index 1bfc8eb8..e389908c 100644 --- a/test/mobile-overview.test.ts +++ b/test/mobile-overview.test.ts @@ -433,6 +433,7 @@ describe('mobile overview run picker (CLI availability gating)', () => { 'pi', 'grok', 'deepseek', + 'omp', 'shell', ]); }); @@ -447,7 +448,7 @@ describe('mobile overview run picker (CLI availability gating)', () => { src.indexOf('];', src.indexOf('const MOBILE_OVERVIEW_RUN_MODES')) + 2 ); const offered = [...modesBlock.matchAll(/mode: '([^']+)'/g)].map((m) => m[1]); - expect(offered).toContain('antigravity'); + expect(offered).toContain('omp'); const fn = src.slice(src.indexOf('_buildMobileOverviewRunMenu() {')); const gate = fn.slice(0, fn.indexOf('const header')); expect(gate).toContain('isCliAvailable'); diff --git a/test/omp-mode.test.ts b/test/omp-mode.test.ts new file mode 100644 index 00000000..c7d3ff39 --- /dev/null +++ b/test/omp-mode.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest'; +import { CreateSessionSchema, QuickStartSchema } from '../src/web/schemas.js'; +import { buildSpawnCommand } from '../src/tmux-manager.js'; +import { defaultDockerCommandForMode } from '../src/docker-hosts.js'; +import { defaultRemoteCommandForMode } from '../src/remote-hosts.js'; +import { isExternalCliMode, isAltScreenStripMode } from '../src/session.js'; + +describe('OMP mode schemas', () => { + it('accepts OMP session creation config', () => { + const parsed = CreateSessionSchema.parse({ + workingDir: '/tmp', + mode: 'omp', + ompConfig: { + model: 'crof/glm-5.2', + }, + }); + + expect(parsed.mode).toBe('omp'); + expect(parsed.ompConfig).toEqual({ + model: 'crof/glm-5.2', + }); + }); + + it('accepts OMP quick-start config', () => { + const parsed = QuickStartSchema.parse({ + caseName: 'omp-case', + mode: 'omp', + ompConfig: { + resumeSessionId: 'session-1234abcd', + }, + }); + + expect(parsed.mode).toBe('omp'); + expect(parsed.ompConfig?.resumeSessionId).toBe('session-1234abcd'); + }); + + it('rejects unsafe OMP model strings', () => { + expect(() => + CreateSessionSchema.parse({ + workingDir: '/tmp', + mode: 'omp', + ompConfig: { model: 'omp; rm -rf /' }, + }) + ).toThrow(); + }); + + it('allows OMP_* env overrides and still rejects unknown prefixes', () => { + const parsed = CreateSessionSchema.parse({ + workingDir: '/tmp', + mode: 'omp', + envOverrides: { OMP_PROFILE: 'work' }, + }); + expect(parsed.envOverrides).toEqual({ OMP_PROFILE: 'work' }); + + expect(() => + CreateSessionSchema.parse({ + workingDir: '/tmp', + envOverrides: { RANDOM_PREFIX_KEY: 'x' }, + }) + ).toThrow(); + }); +}); + +describe('OMP spawn command', () => { + it('builds a bare omp command when no config is sent', () => { + const cmd = buildSpawnCommand({ mode: 'omp', sessionId: 'abc12345' }); + expect(cmd).toBe('omp'); + }); + + it('passes --model and --resume, and drops unsafe ids', () => { + expect( + buildSpawnCommand({ + mode: 'omp', + sessionId: 'abc12345', + ompConfig: { model: 'crof/glm-5.2', resumeSessionId: 'session-99' }, + }) + ).toBe('omp --model crof/glm-5.2 --resume session-99'); + + expect( + buildSpawnCommand({ + mode: 'omp', + sessionId: 'abc12345', + ompConfig: { resumeSessionId: 'x; rm -rf /' }, + }) + ).toBe('omp'); + }); + + it('drops unsafe model strings from the spawn command', () => { + expect( + buildSpawnCommand({ + mode: 'omp', + sessionId: 'abc12345', + ompConfig: { model: 'a`b' }, + }) + ).toBe('omp'); + }); +}); + +describe('OMP mode gates', () => { + it('is an external CLI mode (readiness/ralph/respawn gating)', () => { + expect(isExternalCliMode('omp')).toBe(true); + }); + + it('is NOT an alt-screen strip mode (unverified TUI, like opencode/antigravity)', () => { + expect(isAltScreenStripMode('omp')).toBe(false); + }); + + it('has docker/remote default commands', () => { + expect(defaultDockerCommandForMode('omp')).toBe('exec omp'); + // Routed through an interactive login shell so per-user PATH entries resolve — + // same fix as the other remote agent CLIs (see defaultRemoteCommandForMode). + expect(defaultRemoteCommandForMode('omp')).toBe('exec "${SHELL:-/bin/sh}" -i -l -c \'omp\''); + }); +}); diff --git a/test/run-mode-ui.test.ts b/test/run-mode-ui.test.ts index 50103ed5..731cfdb2 100644 --- a/test/run-mode-ui.test.ts +++ b/test/run-mode-ui.test.ts @@ -81,6 +81,16 @@ describe('run mode UI', () => { expect(app.runMode).toBe('antigravity'); expect(runBtnLabel.textContent).toBe('Run AG'); }); + + it('accepts OMP mode from server sync and updates the run button label', async () => { + const { app, storage, runBtnLabel } = loadRunModeHarness(); + + storage.set('codeman_runMode', 'claude'); + await app.loadAppSettingsFromServer(Promise.resolve({ runMode: 'omp' })); + + expect(app.runMode).toBe('omp'); + expect(runBtnLabel.textContent).toBe('Run OMP'); + }); }); describe('Run launch synchronization', () => { @@ -367,12 +377,13 @@ describe('Codex quick start settings', () => { 'welcomeGeminiBtn', 'welcomePiBtn', 'welcomeGrokBtn', + 'welcomeOmpBtn', 'welcomeTunnelBtn', ]) { welcomeBtns[id] = { style: { display: 'PRISTINE' } }; } const modeBtns: Record = {}; - for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'shell']) { + for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'omp', 'shell']) { modeBtns[mode] = { style: { display: 'PRISTINE' } }; } const menu = { @@ -405,6 +416,7 @@ describe('Codex quick start settings', () => { antigravity: false, pi: false, grok: false, + omp: false, cloudflared: false, }; @@ -442,16 +454,23 @@ describe('Codex quick start settings', () => { withAgy.app.applyWelcomeCliVisibility(); expect(withAgy.welcomeBtns.welcomeAntigravityBtn.style.display).toBe('flex'); expect(withAgy.welcomeBtns.welcomeClaudeBtn.style.display).toBe('none'); + + // OMP is a first-class welcome action, gated on `omp` like the rest. + const withOmp = loadUi({ ...ALL_OFF, omp: true }); + withOmp.app.applyWelcomeCliVisibility(); + expect(withOmp.welcomeBtns.welcomeOmpBtn.style.display).toBe('flex'); + expect(withOmp.welcomeBtns.welcomeClaudeBtn.style.display).toBe('none'); }); it('gates every run mode in the dropdown, antigravity included, and never shell', () => { - const { app, modeBtns, menu } = loadUi({ ...ALL_OFF, claude: true, antigravity: true }); + const { app, modeBtns, menu } = loadUi({ ...ALL_OFF, claude: true, antigravity: true, omp: true }); app._refreshRunModeAvailability(menu); expect(modeBtns.claude.style.display).toBe('flex'); expect(modeBtns.antigravity.style.display).toBe('flex'); expect(modeBtns.opencode.style.display).toBe('none'); expect(modeBtns.codex.style.display).toBe('none'); expect(modeBtns.gemini.style.display).toBe('none'); + expect(modeBtns.omp.style.display).toBe('flex'); // Shell needs no external CLI, and leaving it alone is what guarantees the // menu is never empty on a box with nothing installed. expect(modeBtns.shell.style.display).toBe('PRISTINE'); @@ -468,6 +487,7 @@ describe('Codex quick start settings', () => { expect(offered).toContain('antigravity'); expect(offered).toContain('pi'); expect(offered).toContain('grok'); + expect(offered).toContain('omp'); const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8'); // Anchor on the DEFINITION, not the earlier call site in toggleRunModeMenu. const fn = src.slice(src.indexOf('_refreshRunModeAvailability(menu) {'));