/** * @fileoverview The shipped stock catalog — one `CliEntry` per CLI Codeman supports out of * the box, transcribed to be byte-identical (via the argv engine) to the hand-written * builders in tmux-manager.ts that they replace. * * This is the ONE file allowed to know a CLI's id by name (`test/cli-registry-no-id-branching * .test.ts` enforces that nowhere else does). Everything downstream — session.ts, * tmux-manager.ts, the routes, the frontend — reads capability flags, never `entry.id ===`. * * @module config/cli-registry/stock */ import type { CliEntry } from './types.js'; const HOME_DIRS = { local: '~/.local/bin', usrLocal: '/usr/local/bin', bunBin: '~/.bun/bin', npmGlobal: '~/.npm-global/bin', homeBin: '~/bin', }; const NO_GATES = {}; const NO_PRIVILEGED_PARAMS: CliEntry['capabilities']['privilegedParams'] = []; /** * The common case: every CLI whose privileged switch is a command-line FLAG, reachable * only through its own config object and therefore already covered by `privilegedParams`. * DeepSeek is the sole exception — its switch is an env var. See CliCapabilities. */ const NO_PRIVILEGED_ENV_KEYS: CliEntry['capabilities']['privilegedEnvKeys'] = []; /** Shared skeleton for the "agent CLI, no unusual behaviour" case (pi's own shape). */ function agentDefaults(): Pick< CliEntry['capabilities'], | 'external' | 'requiresMux' | 'hooks' | 'transcript' | 'altScreen' | 'wheelForward' | 'keyboardAccessory' | 'privilegedCommandGate' | 'startMode' | 'stripInkBloat' | 'ralph' | 'respawn' | 'effort' | 'agentSkillInjection' | 'statusLineTelemetry' | 'model' | 'privilegedParams' | 'privilegedEnvKeys' | 'gates' > { return { external: true, requiresMux: true, hooks: 'none', transcript: 'none', altScreen: 'strip-mux-only', wheelForward: { mode: 'never' }, keyboardAccessory: 'agent', privilegedCommandGate: false, startMode: 'interactive', stripInkBloat: true, ralph: false, respawn: false, effort: false, agentSkillInjection: false, statusLineTelemetry: false, model: { source: 'flag', param: 'model' }, privilegedParams: NO_PRIVILEGED_PARAMS, privilegedEnvKeys: NO_PRIVILEGED_ENV_KEYS, gates: NO_GATES, }; } // `accent` on every entry below (except SHELL, which the frontend renders no // distinct color for) is measured from the actual `.btn-toolbar.btn-run.mode-` // CSS rule's `border-color` on the OG skin (styles.css) — the single cleanest // representative hex each entry's own multi-stop gradient resolves around. // Corrected 2026-09-21 after PR #458's review found several were simply wrong // (e.g. claude was registered as Anthropic's brand orange, `#d97757`, but the // button renders blue): `docs/cli-registry.md`'s own "transcribed, not // authoritative, re-measure before wiring one up" warning for this // DECLARED-FOR-LATER field, taken literally. This is a data-accuracy fix only — // `accent` still has no reader, so nothing rendered changes because of it. const CLAUDE: CliEntry = { id: 'claude' as CliEntry['id'], label: 'Claude', shortBadge: 'CC', accent: '#3b82f6', enabled: true, stock: true, order: 0, kind: 'agent', discovery: { binaries: ['claude'], searchDirs: [HOME_DIRS.local, '~/.claude/local', HOME_DIRS.usrLocal, HOME_DIRS.npmGlobal, HOME_DIRS.homeBin], version: { arg: '--version', regex: '(\\d+\\.\\d+\\.\\d+)', retryOnTransientFailure: true }, install: { command: { linux: 'curl -fsSL https://claude.ai/install.sh | bash', darwin: 'curl -fsSL https://claude.ai/install.sh | bash', wsl: 'curl -fsSL https://claude.ai/install.sh | bash', }, npmPackage: '@anthropic-ai/claude-code', docsUrl: 'https://docs.claude.com/claude-code', }, }, launch: { chain: 'fallback', params: { claudeMode: { type: 'enum', values: ['dangerously-skip-permissions', 'auto', 'normal', 'allowedTools'], default: 'dangerously-skip-permissions', }, allowedTools: { type: 'token', pattern: 'tool-list' }, model: { type: 'token', pattern: 'model-claude' }, resumeId: { type: 'token', pattern: 'uuid' }, // buildEffortCliArgs carries `ultracode` as a settings JSON blob and every other // level as a plain `--effort ` flag — two engine values because the two // shapes are mutually exclusive and neither is user-typed text (both are produced // from the EFFORT_LEVELS allowlist upstream, same as every other engine value). effortLevel: { type: 'engine', source: 'effortLevel' }, effortJson: { type: 'engine', source: 'effortSettingsJson' }, sessionId: { type: 'engine', source: 'sessionId' }, sessionName: { type: 'engine', source: 'sessionName' }, }, variants: [ { id: 'resume', when: { param: 'resumeId', state: 'set' }, args: [ { lit: 'claude' }, { flag: '--dangerously-skip-permissions', when: { param: 'claudeMode', is: 'dangerously-skip-permissions' } }, { flag: '--permission-mode', value: 'auto', when: { param: 'claudeMode', is: 'auto' } }, { flag: '--allowedTools', valueFrom: 'allowedTools', quote: 'double', when: { allOf: [ { param: 'claudeMode', is: 'allowedTools' }, { param: 'allowedTools', state: 'set' }, ], }, }, { flag: '--resume', valueFrom: 'resumeId', quote: 'double' }, { flag: '--model', valueFrom: 'model', quote: 'double', when: { param: 'model', state: 'set' } }, { flag: '--effort', valueFrom: 'effortLevel', quote: 'single', when: { param: 'effortLevel', state: 'set' } }, { flag: '--settings', valueFrom: 'effortJson', quote: 'single', when: { param: 'effortJson', state: 'set' } }, { flag: '--name', valueFrom: 'sessionName', quote: 'double', when: { capabilityGate: 'nameFlag' } }, ], }, { id: 'new', args: [ { lit: 'claude' }, { flag: '--dangerously-skip-permissions', when: { param: 'claudeMode', is: 'dangerously-skip-permissions' } }, { flag: '--permission-mode', value: 'auto', when: { param: 'claudeMode', is: 'auto' } }, { flag: '--allowedTools', valueFrom: 'allowedTools', quote: 'double', when: { allOf: [ { param: 'claudeMode', is: 'allowedTools' }, { param: 'allowedTools', state: 'set' }, ], }, }, { flag: '--session-id', valueFrom: 'sessionId', quote: 'double' }, { flag: '--model', valueFrom: 'model', quote: 'double', when: { param: 'model', state: 'set' } }, { flag: '--effort', valueFrom: 'effortLevel', quote: 'single', when: { param: 'effortLevel', state: 'set' } }, { flag: '--settings', valueFrom: 'effortJson', quote: 'single', when: { param: 'effortJson', state: 'set' } }, { flag: '--name', valueFrom: 'sessionName', quote: 'double', when: { capabilityGate: 'nameFlag' } }, ], }, ], // Claude has no `Config` object of its own — the bridge synthesizes one from its // discrete top-level spawn fields, under their EXISTING field name `resumeSessionId`. legacyConfigAliases: { resumeId: 'resumeSessionId' }, }, env: { // Claude asks for truecolor, like every CLI here except `shell` and `opencode`. // tmux hands the pane TERM=screen, which supports-color reads as 16 colors, and // Claude then quantizes every RGB color its theme asks for down to that palette. // Each dark background lands on ESC[40m, the terminal's own black, so the block // Claude draws behind the user's own messages renders invisible. PR #3 unset // COLORTERM here against xterm.js#484, which xterm.js had already closed in 2019, // and Codeman now ships @xterm/xterm 6 and sets `terminal-overrides *:Tc` itself. // The other truecolor CLIs also unset NO_COLOR. Claude does not, so a user who // exports NO_COLOR globally keeps the monochrome panes they asked for. // CLAUDECODE stays unset, because Claude reads it as a signal that it is running // nested inside itself. exports: [{ name: 'COLORTERM', value: 'truecolor' }], unset: ['CLAUDECODE'], tmuxSetenvKeys: [], dockerExecEnvNames: [], // Deliberately excludes ANTHROPIC_* (base URL / API key / default-model overrides): // custom-model-injection.ts's claude recipe uses those names, but they must reach a // session ONLY through the admin-configured, SSRF-guarded custom-model route, never // through a plain client-supplied envOverrides field. Widening this prefix would let // any session-create caller redirect a session's Anthropic traffic and credentials to // an arbitrary, unvalidated URL. allowedPrefixes: ['CLAUDE_CODE_'], allowedKeys: ['CLAUDE_CONFIG_DIR'], }, capabilities: { external: false, // Claude indents its transcript body two columns and puts its own ●/✻/❯ markers // in them, so a copy can drop two and paste flush. The only entry that declares // this, because it is the only one whose gutter has been measured. transcriptGutter: 2, // The historical hard-coded pair, now stated as data. `workingLine` matches both the // `✻ Actualizing… (39s · ↓ 2.0k tokens)` status line and the bare `esc to interrupt` // footer, because tmux repaints partially and only one of the two may land in a chunk. workDetect: { promptGlyph: '❯', workingLine: String.raw`…\s*\((?:\d+h\s+)?(?:\d+m\s+)?\d+s\b|esc to interrupt`, // Claude prints what it started in the background on the footer row beneath its // composer, as `⏵⏵ bypass permissions on · 1 monitor · ← for agents`. The labels are // the CLI's own words for each kind of background task, and group 1 is the one // Codeman badges the session with. Verified against a live 2.1.278 pane on // 2026-09-21. // ⚠️ Two things keep an agent from writing its own label here, and both matter. // The footer is the LAST row, so the default one-row window (`WATCHING_TAIL_LINES`) // holds nothing but Ink's own chrome — in particular it leaves out the status line // directly above, whose content comes from a `statusLine` command a bypassed // session can write into its own `.claude/settings.json`. And the leading `·` keeps // the match on the footer's own item list rather than on any text that happens to // carry a count. A footer that ever drew the chip as its only item would report no // watching rather than open that door. See `watchingLabel()` in // `session-activity.ts`. watchingLine: String.raw`·\s*(\d+ (?:monitors?|shells?|teams?|local agents?|cloud sessions?|MCP tasks?|background tasks?|(?:background|remote) dynamic workflows?|Artifact comment monitors?))`, }, requiresMux: false, // Claude installs Codeman's own hooks block into every workspace it runs in, so its // stop/idle signals are unconditional — no per-session veto, unlike deepseek's bridge. hooks: 'always', transcript: 'claude-jsonl', altScreen: 'strip-full', echo: { policy: 'buffer', anchor: { kind: 'glyph', glyph: '❯', offset: 2 } }, wheelForward: { mode: 'version-gated', minVersion: '2.1.187' }, keyboardAccessory: 'agent', privilegedCommandGate: false, startMode: 'interactive', stripInkBloat: true, ralph: true, respawn: true, effort: true, agentSkillInjection: true, statusLineTelemetry: true, model: { source: 'claude-settings-file' }, privilegedParams: [], // ANTHROPIC_* is NOT in allowedPrefixes/allowedKeys above (deliberately — see the // allowedPrefixes comment nearby), so these are unreachable via plain envOverrides // today. privilegedEnvKeys has exactly one consumer, ownerClampedEnvKeys() in // session-env-clamp.ts, which feeds the generic envOverrides clamp on // POST /api/sessions, POST /api/quick-start and reboot-restore — no custom-model // route reads this field at all, and the values it injects are merged in AFTER // that clamp runs regardless of what's listed here. privilegedEnvKeys: [ 'ANTHROPIC_BASE_URL', 'ANTHROPIC_API_KEY', 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL', 'ANTHROPIC_DEFAULT_OPUS_MODEL', // CLAUDE_CODE_MAX_CONTEXT_TOKENS already matches the CLAUDE_CODE_* allowedPrefix, and // CLAUDE_CONFIG_DIR is already an allowed exact key (docs/wiki/Agent-CLIs.md), so both // were already reachable via plain envOverrides before this pair existed and this // feature does not strictly need either listed. They stay listed anyway, because // types.ts's rule ("every traffic-redirecting var this feature introduces MUST also // appear in privilegedEnvKeys") is meant to hold literally, not with an exception // carved out for the two vars that happen not to need it today. The real // consequence lands on the GENERIC envOverrides clamp above, not on this feature: // a non-granted multi-user owner can no longer set CLAUDE_CONFIG_DIR through // envOverrides at all (the per-client-account override, #255), and a PERSISTED one // is now stripped on reboot-restore for such an owner too — see // session-env-clamp.ts's own fileoverview. 'CLAUDE_CODE_MAX_CONTEXT_TOKENS', 'CLAUDE_CONFIG_DIR', ], gates: { nameFlag: { minVersion: '2.1.224', failClosed: true } }, // Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md) — verified by hand against a real // llama.cpp server. Claude reads these at process start only, so switching requires a // respawn, never a live hot-swap. customModelInjection: { kind: 'env', baseUrlVar: 'ANTHROPIC_BASE_URL', apiKeyVar: 'ANTHROPIC_API_KEY', modelVars: ['ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL', 'ANTHROPIC_DEFAULT_OPUS_MODEL'], // Verified via Claude Code's own docs: CLAUDE_CODE_MAX_CONTEXT_TOKENS overrides the // assumed context window and applies directly for a model name Claude Code doesn't // recognize as one of its own — exactly the custom-model case. Without it, Claude Code // assumes a large (200k) window for any unrecognized model id and never compacts, // eventually overflowing a much smaller real local context (see plan doc reasoning // above the interface for the confirmed failure). contextLengthVar: 'CLAUDE_CODE_MAX_CONTEXT_TOKENS', // Isolates this session's config/credential directory so an injected ANTHROPIC_API_KEY // never shares a directory with a stored claude.ai OAuth login — see the doc comment on // customModelInjection in cli-registry/types.ts for the traded-off side effect. configDirVar: 'CLAUDE_CONFIG_DIR', // ⚠️ Required alongside configDirVar, not optional in practice: verified live that an // isolated, otherwise-empty config directory makes claude stop at an interactive // "Detected a custom API key — use it?" prompt on EVERY launch, defaulting to "No" with // no one at the TTY to answer — silently refusing the very key this feature injected. // Pre-seeding this file's customApiKeyResponses.approved list (verified against a real // ~/.claude.json after answering the prompt once by hand) answers it in advance instead. apiKeyTrustFile: { relPath: '.claude.json', shape: 'claude-api-key-responses' }, // ⚠️ Same isolated-directory root cause, one step further: verified live that on top // of the API-key prompt above, a fresh CLAUDE_CONFIG_DIR also replays claude's ENTIRE // first-run sequence on every launch — the theme picker, the security-notes screen, // the per-project "trust this folder?" dialog, and (running with // --dangerously-skip-permissions) a one-time bypass-permissions warning — none of // which a real, already-onboarded profile shows again. Pre-seeds that same // already-onboarded state instead of leaving a human to click through it. skipFirstRunPrompts: true, }, }, overlays: { // Mirrors the local default so the remote/in-container agent runs non-interactively // (no trust-folder/permission prompt that nothing on that side can answer). A per-host // `commands.claude` override, or the docker multi-user clamp, stays the escape hatch. remote: { command: 'claude --dangerously-skip-permissions' }, // ⚠️ As root the flag is not merely unnecessary, it is REFUSED ("cannot be used with // root/sudo privileges"), and only inside the container — so an adopted root container // would just show a dead pane. Drop it there and let claude ask. docker: { command: 'claude --dangerously-skip-permissions', rootCommand: 'claude' }, // Claude's docker/remote credential handling has its own dedicated code path // (claudeDockerPaneCommand, artifacts at docker-hosts.ts:537-575) — no generic credStore. }, }; const SHELL: CliEntry = { id: 'shell' as CliEntry['id'], label: 'Shell', shortBadge: 'SH', accent: '#6b7280', enabled: true, stock: true, order: 1, kind: 'shell', discovery: { binaries: [], searchDirs: [], install: { command: {} }, }, launch: { params: {}, variants: [{ id: 'shell', args: [] }], // tmux-manager resolves the real login shell in code }, env: { exports: [], unset: ['COLORTERM'], tmuxSetenvKeys: [], dockerExecEnvNames: [], allowedPrefixes: [], allowedKeys: [], }, capabilities: { external: false, requiresMux: false, // ⚠️ `false` here while `external` is ALSO false is the pairing that matters: a shell // has no hooks but is not an "external CLI", so a predicate derived from `external` // once accepted `until=stop` on a shell session and hung for the full timeout. hooks: 'none', transcript: 'none', altScreen: 'preserve', echo: { policy: 'off', anchor: { kind: 'none' } }, wheelForward: { mode: 'never' }, keyboardAccessory: 'shell', privilegedCommandGate: true, startMode: 'shell', stripInkBloat: false, ralph: false, respawn: false, effort: false, agentSkillInjection: false, statusLineTelemetry: false, model: { source: 'none' }, privilegedParams: [], privilegedEnvKeys: [], gates: {}, customModelInjection: { kind: 'unsupported' }, // a raw shell has no "model" concept }, overlays: { // No `remote` entry: defaultRemoteCommandForMode special-cases kind==='shell' directly // (an interactive login shell, no `-c ''` wrapping at all). docker: { disabled: true }, }, }; const OPENCODE: CliEntry = { id: 'opencode' as CliEntry['id'], label: 'OpenCode', shortBadge: 'OC', accent: '#10b981', enabled: true, stock: true, order: 10, kind: 'agent', discovery: { binaries: ['opencode'], searchDirs: [ '~/.opencode/bin', HOME_DIRS.local, HOME_DIRS.usrLocal, '~/go/bin', HOME_DIRS.bunBin, HOME_DIRS.npmGlobal, HOME_DIRS.homeBin, ], version: { arg: '--version', regex: '(\\d+\\.\\d+\\.\\d+)' }, install: { command: { linux: 'curl -fsSL https://opencode.ai/install | bash', darwin: 'curl -fsSL https://opencode.ai/install | bash', }, npmPackage: 'opencode-ai', docsUrl: 'https://opencode.ai/docs', }, }, launch: { params: { model: { type: 'token', pattern: 'model' }, resumeId: { type: 'token', pattern: 'id' }, forkSession: { type: 'bool' }, }, variants: [ { id: 'default', args: [ { lit: 'opencode' }, { flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } }, { flag: '--session', valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } }, { flag: '--fork', when: { allOf: [ { param: 'resumeId', state: 'set' }, { param: 'forkSession', is: true }, ], }, }, ], }, ], legacyConfigAliases: { resumeId: 'continueSession' }, legacyConfigField: 'openCodeConfig', }, env: { exports: [], unset: ['COLORTERM'], tmuxSetenvKeys: ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GOOGLE_API_KEY'], dockerExecEnvNames: [], allowedPrefixes: ['OPENCODE_'], allowedKeys: [], configContentVar: 'OPENCODE_CONFIG_CONTENT', }, capabilities: { ...agentDefaults(), altScreen: 'strip-mux-only', echo: { policy: 'buffer', anchor: { kind: 'cursor' }, predictProfile: undefined }, // Verified by hand against a real llama.cpp server. Reuses the SAME env var opencode's // own `env.configContentVar` already declares — the builder in custom-model-injection.ts // must merge into whatever opencode config Codeman would otherwise send, not clobber it. customModelInjection: { kind: 'configContentEnv', envVar: 'OPENCODE_CONFIG_CONTENT', template: 'opencode-json' }, // OPENCODE_CONFIG_CONTENT already matches the OPENCODE_ allowedPrefix above, so it was // ALREADY reachable via plain envOverrides before this feature existed — it replaces // opencode's whole config, provider api keys included, so a non-granted multi-user owner // sending it is a pre-existing credential-redirection gap, not one this feature opens. privilegedEnvKeys: ['OPENCODE_CONFIG_CONTENT'], }, overlays: { credStore: { rel: '.config/opencode', seedWhole: true }, }, }; const CODEX: CliEntry = { id: 'codex' as CliEntry['id'], label: 'Codex', shortBadge: 'CX', accent: '#a855f7', enabled: true, stock: true, order: 20, kind: 'agent', discovery: { binaries: ['codex'], searchDirs: [ '~/.codex/bin', HOME_DIRS.local, HOME_DIRS.usrLocal, HOME_DIRS.bunBin, HOME_DIRS.npmGlobal, HOME_DIRS.homeBin, ], version: { arg: '--version', regex: '(\\d+\\.\\d+\\.\\d+)' }, install: { command: { linux: 'npm install -g @openai/codex', darwin: 'npm install -g @openai/codex' }, npmPackage: '@openai/codex', docsUrl: 'https://developers.openai.com/codex/cli', }, }, launch: { params: { bypassApprovals: { type: 'bool' }, animations: { type: 'bool' }, model: { type: 'token', pattern: 'model' }, resumeId: { type: 'token', pattern: 'id' }, }, variants: [ { id: 'default', args: [ { lit: 'codex' }, { flag: '--dangerously-bypass-approvals-and-sandbox', when: { param: 'bypassApprovals', is: true } }, { flag: '--config', value: 'tui.animations=true', when: { param: 'animations', is: true } }, { flag: '--config', value: 'tui.animations=false', when: { param: 'animations', is: false } }, { flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } }, { lit: 'resume', when: { param: 'resumeId', state: 'set' } }, { valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } }, ], }, ], legacyConfigAliases: { bypassApprovals: 'dangerouslyBypassApprovals', resumeId: 'resumeSessionId' }, legacyConfigField: 'codexConfig', resumeAppend: { style: 'positional', token: 'resume' }, }, env: { exports: [ { name: 'COLORTERM', value: 'truecolor' }, { name: 'CODEX_INTERNAL_ORIGINATOR_OVERRIDE', value: { engine: 'codemanPrefixedSessionId' } }, ], unset: ['NO_COLOR'], tmuxSetenvKeys: ['OPENAI_API_KEY', 'CODEX_API_KEY', 'CODEX_HOME'], dockerExecEnvNames: ['OPENAI_API_KEY', 'CODEX_API_KEY'], allowedPrefixes: ['CODEX_'], allowedKeys: [], }, capabilities: { ...agentDefaults(), // Codex draws `› Ask Codex to do anything` on its composer row and // `Working (2m 49s • esc to interrupt)` above it while a turn runs. It animates no // braille spinner, and it never prints `esc to interrupt` at rest, so that phrase // alone separates a running turn from an idle one. // Codex pins a row of its own while a background terminal it started is still // running: ` 1 background terminal running · /ps to view · /stop to close`. Unlike // Claude's footer chip that row sits ABOVE the composer, which puts it third from the // bottom once the status line and the composer are counted, hence `watchingLines`. // Measured against a live codex-cli 0.154.0 pane on 2026-09-22: the row appears when // the terminal starts, follows the composer down as the conversation grows, and is // gone after `/stop`. // ⚠️ This entry CANNOT promise what Claude's does, and the difference is Codex's // layout rather than its pattern. The third row from the bottom is the chip only // while a terminal runs; with none running it is the last row of the transcript, // which the agent writes. Matching the complete row raises the bar — an assistant // message has to end with this exact line, to the character — but nothing here makes // forging it impossible, so do not read the Claude comment above as applying here. // What contains it is that codex declares `hooks: 'none'`: no hook event from a codex // session ever reaches `notePrompt()`, so there is no idle item to pre-acknowledge // and a forged label costs a wrong badge and nothing else. A CLI that gains hook // signals must not keep a pattern this soft. // ⚠️ Background TERMINALS are the only background work codex advertises on screen. // A sub-agent started without waiting outlives the turn just as a terminal does — // measured 2026-09-22, the sandboxed process was still running — and the pane shows // nothing at all for it: the last rows are the composer and the status line, and // `Sub-agents running` lives in the on-demand `/subagents` panel, not above the // composer. So a codex session waiting on a sub-agent reads as plainly idle here. // Nothing is misfiled by that (codex raises no idle prompts), and there is no row to // match until codex pins one. workDetect: { promptGlyph: '›', workingLine: '[Ee]sc to interrupt', watchingLine: String.raw`^\s{0,4}(\d+ background terminals?) running · /ps to view · /stop to close$`, watchingLines: 3, }, // Two columns, like claude's, measured on a live 0.154.0 answer: the `•`/`›`/`⚠` // markers sit in the gutter, prose continuations sit at 2, and a nested YAML block // the model wrote rendered at 2/4/6/8 for its own 0/2/4/6. Replayed at 100, 120, // 160, 198, 235 and 282 columns the indents were 0, 2, 4, 6 and 8 at every one, // never 1, so the width is not a function of the pane. transcriptGutter: 2, transcript: 'codex-rollout', altScreen: 'strip-full', echo: { policy: 'predict', anchor: { kind: 'cursor' }, predictProfile: 'codex' }, wheelForward: { mode: 'never' }, // #227: codex ignores SGR wheel reports, never forward maxFrameBytes: 32 * 1024, // codex's own bare-spawn default (no config sent) is already safe (no bypass flag), so // the multi-user clamp only needs to force an EXPLICITLY-SENT bypass back off. // // `param` names the REGISTRY param, like every other `param` in this file — the clamp // resolves it through `legacyConfigAliases` on the way out, exactly as `configSetenv` // does. codex is the entry where the two names differ (`bypassApprovals` here, // `dangerouslyBypassApprovals` on the wire), so it is the one that would have caught a // regression; `schema.ts` now rejects a name that is not a declared param. privilegedParams: [{ param: 'bypassApprovals', clampTo: false }], // Verified by hand against a real llama.cpp server. Written to an isolated CODEX_HOME // so the user's real ~/.codex/config.toml is never touched. customModelInjection: { kind: 'configDir', dirEnvVar: 'CODEX_HOME', fileName: 'config.toml', template: 'codex-toml', }, // CODEX_HOME already matches the CODEX_ allowedPrefix above, so it was ALREADY // reachable via plain envOverrides before this feature existed. It is arguably // MORE sensitive than a bare base-url var: a redirected CODEX_HOME points codex at a // config.toml a non-granted owner fully controls, which can restate sandbox/approval // policy INSIDE that file — a path the argv-level `bypassApprovals` clamp above // cannot see or stop. // CODEMAN_CUSTOM_MODEL_API_KEY: the credential config.toml's env_key references // (see custom-model-injection.ts) — same reasoning as CODEX_HOME above. privilegedEnvKeys: ['CODEX_HOME', 'CODEMAN_CUSTOM_MODEL_API_KEY'], }, overlays: { credStore: { rel: '.codex', shareDirs: ['sessions'], shareFiles: ['history.jsonl'], seedFiles: ['auth.json', 'config.toml'], }, }, }; const GEMINI: CliEntry = { id: 'gemini' as CliEntry['id'], label: 'Gemini', shortBadge: 'GM', accent: '#60a5fa', enabled: true, stock: true, order: 30, kind: 'agent', discovery: { binaries: ['gemini'], searchDirs: [ '~/.gemini/bin', HOME_DIRS.local, HOME_DIRS.usrLocal, HOME_DIRS.bunBin, HOME_DIRS.npmGlobal, HOME_DIRS.homeBin, ], version: { arg: '--version', regex: '(\\d+\\.\\d+\\.\\d+)' }, install: { command: { linux: 'npm install -g @google/gemini-cli', darwin: 'npm install -g @google/gemini-cli' }, npmPackage: '@google/gemini-cli', docsUrl: 'https://github.com/google-gemini/gemini-cli', }, }, launch: { params: { approvalMode: { type: 'enum', values: ['default', 'auto_edit', 'yolo', 'plan'], default: 'yolo' }, model: { type: 'token', pattern: 'model' }, resumeId: { type: 'token', pattern: 'id-dotted' }, }, variants: [ { id: 'default', args: [ { lit: 'gemini' }, { flag: '--skip-trust' }, { flag: '--approval-mode', valueFrom: 'approvalMode' }, { flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } }, { flag: '--resume', valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } }, ], }, ], legacyConfigAliases: { resumeId: 'resumeSession' }, legacyConfigField: 'geminiConfig', resumeAppend: { style: 'flag', flag: '--resume' }, }, env: { exports: [{ name: 'COLORTERM', value: 'truecolor' }], unset: ['NO_COLOR'], tmuxSetenvKeys: [ 'GEMINI_API_KEY', 'GEMINI_MODEL', 'GOOGLE_API_KEY', 'GOOGLE_CLOUD_PROJECT', 'GOOGLE_CLOUD_LOCATION', 'GOOGLE_APPLICATION_CREDENTIALS', 'GOOGLE_GENAI_USE_VERTEXAI', ], dockerExecEnvNames: ['GEMINI_API_KEY', 'GOOGLE_API_KEY'], allowedPrefixes: ['GEMINI_', 'GOOGLE_'], allowedKeys: [], }, capabilities: { ...agentDefaults(), altScreen: 'strip-full', echo: { policy: 'buffer', anchor: { kind: 'cursor' } }, // gemini's builder defaults an ABSENT approvalMode to 'yolo', so the clamp must // MATERIALIZE a config (not just touch an already-sent one) or a non-granted owner who // sends no geminiConfig at all would still get yolo for free. privilegedParams: [{ param: 'approvalMode', clampTo: 'auto_edit', materializeWhenAbsent: true }], // Web-researched, unverified — needs a restart to pick up (CLI reads these at process // start). Confirm the exact model-override env var name against the installed // gemini-cli version before shipping. customModelInjection: { kind: 'env', baseUrlVar: 'GOOGLE_GEMINI_BASE_URL', apiKeyVar: 'GEMINI_API_KEY', modelVars: ['GEMINI_MODEL'], }, // All three already match the GEMINI_/GOOGLE_ allowedPrefixes above, so they were // ALREADY reachable via plain envOverrides before this feature existed — a non-granted // multi-user owner redirecting a gemini session's endpoint/credentials is a // pre-existing gap this feature's analysis surfaced, not one it opens. privilegedEnvKeys: ['GOOGLE_GEMINI_BASE_URL', 'GEMINI_API_KEY', 'GEMINI_MODEL'], }, overlays: { credStore: { rel: '.gemini', seedWhole: true }, // also covers antigravity — see its own entry }, }; const ANTIGRAVITY: CliEntry = { id: 'antigravity' as CliEntry['id'], label: 'Antigravity', shortBadge: 'AG', accent: '#22d3ee', enabled: true, stock: true, order: 40, kind: 'agent', discovery: { // Binary is `agy`, NOT `antigravity` — the mode-name/binary-name split that made // probeDockerCliVersion wrong before this registry existed. binaries: ['agy'], searchDirs: [HOME_DIRS.local, '~/.antigravity/bin', HOME_DIRS.usrLocal, HOME_DIRS.homeBin], version: { arg: '--version', regex: '(\\d+\\.\\d+\\.\\d+)' }, install: { command: { linux: 'curl -fsSL https://antigravity.google/cli/install.sh | bash', darwin: 'curl -fsSL https://antigravity.google/cli/install.sh | bash', }, docsUrl: 'https://antigravity.google/cli', }, }, launch: { params: { dangerouslySkipPermissions: { type: 'bool' }, model: { type: 'token', pattern: 'model' }, resumeId: { type: 'token', pattern: 'id-dotted' }, }, variants: [ { id: 'default', args: [ { lit: 'agy' }, { flag: '--dangerously-skip-permissions', when: { param: 'dangerouslySkipPermissions', is: true } }, { flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } }, { flag: '--conversation', valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } }, ], }, ], legacyConfigAliases: { resumeId: 'resumeConversationId' }, legacyConfigField: 'antigravityConfig', resumeAppend: { style: 'flag', flag: '--conversation' }, }, env: { exports: [{ name: 'COLORTERM', value: 'truecolor' }], unset: ['NO_COLOR'], tmuxSetenvKeys: [], dockerExecEnvNames: [], allowedPrefixes: ['ANTIGRAVITY_'], allowedKeys: [], }, capabilities: { ...agentDefaults(), altScreen: 'strip-mux-only', echo: { policy: 'buffer', anchor: { kind: 'cursor' } }, // Like codex: an ABSENT config already defaults safe (no bypass flag), so only a // SENT config needs the flag forced off — nothing is materialized. privilegedParams: [{ param: 'dangerouslySkipPermissions', clampTo: false }], // No known CLI/env/config mechanism — Antigravity's own docs describe a GUI-only // custom-endpoint setting and explicitly say it "cannot currently" become the core // reasoning model. Toolbar entry stays disabled for this mode. customModelInjection: { kind: 'unsupported' }, }, overlays: { // No credStore of its own: agy nests its whole state under ~/.gemini/antigravity-cli/, // which gemini's seedWhole entry already covers. }, }; const PI: CliEntry = { id: 'pi' as CliEntry['id'], label: 'Pi', shortBadge: 'PI', accent: '#f472b6', enabled: true, stock: true, order: 50, kind: 'agent', discovery: { binaries: ['pi'], searchDirs: [HOME_DIRS.local, HOME_DIRS.usrLocal, HOME_DIRS.bunBin, HOME_DIRS.npmGlobal, HOME_DIRS.homeBin], // pi is a generic binary name (Raspberry Pi tooling, personal scripts), so a `which` // hit alone is not evidence of the right program — require the version match. version: { arg: '--version', regex: '(?:^|\\s)(\\d+\\.\\d+\\.\\d+)', requireVersionMatch: true }, install: { command: { linux: 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent', darwin: 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent', }, npmPackage: '@earendil-works/pi-coding-agent', docsUrl: 'https://pi.dev', agentImageLayer: { kind: 'dedicated', reason: 'installed with --ignore-scripts in its own layer, so the flag cannot leak to the shared block', }, }, }, launch: { params: { approveProjectTrust: { type: 'bool' }, model: { type: 'token', pattern: 'model-pi' }, provider: { type: 'token', pattern: 'slug' }, thinking: { type: 'enum', values: ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] }, resumeId: { type: 'token', pattern: 'id-dotted' }, continueSession: { type: 'bool' }, }, variants: [ { id: 'default', args: [ { lit: 'pi' }, { flag: '--approve', when: { param: 'approveProjectTrust', is: true } }, { flag: '--no-approve', when: { param: 'approveProjectTrust', is: false } }, { flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } }, { flag: '--provider', valueFrom: 'provider', when: { param: 'provider', state: 'set' } }, { flag: '--thinking', valueFrom: 'thinking', when: { param: 'thinking', state: 'set' } }, { flag: '--session', valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } }, { lit: '-c', when: { allOf: [ { param: 'continueSession', is: true }, { param: 'resumeId', state: 'unset' }, ], }, }, ], }, ], legacyConfigAliases: { resumeId: 'resumeSessionId' }, legacyConfigField: 'piConfig', resumeAppend: { style: 'flag', flag: '--session' }, }, env: { exports: [{ name: 'COLORTERM', value: 'truecolor' }], unset: ['NO_COLOR'], // Pi's ~34 provider keys share no common prefix, so they are deliberately NOT // allowlisted here — same reasoning as today's PI_ only prefix. Pi users authenticate // via `/login` or the server process's own env. tmuxSetenvKeys: [], dockerExecEnvNames: [], allowedPrefixes: ['PI_'], allowedKeys: [], }, capabilities: { ...agentDefaults(), altScreen: 'preserve', // pi's TUI renders into the main screen with terminal-owned scrollback echo: { policy: 'buffer', anchor: { kind: 'cursor' } }, // pi's absent-config default is an interactive trust PROMPT the session user could // just answer "yes" to, so omitting --approve is not itself a clamp — MATERIALIZE // approveProjectTrust:false so buildPiCommand emits --no-approve outright. privilegedParams: [{ param: 'approveProjectTrust', clampTo: false, materializeWhenAbsent: true }], // CORRECTED after live-testing: `PI_CONFIG_DIR` does NOT exist anywhere in pi's own // bundled source (grepped the installed package directly) — it does nothing for pi // itself, despite being a real Codeman env var that OTHER things (omp) read. The // confirmed working redirect is `HOME` itself: pi hardcodes `~/.pi/agent/models.json` // with no dedicated override, so redirecting the CHILD PROCESS's HOME is what // actually relocates it (verified: a model written under an isolated HOME's // `.pi/agent/models.json` shows up in `pi --list-models` and answers a real prompt // against a real llama-swap server; PI_CONFIG_DIR alone left it silently unable to // see any provider). ⚠️ This is a bigger blast radius than a dedicated config-dir // var: it also redirects pi's real sessions/auth/extensions for the DURATION of a // custom-model session, not just its provider config — document this trade-off // wherever this capability is surfaced. customModelInjection: { kind: 'configDir', dirEnvVar: 'HOME', fileName: '.pi/agent/models.json', template: 'pi-models-json', // Writing models.json is not enough: without `--model custom/` pi stays on its // own default provider and fails with "No API key found for the selected model" // (confirmed live). `custom` is the provider name pi-models-json declares. launchModel: 'custom/{modelId}', }, // HOME is not `PI_`-prefixed, so unlike the old (wrong) PI_CONFIG_DIR guess this was // never reachable via the generic envOverrides allowlist at all — listed here anyway, // matching the documented pattern for every other CLI's dir-redirect var, since a // redirected HOME is at least as sensitive as CODEX_HOME/GROK_HOME (pi executes // repo-local .pi/extensions TypeScript — see the External CLI modes note in CLAUDE.md). privilegedEnvKeys: ['HOME'], }, overlays: { credStore: { rel: '.pi/agent', seedFiles: ['auth.json', 'settings.json', 'trust.json', 'models.json', 'models-store.json'], }, }, }; // Grok Build (xAI, `grok`). Transcribed from the hand-written buildGrokCommand into // registry data; enabled by default, like every other shipped mode. const GROK: CliEntry = { id: 'grok' as CliEntry['id'], label: 'Grok', shortBadge: 'GK', // Upstream hand-authored a charcoal GRADIENT across 4+ CSS spots (welcome button, tab // badge, run-mode dot, mobile skin overrides) rather than one flat colour; our registry's // `accent` is a single hex, so this is the closest single value (the run-mode-dot colour, // zinc-400). Nothing reads `accent` yet — the frontend is untouched in this change and // keeps its own hand-authored CSS; the field is here so the entry is complete. accent: '#d4d4d8', enabled: true, stock: true, order: 70, kind: 'agent', discovery: { binaries: ['grok'], searchDirs: ['~/.grok/bin', HOME_DIRS.local, HOME_DIRS.usrLocal, HOME_DIRS.homeBin], // `grok` has a known npm squatter (@vibe-kit/grok-cli also installs a `grok` bin), so a // bare `which grok` hit is not evidence of the right program — same defence as pi, // byte-identical regex. version: { arg: '--version', regex: '(?:^|\\s)(\\d+\\.\\d+\\.\\d+)', requireVersionMatch: true }, install: { command: { linux: 'curl -fsSL https://x.ai/cli/install.sh | bash', darwin: 'curl -fsSL https://x.ai/cli/install.sh | bash', }, // Not on npm — xAI ships a standalone installer/binary, same shape as Antigravity. docsUrl: 'https://github.com/xai-org/grok-build', }, }, launch: { params: { alwaysApprove: { type: 'bool' }, model: { type: 'token', pattern: 'model' }, resumeId: { type: 'token', pattern: 'id-dotted' }, continueSession: { type: 'bool' }, }, variants: [ { id: 'default', args: [ { lit: 'grok' }, { flag: '--always-approve', when: { param: 'alwaysApprove', is: true } }, { flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } }, { flag: '--resume', valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } }, { lit: '--continue', when: { allOf: [ { param: 'continueSession', is: true }, { param: 'resumeId', state: 'unset' }, ], }, }, ], }, ], legacyConfigAliases: { resumeId: 'resumeSessionId' }, legacyConfigField: 'grokConfig', resumeAppend: { style: 'flag', flag: '--resume' }, }, env: { exports: [{ name: 'COLORTERM', value: 'truecolor' }], unset: ['NO_COLOR'], // No tmuxSetenvKeys: XAI_API_KEY (xAI's documented headless auth var) is covered by the // XAI_ prefix allowlist below, same "rely on the prefix, not an explicit key list" // reasoning as pi's ~34 provider keys. tmuxSetenvKeys: [], dockerExecEnvNames: [], allowedPrefixes: ['GROK_', 'XAI_'], allowedKeys: [], }, capabilities: { ...agentDefaults(), // Fullscreen alt-screen TUI with mouse support — same shape as opencode/antigravity: // only the tmux-attach-time smcup strip, not Ink's full erase-scrollback+DECSET strip. altScreen: 'strip-mux-only', // Buffer-policy fallthrough default, unmeasured against an authenticated grok composer // (the existing hedge, preserved verbatim) — same as gemini/antigravity/pi. echo: { policy: 'buffer', anchor: { kind: 'cursor' } }, // codex/antigravity-shaped clamp: grok's own bare-spawn default (no config sent) is // already its safe interactive ask-mode, so the multi-user clamp only needs to force an // EXPLICITLY-SENT bypass flag back off — nothing is materialized when config is absent. privilegedParams: [{ param: 'alwaysApprove', clampTo: false }], // CORRECTED after live-testing against a real grok binary: the original `env` kind // (GROK_BASE_URL/GROK_MODEL/XAI_API_KEY) produced "Not signed in" — those env vars // are NOT grok's real custom-endpoint mechanism. The real one (verified against // xAI's own docs) is a `[model.]` block in a config.toml under GROK_HOME, // the same configDir shape as codex/pi/omp. `api_backend = "chat_completions"` is // explicitly supported (unlike codex, which dropped it) — grok CAN talk to a plain // OpenAI Chat-Completions server directly. customModelInjection: { kind: 'configDir', dirEnvVar: 'GROK_HOME', fileName: 'config.toml', template: 'grok-toml', // The `[model.]` block the grok-toml template writes; `--model ` is what // selects it (GROK_CUSTOM_MODEL_NAME in custom-model-injection.ts, pinned equal by // test/custom-model-injection.test.ts so the two cannot drift). launchModel: 'codeman-custom', }, // GROK_HOME already matches the GROK_ allowedPrefix above, so it was ALREADY // reachable via plain envOverrides before this feature existed — same reasoning // as CODEX_HOME: a redirected config dir can restate policy the argv-level // `alwaysApprove` clamp above cannot see. privilegedEnvKeys: ['GROK_HOME'], }, overlays: { // ~/.grok also holds sessions/, memory/, downloads/ (the ~160MB binary), completions/, // docs/, bin/ — per-file seeding like pi's credStore, not a whole-dir seedWhole copy. credStore: { rel: '.grok', seedFiles: ['auth.json', 'config.toml', 'pager.toml'] }, // No remote/docker overlay needed: the defaults (exec grok / login-shell `grok`) are // already correct — verified against upstream's own pinned test/grok-mode.test.ts // expectation `exec "${SHELL:-/bin/sh}" -i -l -c 'grok'`. }, }; // DeepSeek Harness (`dsh`, deepseek-ai/deepseek-harness). The awkward one, and worth // reading before assuming it looks like its siblings — it breaks four of this catalog's // normal assumptions at once, which is why the schema carries four extensions for it: // // 1. `dsh` is a PROFILE LAUNCHER, not the agent. It boots $DSH_HOME/profiles/, and // DeepSeek ships only `web`/`headless`/`base`, none of which can drive a terminal // pane — so the terminal front door is ALWAYS third-party and "installed" is not // "runnable". Hence `discovery.launcherProfile`. // 2. Its permission switch is the `DSH_PERMISSION_MODE` ENV VAR, not a flag — the // harness has none. Hence `env.configSetenv` (so the ordinary privilegedParams clamp // still reaches it) plus `capabilities.privilegedEnvKeys` (so an envOverrides send // cannot hand the privilege straight back). // 3. It is the only non-claude mode with real hook signals, and for it alone that is a // per-SESSION question. Hence `hooks: 'supervised'`. // 4. Its transcript is zstd session files, one frame per write. Hence // `transcript: 'deepseek-zstd'`. // // The identity probe is the strictest in the catalog for a sharper reason than pi's or // grok's npm squatters: Debian ships an unrelated `dsh` (dancer's shell, `apt install // dsh`) that would pass a version probe perfectly happily. const DEEPSEEK: CliEntry = { id: 'deepseek' as CliEntry['id'], label: 'DeepSeek', shortBadge: 'DS', accent: '#7c93ff', enabled: true, stock: true, order: 80, kind: 'agent', discovery: { binaries: ['dsh'], searchDirs: [HOME_DIRS.local, HOME_DIRS.usrLocal, HOME_DIRS.npmGlobal, HOME_DIRS.homeBin], // Checked BEFORE the version probe: dancer's shell answers --version happily, so a // version match alone would accept it. identity: { arg: '--help', regex: 'DeepSeek\\s+Harness' }, // Keeps the `-rc.2` prerelease tail — dsh ships them, and the `codeman doctor` row // shares this regex so the two cannot disagree about what version a binary reports. version: { arg: '--version', regex: '(?:^|\\s)v?(\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?)', requireVersionMatch: true, }, launcherProfile: 'deepseek-profile', launcherTargetParam: 'profile', install: { command: { linux: 'npm install -g @deepseek-ai/dsh', darwin: 'npm install -g @deepseek-ai/dsh', }, npmPackage: '@deepseek-ai/dsh', docsUrl: 'https://github.com/deepseek-ai/deepseek-harness', agentImageLayer: { kind: 'dedicated', reason: 'needs pnpm alongside it (dsh plugin, issue #352) and a dsh-tui profile install', }, }, }, launch: { params: { // A single path segment: interpolated into the shell line AND joined into a // filesystem path, so `path-segment` rather than the looser `id-dotted`. profile: { type: 'token', pattern: 'path-segment' }, // Resolved at spawn time from what is actually installed — see launcherProfile. defaultProfile: { type: 'engine', source: 'launcherDefaultTarget' }, resumeId: { type: 'token', pattern: 'id-dotted' }, resumeSession: { type: 'bool' }, // Never appears in argv. Declared so `configSetenv` can export it and, more to the // point, so `privilegedParams` can clamp it — see capabilities below. permissionMode: { type: 'enum', values: ['read-only', 'workspace-write', 'danger-full-access'] }, // Never appears in argv either; read by the status-bridge setenv profile. statusReporting: { type: 'bool' }, }, variants: [ { id: 'default', args: [ { lit: 'dsh' }, { flag: '--profile', valueFrom: 'profile', when: { param: 'profile', state: 'set' } }, // An invalid profile name resolves to undefined, so `profile` reads as UNSET and // this arm takes over — reproducing the hand-written builder's fall back to the // resolved default rather than failing the spawn outright. { flag: '--profile', valueFrom: 'defaultProfile', when: { allOf: [ { param: 'profile', state: 'unset' }, { param: 'defaultProfile', state: 'set' }, ], }, }, // The launcher forwards everything after its own flags to the profile's app, // which is where --resume is understood. An explicit id wins over the // most-recent-session form, mirroring the sibling builders. { flag: '--resume', valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } }, { flag: '--resume', when: { allOf: [ { param: 'resumeId', state: 'unset' }, { param: 'resumeSession', is: true }, ], }, }, ], }, ], legacyConfigAliases: { resumeId: 'resumeSessionId' }, legacyConfigField: 'deepSeekConfig', resumeAppend: { style: 'flag', flag: '--resume' }, }, env: { exports: [{ name: 'COLORTERM', value: 'truecolor' }], unset: ['NO_COLOR'], // DEEPSEEK_BASE_URL is forwarded from the SERVER's own env alongside the API key, // which is exactly why a non-granted owner may not override it — see privilegedEnvKeys. tmuxSetenvKeys: ['DEEPSEEK_API_KEY', 'DEEPSEEK_BASE_URL', 'DSH_HOME'], dockerExecEnvNames: [], configSetenv: [{ name: 'DSH_PERMISSION_MODE', fromParam: 'permissionMode' }], // Only the vendor namespaces. A dsh settings.yaml can nominate ANY env var as a // provider credential (`apiKeyEnv`), so admitting foreign provider keys here would // widen one GLOBAL allowlist for every mode at once — the same lesson pi taught. allowedPrefixes: ['DSH_', 'DEEPSEEK_'], allowedKeys: [], setenvProfile: 'deepseek-status-bridge', }, capabilities: { ...agentDefaults(), // Definitive rather than inferred: the harness TUI reports idle/working/blocked to a // supervisor and Codeman is that supervisor. 'supervised' rather than 'always' because // the session can disarm the bridge, and docker/remote cannot reach it at all. hooks: 'supervised', transcript: 'deepseek-zstd', altScreen: 'strip-mux-only', echo: { policy: 'buffer', anchor: { kind: 'cursor' } }, // Model is NOT a session field for dsh — it is a profile composition entry. model: { source: 'none' }, // Only-if-sent, like codex/antigravity/grok: an ABSENT permissionMode means the // launcher's own default, `workspace-write`, which already asks. Clamping to // `read-only` instead would break the workspace rather than protect it. privilegedParams: [{ param: 'permissionMode', clampTo: 'workspace-write' }], // The half no other CLI needs. `DSH_*` is an allowlisted envOverrides prefix and // applyEnvOverrides() runs LAST, so without this a non-granted owner could send // DSH_PERMISSION_MODE on the same request and land after the config clamp. // ⚠️ DEEPSEEK_API_KEY deliberately stays OUT of this list (see the docstring on // clampEnvOverridesForOwner() in session-routes.ts): _configureCliEnv() forwards the // SERVER's own key into every dsh pane, so DEEPSEEK_BASE_URL is the exfiltration // vector, not the key itself — a non-granted owner supplying THEIR OWN key removes // privilege rather than granting it, and clamping it here was a real regression // (test/deepseek-mode.test.ts) fixed before this shipped. privilegedEnvKeys: ['DSH_PERMISSION_MODE', 'DSH_HOME', 'DEEPSEEK_BASE_URL'], // Reuses the already-existing DEEPSEEK_BASE_URL/DEEPSEEK_API_KEY keys above. No // modelVars — dsh's model is a profile-composition entry (see `model: { source: 'none' // }` above), not an env var, so forcing a specific model name may not fully work; // verify against a real profile before shipping. // // ⚠️ appendV1Suffix is REQUIRED, not optional-nice-to-have: without it every request // 404s. Confirmed live and by reading dsh's own bundled source // (@deepseek-ai/dsh-llm-deepseek): it builds the request URL as // `${DEEPSEEK_BASE_URL}/chat/completions` with no "/v1" of its own (its real public // API, https://api.deepseek.com, expects the caller's base URL to already carry any // needed prefix), while llama-swap/llama.cpp only serves the OpenAI-conventional // "/v1/chat/completions" — a bare POST to ".../chat/completions" 404s live, and the // 404 reported here originally ("dsh: HTTP_404: DeepSeek API error (HTTP 404)") // matches dsh's own error-message template for exactly this failure. See the // customModelInjection doc comment in cli-registry/types.ts for the full reasoning, // including why claude/gemini must NOT get this. customModelInjection: { kind: 'env', baseUrlVar: 'DEEPSEEK_BASE_URL', apiKeyVar: 'DEEPSEEK_API_KEY', modelVars: [], appendV1Suffix: true, }, }, overlays: { // No credStore: dsh keeps everything under $DSH_HOME (default ~/.dsh), which is // forwarded as a plain env var above rather than seeded as a credential directory. }, }; // OMP (`omp`, omp.sh). The plainest entry in the catalog after opencode: no permission // flags at all — omp reads its model routing and hooks from `~/.omp/agent`, so the CLI's // own config governs and there is deliberately nothing bypass-shaped to clamp. Its only // privileged surface is a pair of ENV keys (see privilegedEnvKeys below). const OMP: CliEntry = { id: 'omp' as CliEntry['id'], label: 'OMP', shortBadge: 'OM', accent: '#818cf8', enabled: true, stock: true, order: 90, kind: 'agent', discovery: { binaries: ['omp'], // `~/.local/bin` leads: omp.sh's installer targets it with no `--dir` override // (verified against a real `--no-cache` docker build); `~/.omp/bin` is a defensive // fallback only. searchDirs: [ HOME_DIRS.local, '~/.omp/bin', HOME_DIRS.usrLocal, HOME_DIRS.bunBin, HOME_DIRS.npmGlobal, HOME_DIRS.homeBin, ], // A real `omp --version` prints `omp/`. `omp` is another short generic name, so // the `omp/` prefix is what distinguishes the coding agent from anything else of that // name — same defence as pi and grok, one notch stricter because the prefix is checked. version: { arg: '--version', regex: '(?:^|\\s)omp/(\\d+\\.\\d+\\.\\d+)', requireVersionMatch: true }, install: { command: { linux: 'curl -fsSL https://omp.sh/install | sh', darwin: 'brew install can1357/tap/omp', }, docsUrl: 'https://omp.sh', }, }, launch: { params: { model: { type: 'token', pattern: 'model' }, resumeId: { type: 'token', pattern: 'id-dotted' }, continueSession: { type: 'bool' }, }, variants: [ { id: 'default', args: [ { lit: 'omp' }, { flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } }, // `--resume` and `--continue` conflict; a valid explicit id wins, mirroring the // sibling builders (grok/pi/opencode). { flag: '--resume', valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } }, { lit: '--continue', when: { allOf: [ { param: 'continueSession', is: true }, { param: 'resumeId', state: 'unset' }, ], }, }, ], }, ], legacyConfigAliases: { resumeId: 'resumeSessionId' }, legacyConfigField: 'ompConfig', resumeAppend: { style: 'flag', flag: '--resume' }, }, env: { exports: [{ name: 'COLORTERM', value: 'truecolor' }], unset: ['NO_COLOR'], // omp's provider credentials live in `~/.omp` config files, not env vars, so there is // nothing for the server to forward into the pane. tmuxSetenvKeys: [], dockerExecEnvNames: [], allowedPrefixes: ['OMP_'], allowedKeys: [], }, capabilities: { ...agentDefaults(), // Fullscreen alt-screen TUI, same shape as opencode/antigravity/grok: only the // tmux-attach-time smcup strip, not Ink's full erase-scrollback+DECSET strip. altScreen: 'strip-mux-only', // Codeman reads omp's own `~/.omp/agent/sessions/**/*.jsonl` host-side, which is what // makes an omp conversation survive a full session kill. transcript: 'omp-jsonl', echo: { policy: 'buffer', anchor: { kind: 'cursor' } }, // No permission prompts and no bypass flag, so nothing config-shaped to clamp — the // whole privileged surface here is env-shaped. privilegedParams: [], // Where omp resolves its auth from. No known concrete exfiltration path today (omp // forwards no operator-held key into a pane), but a non-granted owner redirecting where // a shared multi-tenant deployment resolves auth is not something to allow silently. // HOME added for custom-model-injection.ts's omp recipe (see below). Unlike pi, // PI_CONFIG_DIR genuinely IS one of the env vars omp reads (per the DeepSeek/OMP // note in CLAUDE.md) — but live-testing this feature found it did NOT relocate // omp's model config the way expected, while redirecting HOME itself (like pi) // worked immediately (verified end-to-end: a real "hello world" reply came back). privilegedEnvKeys: ['OMP_AUTH_BROKER_URL', 'OMP_AUTH_BROKER_TOKEN', 'HOME'], // Verified end-to-end against a real llama-swap server (live-tested, not just // researched — a real "hello world" reply came back). Same HOME-redirect mechanism // as pi (see its customModelInjection comment for the full reasoning) — omp hardcodes // `~/.omp/agent/models.yml` with no dedicated config-dir override either. customModelInjection: { kind: 'configDir', dirEnvVar: 'HOME', fileName: '.omp/agent/models.yml', template: 'omp-models-yml', // Same as pi: omp's own default model has no credential, so without an explicit // `--model custom/` it never reaches the injected provider at all. launchModel: 'custom/{modelId}', }, }, overlays: { // `~/.omp/agent` also holds agent.db/history.db/models.db (SQLite caches) and // terminal-sessions/blobs/cache (large, regenerable), so only the config files are // seeded. UNLIKE pi/grok, `sessions/` is SHARED (RW) rather than host-invisible: // Codeman reads it HOST-SIDE for history recovery and `--resume` pinning, the same // reason codex's `sessions/` is shared — without it an in-container omp conversation // would be invisible to Codeman's own resume logic. credStore: { rel: '.omp/agent', shareDirs: ['sessions'], seedFiles: ['config.yml', 'mcp.json', 'models.yml', 'settings.yml'], }, }, }; /** The full stock catalog, in the order the run menu shows by default. */ export const STOCK_CLIS: CliEntry[] = [CLAUDE, SHELL, OPENCODE, CODEX, GEMINI, ANTIGRAVITY, PI, GROK, DEEPSEEK, OMP];