feat(omp): rebase OMP backend onto master (merge Pi + OMP modes)

This commit is contained in:
timkjr
2026-08-26 20:05:48 -05:00
parent 7dfb4acf24
commit 4f5678fac4
29 changed files with 579 additions and 64 deletions
+15
View File
@@ -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
+3
View File
@@ -2,6 +2,9 @@
.agents/ .agents/
skills-lock.json 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 # Written by install.sh into end-user clones when setup finishes
.install-complete .install-complete
+3 -4
View File
File diff suppressed because one or more lines are too long
+10 -10
View File
@@ -5,7 +5,7 @@
<h2 align="center">Mission control for AI coding agents</h2> <h2 align="center">Mission control for AI coding agents</h2>
<p align="center"> <p align="center">
<em>Claude Code &bull; OpenCode &bull; Codex &bull; Antigravity &bull; Gemini &bull; Pi &bull; Grok &bull; Terminal - One Dashboard &bull; Any Device</em> <em>Claude Code &bull; OpenCode &bull; Codex &bull; Antigravity &bull; Gemini &bull; Pi &bull; Grok &bull; OMP &bull; Terminal - One Dashboard &bull; Any Device</em>
</p> </p>
<p align="center"> <p align="center">
@@ -27,7 +27,7 @@
<img src="docs/images/subagent-demo-20260724.gif" alt="Codeman — parallel subagent visualization" width="900"> <img src="docs/images/subagent-demo-20260724.gif" alt="Codeman — parallel subagent visualization" width="900">
</p> </p>
**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): 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). 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 - **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 - **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 - **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. - **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. - **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 ```bash
codeman web 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" 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.
</details> </details>
@@ -253,7 +253,7 @@ Click **+ New Session** (or **Quick Start**). A session is one AI CLI running in
| Field | What it does | | 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**). | | **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. | | **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`. | | **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 - **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) - **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/<name>` 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 - **Clone a GitHub repo as a case** — paste a repository URL into **Add Case → Clone Repo** and Codeman clones it into `~/codeman-cases/<name>` 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) - **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) - **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 - **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. - **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. - **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. - **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. - **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). 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/*`. 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). 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. 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 ### Recipes
@@ -1011,7 +1011,7 @@ flowchart TB
subgraph External["External"] subgraph External["External"]
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Antigravity / Gemini / Pi</small>"] CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Antigravity / Gemini / Pi</small>"]
BG["Background Agents<br/><small>(Task tool)</small>"] CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Antigravity / Gemini / OMP</small>"] BG["Background Agents<br/><small>(Task tool)</small>"]
end end
end end
+9
View File
@@ -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', id: 'libreoffice',
label: 'LibreOffice', label: 'LibreOffice',
+1
View File
@@ -147,6 +147,7 @@ export function defaultDockerCommandForMode(mode: SessionMode): string {
pi: 'exec pi', pi: 'exec pi',
grok: 'exec grok', grok: 'exec grok',
deepseek: 'exec dsh', deepseek: 'exec dsh',
omp: 'exec omp',
}; };
return commands[mode as DockerCommandMode] || commands.shell; return commands[mode as DockerCommandMode] || commands.shell;
} }
+3
View File
@@ -21,6 +21,7 @@ import type {
PiConfig, PiConfig,
GrokConfig, GrokConfig,
DeepSeekConfig, DeepSeekConfig,
OmpConfig,
SessionRemote, SessionRemote,
SessionDocker, SessionDocker,
} from './types.js'; } from './types.js';
@@ -82,6 +83,7 @@ export interface CreateSessionOptions {
piConfig?: PiConfig; piConfig?: PiConfig;
grokConfig?: GrokConfig; grokConfig?: GrokConfig;
deepSeekConfig?: DeepSeekConfig; deepSeekConfig?: DeepSeekConfig;
ompConfig?: OmpConfig;
/** When restoring after reboot, resume a previous Claude conversation by its session ID */ /** When restoring after reboot, resume a previous Claude conversation by its session ID */
resumeSessionId?: string; resumeSessionId?: string;
/** Extra env vars exported before launching the CLI (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Ephemeral — not written to disk. */ /** 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; piConfig?: PiConfig;
grokConfig?: GrokConfig; grokConfig?: GrokConfig;
deepSeekConfig?: DeepSeekConfig; deepSeekConfig?: DeepSeekConfig;
ompConfig?: OmpConfig;
/** Resume a previous Claude conversation when respawning */ /** Resume a previous Claude conversation when respawning */
resumeSessionId?: string; resumeSessionId?: string;
/** Extra env vars exported before launching the CLI (preserved across respawns). */ /** Extra env vars exported before launching the CLI (preserved across respawns). */
+2
View File
@@ -119,6 +119,7 @@ export function defaultRemoteCommandForMode(mode: SessionMode): string {
// profile inventory is unknown here. The per-host `commands.deepseek` override // profile inventory is unknown here. The per-host `commands.deepseek` override
// is the escape hatch for naming one. // is the escape hatch for naming one.
deepseek: remoteLoginShellCommand('dsh'), deepseek: remoteLoginShellCommand('dsh'),
omp: remoteLoginShellCommand('omp'),
}; };
return commands[mode as RemoteCommandMode] || commands.shell; return commands[mode as RemoteCommandMode] || commands.shell;
} }
@@ -274,6 +275,7 @@ const REMOTE_CLI_BIN: Partial<Record<SessionMode, string>> = {
gemini: 'gemini', gemini: 'gemini',
antigravity: 'agy', antigravity: 'agy',
pi: 'pi', pi: 'pi',
omp: 'omp',
}; };
/** /**
+16 -1
View File
@@ -53,6 +53,7 @@ import {
type PiConfig, type PiConfig,
type GrokConfig, type GrokConfig,
type DeepSeekConfig, type DeepSeekConfig,
type OmpConfig,
type SessionRemote, type SessionRemote,
type SessionDocker, type SessionDocker,
} from './types.js'; } from './types.js';
@@ -180,7 +181,8 @@ export function isExternalCliMode(mode: SessionMode): boolean {
mode === 'antigravity' || mode === 'antigravity' ||
mode === 'pi' || mode === 'pi' ||
mode === 'grok' || mode === 'grok' ||
mode === 'deepseek' mode === 'deepseek' ||
mode === 'omp'
); );
} }
@@ -200,6 +202,8 @@ function getModeLabel(mode: SessionMode): string {
return 'Grok'; return 'Grok';
case 'deepseek': case 'deepseek':
return 'DeepSeek'; return 'DeepSeek';
case 'omp':
return 'OMP';
case 'shell': case 'shell':
return 'Shell'; return 'Shell';
case 'claude': case 'claude':
@@ -528,6 +532,8 @@ export class Session extends EventEmitter {
// DeepSeek Harness configuration (only for mode === 'deepseek') // DeepSeek Harness configuration (only for mode === 'deepseek')
private _deepSeekConfig: DeepSeekConfig | undefined; private _deepSeekConfig: DeepSeekConfig | undefined;
// OMP configuration (only for mode === 'omp')
private _ompConfig: OmpConfig | undefined;
private _resumeSessionId: string | undefined; private _resumeSessionId: string | undefined;
// Ephemeral env overrides (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Exported by tmux // Ephemeral env overrides (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Exported by tmux
@@ -627,6 +633,8 @@ export class Session extends EventEmitter {
grokConfig?: GrokConfig; grokConfig?: GrokConfig;
/** DeepSeek Harness configuration (only for mode === 'deepseek') */ /** DeepSeek Harness configuration (only for mode === 'deepseek') */
deepSeekConfig?: DeepSeekConfig; deepSeekConfig?: DeepSeekConfig;
/** OMP configuration (only for mode === 'omp') */
ompConfig?: OmpConfig;
/** Resume a previous Claude conversation (used after server reboot) */ /** Resume a previous Claude conversation (used after server reboot) */
resumeSessionId?: string; resumeSessionId?: string;
/** Extra env vars exported to the CLI at spawn time (no disk persistence) */ /** 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) { if (config.piConfig) {
this._piConfig = config.piConfig; this._piConfig = config.piConfig;
} }
// Apply OMP configuration
if (config.ompConfig) {
this._ompConfig = config.ompConfig;
}
// Apply DeepSeek Harness configuration // Apply DeepSeek Harness configuration
if (config.deepSeekConfig) { if (config.deepSeekConfig) {
@@ -1368,6 +1380,7 @@ export class Session extends EventEmitter {
piConfig: this._piConfig, piConfig: this._piConfig,
grokConfig: this._grokConfig, grokConfig: this._grokConfig,
deepSeekConfig: this._deepSeekConfig, deepSeekConfig: this._deepSeekConfig,
ompConfig: this._ompConfig,
resumeSessionId: this._resumeSessionId, resumeSessionId: this._resumeSessionId,
effort: this._effort, effort: this._effort,
// COD-118: runtime-only — surfaced so the frontend can require explicit user // COD-118: runtime-only — surfaced so the frontend can require explicit user
@@ -1617,6 +1630,7 @@ export class Session extends EventEmitter {
piConfig: this._piConfig, piConfig: this._piConfig,
grokConfig: this._grokConfig, grokConfig: this._grokConfig,
deepSeekConfig: this._deepSeekConfig, deepSeekConfig: this._deepSeekConfig,
ompConfig: this._ompConfig,
resumeSessionId: this._resumeSessionId, resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides, envOverrides: this._envOverrides,
effort: this._effort, effort: this._effort,
@@ -1877,6 +1891,7 @@ export class Session extends EventEmitter {
piConfig: this._piConfig, piConfig: this._piConfig,
grokConfig: this._grokConfig, grokConfig: this._grokConfig,
deepSeekConfig: this._deepSeekConfig, deepSeekConfig: this._deepSeekConfig,
ompConfig: this._ompConfig,
resumeSessionId: this._resumeSessionId, resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides, envOverrides: this._envOverrides,
effort: this._effort, effort: this._effort,
+44 -3
View File
@@ -54,6 +54,7 @@ import {
type PiConfig, type PiConfig,
type GrokConfig, type GrokConfig,
type DeepSeekConfig, type DeepSeekConfig,
type OmpConfig,
type SessionRemote, type SessionRemote,
type SessionDocker, type SessionDocker,
type DockerCommandMode, type DockerCommandMode,
@@ -99,6 +100,8 @@ import {
resolveDeepSeekDir, resolveDeepSeekDir,
getDeepSeekNotFoundMessage, getDeepSeekNotFoundMessage,
resolveDefaultDeepSeekProfile, resolveDefaultDeepSeekProfile,
getOmpNotFoundMessage,
resolveOmpDir,
resolveLocalShell, resolveLocalShell,
loginShellArgs, loginShellArgs,
} from './utils/index.js'; } from './utils/index.js';
@@ -896,6 +899,29 @@ function buildDeepSeekCommand(config?: DeepSeekConfig): string {
return parts.join(' '); 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. * Build the spawn command for any session mode.
* Shared by createSession() and respawnPane() to avoid duplication. * Shared by createSession() and respawnPane() to avoid duplication.
@@ -941,6 +967,7 @@ export function buildSpawnCommand(options: {
piConfig?: PiConfig; piConfig?: PiConfig;
grokConfig?: GrokConfig; grokConfig?: GrokConfig;
deepSeekConfig?: DeepSeekConfig; deepSeekConfig?: DeepSeekConfig;
ompConfig?: OmpConfig;
resumeSessionId?: string; resumeSessionId?: string;
effort?: EffortLevel; effort?: EffortLevel;
/** Codeman session name, passed to claude as `--name` (version-gated, sanitized; local spawns only). */ /** 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') { if (options.mode === 'deepseek') {
return buildDeepSeekCommand(options.deepSeekConfig); 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 "…"` // #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`, // 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 // 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). * adopts/resizes/respawns our session (same defence as the remote socket).
*/ */
const DOCKER_TMUX_SOCKET = 'codeman-docker'; const DOCKER_TMUX_SOCKET = 'codeman-docker';
/** /**
* Deterministic, reattach-stable in-container tmux session name. Derived from the * 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 * 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': case 'grok':
return `${modeCommand} --resume ${resumeId}`; return `${modeCommand} --resume ${resumeId}`;
case 'deepseek': case 'deepseek':
case 'omp':
return `${modeCommand} --resume ${resumeId}`; return `${modeCommand} --resume ${resumeId}`;
default: default:
return modeCommand; // shell / opencode: no resume return modeCommand; // shell / opencode: no resume
@@ -1810,7 +1840,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
mode === 'antigravity' || mode === 'antigravity' ||
mode === 'pi' || mode === 'pi' ||
mode === 'grok' || mode === 'grok' ||
mode === 'deepseek' mode === 'deepseek' ||
mode === 'omp'
? 'export COLORTERM=truecolor' ? 'export COLORTERM=truecolor'
: 'unset COLORTERM', : 'unset COLORTERM',
...(mode === 'codex' || ...(mode === 'codex' ||
@@ -1818,7 +1849,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
mode === 'antigravity' || mode === 'antigravity' ||
mode === 'pi' || mode === 'pi' ||
mode === 'grok' || mode === 'grok' ||
mode === 'deepseek' mode === 'deepseek' ||
mode === 'omp'
? ['unset NO_COLOR'] ? ['unset NO_COLOR']
: []), : []),
// Stamp each Codex pane with a unique originator so the response-viewer // 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(); const dir = resolveDeepSeekDir();
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir }; 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 }; return { pathExport: '', dir: null };
} }
@@ -2033,6 +2069,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
piConfig, piConfig,
grokConfig, grokConfig,
deepSeekConfig, deepSeekConfig,
ompConfig,
resumeSessionId, resumeSessionId,
envOverrides, envOverrides,
effort, effort,
@@ -2099,6 +2136,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (mode === 'grok' && !cliDir) { if (mode === 'grok' && !cliDir) {
throw new Error(getGrokNotFoundMessage()); throw new Error(getGrokNotFoundMessage());
} }
if (mode === 'omp' && !cliDir) {
throw new Error(getOmpNotFoundMessage());
}
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && '); const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
@@ -2115,6 +2155,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
piConfig, piConfig,
grokConfig, grokConfig,
deepSeekConfig, deepSeekConfig,
ompConfig,
resumeSessionId, resumeSessionId,
effort, effort,
sessionName: name, sessionName: name,
+15 -4
View File
@@ -8,7 +8,7 @@
* - SessionConfig — creation-time config (id, workingDir, createdAt) * - SessionConfig — creation-time config (id, workingDir, createdAt)
* - SessionOutput — captured stdout/stderr/exitCode * - SessionOutput — captured stdout/stderr/exitCode
* - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error' * - 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') * - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools')
* - SessionColor — visual differentiation color * - SessionColor — visual differentiation color
* - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession) * - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession)
@@ -55,11 +55,12 @@ export type SessionMode =
| 'antigravity' | 'antigravity'
| 'pi' | 'pi'
| 'grok' | 'grok'
| 'deepseek'; | 'deepseek'
| 'omp';
export type RemoteCommandMode = Extract< export type RemoteCommandMode = Extract<
SessionMode, 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). */ /** Which CLI backends a Docker case can run (same set as remote). */
export type DockerCommandMode = Extract< export type DockerCommandMode = Extract<
SessionMode, 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. */ /** Container engine. Docker and Podman differ in the uid/userns + host-gateway alias. */
@@ -343,6 +344,14 @@ export interface AntigravityConfig {
resumeConversationId?: string; 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. * Pi CLI (pi.dev) session configuration.
* *
@@ -620,6 +629,8 @@ export interface SessionState {
grokConfig?: GrokConfig; grokConfig?: GrokConfig;
/** DeepSeek Harness configuration (only for mode === 'deepseek') */ /** DeepSeek Harness configuration (only for mode === 'deepseek') */
deepSeekConfig?: DeepSeekConfig; deepSeekConfig?: DeepSeekConfig;
/** OMP-specific configuration (only for mode === 'omp') */
ompConfig?: OmpConfig;
/** Claude conversation session ID to resume after reboot (set by restore script) */ /** Claude conversation session ID to resume after reboot (set by restore script) */
resumeSessionId?: string; resumeSessionId?: string;
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
+1
View File
@@ -61,3 +61,4 @@ export {
export type { DeepSeekProfile, DeepSeekProfileKind } from './deepseek-cli-resolver.js'; export type { DeepSeekProfile, DeepSeekProfileKind } from './deepseek-cli-resolver.js';
export { compileFileQuery, matchFileQuery } from './file-query.js'; export { compileFileQuery, matchFileQuery } from './file-query.js';
export type { FileQueryMatcher } from './file-query.js'; export type { FileQueryMatcher } from './file-query.js';
export { resolveOmpDir, isOmpAvailable, getOmpNotFoundMessage, getOmpCliVersion } from './omp-cli-resolver.js';
+134
View File
@@ -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/<semver>` (e.g. `omp/17.4.0`).
*
* Shape mirrors PI_VERSION_REGEX: a capturing group and a leading boundary so
* `omp/17.4.0` matches while an unrelated `omp` (some other program) does not.
*/
export const OMP_VERSION_REGEX = /(?:^|\s)omp\/(\d+\.\d+\.\d+)/;
const OMP_NOT_FOUND = 'OMP CLI not found. Install with: curl -fsSL https://omp.sh/install | sh';
/**
* Run `omp --version` on a candidate path and return the trimmed version when
* it looks like the coding agent. Returns null for anything else — a missing
* binary, a non-zero exit, a hang (timeout), or output that is not
* `omp/<semver>`-shaped (which is how an unrelated `omp` on PATH gets rejected).
*
* Never runs under vitest: the suites must stay hermetic and must not depend on
* whether the dev box happens to have omp installed. The shared resolver host
* is already inert under vitest, so this gate is defense in depth for any
* opted-in host that still carries the default probe.
*/
function probeOmpVersion(binPath: string): string | null {
if (process.env.VITEST) return null;
try {
const out = execFileSync(binPath, ['--version'], {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
stdio: ['ignore', 'pipe', 'ignore'],
// A stuck or hostile `omp` that ignores SIGTERM would survive the timeout
// and block the server (execFileSync keeps waiting after the signal).
killSignal: 'SIGKILL',
}).trim();
const candidate = OMP_VERSION_REGEX.exec(out)?.[1];
if (candidate) return candidate;
console.warn(`[OmpResolver] Ignoring ${binPath}: "omp --version" printed ${JSON.stringify(out.slice(0, 80))}`);
} catch (err) {
console.warn(`[OmpResolver] Ignoring ${binPath}: "omp --version" failed (${(err as Error).message})`);
}
return null;
}
type OmpVersionProbe = (binPath: string) => string | null;
function createOmpResolver(host?: CliResolverHost, versionProbe: OmpVersionProbe = probeOmpVersion, now?: () => number) {
return createCliExecutableResolver<string>(
{
binary: 'omp',
searchDirs: OMP_SEARCH_DIRS,
validateCandidate: (binPath) => {
const version = versionProbe(binPath);
return version ? { accepted: true, metadata: version } : { accepted: false };
},
now,
},
host
);
}
/**
* Creates an isolated OMP wrapper around an injected host, version probe and
* clock. Omitting `versionProbe` keeps the ambient (VITEST-gated) probe, which
* is exactly what the hermeticity test exercises.
*/
export function createOmpResolverForTest(host: CliResolverHost, versionProbe?: OmpVersionProbe, now?: () => number) {
return createOmpResolver(host, versionProbe ?? probeOmpVersion, now);
}
const ompResolver = createOmpResolver();
/**
* Finds the directory containing a verified `omp` binary.
* Checks `which omp` first, then falls back to common install locations. Every
* candidate must pass the `omp --version` sanity probe before it is accepted.
*
* @returns Directory path, or null if not found
*/
export function resolveOmpDir(): string | null {
return ompResolver.resolve()?.directory ?? null;
}
/**
* Check if the OMP CLI is available on the system.
*/
export function isOmpAvailable(): boolean {
return resolveOmpDir() !== null;
}
export function getOmpNotFoundMessage(): string {
return formatCliNotFoundMessage(OMP_NOT_FOUND, ompResolver.diagnostics());
}
/**
* Version reported by the resolved `omp` binary, or null when omp is
* unavailable. Surfaced through `GET /api/omp/status` so a misresolution is
* diagnosable from the UI.
*/
export function getOmpCliVersion(): string | null {
return ompResolver.resolve()?.metadata ?? null;
}
+9 -5
View File
@@ -2270,9 +2270,11 @@ class CodemanApp {
? 'Grok' ? 'Grok'
: mode === 'deepseek' : mode === 'deepseek'
? 'DeepSeek' ? 'DeepSeek'
: mode === 'opencode' : mode === 'omp'
? 'OpenCode' ? 'OMP'
: 'Claude'; : mode === 'opencode'
? 'OpenCode'
: 'Claude';
} }
async toggleResponseViewer() { async toggleResponseViewer() {
@@ -4888,7 +4890,7 @@ class CodemanApp {
<span class="tab-status ${status}" aria-hidden="true"></span> <span class="tab-status ${status}" aria-hidden="true"></span>
<span class="tab-info"> <span class="tab-info">
<span class="tab-name-row"> <span class="tab-name-row">
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : mode === 'pi' ? '<span class="tab-mode pi" aria-hidden="true">pi</span>' : mode === 'grok' ? '<span class="tab-mode grok" aria-hidden="true">gk</span>' : mode === 'deepseek' ? '<span class="tab-mode deepseek" aria-hidden="true">ds</span>' : ''} ${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : mode === 'pi' ? '<span class="tab-mode pi" aria-hidden="true">pi</span>' : mode === 'grok' ? '<span class="tab-mode grok" aria-hidden="true">gk</span>' : mode === 'deepseek' ? '<span class="tab-mode deepseek" aria-hidden="true">ds</span>' : mode === 'omp' ? '<span class="tab-mode omp" aria-hidden="true">om</span>' : ''}
<span class="tab-name" data-session-id="${id}" data-full-name="${escapeHtml(name)}">${tabLabel}</span> <span class="tab-name" data-session-id="${id}" data-full-name="${escapeHtml(name)}">${tabLabel}</span>
${inlineSessionActions ? tabActionsHtml : ''} ${inlineSessionActions ? tabActionsHtml : ''}
<span class="tab-detached-badge" aria-hidden="true">detached</span> <span class="tab-detached-badge" aria-hidden="true">detached</span>
@@ -6336,7 +6338,9 @@ class CodemanApp {
? 'Kill Tmux & Grok' ? 'Kill Tmux & Grok'
: session.mode === 'deepseek' : session.mode === 'deepseek'
? 'Kill Tmux & DeepSeek' ? 'Kill Tmux & DeepSeek'
: 'Kill Tmux & Claude Code'; : session.mode === 'omp'
? 'Kill Tmux & OMP'
: 'Kill Tmux & Claude Code';
} }
document.getElementById('closeConfirmModal').classList.add('active'); document.getElementById('closeConfirmModal').classList.add('active');
+1
View File
@@ -80,6 +80,7 @@ const HOME_SESSIONS_MODE_BADGE = {
pi: 'pi', pi: 'pi',
grok: 'gk', grok: 'gk',
deepseek: 'ds', deepseek: 'ds',
omp: 'om',
}; };
Object.assign(CodemanApp.prototype, { Object.assign(CodemanApp.prototype, {
+9
View File
@@ -452,6 +452,10 @@
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Run DeepSeek Run DeepSeek
</button> </button>
<button class="welcome-btn welcome-btn-omp" id="welcomeOmpBtn" style="display: none;" onclick="app.setRunMode('omp'); app.runOmp()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Run OMP
</button>
</div> </div>
<div class="welcome-qr" id="welcomeQr" onclick="app.toggleWelcomeQrSize()"> <div class="welcome-qr" id="welcomeQr" onclick="app.toggleWelcomeQrSize()">
<div class="welcome-qr-inner" id="welcomeQrInner"></div> <div class="welcome-qr-inner" id="welcomeQrInner"></div>
@@ -643,6 +647,9 @@
<button class="run-mode-option run-mode-option-install" data-action="deepseek-install" id="runModeDeepSeekInstall" style="display: none;" onclick="app.installDeepSeekProfile()"> <button class="run-mode-option run-mode-option-install" data-action="deepseek-install" id="runModeDeepSeekInstall" style="display: none;" onclick="app.installDeepSeekProfile()">
<span class="run-mode-dot deepseek"></span>DeepSeek — add a terminal profile… <span class="run-mode-dot deepseek"></span>DeepSeek — add a terminal profile…
</button> </button>
<button class="run-mode-option" data-mode="omp" onclick="app.setRunMode('omp')">
<span class="run-mode-dot omp"></span>OMP
</button>
<div class="run-mode-sep"></div> <div class="run-mode-sep"></div>
<button class="run-mode-option" data-mode="shell" onclick="app.setRunMode('shell')"> <button class="run-mode-option" data-mode="shell" onclick="app.setRunMode('shell')">
<span class="run-mode-dot shell"></span>Terminal / Shell <span class="run-mode-dot shell"></span>Terminal / Shell
@@ -930,6 +937,7 @@
<option value="pi">Pi</option> <option value="pi">Pi</option>
<option value="grok">Grok</option> <option value="grok">Grok</option>
<option value="deepseek">DeepSeek</option> <option value="deepseek">DeepSeek</option>
<option value="omp">OMP</option>
</select> </select>
</div> </div>
<div class="form-row"><label>Working Directory</label><input type="text" id="schWorkingDir" placeholder="/absolute/path"></div> <div class="form-row"><label>Working Directory</label><input type="text" id="schWorkingDir" placeholder="/absolute/path"></div>
@@ -2709,6 +2717,7 @@
<option value="pi" data-cli="pi">Pi</option> <option value="pi" data-cli="pi">Pi</option>
<option value="grok" data-cli="grok">Grok</option> <option value="grok" data-cli="grok">Grok</option>
<option value="deepseek" data-cli="deepseek">DeepSeek</option> <option value="deepseek" data-cli="deepseek">DeepSeek</option>
<option value="omp" data-cli="omp">OMP</option>
<option value="shell">Shell (no agent)</option> <option value="shell">Shell (no agent)</option>
</select> </select>
<span class="form-hint">Which CLI to point the Run button at once the clone finishes. Changeable any time from the Run dropdown.</span> <span class="form-hint">Which CLI to point the Run button at once the clone finishes. Changeable any time from the Run dropdown.</span>
+1
View File
@@ -56,6 +56,7 @@ const MOBILE_OVERVIEW_RUN_MODES = [
{ mode: 'pi', label: 'Pi', short: 'Pi' }, { mode: 'pi', label: 'Pi', short: 'Pi' },
{ mode: 'grok', label: 'Grok', short: 'Grok' }, { mode: 'grok', label: 'Grok', short: 'Grok' },
{ mode: 'deepseek', label: 'DeepSeek', short: 'DeepSeek' }, { mode: 'deepseek', label: 'DeepSeek', short: 'DeepSeek' },
{ mode: 'omp', label: 'OMP', short: 'OMP' },
{ mode: 'shell', label: 'Terminal / Shell', short: 'Shell' }, { mode: 'shell', label: 'Terminal / Shell', short: 'Shell' },
]; ];
+17 -1
View File
@@ -1003,6 +1003,20 @@ html.mobile-init .file-browser-panel {
border-color: rgba(150, 170, 255, 0.55) !important; 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 dropdown menu — positioned above toolbar on mobile */
.run-mode-menu { .run-mode-menu {
bottom: 100%; 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) { 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); background: linear-gradient(135deg, #be185d, #db2777);
border-color: #9d174d; 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) { 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) {
+1 -1
View File
@@ -432,7 +432,7 @@ Object.assign(CodemanApp.prototype, {
_buildCommandPaletteNewSessionItem(query = '') { _buildCommandPaletteNewSessionItem(query = '') {
const mode = this.runMode || this._runMode || 'claude'; 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'; const caseName = this._findCommandPaletteCaseMatch(query) || document.getElementById('quickStartCase')?.value || 'testcase';
return { return {
id: 'new-session', id: 'new-session',
+27 -14
View File
@@ -409,6 +409,9 @@ Object.assign(CodemanApp.prototype, {
if (mode === 'deepseek') { if (mode === 'deepseek') {
return await this.runDeepSeek(); return await this.runDeepSeek();
} }
if (mode === 'omp') {
return await this.runOmp();
}
if (mode === 'shell') { if (mode === 'shell') {
return await this.runShell(); 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. * run modes like the rest, and neither `agy` nor `pi` is likely to be installed.
*/ */
_refreshRunModeAvailability(menu) { _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}"]`); const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`);
if (btn) btn.style.display = this.isCliAvailable(mode) ? 'flex' : 'none'; if (btn) btn.style.display = this.isCliAvailable(mode) ? 'flex' : 'none';
} }
@@ -706,11 +709,8 @@ Object.assign(CodemanApp.prototype, {
if (runBtn) { if (runBtn) {
runBtn.className = `btn-toolbar btn-run mode-${mode}`; runBtn.className = `btn-toolbar btn-run mode-${mode}`;
} }
if (gearBtn) {
gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`;
}
if (label) { 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 isRemote = _runLoc === 'remote' || _runLoc === 'docker';
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Pi session in ${caseName}...`); 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 { try {
if (!isRemote) { if (!isRemote) {
const statusRes = await fetch('/api/pi/status'); 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) { if (!status.available) {
this._reportSessionLaunchError( this._reportSessionLaunchError(
ownsLaunchTerminal, ownsLaunchTerminal,
'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent' '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; return;
} }
} }
@@ -1411,7 +1418,15 @@ Object.assign(CodemanApp.prototype, {
}); });
const data = await res.json(); const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to start Pi'); 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) { if (data.data.sessionId) {
await this.selectSession(data.data.sessionId); await this.selectSession(data.data.sessionId);
@@ -1626,10 +1641,9 @@ Object.assign(CodemanApp.prototype, {
if (detachToggle) detachToggle.checked = this.hasTabDetachOverride(sessionId); if (detachToggle) detachToggle.checked = this.hasTabDetachOverride(sessionId);
// Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only) // 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'); this.switchOptionsTab(isAltMode ? 'summary' : 'respawn');
// Update respawn status display and buttons
const respawnStatus = document.getElementById('sessionRespawnStatus'); const respawnStatus = document.getElementById('sessionRespawnStatus');
const enableBtn = document.getElementById('modalEnableRespawnBtn'); const enableBtn = document.getElementById('modalEnableRespawnBtn');
const stopBtn = document.getElementById('modalStopRespawnBtn'); const stopBtn = document.getElementById('modalStopRespawnBtn');
@@ -1656,10 +1670,9 @@ Object.assign(CodemanApp.prototype, {
} }
// Hide Claude-specific options for external CLI sessions // 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]'); const claudeOnlyEls = document.querySelectorAll('[data-claude-only]');
claudeOnlyEls.forEach(el => { el.style.display = isExternalCli ? 'none' : ''; }); claudeOnlyEls.forEach(el => { el.style.display = isExternalCli ? 'none' : ''; });
// Reset duration presets to default (unlimited) // Reset duration presets to default (unlimited)
this.selectDurationPreset(''); this.selectDurationPreset('');
@@ -3442,7 +3455,7 @@ Object.defineProperty(CodemanApp.prototype, 'runMode', {
}, },
set(mode) { set(mode) {
this._runMode = 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 ? mode
: 'claude'; : 'claude';
}, },
+1
View File
@@ -1230,6 +1230,7 @@ Object.assign(CodemanApp.prototype, {
['welcomeClaudeBtn', 'claude'], ['welcomeClaudeBtn', 'claude'],
['welcomeOpencodeBtn', 'opencode'], ['welcomeOpencodeBtn', 'opencode'],
['welcomeAntigravityBtn', 'antigravity'], ['welcomeAntigravityBtn', 'antigravity'],
['welcomeOmpBtn', 'omp'],
['welcomeGeminiBtn', 'gemini'], ['welcomeGeminiBtn', 'gemini'],
['welcomePiBtn', 'pi'], ['welcomePiBtn', 'pi'],
['welcomeGrokBtn', 'grok'], ['welcomeGrokBtn', 'grok'],
+34 -2
View File
@@ -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.gemini,
.session-tab .tab-mode.antigravity, .session-tab .tab-mode.antigravity,
.session-tab .tab-mode.pi, .session-tab .tab-mode.pi,
.session-tab .tab-mode.grok .session-tab .tab-mode.grok,
.session-tab .tab-mode.omp
) { ) {
color: var(--accent-d); color: var(--accent-d);
} }
@@ -2490,6 +2491,10 @@ body.solo-mode .btn-lifecycle-log {
background: rgba(34, 211, 238, 0.2); background: rgba(34, 211, 238, 0.2);
color: #22d3ee; color: #22d3ee;
} }
.session-tab .tab-mode.omp {
background: rgba(129, 140, 248, 0.2);
color: #818cf8;
}
.session-tab .tab-mode.pi { .session-tab .tab-mode.pi {
background: rgba(244, 114, 182, 0.2); 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); 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); border-color: rgba(249, 168, 212, 0.5);
color: #fff1f7; 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 /* 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); 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); border-color: rgba(249, 168, 212, 0.6);
color: #fff1f7; 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 /* 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 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.pi { background: #f472b6; }
.run-mode-dot.grok { background: #a1a1aa; } .run-mode-dot.grok { background: #a1a1aa; }
.run-mode-dot.deepseek { background: #4d6bfe; } .run-mode-dot.deepseek { background: #4d6bfe; }
.run-mode-dot.omp { background: #818cf8; }
.run-mode-dot.shell { background: #94a3b8; } .run-mode-dot.shell { background: #94a3b8; }
/* Phone-only Enter button (see index.html). Hidden by default at every width; /* Phone-only Enter button (see index.html). Hidden by default at every width;
+42 -12
View File
@@ -866,6 +866,7 @@ export function registerSessionRoutes(
body.mode !== 'pi' && body.mode !== 'pi' &&
body.mode !== 'grok' && body.mode !== 'grok' &&
body.mode !== 'deepseek' && body.mode !== 'deepseek' &&
body.mode !== 'omp' &&
body.envOverrides && body.envOverrides &&
Object.keys(body.envOverrides).length > 0 && Object.keys(body.envOverrides).length > 0 &&
(workingDir.startsWith(CASES_DIR + '/') || workingDir.startsWith(managedCasesBase + '/')); (workingDir.startsWith(CASES_DIR + '/') || workingDir.startsWith(managedCasesBase + '/'));
@@ -965,6 +966,12 @@ export function registerSessionRoutes(
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getGrokNotFoundMessage()); 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 // Pre-validate resumeSessionId: check that the conversation file actually exists
// in Claude's projects directory. If not, skip resume to avoid confusing // in Claude's projects directory. If not, skip resume to avoid confusing
@@ -1012,12 +1019,14 @@ export function registerSessionRoutes(
? body.piConfig?.model ? body.piConfig?.model
: mode === 'grok' : mode === 'grok'
? body.grokConfig?.model ? body.grokConfig?.model
: // DeepSeek's model is a composition entry in the profile's config : mode === 'omp'
// tree, not a session flag, so there is deliberately nothing to ? body.ompConfig?.model
// read here (see docs/deepseek-integration.md). : // DeepSeek's model is a composition entry in the profile's config
mode !== 'shell' && mode !== 'deepseek' // tree, not a session flag, so there is deliberately nothing to
? modelConfig?.defaultModel || undefined // read here (see docs/deepseek-integration.md).
: undefined; mode !== 'shell' && mode !== 'deepseek'
? modelConfig?.defaultModel || undefined
: undefined;
const claudeModeConfig = await ctx.getClaudeModeConfig(); const claudeModeConfig = await ctx.getClaudeModeConfig();
// Section 6.3: force non-granted users to a classifier-guarded mode. // Section 6.3: force non-granted users to a classifier-guarded mode.
const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, owner); const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, owner);
@@ -1056,6 +1065,7 @@ export function registerSessionRoutes(
piConfig: mode === 'pi' ? gatedPiConfig : undefined, piConfig: mode === 'pi' ? gatedPiConfig : undefined,
grokConfig: mode === 'grok' ? gatedGrokConfig : undefined, grokConfig: mode === 'grok' ? gatedGrokConfig : undefined,
deepSeekConfig: mode === 'deepseek' ? gatedDeepSeekConfig : undefined, deepSeekConfig: mode === 'deepseek' ? gatedDeepSeekConfig : undefined,
ompConfig: mode === 'omp' ? body.ompConfig : undefined,
resumeSessionId: validatedResumeId, resumeSessionId: validatedResumeId,
envOverrides: await clampEnvOverridesForOwner(owner, body.envOverrides), envOverrides: await clampEnvOverridesForOwner(owner, body.envOverrides),
effort: body.effort, effort: body.effort,
@@ -1289,6 +1299,7 @@ export function registerSessionRoutes(
session.mode !== 'pi' && session.mode !== 'pi' &&
session.mode !== 'grok' && session.mode !== 'grok' &&
session.mode !== 'deepseek' && session.mode !== 'deepseek' &&
session.mode !== 'omp' &&
ctx.store.getConfig().ralphEnabled && ctx.store.getConfig().ralphEnabled &&
!session.ralphTracker.autoEnableDisabled !session.ralphTracker.autoEnableDisabled
) { ) {
@@ -2857,6 +2868,7 @@ export function registerSessionRoutes(
piConfig, piConfig,
grokConfig, grokConfig,
deepSeekConfig, deepSeekConfig,
ompConfig,
envOverrides, envOverrides,
effort, effort,
parentSessionId, parentSessionId,
@@ -2907,6 +2919,7 @@ export function registerSessionRoutes(
piConfig || piConfig ||
grokConfig || grokConfig ||
deepSeekConfig || deepSeekConfig ||
ompConfig ||
openCodeConfig openCodeConfig
) { ) {
return createErrorResponse( return createErrorResponse(
@@ -2941,6 +2954,7 @@ export function registerSessionRoutes(
piConfig || piConfig ||
grokConfig || grokConfig ||
deepSeekConfig || deepSeekConfig ||
ompConfig ||
openCodeConfig openCodeConfig
) { ) {
return createErrorResponse( return createErrorResponse(
@@ -3042,6 +3056,16 @@ export function registerSessionRoutes(
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getPiNotFoundMessage()); 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 // Check Grok availability if requested
if (mode === 'grok') { if (mode === 'grok') {
@@ -3103,14 +3127,15 @@ export function registerSessionRoutes(
writeFileSync(join(resolvedCasePath, 'CLAUDE.md'), claudeMd); writeFileSync(join(resolvedCasePath, 'CLAUDE.md'), claudeMd);
// Write .claude/settings.local.json with hooks for desktop notifications // 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 ( if (
mode !== 'opencode' && mode !== 'opencode' &&
mode !== 'codex' && mode !== 'codex' &&
mode !== 'gemini' && mode !== 'gemini' &&
mode !== 'antigravity' && mode !== 'antigravity' &&
mode !== 'pi' && mode !== 'pi' &&
mode !== 'grok' mode !== 'grok' &&
mode !== 'omp'
) { ) {
await writeHooksConfig(resolvedCasePath); await writeHooksConfig(resolvedCasePath);
} }
@@ -3150,6 +3175,7 @@ export function registerSessionRoutes(
// rule the existing-case branch above states; this branch used to exclude just // rule the existing-case branch above states; this branch used to exclude just
// the five external CLIs and let `shell` through). // the five external CLIs and let `shell` through).
if (docker && docker.hooksEnabled && mode === 'claude') { if (docker && docker.hooksEnabled && mode === 'claude') {
// configured project. Skipped for external CLIs (they use their own systems).
try { try {
if (!existsSync(join(resolvedCasePath, 'CLAUDE.md'))) { if (!existsSync(join(resolvedCasePath, 'CLAUDE.md'))) {
const templatePath = await ctx.getDefaultClaudeMdPath(); const templatePath = await ctx.getDefaultClaudeMdPath();
@@ -3185,6 +3211,7 @@ export function registerSessionRoutes(
mode !== 'pi' && mode !== 'pi' &&
mode !== 'grok' && mode !== 'grok' &&
mode !== 'deepseek' && mode !== 'deepseek' &&
mode !== 'omp' &&
!remote && !remote &&
envOverrides && envOverrides &&
Object.keys(envOverrides).length > 0 Object.keys(envOverrides).length > 0
@@ -3209,10 +3236,12 @@ export function registerSessionRoutes(
? piConfig?.model ? piConfig?.model
: mode === 'grok' : mode === 'grok'
? grokConfig?.model ? grokConfig?.model
: // DeepSeek's model lives in the profile's config tree, not here. : mode === 'omp'
mode !== 'shell' && mode !== 'deepseek' ? ompConfig?.model
? qsModelConfig?.defaultModel || undefined : // DeepSeek's model lives in the profile's config tree, not here.
: undefined; mode !== 'shell' && mode !== 'deepseek'
? qsModelConfig?.defaultModel || undefined
: undefined;
const qsClaudeModeConfig = await ctx.getClaudeModeConfig(); const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
const qsEffectiveClaudeMode = await resolveClaudeModeForUsername(qsClaudeModeConfig.claudeMode, owner); 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). // 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, piConfig: mode === 'pi' ? qsGatedPiConfig : undefined,
grokConfig: mode === 'grok' ? qsGatedGrokConfig : undefined, grokConfig: mode === 'grok' ? qsGatedGrokConfig : undefined,
deepSeekConfig: mode === 'deepseek' ? qsGatedDeepSeekConfig : undefined, deepSeekConfig: mode === 'deepseek' ? qsGatedDeepSeekConfig : undefined,
ompConfig: mode === 'omp' ? ompConfig : undefined,
envOverrides: qsGatedEnvOverrides, envOverrides: qsGatedEnvOverrides,
effort, effort,
remote, remote,
+11
View File
@@ -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) // State & Lifecycle (cleanup, lifecycle log, stats)
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
+29 -4
View File
@@ -141,6 +141,7 @@ const ALLOWED_ENV_PREFIXES = [
// 34-provider-key problem in a new shape, and the answer is the same one. // 34-provider-key problem in a new shape, and the answer is the same one.
'DSH_', 'DSH_',
'DEEPSEEK_', 'DEEPSEEK_',
'OMP_',
]; ];
/** /**
@@ -180,7 +181,7 @@ const safeEnvOverridesSchema = z
}, },
{ {
message: 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(); .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. * Schema for DeepSeek Harness (`dsh`)-specific configuration.
* *
@@ -440,7 +459,9 @@ const parentSessionIdSchema = z.string().max(100).optional();
export const CreateSessionSchema = z.object({ export const CreateSessionSchema = z.object({
workingDir: safePathSchema.optional(), 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(), name: z.string().max(100).optional(),
/** Session that spawned this one — see parentSessionIdSchema. */ /** Session that spawned this one — see parentSessionIdSchema. */
parentSessionId: parentSessionIdSchema, parentSessionId: parentSessionIdSchema,
@@ -458,6 +479,7 @@ export const CreateSessionSchema = z.object({
piConfig: PiConfigSchema, piConfig: PiConfigSchema,
grokConfig: GrokConfigSchema, grokConfig: GrokConfigSchema,
deepSeekConfig: DeepSeekConfigSchema, deepSeekConfig: DeepSeekConfigSchema,
ompConfig: OmpConfigSchema,
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */ /** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
resumeSessionId: z resumeSessionId: z
.string() .string()
@@ -869,7 +891,9 @@ export const QuickStartSchema = z.object({
* a real host dir, so the settings file crosses the bind mount); rejected for * 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). */ * remote cases (the file would be written on the WRONG machine). */
modelOverride: z.string().max(50).optional(), 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, openCodeConfig: OpenCodeConfigSchema,
codexConfig: CodexConfigSchema, codexConfig: CodexConfigSchema,
geminiConfig: GeminiConfigSchema, geminiConfig: GeminiConfigSchema,
@@ -877,6 +901,7 @@ export const QuickStartSchema = z.object({
piConfig: PiConfigSchema, piConfig: PiConfigSchema,
grokConfig: GrokConfigSchema, grokConfig: GrokConfigSchema,
deepSeekConfig: DeepSeekConfigSchema, deepSeekConfig: DeepSeekConfigSchema,
ompConfig: OmpConfigSchema,
envOverrides: safeEnvOverridesSchema, envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort: effortLevelSchema, effort: effortLevelSchema,
@@ -1410,7 +1435,7 @@ const noNewlines = (v: string) => !/[\r\n]/.test(v);
/** Shared field shape for creating/updating a scheduled job. */ /** Shared field shape for creating/updating a scheduled job. */
const CronJobBaseSchema = z.object({ const CronJobBaseSchema = z.object({
name: z.string().min(1).max(200), 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, workingDir: safePathSchema,
launchCommand: z.string().max(2000).refine(noNewlines, 'launchCommand must be a single line').optional(), launchCommand: z.string().max(2000).refine(noNewlines, 'launchCommand must be a single line').optional(),
promptMode: z.enum(['inline_text', 'prompt_file_path']), promptMode: z.enum(['inline_text', 'prompt_file_path']),
+3
View File
@@ -1441,6 +1441,7 @@ export class WebServer extends EventEmitter {
{ isPiAvailable }, { isPiAvailable },
{ isGrokAvailable }, { isGrokAvailable },
{ isDeepSeekRunnable, isDeepSeekAvailable }, { isDeepSeekRunnable, isDeepSeekAvailable },
{ isOmpAvailable },
{ isCloudflaredAvailable }, { isCloudflaredAvailable },
{ isGitAvailable }, { isGitAvailable },
] = await Promise.all([ ] = await Promise.all([
@@ -1452,6 +1453,7 @@ export class WebServer extends EventEmitter {
import('../utils/pi-cli-resolver.js'), import('../utils/pi-cli-resolver.js'),
import('../utils/grok-cli-resolver.js'), import('../utils/grok-cli-resolver.js'),
import('../utils/deepseek-cli-resolver.js'), import('../utils/deepseek-cli-resolver.js'),
import('../utils/omp-cli-resolver.js'),
import('../utils/cloudflared-resolver.js'), import('../utils/cloudflared-resolver.js'),
import('../git-clone.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. // profile is offered the fix rather than a greyed-out entry.
deepseek: isDeepSeekRunnable(), deepseek: isDeepSeekRunnable(),
deepseekBinary: isDeepSeekAvailable(), deepseekBinary: isDeepSeekAvailable(),
omp: isOmpAvailable(),
cloudflared: isCloudflaredAvailable(), cloudflared: isCloudflaredAvailable(),
// Not a run mode: the Add Case → Clone tab is an offer this box cannot // 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. // keep without git (issue #236), same reasoning as cloudflared above.
+2 -1
View File
@@ -433,6 +433,7 @@ describe('mobile overview run picker (CLI availability gating)', () => {
'pi', 'pi',
'grok', 'grok',
'deepseek', 'deepseek',
'omp',
'shell', 'shell',
]); ]);
}); });
@@ -447,7 +448,7 @@ describe('mobile overview run picker (CLI availability gating)', () => {
src.indexOf('];', src.indexOf('const MOBILE_OVERVIEW_RUN_MODES')) + 2 src.indexOf('];', src.indexOf('const MOBILE_OVERVIEW_RUN_MODES')) + 2
); );
const offered = [...modesBlock.matchAll(/mode: '([^']+)'/g)].map((m) => m[1]); 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 fn = src.slice(src.indexOf('_buildMobileOverviewRunMenu() {'));
const gate = fn.slice(0, fn.indexOf('const header')); const gate = fn.slice(0, fn.indexOf('const header'));
expect(gate).toContain('isCliAvailable'); expect(gate).toContain('isCliAvailable');
+114
View File
@@ -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\'');
});
});
+22 -2
View File
@@ -81,6 +81,16 @@ describe('run mode UI', () => {
expect(app.runMode).toBe('antigravity'); expect(app.runMode).toBe('antigravity');
expect(runBtnLabel.textContent).toBe('Run AG'); 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', () => { describe('Run launch synchronization', () => {
@@ -367,12 +377,13 @@ describe('Codex quick start settings', () => {
'welcomeGeminiBtn', 'welcomeGeminiBtn',
'welcomePiBtn', 'welcomePiBtn',
'welcomeGrokBtn', 'welcomeGrokBtn',
'welcomeOmpBtn',
'welcomeTunnelBtn', 'welcomeTunnelBtn',
]) { ]) {
welcomeBtns[id] = { style: { display: 'PRISTINE' } }; welcomeBtns[id] = { style: { display: 'PRISTINE' } };
} }
const modeBtns: Record<string, { style: { display: string } }> = {}; const modeBtns: Record<string, { style: { display: string } }> = {};
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' } }; modeBtns[mode] = { style: { display: 'PRISTINE' } };
} }
const menu = { const menu = {
@@ -405,6 +416,7 @@ describe('Codex quick start settings', () => {
antigravity: false, antigravity: false,
pi: false, pi: false,
grok: false, grok: false,
omp: false,
cloudflared: false, cloudflared: false,
}; };
@@ -442,16 +454,23 @@ describe('Codex quick start settings', () => {
withAgy.app.applyWelcomeCliVisibility(); withAgy.app.applyWelcomeCliVisibility();
expect(withAgy.welcomeBtns.welcomeAntigravityBtn.style.display).toBe('flex'); expect(withAgy.welcomeBtns.welcomeAntigravityBtn.style.display).toBe('flex');
expect(withAgy.welcomeBtns.welcomeClaudeBtn.style.display).toBe('none'); 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', () => { 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); app._refreshRunModeAvailability(menu);
expect(modeBtns.claude.style.display).toBe('flex'); expect(modeBtns.claude.style.display).toBe('flex');
expect(modeBtns.antigravity.style.display).toBe('flex'); expect(modeBtns.antigravity.style.display).toBe('flex');
expect(modeBtns.opencode.style.display).toBe('none'); expect(modeBtns.opencode.style.display).toBe('none');
expect(modeBtns.codex.style.display).toBe('none'); expect(modeBtns.codex.style.display).toBe('none');
expect(modeBtns.gemini.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 // Shell needs no external CLI, and leaving it alone is what guarantees the
// menu is never empty on a box with nothing installed. // menu is never empty on a box with nothing installed.
expect(modeBtns.shell.style.display).toBe('PRISTINE'); expect(modeBtns.shell.style.display).toBe('PRISTINE');
@@ -468,6 +487,7 @@ describe('Codex quick start settings', () => {
expect(offered).toContain('antigravity'); expect(offered).toContain('antigravity');
expect(offered).toContain('pi'); expect(offered).toContain('pi');
expect(offered).toContain('grok'); expect(offered).toContain('grok');
expect(offered).toContain('omp');
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8'); 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. // Anchor on the DEFINITION, not the earlier call site in toggleRunModeMenu.
const fn = src.slice(src.indexOf('_refreshRunModeAvailability(menu) {')); const fn = src.slice(src.indexOf('_refreshRunModeAvailability(menu) {'));