Merge remote-tracking branch 'origin/master' into relprep/pr-510

Brings the 1.33.2 based adoption feature up to master. Conflicts were
resolved by keeping master's intent and re-applying the feature on top:
both sides kept for imports, the script tag and the stylesheet blocks; the
trust-dialog guard keeps master's stopped check plus the adopted refusal;
the Ralph and image watchers keep master's remote branch plus the adopted
skip; the close confirmation keeps master's worktree option plus the
adopted wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 02:51:00 +01:00
commit 57b2dcc466
858 files changed
+173626 -7078

No files matched your search

+17 -1
View File
@@ -21,9 +21,25 @@ export const MAX_AUTH_SESSIONS = 100;
// Rate Limiting
// ============================================================================
/** Max failed auth attempts per IP before 429 rejection */
/**
* Max failed auth attempts per bucket before 429 rejection. Applies to the
* single-user per-IP bucket, the hook-secret bucket and two multi-user buckets:
* the (username, IP) gate, checked before the scrypt verify, and the
* per-username bucket, checked after it.
*/
export const AUTH_FAILURE_MAX = 10;
/**
* Max failed login attempts per client IP for the multi-user pre-gate, which
* runs before the scrypt verify and so refuses even a correct password once
* reached. It bounds scrypt cost from one source and is wider than
* AUTH_FAILURE_MAX because one egress IP (NAT, a tunnel) can carry many users'
* failures. Verified guesses against any one account are capped by the
* (username, IP) gate at AUTH_FAILURE_MAX, not by this. Single-user mode does
* not use this.
*/
export const AUTH_IP_FAILURE_MAX = 50;
/** Failed auth attempt tracking window (ms) */
export const AUTH_FAILURE_WINDOW_MS = 15 * 60 * 1000;
+10
View File
@@ -113,6 +113,16 @@ export const FILE_PEEK_BYTES = 8 * 1024 - 1; // 8KB (inclusive end offset)
*/
export const MAX_PASTE_IMAGE_BYTES = parseInt(process.env.CODEMAN_MAX_PASTE_IMAGE_BYTES || '') || 50 * 1024 * 1024; // 50MB
/**
* Maximum size (bytes) of a single video uploaded via POST
* /api/sessions/:id/paste-image?kind=video (the phone toolbar's Attach button
* offers the camera roll's videos beside its photos). Videos stream to disk
* rather than through memory, so this is a disk-fill sanity bound, not a memory
* bound: a minute of 4K60 from an iPhone is about 400MB, so the image cap would
* refuse most clips. Override: CODEMAN_MAX_PASTE_VIDEO_BYTES (bytes)
*/
export const MAX_PASTE_VIDEO_BYTES = parseInt(process.env.CODEMAN_MAX_PASTE_VIDEO_BYTES || '') || 1024 * 1024 * 1024; // 1GB
// ============================================================================
// File Download Limits
// ============================================================================
+22
View File
@@ -20,6 +20,8 @@
export type TokenPattern =
| 'model'
| 'model-claude'
| 'model-plain'
| 'model-plain-colon'
| 'model-pi'
| 'id'
| 'id-dotted'
@@ -43,6 +45,13 @@ const PATTERNS: Record<TokenPattern, RegExp> = {
model: /^[a-zA-Z0-9._\-/]+$/,
// buildSpawnCommand's claude branch — `[` and `]` for bracketed model aliases
'model-claude': /^[a-zA-Z0-9._\-[\]]+$/,
// copilot --model. Same characters as `model`, but a leading alphanumeric: the value follows a
// flag, so `--yolo` must not pass as a model id and become its own flag (the allowAll clamp bypass).
'model-plain': /^[a-zA-Z0-9][a-zA-Z0-9._\-/]*$/,
// codex --model, and the value a custom-model `launchModel` is forced onto it. Same
// shape as `model-plain` (leading alphanumeric, so a flag cannot ride the flag) plus
// `:`, since Ollama-style ids like `qwen3:32b` must survive onto the command line.
'model-plain-colon': /^[a-zA-Z0-9][a-zA-Z0-9._\-/:]*$/,
// buildPiCommand — `:` for a thinking suffix (`sonnet:high`), `/` for `provider/id`
'model-pi': /^[a-zA-Z0-9._\-/:]+$/,
// opencode --session, codex resume
@@ -119,3 +128,16 @@ export function compileVersionRegex(source: string): RegExp | null {
return null;
}
}
/**
* How many capture groups a regex source declares (named ones included), or -1 when it
* does not compile. Matching the empty string against `source|` always succeeds through
* the empty alternative, and the match array then has one slot per group.
*/
export function countCaptureGroups(source: string): number {
try {
return (new RegExp(`${source}|`).exec('') as RegExpExecArray).length - 1;
} catch {
return -1;
}
}
+241 -7
View File
@@ -13,8 +13,12 @@
*/
import { z } from 'zod';
import { compileVersionRegex, TOKEN_PATTERNS } from './patterns.js';
import { compileVersionRegex, countCaptureGroups, TOKEN_PATTERNS } from './patterns.js';
import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js';
import type { AgentSkillDir, LaunchDefaultSettingKey, McpConfigFormat, ModelConfigResolverName } from './types.js';
/** `capabilities.agentSkillDir`: every value must stay a fixed, relative, `..`-free path. */
export const AGENT_SKILL_DIRS = ['.claude/skills', '.agents/skills'] as const satisfies readonly AgentSkillDir[];
/** A bare CLI id: lowercase, starts with a letter, at most 24 chars. Also used as a CSS/URL token. */
const cliId = z
@@ -27,6 +31,14 @@ const envName = z
.regex(/^[A-Z_][A-Z0-9_]*$/, 'env var name must be UPPER_SNAKE_CASE')
.max(64);
/** A relative file path with no traversal or odd characters (MCP sync writes to it). */
const mcpRelativePath = z
.string()
.min(1)
.max(100)
.regex(/^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/)
.refine((v) => !v.split('/').includes('..'), 'must not contain ..');
/**
* A shell-safe bare word: no space, quote, backtick, `$`, `;`, `&`, `|`, `<`, `>`, parens,
* braces, newline or backslash. Every LITERAL in the launch spec (base command, flag names,
@@ -68,6 +80,7 @@ const paramSpecSchema = z.union([
'muxName',
'effortLevel',
'effortSettingsJson',
'appendSystemPrompt',
'codemanPrefixedSessionId',
'launcherDefaultTarget',
]),
@@ -212,6 +225,7 @@ const envExportSchema = z
'muxName',
'effortLevel',
'effortSettingsJson',
'appendSystemPrompt',
'codemanPrefixedSessionId',
'launcherDefaultTarget',
]),
@@ -274,17 +288,32 @@ const launchModelTemplate = z
.regex(/^[a-zA-Z0-9._\-/:{}]+$/)
.optional();
/** One entry name under a CLI's real home, for `linkFromRealHome`: single path segment,
* leading alphanumeric, no traversal or hidden-dot escape. */
const homeSegmentName = z
.string()
.min(1)
.max(40)
.regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/);
const capabilitiesSchema = z
.object({
external: z.boolean(),
requiresMux: z.boolean(),
hooks: z.enum(['none', 'always', 'supervised']),
transcript: z.enum(['claude-jsonl', 'codex-rollout', 'deepseek-zstd', 'omp-jsonl', 'none']),
altScreen: z.enum(['strip-full', 'strip-mux-only', 'preserve']),
altScreen: z.enum(['strip-full', 'strip-mux-only', 'strip-mux-and-mouse', 'preserve']),
echo: echoSchema,
wheelForward: z
.object({ mode: z.enum(['never', 'version-gated']), minVersion: z.string().max(20).optional() })
.object({ mode: z.enum(['never', 'version-gated', 'always']), minVersion: z.string().max(20).optional() })
.strict(),
// Whether a hollow pane's wheel/touch scroll may be turned into PageUp/PageDown for
// the CLI to page its own transcript. Distinct from wheelForward (SGR reports).
// Optional: absent means no paging, the safe default for an unmeasured CLI.
transcriptPageKeys: z.boolean().optional(),
// Keep the PTY's rows while a phone keyboard is up (inline renderers only).
// Optional: absent means the keyboard resizes, the safe default.
keyboardHold: z.literal('inline').optional(),
keyboardAccessory: z.enum(['agent', 'shell']),
privilegedCommandGate: z.boolean(),
startMode: z.enum(['interactive', 'shell']),
@@ -293,6 +322,9 @@ const capabilitiesSchema = z
respawn: z.boolean(),
effort: z.boolean(),
agentSkillInjection: z.boolean(),
// A closed list on purpose: it decides where the server WRITES, and this file is
// user-editable, so it can never be a free path (`..`, absolute).
agentSkillDir: z.enum(AGENT_SKILL_DIRS).optional(),
statusLineTelemetry: z.boolean(),
// How many columns this CLI indents its transcript body by, so a copy can take
// that much off the clipboard. Bounded, because it is the whole strip: a copy
@@ -311,6 +343,8 @@ const capabilitiesSchema = z
// A declared width cannot do either. Absent means no strip, so a CLI whose
// transcript layout nobody has measured is never touched.
transcriptGutter: z.number().int().min(1).max(8).optional(),
// Literal text matched by a `wait-output` long-poll, never compiled as a regex.
composerReadyMark: z.string().min(1).max(64).optional(),
workDetect: z
.object({
promptGlyph: z.string().min(1).max(8),
@@ -347,6 +381,51 @@ const capabilitiesSchema = z
'awaitingLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers'
)
.optional(),
// Same guard again: run over the foot of a pane capture every time a session
// settles and on a timer while a dialog is up, and ~/.codeman/clis.json can set it.
blockedLine: z
.string()
.min(1)
.refine(
(src) => compileVersionRegex(src) !== null,
'blockedLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers'
)
.optional(),
// Wider than `watchingLines` because a dialog's options wrap at narrow widths (codex
// needs 20 at 36 columns). That is safe only for a pattern that ends on the dialog's
// own footer: the rows above it, which the agent may write, can then supply a
// marker but never the footer. Still bounded, since it is also how much text the
// pattern runs over.
blockedLines: z.number().int().min(1).max(24).optional(),
// Same guard again: run over the rows under the composer each time guarded
// input reads the pane, and ~/.codeman/clis.json can set it.
composerFooterLine: z
.string()
.min(1)
.refine(
(src) => compileVersionRegex(src) !== null,
'composerFooterLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers'
)
.optional(),
// Same guard again: run over the rows just above the composer by the same reads.
composerPopupLine: z
.string()
.min(1)
.refine(
(src) => compileVersionRegex(src) !== null,
'composerPopupLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers'
)
.optional(),
// Single ASCII punctuation characters, never letters, digits or whitespace: each one
// refuses every message that starts (or whose last word starts) with it.
composerCommandPrefixes: z
.string()
.regex(/^[!-/:-@[-`{-~]{1,8}$/, 'composerCommandPrefixes must be 1 to 8 ASCII punctuation characters')
.optional(),
composerCompletionSigils: z
.string()
.regex(/^[!-/:-@[-`{-~]{1,8}$/, 'composerCompletionSigils must be 1 to 8 ASCII punctuation characters')
.optional(),
})
.strict()
// A window with nothing to search is a typo, not a configuration. Refused at LOAD
@@ -357,10 +436,64 @@ const capabilitiesSchema = z
(v) => v.watchingLines === undefined || v.watchingLine !== undefined,
'watchingLines has nothing to bound without a watchingLine'
)
.refine(
(v) => v.blockedLines === undefined || v.blockedLine !== undefined,
'blockedLines has nothing to bound without a blockedLine'
)
.optional(),
model: z
.object({ source: z.enum(['flag', 'claude-settings-file', 'none']), param: z.string().optional() })
.strict(),
// Same guard as the workDetect patterns: ~/.codeman/clis.json can set it, and it runs
// over the foot of a pane capture every time a session settles. Exactly one capture
// group (the model), checked here so a pattern without one fails at LOAD time instead
// of silently never naming a model.
modelDetect: z
.object({
screenLine: z
.string()
.min(1)
.refine(
(src) => compileVersionRegex(src) !== null && countCaptureGroups(src) === 1,
'screenLine must be a regex compileVersionRegex() accepts (at most 200 characters, no nested quantifiers) with exactly one capture group'
)
.optional(),
// Bounded hard, like watchingLines: every row it adds is one more row the agent
// itself may be able to write. 8 is the reader's own cap (readScreenModel); a
// window taller than the CLI's footer needs a pattern only that CLI's chrome can
// satisfy at its position, as opencode's does by taking the LAST composer row.
screenLines: z.number().int().min(1).max(8).optional(),
// Single tokens, bounded: each is compared against one captured field.
rejectWords: z.array(z.string().min(1).max(40).regex(/^\S+$/)).max(32).optional(),
// A NAMED reader (src/model-config-resolvers.ts), never code in config.
configResolver: z.enum(['deepseek-route'] as const satisfies readonly ModelConfigResolverName[]).optional(),
})
.strict()
// Typos rather than configurations, refused at LOAD time like watchingLines.
.refine(
(v) => v.screenLine !== undefined || v.configResolver !== undefined,
'modelDetect declares nothing to read'
)
.refine(
(v) => v.screenLines === undefined || v.screenLine !== undefined,
'screenLines has nothing to bound without a screenLine'
)
.refine(
(v) => v.rejectWords === undefined || v.screenLine !== undefined,
'rejectWords has nothing to filter without a screenLine'
)
.optional(),
// Launch param -> synced App Settings key. The values are a closed enum, like
// configResolver: a clis.json override names one of the settings this build knows
// how to validate, never an arbitrary key. Params are checked against the declared
// ones in the superRefine below.
launchDefaults: z
.record(
z.string(),
z.enum(['codexModel', 'codexReasoningEffort'] as const satisfies readonly LaunchDefaultSettingKey[])
)
.refine((v) => Object.keys(v).length >= 1 && Object.keys(v).length <= 8, 'launchDefaults takes 1 to 8 params')
.optional(),
privilegedParams: z
.array(
z
@@ -373,10 +506,36 @@ const capabilitiesSchema = z
)
.max(8),
// Exact env var NAMES, not prefixes: this list is a targeted deny, and a prefix here
// would let one entry silently strip a whole namespace off every owner's overrides.
privilegedEnvKeys: z.array(envName).max(8),
// would let one entry silently strip a whole namespace off every owner's overrides. The
// bound is generous because a longer list only takes more away from non-granted owners:
// copilot's COPILOT_ prefix covers dozens of variables that run code or redirect its token.
privilegedEnvKeys: z.array(envName).max(48),
gates: z.record(z.string(), z.object({ minVersion: z.string().max(20), failClosed: z.boolean() }).strict()),
maxFrameBytes: z.number().int().positive().optional(),
newline: z.enum(['line-feed', 'esc-enter']).optional(),
mcpConfig: z
.object({
// Home-relative, no traversal: sync writes to this path.
path: mcpRelativePath,
// Home-relative fallback used when `path` is absent. Same no-traversal rule as `path`:
// sync reads it and, when it parses as plain JSON, writes to it.
altPath: mcpRelativePath.optional(),
// Every value must be a known McpConfigFormat (types.ts); mcp-sync.ts's dialect table is
// keyed by the same type, so an adapter-less format fails to compile there.
format: z.enum([
'claude-json',
'gemini-json',
'codex-toml',
'opencode-json',
'antigravity-json',
'copilot-json',
] as const satisfies readonly McpConfigFormat[]),
// The env var the CLI reads to move the file, and the path under it (same no-traversal
// rule: sync writes there too). Resolved from the server env at call time, never here.
relocation: z.object({ envVar: envName, path: mcpRelativePath }).strict().optional(),
})
.strict()
.optional(),
customModelInjection: z.discriminatedUnion('kind', [
z
.object({
@@ -431,10 +590,61 @@ const capabilitiesSchema = z
kind: z.literal('configDir'),
dirEnvVar: envName,
fileName: z.string().min(1).max(80),
template: z.enum(['codex-toml', 'pi-models-json', 'omp-models-yml', 'grok-toml']),
template: z.enum(['codex-toml', 'pi-models-json', 'omp-models-yml', 'grok-toml', 'deepseek-patch-json']),
launchModel: launchModelTemplate,
// State that must survive the isolated-directory redirect, symlinked by name from
// the CLI's real home (deepseek: profiles/sessions/settings.yaml). Single path
// segments only: the consumer joins them, so a traversal here would escape the
// real home. See the field's comment in cli-registry/types.ts.
linkFromRealHome: z.array(homeSegmentName).max(8).optional(),
// Which of `linkFromRealHome` are directories (missing ones are created before
// linking; missing files are skipped). Same segment pattern, see types.ts.
linkFromRealHomeDirs: z.array(homeSegmentName).max(8).optional(),
// The real home's fallback location, relative to the user's home, ONE segment:
// a leading dot is legitimate (`.dsh` is what ships) but separators, `..` and
// bare `.` are not; the consumer joins it under the user's home, a traversal
// here escapes that home and `.` IS that home (linking THROUGH it unshielded).
realHomeFallback: z
.string()
.min(1)
.max(80)
.regex(/^(?!\.\.?$)[^/\\]+$/)
.optional(),
})
.strict(),
.strict()
.superRefine((recipe, ctx) => {
// Both rules were comments before; the schema is the security boundary, so
// enforce them at load time (see the header of this file).
if (recipe.linkFromRealHome?.length && !recipe.realHomeFallback) {
ctx.addIssue({
code: 'custom',
message: 'realHomeFallback is required whenever linkFromRealHome is declared',
path: ['realHomeFallback'],
});
}
for (const name of recipe.linkFromRealHome ?? []) {
if (name === recipe.fileName) {
ctx.addIssue({
code: 'custom',
message: `linkFromRealHome must never include fileName (${JSON.stringify(name)}): the generated file owns that name in the isolated directory, a link there would write THROUGH into the user's real file`,
path: ['linkFromRealHome'],
});
}
}
// types.ts documents the dirs list as "must be a subset of linkFromRealHome";
// a name absent from it names nothing (missing-file entries are skipped as
// files), so the recipe would silently stop creating a directory it declares.
const linkedNames = new Set(recipe.linkFromRealHome ?? []);
for (const dir of recipe.linkFromRealHomeDirs ?? []) {
if (!linkedNames.has(dir)) {
ctx.addIssue({
code: 'custom',
message: `linkFromRealHomeDirs must be a subset of linkFromRealHome: ${JSON.stringify(dir)} is not linked, so the directory rule for it can never apply`,
path: ['linkFromRealHomeDirs'],
});
}
}
}),
z.object({ kind: z.literal('unsupported') }).strict(),
]),
})
@@ -559,6 +769,30 @@ export const CliEntrySchema = z
}
});
// Same silent-no-op class again: a launch default for a param the entry never declared
// would be filled into the config object and then read by nothing. And without a
// `legacyConfigField` the entry's params are read off the request body itself, where a
// filled `model` would be a different field (claude's per-session one), so refuse it.
const { launchDefaults } = entry.capabilities;
if (launchDefaults !== undefined) {
if (entry.launch.legacyConfigField === undefined) {
ctx.addIssue({
code: 'custom',
message: 'launchDefaults needs launch.legacyConfigField to fill',
path: ['capabilities', 'launchDefaults'],
});
}
for (const param of Object.keys(launchDefaults)) {
if (!declaredParams.has(param)) {
ctx.addIssue({
code: 'custom',
message: `launchDefaults param "${param}" is not a declared launch param`,
path: ['capabilities', 'launchDefaults', param],
});
}
}
}
const { setenvProfile } = entry.env;
if (setenvProfile !== undefined && !isKnownSetenvProfile(setenvProfile)) {
ctx.addIssue({
+649 -40
View File
@@ -11,6 +11,15 @@
*/
import type { CliEntry } from './types.js';
import { CODEX_REASONING_EFFORTS } from '../../types/session.js';
/**
* Codex's footer effort words: every CODEX_REASONING_EFFORTS level plus `default` (what
* codex prints when no effort is configured), as one regex alternation. Shared by the two
* patterns that read that footer (`modelDetect.screenLine` and
* `workDetect.composerFooterLine`), so a new level is a one-place edit.
*/
const CODEX_EFFORT_WORDS = [...CODEX_REASONING_EFFORTS, 'default'].join('|');
const HOME_DIRS = {
local: '~/.local/bin',
@@ -75,6 +84,21 @@ function agentDefaults(): Pick<
};
}
/**
* 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
@@ -128,6 +152,7 @@ const CLAUDE: CliEntry = {
// from the EFFORT_LEVELS allowlist upstream, same as every other engine value).
effortLevel: { type: 'engine', source: 'effortLevel' },
effortJson: { type: 'engine', source: 'effortSettingsJson' },
discordHint: { type: 'engine', source: 'appendSystemPrompt' },
sessionId: { type: 'engine', source: 'sessionId' },
sessionName: { type: 'engine', source: 'sessionName' },
},
@@ -155,6 +180,12 @@ const CLAUDE: CliEntry = {
{ 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' } },
{
flag: '--append-system-prompt',
valueFrom: 'discordHint',
quote: 'single',
when: { param: 'discordHint', state: 'set' },
},
],
},
{
@@ -179,6 +210,12 @@ const CLAUDE: CliEntry = {
{ 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' } },
{
flag: '--append-system-prompt',
valueFrom: 'discordHint',
quote: 'single',
when: { param: 'discordHint', state: 'set' },
},
],
},
],
@@ -217,6 +254,9 @@ const CLAUDE: CliEntry = {
// 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.
@@ -266,6 +306,14 @@ const CLAUDE: CliEntry = {
// 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,
// Claude's default renderer is inline, and each keyboard-driven PTY height change made
// it reprint only the tail that fits, erasing transcript rows from tmux (measured on
// 2.1.306: 25 of 100 lines over five keyboard cycles). See CliCapabilities.keyboardHold.
keyboardHold: 'inline',
keyboardAccessory: 'agent',
privilegedCommandGate: false,
startMode: 'interactive',
@@ -274,6 +322,7 @@ const CLAUDE: CliEntry = {
respawn: true,
effort: true,
agentSkillInjection: true,
agentSkillDir: '.claude/skills',
statusLineTelemetry: true,
model: { source: 'claude-settings-file' },
privilegedParams: [],
@@ -305,7 +354,18 @@ const CLAUDE: CliEntry = {
'CLAUDE_CODE_MAX_CONTEXT_TOKENS',
'CLAUDE_CONFIG_DIR',
],
gates: { nameFlag: { minVersion: '2.1.224', failClosed: true } },
// idleCompactionSetting: the `idleCompaction` settings key first ships in 2.1.290 (2.1.289
// already idle-compacts with no opt-out); see TmuxManager._resolveIdleCompactionOptOut.
gates: {
nameFlag: { minVersion: '2.1.224', failClosed: true },
idleCompactionSetting: { minVersion: '2.1.290', 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.
@@ -484,8 +544,58 @@ const OPENCODE: CliEntry = {
},
capabilities: {
...agentDefaults(),
altScreen: 'strip-mux-only',
...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.
@@ -531,7 +641,11 @@ const CODEX: CliEntry = {
params: {
bypassApprovals: { type: 'bool' },
animations: { type: 'bool' },
model: { type: 'token', pattern: 'model' },
// `model-plain-colon`, not `model`: the custom-model feature forces the selected id
// onto this param (`launchModel: '{modelId}'` below), and Ollama-style ids carry a
// `:` (`qwen3:32b`). A leading alphanumeric still keeps `--yolo` from riding a flag.
model: { type: 'token', pattern: 'model-plain-colon' },
reasoningEffort: { type: 'enum', values: [...CODEX_REASONING_EFFORTS] },
resumeId: { type: 'token', pattern: 'id' },
},
variants: [
@@ -543,6 +657,14 @@ const CODEX: CliEntry = {
{ 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' } },
],
@@ -565,6 +687,7 @@ const CODEX: CliEntry = {
},
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
@@ -576,16 +699,22 @@ const CODEX: CliEntry = {
// 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 third row from the bottom is the chip only
// 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. Matching the complete row raises the bar — an assistant
// 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 hook event from a codex
// session ever reaches `notePrompt()`, so there is no idle item to pre-acknowledge
// and a forged label costs a wrong badge and nothing else. A CLI that gains hook
// signals must not keep a pattern this soft.
// 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
@@ -594,12 +723,103 @@ const CODEX: CliEntry = {
// 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: 3,
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 rows codex draws under an idle composer, tested one at a time by guarded
// stage/submit (agent-staging.ts): the model footer (` gpt-5.6-terra default ·
// ~/case`, effort words from CODEX_EFFORT_WORDS as in modelDetect below), the
// `? for shortcuts` / `← for agents` hint row 0.162.0 adds at rest, and the older
// `NN% context left` footer. Indented, so the `›` composer row itself never matches.
composerFooterLine: String.raw`^\s+(?:\d+% context left|\? for shortcuts|← for agents|\S+ (?:${CODEX_EFFORT_WORDS}) · )`,
// Read from codex-rs/tui/src/bottom_pane at rust-v0.162.0 (chat_composer/
// popup_state.rs, composer_layout.rs and the popups' render code and snapshots):
// every suggestion popup renders ABOVE the composer and keeps the footer below it,
// so the footer alone cannot tell `› /stat` with the command list open from an
// idle draft, and Enter then runs the highlighted `/status`. What sits in the
// block just above the composer (past its blank top padding row):
// command list (`/`) `› /status show current session ...`
// ` /statusline configure which items ...`
// skill and mention lists ` enter insert · esc close` (key-hint row)
// (`$`, `@` with mentions on) ` enter/tab insert · esc close · ↑/↓ select · ←/→ filter`
// file search (`@`) ` loading...` / ` no matches`, else bare path
// rows, which no pattern can tell from prose
// The last case is why stage also refuses the triggers themselves (below).
composerPopupLine: String.raw`^ {0,2}(?:enter(?:/tab)? insert · esc close|loading\.\.\.$|no matches$)|^[› ] /[\w:-]+ {2,}\S`,
// Same source. A draft starting with `/` is a slash command (the popup opens while
// the caret is in its name, and Enter runs a command either way), and one starting
// with `!` is a shell command (codex switches its prompt to `!` and runs it). Popups
// open for the word under the caret only: `@` (file search, or the unified mention
// list) and `$` (skills and mentions; whether a given `$word` opens it depends on
// codex's skill catalog, so every `$` word is refused).
composerCommandPrefixes: '/!',
composerCompletionSigils: '@$',
},
// 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_EFFORT_WORDS}) · [^\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,
@@ -609,7 +829,12 @@ const CODEX: CliEntry = {
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
wheelForward: { mode: 'always' }, // 0.163.0 fullscreen: measured SGR scrolling; require observed tracking
// Without observed mouse tracking, retain PageUp/PageDown fallback. 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.
@@ -620,6 +845,11 @@ const CODEX: CliEntry = {
// `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: {
@@ -627,6 +857,7 @@ const CODEX: CliEntry = {
dirEnvVar: 'CODEX_HOME',
fileName: 'config.toml',
template: 'codex-toml',
launchModel: '{modelId}',
},
// CODEX_HOME already matches the CODEX_ allowedPrefix above, so it was ALREADY
// reachable via plain envOverrides before this feature existed. It is arguably
@@ -715,12 +946,41 @@ const GEMINI: CliEntry = {
},
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.
@@ -795,11 +1055,38 @@ const ANTIGRAVITY: CliEntry = {
},
capabilities: {
...agentDefaults(),
...AGENTS_DIR_SKILL,
altScreen: 'strip-mux-only',
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
// Measured on a live `agy` 1.3.3 pane (capture-pane every second through a prose turn
// and a shell-call turn, plus the raw PTY stream, 2026-10-11). A running turn animates
// `⣯ Generating...` (the label also reads `Working...` and `Loading...`) one row above
// the composer, and the footer's left end reads `esc to cancel`; at rest the footer
// reads `? for shortcuts` and nothing else on screen matches. A shell call replaces the
// composer with a `Run this command?` list, the spinner stops, and the pane goes
// silent. Nothing arms the idle check while the list is up (the footer hint is drawn only
// at rest), so a pending approval reads as working until it is answered. That differs from
// gemini, whose `▀` bar comes back on every spinner repaint.
// ⚠️ The frames are the 8-dot braille set (`⣯ ⡿ ⣻ ⣾ ⣷ ⣟ ⢿ ⣽`), none of which is in
// SPINNER_PATTERN's Claude set, and this entry used to declare nothing, so no frame
// ever marked an antigravity tab working and it never showed the activity spinner.
// The composer's `>` is no arming mark: the submitted prompt is echoed as `> <text>`
// too. The footer hint is drawn only at rest, and the turn's last repaint carries it.
// The schema caps a glyph at 8 characters, so this is the head of `? for shortcuts`. It also
// keeps the submit verifier inert (no row starts with it and holds prompt text).
// The working line is anchored to the start of a row, like gemini's braille branch: the
// idle probe tests it against the whole pane, and an answer that QUOTES the spinner row
// (an agent explaining this entry) would otherwise hold the tab busy while it is on screen.
// Every `agy` capture draws the spinner at column 0.
workDetect: {
promptGlyph: '? for sh',
workingLine: String.raw`(?:^|\n)[⠀-⣿] [A-Z][A-Za-z ]{2,30}(?:\.\.\.|…)`,
},
// 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.
@@ -888,8 +1175,44 @@ const PI: CliEntry = {
},
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.
@@ -1007,8 +1330,10 @@ const GROK: CliEntry = {
},
capabilities: {
...agentDefaults(),
// Fullscreen alt-screen TUI with mouse support — same shape as opencode/antigravity:
// only the tmux-attach-time smcup strip, not Ink's full erase-scrollback+DECSET strip.
...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.
@@ -1096,8 +1421,14 @@ const DEEPSEEK: CliEntry = {
launcherTargetParam: 'profile',
install: {
command: {
linux: 'npm install -g @deepseek-ai/dsh',
darwin: 'npm install -g @deepseek-ai/dsh',
// 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',
@@ -1178,15 +1509,47 @@ const DEEPSEEK: CliEntry = {
},
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.
@@ -1199,30 +1562,30 @@ const DEEPSEEK: CliEntry = {
// 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.
// (test/deepseek-mode.test.ts) fixed before this shipped. CODEMAN_CUSTOM_MODEL_API_KEY
// joins it (like codex's entry): the dedicated custom-endpoint key rides the same
// allowlisted-envOverrides path as the redirect itself.
privilegedEnvKeys: ['DSH_PERMISSION_MODE', 'DSH_HOME', 'DEEPSEEK_BASE_URL', 'CODEMAN_CUSTOM_MODEL_API_KEY'],
// The installed profile supplies the transport; a per-session patch selects its exact model.
customModelInjection: {
kind: 'env',
baseUrlVar: 'DEEPSEEK_BASE_URL',
apiKeyVar: 'DEEPSEEK_API_KEY',
modelVars: [],
appendV1Suffix: true,
kind: 'configDir',
dirEnvVar: 'DSH_HOME',
fileName: 'cordis.patch.yml',
template: 'deepseek-patch-json',
// dsh keeps state in its home that the redirect must NOT swallow: `sessions` is the
// session-log root (deleting it would take the user's transcripts with it on clear/
// close), `profiles` are the installed bundles the patch composes over, and
// `settings.yaml` must keep applying (read through the link only until the session's
// first settings write: dsh saves by rename, which replaces the link with a local file
// that goes away with the session). The generated home-level `cordis.patch.yml`
// deliberately replaces the user's own for the session rather than linking it.
// `sessions` and `profiles` are directories: dsh creates them itself on a first
// turn, and a fresh/relocated $DSH_HOME that never ran one would otherwise get a
// dangling link whose every write fails ENOENT: the consumer creates missing
// directory targets and skips missing file targets like `settings.yaml`.
linkFromRealHome: ['profiles', 'sessions', 'settings.yaml'],
linkFromRealHomeDirs: ['profiles', 'sessions'],
realHomeFallback: '.dsh',
},
},
overlays: {
@@ -1312,13 +1675,28 @@ const OMP: CliEntry = {
},
capabilities: {
...agentDefaults(),
// Fullscreen alt-screen TUI, same shape as opencode/antigravity/grok: only the
...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: [],
@@ -1360,5 +1738,236 @@ const OMP: CliEntry = {
},
};
// GitHub Copilot CLI (`copilot`, github/copilot-cli, npm @github/copilot). Measured on a live
// 1.0.94 pane inside tmux (capture-pane through real turns, a shell tool call and /model,
// 2026-10-09). It is an ALTERNATE-SCREEN TUI with mouse tracking on (tmux `alternate_on=1`,
// `mouse_any_flag=1`, `history_size=0`), so like opencode its mouse DECSETs are stripped
// (`altScreen: 'strip-mux-and-mouse'` below): a drag selects locally and the wheel is forwarded.
// Keystrokes sent with `tmux send-keys` reach its composer and a shell tool call completes
// inside tmux, which github/copilot-cli#4180 (a driven PTY ignoring input) and #4223 (a shell
// command never reported done under tmux) say they do not on 1.0.70 to 1.0.74: neither
// reproduces on 1.0.94, and the register of `version` below is what would show a regression.
const COPILOT: CliEntry = {
id: 'copilot' as CliEntry['id'],
label: 'GitHub Copilot',
shortBadge: 'CP',
accent: '#a371f7',
enabled: true,
stock: true,
order: 100,
kind: 'agent',
discovery: {
binaries: ['copilot'],
searchDirs: [HOME_DIRS.local, HOME_DIRS.usrLocal, HOME_DIRS.bunBin, HOME_DIRS.npmGlobal, HOME_DIRS.homeBin],
// `copilot --version` prints `GitHub Copilot CLI 1.0.94.`; anchoring on the product name
// keeps an unrelated `copilot` binary on PATH from passing the probe.
version: { arg: '--version', regex: 'GitHub Copilot CLI (\\d+\\.\\d+\\.\\d+)', requireVersionMatch: true },
install: {
command: { linux: 'npm install -g @github/copilot', darwin: 'npm install -g @github/copilot' },
npmPackage: '@github/copilot',
docsUrl: 'https://github.com/github/copilot-cli',
},
},
// `--yolo` (allow every tool, path and URL) is opt-in through `allowAll`, which the Run button
// sends like the other agent CLIs' bypass switches; an absent config spawns a bare `copilot`
// in its own Manual Approval mode. `--model` and `--resume=<id>` are Copilot's own flags
// (`copilot --help`, 1.0.94). `--resume` takes an id, id prefix or session NAME and its value
// is optional, so the token must be a single plain word that cannot start with `-`: a value
// like `--yolo` would otherwise be parsed as its own flag and get around the `allowAll` clamp.
launch: {
params: {
allowAll: { type: 'bool' },
// `model-plain`, not `model`: the leading alphanumeric keeps a flag-shaped value out.
model: { type: 'token', pattern: 'model-plain' },
// `path-segment`, not `id-dotted`: the leading alphanumeric keeps a flag-shaped value out.
resumeId: { type: 'token', pattern: 'path-segment' },
continueSession: { type: 'bool' },
// The tab's name, so the CLI's own session list reads the same as the tab.
sessionName: { type: 'engine', source: 'sessionName' },
},
variants: [
{
id: 'default',
args: [
{ lit: 'copilot' },
{ flag: '--yolo', when: { param: 'allowAll', is: true } },
// `--name` is refused next to `--resume` and `--continue` (copilot 1.0.95: "cannot be used
// with"), and a resumed session keeps the name it was created with.
{
flag: '--name',
valueFrom: 'sessionName',
quote: 'double',
when: {
allOf: [
{ param: 'sessionName', state: 'set' },
{ param: 'resumeId', state: 'unset' },
{ not: { param: 'continueSession', 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: 'copilotConfig',
resumeAppend: { style: 'flag', flag: '--resume' },
},
env: {
exports: [{ name: 'COLORTERM', value: 'truecolor' }],
unset: ['NO_COLOR'],
// Sign-in is the CLI's own (`copilot login`, kept under ~/.copilot). A headless host sets
// COPILOT_GITHUB_TOKEN, which the COPILOT_ prefix already admits. GH_TOKEN / GITHUB_TOKEN are
// deliberately NOT allowlisted: the env allowlist is one global list with no mode context, so
// admitting them would make them settable on every session (see allowedEnvPrefixes()).
tmuxSetenvKeys: [],
dockerExecEnvNames: [],
allowedPrefixes: ['COPILOT_'],
allowedKeys: [],
},
capabilities: {
...agentDefaults(),
// A full-screen TUI that turns mouse tracking on itself (measured on 1.0.94: alt screen,
// mouse reporting on). Kept, xterm reports a plain drag to the TUI instead of selecting, so
// Auto Copy and "mark text, copy on select" silently do nothing; stripped, a drag is a local
// selection and clicks still reach the CLI through the browser's hand-encoded tap
// (`cliMouseTracking`). Same shape and reason as opencode. The wheel keeps working because
// `_shouldForwardWheelToApp` forwards it as SGR reports while the CLI is tracking.
altScreen: 'strip-mux-and-mouse',
// Forwards the wheel as SGR reports while the CLI is tracking the mouse, at any version
// (`_shouldForwardWheelToApp` in terminal-ui.js has no version check for copilot).
wheelForward: { mode: 'always' },
// Pages its own transcript on PageUp/PageDown (1.0.95, measured in tmux: three PageUp moved
// lines 109-140 to 010-042 and PageDown returned). This is the fallback when the
// wheel-forward flag is stale after a server restart, so the wheel and a swipe never go dead.
transcriptPageKeys: true,
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
// What `codeman agent spawn` waits for. The composer's footer hint, NOT the `❯` glyph: the
// folder-trust dialog draws `❯ 1. Yes` for its selected row, so the glyph would call a worker
// ready while it is still asking for trust (measured on 1.0.95: the dialog's footer reads
// `↑/↓ to navigate`, the composer's `/ commands · ? help`).
composerReadyMark: '/ commands · ? help',
// The composer row is a `❯` between two rules, and the submitted prompt is echoed as
// ` ❯ <text> <time>` above it. While a turn runs the footer's left end reads
// `◉ Working esc edit prompt` (the dot alternates `◉` and `◎`); at rest it reads
// `← open sidebar · Interactive · Manual Approval · / commands · ? help · tab next tab`.
// A tool call that is waiting for approval replaces the composer and the footer's
// `Working` goes with it, so it reads as idle (waiting on the user), like gemini.
workDetect: {
promptGlyph: '❯',
workingLine: String.raw`[◉◎] Working\b`,
},
// The model is the right-hand field of the last row, both at rest and mid-turn
// (`... tab next tab Claude Sonnet 5.5`, `◎ Working esc edit prompt GPT-6 Luna`).
// Anchored on the footer's own words so a model-looking string in the transcript cannot match.
modelDetect: {
screenLine: String.raw`(?:tab next tab|\? help|esc edit prompt|github-mcp-server) {3,}([A-Za-z0-9][\w.:/@ -]{1,60}?) *(?:\n|$)`,
screenLines: 2,
},
// `copilot mcp add` writes `~/.copilot/mcp-config.json`; COPILOT_HOME replaces ~/.copilot
// (checked: `COPILOT_HOME=<dir> copilot mcp list` reads <dir>).
mcpConfig: {
path: '.copilot/mcp-config.json',
format: 'copilot-json',
relocation: { envVar: 'COPILOT_HOME', path: 'mcp-config.json' },
},
// Every one of these matches the COPILOT_ allowedPrefix, so a non-granted multi-user owner
// must not be able to set it through envOverrides. Read from the 1.0.98 bundle (copilot reads
// far more COPILOT_* variables than `copilot help environment` lists), by what each hands back:
// - approval: COPILOT_ALLOW_ALL turns every tool on; COPILOT_PLAN_THEN_AUTOPILOT switches a
// plan into autopilot; COPILOT_ASSISTED_APPROVAL; COPILOT_HOME restates the permission config.
// - code at boot or without an approval row: COPILOT_CLI_DIST_DIR runs `<dir>/index.js` in place
// of the CLI, the package caches pick which CLI build runs, COPILOT_EDITOR,
// COPILOT_PROVIDER_API_KEY_COMMAND and COPILOT_AHP_HOST_BIN are run as commands, skills and
// provider configs are loaded from the named paths, and the hook switches widen what hooks run.
// - credential redirects: the Copilot and GitHub API bases receive the CLI's sign-in token, which
// in multi-user mode is the operator's (every user shares the OS account and ~/.copilot), and
// the BYOK provider family aims a Codeman custom endpoint elsewhere.
// The prefix stays because COPILOT_GITHUB_TOKEN and the harmless knobs live in it; a variable a
// later release adds is NOT covered until it is listed here.
privilegedEnvKeys: [
'COPILOT_HOME',
'COPILOT_ALLOW_ALL',
'COPILOT_PLAN_THEN_AUTOPILOT',
'COPILOT_ASSISTED_APPROVAL',
'COPILOT_CLI_DIST_DIR',
'COPILOT_CLI_RESOLVED_DIST_DIR',
'COPILOT_PKG_CACHE_HOME',
'COPILOT_CACHE_HOME',
'COPILOT_EDITOR',
'COPILOT_AHP_HOST_BIN',
'COPILOT_DEBUG_VOICE_ENGINE_SCRIPT',
'COPILOT_SKILLS_DIRS',
'COPILOT_PROVIDERS_CONFIG',
'COPILOT_HOOK_ALLOW_HTTP_AUTH_HOOKS',
'COPILOT_HOOK_ALLOW_LOCALHOST',
'COPILOT_E2E_TEST_HOOKS',
'COPILOT_API_URL',
'COPILOT_DEBUG_GITHUB_API_URL',
'COPILOT_GH_HOST',
'COPILOT_MC_BASE_URL',
'COPILOT_AHP_URL',
'COPILOT_DEBUG_ENTRA_BROKER_URL',
'COPILOT_CAFE_METRICS_URL',
'COPILOT_PROVIDER_BASE_URL',
'COPILOT_PROVIDER_API_KEY',
'COPILOT_PROVIDER_API_KEY_COMMAND',
'COPILOT_PROVIDER_BEARER_TOKEN',
'COPILOT_PROVIDER_HEADERS',
'COPILOT_PROVIDER_TYPE',
'COPILOT_PROVIDER_WIRE_MODEL',
'COPILOT_PROVIDER_MODEL_ID',
'COPILOT_PROVIDER_GHES_HOST',
'COPILOT_PROVIDER_GHES_TOKEN',
'COPILOT_MODEL',
],
// Grok/codex-shaped clamp: a bare spawn is already Manual Approval, so the multi-user clamp
// only forces an EXPLICITLY-SENT `--yolo` back off; nothing is materialized when absent.
privilegedParams: [{ param: 'allowAll', clampTo: false }],
// BYOK (`copilot help environment`, 1.0.95): COPILOT_PROVIDER_BASE_URL switches the CLI to a
// custom provider and drops the GitHub sign-in requirement; COPILOT_PROVIDER_TYPE defaults to
// "openai", which is what a Codeman custom endpoint is (llama.cpp, vLLM, Ollama).
// COPILOT_MODEL names the model and COPILOT_PROVIDER_MODEL_ID / _WIRE_MODEL default to it.
// The OpenAI-style route is `<base>/v1/chat/completions`, so the base gets the `/v1`
// suffix via appendV1Suffix below. All four already match the COPILOT_ prefix allowlist, so
// they are privileged env keys below: a non-granted multi-user owner must not be able to
// redirect the session's endpoint or credentials through plain envOverrides.
customModelInjection: {
kind: 'env',
baseUrlVar: 'COPILOT_PROVIDER_BASE_URL',
apiKeyVar: 'COPILOT_PROVIDER_API_KEY',
modelVars: ['COPILOT_MODEL'],
appendV1Suffix: true,
},
},
overlays: {
// No credStore: Docker seeding is CRED_STORES in docker-hosts.ts, which has no `.copilot` row,
// and the sign-in token sits in plain text in config.json on a host without a keyring, so it
// is not copied into a container by default. 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];
export const STOCK_CLIS: CliEntry[] = [
CLAUDE,
SHELL,
OPENCODE,
CODEX,
GEMINI,
ANTIGRAVITY,
PI,
GROK,
DEEPSEEK,
OMP,
COPILOT,
];
+320 -25
View File
@@ -29,6 +29,8 @@ export type EngineValue =
| 'muxName'
| 'effortLevel'
| 'effortSettingsJson'
/** The Discord bridge's startup hint, passed to Claude as `--append-system-prompt`. */
| 'appendSystemPrompt'
/** `sessionId` prefixed `codeman_<id>` — codex's unique per-pane rollout originator. */
| 'codemanPrefixedSessionId'
/**
@@ -69,6 +71,14 @@ export type Cond =
*/
export type QuoteStyle = 'auto' | 'bare' | 'double' | 'single';
/**
* Workspace-relative directories an agent CLI scans for project skills. A closed list
* rather than a free path: the value decides where the server WRITES, and
* `~/.codeman/clis.json` can set it, so it must never be able to name `..` or an
* absolute path.
*/
export type AgentSkillDir = '.claude/skills' | '.agents/skills';
/** One argv element. */
export type ArgSpec =
/** A bare literal word, e.g. the base binary or codex's `resume` subcommand. */
@@ -90,6 +100,29 @@ export interface CliVariant {
args: ArgSpec[];
}
/** The newline chord a CLI's composer reads as "insert a line break" (see `CliCapabilities.newline`). */
export type NewlineSequence = 'line-feed' | 'esc-enter';
/** The config readers `capabilities.modelDetect.configResolver` may name (src/model-config-resolvers.ts). */
export type ModelConfigResolverName = 'deepseek-route';
/**
* The synced App Settings keys `capabilities.launchDefaults` may name (src/web/launch-defaults.ts).
* A closed list rather than any settings key, so a clis.json override cannot feed an
* arbitrary setting onto a command line; each name must also be a `SettingsUpdateSchema`
* key, which the resolver's typing enforces.
*/
export type LaunchDefaultSettingKey = 'codexModel' | 'codexReasoningEffort';
/** The MCP config dialects `src/mcp-sync.ts` has an adapter for. */
export type McpConfigFormat =
| 'claude-json'
| 'gemini-json'
| 'codex-toml'
| 'opencode-json'
| 'antigravity-json'
| 'copilot-json';
export interface CliLaunch {
params: Record<string, ParamSpec>;
/**
@@ -356,8 +389,8 @@ export interface CliCapabilities {
/**
* How many rows at the FOOT of the screen that row can appear in, counting non-blank
* rows only. Claude writes its chip on the last row and keeps the default; Codex pins
* its own above the composer, which puts it third from the bottom, so it declares
* more. Keep each number as small as that CLI's layout allows: every extra row is
* its own above the composer, which puts it third or fourth from the bottom (its hint
* row comes and goes), so it declares more. Keep each number as small as that CLI's layout allows: every extra row is
* another row an agent might be able to write, and the label is what silences an
* alert. See `watchingLabel()` in `session-activity.ts`.
*/
@@ -375,6 +408,65 @@ export interface CliCapabilities {
* tested. See `isAwaitingWorkers()` in `session-activity.ts`.
*/
awaitingLine?: string;
/**
* Source of a regex matching the dialog this CLI draws when it stops and waits for the
* user to answer something (an approval, a question, a trust prompt): Codeman reads
* such a pane as BLOCKED, with the same red "needs you" alert, push, webhook and
* `blocked` wait signal a Claude `permission_prompt` hook raises. It is the source of
* that state for a CLI with no hook of its own (`hooks: 'none'`); a CLI whose hooks
* report it never consults the screen for it.
*
* Tested against the last `blockedLines` non-blank rows of a pane capture, joined
* with `\n`, so the pattern can require several markers at once and anchor on the
* end of the window (no `m` flag: `$` is the last row). It must anchor on chrome
* only that CLI draws, at the FOOT of the screen, where a dialog replaces the
* composer: the text above is the agent's own, and it can print anything. A forged
* match may cost a false alert and nothing else, because Codeman never answers such a
* dialog. See `isBlockedScreen()` in `session-activity.ts`.
*
* A CLI that omits it is never blocked from the screen, which is what every CLI was
* before the field existed.
*/
blockedLine?: string;
/**
* How many non-blank rows at the foot of the screen `blockedLine` sees. A dialog's
* options wrap at narrow widths, which is what pushes its markers up the screen, so
* this is sized for the narrowest pane the CLI was measured at. Default 1.
*/
blockedLines?: number;
/**
* Source of a regex every non-blank row BELOW an idle composer must match (at most
* three rows): the footer and hint rows this CLI draws there and nowhere else. It is
* what lets the experimental guarded stage/submit routes (`agent-staging.ts`) prove
* that the `promptGlyph` row they read is this CLI's own composer, at rest, and not
* a dialog option or a transcript line the agent printed. A CLI that omits it never
* takes guarded input: stage and submit refuse it. Tested one row at a time.
*/
composerFooterLine?: string;
/**
* Source of a regex matching a row of a suggestion popup this CLI draws directly
* ABOVE its composer (a slash-command list, a file or mention search, its key-hint
* row). The footer below such a composer is unchanged, so without this guarded
* stage/submit would read the popup frame as an idle composer, and Enter would
* accept the highlighted entry instead of sending the draft. Tested one row at a
* time against the contiguous block just above the composer row, never further
* up the pane: a match there refuses the frame. See `readGuardedComposer()`.
*/
composerPopupLine?: string;
/**
* Characters that, as the first character of a draft, make Enter run a command
* rather than send a prompt (codex: `/` slash commands, `!` shell commands).
* Guarded stage refuses such a message before typing; commands go through the
* ordinary input route.
*/
composerCommandPrefixes?: string;
/**
* Characters that, starting the word under the caret, open a completion popup
* whose highlighted entry Enter accepts (codex: `@` file and mention search, `$`
* skills). Guarded stage refuses a message whose LAST word starts with one,
* before typing: the caret ends there, and the popup follows the caret.
*/
composerCompletionSigils?: string;
};
/**
* How many columns this CLI indents its transcript body by, so a copy taken from its
@@ -399,6 +491,17 @@ export interface CliCapabilities {
* Absent means no strip at all, the same fail-safe direction `workDetect` takes.
*/
transcriptGutter?: number;
/**
* Literal text the TUI draws once its composer can take a prompt — what `codeman agent
* spawn` waits for (a `wait-output` match) before it calls a worker ready.
*
* Deliberately NOT `workDetect.promptGlyph`: claude's `❯` also marks the selected row
* of its workspace-trust dialog, which is exactly the screen a readiness wait must not
* mistake for a composer, so claude declares its composer's own hint text instead.
* Absent means no readiness wait: a spawn returns as soon as the session exists, and
* the caller synchronizes on `wait-output` markers.
*/
composerReadyMark?: string;
/** No direct-PTY fallback: the CLI must run inside tmux (secrets ride tmux setenv). */
requiresMux: boolean;
/**
@@ -430,11 +533,40 @@ export interface CliCapabilities {
*/
transcript: 'claude-jsonl' | 'codex-rollout' | 'deepseek-zstd' | 'omp-jsonl' | 'none';
/**
* 'strip-full' — alt-screen + erase-scrollback + mouse DECSETs stripped (Ink TUIs).
* 'strip-mux-only' — only tmux's own attach-time smcup (the safe default).
* 'preserve' — leave everything (a direct-PTY shell running vim/less/htop).
* What the server strips from this CLI's output stream before the browser sees it.
* The value encodes three independent choices (predicates in session.ts):
*
* | value | alt-screen toggles | `3J` (erase scrollback) | mouse DECSETs |
* |-----------------------|---------------------|-------------------------|---------------------|
* | `strip-full` | stripped | stripped | stripped |
* | `strip-mux-and-mouse` | stripped under tmux | kept | stripped under tmux |
* | `strip-mux-only` | stripped under tmux | kept | kept |
* | `preserve` | stripped under tmux | kept | kept |
*
* `strip-full` is `isAltScreenStripMode`; `strip-mux-and-mouse` is `isMuxMouseStripMode`;
* every other value takes `isMuxAltScreenOnlyStripMode`, so at runtime `preserve` and
* `strip-mux-only` are the same row — `preserve` only says what such a CLI's pane
* holds (terminal-owned scrollback: a shell, pi), not a different strip.
*
* - alt-screen: the tmux CLIENT emits `smcup` as its first bytes at attach, parking
* xterm in the scrollback-less alternate buffer; a pane program's own toggles never
* reach the client (tmux repaints instead). "Under tmux" means `useMux`: on a
* direct-PTY fallback the `?1049h` is the program's own and must stay.
* - `3J`: a user's `clear` is a deliberate scrollback wipe; only an Ink TUI's
* redraw-driven `3J` (strip-full) is noise.
* - mouse DECSETs: stripping them keeps a drag a local selection instead of a report
* to the TUI. The browser then hand-encodes clicks (`_sendSyntheticSgrTap`), gated
* on the `cliMouseTracking` the server records as it strips. Kept where a program's
* own mouse support must work in the pane (htop/vim in a shell).
*
* Stock CLIs: `strip-full` = claude, codex, gemini (Ink TUIs); `strip-mux-and-mouse` =
* opencode and copilot (full-screen TUIs that enable tracking themselves); `strip-mux-only` =
* antigravity, grok, deepseek, omp; `preserve` = shell, pi.
*
* A fourth combination is the point to split this into flags; three is still cheaper
* as an enum.
*/
altScreen: 'strip-full' | 'strip-mux-only' | 'preserve';
altScreen: 'strip-full' | 'strip-mux-only' | 'strip-mux-and-mouse' | 'preserve';
echo: {
policy: 'buffer' | 'predict' | 'off';
/** How the local-echo overlay locates the composer row. */
@@ -442,8 +574,54 @@ export interface CliCapabilities {
/** Names a PREDICT_PROFILES key. Unknown or absent degrades to 'buffer', never to broken. */
predictProfile?: string;
};
/** Forwarding the wheel to the CLI's own transcript. 'never' keeps local scrollback. */
wheelForward: { mode: 'never' | 'version-gated'; minVersion?: string };
/**
* Forwarding the wheel to the CLI's own transcript. 'never' keeps local scrollback;
* 'version-gated' forwards from `minVersion` on; 'always' forwards at any version. Either
* forwarding mode applies only while the CLI has mouse tracking on (`cliMouseTracking`).
*
* ⚠️ Declared only: nothing reads this field yet (see DECLARED-FOR-LATER below). The browser's
* gate is `_shouldForwardWheelToApp` in terminal-ui.js, which keeps its own hand-written
* per-CLI rules, so changing a value here changes nothing until that gate is changed too.
*/
wheelForward: { mode: 'never' | 'version-gated' | 'always'; minVersion?: string };
/**
* The CLI pages its OWN transcript on PageUp/PageDown, so a wheel or touch scroll over
* a pane with no local history can be turned into those keys instead of doing nothing
* (`_maybePageCliTranscript` in terminal-ui.js). Read by the frontend through
* `window.__codemanTranscriptPageKeys`, which the server builds from this field.
*
* ⚠ NOT wheel forwarding. `wheelForward` above sends SGR mouse wheel REPORTS; this sends
* plain keys, and only when the local buffer is hollow (normal buffer, `baseY === 0`).
* A CLI can ignore one and honour the other: Codex without observed mouse tracking
* uses this fallback, while fullscreen Codex 0.163.0 accepts SGR wheel reports.
*
* Absent means no paging, so a CLI nobody has measured keeps plain local scrolling.
*/
transcriptPageKeys?: boolean;
/**
* `'inline'`: the CLI renders inline (its content stacks at the bottom of the
* main screen), so while a phone's on-screen keyboard is up the browser keeps
* the PTY's rows and scrolls the view instead of resizing (`keyboardHoldsRows`,
* terminal-ui.js `_keyboardHoldEligible`). Read by the frontend through
* `window.__codemanKeyboardHoldModes`, which the server builds from this field.
*
* Why it exists: every PTY height change makes Claude Code's inline renderer
* clear the screen and reprint only the tail that fits (#672). Measured on
* Claude Code 2.1.306 with tmux 3.2a, a fresh session already at the phone's
* size printing 100 numbered lines: five keyboard open/close cycles (PTY
* 62x38 to 62x15 and back, one SIGWINCH per transition) left 75 of the 100 in
* `tmux capture-pane` history, the other 25 gone for good; with the rows held
* the PTY stayed 62x38, no SIGWINCH reached the pane and all 100 remained.
*
* ⚠ Only for a renderer that is inline. A CLI that lays out the FULL height
* (codex: banner at the top, composer pinned to the bottom) cannot fit both
* ends above the keyboard and must be resized instead, so it stays absent.
* Absent means resize, the safe default for an unmeasured CLI. Even when set,
* the hold stands aside while the CLI reports mouse tracking (Claude's
* fullscreen mode, which lays out the full height and has not been measured
* losing lines, so it resizes as before), and for remote and docker sessions.
*/
keyboardHold?: 'inline';
keyboardAccessory: 'agent' | 'shell';
/** Multi-user: this CLI is a raw shell, so its commands need the privileged gate. */
privilegedCommandGate: boolean;
@@ -452,10 +630,72 @@ export interface CliCapabilities {
ralph: boolean;
respawn: boolean;
effort: boolean;
/**
* Codeman may write its agent skill (`skills/codeman`) into this CLI's workspace and
* seed its per-session preamble (behind the synced `agentSkillEnabled` setting,
* add-only, marker-owned). Where it lands comes from `agentSkillDir` below.
*/
agentSkillInjection: boolean;
/**
* The workspace-relative directory this CLI scans for project skills, so the skill
* lands at `<workspace>/<agentSkillDir>/codeman/SKILL.md` where the CLI discovers it.
* Measured per CLI. Absent with `agentSkillInjection: true` means Claude Code's
* `.claude/skills`, which is what the boolean meant before this field existed, so a
* `~/.codeman/clis.json` written against the old schema keeps its behaviour.
*/
agentSkillDir?: AgentSkillDir;
statusLineTelemetry: boolean;
/** Where a model override is delivered. Claude uniquely writes settings.local.json. */
model: { source: 'flag' | 'claude-settings-file' | 'none'; param?: string };
/**
* Where this CLI draws the model it is running, so a session header can name it
* (`SessionState.displayModel`, src/session-display-model.ts).
*
* `screenLine` is the source of a regex with exactly ONE capture group, the model. It
* runs over the last `screenLines` non-blank rows of the pane capture the idle/working
* probe already takes (rows joined with `\n`, so a pattern may span them), which costs no
* extra tmux call and re-reads the footer at every turn transition, so an in-session
* `/model` switch is followed.
*
* ⚠ The rows are pane text and the agent writes most of a pane, so a pattern must anchor
* on chrome only this CLI draws (the row under its own composer, an effort word in its
* own footer format), never on a shape the agent could print in its transcript. Measured
* on a live pane per CLI; absent means the CLI's screen is never read for a model and
* the session shows its launch model, if any.
*
* `configResolver` names a reader (src/model-config-resolvers.ts) that resolves the
* model the CLI's own config pins, the way that CLI resolves it for the session, for
* while the screen names none (its status line switched off, or not drawn yet). Read
* once per pane start, attach or relaunch, bounded and read-only; the screen still
* wins whenever it names a model. A NAMED reader, like a launcher profile, so the
* per-CLI behaviour stays data here and code in one module.
*/
modelDetect?: {
screenLine?: string;
screenLines?: number;
/**
* Words the `screenLine` field can show when it is NOT the model (a footer whose model
* field is switched off shows the next field there), compared lower-cased. A field
* equal to the session's own working-directory basename is never the model either,
* for every CLI; that rule is the shared reader's, not data.
*/
rejectWords?: string[];
configResolver?: ModelConfigResolverName;
};
/**
* Synced App Settings that seed this CLI's launch params when the caller left them unset,
* keyed by LAUNCH PARAM name (`{ model: 'codexModel' }`), never the legacy wire name; the
* resolver translates through `launch.legacyConfigAliases` like every other `param`.
*
* Filled into the entry's `launch.legacyConfigField` object at create time by
* `applyLaunchDefaults()` (src/web/launch-defaults.ts), which re-validates each value
* with `SettingsUpdateSchema` and never overwrites a value the caller sent. Which
* launches get it is the CALLER's decision (local ones only: never remote, Docker or a
* custom model endpoint). `schema.ts` refuses an undeclared param, and an entry without
* a `legacyConfigField`, whose params would otherwise be read off the request body itself.
* Absent = no launch defaults.
*/
launchDefaults?: Record<string, LaunchDefaultSettingKey>;
/**
* Params a non-granted multi-user owner may not set freely, and what they are forced to.
* Data-driven so a CUSTOM CLI's bypass flag is clampable exactly like codex's.
@@ -511,6 +751,33 @@ export interface CliCapabilities {
gates: Record<string, { minVersion: string; failClosed: boolean }>;
/** Cap on a single terminal frame, when this CLI needs a tighter one than the default. */
maxFrameBytes?: number;
/**
* The bytes the web UI types into this CLI's pane for Shift+Enter (the `send-key` route).
* `line-feed` (`0x0a`, also what Ctrl+Enter sends) is what Claude Code's Ink input and most TUIs
* read as "insert a newline"; `esc-enter` (`ESC` `CR`, the same chord as Option/Alt+Enter and
* the mobile ⌥Enter key) is for a TUI that ignores a bare line feed. Absent = `line-feed`.
* Data, not a branch on the CLI id, so supporting another CLI's quirk is one line here.
*/
newline?: NewlineSequence;
/**
* Where this CLI keeps its user-level MCP server list, for MCP sync (`src/mcp-sync.ts`).
* `path` is relative to the home directory. `format` names the file dialect the sync
* adapter reads and writes. Absent = no known/verified MCP config file, so the CLI is
* skipped by sync rather than guessed at.
*
* `relocation` names the env var the CLI itself reads to move that file (codex's
* `CODEX_HOME`, claude's `CLAUDE_CONFIG_DIR`, opencode's `XDG_CONFIG_HOME`). When the SERVER
* process env (what the CLIs Codeman spawns inherit) sets it to an absolute directory, the
* file is `<that dir>/<relocation.path>` instead; set to anything else, the target is
* reported `skipped` rather than written somewhere the CLI never reads. Absent = the file
* only follows `$HOME`.
*/
mcpConfig?: {
path: string;
altPath?: string;
format: McpConfigFormat;
relocation?: { envVar: string; path: string };
};
/**
* How this CLI is pointed at a user-supplied custom OpenAI-compatible
* endpoint (local, e.g. llama.cpp, or cloud, e.g. Azure AI Foundry) — the
@@ -547,9 +814,11 @@ export interface CliCapabilities {
* for pi and omp (`--model custom/<id>`, or the CLI stays on its own default
* provider and reports "No API key found for the selected model") or for
* grok (`--model codeman-custom`, the `[model.<name>]` block the config
* declares). Absent = the config alone selects the model (claude's env vars,
* opencode's blob, codex's top-level `model` key). Applied by the session's
* respawn options through the entry's `legacyConfigField`, never by id.
* declares), and codex carries its bare selected id (`{modelId}`, an id the
* entry's `model` token pattern must be able to carry). Absent = the config
* alone selects the model (claude's env vars, opencode's blob). Applied by
* the session's respawn options through the entry's `legacyConfigField`,
* never by id.
*
* `contextLengthVar` (env kind only): the env var a discovered per-model context-window
* size is written to when known (claude's `CLAUDE_CODE_MAX_CONTEXT_TOKENS`) — without it,
@@ -591,19 +860,13 @@ export interface CliCapabilities {
* reuses that file.
*
* `appendV1Suffix` (env kind only): the raw `endpoint.baseUrl` gets `withV1Suffix()`
* applied before being written to `baseUrlVar`, instead of being used verbatim.
* DeepSeek needs this and claude/gemini must NOT get it — a per-CLI asymmetry confirmed
* by reading each SDK's own request-building source, not assumed: DeepSeek Harness's
* bundled `@deepseek-ai/dsh-llm-deepseek` concatenates `${connection.baseURL}/chat/
* completions` with no `/v1` insertion of its own (its real public API base,
* `https://api.deepseek.com`, expects the caller's base URL to already carry any
* needed prefix), while llama-swap/llama.cpp only ever serves the OpenAI-conventional
* `/v1/chat/completions` — confirmed live: a bare `POST <baseUrl>/chat/completions`
* 404s, `POST <baseUrl>/v1/chat/completions` succeeds, and the harness's own error
* message template (`DeepSeek API error (HTTP ${status})`) reproduces the exact
* `HTTP_404` this feature originally shipped with unexplained. Claude Code's own SDK,
* by contrast, was already confirmed working end-to-end against the RAW `baseUrl` with
* no suffix — appending one there would be wrong, not just redundant.
* applied before being written to `baseUrlVar`, instead of being used verbatim. Only
* copilot declares it: its OpenAI-style route is `<base>/v1/chat/completions`, and a
* gateway saves the bare root. claude/gemini must NOT get it: Claude Code's own SDK was
* confirmed working end-to-end against the RAW `baseUrl`, so appending a suffix there
* would be wrong, not just redundant. The per-session config-file recipes do not need
* this flag at all: `renderConfigFile()` applies the suffix inside every template
* (deepseek's patch included), so no configDir entry carries a raw base URL.
*/
customModelInjection:
| {
@@ -623,8 +886,40 @@ export interface CliCapabilities {
kind: 'configDir';
dirEnvVar: string;
fileName: string;
template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml';
template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml' | 'deepseek-patch-json';
launchModel?: string;
/**
* Names of entries under this CLI's REAL home that must be symlinked into the
* isolated directory after the generated file is written: the same trick
* `linkSharedProjectsDir()` plays for claude's `projects`. Declared for CLIs whose
* home carries state that must survive the redirect (deepseek's `sessions` transcript
* root and `profiles` bundles, plus `settings.yaml`, which would otherwise stop
* applying and, for `sessions`, get deleted by `removeConfigDir` on clear/close).
* A CLI that saves a linked file by rename replaces the link: dsh does, so the first
* in-session settings write turns `settings.yaml` into a local file.
* Never include `fileName` itself: the generated file owns that name in the isolated
* directory, and a link there would write THROUGH into the user's real file.
* Consumed generically by custom-model-injection-apply.ts.
*/
linkFromRealHome?: string[];
/**
* Which of `linkFromRealHome` are DIRECTORIES (`sessions`, `profiles`) rather than
* files (`settings.yaml`). A missing directory target is created (mode 0700, what
* dsh does itself on a first turn) before linking: linking a nonexistent directory
* leaves a dangling symlink the CLI's own `mkdir` under it fails on with ENOENT on
* EVERY turn. A missing FILE target is skipped instead of linked (dangling-file
* links serve no purpose the CLI could not do itself). Must be a subset of
* `linkFromRealHome`; entries absent from it are treated as files.
* Consumed generically by custom-model-injection-apply.ts.
*/
linkFromRealHomeDirs?: string[];
/**
* Fallback location of the CLI's real home, relative to the user's home directory
* (deepseek's `.dsh`), used only when neither the session's own `dirEnvVar` override
* nor the server's environment sets it. Required whenever `linkFromRealHome` is
* declared, so no consumer has to hardcode a per-CLI default of its own.
*/
realHomeFallback?: string;
}
| { kind: 'unsupported' };
}
+21
View File
@@ -7,6 +7,8 @@
* @module config/dependency-registry
*/
import { homedir } from 'node:os';
import { join } from 'node:path';
import { enabledClis } from './cli-registry/registry.js';
import { compileVersionRegex } from './cli-registry/patterns.js';
@@ -30,6 +32,24 @@ export interface PathResolver {
* there and a false "installed" contradicts the run mode's own resolver.
*/
requireVersionMatch?: boolean;
/**
* Absolute directories to probe (`<dir>/<bin>`) when `which` misses. A service (systemd,
* launchd) runs with a minimal PATH, so a CLI installed under `~/.local/bin` or an npm/nvm
* prefix is invisible to `which` while the run mode, which falls back to the registry's
* `discovery.searchDirs`, still finds it. Carries those dirs so the doctor agrees.
*/
searchDirs?: string[];
}
/**
* Expand a leading `~` (the only form registry `searchDirs` use). Twin of `expandHome()` in
* src/utils/cli-resolver.ts, copied rather than imported because importing it from config/
* would pull in the whole resolver chain; keep the two in step.
*/
function expandSearchDir(dir: string): string {
if (dir === '~') return homedir();
if (dir.startsWith('~/')) return join(homedir(), dir.slice(2));
return dir;
}
/** Resolve a Windows-installed app reachable from win32 or WSL. */
@@ -131,6 +151,7 @@ function cliDependencyEntries(): ToolDependency[] {
// (pi, grok, dsh): a bare `which` hit there is not evidence of the right
// program, so a version mismatch means MISSING rather than unknown-version.
requireVersionMatch: version?.requireVersionMatch,
searchDirs: cli.discovery.searchDirs.map(expandSearchDir),
},
},
],
+30 -1
View File
@@ -18,11 +18,40 @@
// Session Tracking Limits
// ============================================================================
/** Default cap on concurrent sessions when `CODEMAN_MAX_SESSIONS` is not set. */
const DEFAULT_MAX_CONCURRENT_SESSIONS = 50;
/**
* Highest value `CODEMAN_MAX_SESSIONS` can raise the cap to. An owner's tab layout
* holds at most `MAX_TAB_REFS` (512) refs, and those count saved web tabs as well
* as sessions; an owner can save up to `MAX_WEBVIEWS` (50 by default) web tabs.
* 512 - 50 = 462 keeps session creation answering with the capacity message
* instead of failing with a 500 on the layout limit, at the default `MAX_WEBVIEWS`
* and with no pinned closed sessions: a pinned session stays in the layout as a
* stopped record after it is closed, and it shares the same 512-ref budget.
* (`PUT /api/session-order` accepts up to 500 ids, which stays above this.)
*/
export const MAX_CONCURRENT_SESSIONS_CEILING = 462;
/**
* Resolve the session cap from `CODEMAN_MAX_SESSIONS` (a positive integer,
* clamped to {@link MAX_CONCURRENT_SESSIONS_CEILING}). Anything else (unset,
* empty, 0, negative, fractional, not a number) falls back to
* {@link DEFAULT_MAX_CONCURRENT_SESSIONS}, so a typo never locks session creation.
*/
function resolveMaxConcurrentSessions(): number {
const raw = process.env.CODEMAN_MAX_SESSIONS?.trim();
if (!raw || !/^\d+$/.test(raw)) return DEFAULT_MAX_CONCURRENT_SESSIONS;
const n = Number(raw);
return n >= 1 ? Math.min(n, MAX_CONCURRENT_SESSIONS_CEILING) : DEFAULT_MAX_CONCURRENT_SESSIONS;
}
/**
* Maximum concurrent sessions allowed.
* Each session consumes significant resources (PTY, buffers, watchers).
* 50 unless `CODEMAN_MAX_SESSIONS` says otherwise (1 to 462); read once at startup.
*/
export const MAX_CONCURRENT_SESSIONS = 50;
export const MAX_CONCURRENT_SESSIONS = resolveMaxConcurrentSessions();
// ============================================================================
// SSE Client Limits
+55
View File
@@ -0,0 +1,55 @@
/**
* @fileoverview Limits for the bounded path probe (`src/utils/bounded-path-probe.ts`).
*
* A linked case can live on a network mount, and a hard mount that went away makes
* `stat()` wait until the mount comes back. The probe gives up on such a path after
* `PATH_PROBE_TIMEOUT_MS` and answers "unknown", and it stops starting new probes
* once `MAX_STALLED_PATH_PROBES` timed-out stats are still holding libuv threadpool
* workers (the pool is shared by every `fs`, `dns.lookup` and `crypto` call in the
* process, and holds 4 workers unless `UV_THREADPOOL_SIZE` says otherwise).
*
* Both are env-overridable, in the same style as the other config modules. A slow
* but healthy mount (an sshfs that needs a couple of seconds on first touch) may want
* a longer timeout. The stall limits follow `UV_THREADPOOL_SIZE` on their own, so a
* server started with a larger pool gets a higher ceiling without further setup.
*
* @module config/path-probe
*/
function envInt(name: string, fallback: number, min: number, max: number): number {
const raw = parseInt(process.env[name] || '', 10);
if (!Number.isFinite(raw) || raw <= 0) return fallback;
return Math.max(min, Math.min(max, raw));
}
/** How long a caller waits for one path probe before the answer is "unknown". */
export const PATH_PROBE_TIMEOUT_MS = envInt('CODEMAN_PATH_PROBE_TIMEOUT_MS', 1_500, 100, 60_000);
/**
* Hard ceiling on timed-out probes left pending, for every caller, `pastCap` ones
* included: the threadpool size minus one, so a dead mount can never take the last
* worker. libuv sizes the pool from `UV_THREADPOOL_SIZE` (4 when unset). A pool of
* one cannot keep a worker free at all, so the ceiling never drops below one.
*/
export const PATH_PROBE_STALL_CEILING = Math.max(1, (Number(process.env.UV_THREADPOOL_SIZE) || 4) - 1);
/**
* Timed-out probes allowed to stay pending before new BULK probes are refused
* (answered "unknown" without a stat). This is a backstop, not the main defence: a
* stalled path on a network or FUSE mount already takes the rest of that mount out
* of probing (a stall anywhere else takes out only the stalled path), so the cap
* only engages once that many UNRELATED places have stopped answering. It defaults
* to one below {@link PATH_PROBE_STALL_CEILING} (2 with the default pool), leaving a
* slot a `pastCap` probe may still use, and is never allowed above the ceiling.
*/
export const MAX_STALLED_PATH_PROBES = Math.min(
PATH_PROBE_STALL_CEILING,
envInt('CODEMAN_PATH_PROBE_MAX_STALLED', Math.max(1, PATH_PROBE_STALL_CEILING - 1), 1, 64)
);
/**
* How long a session waits before trying again to resolve its workspace's symlinks
* after the folder did not answer, so a mount that recovers is picked up (the pane
* directory reading stays in the resolved spelling meanwhile).
*/
export const WORKSPACE_REALPATH_RETRY_MS = 60_000;
+11
View File
@@ -70,6 +70,17 @@ export const SESSION_LIMIT_WAIT_MS = 5000;
/** Pause between scheduled run iterations (ms) */
export const ITERATION_PAUSE_MS = 2000;
// ============================================================================
// Git Fetch Interval
// ============================================================================
/**
* How often the fetch scheduler (src/git-fetch-scheduler.ts) wakes to check which sessions are due (ms). A
* run lands within one tick of the interval the user set, because the interval is timed from the start of
* a run; a tick is a settings read and a due check, not a spawn, so a quiet install costs nothing.
*/
export const GIT_FETCH_TICK_INTERVAL_MS = 15 * 1000;
// ============================================================================
// Mux Stats
// ============================================================================