/** * @fileoverview Bridges the legacy per-mode spawn options (`buildSpawnCommand`'s option bag * in tmux-manager.ts, unchanged on the wire since before this registry existed) onto the CLI * registry's generic argv engine (`renderLaunch`). * * The per-mode `Config` objects on `POST /api/sessions` predate the registry and stay * on the wire for API compatibility (`docs/versioning-policy.md`), so SOMETHING has to know * which field holds which CLI's config. That knowledge is DATA — `launch.legacyConfigField` * and `launch.legacyConfigAliases`, declared once per entry in `config/cli-registry/stock.ts` * — which is what lets this file stay a generic reader rather than a `switch (mode)`. * * An entry declaring NO `legacyConfigField` reads its params straight off the top-level * option bag. That is claude, whose discrete `claudeMode`/`allowedTools`/`model`/ * `resumeSessionId` fields predate the `Config` pattern — not a special case for * claude, just the other of the two shapes the wire has always had. * * @module session-cli-registry-bridge */ import { getDiscordStartupHint } from './discord-bridge.js'; import type { CliEntry } from './config/cli-registry/types.js'; import { renderLaunch, type EngineValues, type ParamValues } from './config/cli-registry/argv.js'; import { matchesPattern } from './config/cli-registry/patterns.js'; import { buildAdvisorSettings, buildEffortCliArgs, sanitizeCliSessionName } from './session-cli-builder.js'; import { compareVersions } from './utils/dependency-checker.js'; import { resolveSessionCliVersion } from './utils/cli-resolver.js'; import { launcherDefaultTarget } from './utils/cli-launcher.js'; import { getCli } from './config/cli-registry/registry.js'; import type { AntigravityConfig, ClaudeMode, CodexConfig, DeepSeekConfig, EffortLevel, GeminiConfig, GrokConfig, CopilotConfig, OmpConfig, OpenCodeConfig, PiConfig, } from './types/session.js'; export interface SpawnBridgeOptions { mode: string; sessionId: string; model?: string; claudeMode?: ClaudeMode; allowedTools?: string; openCodeConfig?: OpenCodeConfig; codexConfig?: CodexConfig; geminiConfig?: GeminiConfig; antigravityConfig?: AntigravityConfig; piConfig?: PiConfig; grokConfig?: GrokConfig; copilotConfig?: CopilotConfig; deepSeekConfig?: DeepSeekConfig; ompConfig?: OmpConfig; resumeSessionId?: string; effort?: EffortLevel; /** Claude advisor model; rides the same `--settings` JSON as ultracode (see buildAdvisorSettings). */ advisorModel?: string; sessionName?: string; claudeCliVersion?: string | null; /** * Resolved by resolveStatusLineCliCommand (hooks-config.ts) — undefined skips the * exporter. Claude only. Rides the SAME `--settings` JSON object as `effortSettingsJson` * (see buildSpawnCommandFromRegistry): Claude Code accepts only one `--settings` flag * per invocation, so the two must be merged before reaching the argv engine rather than * rendered as two independent params. */ statusLineCommand?: string; /** * Add `idleCompaction: false` to the same `--settings` JSON: Codeman's prompt-cache keep-warm * owns the idle policy for this launch. Decided (keep-warm on, exporter injected, version gate * passed) by TmuxManager._resolveIdleCompactionOptOut, which records it on the mux record. */ idleCompactionOff?: boolean; } /** * The raw legacy config object this entry's params should be read from: the declared * `Config` field, or the option bag itself when none is declared. */ function legacyConfigFor(entry: CliEntry, options: SpawnBridgeOptions): Record | undefined { const field = entry.launch.legacyConfigField; if (field === undefined) return options as unknown as Record; return (options as unknown as Record)[field] as Record | undefined; } /** * Same lookup, addressed by mode rather than by entry, for callers holding only a mode and an * option bag (tmux-manager's env configuration). Returns undefined for an unregistered mode. */ export function legacyConfigForMode( mode: string, options: Record ): Record | undefined { const entry = getCli(mode); if (!entry) return undefined; return legacyConfigFor(entry, options as unknown as SpawnBridgeOptions); } /** * Build `ParamValues` for every declared `token`/`bool`/`enum` param by reading it out of the * legacy config object through `legacyConfigAliases` (falling back to the param's own name). * `engine`-sourced params are skipped — those come from `EngineValues`, never legacy config. */ function buildParamsFromLegacyConfig(entry: CliEntry, rawConfig: Record | undefined): ParamValues { const params: ParamValues = {}; if (!rawConfig) return params; const aliases = entry.launch.legacyConfigAliases ?? {}; for (const [paramName, spec] of Object.entries(entry.launch.params)) { if (spec.type === 'engine') continue; const legacyKey = aliases[paramName] ?? paramName; const value = rawConfig[legacyKey]; if (value === undefined) continue; // Anything that is not already a string or boolean is DROPPED rather than coerced: the // wire shape is Zod-validated upstream, so a surprise here means something is wrong, // and `String({})` would happily produce a token nobody intended. if (typeof value === 'string' || typeof value === 'boolean') { params[paramName] = value; } } return params; } /** * The env vars this CLI declares in `env.configSetenv`, resolved from its legacy config * object — i.e. the ones whose value comes from the CALLER rather than the server's own * environment. * * ⚠️ Re-validated here against the declared `ParamSpec` even though the wire shape is already * Zod-checked upstream. These values reach `tmux setenv`, and for DeepSeek the value IS a * permission level: a builder must never trust its caller on a security-relevant field, and * the cost of re-checking an enum is nothing. * * A value that fails validation is DROPPED, not defaulted — which is the safe direction: the * var goes unset, and the CLI falls back to its own default (for dsh, `workspace-write`, * which asks) rather than to something we guessed. */ export function configSetenvValues( entry: CliEntry, rawConfig: Record | undefined ): Record { const out: Record = {}; const mappings = entry.env.configSetenv; if (!mappings || !rawConfig) return out; const aliases = entry.launch.legacyConfigAliases ?? {}; for (const { name, fromParam } of mappings) { const spec = entry.launch.params[fromParam]; if (!spec) continue; // schema-validated at load; belt and braces const raw = rawConfig[aliases[fromParam] ?? fromParam]; if (typeof raw !== 'string') continue; if (spec.type === 'enum' && !spec.values.includes(raw)) continue; if (spec.type === 'token' && !matchesPattern(spec.pattern, raw)) continue; out[name] = raw; } return out; } /** * Which `capabilities.gates` are currently satisfied. `resolveVersion` is called AT MOST * ONCE, and only when the entry actually declares a gate — a `--version` subprocess probe * has no reason to run for an entry with none. */ function resolveGatesPassed(entry: CliEntry, resolveVersion: () => string | null): Set { const passed = new Set(); const gateEntries = Object.entries(entry.capabilities.gates); if (gateEntries.length === 0) return passed; const cliVersion = resolveVersion(); if (!cliVersion) return passed; // fail-closed: an unknown version satisfies no gate for (const [name, gate] of gateEntries) { if (compareVersions(cliVersion, gate.minVersion) >= 0) passed.add(name); } return passed; } /** * Whether `mode`'s `capabilities.gates[gate]` passes, through the same fail-closed resolution * the launch template uses (an unknown version passes no gate). For a launch-time decision that * is also recorded, like the `idleCompaction` opt-out. `claudeCliVersion` as in * SpawnBridgeOptions: undefined probes the local CLI, null means unknown. */ export function launchGatePasses(mode: string, gate: string, claudeCliVersion?: string | null): boolean { const entry = getCli(mode); if (!entry?.capabilities.gates[gate]) return false; return resolveGatesPassed(entry, () => gateVersion(entry, claudeCliVersion)).has(gate); } /** * The version a gate of `entry` is measured against: the caller's explicit value (tests, or * null for "known unknown"), else THIS entry's own local CLI via resolveSessionCliVersion. * ⚠️ Never `getClaudeCliVersion()` for every entry, which is what this used to do: codex's * `noDaemonFlag` gate (0.162.0) would then have passed on any claude 2.x and handed an older * codex a flag it exits on. resolveSessionCliVersion still routes claude to its own probe * policy (`retryOnTransientFailure`), so a claude launch is unchanged. */ function gateVersion(entry: CliEntry, explicit: string | null | undefined): string | null { return explicit !== undefined ? explicit : resolveSessionCliVersion(entry.id); } /** * Render the spawn command for `entry` from the legacy option bag. Returns `undefined` for a * `shell`-kind entry (or any entry declaring no launch variants), which callers take as "fall * back to the local login-shell resolution" — shell has no CLI to template. */ export function buildSpawnCommandFromRegistry(entry: CliEntry, options: SpawnBridgeOptions): string | undefined { if (entry.kind === 'shell' || entry.launch.variants.length === 0) return undefined; const params = buildParamsFromLegacyConfig(entry, legacyConfigFor(entry, options)); const engineValues: EngineValues = { sessionId: options.sessionId, // Allowlist-sanitized (Unicode letters/digits + ` . _ : -`, 64 chars), matching // buildNameCliArgs exactly — sanitizeCliSessionName is the injection guard for this // value, NOT the `quote: 'double'` escaping on the --name arg (which only makes an // unsafe value inert, it does not launder one into something meaningful). sessionName: sanitizeCliSessionName(options.sessionName), }; // Only a launcher CLI has one, and resolving it means a filesystem scan of the launcher's // profile tree, so skip the lookup entirely for the eight entries that declare no profile. if (entry.discovery.launcherProfile !== undefined) { engineValues.launcherDefaultTarget = launcherDefaultTarget(entry) ?? undefined; } // Mirrors buildEffortCliArgs exactly: ultracode carries a fixed settings blob, every other // level rides a plain `--effort ` flag. Reusing the canonical builder here (rather // than re-deriving the ultracode special case) keeps the EFFORT_LEVELS allowlist and the // settings-JSON shape single-sourced in session-cli-builder.ts. const [effortFlag, effortValue] = buildEffortCliArgs(options.effort); if (effortFlag === '--effort') { engineValues.effortLevel = effortValue; } // Fold the advisor model and the ephemeral plan-usage statusLine exporter (see // resolveStatusLineCliCommand in hooks-config.ts) into the SAME `--settings` JSON object as // ultracode/ effort, since Claude Code accepts only one `--settings` flag per invocation: // rendering them as independent params would let the last one silently win. Claude-only in // practice: only claude's launch template renders this engine value, so another CLI's // session carrying an advisorModel launches exactly as before. // Key order (ultracode, advisorModel, statusLine, idleCompaction) keeps a launch without an // advisor or keep-warm byte-identical to one from before either existed. const advisorSettings = buildAdvisorSettings(options.advisorModel); if ( (effortFlag === '--settings' && effortValue) || advisorSettings.advisorModel || options.statusLineCommand || options.idleCompactionOff ) { const settingsObj: Record = { ...(effortFlag === '--settings' && effortValue ? JSON.parse(effortValue) : {}), ...advisorSettings, }; if (options.statusLineCommand) { settingsObj.statusLine = { type: 'command', command: options.statusLineCommand }; } if (options.idleCompactionOff) settingsObj.idleCompaction = false; engineValues.effortSettingsJson = JSON.stringify(settingsObj); } // The Discord bridge's startup hint: undefined (so the flag is omitted) unless the bridge and its // `startupHint` setting are on. Claude-only in practice, like the settings object above. engineValues.appendSystemPrompt = getDiscordStartupHint(); // Preserves buildSpawnCommand's original fallback: an EXPLICIT `undefined` probes the // entry's own local CLI (gateVersion: claude's probe for claude, the registry resolver for // the rest, both null under vitest); an explicit `null` means "known to be unresolvable" // and must not probe. The probe only ever runs from resolveGatesPassed, and only for an // entry that actually declares a gate, so a CLI with none never spawns a `--version`. const gatesPassed = resolveGatesPassed(entry, () => gateVersion(entry, options.claudeCliVersion)); return renderLaunch(entry.launch, params, engineValues, gatesPassed); }