refactor(cli-registry): make CLI backends data instead of per-mode branching

Every run mode is now a `CliEntry` in `src/config/cli-registry/` — discovery
(search dirs, version + identity probes), the launch argv template, env
handling, the `capabilities` flags that replace per-CLI branching, and the
`overlays` that back the remote/docker pane commands. Code that used to ask
"which CLI is this?" reads the entry instead.

Behaviour is unchanged. `test/cli-registry-spawn-golden.test.ts` pins every
spawn command as a literal string, captured from the hand-written builders
before they were deleted, and `test/location-overlay-commands.test.ts` does the
same for all 20 remote and in-container pane commands.

Config can never contain shell text: an entry declares typed argv tokens,
literals are validated against a safe-word pattern at LOAD time (a bad literal
rejects the whole entry — a silently dropped `--no-approve` is not cosmetic),
and values resolve through patterns NAMED in code, so a user `clis.json` cannot
widen its own validation. `~/.codeman/clis.json` overrides any entry, read-only
in this release.

OMP is included as a registry entry rather than a tenth hand-written builder,
so `buildOmpCommand()`, the omp availability pre-flight, the omp arm of
`buildPathExport()` and the omp entries in the truecolor/NO_COLOR, alt-screen
and doctor ladders all drop out.

Guard rails:

- `test/cli-registry-no-id-branching.test.ts` fails the build if per-CLI-id
  branching reappears outside `stock.ts`, in any of its four shapes (`===`,
  `!==`, `switch`/`case`, `includes`) — an `===`-only version would miss the
  negated forms, which is how 36 of them survived an earlier pass. Every
  allowlisted branch carries its reason.
- `external`, `hooks` and `altScreen` stay three INDEPENDENT capabilities;
  deriving one from another shipped the `until=stop`-hangs-on-shell bug.
- `param` is two namespaces. `launch.params` keys, `configSetenv.fromParam` and
  `privilegedParams[].param` all name a LAUNCH param; the legacy `<Mode>Config`
  wire field is separate, bridged only by `legacyConfigAliases`. Getting
  `privilegedParams[].param` wrong is SILENT — it is the multi-user bypass
  clamp's only handle on a CLI's privilege switch, and a wrong name clamps
  nothing with no error and no failing test — so `schema.ts` rejects an entry
  naming a param it never declared.
- Registry data resolves AT CALL TIME (`sessionModeSchema()`,
  `allowedEnvPrefixes()`, `dependencyRegistry()`, the resolvers' `searchDirs`
  thunks). A module-level const freezes at first import, so a CLI enabled while
  the server ran moved the run menu but not that surface.
- Six fields are annotated DECLARED-FOR-LATER and read by nothing
  (`shortBadge`, `accent`, `capabilities.echo`/`wheelForward`/
  `keyboardAccessory`/`maxFrameBytes`): all frontend behaviour, transcribed
  rather than measured. A test pins the list so it cannot quietly grow.

Three user-visible changes, all deliberate and named:

- `probeDockerCliVersion()` derives the in-container binary from the registry
  rather than assuming it equals the mode name (`antigravity` runs `agy`).
- The remote CLI version probe now covers grok and deepseek, which the
  hardcoded map it replaces omitted while its own comment said the rule was
  "every mode except shell".
- `codeman doctor`'s CLI rows are generated from the entries, so Claude's
  install hint is the install command rather than a docs URL, five CLIs gain
  hints they never had, and the row order follows the catalog.

Also hardened along the way: `sessionModeSchema()` is bounded at 24 chars
(matching the `cliId` pattern) before its failure message quotes the value
back, and `deepMerge` skips `__proto__`/`constructor`/`prototype` when reading
the hand-editable `clis.json`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQkoi1cNegqVwZHgzx5SbJ
This commit is contained in:
Devvyn
2026-09-02 08:26:45 +08:00
co-authored by Claude Opus 5
parent 71ffbf18e4
commit 4830e662f9
45 changed files with 5772 additions and 1478 deletions
+3 -2
View File
@@ -7,6 +7,7 @@
*/
import { FastifyInstance } from 'fastify';
import { getCli } from '../../config/cli-registry/registry.js';
import { ApiErrorCode, createErrorResponse } from '../../types.js';
import { CronJobSchema, CronJobUpdateSchema, CronJobEnabledSchema } from '../schemas.js';
import { canAccessOwned, getAuthUser, isWorkingDirAllowed, ownerFor, parseBody } from '../route-helpers.js';
@@ -45,7 +46,7 @@ export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void {
// Resolve the owner's grant from the store (AuthUser.role alone can't tell a GRANTED
// regular user from a plain one); mirrors session-routes + the cron fire-time re-check.
if (
(body.agentType === 'shell' || body.launchCommand) &&
(getCli(body.agentType)?.capabilities.privilegedCommandGate || body.launchCommand) &&
!(await canUsernameRunPrivilegedCommands(ownerFor(req)))
) {
return createErrorResponse(
@@ -72,7 +73,7 @@ export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void {
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'workingDir is outside your workspace');
}
if (
(body.agentType === 'shell' || body.launchCommand) &&
(getCli(body.agentType ?? 'claude')?.capabilities.privilegedCommandGate || body.launchCommand) &&
!(await canUsernameRunPrivilegedCommands(ownerFor(req)))
) {
return createErrorResponse(
+187 -270
View File
@@ -29,7 +29,7 @@ import {
type DeepSeekConfig,
type OmpConfig,
} from '../../types.js';
import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode } from '../../session.js';
import { Session, isAltScreenStripMode, isExternalCliMode, isMuxAltScreenOnlyStripMode } from '../../session.js';
import { SseEvent } from '../sse-events.js';
import {
CreateSessionSchema,
@@ -82,6 +82,9 @@ import {
validatePathWithinBase,
} from '../route-helpers.js';
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js';
import { enabledClis, getCli } from '../../config/cli-registry/registry.js';
import { resolveCliLaunchError } from '../../utils/cli-launcher.js';
import { legacyConfigForMode } from '../../session-cli-registry-bridge.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
import {
@@ -356,12 +359,54 @@ export function _resetPasteRateBuckets(): void {
*/
async function clampExternalCliBypassForOwner(
owner: string | undefined,
codexConfig: CodexConfig | undefined,
geminiConfig: GeminiConfig | undefined,
antigravityConfig: AntigravityConfig | undefined,
piConfig: PiConfig | undefined,
grokConfig: GrokConfig | undefined,
deepSeekConfig: DeepSeekConfig | undefined
configs: Record<string, unknown>
): Promise<Record<string, unknown>> {
if (await canUsernameRunPrivilegedCommands(owner)) return configs;
const out = { ...configs };
for (const entry of enabledClis()) {
const field = entry.launch.legacyConfigField;
if (!field) continue;
// `privilegedParams[].param` names the REGISTRY param, so it has to be translated to the
// legacy wire field on the way out — the same `legacyConfigAliases` hop `configSetenvValues`
// already makes. Writing `param` straight through would put it in a DIFFERENT namespace
// from every other `param` in the schema, and a name that is right in one and wrong in the
// other is a SILENT no-op: no load error, no failing test, the clamp simply stops clamping.
// Codex is where the two names differ (`bypassApprovals` vs `dangerouslyBypassApprovals`),
// and `schema.ts` refuses an entry naming a param it never declared.
const aliases = entry.launch.legacyConfigAliases ?? {};
const existing = out[field] as Record<string, unknown> | undefined;
let next = existing;
for (const { param, clampTo, materializeWhenAbsent } of entry.capabilities.privilegedParams) {
// MATERIALIZE vs ONLY-IF-SENT is the whole design of this clamp, and the two are not
// interchangeable — see CliCapabilities.privilegedParams. Materialize where the CLI's
// own absent-config default is ITSELF unsafe (gemini defaults to yolo; pi's default is
// an interactive trust prompt the session user could just answer "yes" to), so a
// caller who sends no config at all still gets clamped.
if (next === undefined && !materializeWhenAbsent) continue;
next = { ...(next ?? {}), [aliases[param] ?? param]: clampTo };
}
if (next !== existing) out[field] = next;
}
return out;
}
/**
* Test hook, and the positional shape the clamp has always been called with in tests.
*
* The clamp itself is now generic over the registry, which is what makes a CUSTOM CLI's
* privileged flag clampable with no code here — previously the five config objects were
* named individually, so `privilegedParams` on anything outside that list was declared but
* unreachable.
*/
export async function _clampExternalCliBypassForOwner(
owner: string | undefined,
codexConfig?: CodexConfig,
geminiConfig?: GeminiConfig,
antigravityConfig?: AntigravityConfig,
piConfig?: PiConfig,
grokConfig?: GrokConfig,
deepSeekConfig?: DeepSeekConfig
): Promise<{
codexConfig: CodexConfig | undefined;
geminiConfig: GeminiConfig | undefined;
@@ -370,34 +415,24 @@ async function clampExternalCliBypassForOwner(
grokConfig: GrokConfig | undefined;
deepSeekConfig: DeepSeekConfig | undefined;
}> {
const granted = await canUsernameRunPrivilegedCommands(owner);
if (granted) return { codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig, deepSeekConfig };
// Non-granted: force codex/antigravity bypass off (only meaningful when a config was
// sent) and materialize gemini to auto_edit (clamps an explicit 'yolo' and the yolo default)
// and pi to --no-approve (clamps an explicit true AND pi's own "ask" default).
const clampedCodex = codexConfig ? { ...codexConfig, dangerouslyBypassApprovals: false } : codexConfig;
const clampedGemini: GeminiConfig = { ...(geminiConfig ?? {}), approvalMode: 'auto_edit' };
const clampedAntigravity = antigravityConfig
? { ...antigravityConfig, dangerouslySkipPermissions: false }
: antigravityConfig;
const clampedPi: PiConfig = { ...(piConfig ?? {}), approveProjectTrust: false };
const clampedGrok = grokConfig ? { ...grokConfig, alwaysApprove: false } : grokConfig;
const clampedDeepSeek = deepSeekConfig
? { ...deepSeekConfig, permissionMode: 'workspace-write' as const }
: deepSeekConfig;
return {
codexConfig: clampedCodex,
geminiConfig: clampedGemini,
antigravityConfig: clampedAntigravity,
piConfig: clampedPi,
grokConfig: clampedGrok,
deepSeekConfig: clampedDeepSeek,
const out = await clampExternalCliBypassForOwner(owner, {
codexConfig,
geminiConfig,
antigravityConfig,
piConfig,
grokConfig,
deepSeekConfig,
});
return out as {
codexConfig: CodexConfig | undefined;
geminiConfig: GeminiConfig | undefined;
antigravityConfig: AntigravityConfig | undefined;
piConfig: PiConfig | undefined;
grokConfig: GrokConfig | undefined;
deepSeekConfig: DeepSeekConfig | undefined;
};
}
/** Test hook: the clamp is the multi-user safety gate for the external CLIs' privileged flags. */
export const _clampExternalCliBypassForOwner = clampExternalCliBypassForOwner;
/**
* Env-var keys a non-granted owner must not be able to set, because each one
* hands back privilege the config clamp above just removed, or redirects a
@@ -414,7 +449,7 @@ export const _clampExternalCliBypassForOwner = clampExternalCliBypassForOwner;
* - `DSH_HOME` points the launcher at a profile tree, and a profile's plugin code
* executes at BOOT, before any approval row can apply. A user who can write a
* workspace can put a profile in it, so this is the wider of the two.
* - `DEEPSEEK_BASE_URL` aims the provider endpoint, and `_configureDeepSeek()`
* - `DEEPSEEK_BASE_URL` aims the provider endpoint, and `_configureCliEnv()`
* forwards the SERVER's own `DEEPSEEK_API_KEY` into every dsh pane before
* `applyEnvOverrides()` runs — so a non-granted owner who could set the base
* URL would have the operator's API key sent as a bearer credential to a host
@@ -431,25 +466,21 @@ export const _clampExternalCliBypassForOwner = clampExternalCliBypassForOwner;
* Ark0N/Codeman#353 review; omp's own knobs are otherwise mostly `PI_*`,
* already allowlisted for pi and not addressed here — see resolveOmpHome()).
*/
const OWNER_CLAMPED_ENV_KEYS = [
'DSH_PERMISSION_MODE',
'DSH_HOME',
'DEEPSEEK_BASE_URL',
'OMP_AUTH_BROKER_URL',
'OMP_AUTH_BROKER_TOKEN',
] as const;
function ownerClampedEnvKeys(): string[] {
return enabledClis().flatMap((entry) => entry.capabilities.privilegedEnvKeys);
}
/**
* Env-var half of the multi-user bypass clamp.
*
* `clampExternalCliBypassForOwner()` clamps the per-CLI CONFIG, and for every CLI
* but DeepSeek that is the whole story. Here it is not: `applyEnvOverrides()` runs
* AFTER `_configureDeepSeek()` in tmux-manager, so an override sent on the SAME
* AFTER `_configureCliEnv()` in tmux-manager, so an override sent on the SAME
* request lands last and wins, and a non-granted owner could restore
* `danger-full-access` on the very request the config clamp downgraded.
*
* Keys are DROPPED rather than rewritten: dropping falls through to what
* `_configureDeepSeek()` exports, which is the clamped config and the server's own
* `_configureCliEnv()` exports, which is the clamped config and the server's own
* `DSH_HOME`, i.e. exactly the intended state. No-op in single-user mode and for a
* granted owner, like every other clamp here
* (`canUsernameRunPrivilegedCommands()` returns true when `!isMultiUserMode()`),
@@ -460,38 +491,17 @@ async function clampEnvOverridesForOwner(
envOverrides: Record<string, string> | undefined
): Promise<Record<string, string> | undefined> {
if (!envOverrides) return envOverrides;
if (!OWNER_CLAMPED_ENV_KEYS.some((key) => key in envOverrides)) return envOverrides;
const keys = ownerClampedEnvKeys();
if (!keys.some((key) => key in envOverrides)) return envOverrides;
if (await canUsernameRunPrivilegedCommands(owner)) return envOverrides;
const clamped = { ...envOverrides };
for (const key of OWNER_CLAMPED_ENV_KEYS) delete clamped[key];
for (const key of keys) delete clamped[key];
return clamped;
}
/** Test hook: the env-var half of the same multi-user safety gate. */
export const _clampEnvOverridesForOwner = clampEnvOverridesForOwner;
/**
* Why a DeepSeek session cannot start, or null when it can.
*
* Availability for this mode is TWO questions, not one, because `dsh` is a
* profile launcher rather than an agent: the binary must resolve (and prove it
* is the harness and not Debian's dancer's shell), AND a profile that can occupy
* a pane must exist. Reporting only the first would let the Run button spawn a
* pane that dies instantly, which is the single most confusing failure this mode
* can produce, so each half gets its own actionable message.
*
* A profile named EXPLICITLY is checked on both counts: existence, and whether
* it is pane-capable — `web` serves a browser UI and `headless` answers one task
* and exits, so both would present as "the tab immediately died".
*/
async function resolveDeepSeekLaunchError(requestedProfile?: string): Promise<string | null> {
// Thin async wrapper: the implementation moved into the resolver module so
// CRON fires can ask the same question before constructing a Session; the
// dynamic import keeps this file's startup free of the probe machinery.
const { resolveDeepSeekLaunchError: impl } = await import('../../utils/deepseek-cli-resolver.js');
return impl(requestedProfile);
}
// ═══════════════════════════════════════════════════════════════
// Agent wait helpers (shared by GET /wait, GET /wait-output, POST /input)
// ═══════════════════════════════════════════════════════════════
@@ -876,7 +886,10 @@ export function registerSessionRoutes(
// Multi-user: shell mode is arbitrary command execution as the host account,
// gated behind the same grant as bypass (section 6.3). Resolve the owner's grant
// from the store so a GRANTED regular user is not wrongly denied (AuthUser role alone can't tell).
if (body.mode === 'shell' && !(await canUsernameRunPrivilegedCommands(owner))) {
if (
getCli(body.mode ?? 'claude')?.capabilities.privilegedCommandGate &&
!(await canUsernameRunPrivilegedCommands(owner))
) {
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Shell sessions require the can-bypass-permissions grant');
}
@@ -909,15 +922,11 @@ export function registerSessionRoutes(
// repos that POST /api/sessions can target, as those may have hand-authored
// values).
const managedCasesBase = resolveCasesDir(getAuthUser(req));
// `!isExternalCliMode()` is byte-identical to the eight-mode `!==` chain it replaces
// (claude and shell are the two non-external modes) and, unlike the chain, cannot fall
// behind the next CLI added.
const canStripDisk =
body.mode !== 'opencode' &&
body.mode !== 'codex' &&
body.mode !== 'gemini' &&
body.mode !== 'antigravity' &&
body.mode !== 'pi' &&
body.mode !== 'grok' &&
body.mode !== 'deepseek' &&
body.mode !== 'omp' &&
!isExternalCliMode(body.mode ?? 'claude') &&
body.envOverrides &&
Object.keys(body.envOverrides).length > 0 &&
(workingDir.startsWith(CASES_DIR + '/') || workingDir.startsWith(managedCasesBase + '/'));
@@ -969,58 +978,24 @@ export function registerSessionRoutes(
}
}
// Check OpenCode availability if requested. The error text comes from the
// resolver (formatCliNotFoundMessage) so it names where resolution looked —
// server PATH, login shell, common directories — same for the modes below.
if (body.mode === 'opencode') {
const { isOpenCodeAvailable, getOpenCodeNotFoundMessage } = await import('../../utils/opencode-cli-resolver.js');
if (!isOpenCodeAvailable()) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getOpenCodeNotFoundMessage());
}
}
// Check Codex availability if requested
if (body.mode === 'codex') {
const { isCodexAvailable, getCodexNotFoundMessage } = await import('../../utils/codex-cli-resolver.js');
if (!isCodexAvailable()) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getCodexNotFoundMessage());
}
}
// Check Gemini availability if requested
if (body.mode === 'gemini') {
const { isGeminiAvailable, getGeminiNotFoundMessage } = await import('../../utils/gemini-cli-resolver.js');
if (!isGeminiAvailable()) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getGeminiNotFoundMessage());
}
}
if (body.mode === 'antigravity') {
const { isAntigravityAvailable, getAntigravityNotFoundMessage } =
await import('../../utils/antigravity-cli-resolver.js');
if (!isAntigravityAvailable()) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getAntigravityNotFoundMessage());
}
}
if (body.mode === 'pi') {
const { isPiAvailable, getPiNotFoundMessage } = await import('../../utils/pi-cli-resolver.js');
if (!isPiAvailable()) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getPiNotFoundMessage());
}
}
if (body.mode === 'deepseek') {
const err = await resolveDeepSeekLaunchError(body.deepSeekConfig?.profile);
if (err) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, err);
}
if (body.mode === 'grok') {
const { isGrokAvailable, getGrokNotFoundMessage } = await import('../../utils/grok-cli-resolver.js');
if (!isGrokAvailable()) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getGrokNotFoundMessage());
}
}
if (body.mode === 'omp') {
const { isOmpAvailable, getOmpNotFoundMessage } = await import('../../utils/omp-cli-resolver.js');
if (!isOmpAvailable()) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getOmpNotFoundMessage());
// Refuse up front if the requested CLI cannot start, rather than spawning a pane that
// dies on `command not found`. The message comes from the resolver, so it names where
// resolution actually looked (server PATH, login shell, the entry's search dirs); a
// LAUNCHER CLI answers with its own more specific reason instead — for dsh, whether the
// binary is missing, no pane-capable profile exists, or the profile the caller NAMED
// cannot drive a pane, which are three different things to go and fix.
//
// Scoped to EXTERNAL CLIs, matching what this route has always pre-flighted: claude and
// shell deliberately fall through to tmux-manager's own not-found throw instead, and
// pulling them forward here would change which error a missing claude produces.
const requestedMode = body.mode ?? 'claude';
if (getCli(requestedMode)?.capabilities.external) {
const cliLaunchError = await resolveCliLaunchError(
requestedMode,
legacyConfigForMode(requestedMode, body as unknown as Record<string, unknown>)
);
if (cliLaunchError) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, cliLaunchError);
}
}
@@ -1057,27 +1032,25 @@ export function registerSessionRoutes(
const globalNice = await ctx.getGlobalNiceConfig();
const modelConfig = await ctx.getModelConfig();
const mode = body.mode || 'claude';
// Where a model override comes from is a capability, and the three answers are
// genuinely different mechanisms:
// 'flag' — the CLI takes --model, so read the value the caller sent
// in that CLI's own config object.
// 'claude-settings-file' — claude alone, whose model is written to
// <case>/.claude/settings.local.json rather than passed as
// a flag, so the app-wide default applies here.
// 'none' — shell has no model; deepseek's is a composition entry in
// the profile's config tree, not a session field
// (docs/deepseek-integration.md). Both get nothing.
const modelSource = getCli(mode)?.capabilities.model;
const model =
mode === 'opencode'
? body.openCodeConfig?.model
: mode === 'codex'
? body.codexConfig?.model
: mode === 'gemini'
? body.geminiConfig?.model
: mode === 'antigravity'
? body.antigravityConfig?.model
: mode === 'pi'
? body.piConfig?.model
: mode === 'grok'
? body.grokConfig?.model
: mode === 'omp'
? body.ompConfig?.model
: // DeepSeek's model is a composition entry in the profile's config
// tree, not a session flag, so there is deliberately nothing to
// read here (see docs/deepseek-integration.md).
mode !== 'shell' && mode !== 'deepseek'
? modelConfig?.defaultModel || undefined
: undefined;
modelSource?.source === 'flag'
? (legacyConfigForMode(mode, body as unknown as Record<string, unknown>)?.[modelSource.param ?? 'model'] as
| string
| undefined)
: modelSource?.source === 'claude-settings-file'
? modelConfig?.defaultModel || undefined
: undefined;
const claudeModeConfig = await ctx.getClaudeModeConfig();
// Section 6.3: force non-granted users to a classifier-guarded mode.
const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, owner);
@@ -1089,7 +1062,7 @@ export function registerSessionRoutes(
piConfig: gatedPiConfig,
grokConfig: gatedGrokConfig,
deepSeekConfig: gatedDeepSeekConfig,
} = await clampExternalCliBypassForOwner(
} = await _clampExternalCliBypassForOwner(
owner,
body.codexConfig,
body.geminiConfig,
@@ -1132,7 +1105,7 @@ export function registerSessionRoutes(
await ctx.setupSessionListeners(session);
// Pre-seed the agent skill's preamble cache so its §0 bootstrap is a two-line
// loader (see seedAgentSessionPreamble). Local claude sessions only; best-effort.
if (mode === 'claude' && !remote && (await ctx.getAgentSkillEnabled())) {
if (getCli(mode)?.capabilities.agentSkillInjection && !remote && (await ctx.getAgentSkillEnabled())) {
await seedAgentSessionPreamble(session.id).catch((err: unknown) =>
console.warn(`[agent-skill] preamble seed failed for ${session.id}: ${getErrorMessage(err)}`)
);
@@ -1354,20 +1327,21 @@ export function registerSessionRoutes(
}
try {
// Auto-detect completion phrase from CLAUDE.md BEFORE starting (only if globally enabled and not explicitly disabled by user)
// Ralph tracker is not supported for opencode / codex / gemini / antigravity / pi sessions.
// Keep this list in step with isExternalCliMode(): _processExpensiveParsers() returns early
// for those modes, so a tracker enabled here would never be fed, and the session would
// still report ralphEnabled + Ralph UI state that no other external CLI shows.
// Auto-detect completion phrase from CLAUDE.md BEFORE starting (only if globally
// enabled and not explicitly disabled by user).
//
// `isExternalCliMode()` is what the eight-mode `!==` chain this replaces was FOR: its
// own comment asked the next person to keep the list in step with that predicate by
// hand. Calling it instead is byte-identical today (claude and shell are the two
// non-external modes, exactly what the chain admitted) and cannot drift.
//
// ⚠️ Deliberately NOT `capabilities.ralph`, which the quick-start path below reads:
// that capability is claude-only, so using it here would stop auto-enabling Ralph for
// SHELL sessions, which this path has always done. The two paths genuinely disagree
// about shell, and they disagree upstream too — reconciling them is a behaviour change
// and belongs in its own PR, not in a refactor that is meant to change nothing.
if (
session.mode !== 'opencode' &&
session.mode !== 'codex' &&
session.mode !== 'gemini' &&
session.mode !== 'antigravity' &&
session.mode !== 'pi' &&
session.mode !== 'grok' &&
session.mode !== 'deepseek' &&
session.mode !== 'omp' &&
!isExternalCliMode(session.mode) &&
ctx.store.getConfig().ralphEnabled &&
!session.ralphTracker.autoEnableDisabled
) {
@@ -2106,7 +2080,7 @@ export function registerSessionRoutes(
// Codex sessions don't write to ~/.claude/projects — their transcripts
// live in ~/.codex/sessions/**. Branch to a Codex-specific reader so the
// response-viewer works for Codex panes too.
if (session.mode === 'codex') {
if (getCli(session.mode)?.capabilities.transcript === 'codex-rollout') {
const codexQuery = req.query as { context?: string };
return await readCodexLastResponse(session, codexQuery.context === 'full');
}
@@ -2126,7 +2100,7 @@ export function registerSessionRoutes(
// and return "nothing said yet" forever — an agent polling that worker
// would starve on an answer that exists. Those configurations keep the
// pane segmenter below: coarse, but the real conversation.
if (session.mode === 'deepseek' && !session.docker && !session.remote) {
if (getCli(session.mode)?.capabilities.transcript === 'deepseek-zstd' && !session.docker && !session.remote) {
const deepSeekQuery = req.query as { context?: string };
const full = deepSeekQuery.context === 'full';
const transcript = await readDeepSeekLastResponse(session, { blocks: full });
@@ -2269,7 +2243,7 @@ export function registerSessionRoutes(
const WINDOW_MS = 15_000;
const otherSubmits: number[] = [];
for (const s of ctx.sessions.values()) {
if (s.id !== session.id && s.mode === 'codex' && s.lastSubmitAt) {
if (s.id !== session.id && getCli(s.mode)?.capabilities.transcript === 'codex-rollout' && s.lastSubmitAt) {
otherSubmits.push(s.lastSubmitAt);
}
}
@@ -2614,7 +2588,8 @@ export function registerSessionRoutes(
// During long thinking phases, Ink rewrites the same rows thousands of times
// (500KB+). Without stripping, tail mode returns only spinner frames and
// the terminal appears empty when switching tabs.
let strippedBuffer = session.mode === 'shell' ? rawBuffer : stripInkRedrawBloat(rawBuffer);
let strippedBuffer =
getCli(session.mode)?.capabilities.stripInkBloat === false ? rawBuffer : stripInkRedrawBloat(rawBuffer);
// Strip alt-screen toggles and scrollback-erase from Codex/Claude byte
// streams. xterm.js obeys them by switching to its scrollback-less alt
@@ -2944,7 +2919,7 @@ export function registerSessionRoutes(
// Multi-user: shell mode is arbitrary host-account execution, gated by the grant.
// Resolve the owner's grant from the store so a GRANTED regular user is not wrongly denied.
if (mode === 'shell' && !(await canUsernameRunPrivilegedCommands(owner))) {
if (getCli(mode)?.capabilities.privilegedCommandGate && !(await canUsernameRunPrivilegedCommands(owner))) {
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Shell sessions require the can-bypass-permissions grant');
}
@@ -3082,73 +3057,29 @@ export function registerSessionRoutes(
dockerResumeId = dockerCase.lastClaudeSessionId;
}
} else {
// Check OpenCode availability if requested. Error text comes from the
// resolver so it carries the resolution diagnostics; same for the modes below.
if (mode === 'opencode') {
const { isOpenCodeAvailable, getOpenCodeNotFoundMessage } =
await import('../../utils/opencode-cli-resolver.js');
if (!isOpenCodeAvailable()) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getOpenCodeNotFoundMessage());
// Same pre-flight as POST /api/sessions: refuse before spawning a pane that would die
// on `command not found`, with the resolver's own diagnostics, and a launcher CLI's
// more specific reason (dsh: binary vs no pane-capable profile vs the profile the
// caller named). External CLIs only — claude and shell fall through to tmux-manager's
// own not-found throw, exactly as before.
if (getCli(mode)?.capabilities.external) {
const qsLaunchError = await resolveCliLaunchError(
mode,
legacyConfigForMode(mode, {
openCodeConfig,
codexConfig,
geminiConfig,
antigravityConfig,
piConfig,
grokConfig,
deepSeekConfig,
} as unknown as Record<string, unknown>)
);
if (qsLaunchError) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, qsLaunchError);
}
}
// Check Codex availability if requested
if (mode === 'codex') {
const { isCodexAvailable, getCodexNotFoundMessage } = await import('../../utils/codex-cli-resolver.js');
if (!isCodexAvailable()) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getCodexNotFoundMessage());
}
}
// Check Gemini availability if requested
if (mode === 'gemini') {
const { isGeminiAvailable, getGeminiNotFoundMessage } = await import('../../utils/gemini-cli-resolver.js');
if (!isGeminiAvailable()) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getGeminiNotFoundMessage());
}
}
// Check Antigravity availability if requested
if (mode === 'antigravity') {
const { isAntigravityAvailable, getAntigravityNotFoundMessage } =
await import('../../utils/antigravity-cli-resolver.js');
if (!isAntigravityAvailable()) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getAntigravityNotFoundMessage());
}
}
// Check Pi availability if requested
if (mode === 'pi') {
const { isPiAvailable, getPiNotFoundMessage } = await import('../../utils/pi-cli-resolver.js');
if (!isPiAvailable()) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getPiNotFoundMessage());
}
}
// Check OMP availability if requested
if (mode === 'omp') {
const { isOmpAvailable } = await import('../../utils/omp-cli-resolver.js');
if (!isOmpAvailable()) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
'OMP CLI not found. Install with: curl -fsSL https://omp.sh/install | sh'
);
}
}
// Check Grok availability if requested
if (mode === 'grok') {
const { isGrokAvailable, getGrokNotFoundMessage } = await import('../../utils/grok-cli-resolver.js');
if (!isGrokAvailable()) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getGrokNotFoundMessage());
}
}
// Check DeepSeek Harness availability if requested (binary AND a pane-capable profile).
if (mode === 'deepseek') {
const err = await resolveDeepSeekLaunchError(deepSeekConfig?.profile);
if (err) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, err);
}
// Resolve case path: check linked-cases registry first, then fall back to CASES_DIR.
// This mirrors the behaviour of resolveCasePath() in case-routes so that linked
// external project directories are honoured by quick-start just like regular case routes.
@@ -3219,7 +3150,7 @@ export function registerSessionRoutes(
// reads `.claude` hooks, so a shell/codex quick-start should not author a block
// of its own. Skipped for remote cases — resolvedCasePath is a REMOTE path that
// doesn't exist on the local filesystem.
if (mode === 'claude') {
if (getCli(mode)?.capabilities.hooks === 'always') {
await applyWorkspaceHooks(resolvedCasePath, await ctx.getWorkspaceHooksEnabled());
} else {
await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {});
@@ -3231,7 +3162,7 @@ export function registerSessionRoutes(
// (`.claude/skills/` is a Claude Code surface); skipped for remote cases, whose
// casePath lives on another host. Docker cases qualify: hostWorkspacePath is a
// real host dir and the skill crosses the bind mount like the rest of `.claude/`.
if (!remote && mode === 'claude' && (await ctx.getAgentSkillEnabled())) {
if (!remote && getCli(mode)?.capabilities.agentSkillInjection && (await ctx.getAgentSkillEnabled())) {
await injectAgentSkill(resolvedCasePath);
}
@@ -3242,7 +3173,7 @@ export function registerSessionRoutes(
// shell or external-CLI quick-start must not author a block of its own (the same
// rule the existing-case branch above states; this branch used to exclude just
// the five external CLIs and let `shell` through).
if (docker && docker.hooksEnabled && mode === 'claude') {
if (docker && docker.hooksEnabled && getCli(mode)?.capabilities.hooks === 'always') {
try {
if (!existsSync(join(resolvedCasePath, 'CLAUDE.md'))) {
const templatePath = await ctx.getDefaultClaudeMdPath();
@@ -3264,25 +3195,14 @@ export function registerSessionRoutes(
// Model override → <case>/.claude/settings.local.json (claude-mode; local AND
// docker — the docker workspace is a real host dir, so the settings file crosses
// the bind mount and the in-container claude reads it). Remote was rejected above.
if (mode === 'claude' && modelOverride !== undefined) {
if (getCli(mode)?.capabilities.model.source === 'claude-settings-file' && modelOverride !== undefined) {
await updateCaseModel(resolvedCasePath, modelOverride || null);
}
// Strip stale disk entries for keys this request is actively setting (Claude only —
// see POST /api/sessions for full rationale).
if (
mode !== 'opencode' &&
mode !== 'codex' &&
mode !== 'gemini' &&
mode !== 'antigravity' &&
mode !== 'pi' &&
mode !== 'grok' &&
mode !== 'deepseek' &&
mode !== 'omp' &&
!remote &&
envOverrides &&
Object.keys(envOverrides).length > 0
) {
// Same chain, same replacement as the create path above: byte-identical, drift-proof.
if (!isExternalCliMode(mode) && !remote && envOverrides && Object.keys(envOverrides).length > 0) {
await stripCaseEnvKeys(resolvedCasePath, Object.keys(envOverrides));
}
@@ -3290,25 +3210,22 @@ export function registerSessionRoutes(
// Apply global Nice priority config and model config from settings
const niceConfig = await ctx.getGlobalNiceConfig();
const qsModelConfig = await ctx.getModelConfig();
// See the create path for why this is a capability rather than a mode ladder.
const qsModelSource = getCli(mode)?.capabilities.model;
const qsModel =
mode === 'opencode'
? openCodeConfig?.model
: mode === 'codex'
? codexConfig?.model
: mode === 'gemini'
? geminiConfig?.model
: mode === 'antigravity'
? antigravityConfig?.model
: mode === 'pi'
? piConfig?.model
: mode === 'grok'
? grokConfig?.model
: mode === 'omp'
? ompConfig?.model
: // DeepSeek's model lives in the profile's config tree, not here.
mode !== 'shell' && mode !== 'deepseek'
? qsModelConfig?.defaultModel || undefined
: undefined;
qsModelSource?.source === 'flag'
? (legacyConfigForMode(mode, {
openCodeConfig,
codexConfig,
geminiConfig,
antigravityConfig,
piConfig,
grokConfig,
deepSeekConfig,
} as unknown as Record<string, unknown>)?.[qsModelSource.param ?? 'model'] as string | undefined)
: qsModelSource?.source === 'claude-settings-file'
? qsModelConfig?.defaultModel || undefined
: undefined;
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
const qsEffectiveClaudeMode = await resolveClaudeModeForUsername(qsClaudeModeConfig.claudeMode, owner);
// Section 6.3: clamp Codex/Gemini/Antigravity bypass switches for a non-granted owner (no-op single-user/granted).
@@ -3319,7 +3236,7 @@ export function registerSessionRoutes(
piConfig: qsGatedPiConfig,
grokConfig: qsGatedGrokConfig,
deepSeekConfig: qsGatedDeepSeekConfig,
} = await clampExternalCliBypassForOwner(
} = await _clampExternalCliBypassForOwner(
owner,
codexConfig,
geminiConfig,
@@ -3360,7 +3277,7 @@ export function registerSessionRoutes(
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
// so the initial state already has the phrase configured (only if globally enabled)
if (mode === 'claude' && !remote && !docker && ctx.store.getConfig().ralphEnabled) {
if (getCli(mode)?.capabilities.ralph && !remote && !docker && ctx.store.getConfig().ralphEnabled) {
autoConfigureRalph(session, resolvedCasePath, ctx);
if (!session.ralphTracker.enabled) {
session.ralphTracker.enable();
@@ -3374,7 +3291,7 @@ export function registerSessionRoutes(
await ctx.setupSessionListeners(session);
// Pre-seed the agent skill's preamble cache so its §0 bootstrap is a two-line
// loader (see seedAgentSessionPreamble). Local claude sessions only; best-effort.
if (mode === 'claude' && !remote && !docker && (await ctx.getAgentSkillEnabled())) {
if (getCli(mode)?.capabilities.agentSkillInjection && !remote && !docker && (await ctx.getAgentSkillEnabled())) {
await seedAgentSessionPreamble(session.id).catch((err: unknown) =>
console.warn(`[agent-skill] preamble seed failed for ${session.id}: ${getErrorMessage(err)}`)
);
@@ -3389,7 +3306,7 @@ export function registerSessionRoutes(
// Start in the appropriate mode
try {
if (mode === 'shell') {
if (getCli(mode)?.capabilities.startMode === 'shell') {
await session.startShell();
getLifecycleLog().log({
event: 'started',
+3 -1
View File
@@ -5,6 +5,7 @@
*/
import { FastifyInstance } from 'fastify';
import { getCli } from '../../config/cli-registry/registry.js';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync, mkdirSync, readdirSync } from 'node:fs';
@@ -1040,7 +1041,8 @@ export function registerSystemRoutes(
if (statusLineTelemetry === true) {
const dirs = new Set<string>();
for (const session of ctx.sessions.values()) {
if (session.mode === 'claude' && session.workingDir) dirs.add(session.workingDir);
if (getCli(session.mode)?.capabilities.statusLineTelemetry && session.workingDir)
dirs.add(session.workingDir);
}
await Promise.all([...dirs].map((dir) => applyStatusLineConfig(dir, true).catch(() => {})));
}
+89 -39
View File
@@ -18,6 +18,8 @@ import {
} from '../config/terminal-history.js';
import { MAX_EDITABLE_BYTES } from '../config/file-editing.js';
import { MIN_MATCH_LENGTH, MAX_MATCH_LENGTH } from '../config/agent-wait.js';
import { enabledCliIds, enabledClis } from '../config/cli-registry/registry.js';
import type { SessionMode } from '../types.js';
// ========== Path Validation ==========
@@ -119,38 +121,84 @@ export const FileWriteSchema = z
})
.strict();
// ========== Env Var Allowlist ==========
/** Allowlisted env var key prefixes */
const ALLOWED_ENV_PREFIXES = [
'CLAUDE_CODE_',
'OPENCODE_',
'CODEX_',
'GEMINI_',
'GOOGLE_',
'ANTIGRAVITY_',
'PI_',
'GROK_',
'XAI_',
// DeepSeek Harness: `DSH_*` carries the launcher's own documented inputs
// (DSH_HOME, DSH_PERMISSION_MODE, DSH_TELEMETRY_MODE, and the DSH_TUI_* knobs
// the terminal front door reads); `DEEPSEEK_*` is the vendor namespace holding
// DEEPSEEK_API_KEY / DEEPSEEK_BASE_URL, the same narrow-vendor reasoning that
// admitted XAI_* for grok. Foreign provider keys stay out: a dsh settings.yaml
// can name ANY env var as a provider credential (apiKeyEnv), which is pi's
// 34-provider-key problem in a new shape, and the answer is the same one.
'DSH_',
'DEEPSEEK_',
'OMP_',
];
/**
* The run-mode ids the API currently accepts: every ENABLED registry entry.
*
* Exported so anything needing the authoritative list derives it from here rather than
* restating the nine names (which is how the old literal enum drifted from the run menu).
*/
export function sessionModeIds(): string[] {
return enabledCliIds();
}
/**
* Allowlisted exact env var keys (checked alongside the prefixes).
* CLAUDE_CONFIG_DIR relocates the Claude CLI's user config (credentials,
* settings, stats) so a case can run on a separate Claude subscription (#255).
* Exact match only — CLAUDE_CONFIG_DIR_EXTRA etc. stay rejected.
* Validation for a run mode, resolved AT PARSE TIME.
*
* ⚠️ Deliberately not a `z.enum([...])`. An enum has to be handed its members when the
* SCHEMA OBJECT is built, which happens once at module import — so a CLI enabled while the
* server was running kept failing validation with INVALID_INPUT until a restart, even
* though the run menu already offered it. Checking membership inside the refinement moves
* the question to when the request is actually validated.
*
* The cast is because callers type this field as `SessionMode`; the runtime check above is
* what actually constrains it.
*/
const ALLOWED_ENV_KEYS = new Set(['CLAUDE_CONFIG_DIR']);
function sessionModeSchema(): z.ZodType<SessionMode> {
return (
z
.string()
// Bounded BEFORE the membership check, and before the failure message quotes the value
// back. `.max(24)` matches the `cliId` pattern in cli-registry/schema.ts — no id longer
// than that can ever be registered, so nothing legitimate is rejected — and it means a
// rejected mode cannot echo a body-limit-sized string into an error string and a log
// line. Without it the only bound on either was the HTTP body limit.
.max(24)
.superRefine((value, ctx) => {
const allowed = sessionModeIds();
if (!allowed.includes(value)) {
ctx.addIssue({
code: 'custom',
message: `Invalid run mode ${JSON.stringify(value)}. Enabled modes: ${allowed.join(', ')}`,
});
}
}) as unknown as z.ZodType<SessionMode>
);
}
// ========== Env Var Allowlist ==========
/**
* Allowlisted env var key prefixes, contributed by the ENABLED CLIs in the registry
* (`env.allowedPrefixes`) — `CLAUDE_CODE_`, `OPENCODE_`, `CODEX_`, `GEMINI_`, `GOOGLE_`,
* `ANTIGRAVITY_`, `PI_`, `GROK_`, `XAI_`, `DSH_`, `DEEPSEEK_` as shipped.
*
* ⚠️ Resolved AT PARSE TIME, not at module load. This used to be a frozen array computed
* once when the module was imported, which meant a CLI enabled while the server was running
* had its env prefix rejected until a restart — validation and the run menu disagreeing
* about which CLIs exist. Reading the registry per call costs a memoized array lookup.
*
* ⚠️ This is ONE GLOBAL LIST applied with no mode context, so admitting a prefix for one CLI
* widens it for every mode at once. That is why an entry only ever contributes its own
* VENDOR namespace: pi's ~34 provider keys (ANTHROPIC_API_KEY, OPENAI_API_KEY, HF_TOKEN, …)
* share no prefix and stay out, and a dsh `settings.yaml` can nominate ANY env var as a
* provider credential — same problem, same answer. Those CLIs authenticate via their own
* `/login` or the server process's own environment.
*/
function allowedEnvPrefixes(): string[] {
return enabledClis().flatMap((entry) => entry.env.allowedPrefixes);
}
/**
* Allowlisted exact env var keys (checked alongside the prefixes), likewise contributed by
* enabled registry entries via `env.allowedKeys`.
*
* As shipped this is claude's CLAUDE_CONFIG_DIR, which relocates the Claude CLI's user
* config (credentials, settings, stats) so a case can run on a separate Claude subscription
* (#255). Exact match only — CLAUDE_CONFIG_DIR_EXTRA etc. stay rejected.
*/
function allowedEnvKeys(): Set<string> {
return new Set(enabledClis().flatMap((entry) => entry.env.allowedKeys));
}
/** Env var keys that are always blocked (security-sensitive) */
const BLOCKED_ENV_KEYS = new Set([
@@ -163,11 +211,17 @@ const BLOCKED_ENV_KEYS = new Set([
'OPENCODE_SERVER_PASSWORD', // Security-sensitive: server auth password
]);
/** Validate that an env var key is allowed */
/**
* Validate that an env var key is allowed.
*
* ⚠️ `BLOCKED_ENV_KEYS` is checked FIRST and is deliberately NOT registry-driven. It is a
* hard floor: a rogue or fat-fingered `allowedPrefixes` entry (say `''`, which prefixes
* everything) still cannot unblock PATH or LD_PRELOAD.
*/
function isAllowedEnvKey(key: string): boolean {
if (BLOCKED_ENV_KEYS.has(key)) return false;
if (ALLOWED_ENV_KEYS.has(key)) return true;
return ALLOWED_ENV_PREFIXES.some((prefix) => key.startsWith(prefix));
if (allowedEnvKeys().has(key)) return true;
return allowedEnvPrefixes().some((prefix) => key.startsWith(prefix));
}
/** Zod schema for env overrides with allowlist enforcement */
@@ -460,9 +514,7 @@ const parentSessionIdSchema = z.string().max(100).optional();
export const CreateSessionSchema = z.object({
workingDir: safePathSchema.optional(),
mode: z
.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek', 'omp'])
.optional(),
mode: sessionModeSchema().optional(),
name: z.string().max(100).optional(),
/** Session that spawned this one — see parentSessionIdSchema. */
parentSessionId: parentSessionIdSchema,
@@ -892,9 +944,7 @@ export const QuickStartSchema = z.object({
* a real host dir, so the settings file crosses the bind mount); rejected for
* remote cases (the file would be written on the WRONG machine). */
modelOverride: z.string().max(50).optional(),
mode: z
.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek', 'omp'])
.optional(),
mode: sessionModeSchema().optional(),
openCodeConfig: OpenCodeConfigSchema,
codexConfig: CodexConfigSchema,
geminiConfig: GeminiConfigSchema,
@@ -1436,7 +1486,7 @@ const noNewlines = (v: string) => !/[\r\n]/.test(v);
/** Shared field shape for creating/updating a scheduled job. */
const CronJobBaseSchema = z.object({
name: z.string().min(1).max(200),
agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek', 'omp']),
agentType: sessionModeSchema(),
workingDir: safePathSchema,
launchCommand: z.string().max(2000).refine(noNewlines, 'launchCommand must be a single line').optional(),
promptMode: z.enum(['inline_text', 'prompt_file_path']),
+21 -12
View File
@@ -65,6 +65,7 @@ import {
MAX_SNIPPET_CONTEXT,
} from '../config/agent-wait.js';
import type { SessionMode, SessionStatus } from '../types.js';
import { getCli } from '../config/cli-registry/registry.js';
// ─── Signals ─────────────────────────────────────────────────────────────────
@@ -174,7 +175,7 @@ const HOOK_ONLY_SIGNALS: readonly WaitSignal[] = ['stop', 'blocked'];
export interface HookCapabilityOptions {
/**
* `deepSeekConfig.statusReporting`, verbatim (so `undefined` means "not sent",
* i.e. ON). `false` is the per-session opt-out that stops `_configureDeepSeek()`
* i.e. ON). `false` is the per-session opt-out that stops `_configureCliEnv()`
* exporting the `HERDR_*` triple, which is the ONLY thing that makes a dsh
* session emit hook events at all.
*/
@@ -223,18 +224,26 @@ export interface HookCapabilityOptions {
* function only about hook SIGNALS.
*/
export function hooksAvailableForMode(mode: SessionMode, options: HookCapabilityOptions = {}): boolean {
if (mode === 'claude') return true;
// `deepseek` earns this the same way `claude` does — by emitting DEFINITIVE
// signals rather than having them inferred. The DeepSeek Harness terminal
// front door reports idle/working/blocked to its supervisor, and Codeman is
// that supervisor (see deepseek-status-shim.ts), so a dsh session really can
// deliver `stop` and `blocked` — unless the user turned the bridge off, in
// which case nothing on the box will ever post one. Every other mode is
// output-stabilization guesswork and must keep failing the ask.
if (mode === 'deepseek') {
return options.deepSeekStatusReporting !== false && options.deepSeekBridgeUnreachable !== true;
// A TRI-state capability, not a boolean, because the three answers are genuinely
// different questions — see CliCapabilities.hooks.
switch (getCli(mode)?.capabilities.hooks) {
case 'always':
// The CLI installs Codeman's own hooks block into its workspace (claude), so the
// signals are unconditional.
return true;
case 'supervised':
// The CLI REPORTS its own state to a supervisor and Codeman is that supervisor
// (deepseek, via deepseek-status-shim.ts) — definitive signals rather than inferred
// ones, which is what earns it a yes. But the user can disarm the bridge, and a
// docker/remote session cannot reach it at all; in either case nothing on the box
// will ever post one, so the answer has to come from the SESSION, not the mode.
return options.deepSeekStatusReporting !== false && options.deepSeekBridgeUnreachable !== true;
default:
// 'none', and an unregistered mode. Every other CLI's idle is output-stabilization
// guesswork, and must keep failing the ask rather than promising a signal that never
// arrives.
return false;
}
return false;
}
/**