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