mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
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>
1805 lines
91 KiB
TypeScript
1805 lines
91 KiB
TypeScript
/**
|
||
* @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,
|
||
];
|