Files
Codeman/src/config/cli-registry/stock.ts
T
Codeman maintainer 1709b0f04e feat(jcode): add jcode as a run mode
jcode (1jehuang/jcode) joins the CLI registry as the `jcode` run mode:
Run menu, phone overview, TUI, quick-start and POST /api/sessions
(`jcodeConfig: { model?, resumeSessionId? }`), local, SSH and Docker
(dedicated agent-image layer), plus codeman doctor and install.sh
through the generated catalogue. Measured on a live jcode 0.98.1 pane.

- Work detection reads jcode's braille status row and `<n>>` composer;
  alt screen with mouse strip and PageUp/PageDown paging like opencode.
- Every jcode TUI is a client of one shared per-user daemon whose tools
  run with the daemon's env, so no agent skill injection and no JCODE_*
  envOverrides prefix.
- New `capabilities.sharedDaemon`: a session kill spares descendants
  outside the pane's process group, so closing the tab that started the
  daemon no longer cuts every other jcode session off (measured).
- last-response answers an honest empty for an external CLI with no
  transcript reader that the pane segmenter cannot read (jcode alone).
- GET /api/jcode/status via the generic registry resolver.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 17:04:28 +02:00

1805 lines
91 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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';
import { CODEX_REASONING_EFFORTS } from '../../types/session.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,
};
}
/**
* The agent skill in `<workspace>/.agents/skills/codeman`, the one project skills
* directory every CLI that spreads this scans. Measured per CLI on
* 2026-10-11 against the installed builds or their source: codex 0.162 (every dir from
* the git root down to cwd), gemini (cwd only, behind folder trust, which `--skip-trust`
* already answers), opencode 1.3 (cwd up to the worktree), agy 1.3.2 (its `.agents/`
* customization root), grok 1.0.5 (each tier from cwd up to the repo root), dsh 0.1.1
* (the git root, else cwd) and omp (cwd up to the git root). Claude Code does NOT read
* `.agents/skills`, so a case shared with a claude session lists each skill once there.
*/
const AGENTS_DIR_SKILL: Pick<CliEntry['capabilities'], 'agentSkillInjection' | 'agentSkillDir'> = {
agentSkillInjection: true,
agentSkillDir: '.agents/skills',
};
// `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-<id>`
// 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. The one exception is GEMINI, whose
// run-button border (#60a5fa) is the only one that disagrees with its own tab badge
// and run-mode dot (#8ab4f8); it takes the badge colour, so every accent names the
// same hex the frontend uses as that CLI's flat identity. 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 Code',
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 <level>` 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 `<Mode>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. Claude and codex are the only
// entries that declare this, because theirs are the only gutters that have been measured.
transcriptGutter: 2,
// The composer's own hint text (`⏵⏵ … (shift+tab to cycle)`), not `❯`, which the
// trust dialog's selected row also carries. Measured by the agent skill's spawn_worker.
composerReadyMark: 'shift+tab',
// 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`.
// ⚠️ An Artifact comment monitor is the one chip that waits on the user. The agent
// has published a page and hears nothing until somebody comments on it, so the
// lookahead refuses the whole row while that chip is on it, whatever else is
// running beside it. The `^` is what makes the lookahead judge the row once:
// without it the engine retries from each later position, and a start past the
// chip reports the shell beside it. The lookahead keys on "Artifact" alone, so a
// footer cut off mid-chip (`· 1 Artifact…`, `· 1 Artifact comm…`) is still refused;
// no other chip on this row says "Artifact". Counting the chip as watching kept the
// idle alert quiet for a session that was waiting for a human.
watchingLine: String.raw`^(?!.*Artifact).*?·\s*(\d+ (?:monitors?|shells?|teams?|local agents?|cloud sessions?|MCP tasks?|background tasks?|(?:background|remote) dynamic workflows?))`,
// When a turn ends while background agents or an ultracode workflow are still
// running, Claude swaps its `✻ Brewed for 1m 18s` closing row for
// `✻ Waiting for 2 background agents and 1 dynamic workflow to finish` and resumes
// by itself when they report back. Read from the 2.1.283 bundle (the turn-duration
// renderer) and a live pane on 2026-09-28. The row is a snapshot taken at turn end
// and never redrawn, which is why only the newest row above the composer counts.
// Anchored on column 0: Claude's own rows start there, the agent's prose never does.
awaitingLine: String.raw`^✻ Waiting for \d+ (?:background agents?|dynamic workflows?)\b`,
},
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 } },
// Declared-for-later: the live rule (`_shouldForwardWheelToApp`, terminal-ui.js) is this version
// AND the server-published `cliMouseTracking` flag (#498), so wiring this field up needs both.
wheelForward: { mode: 'version-gated', minVersion: '2.1.187' },
// A repaint-mode Claude pane keeps no local history, so when the wheel is not
// forwarded (older CLI, inline renderer, the local-scrollback opt-out) a scroll is
// turned into PageUp/PageDown, which Claude pages its transcript on (#205).
transcriptPageKeys: true,
keyboardAccessory: 'agent',
privilegedCommandGate: false,
startMode: 'interactive',
stripInkBloat: true,
ralph: true,
respawn: true,
effort: true,
agentSkillInjection: true,
agentSkillDir: '.claude/skills',
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 } },
// claude reads `$CLAUDE_CONFIG_DIR/.claude.json` when that is set (checked in 2.1.289).
mcpConfig: {
path: '.claude.json',
format: 'claude-json',
relocation: { envVar: 'CLAUDE_CONFIG_DIR', path: '.claude.json' },
},
// 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 '<command>'` 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(),
...AGENTS_DIR_SKILL,
altScreen: 'strip-mux-and-mouse',
echo: { policy: 'buffer', anchor: { kind: 'cursor' }, predictProfile: undefined },
// Pages its own transcript on PageUp/PageDown (`messages_page_up/down`) and ignores
// SGR wheel reports, and its TUI runs on the alternate screen (1.18.31: tmux
// `alternate_on=1`, `history_size=0`), so a pane keeps no local history and the
// wheel is turned into page keys or it does nothing at all (#555).
transcriptPageKeys: true,
// Measured on a live opencode 1.3.0 pane (capture-pane every 250-300 ms through real
// turns at 40, 60, 120 and 200 columns, plus the raw PTY stream, 2026-10-09). Every
// composer row starts with a `┃` bar, and the submitted prompt lands in the transcript
// with the same bar, so a turn's first repaint arms the idle confirmation and tmux's
// reattach repaint does the same for a restored pane. While a turn runs the footer row
// starts with an 8-cell knight-rider spinner, `⬝■■■■■■⬝ esc interrupt`, redrawn about
// every 40 ms (never a 2.5 s gap mid-turn, so silence cannot end one early); at rest the
// row holds only the key hints and nothing on screen draws a `⬝`/`■` run, the wide
// layout's sidebar included. The working line is the spinner run, not the label: tmux
// ships `esc` and `interrupt` as separate words joined by cursor moves, and below about
// 45 columns the footer wraps the label itself. A pending permission prompt replaces
// the composer and stops the spinner, so it reads as idle (waiting on the user).
// ⚠️ Without this entry an opencode session latched `busy` after any turn that ran a
// tool: the braille spinner on a running tool row trips SPINNER_PATTERN, and opencode
// never draws Claude's `❯`, the fallback that would have armed the idle check. The
// last `┃` row on screen is the composer's agent/model row (or the permission box's
// closing bar), never the prompt text, so the submit verifier stands down.
workDetect: {
promptGlyph: '┃',
workingLine: '[⬝■]{8}',
},
// The composer's agent row, measured on live opencode 1.3.0 panes (home screen and in
// session, at 40, 60, 120 and 200 columns, 2026-10-09): `┃ Build Big Pickle OpenCode
// Zen`, directly above the box's bottom edge `╹▀▀▀`. opencode renders it as the agent,
// then the model's name, then the provider's name (then `· <variant>` when the model
// has one), and only colour tells model from provider, so the field is all of it: what
// opencode itself shows, owner's choice. A double space ends it, which is where the
// 200-column layout's sidebar shares the row. The lookahead takes the LAST such row in
// the window, so nothing the agent prints higher up can stand in for it; below the
// composer there is only opencode's own chrome (key hints, a tip, the cwd/version
// row), which is why the window can be 8 rows: the home screen puts up to 5 of those
// rows under it. A permission prompt or shell mode hides the row, and the last model
// is kept. `No provider ` is opencode's placeholder before a provider is connected.
modelDetect: {
screenLine: String.raw`┃ {2}[^\s·]+ {2}(?!No provider )([^ \n](?:[^ \n]| (?! ))*)(?: {2}.*)?\n *╹(?![\s\S]*\n *╹)`,
screenLines: 8,
},
// opencode's global config dir is xdg-basedir's `$XDG_CONFIG_HOME/opencode`.
mcpConfig: {
path: '.config/opencode/opencode.json',
altPath: '.config/opencode/opencode.jsonc',
format: 'opencode-json',
relocation: { envVar: 'XDG_CONFIG_HOME', path: 'opencode/opencode.json' },
},
// 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' },
reasoningEffort: { type: 'enum', values: [...CODEX_REASONING_EFFORTS] },
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' } },
// One literal per level: an argv token cannot splice a value into a literal, and
// `model_reasoning_effort=<level>` is a single `--config` value. The enum above is
// what admits a level, so an unknown one emits nothing.
...CODEX_REASONING_EFFORTS.map((level) => ({
flag: '--config',
value: `model_reasoning_effort=${level}`,
when: { param: 'reasoningEffort', is: level },
})),
{ 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(),
...AGENTS_DIR_SKILL,
// 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`.
// ⚠️ Codex 0.162.0 (measured 2026-10-09) draws a hint row under the status line at
// rest (` ← for agents · ? for shortcuts`) and drops it while a prompt is typed, so
// the chip is FOURTH from the bottom at rest and third while typing. A three-row
// window never saw it at rest, which is exactly when the idle probe reads it, so a
// session waiting on its terminal read as plainly idle. Four rows cover both.
// ⚠️ This entry CANNOT promise what Claude's does, and the difference is Codex's
// layout rather than its pattern. The fourth 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 (and while a prompt is typed, the last two). 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 IDLE prompt from a codex
// session ever reaches `notePrompt()` (its screen-read dialogs below open PERMISSION
// items, which a watching label never acknowledges), so there is no idle item to
// pre-acknowledge and a forged label costs a wrong badge and nothing else. A CLI that
// gains idle 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.
// Codex is the one CLI Codeman launches with its approvals on by default, and it has no
// hook to say it stopped for one, so its dialogs are read off the screen. Measured on a
// live codex-cli 0.162.0 pane on 2026-10-14 (`-a on-request -s read-only`, widths 200,
// 80, 60, 44 and 36): every dialog it blocks on REPLACES the composer and its footer
// with a numbered option list, the selected one marked `›`, over a key-hint row:
// command approval (a network `› 1. Yes, proceed (y)` ...
// escalation too), edit approval
// ` Press enter to confirm or esc to cancel`
// question (request_user_input) ` › 1. Blue Write the color blue.` ...
// ` tab to add notes | enter to submit answer | esc to interrupt`
// (its notes field open) ` tab or esc to clear notes | enter to submit answer`
// workspace trust, at startup `› 1. Trust and continue`
// ` enter continue · esc quit`
// (the same prompt reached after the ` 2. Back to Agent Command Center`
// update prompt, 0.162.0, 2026-10-16) ` enter continue · esc back`
// The pattern needs both: a `› N.` option row, then the key-hint row, which must END
// the window. Below about 60 columns that hint wraps onto rows of its own
// (` Press enter to confirm or esc to` / ` cancel`, or `tab to add notes` /
// `enter to submit answer` / `esc to interrupt` one per row), so the hint may run on
// over rows that start with two spaces and a lowercase word or arrow, and must end on
// one of its own closing words. The transcript can quote every one of those strings
// (measured: an answer that printed them verbatim) and still not match, because while
// no dialog is up codex keeps its composer row (`› ...`, column 0, which ends the
// run) and its footer below anything the agent prints. The longest option list
// measured, a question at 36 columns, put the `›` row 16 non-blank rows up; 20 leaves
// room for longer option text, and a dialog whose marker scrolls out of the window is
// simply not seen (the behaviour before this field). Not matched on purpose:
// `Implement this plan?` after a plan turn, whose `enter select · esc back` footer is
// the same as `/model`, `/permissions` and `/review`, menus the user opened at the
// keyboard; the plan turn has ended there, and the session reads idle as before.
// ⚠️ The question dialog's hint contains `esc to interrupt`, the working line, so
// such a pane read as WORKING before this field: a blocked screen now wins over it.
workDetect: {
promptGlyph: '›',
workingLine: '[Ee]sc to interrupt',
watchingLine: String.raw`^\s{0,4}(\d+ background terminals?) running · /ps to view · /stop to close$`,
watchingLines: 4,
blockedLine: String.raw`(?:^|\n) {0,4}› \d\. [\s\S]*\n {2}(?:Press enter to confirm|tab (?:to add|or esc to clear) notes|enter continue)(?:[^\n]|\n {2}(?=[a-z←])){0,120}(?:cancel|interrupt|quit|back|answer|all)$`,
blockedLines: 20,
},
// The footer under the composer, measured on a live 0.147.0 pane:
// ` gpt-5.6-terra default · ~/codeman-cases/th-scratch` (model, reasoning effort,
// cwd). It sits below the composer, so the transcript never reaches it, and the
// effort word right after the model is codex's own format: an open slash-command
// popup or a bare line of prose does not have that shape. A footer without an effort
// word (a model with no reasoning setting) is not read, and the session keeps its
// last known or launch model.
// The effort words are built from CODEX_REASONING_EFFORTS, the same list the
// `reasoningEffort` launch param above admits, plus `default` (what codex prints when
// no effort is configured). A hand-kept copy once left out `ultra`, so a session at
// that level never named its model. Every word is plain letters, so the join adds no
// quantifier and only a few characters to the 200-character compileVersionRegex cap.
// ⚠️ It is not always the LAST row. 0.162.0 (measured 2026-10-09) adds a hint row
// under it at rest, ` ← for agents · ? for shortcuts` or ` ? for shortcuts`, and
// drops it again while a prompt is being typed. With a one-row window the footer was
// never seen and every codex tile showed no model. So the window is two rows and the
// footer is either the last one or followed by exactly one more two-space-indented
// row. The `$` (no `m` flag: the end of the window) is what keeps the guard the
// one-row rule had: with the footer hidden, the last two rows are a transcript line
// and the `›` composer, and a forged footer-shaped transcript line is not followed by
// an indented row, so it is not read.
modelDetect: {
screenLine: String.raw`(?:^|\n) {2}([A-Za-z0-9][\w.:/@+-]{0,79}) (?:${[...CODEX_REASONING_EFFORTS, 'default'].join('|')}) · [^\n]*(?:\n {2}[^\n]*)?$`,
screenLines: 2,
},
// App Settings → Codex model / reasoning effort (synced), filled into a LOCAL launch's
// codexConfig wherever the caller left the field unset. Launch-only: nothing writes
// codex's own config.toml. Read by applyLaunchDefaults() in src/web/launch-defaults.ts.
launchDefaults: { model: 'codexModel', reasoningEffort: 'codexReasoningEffort' },
// 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
// ...but it DOES page its transcript on PageUp/PageDown. Measured on 0.157.1 (local,
// alternate screen inside tmux, so xterm holds no history) and 0.160 (remote): PageUp
// moved the visible transcript while SGR wheel reports and local scrolling did nothing.
// This is the page-key fallback for a hollow pane, not wheel forwarding.
transcriptPageKeys: true,
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 }],
mcpConfig: {
path: '.codex/config.toml',
format: 'codex-toml',
relocation: { envVar: 'CODEX_HOME', path: 'config.toml' },
},
// 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',
// The tab badge / run-mode-dot colour, not the run-button border (see the note above CLAUDE).
accent: '#8ab4f8',
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(),
...AGENTS_DIR_SKILL,
altScreen: 'strip-full',
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
// Measured on a live Gemini CLI 0.63.0 pane (capture-pane every 300 ms through real
// turns with a shell call at 40, 120 and 200 columns, YOLO and default approval mode,
// plus the raw PTY stream, 2026-10-09). The TUI repaints its whole bottom region on
// every frame, composer included, and the composer sits between a `▄` bar and a `▀`
// bar; the submitted prompt is echoed between the same bars. So the `▀` bar arms the
// idle confirmation (every repaint and tmux's reattach repaint carry it), and it is the
// glyph rather than the composer's prompt character, which follows the approval mode
// (`*` in YOLO) and whose `>` also starts the echoed prompt. While a turn runs a line
// `⠦ Thinking... (esc to cancel, 6s)` animates about every 80 ms (largest gap mid-turn:
// 214 ms); the label can be any loading phrase, so the working line is the
// `(esc to cancel, <n>` suffix, or a spinner frame opening a line where a long phrase
// pushed that suffix onto the next one. At rest nothing on screen matches either. A tool
// confirmation (default mode) replaces the composer and stops the spinner, and the pane
// goes silent, so it reads as idle (waiting on the user).
// ⚠️ Without this entry a gemini session latched `busy` after its first turn: the braille
// spinner trips SPINNER_PATTERN, and gemini never draws Claude's `❯`, the fallback that
// would have armed the idle check. A line starting with `▀` is a bar, never prompt text,
// so the submit verifier stands down.
workDetect: {
promptGlyph: '▀',
workingLine: String.raw`\(esc to cancel, \d|(?:^|\n) ?[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] `,
},
// 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 }],
// gemini-cli's `homedir()` returns `GEMINI_CLI_HOME` when set (packages/core/src/utils/paths.ts).
mcpConfig: {
path: '.gemini/settings.json',
format: 'gemini-json',
relocation: { envVar: 'GEMINI_CLI_HOME', path: '.gemini/settings.json' },
},
// 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(),
...AGENTS_DIR_SKILL,
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 relocation var: `agy` 1.1.12 resolves `~/.gemini/config` from $HOME only.
mcpConfig: { path: '.gemini/config/mcp_config.json', format: 'antigravity-json' },
// 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(),
// No agent skill (agentSkillInjection stays false): any project-level `.agents/skills`
// or `.pi/skills` makes pi ask "Trust project folder?" at startup
// (hasTrustRequiringProjectResources, pi 1.1.0), which would stall a spawned worker on
// a dialog. pi reads the user-level `~/.agents/skills` without that prompt, so a pi
// user installs it there (`npx skills add Ark0N/Codeman --skill codeman -g`).
altScreen: 'preserve', // pi's TUI renders into the main screen with terminal-owned scrollback
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
// Measured on a live pi 1.1.0 pane (capture-pane every 250 ms through a turn,
// 2026-10-09): pi has no composer glyph. Its composer sits between two `─` rules, and
// while a turn runs it embeds its status in the TOP rule as `── ⠏ Working ───…`, the
// braille frame animating every ~80 ms; at rest both rules are plain `─`. So the rule
// is the glyph that arms the idle confirmation, and a spinner frame inside it is the
// working line (the frame, not the word: an extension can replace "Working").
// ⚠️ Without this entry a pi session never left `busy` once marked working: the
// braille spinner trips SPINNER_PATTERN, and pi never draws Claude's `❯`, the
// fallback that would have armed the idle check. The rules carry no prompt text, so
// the submit verifier reading them stands down instead of re-pressing Enter.
workDetect: {
promptGlyph: '─',
workingLine: '── [⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] ',
},
// pi's footer stats row, read from pi 1.1.0's footer code (0.84.4's is the same) and
// measured live as `0.8%/253k (auto) qwen3.8-27b-pi • xhigh`: usage and
// context on the left, then at least two spaces and `[(provider) ]<model>` followed
// by ` • <thinking>` for a reasoning model and ` → <routed model>` when routed. Only
// the last two rows are read, which sit below the composer where the transcript never
// reaches (an extension's status row may sit under the stats row), and the context
// field (`12.3%/253k`, `?/128k`) picks the stats row out of them.
// ⚠️ A narrow pane truncates the right side with NO ellipsis, leaving exactly two
// spaces of padding. So a model with nothing after it is read only with 3+ spaces in
// front; with two, only when a following ` •`/` →` proves the name is whole (the
// bullet only ever follows a complete name, even when the cut lands right after it).
// A cut name is never shown. `no-model` is pi's placeholder when none is selected.
modelDetect: {
screenLine: String.raw`[%?]/[\d.]+[kKM]?(?: \(auto\))?(?: • xp)? {2}(?: +|(?=(?:\(\S{1,40}\) )?\S{1,80} [•→]))(?:\([\w.@-]{1,40}\) )?([A-Za-z0-9][\w.:/@+-]{0,79})(?= [•→]|\n|$)`,
screenLines: 2,
rejectWords: ['no-model'],
},
// 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/<id>` 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 (zinc-300, the run-button
// border and tab-badge colour). Nothing reads `accent` yet: the frontend 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(),
...AGENTS_DIR_SKILL,
// Fullscreen alt-screen TUI with mouse support, same strip as antigravity until measured
// (opencode's mouse strip is #443): 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.<name>]` 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.<name>]` block the grok-toml template writes; `--model <name>` 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/<name>, 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: {
// pnpm rides along: `dsh plugin` spawns a literal `pnpm` with no npm fallback, so the
// terminal profile this CLI needs before it can run cannot be installed without it (#352).
// ⚠️ Only when none is on PATH. `corepack enable` puts a `pnpm` shim in node's own bin
// directory, which is npm's global bin for nvm and Homebrew, and `npm install -g pnpm`
// refuses to overwrite it (EEXIST). Asked for in the same command as dsh, that failure
// took dsh down with it, on exactly the machines that already had a working pnpm.
linux: 'npm install -g @deepseek-ai/dsh && (command -v pnpm >/dev/null || npm install -g pnpm)',
darwin: 'npm install -g @deepseek-ai/dsh && (command -v pnpm >/dev/null || npm install -g pnpm)',
},
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(),
...AGENTS_DIR_SKILL,
// 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',
// dsh's composer glyph, drawn once the harness TUI can take a prompt.
composerReadyMark: '❯',
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' },
// So the screen is where the model is known: dsh-TUI resolves the route itself
// (profile cordis.yml pin, else the persisted `/model` choice, else its default;
// lib/types/modelRoute.js) and its status line draws "the route requests actually
// take", model first (StatusLine.js; `statusBar.model` is on by default and forced
// on in minimal mode). Measured on dsh-TUI 0.10.0-beta.1: the composer's rounded box
// and, on the row right under its bottom border, ` qwen3.8-27b · medium · <cwd>`.
// The border anchors it: nothing the agent writes can sit below the composer, and a
// suggestion popup there starts with `/` or `+`, never a model id.
// ⚠ The first field is the model only while the status bar's model field is on (the
// default). Switched off, the first field is the next one (StatusLine.js): tokens per
// second (`12 t/s`) and the token count (`1.2k→3.4k`), which the pattern cannot match,
// then the reasoning effort (` medium · th-config`, measured live), then the session
// mode, then the cwd's basename. So `rejectWords` lists what those can be, from the
// dsh 0.1.1-rc.2 / dsh-TUI 0.10.0-beta.1 sources: every effort id (pi-ai's
// THINKING_LEVELS and the DeepSeek adapter's off/low/high/max), and the shipped mode
// ids. A mode's drawn label (`plan mode`, `full access`, CJK) never matches one token,
// and a field equal to the session's folder name is refused by the shared reader.
// Known gaps, all off by default: a custom mode id drawn raw, a git branch or a
// one-word session title as the first field; and the non-compact layout, whose
// left/right justification never ends a field with ` · `, so nothing is read there
// and the session shows its route config.
modelDetect: {
screenLine: String.raw`╰─+╯\n ?([A-Za-z0-9][\w.:/@+-]{0,79})(?= · |\n|$)`,
screenLines: 3,
rejectWords: ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'default', 'plan', 'full'],
// With the status bar's model field off (or before it paints), the route the
// session's profile pins, read the way dsh-TUI resolves it: src/deepseek-route-config.ts.
configResolver: 'deepseek-route',
},
// 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/<semver>`. `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(),
...AGENTS_DIR_SKILL,
// Fullscreen alt-screen TUI, same shape as 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' } },
// Measured on live omp 18.8.6 and 18.0.11 panes (2026-10-09, a turn held open against
// an endpoint that never answers): the input row is `╰─ <text>`, redrawn when a turn
// ends, at launch and on reattach. While a turn runs the status bar's leading `π`
// becomes a braille spinner plus the elapsed time (` ⠼ 14s > ⬢ model > 📁 ~/dir ▶──`;
// 18.0.11 pads it with two spaces, past a minute it reads `1m`), and a `⎋ Working…`
// row appears above it. At rest the bar starts ` π > `.
// ⚠️ Without this entry an omp session never left `busy` once marked working, like pi:
// the spinner trips SPINNER_PATTERN and omp never draws Claude's `❯` after setup.
// The glyph also switches the submit verifier on for omp. A prompt sent mid-turn goes
// to omp's `Steering` queue and clears the input row, so the verifier stands down.
workDetect: {
promptGlyph: '╰─',
workingLine: '[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] [0-9hms ]+> |⎋ Working',
},
// 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/<id>` 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'],
},
},
};
// jcode (1jehuang/jcode, a Rust coding-agent TUI). Measured on a live v0.98.1 pane inside tmux
// (capture-pane every 200 ms through real turns, a recorded tmux client stream, PageUp/PageDown
// and SGR wheel reports, a bracketed-paste prompt; 2026-10-16). Three things about it shape
// this entry and are worth knowing before changing it:
//
// 1. Every jcode TUI is a CLIENT of one shared per-user daemon (`jcode serve`, started by the
// first client in its own session, so it outlives the pane that started it). The agent and
// its tools run in the DAEMON, with the daemon's environment: a second client's env never
// reaches its bash tool (measured, `probe=[]`). A session's `CODEMAN_SESSION_ID` and
// `CODEMAN_API_URL` are therefore whatever the session that started the daemon had, or
// nothing. That is why the agent skill is not injected (below) and why `JCODE_*` is not an
// `envOverrides` prefix: most of that namespace is read by the daemon, so a per-session
// value would do nothing, and where it did act (the session that starts the daemon) it
// would reach every jcode session, hook and command-path variables included.
// 2. It has no approval prompts and no bypass flag: bash runs ungated apart from jcode's own
// model-side reflection gate and a catastrophic-path deny. Nothing to clamp, nothing to
// read as blocked, same shape as pi.
// 3. A fresh install opens an onboarding wizard instead of the composer until its launch
// counter passes 5 (or 10 sessions exist). A prompt typed into it is lost, so a new user
// should run `jcode` once in a terminal (or press Esc in the pane) before driving it.
const JCODE: CliEntry = {
id: 'jcode' as CliEntry['id'],
label: 'jcode',
shortBadge: 'JC',
// No brand colour to borrow (the mark is a monochrome halftone donut, and its TUI's own blue
// is gemini's #8ab4f8), so an amber no other entry uses.
accent: '#f59e0b',
enabled: true,
stock: true,
order: 95,
kind: 'agent',
discovery: {
binaries: ['jcode'],
// The official installer links `~/.local/bin/jcode` (its `JCODE_INSTALL_DIR` default) to a
// launcher script under `~/.jcode/builds/`; the agent image puts it in `/usr/local/bin`.
searchDirs: [HOME_DIRS.local, HOME_DIRS.usrLocal, HOME_DIRS.homeBin],
// `jcode --version` prints `jcode v0.98.1 (1c7426eb1)`. npm carries an unrelated `jcode`
// package, so the probe is anchored on the `jcode v` prefix and a mismatch counts as absent.
version: { arg: '--version', regex: '(?:^|\\s)jcode v(\\d+\\.\\d+\\.\\d+)', requireVersionMatch: true },
install: {
command: {
linux: 'curl -fsSL https://jcode.sh/install | bash',
darwin: 'curl -fsSL https://jcode.sh/install | bash',
},
docsUrl: 'https://jcode.sh/docs',
agentImageLayer: {
kind: 'dedicated',
reason:
'Not on npm: jcode ships a standalone Rust binary through jcode.sh/install, whose launcher is a script that execs the binary beside it.',
},
},
},
launch: {
params: {
model: { type: 'token', pattern: 'model' },
// jcode ids look like `session_fox_1792160401272_f5b47ce47d631006` and `--resume` also
// takes the short name (`fox`). Its value is OPTIONAL (bare `--resume` opens the session
// picker), so the token must not be able to start with `-`, or it would be read as a
// flag of its own: `path-segment` requires a leading alphanumeric.
resumeId: { type: 'token', pattern: 'path-segment' },
},
variants: [
{
id: 'default',
args: [
{ lit: 'jcode' },
{ flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } },
{ flag: '--resume', valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } },
],
},
],
legacyConfigAliases: { resumeId: 'resumeSessionId' },
legacyConfigField: 'jcodeConfig',
resumeAppend: { style: 'flag', flag: '--resume' },
},
env: {
exports: [{ name: 'COLORTERM', value: 'truecolor' }],
unset: ['NO_COLOR'],
// Provider logins live in jcode's own files (`~/.jcode/auth.json`, `~/.config/jcode/*.env`)
// and are read by the daemon, so there is nothing for the server to forward into the pane.
tmuxSetenvKeys: [],
dockerExecEnvNames: [],
// Deliberately none: see note 1 above the entry.
allowedPrefixes: [],
allowedKeys: [],
},
capabilities: {
...agentDefaults(),
// A full-screen alternate-screen TUI that turns mouse tracking on itself (tmux
// `alternate_on=1`, `mouse_any_flag=1`, `mouse_sgr_flag=1`), the opencode shape: kept, a
// drag would be reported to jcode instead of selecting text, so Auto Copy and copy-on-select
// would silently do nothing.
altScreen: 'strip-mux-and-mouse',
// Pages its own transcript on PageUp/PageDown (measured: PageUp brought the welcome header
// back, PageDown returned to the composer). Over the hollow alt-screen buffer the wheel and a
// swipe become those keys instead of doing nothing.
transcriptPageKeys: true,
// Closing a jcode tab must not take the shared daemon (note 1) down with it: it is a
// descendant of whichever pane started it, in a process group of its own (setsid).
// Measured before this flag: deleting that tab left the next tab's client looping on
// `Connection lost - retrying` with its prompt queued.
sharedDaemon: true,
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
// At rest the composer row is `<n>> ` (the prompt number, then `>`); while a turn runs the
// same row reads `<n>…` and a status row appears directly above it, a braille spinner (the
// gemini/omp frame set, ~12 fps) then the phase and the elapsed time:
// `⠋ sending… 0s`, `⠦ thinking… 2s · https`, `⠼ waiting for response… 3s · https`,
// `⠸ running bash · <detail> · 12s · Alt+B bg`, or, while streaming, the time first
// (`⠼ 12s · 45.1 tps · ↑16k ↓20`). Past a minute the time reads `1m 3s`, past an hour
// `1h 2m`. A dropped connection swaps the spinner for `↻ network disconnected, waiting to
// retry · … · 5s`, still mid-turn. At rest that row is empty or a dim tip, never a spinner.
// The spinner repaints the pane at least every 100 ms for the whole turn (recorded tmux
// client stream), so the pane is never quiet mid-turn, and at rest it sends nothing at all
// (12 s recorded: zero bytes), so the idle check settles.
// The glyph is the bare `>`: the turn-ending repaint redraws only the cells that changed,
// which on that row is the `…` turning into `>` (recorded: `\x1b[43;2H...> `), never the
// prompt number. It only ARMS the idle check. It also leaves the submit verifier inert,
// which is what jcode wants (no early-Enter defect): every composer row starts with its
// number, so no screen row starts with `>` the way the verifier requires.
workDetect: {
promptGlyph: '>',
workingLine: String.raw`(?:^|\n) *(?:[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] [^\n]*\d[smh]\b|↻ network disconnected)`,
},
// jcode reads `./.agents/skills` (and `./.jcode/skills`, `./.claude/skills`), but its tools
// run in the shared daemon, whose CODEMAN_* identity belongs to whichever session started
// it (note 1). An injected skill would act as THAT session, so it is not injected.
agentSkillInjection: false,
customModelInjection: { kind: 'unsupported' },
},
overlays: {
// No credStore: Docker seeding is CRED_STORES in docker-hosts.ts, which has no `.jcode` row.
// `~/.jcode` holds the OAuth tokens beside 160 MB of builds, sessions and SQLite caches, and
// API keys live in `~/.config/jcode`; sign in inside the container instead.
},
};
/** 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,
JCODE,
];