diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fcd88699..46253589 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,39 @@ jobs: - run: bun run typecheck - run: bun run test + # Issue #392: spawn/resume must behave equivalently with AND without a + # repoGolem launcher registry. Runners have none by default, so the "absent" + # leg is a genuine fresh-install environment rather than a stubbed one. + launcher-parity: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + launcher_registry: [absent, present] + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + + - run: rm -f package-lock.json + - run: bun install + + - name: Plant a repoGolem launcher registry + if: matrix.launcher_registry == 'present' + run: | + mkdir -p "$HOME/.config/ralphtools" + printf 'repoGolem parityrepo "%s"\n' "$HOME/checkouts/parityrepo" \ + > "$HOME/.config/ralphtools/launchers.zsh" + mkdir -p "$HOME/checkouts/parityrepo" + + - name: Confirm no registry exists + if: matrix.launcher_registry == 'absent' + run: | + rm -f "$HOME/.config/ralphtools/launchers.zsh" + test ! -f "$HOME/.config/ralphtools/launchers.zsh" + + - run: bun run test:parity + build-site: runs-on: ubuntu-latest defaults: diff --git a/docs/registry-optional-spawn.md b/docs/registry-optional-spawn.md new file mode 100644 index 00000000..be59f3b0 --- /dev/null +++ b/docs/registry-optional-spawn.md @@ -0,0 +1,143 @@ +# Registry-optional spawn and resume + +> Contract for issue #392. AGENTS.md law: *"Don't assume my setup — someone +> installing this fresh has none of my skills or launchers."* + +The repoGolem launcher registry (`~/.config/ralphtools/launchers.zsh`) is an +**optional enhancement**. `spawn_agent` and resume work with it and without it. + +## The two lanes + +| | registry lane | raw lane | +|---|---|---| +| trigger | `launchers.zsh` exists **and** names the repo | no registry file, or no entry for the repo | +| launch | `mmClaude -s` | `cd '' && MCP_CONNECTION_NONBLOCKING=1 CLAUDE_CODE_NO_FLICKER=1 claude --dangerously-skip-permissions` | +| resume | `mmClaude -s --resume ` | `cd '' && … claude --dangerously-skip-permissions --resume ` | +| `launcher_name` on the record | the launcher | `null` | +| `launch_mode` on the record | `"launcher"` | `"raw"` | +| `model_pin` on the record | `"launcher"` | `"cli_flag"` or `"cli_default"` | +| everything else in the receipt | identical | identical | + +`kiro` has no launcher and is raw on both lanes. + +**The approval bypass is on the resume too.** An agent that comes back without +it blocks on its first tool call and presents as a hung pane rather than a +failed resume, so both lanes carry it in both commands. The parity suite +asserts this lane-independently, by reading the command the engine actually +sent rather than comparing against a per-lane expected string. + +Raw flags, verified against each CLI's `--help`: + +| cli | binary | skip flag | model flag | resume | +|---|---|---|---|---| +| claude | `claude` | `--dangerously-skip-permissions` | `--model` | `--resume ` | +| codex | `codex` | `--dangerously-bypass-approvals-and-sandbox` | `-m` | `resume ` (global flags precede the subcommand) | +| cursor | `cursor agent` | `--force` | `--model` | `--resume ` | +| gemini | `gemini` | `-y` | `--model` | **none** — see below | + +### gemini has no raw resume + +`gemini --resume` takes `"latest"` or an **index number** ("Resume a previous +session. Use \"latest\" for most recent or index number (e.g. --resume 5)"), +never a session UUID. There is therefore no raw gemini resume addressable by +the id cmuxlayer captures. `buildRawResumeCommand` refuses rather than emitting +a command that would start a *fresh* session while reading as a successful +resume; raw gemini agents report `resumable: false`, and gemini is excluded +from same-surface auto-revive. The registered gemini launcher path is +unchanged. + +Retired command forms are still *recognized* on screen +(`rawResumeEchoCandidates`) even when they are no longer *emitted* — the +stale-resume guards match against scrollback typed by older builds, including +the old bypass-less forms and the old gemini form. + +## Model pin provenance (`model_pin`) + +Model tokens in this repo are **launcher vocabulary** (`claude-opus-5[1m]`, +`pro`, `codex`, `auto`); raw binaries do not share it. Canon §5 puts the pin on +the launcher, so in raw mode there is no launcher to carry it. The receipt +therefore states what actually happened instead of claiming a pin it never +applied: + +| `model_pin` | meaning | +|---|---| +| `launcher` | the repoGolem launcher carries the pin | +| `cli_flag` | raw mode passed an explicit `--model`/`-m` the binary understands | +| `cli_default` | **unpinned** — the CLI used its own configured default, which may be a prior session's model | + +`cli_default` always ships a `MODEL PIN NOT APPLIED` warning on the spawn +receipt. Gemini's launcher aliases (`pro`, `flash`, `pro-high`) are *not* +passed to a raw gemini, because they are repoGolem names the binary does not +define; only canonical `gemini-*` names are forwarded. + +## Where the raw lane looks for the repo + +`resolveRepoRootWithoutRegistry` (src/repo-root-fallback.ts), first hit wins: + +1. every absolute root in `CMUXLAYER_REPO_HOME` (colon separated) → `/` +2. the running checkout, when its basename **is** the repo +3. a sibling of the running checkout +4. `~/Gits/` +5. `~/` + +Nothing found is an error that names every path it searched — spawn never +launches into a lookalike directory silently. + +**A fallback past a *present* registry is disclosed.** On a fully-registered +machine, a repo whose key does not normalize to a registration would otherwise +fall through to `~/Gits/` — which usually exists — and boot a raw binary +with none of the launcher's MCP wiring or contexts, silently. The spawn receipt +now carries a `RAW LAUNCH:` warning naming which door failed and where it +landed, plus `launch_mode: "raw"`, so the degradation is legible without +forcing strict mode on and re-breaking fresh installs. + +## Environment variables + +| var | effect | +|---|---| +| `CMUXLAYER_LAUNCHER_REGISTRY_PATH` | override the registry location | +| `CMUXLAYER_REPO_HOME` | colon-separated roots searched first on the raw lane | +| `CMUXLAYER_REQUIRE_LAUNCHER_REGISTRY=1` | restore the pre-#392 hard failure when a repo is unregistered | + +Set `CMUXLAYER_REQUIRE_LAUNCHER_REGISTRY=1` on a machine where every repo *is* +registered: a typo'd repo name then fails loudly instead of raw-launching in a +directory that happens to exist. + +## Resume honesty + +Claude, Cursor, and Gemini key their session stores by working directory, so a +raw resume for those is only advertised when a cwd is known (`launch_cwd` or +`worktree_path`). Without one, `resumable` is `false` rather than a command +that would silently start a *new* session. Codex reads a global session store +and needs no cwd to find the session. + +`resumeInvocationForAgent` in `src/agent-facade.ts` is the **single authority** +for this: `list_agents`, `get_agent_state`, `resolveAgentRoute`, `resume_agent`, +and crash recovery all go through it, so what the tools advertise and what the +engine sends can never disagree. It returns either a command or a *reason*, and +the engine surfaces that reason — a malformed session id, a missing cwd, a +harness with no UUID resume form — instead of flattening it to "not resumable". + +`harnessCwdForAgent`'s `~/Gits/` default remains, but only for transcript +probing; it is no longer used to aim a resume command. + +## CI + +`bun run test:parity` runs the contract through both lanes. The `launcher-parity` +job in `.github/workflows/ci.yml` runs it twice: once on a runner with no +registry (a real fresh install) and once with a registry planted. + +The registry lane **reads a planted `launchers.zsh` when the host has one** +rather than stubbing `CMUXLAYER_LAUNCHER_REGISTRY_PATH` over it, so the +`present` leg genuinely exercises registry parsing and root resolution instead +of running byte-identically to the `absent` leg. + +The suite holds two kinds of assertion, and the second kind is the one that +matters: + +- per-lane expected command strings (readable, but a hand-written table can + only encode a divergence, never catch one); +- **lane-independent invariants** read off the command the engine actually + sent: both lanes carry an approval bypass in launch *and* resume, the receipt + claims a model pin only when the command applied one, the tab title is + `` in both lanes, and the public agent id survives a resume. diff --git a/package.json b/package.json index 430fe1ac..df6811d3 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "typecheck": "tsc -p tsconfig.json --noEmit", "test": "vitest run", "test:topology": "vitest run tests/topology-contract.test.ts", + "test:parity": "vitest run tests/spawn-resume-parity.test.ts tests/launcher-registry.test.ts tests/repo-root-fallback.test.ts tests/registry-optional-resume.test.ts", "test:contract": "bun run build && tsx scripts/run-real-cmux-contract.ts", "pre-pr": "bun run typecheck && bun run pre-pr:harness", "pre-pr:harness": "vitest run tests/live-agent-harness.test.ts tests/live-agent-harness-ci.test.ts tests/live-agent-harness-replay.test.ts tests/pre-pr-scripts.test.ts", diff --git a/src/agent-command.ts b/src/agent-command.ts index bd4e2873..cc02ae2c 100644 --- a/src/agent-command.ts +++ b/src/agent-command.ts @@ -23,38 +23,104 @@ export function sanitizeRepoName(repo: string): string { const FULL_SESSION_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -const LAUNCHER_SUFFIX: Partial> = { - claude: "Claude", - codex: "Codex", - cursor: "Cursor", - gemini: "Gemini", -}; - function cleanLauncherName( _cli: CliType, launcherName: string | null | undefined, ): string | null { if (!launcherName) return null; - const trimmed = launcherName.trim(); + // Legacy records stored decorated titles ("brainlayerCodex [surface:606]"). + // Strip only that bracketed decoration -- anything else unparseable stays + // unparseable and drops to the raw CLI. + const trimmed = launcherName.trim().replace(/\s*\[[^\]]*\]$/, ""); if (!/^[a-zA-Z][a-zA-Z0-9._-]*$/.test(trimmed)) return null; return trimmed; } +/** + * Harnesses whose transcript store is keyed by working directory. Resuming one + * from the wrong cwd silently starts a NEW session instead of resuming, so a + * raw resume command for these is only advertised when a cwd is known. + * `codex` keeps its sessions in a global store, and `kiro` carries its own cd. + */ +const CWD_KEYED_RESUME: ReadonlySet = new Set([ + "claude", + "cursor", + "gemini", +]); + +export function rawResumeNeedsCwd(cli: CliType): boolean { + return CWD_KEYED_RESUME.has(cli); +} + +/** + * Raw skip-approval flags, the CLI-level equivalent of the repoGolem launcher + * `-s`. Verified against each installed CLI's `--help`: + * claude --dangerously-skip-permissions codex --dangerously-bypass-approvals-and-sandbox + * cursor agent --force gemini -y + * + * AIDEV-NOTE: these belong on the RESUME command too, not just spawn. A + * resumed agent without its approval bypass blocks on its first tool call and + * presents as a hung pane rather than a failed resume. + */ +export const RAW_SKIP_APPROVALS: Partial> = { + claude: "--dangerously-skip-permissions", + codex: "--dangerously-bypass-approvals-and-sandbox", + cursor: "--force", + gemini: "-y", +}; + +/** + * `gemini --resume` takes `latest` or an INDEX number ("Resume a previous + * session. Use \"latest\" for most recent or index number (e.g. --resume 5)"), + * never a session UUID -- so there is no raw gemini resume we can address by + * the id cmuxlayer captures. Refusing beats emitting a command that would + * start a fresh session while reading as a successful resume. + * + * AIDEV-NOTE: if gemini ever grows UUID resume, delete this set and add the + * form to buildRawResumeCommand -- nothing else needs to change. + */ +const RAW_RESUME_UNSUPPORTED: ReadonlySet = new Set(["gemini"]); + +export function rawResumeSupported(cli: CliType): boolean { + return !RAW_RESUME_UNSUPPORTED.has(cli); +} + +export interface ResumeCommandOptions { + /** + * Directory the resumed harness must run in. Honoured only by the raw-CLI + * form: repoGolem launchers already cd themselves, and adding a cd would + * change behaviour for existing registered installs. + * + * AIDEV-NOTE: this is not cosmetic for Claude Code — its transcripts are + * keyed by cwd (~/.claude/projects/), so `claude --resume` + * from the wrong directory cannot find the session. + */ + cwd?: string | null; +} + +/** + * Resume command for an agent. Prefers the repoGolem launcher recorded at + * spawn; falls back to the raw CLI when no launcher was recorded. + * + * AIDEV-NOTE (issue #392): the old fallback guessed `${repo}${Suffix}` — a + * binary that does not exist on a machine without repoGolem, so fresh + * installs (and crash recovery, which reuses this) got an uncallable command. + * Absence of a launcher now means "raw CLI", never "guess a launcher". + */ export function buildResumeCommand( cli: CliType, repo: string, sessionId: string, launcherName?: string | null, + opts?: ResumeCommandOptions, ): string { if (!FULL_SESSION_UUID_RE.test(sessionId)) { throw new Error( `Invalid session id: "${sessionId}". A full session UUID is required.`, ); } - const suffix = LAUNCHER_SUFFIX[cli]; - const launcher = - cleanLauncherName(cli, launcherName) ?? - (suffix ? `${sanitizeRepoName(repo)}${suffix}` : null); + const launcher = cleanLauncherName(cli, launcherName); + if (!launcher) return buildRawResumeCommand(cli, repo, sessionId, opts); switch (cli) { case "claude": return `${launcher} -s --resume ${sessionId}`; @@ -81,26 +147,100 @@ export function buildRawResumeCommand( cli: CliType, repo: string, sessionId: string, + opts?: ResumeCommandOptions, ): string { if (!FULL_SESSION_UUID_RE.test(sessionId)) { throw new Error( `Invalid session id: "${sessionId}". A full session UUID is required.`, ); } + if (!rawResumeSupported(cli)) { + throw new Error( + `No raw ${cli} resume exists for a session UUID: \`${cli} --resume\` takes ` + + `"latest" or an index, not "${sessionId}". Register a repoGolem ` + + `launcher for this repo, or resume the session by hand.`, + ); + } + const cwd = opts?.cwd?.trim(); + const cd = cwd ? `cd ${shellQuote(cwd)} && ` : ""; + // The approval bypass must survive a resume; see RAW_SKIP_APPROVALS. + const skip = RAW_SKIP_APPROVALS[cli]; switch (cli) { case "claude": - return `${AGENT_ENV} claude --resume ${sessionId}`; + return `${cd}${AGENT_ENV} claude ${skip} --resume ${sessionId}`; + // Codex takes global options BEFORE the subcommand -- matching the + // launcher form ` --dangerously-bypass-approvals-and-sandbox resume`. case "codex": - return `codex resume ${sessionId}`; + return `${cd}codex ${skip} resume ${sessionId}`; // `cursor agent` exposes `--resume [chatId]`; it has no `--session` flag // (`error: unknown option '--session'`). Verified against `cursor agent --help`. case "cursor": - return `cursor agent --resume ${sessionId}`; + return `${cd}cursor agent ${skip} --resume ${sessionId}`; + case "kiro": { + const safeRepo = sanitizeRepoName(repo); + const kiroCd = cd || `cd ~/Gits/${safeRepo} && `; + return `${kiroCd}${AGENT_ENV} kiro-cli chat --resume-id ${sessionId}`; + } + case "gemini": + throw new Error("unreachable: gemini raw resume is refused above"); + } +} + +/** + * Command forms a PREVIOUSLY typed raw resume may appear as on screen. + * + * Distinct from `buildRawResumeCommand`, which answers "what do we send". This + * answers "what would a resume attempt look like in this scrollback", and so + * it must include forms we no longer emit: + * - the pre-#453 forms without an approval bypass, still echoed on surfaces + * resumed by an older cmuxlayer; + * - the gemini `--resume ` form, which we now refuse to send but which + * older builds did type (and whose failure is exactly what the stale-screen + * guards look for). + * + * AIDEV-NOTE: keep every retired form here. Dropping one silently disables the + * guard that stops a failed resume being finalized as a healthy revival. + */ +export function rawResumeEchoCandidates( + cli: CliType, + repo: string, + sessionId: string, + opts?: ResumeCommandOptions, +): string[] { + if (!FULL_SESSION_UUID_RE.test(sessionId)) return []; + const cwd = opts?.cwd?.trim(); + const cd = cwd ? `cd ${shellQuote(cwd)} && ` : ""; + const skip = RAW_SKIP_APPROVALS[cli]; + const forms: string[] = []; + switch (cli) { + case "claude": + forms.push( + `${cd}${AGENT_ENV} claude ${skip} --resume ${sessionId}`, + `${cd}${AGENT_ENV} claude --resume ${sessionId}`, + ); + break; + case "codex": + forms.push( + `${cd}codex ${skip} resume ${sessionId}`, + `${cd}codex resume ${sessionId}`, + ); + break; + case "cursor": + forms.push( + `${cd}cursor agent ${skip} --resume ${sessionId}`, + `${cd}cursor agent --resume ${sessionId}`, + ); + break; case "gemini": - return `${AGENT_ENV} gemini --resume ${sessionId}`; + // Never emitted any more (takes an index, not a UUID) -- recognized only. + forms.push(`${cd}${AGENT_ENV} gemini --resume ${sessionId}`); + break; case "kiro": { const safeRepo = sanitizeRepoName(repo); - return `cd ~/Gits/${safeRepo} && ${AGENT_ENV} kiro-cli chat --resume-id ${sessionId}`; + const kiroCd = cd || `cd ~/Gits/${safeRepo} && `; + forms.push(`${kiroCd}${AGENT_ENV} kiro-cli chat --resume-id ${sessionId}`); + break; } } + return [...new Set(forms)]; } diff --git a/src/agent-engine.ts b/src/agent-engine.ts index 69ce6727..b1804099 100644 --- a/src/agent-engine.ts +++ b/src/agent-engine.ts @@ -21,8 +21,9 @@ import { StateManager } from "./state-manager.js"; import { isSafeShellToken, sanitizeTerminalInput } from "./sanitize.js"; import { AGENT_ENV, + RAW_SKIP_APPROVALS, buildRawResumeCommand, - buildResumeCommand, + rawResumeEchoCandidates, sanitizeRepoName, shellQuote, } from "./agent-command.js"; @@ -32,7 +33,12 @@ import { type AgentFilter, } from "./agent-registry.js"; import type { AgentDiscovery } from "./agent-discovery.js"; -import { toPublicAgent } from "./agent-facade.js"; +import { + resumeCommandForAgent, + resumeCwdForAgent, + resumeInvocationForAgent, + toPublicAgent, +} from "./agent-facade.js"; import type { CmuxMoveSurfaceResult, CmuxPane, @@ -153,11 +159,18 @@ import { } from "./agent-health.js"; import { launcherNameCandidates, + loadLauncherRegistrySnapshot, resolveLauncherNameFromRegistry, + resolveLauncherNameFromRegistryOrNull, resolveRepoRootFromLauncherRegistry, + type LauncherRegistryOptions, type LauncherSuffix, } from "./launcher-registry.js"; import { buildAgentHealthInput } from "./agent-health-input.js"; +import { + resolveRepoRootWithoutRegistry, + type RepoRootFallbackOptions, +} from "./repo-root-fallback.js"; import { assertSeatIdentity, loadSeatRegistryFromConfig, @@ -321,6 +334,10 @@ export interface SpawnAgentResult { model_policy?: SpawnModelPolicy; cwd?: string; mcp_env?: string; + /** Which door answered: the repoGolem launcher, or the raw CLI (#392). */ + launch_mode?: AgentLaunchMode; + /** Whether the reported `model` was actually pinned, and by what (#433). */ + model_pin?: ModelPinSource; } export class AgentLaunchError extends Error { @@ -429,6 +446,19 @@ function sessionCollisionSuffix(sessionId: string): string { export interface SpawnPreflightResult { launcherName?: string; repoRoot?: string; + /** + * How the harness should be started. "launcher" runs the repoGolem wrapper + * named by `launcherName`; "raw" runs the CLI binary directly with an + * explicit cd into `repoRoot`. Defaults to "launcher" so existing callers + * (and every test that stubs preflight) keep their current behaviour. + */ + launchMode?: AgentLaunchMode; + /** + * Why the launcher registry did not answer, when it did not. Surfaced as a + * spawn warning so a fallback past a PRESENT registry is legible instead of + * silent -- a registered machine spawning raw is usually a typo'd repo. + */ + launchModeReason?: string; } export type CodexModelListRunner = ( @@ -816,19 +846,17 @@ function resumeAwaitsFreshReadiness( ) { return false; } - let resumeCommand: string; - try { - resumeCommand = buildRawResumeCommand( - agent.cli, - agent.repo, - agent.cli_session_id, - ); - } catch { - return false; - } - const latestResume = screenText.lastIndexOf(resumeCommand); - if (latestResume < 0) return true; - const afterResume = screenText.slice(latestResume + resumeCommand.length); + // Recognition, not emission: scan every form a resume may have been typed + // as, including ones this build no longer sends (see rawResumeEchoCandidates). + const echo = latestRawResumeEcho( + screenText, + agent.cli, + agent.repo, + agent.cli_session_id, + ); + if (!echo) return false; + if (echo.index < 0) return true; + const afterResume = screenText.slice(echo.index + echo.command.length); const parsed = parseScreen(afterResume); const hasFreshIdentity = screenHasReadyAgentIdentity(agent.cli, afterResume, parsed) || @@ -1076,6 +1104,85 @@ function modelMatchesDefaultForLaunch(cli: CliType, model?: string): boolean { return cli === "codex" && model?.trim().toLowerCase() === "codex"; } +export type AgentLaunchMode = "launcher" | "raw"; + +/** + * How the model the receipt reports was actually applied to the launch. + * launcher - the repoGolem launcher carries the pin (canon §5) + * cli_flag - raw mode passed an explicit --model/-m the CLI understands + * cli_default - raw mode passed NO model flag; the CLI uses its own + * configured default, which may be a prior session's model + * + * AIDEV-NOTE (#433 family): the receipt must never claim a pin the command did + * not apply. `cli_default` is the honest name for "unpinned", and it carries a + * spawn warning rather than being reported silently. + */ +export type ModelPinSource = "launcher" | "cli_flag" | "cli_default"; + +/** + * Model tokens in this repo are LAUNCHER vocabulary: `claude-opus-5[1m]`, + * `pro`, `codex`, `auto`. Raw binaries do not share it. This returns the token + * that is safe to hand a raw CLI, or null when the pin cannot be expressed. + * + * - claude/codex/cursor: the resolved flag is already a real CLI model name + * (`sonnet`, `gpt-5.4`, ...) because resolveLaunchModelFlag only emits one + * when the caller asked for a specific model. + * - gemini: `pro`/`flash`/`pro-high` are repoGolem aliases that raw gemini + * does not define, so only canonical `gemini-*` names are passed through. + */ +export function rawModelFlagToken( + cli: CliType, + modelFlag: string | null, +): string | null { + if (!modelFlag) return null; + if (cli === "gemini" && !/^gemini-/i.test(modelFlag.trim())) return null; + return modelFlag; +} + +/** + * The exact model flag buildLaunchCommand will resolve for these inputs. + * Exported so spawn can report the pin it actually applied without + * re-deriving (and drifting from) the command builder's own logic. + */ +export function resolveLaunchModelFlagForCommand( + cli: CliType, + model: string | undefined, + opts?: { allowModelOverride?: boolean }, +): string | null { + return resolveLaunchModelFlag(cli, model, { + allowModelOverride: + opts?.allowModelOverride ?? + (cli === "codex" && + Boolean(model?.trim()) && + !modelMatchesDefaultForLaunch(cli, model)), + }); +} + +/** Truthful model provenance for a launch, plus the warning it owes the caller. */ +export function describeModelPin( + cli: CliType, + launchMode: AgentLaunchMode, + modelFlag: string | null, + effectiveModel: string | undefined, +): { pin: ModelPinSource; warning: string | null } { + if (launchMode === "launcher") return { pin: "launcher", warning: null }; + if (cli === "kiro") return { pin: "launcher", warning: null }; + if (rawModelFlagToken(cli, modelFlag)) { + return { pin: "cli_flag", warning: null }; + } + const claimed = effectiveModel?.trim(); + return { + pin: "cli_default", + warning: + `MODEL PIN NOT APPLIED: this is a raw ${cli} launch (no repoGolem ` + + `launcher for this repo), and ${cli} accepts no flag for ` + + `"${claimed ?? "the policy default"}". The agent starts on whichever ` + + `model ${cli} has configured, which may be a prior session's. ` + + `model_pin="cli_default" -- the reported model is the policy default, ` + + `not an applied pin. Register a repoGolem launcher to pin it.`, + }; +} + export function buildLaunchCommand( cli: CliType, repo: string, @@ -1090,15 +1197,12 @@ export function buildLaunchCommand( envPrefix?: string; allowModelOverride?: boolean; effort?: CodexEffort; + launchMode?: AgentLaunchMode; }, ): string { const safeRepo = sanitizeRepoName(repo); - const modelFlag = resolveLaunchModelFlag(cli, model, { - allowModelOverride: - opts?.allowModelOverride ?? - (cli === "codex" && - Boolean(model?.trim()) && - !modelMatchesDefaultForLaunch(cli, model)), + const modelFlag = resolveLaunchModelFlagForCommand(cli, model, { + allowModelOverride: opts?.allowModelOverride, }); const formattedModelFlag = modelFlag ? formatModelArg(modelFlag) : null; const launcherModelArgs = formattedModelFlag @@ -1118,6 +1222,41 @@ export function buildLaunchCommand( opts?.envPrefix ?? null, ].filter((part): part is string => Boolean(part)); const envPrefix = envParts.length > 0 ? `${envParts.join(" ")} ` : ""; + + // AIDEV-NOTE (issue #392): registry-optional launch. With no repoGolem + // launcher registered, spawn drops to the raw CLI and does the cd itself + // (the launcher normally owns that). Registered installs are untouched -- + // "raw" is only ever requested explicitly by preflight. + if (opts?.launchMode === "raw" && cli !== "kiro") { + // REPOGOLEM_ALLOW_MODEL is a launcher-only escape hatch; it means nothing + // to a raw binary, so raw mode carries only the harness + caller env. + const rawEnvParts = [ + cli === "claude" || cli === "gemini" ? AGENT_ENV : null, + opts?.envPrefix ?? null, + ].filter((part): part is string => Boolean(part)); + const rawEnvPrefix = + rawEnvParts.length > 0 ? `${rawEnvParts.join(" ")} ` : ""; + const skipFlag = RAW_SKIP_APPROVALS[cli]; + const rawEffortArg = + cli === "codex" && opts?.effort + ? ` -c model_reasoning_effort=${opts.effort}` + : ""; + // Only pass a model the raw binary actually understands; launcher-only + // vocabulary is dropped here and disclosed by describeModelPin instead. + const rawToken = rawModelFlagToken(cli, modelFlag); + const formattedRawToken = rawToken ? formatModelArg(rawToken) : null; + // `codex` takes `-m`; claude/cursor/gemini all accept `--model`. + const rawModelFlag = formattedRawToken + ? cli === "codex" + ? ` -m ${formattedRawToken}` + : ` --model ${formattedRawToken}` + : ""; + const binary = cli === "cursor" ? "cursor agent" : cli; + return `${rawCdPrefix}${rawEnvPrefix}${binary}${ + skipFlag ? ` ${skipFlag}` : "" + }${rawModelFlag}${rawEffortArg}`; + } + switch (cli) { case "claude": // repoGolem launcher handles env vars via ralph-registry @@ -1148,23 +1287,136 @@ export function extractSessionId(text: string): string | null { return uniqueMatches.length === 1 ? uniqueMatches[0] : null; } +/** + * `buildRawResumeCommand` throws for harnesses with no UUID resume form + * (gemini) and for malformed session ids. The same-surface auto-revive paths + * treat that as "not revivable", never as a sweep-breaking error. + */ +function rawResumeCommandOrNull( + cli: CliType, + repo: string, + sessionId: string, + opts?: { cwd?: string | null }, +): string | null { + try { + return buildRawResumeCommand(cli, repo, sessionId, opts); + } catch { + return null; + } +} + +/** + * Last position in `screenText` at which any recognized raw-resume form for + * this agent appears. `null` means the harness has no recognizable form at + * all; `index === -1` means it has one but the screen does not show it. + */ +function latestRawResumeEcho( + screenText: string, + cli: CliType, + repo: string, + sessionId: string, + opts?: { cwd?: string | null }, +): { command: string; index: number } | null { + const candidates = rawResumeEchoCandidates(cli, repo, sessionId, opts); + if (candidates.length === 0) return null; + let best: { command: string; index: number } = { + command: candidates[0]!, + index: -1, + }; + for (const command of candidates) { + const index = screenText.lastIndexOf(command); + if (index > best.index) best = { command, index }; + } + return best; +} + +function cliForLauncherSuffix(suffix: LauncherSuffix): CliType { + return suffix === "Claude" + ? "claude" + : suffix === "Codex" + ? "codex" + : suffix === "Cursor" + ? "cursor" + : "gemini"; +} + /** * Validate that a launcher is registered and return its resolved name. Probes * the launcher registry instead of executing shell profile code. + * + * Strict by design: this is the "registry is mandatory" contract. The default + * spawn preflight no longer calls it unless + * CMUXLAYER_REQUIRE_LAUNCHER_REGISTRY is set — see resolveSpawnLaunchPlan. */ export async function assertLauncherAvailable( repo: string, suffix: LauncherSuffix, ): Promise { - const cli = - suffix === "Claude" - ? "claude" - : suffix === "Codex" - ? "codex" - : suffix === "Cursor" - ? "cursor" - : "gemini"; - return resolveLauncherNameFromRegistry(repo, cli); + return resolveLauncherNameFromRegistry(repo, cliForLauncherSuffix(suffix)); +} + +export const REQUIRE_LAUNCHER_REGISTRY_ENV = + "CMUXLAYER_REQUIRE_LAUNCHER_REGISTRY"; + +function launcherRegistryRequired( + env: Record = process.env, +): boolean { + const value = env[REQUIRE_LAUNCHER_REGISTRY_ENV]?.trim().toLowerCase(); + return value === "1" || value === "true" || value === "yes"; +} + +/** + * Decide how a spawn should start its harness. + * + * AIDEV-NOTE (issue #392): the repoGolem launcher registry is an OPTIONAL + * enhancement. When it names this repo we keep the launcher path verbatim — + * existing installs see no change. When there is no registry, or no entry for + * the repo, we fall back to the raw CLI with a cwd resolved from the repo + * param. Set CMUXLAYER_REQUIRE_LAUNCHER_REGISTRY=1 to restore the old hard + * failure (useful when a typo'd repo name should be an error, not a raw + * launch in a lookalike directory). + */ +export function resolveSpawnLaunchPlan( + repo: string, + cli: CliType, + opts?: { + registryOptions?: LauncherRegistryOptions; + repoRootFallback?: RepoRootFallbackOptions; + env?: Record; + }, +): SpawnPreflightResult { + const registryOptions = opts?.registryOptions; + const launcherName = resolveLauncherNameFromRegistryOrNull( + repo, + cli, + registryOptions, + ); + if (launcherName) { + return { + launcherName, + repoRoot: resolveRepoRootFromLauncherRegistry(repo, registryOptions), + launchMode: "launcher", + }; + } + + const snapshot = loadLauncherRegistrySnapshot(registryOptions); + if (launcherRegistryRequired(opts?.env)) { + // Strict mode: reproduce the self-answering registry error. + resolveLauncherNameFromRegistry(repo, cli, registryOptions); + } + + const registryHint = snapshot.available + ? `Launcher registry ${snapshot.sourcePath} has no entry for "${repo}".` + : `No launcher registry at ${snapshot.sourcePath} (${snapshot.unavailable_reason}).`; + + return { + launchMode: "raw", + launchModeReason: registryHint, + repoRoot: resolveRepoRootWithoutRegistry(repo, { + ...opts?.repoRootFallback, + registryHint, + }), + }; } export class AgentEngine { @@ -1398,31 +1650,11 @@ export class AgentEngine { this.spawnPreflight = opts?.spawnPreflight ?? (async (params): Promise => { - if (params.cli === "claude") { - return { - launcherName: await assertLauncherAvailable(params.repo, "Claude"), - repoRoot: resolveRepoRootFromLauncherRegistry(params.repo), - }; - } + if (params.cli === "kiro") return; if (params.cli === "codex") { await validateCodexModel(params.model, this.codexModelListRunner); - return { - launcherName: await assertLauncherAvailable(params.repo, "Codex"), - repoRoot: resolveRepoRootFromLauncherRegistry(params.repo), - }; - } - if (params.cli === "cursor") { - return { - launcherName: await assertLauncherAvailable(params.repo, "Cursor"), - repoRoot: resolveRepoRootFromLauncherRegistry(params.repo), - }; - } - if (params.cli === "gemini") { - return { - launcherName: await assertLauncherAvailable(params.repo, "Gemini"), - repoRoot: resolveRepoRootFromLauncherRegistry(params.repo), - }; } + return resolveSpawnLaunchPlan(params.repo, params.cli); }); } @@ -3942,18 +4174,13 @@ export class AgentEngine { ); return episode; } - let resumeCommand = "no captured session; inspect the live surface"; - if (episode.cli_session_id) { - try { - resumeCommand = buildRawResumeCommand( + const resumeCommand = episode.cli_session_id + ? (rawResumeCommandOrNull( episode.cli, episode.repo, episode.cli_session_id, - ); - } catch { - // Keep the explicit non-resumable fallback above. - } - } + ) ?? `no raw ${episode.cli} resume form; inspect the live surface`) + : "no captured session; inspect the live surface"; const durationSeconds = Math.max( 0, Math.floor((nowMs - startedAtMs) / 1_000), @@ -4019,12 +4246,12 @@ export class AgentEngine { !!agent.cli_session_id && this.registry.canControlSurface(agent); if (!structurallyEligible) return false; - try { - buildRawResumeCommand(agent.cli, agent.repo, agent.cli_session_id!); - return true; - } catch { - return false; - } + // A harness with no raw resume form (gemini) is structurally not + // auto-revivable on its own surface. + return ( + rawResumeCommandOrNull(agent.cli, agent.repo, agent.cli_session_id!) !== + null + ); } private autoReviveBackoffMs(attempt: number): number { @@ -4042,8 +4269,12 @@ export class AgentEngine { return { record: agent, dispatched: false }; } const attempts = agent.revive_attempts ?? 0; + // Human-facing hint for a surface that may be gone: carry the cwd so the + // command works when pasted into a fresh terminal. const manualResumeCommand = agent.cli_session_id - ? buildRawResumeCommand(agent.cli, agent.repo, agent.cli_session_id) + ? rawResumeCommandOrNull(agent.cli, agent.repo, agent.cli_session_id, { + cwd: resumeCwdForAgent(agent), + }) : null; const tag = outcome === "unrecoverable" @@ -4106,7 +4337,12 @@ export class AgentEngine { outcome === "revived" ? (agent.parsed_model ?? null) : null, manual_resume_command: outcome === "unrecoverable" && agent.cli_session_id - ? buildRawResumeCommand(agent.cli, agent.repo, agent.cli_session_id) + ? rawResumeCommandOrNull( + agent.cli, + agent.repo, + agent.cli_session_id, + { cwd: resumeCwdForAgent(agent) }, + ) : null, }); } @@ -4291,19 +4527,14 @@ export class AgentEngine { if (agent.revive_last_outcome !== "pending" || !agent.cli_session_id) { return null; } - let resumeCommand: string; - try { - resumeCommand = buildRawResumeCommand( - agent.cli, - agent.repo, - agent.cli_session_id, - ); - } catch { - return null; - } - const echoed = screenText.lastIndexOf(resumeCommand); - if (echoed < 0) return null; - const tail = screenText.slice(echoed + resumeCommand.length); + const echo = latestRawResumeEcho( + screenText, + agent.cli, + agent.repo, + agent.cli_session_id, + ); + if (!echo || echo.index < 0) return null; + const tail = screenText.slice(echo.index + echo.command.length); const parsed = parseScreen(tail); // An agent that actually came up is not a rejected resume, whatever else // its own output happens to say. @@ -4787,12 +5018,14 @@ export class AgentEngine { if (!sessionId) { throw new Error("Crash recovery requires a captured session id"); } - const resumeCmd = buildResumeCommand( - agent.cli, - agent.repo, - sessionId, - agent.launcher_name, - ); + // Surface the REAL reason (bad session id, missing cwd, no UUID + // resume form) instead of flattening it to "not resumable". + const recovery = resumeInvocationForAgent({ + ...agent, + cli_session_id: sessionId, + }); + if (recovery.command === null) throw new Error(recovery.reason); + const resumeCmd = recovery.command; await this.beforeCrashRecoveryMutation?.({ phase: "placement", agent_id: agent.agent_id, @@ -7269,6 +7502,30 @@ export class AgentEngine { const preflight = await this.spawnPreflight(spawnParams); const launchCwd = spawnParams.cwd ?? preflight?.repoRoot ?? null; + const launchMode: AgentLaunchMode = preflight?.launchMode ?? "launcher"; + // Truthful provenance for BOTH the door we used and the pin we applied. + // Neither may be inferable only from a null field on the record. + const modelPin = describeModelPin( + spawnParams.cli, + launchMode, + resolveLaunchModelFlagForCommand( + spawnParams.cli, + modelPolicy.launcher_model ?? undefined, + { allowModelOverride: modelPolicy.override_allowed }, + ), + modelPolicy.effective_model, + ); + const launchWarnings = [ + ...modelPolicy.warnings, + ...(launchMode === "raw" && preflight?.launchModeReason + ? [ + `RAW LAUNCH: ${preflight.launchModeReason} Started \`${spawnParams.cli}\` ` + + `directly in "${launchCwd ?? "the surface cwd"}" -- without the ` + + `launcher's MCP wiring or contexts.`, + ] + : []), + ...(modelPin.warning ? [modelPin.warning] : []), + ]; const seatIdentity = assertSeatIdentity({ repo: spawnParams.repo, cli: spawnParams.cli, @@ -7359,6 +7616,8 @@ export class AgentEngine { cli_session_id: null, cli_session_path: null, launcher_name: preflight?.launcherName ?? null, + launch_mode: launchMode, + model_pin: modelPin.pin, seat_id: seatIdentity.seat_id, seat_lane: seatIdentity.seat_lane, seat_role: seatIdentity.seat_role, @@ -7488,13 +7747,19 @@ export class AgentEngine { modelPolicy.launcher_model ?? undefined, preflight?.launcherName, { - cwd: spawnParams.cwd, + // Raw launches must cd themselves; the launcher path keeps its + // existing `-w ` semantics (cwd only when explicitly requested). + cwd: launchMode === "raw" ? (launchCwd ?? undefined) : spawnParams.cwd, envPrefix: spawnParams.mcp_env, allowModelOverride: modelPolicy.override_allowed, effort: effort ?? undefined, + launchMode, }, ); try { + // Tab title stays `` in BOTH modes: agent-discovery parses + // the repo and cli back out of it (agent-discovery.ts:43-62), so the + // title is a discovery contract, not a claim about which binary ran. const launcherName = preflight?.launcherName ?? launcherNameForCli(spawnParams.repo, spawnParams.cli); @@ -7557,10 +7822,12 @@ export class AgentEngine { state: "booting", model: modelPolicy.effective_model, requested_model: modelPolicy.requested_model, - warnings: [...modelPolicy.warnings], + warnings: [...launchWarnings], model_policy: modelPolicy, cwd: launchCwd ?? undefined, mcp_env: spawnParams.mcp_env, + launch_mode: launchMode, + model_pin: modelPin.pin, }; } @@ -7585,12 +7852,18 @@ export class AgentEngine { `Agent "${agent.agent_id}" has no captured CLI session to resume`, ); } - const resumeCommand = buildResumeCommand( - agent.cli, - agent.repo, - agent.cli_session_id, - agent.launcher_name, - ); + // Same authority list_agents uses. Previously this passed + // harnessCwdForAgent, whose ~/Gits guess never returns null -- so an agent + // reported NOT resumable could still be sent `cd ~/Gits/ && claude + // --resume `, silently starting a new session in a lookalike tree. + const resumeInvocation = resumeInvocationForAgent(agent); + if (resumeInvocation.command === null) { + throw new Error( + `Agent "${agent.agent_id}" has no runnable resume command: ` + + `${resumeInvocation.reason}`, + ); + } + const resumeCommand = resumeInvocation.command; const requestedWorkspace = opts?.workspace ?? agent.workspace_id ?? undefined; this.spawnGuard.check(requestedWorkspace); @@ -7955,20 +8228,11 @@ export class AgentEngine { if (!agent) { throw new Error(`Agent not found: ${agentId}`); } - let resumeCommand: string | undefined; - if (agent.cli_session_id) { - try { - resumeCommand = buildResumeCommand( - agent.cli, - agent.repo, - agent.cli_session_id, - agent.launcher_name, - ); - } catch { - // Terminal I/O depends on the stable surface binding, not optional - // resume metadata. A damaged legacy repo field must not disable send. - } - } + // Terminal I/O depends on the stable surface binding, not optional resume + // metadata: resumeCommandForAgent swallows a damaged legacy repo field and + // withholds a cwd-keyed raw resume it cannot aim, rather than advertising + // a command that would silently start a NEW session. + const resumeCommand = resumeCommandForAgent(agent); return { agent_id: agent.agent_id, surface_id: agent.surface_id, diff --git a/src/agent-facade.ts b/src/agent-facade.ts index 7be5af48..e9dd408d 100644 --- a/src/agent-facade.ts +++ b/src/agent-facade.ts @@ -8,32 +8,106 @@ import type { PublicAgent, } from "./agent-types.js"; import { pauseHonestyFields, type PauseSource } from "./types.js"; -import { buildResumeCommand } from "./agent-command.js"; +import { + buildResumeCommand, + rawResumeNeedsCwd, + rawResumeSupported, +} from "./agent-command.js"; export type AgentStatePayload = AgentRecord & { resumable: boolean; resume_command?: string; }; -export function resumeCommandForAgent( +/** + * Directory a resumed harness must be started in. Only the raw-CLI resume form + * consumes it — launchers cd themselves. + */ +export function resumeCwdForAgent( + record: Pick, +): string | null { + return ( + record.worktree_path?.trim() || record.launch_cwd?.trim() || null + ); +} + +/** + * The command that actually resumes this agent, or `undefined` when no honest + * one exists. THE single authority: `resolveAgentRoute`, the public + * projections, and the engine's own resume paths all go through it, so what + * `list_agents` advertises and what `resume_agent` sends can never disagree. + */ +/** + * Like `resumeCommandForAgent`, but explains itself. Callers that must fail + * loudly (the engine's resume + crash-recovery paths) use this so the real + * reason — an invalid session id, a missing cwd, a harness with no UUID resume + * form — reaches the operator instead of being flattened to `undefined`. + */ +export function resumeInvocationForAgent( record: Pick< AgentRecord, - "cli" | "repo" | "cli_session_id" | "launcher_name" + | "cli" + | "repo" + | "cli_session_id" + | "launcher_name" + | "launch_cwd" + | "worktree_path" >, -): string | undefined { - if (!record.cli_session_id) return undefined; +): { command: string; reason: null } | { command: null; reason: string } { + if (!record.cli_session_id) { + return { command: null, reason: "no CLI session has been captured" }; + } + const cwd = resumeCwdForAgent(record); + if (!record.launcher_name) { + if (!cwd && rawResumeNeedsCwd(record.cli)) { + return { + command: null, + reason: + `a raw ${record.cli} resume needs a recorded working directory ` + + `(${record.cli} keys its session store by cwd) and neither ` + + `launch_cwd nor worktree_path is set`, + }; + } + if (!rawResumeSupported(record.cli)) { + return { + command: null, + reason: `${record.cli} has no raw resume form that takes a session UUID`, + }; + } + } try { - return buildResumeCommand( - record.cli, - record.repo, - record.cli_session_id, - record.launcher_name, - ); - } catch { - return undefined; + return { + command: buildResumeCommand( + record.cli, + record.repo, + record.cli_session_id, + record.launcher_name, + { cwd }, + ), + reason: null, + }; + } catch (error) { + return { + command: null, + reason: error instanceof Error ? error.message : String(error), + }; } } +export function resumeCommandForAgent( + record: Pick< + AgentRecord, + | "cli" + | "repo" + | "cli_session_id" + | "launcher_name" + | "launch_cwd" + | "worktree_path" + >, +): string | undefined { + return resumeInvocationForAgent(record).command ?? undefined; +} + function observed( value: T, source: ObservationSource, diff --git a/src/agent-types.ts b/src/agent-types.ts index bb9cbd7e..bebc2e40 100644 --- a/src/agent-types.ts +++ b/src/agent-types.ts @@ -56,6 +56,19 @@ export interface AgentRecord { cli_session_id: string | null; cli_session_path?: string | null; launcher_name?: string | null; + /** + * Which door launched this agent: the repoGolem launcher, or the raw CLI + * because no launcher registration answered (#392). Recorded rather than + * inferred from `launcher_name === null`, so a raw launch past a PRESENT + * registry is legible after the fact. + */ + launch_mode?: "launcher" | "raw" | null; + /** + * Whether `model` was actually pinned at launch, and by what (#433 family): + * "launcher" (repoGolem carries it), "cli_flag" (an explicit --model the raw + * binary understands), or "cli_default" (UNPINNED -- the CLI chose). + */ + model_pin?: "launcher" | "cli_flag" | "cli_default" | null; /** Deliberately pinned tab title used by the resume-integrity manifest. */ tab_name?: string | null; seat_id?: string | null; diff --git a/src/launcher-registry.ts b/src/launcher-registry.ts index 359b8ef9..f1f1cb57 100644 --- a/src/launcher-registry.ts +++ b/src/launcher-registry.ts @@ -45,13 +45,22 @@ function registryPath(options?: LauncherRegistryOptions): string { ); } -function normalizeLauncherKey(value: string): string { +/** + * Registry key normalization: case-, hyphen-, and underscore-insensitive. + * Exported so the registry-less fallback path matches repos to directories by + * exactly the same rule the registered path uses (spawn/resume parity). + */ +export function normalizeRepoKey(value: string): string { return value .trim() .toLowerCase() .replace(/[-_\s]/g, ""); } +function normalizeLauncherKey(value: string): string { + return normalizeRepoKey(value); +} + function shellWords(line: string): string[] { const words: string[] = []; let current = ""; @@ -115,26 +124,63 @@ export function parseLauncherRegistry( return entries; } -function loadLauncherRegistry( +/** + * Non-throwing registry probe. The registry is an OPTIONAL enhancement + * (issue #392): a fresh install has no `launchers.zsh`, and that is a + * supported state, not an error. Callers that need the strict behaviour keep + * using the throwing resolvers below. + */ +export interface LauncherRegistrySnapshot { + available: boolean; + entries: LauncherRegistryEntry[]; + sourcePath: string; + unavailable_reason: string | null; +} + +export function loadLauncherRegistrySnapshot( options?: LauncherRegistryOptions, -): { entries: LauncherRegistryEntry[]; sourcePath: string } { +): LauncherRegistrySnapshot { const sourcePath = registryPath(options); - if (options?.entries) return { entries: options.entries, sourcePath }; - const reader = options?.readRegistry ?? ((path: string) => readFileSync(path, "utf8")); - let input: string; + if (options?.entries) { + return { + available: true, + entries: options.entries, + sourcePath, + unavailable_reason: null, + }; + } + const reader = + options?.readRegistry ?? ((path: string) => readFileSync(path, "utf8")); try { - input = reader(sourcePath); + const input = reader(sourcePath); + return { + available: true, + entries: parseLauncherRegistry(input, sourcePath), + sourcePath, + unavailable_reason: null, + }; } catch (error) { - const reason = error instanceof Error ? error.message : String(error); + return { + available: false, + entries: [], + sourcePath, + unavailable_reason: error instanceof Error ? error.message : String(error), + }; + } +} + +function loadLauncherRegistry( + options?: LauncherRegistryOptions, +): { entries: LauncherRegistryEntry[]; sourcePath: string } { + const snapshot = loadLauncherRegistrySnapshot(options); + if (!snapshot.available) { throw new Error( - `Launcher registry unavailable at ${sourcePath}: ${reason}. ` + + `Launcher registry unavailable at ${snapshot.sourcePath}: ` + + `${snapshot.unavailable_reason}. ` + "Register repoGolem launchers before using spawn_agent.", ); } - return { - entries: parseLauncherRegistry(input, sourcePath), - sourcePath, - }; + return { entries: snapshot.entries, sourcePath: snapshot.sourcePath }; } export function resolveLauncherPrefix( @@ -245,3 +291,39 @@ export function resolveRepoRootFromLauncherRegistry( } return resolve(entry.path); } + +/** + * Registry-optional launcher lookup: `null` means "no registry, or no entry + * for this repo" — the caller should fall back to the raw CLI. A malformed + * registry (ambiguous alias, relative path) still throws, because that is a + * broken config rather than an absent one. + */ +export function resolveLauncherNameFromRegistryOrNull( + repo: string, + cli: CliType, + options?: LauncherRegistryOptions, +): string | null { + const suffix = CLI_SUFFIX[cli]; + if (!suffix) return null; + + const { entries } = loadLauncherRegistrySnapshot(options); + const registeredPrefix = resolveLauncherPrefix(repo, entries); + return registeredPrefix ? launcherName(registeredPrefix, suffix) : null; +} + +/** Registry-optional repo-root lookup. See the launcher variant above. */ +export function resolveRepoRootFromLauncherRegistryOrNull( + repo: string, + options?: LauncherRegistryOptions, +): string | null { + const { entries, sourcePath } = loadLauncherRegistrySnapshot(options); + const entry = resolveLauncherEntry(repo, entries); + if (!entry) return null; + if (!isAbsolute(entry.path)) { + throw new Error( + `Launcher registry path for repo "${repo}" must be absolute: ` + + `"${entry.path}" in ${sourcePath}.`, + ); + } + return resolve(entry.path); +} diff --git a/src/repo-root-fallback.ts b/src/repo-root-fallback.ts new file mode 100644 index 00000000..7779cb30 --- /dev/null +++ b/src/repo-root-fallback.ts @@ -0,0 +1,95 @@ +import { existsSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, dirname, isAbsolute, join } from "node:path"; +import { sanitizeRepoName } from "./agent-command.js"; +import { normalizeRepoKey } from "./launcher-registry.js"; + +/** + * Where a repo lives when there is NO repoGolem launcher registry. + * + * AIDEV-NOTE: AGENTS.md law — "someone installing this fresh has none of my + * skills or launchers". The registry is an optional enhancement, so spawn and + * resume must still resolve a working directory from the `repo` param alone. + * The search is deterministic and fully enumerable so a miss can explain + * itself instead of silently launching in the wrong tree. + */ +export const REPO_HOME_ENV = "CMUXLAYER_REPO_HOME"; + +export interface RepoRootFallbackOptions { + /** Working directory of the running cmuxlayer process. */ + cwd?: string; + homeDir?: string; + env?: Record; + /** Injected for tests; defaults to a real filesystem directory probe. */ + isDirectory?: (path: string) => boolean; + /** Extra context (e.g. why the registry did not answer) for the miss error. */ + registryHint?: string; +} + +function isRealDirectory(path: string): boolean { + try { + return existsSync(path) && statSync(path).isDirectory(); + } catch { + return false; + } +} + +function envRoots(env: Record): string[] { + return (env[REPO_HOME_ENV] ?? "") + .split(":") + .map((part) => part.trim()) + .filter((part) => part.length > 0 && isAbsolute(part)); +} + +/** + * The full, ordered candidate list for `repo` — highest confidence first: + * 1. every absolute root in `CMUXLAYER_REPO_HOME` (colon separated) + * 2. the running checkout itself, when its basename IS the repo + * 3. a sibling of the running checkout + * 4. `~/Gits/` (the historical default this repo was built against) + * 5. `~/` + * Exported so the miss error can print exactly what was searched. + */ +export function repoRootSearchCandidates( + repo: string, + options?: RepoRootFallbackOptions, +): string[] { + const safeRepo = sanitizeRepoName(repo); + const cwd = options?.cwd ?? process.cwd(); + const home = options?.homeDir ?? homedir(); + const env = options?.env ?? process.env; + + const candidates = [ + ...envRoots(env).map((root) => join(root, safeRepo)), + // The running checkout only counts when it IS this repo; otherwise the + // sibling form below is the right guess. + ...(normalizeRepoKey(basename(cwd)) === normalizeRepoKey(safeRepo) + ? [cwd] + : [join(dirname(cwd), safeRepo)]), + join(home, "Gits", safeRepo), + join(home, safeRepo), + ]; + return [...new Set(candidates)]; +} + +/** + * Resolve a launch/resume cwd for `repo` without consulting the launcher + * registry. Throws a self-answering error naming every path it searched. + */ +export function resolveRepoRootWithoutRegistry( + repo: string, + options?: RepoRootFallbackOptions, +): string { + const candidates = repoRootSearchCandidates(repo, options); + const isDirectory = options?.isDirectory ?? isRealDirectory; + const hit = candidates.find((candidate) => isDirectory(candidate)); + if (hit) return hit; + + const hint = options?.registryHint ? `${options.registryHint} ` : ""; + throw new Error( + `Cannot resolve a working directory for repo "${repo}". ${hint}` + + `Searched: ${candidates.join(", ")}. ` + + `Set ${REPO_HOME_ENV} to a colon-separated list of directories that ` + + `contain your checkouts, or pass an explicit cwd.`, + ); +} diff --git a/src/server.ts b/src/server.ts index 905ee1b1..c163bc7a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -240,7 +240,8 @@ import { type McpProfile, type WorktreeExec, } from "./worktree.js"; -import { resolveRepoRootFromLauncherRegistry } from "./launcher-registry.js"; +import { resolveRepoRootFromLauncherRegistryOrNull } from "./launcher-registry.js"; +import { resolveRepoRootWithoutRegistry } from "./repo-root-fallback.js"; import { loadSeatRegistryFromConfig, type SeatRegistry, @@ -10717,9 +10718,12 @@ export function createServer(opts?: CreateServerOptions): McpServer { } const profile = mcpProfile ?? "inherit"; + // Registry-optional (issue #392): a registered repo keeps its registry + // path; otherwise fall back to the same search spawn uses. const repoRoot = disableSpawnPreflight ? resolve(opts?.worktreeHomeDir ?? join(homedir(), "Gits"), repo) - : resolveRepoRootFromLauncherRegistry(repo); + : (resolveRepoRootFromLauncherRegistryOrNull(repo) ?? + resolveRepoRootWithoutRegistry(repo)); const prepared = await prepareWorktree({ repo, repoRoot, @@ -11157,7 +11161,7 @@ export function createServer(opts?: CreateServerOptions): McpServer { worktree: worktreeArgSchema .optional() .describe( - 'When set, create or reuse a git worktree before launch. Pass a string such as "tool-usage" as the worktree name, true for a generated name, or an object with name, path, branch, base, create, and reuse. A repoGolem registration with an absolute path is required; that registry path is the repo root. true uses /.worktrees/ (legacy ~/Gits/.wt read-fallback until ~2026-09). If a later spawn step fails before a recoverable surface exists, a newly created worktree and branch are rolled back.', + 'When set, create or reuse a git worktree before launch. Pass a string such as "tool-usage" as the worktree name, true for a generated name, or an object with name, path, branch, base, create, and reuse. When repoGolem registers the repo with an absolute path, that path is the repo root; otherwise the root is resolved from CMUXLAYER_REPO_HOME, the running checkout, or ~/Gits. true uses /.worktrees/ (legacy ~/Gits/.wt read-fallback until ~2026-09). If a later spawn step fails before a recoverable surface exists, a newly created worktree and branch are rolled back.', ), mcp_profile: mcpProfileSchema .optional() @@ -12059,7 +12063,7 @@ export function createServer(opts?: CreateServerOptions): McpServer { worktree: worktreeArgSchema .optional() .describe( - 'Worktree options. Pass a string such as "tool-usage" as the worktree name, true for a generated name, or an options object. A repoGolem registration with an absolute path is required. Defaults to true, creating/reusing /.worktrees/; a newly created worktree and branch are rolled back if spawn fails before a recoverable surface exists (legacy ~/Gits/.wt read-fallback until ~2026-09).', + 'Worktree options. Pass a string such as "tool-usage" as the worktree name, true for a generated name, or an options object. A repoGolem registration with an absolute path names the repo root; without one it is resolved from CMUXLAYER_REPO_HOME, the running checkout, or ~/Gits. Defaults to true, creating/reusing /.worktrees/; a newly created worktree and branch are rolled back if spawn fails before a recoverable surface exists (legacy ~/Gits/.wt read-fallback until ~2026-09).', ), mcp_profile: mcpProfileSchema .optional() diff --git a/tests/agent-engine.test.ts b/tests/agent-engine.test.ts index f5f83fa7..c2e259c4 100644 --- a/tests/agent-engine.test.ts +++ b/tests/agent-engine.test.ts @@ -3628,6 +3628,7 @@ describe("AgentEngine", () => { model: "gpt-5.4", cli: "codex", cli_session_id: "019d9aa5-93c0-7a52-9c47-9be1f7625f3e", + launcher_name: "brainlayerCodex", crash_recover: true, }), ); @@ -3656,6 +3657,38 @@ describe("AgentEngine", () => { expect(recovered?.respawn_attempts).toBe(1); }); + // Issue #392: a registry-less spawn records no launcher, so crash recovery + // must resume through the raw CLI in the recorded cwd -- never through a + // guessed `Codex` binary that does not exist on a fresh install. + it("respawns a launcher-less crashed agent through the raw CLI in its launch cwd", async () => { + stateMgr.writeState( + makeRecord({ + agent_id: "agent-crash-raw", + state: "working", + surface_id: "surface:dead-raw", + repo: "brainlayer", + model: "gpt-5.4", + cli: "codex", + cli_session_id: "019d9aa5-93c0-7a52-9c47-9be1f7625f3e", + launcher_name: null, + launch_cwd: "/srv/repos/brainlayer", + crash_recover: true, + }), + ); + liveSurfaces = [makeSurface("surface:dead-raw")]; + await engine.getRegistry().reconstitute(); + + liveSurfaces = [makeSurface("surface:other")]; + await runConfirmedSurfaceAbsenceSweep(); + + expect(mockClient.send).toHaveBeenCalledWith( + "surface:new", + "cd '/srv/repos/brainlayer' && codex --dangerously-bypass-approvals-and-sandbox resume 019d9aa5-93c0-7a52-9c47-9be1f7625f3e", + { workspace: "ws:1" }, + ); + expect(engine.getAgentState("agent-crash-raw")?.state).toBe("booting"); + }); + it("explicitly resumes a captured session on a new surface without changing the public agent id", async () => { stateMgr.writeState( makeRecord({ @@ -3666,6 +3699,7 @@ describe("AgentEngine", () => { repo: "brainlayer", cli: "codex", cli_session_id: "019d9aa5-93c0-7a52-9c47-9be1f7625f3e", + launcher_name: "brainlayerCodex", }), ); await engine.getRegistry().reconstitute(); @@ -3682,6 +3716,32 @@ describe("AgentEngine", () => { expect(engine.getAgentState("agent-stable-resume")?.state).toBe("booting"); }); + it("explicitly resumes a launcher-less agent through the raw CLI", async () => { + stateMgr.writeState( + makeRecord({ + agent_id: "agent-stable-resume-raw", + state: "done", + surface_id: "surface:old-raw", + workspace_id: "ws:1", + repo: "brainlayer", + cli: "claude", + cli_session_id: "019d9aa5-93c0-7a52-9c47-9be1f7625f3e", + launcher_name: null, + launch_cwd: "/srv/repos/brainlayer", + }), + ); + await engine.getRegistry().reconstitute(); + + const resumed = await engine.resumeAgent("agent-stable-resume-raw"); + + expect(resumed.agent_id).toBe("agent-stable-resume-raw"); + expect(mockClient.send).toHaveBeenCalledWith( + "surface:new", + "cd '/srv/repos/brainlayer' && MCP_CONNECTION_NONBLOCKING=1 CLAUDE_CODE_NO_FLICKER=1 claude --dangerously-skip-permissions --resume 019d9aa5-93c0-7a52-9c47-9be1f7625f3e", + { workspace: "ws:1" }, + ); + }); + it("rejects an invalid crash-recovery session before creating a surface", async () => { stateMgr.writeState( makeRecord({ @@ -9275,7 +9335,7 @@ Session ID: ${sessionId}`, .mockResolvedValue({ surface: "surface:cli-auto-revive", text: - `❯ codex resume ${sessionId}\n` + + `❯ codex --dangerously-bypass-approvals-and-sandbox resume ${sessionId}\n` + "gpt-5.4 xhigh · 64% left\nWorking (2s • esc to interrupt)", lines: 80, scrollback_used: false, @@ -9288,7 +9348,7 @@ Session ID: ${sessionId}`, expect(mockClient.newSplit).not.toHaveBeenCalled(); expect(mockClient.send).toHaveBeenCalledWith( "surface:cli-auto-revive", - `codex resume ${sessionId}`, + `codex --dangerously-bypass-approvals-and-sandbox resume ${sessionId}`, expect.objectContaining({ workspace: undefined }), ); expect(mockClient.sendKey).toHaveBeenCalledWith( @@ -9351,7 +9411,7 @@ Session ID: ${sessionId}`, text: "› signal: killed my current changes\n" + " gpt-5.6-sol xhigh · ~/Gits/cmuxlayer\n" + - " % etanheyman ~/Gits/cmuxlayer [main] $ codex resume 019faccc-4040-7555-8666-777788889999\n" + + " % etanheyman ~/Gits/cmuxlayer [main] $ codex --dangerously-bypass-approvals-and-sandbox resume 019faccc-4040-7555-8666-777788889999\n" + "Shellbook: starting the agent directly.\n" + "Shellbook: run `shellbook tui` in another terminal for the social pane.\n" + "Error: Failed to resume session: no rollout found (code -32600)\n" + @@ -9408,7 +9468,7 @@ Session ID: ${sessionId}`, text: "› signal: killed my current changes\n" + "gpt-5.6-sol xhigh · ~/Gits/cmuxlayer\n" + - `${shellPrompt} codex resume 019faccc-4545-7555-8666-777788889999\n` + + `${shellPrompt} codex --dangerously-bypass-approvals-and-sandbox resume 019faccc-4545-7555-8666-777788889999\n` + "Shellbook: starting the agent directly.\n" + "Error: Failed to resume session: no rollout found (code -32600)\n" + "exit status 1", @@ -9502,7 +9562,7 @@ Session ID: ${sessionId}`, text: "Claude Code\n" + "❯\n" + - "➜ MCP_CONNECTION_NONBLOCKING=1 CLAUDE_CODE_NO_FLICKER=1 claude --resume 019faccc-4646-7555-8666-777788889999\n" + + "➜ MCP_CONNECTION_NONBLOCKING=1 CLAUDE_CODE_NO_FLICKER=1 claude --dangerously-skip-permissions --resume 019faccc-4646-7555-8666-777788889999\n" + "Error: No conversation found with session ID 019faccc-4646-7555-8666-777788889999\n" + "exit status 1", lines: 80, @@ -9527,7 +9587,7 @@ Session ID: ${sessionId}`, cli: "cursor" as const, id: "019faccc-4848-7555-8666-777788889999", command: - "cursor agent --resume 019faccc-4848-7555-8666-777788889999", + "cursor agent --force --resume 019faccc-4848-7555-8666-777788889999", staleReady: "Cursor Agent\ncursor>", }, { @@ -9616,7 +9676,7 @@ Session ID: ${sessionId}`, text: "Error: Failed to resume session: no rollout found (code -32600)\n" + "exit status 1\n" + - "% codex resume 019faccc-5050-7666-8777-888899990000\n" + + "% codex --dangerously-bypass-approvals-and-sandbox resume 019faccc-5050-7666-8777-888899990000\n" + "OpenAI Codex\n" + "Model: gpt-5.6-sol xhigh\n" + "› Continue the previous task\n" + @@ -9675,7 +9735,7 @@ Session ID: ${sessionId}`, expect(mockClient.send).toHaveBeenCalledWith( "surface:cli-auto-revive-flapping", - `codex resume ${sessionId}`, + `codex --dangerously-bypass-approvals-and-sandbox resume ${sessionId}`, expect.objectContaining({ workspace: undefined }), ); expect( @@ -9879,7 +9939,7 @@ Session ID: ${sessionId}`, expect(readInbox("cmuxlayerClaude", { baseDir: TEST_DIR })).toEqual([ expect.objectContaining({ tag: "agent_cli_exit_unrecoverable", - task: expect.stringContaining(`Manual fallback: codex resume ${sessionId}`), + task: expect.stringContaining(`Manual fallback: codex --dangerously-bypass-approvals-and-sandbox resume ${sessionId}`), }), ]); expect( @@ -9923,7 +9983,7 @@ Session ID: ${sessionId}`, .mockResolvedValue({ surface: "surface:cursor-auto-revive", text: - `% cursor agent --resume ${sessionId}\n` + + `% cursor agent --force --resume ${sessionId}\n` + "Cursor Agent\n→ Plan, search, build anything\nAuto\ncursor>", lines: 80, scrollback_used: false, @@ -9935,7 +9995,7 @@ Session ID: ${sessionId}`, expect(mockClient.send).toHaveBeenCalledWith( "surface:cursor-auto-revive", - `cursor agent --resume ${sessionId}`, + `cursor agent --force --resume ${sessionId}`, expect.objectContaining({ workspace: undefined }), ); @@ -10041,7 +10101,7 @@ Session ID: ${sessionId}`, (mockClient.readScreen as ReturnType).mockResolvedValue({ surface: "surface:cursor-auto-revive-rejected", text: [ - `% cursor agent --resume ${sessionId}`, + `% cursor agent --force --resume ${sessionId}`, "error: unknown option '--resume'", "(Did you mean --version?)", "%", @@ -10092,7 +10152,7 @@ Session ID: ${sessionId}`, liveSurfaces = [makeSurface("surface:cursor-auto-revive-exhausted")]; (mockClient.readScreen as ReturnType).mockResolvedValue({ surface: "surface:cursor-auto-revive-exhausted", - text: [`% cursor agent --resume ${sessionId}`, "%"].join("\n"), + text: [`% cursor agent --force --resume ${sessionId}`, "%"].join("\n"), lines: 80, scrollback_used: false, }); @@ -10113,7 +10173,7 @@ Session ID: ${sessionId}`, expect.objectContaining({ tag: "agent_cli_exit_unrecoverable", task: expect.stringContaining( - `Manual fallback: cursor agent --resume ${sessionId}`, + `Manual fallback: cursor agent --force --resume ${sessionId}`, ), }), ]); @@ -10843,7 +10903,7 @@ Session ID: ${sessionId}`, to: parent.agent_id, tag: "agent_halt_awaiting_input", task: expect.stringMatching( - /cmuxlayerCodex-awaiting.*surface:halt-awaiting.*awaiting_input.*1s.*send_key\(surface: "surface:halt-awaiting", key: "return"\).*codex resume 019fad12-1111-7222-8333-444455556666/s, + /cmuxlayerCodex-awaiting.*surface:halt-awaiting.*awaiting_input.*1s.*send_key\(surface: "surface:halt-awaiting", key: "return"\).*codex --dangerously-bypass-approvals-and-sandbox resume 019fad12-1111-7222-8333-444455556666/s, ), }), ]); @@ -11853,7 +11913,7 @@ Session ID: ${sessionId}`, ["codex", "Codex"], ["cursor", "Cursor"], ] as const)( - "rejects missing %s repoGolem launchers before creating a surface", + "rejects an unresolvable %s repo before creating a surface", async (cli, suffix) => { const registryPath = join(TEST_DIR, `missing-${cli}-launchers.zsh`); writeFileSync( @@ -11865,13 +11925,21 @@ Session ID: ${sessionId}`, const registry = new AgentRegistry(stateMgr, async () => liveSurfaces); const defaultEngine = new AgentEngine(stateMgr, registry, mockClient); try { + // Registry-optional (#392): an unregistered repo is no longer a hard + // stop, but a repo that exists nowhere on disk still cannot spawn -- + // and the error names both doors it tried. await expect( defaultEngine.spawnAgent({ repo: `missinglauncher${suffix}`, cli, prompt: "", }), - ).rejects.toThrow(`missinglauncher${suffix}${suffix}`); + ).rejects.toThrow( + new RegExp( + `Cannot resolve a working directory for repo "missinglauncher${suffix}".*has no entry.*Searched:.*CMUXLAYER_REPO_HOME`, + "s", + ), + ); expect(mockClient.newSplit).not.toHaveBeenCalled(); } finally { defaultEngine.dispose(); @@ -11880,35 +11948,107 @@ Session ID: ${sessionId}`, }, ); - it("uses the launcher registry and fails loudly before any bare split fallback", async () => { - const registryPath = join(TEST_DIR, "launchers.zsh"); + it("falls back to the raw CLI when the repo is not in the registry", async () => { + const registryPath = join(TEST_DIR, "raw-fallback-launchers.zsh"); + writeFileSync( + registryPath, + 'repoGolem mm "/Users/etanheyman/Gits/matchmat"\n', + ); + const repoHome = join(TEST_DIR, "raw-repos"); + mkdirSync(join(repoHome, "freshrepo"), { recursive: true }); + vi.stubEnv("CMUXLAYER_LAUNCHER_REGISTRY_PATH", registryPath); + vi.stubEnv("CMUXLAYER_REPO_HOME", repoHome); + + const registry = new AgentRegistry(stateMgr, async () => liveSurfaces); + const defaultEngine = new AgentEngine(stateMgr, registry, mockClient, { + sessionIdentityResolver: () => null, + }); + try { + const result = await defaultEngine.spawnAgent({ + repo: "freshrepo", + cli: "claude", + prompt: "", + }); + + const [, launchCmd] = (mockClient.send as ReturnType).mock + .calls[0]; + expect(launchCmd).toBe( + `cd '${join(repoHome, "freshrepo")}' && ` + + "MCP_CONNECTION_NONBLOCKING=1 CLAUDE_CODE_NO_FLICKER=1 claude --dangerously-skip-permissions", + ); + const state = defaultEngine.getAgentState(result.agent_id); + expect(state?.launcher_name).toBeNull(); + expect(state?.launch_cwd).toBe(join(repoHome, "freshrepo")); + } finally { + defaultEngine.dispose(); + vi.unstubAllEnvs(); + } + }); + + it("restores the hard registry requirement under CMUXLAYER_REQUIRE_LAUNCHER_REGISTRY", async () => { + const registryPath = join(TEST_DIR, "strict-launchers.zsh"); writeFileSync( registryPath, 'repoGolem mm "/Users/etanheyman/Gits/matchmat"\n', ); + const repoHome = join(TEST_DIR, "strict-repos"); + mkdirSync(join(repoHome, "freshrepo"), { recursive: true }); vi.stubEnv("CMUXLAYER_LAUNCHER_REGISTRY_PATH", registryPath); + vi.stubEnv("CMUXLAYER_REPO_HOME", repoHome); + vi.stubEnv("CMUXLAYER_REQUIRE_LAUNCHER_REGISTRY", "1"); const registry = new AgentRegistry(stateMgr, async () => liveSurfaces); const defaultEngine = new AgentEngine(stateMgr, registry, mockClient); try { await expect( defaultEngine.spawnAgent({ - repo: "missinglauncher", + repo: "freshrepo", cli: "claude", prompt: "", - cwd: "/tmp/cmux-worktree", }), ).rejects.toThrow( - /Launcher registry miss.*missinglauncherClaude.*launchers\.zsh.*\/Users\/etanheyman\/Gits\/matchmat.*mmClaude/s, + /Launcher registry miss.*freshrepoClaude.*strict-launchers\.zsh.*mmClaude/s, ); expect(mockClient.newSplit).not.toHaveBeenCalled(); - expect(mockClient.send).not.toHaveBeenCalled(); expect(stateMgr.listStates()).toHaveLength(0); } finally { defaultEngine.dispose(); vi.unstubAllEnvs(); } }); + + it("uses the launcher registry verbatim when the repo IS registered", async () => { + const registryPath = join(TEST_DIR, "launchers.zsh"); + const registeredRoot = join(TEST_DIR, "registered", "matchmat"); + mkdirSync(registeredRoot, { recursive: true }); + writeFileSync( + registryPath, + `repoGolem mm "${registeredRoot}"\n`, + ); + vi.stubEnv("CMUXLAYER_LAUNCHER_REGISTRY_PATH", registryPath); + + const registry = new AgentRegistry(stateMgr, async () => liveSurfaces); + const defaultEngine = new AgentEngine(stateMgr, registry, mockClient, { + sessionIdentityResolver: () => null, + }); + try { + const result = await defaultEngine.spawnAgent({ + repo: "matchmat", + cli: "claude", + prompt: "", + }); + + const [, launchCmd] = (mockClient.send as ReturnType).mock + .calls[0]; + expect(launchCmd).toBe("mmClaude -s"); + const state = defaultEngine.getAgentState(result.agent_id); + expect(state?.launcher_name).toBe("mmClaude"); + expect(state?.launch_cwd).toBe(registeredRoot); + } finally { + defaultEngine.dispose(); + vi.unstubAllEnvs(); + } + }); }); describe("waitForAll", () => { @@ -14275,38 +14415,44 @@ describe("buildResumeCommand", () => { it("builds raw harness resume commands for engine-owned same-surface revival", () => { expect(buildRawResumeCommand("codex", "brainlayer", sessionId)).toBe( - `codex resume ${sessionId}`, + `codex --dangerously-bypass-approvals-and-sandbox resume ${sessionId}`, ); expect(buildRawResumeCommand("claude", "brainlayer", sessionId)).toBe( - `MCP_CONNECTION_NONBLOCKING=1 CLAUDE_CODE_NO_FLICKER=1 claude --resume ${sessionId}`, + `MCP_CONNECTION_NONBLOCKING=1 CLAUDE_CODE_NO_FLICKER=1 claude --dangerously-skip-permissions --resume ${sessionId}`, ); // `cursor agent` has no `--session`; the real flag is `--resume [chatId]`. // Verified against `cursor agent --help` -- `--session` exits with // "error: unknown option '--session'". expect(buildRawResumeCommand("cursor", "brainlayer", sessionId)).toBe( - `cursor agent --resume ${sessionId}`, - ); - expect(buildRawResumeCommand("gemini", "brainlayer", sessionId)).toBe( - `MCP_CONNECTION_NONBLOCKING=1 CLAUDE_CODE_NO_FLICKER=1 gemini --resume ${sessionId}`, + `cursor agent --force --resume ${sessionId}`, ); + // gemini is refused, not emitted: `gemini --resume` takes "latest" or an + // index number, never a session UUID (verified against `gemini --help`). + expect(() => + buildRawResumeCommand("gemini", "brainlayer", sessionId), + ).toThrow(/No raw gemini resume exists for a session UUID/); expect(buildRawResumeCommand("kiro", "brainlayer", sessionId)).toBe( `cd ~/Gits/brainlayer && MCP_CONNECTION_NONBLOCKING=1 CLAUDE_CODE_NO_FLICKER=1 kiro-cli chat --resume-id ${sessionId}`, ); }); it("uses the verified resume command for each supported CLI", () => { - expect(buildResumeCommand("claude", "brainlayer", sessionId)).toBe( - "brainlayerClaude -s --resume 019d9aa5-93c0-7a52-9c47-9be1f7625f3e", - ); - expect(buildResumeCommand("codex", "brainlayer", sessionId)).toBe( + // Issue #392: the launcher form requires a RECORDED launcher name. Absent + // one, the raw-CLI form is used (covered in registry-optional-resume). + expect( + buildResumeCommand("claude", "brainlayer", sessionId, "brainlayerClaude"), + ).toBe("brainlayerClaude -s --resume 019d9aa5-93c0-7a52-9c47-9be1f7625f3e"); + expect( + buildResumeCommand("codex", "brainlayer", sessionId, "brainlayerCodex"), + ).toBe( "brainlayerCodex --dangerously-bypass-approvals-and-sandbox resume 019d9aa5-93c0-7a52-9c47-9be1f7625f3e", ); - expect(buildResumeCommand("cursor", "brainlayer", sessionId)).toBe( - "brainlayerCursor -s --resume 019d9aa5-93c0-7a52-9c47-9be1f7625f3e", - ); - expect(buildResumeCommand("gemini", "brainlayer", sessionId)).toBe( - "brainlayerGemini -s --resume 019d9aa5-93c0-7a52-9c47-9be1f7625f3e", - ); + expect( + buildResumeCommand("cursor", "brainlayer", sessionId, "brainlayerCursor"), + ).toBe("brainlayerCursor -s --resume 019d9aa5-93c0-7a52-9c47-9be1f7625f3e"); + expect( + buildResumeCommand("gemini", "brainlayer", sessionId, "brainlayerGemini"), + ).toBe("brainlayerGemini -s --resume 019d9aa5-93c0-7a52-9c47-9be1f7625f3e"); expect( buildResumeCommand( "gemini", @@ -14362,7 +14508,7 @@ describe("buildResumeCommand", () => { ).toThrow(/full session UUID/i); }); - it("does not advertise resumable when no clean launcher can be derived", () => { + it("does not advertise resumable when no launcher and no cwd can be derived", () => { const payload = toAgentStatePayload( makeRecord({ cli: "claude", @@ -14375,6 +14521,23 @@ describe("buildResumeCommand", () => { expect(payload.resumable).toBe(false); expect(payload).not.toHaveProperty("resume_command"); }); + + it("advertises the raw form once a launch cwd is recorded", () => { + const payload = toAgentStatePayload( + makeRecord({ + cli: "claude", + repo: "brainlayer-lead PR647-red-baseline", + launcher_name: null, + launch_cwd: "/srv/repos/brainlayer", + cli_session_id: sessionId, + }), + ); + + expect(payload.resumable).toBe(true); + expect(payload.resume_command).toBe( + `cd '/srv/repos/brainlayer' && MCP_CONNECTION_NONBLOCKING=1 CLAUDE_CODE_NO_FLICKER=1 claude --dangerously-skip-permissions --resume ${sessionId}`, + ); + }); }); describe("extractSessionId", () => { diff --git a/tests/agent-facade.test.ts b/tests/agent-facade.test.ts index 8b7c19ec..f591ad6a 100644 --- a/tests/agent-facade.test.ts +++ b/tests/agent-facade.test.ts @@ -18,6 +18,8 @@ function makeRecord(overrides?: Partial): AgentRecord { model: "sonnet", cli: "claude", cli_session_id: "019d9aa5-93c0-7a52-9c47-9be1f7625f3e", + launcher_name: "brainlayerClaude", + launch_cwd: "/Users/etanheyman/Gits/brainlayer", task_summary: "Fix the bug", pid: null, version: 1, @@ -140,6 +142,36 @@ describe("agent facade projections", () => { expect((projected as any).surface_id).toBeUndefined(); }); + it("emits a raw cd+CLI resume for a registry-less record (issue #392)", () => { + const projected = toPublicAgent( + makeRecord({ launcher_name: null, launch_cwd: "/srv/repos/brainlayer" }), + ); + + expect(projected.resumable).toBe(true); + expect(projected.resume_command).toBe( + "cd '/srv/repos/brainlayer' && MCP_CONNECTION_NONBLOCKING=1 CLAUDE_CODE_NO_FLICKER=1 claude --dangerously-skip-permissions --resume 019d9aa5-93c0-7a52-9c47-9be1f7625f3e", + ); + }); + + it("withholds a cwd-keyed raw resume it cannot aim", () => { + const projected = toPublicAgent( + makeRecord({ launcher_name: null, launch_cwd: null }), + ); + + expect(projected.resumable).toBe(false); + expect(projected).not.toHaveProperty("resume_command"); + }); + + it("still advertises codex, whose session store is not cwd-keyed", () => { + const projected = toPublicAgent( + makeRecord({ cli: "codex", launcher_name: null, launch_cwd: null }), + ); + + expect(projected.resume_command).toBe( + "codex --dangerously-bypass-approvals-and-sandbox resume 019d9aa5-93c0-7a52-9c47-9be1f7625f3e", + ); + }); + it("omits resume_command when no session id has been captured", () => { const projected = toPublicAgent(makeRecord({ cli_session_id: null })); diff --git a/tests/launcher-registry.test.ts b/tests/launcher-registry.test.ts index 52a0ba9c..b7e8ccb8 100644 --- a/tests/launcher-registry.test.ts +++ b/tests/launcher-registry.test.ts @@ -1,9 +1,12 @@ import { describe, expect, it } from "vitest"; import { + loadLauncherRegistrySnapshot, parseLauncherRegistry, resolveLauncherNameFromRegistry, resolveLauncherPrefix, + resolveLauncherNameFromRegistryOrNull, resolveRepoRootFromLauncherRegistry, + resolveRepoRootFromLauncherRegistryOrNull, } from "../src/launcher-registry.js"; const REGISTRY = ` @@ -137,3 +140,108 @@ describe("launcher registry", () => { ).toThrow(/Launcher registry unavailable.*\/missing\/launchers\.zsh/s); }); }); + +describe("registry-optional resolution (issue #392)", () => { + const missingRegistry = { + sourcePath: "/missing/launchers.zsh", + readRegistry: (): string => { + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }, + }; + + it("reports an absent registry as unavailable instead of throwing", () => { + const snapshot = loadLauncherRegistrySnapshot(missingRegistry); + + expect(snapshot.available).toBe(false); + expect(snapshot.entries).toEqual([]); + expect(snapshot.sourcePath).toBe("/missing/launchers.zsh"); + expect(snapshot.unavailable_reason).toMatch(/ENOENT/); + }); + + it("reports a present registry as available", () => { + const snapshot = loadLauncherRegistrySnapshot({ + sourcePath: "/tmp/launchers.zsh", + readRegistry: () => REGISTRY, + }); + + expect(snapshot.available).toBe(true); + expect(snapshot.entries).toHaveLength(3); + expect(snapshot.unavailable_reason).toBeNull(); + }); + + it("returns null launcher/root when the registry file is absent", () => { + expect( + resolveLauncherNameFromRegistryOrNull("matchmat", "claude", missingRegistry), + ).toBeNull(); + expect( + resolveRepoRootFromLauncherRegistryOrNull("matchmat", missingRegistry), + ).toBeNull(); + }); + + it("returns null when the registry exists but the repo is unregistered", () => { + const entries = parseLauncherRegistry(REGISTRY, "/tmp/launchers.zsh"); + + expect( + resolveLauncherNameFromRegistryOrNull("unknown", "claude", { + entries, + sourcePath: "/tmp/launchers.zsh", + }), + ).toBeNull(); + expect( + resolveRepoRootFromLauncherRegistryOrNull("unknown", { + entries, + sourcePath: "/tmp/launchers.zsh", + }), + ).toBeNull(); + }); + + it("still answers registered repos exactly as the strict resolver does", () => { + const entries = parseLauncherRegistry(REGISTRY, "/tmp/launchers.zsh"); + const options = { entries, sourcePath: "/tmp/launchers.zsh" }; + + expect(resolveLauncherNameFromRegistryOrNull("matchmat", "claude", options)).toBe( + resolveLauncherNameFromRegistry("matchmat", "claude", options), + ); + expect(resolveRepoRootFromLauncherRegistryOrNull("hyphen", options)).toBe( + resolveRepoRootFromLauncherRegistry("hyphen", options), + ); + }); + + it("still throws on a genuinely broken registry rather than falling back", () => { + const ambiguous = { + entries: parseLauncherRegistry( + `repoGolem first "/tmp/one/shared"\nrepoGolem second "/tmp/two/shared"\n`, + "/tmp/launchers.zsh", + ), + sourcePath: "/tmp/launchers.zsh", + }; + + expect(() => + resolveRepoRootFromLauncherRegistryOrNull("shared", ambiguous), + ).toThrow(/Ambiguous launcher registry match/); + + expect(() => + resolveRepoRootFromLauncherRegistryOrNull("relative", { + entries: [ + { + prefix: "relative", + path: "../somewhere-else", + repoBasename: "somewhere-else", + }, + ], + sourcePath: "/tmp/launchers.zsh", + }), + ).toThrow(/must be absolute/); + }); + + it("returns null for a cli that has no launcher suffix (kiro is raw already)", () => { + const entries = parseLauncherRegistry(REGISTRY, "/tmp/launchers.zsh"); + + expect( + resolveLauncherNameFromRegistryOrNull("matchmat", "kiro", { + entries, + sourcePath: "/tmp/launchers.zsh", + }), + ).toBeNull(); + }); +}); diff --git a/tests/registry-optional-resume.test.ts b/tests/registry-optional-resume.test.ts new file mode 100644 index 00000000..0bca04ea --- /dev/null +++ b/tests/registry-optional-resume.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, it } from "vitest"; +import { + buildRawResumeCommand, + buildResumeCommand, +} from "../src/agent-command.js"; +import { + resumeCommandForAgent, + resumeInvocationForAgent, +} from "../src/agent-facade.js"; + +const SESSION = "019d9aa5-93c0-7a52-9c47-9be1f7625f3e"; + +describe("raw resume commands carry a working directory", () => { + it("prefixes a cd for every harness when a cwd is known", () => { + expect( + buildRawResumeCommand("claude", "brainlayer", SESSION, { + cwd: "/srv/repos/brainlayer", + }), + ).toBe( + "cd '/srv/repos/brainlayer' && MCP_CONNECTION_NONBLOCKING=1 CLAUDE_CODE_NO_FLICKER=1 claude --dangerously-skip-permissions --resume " + + SESSION, + ); + expect( + buildRawResumeCommand("codex", "brainlayer", SESSION, { + cwd: "/srv/repos/brainlayer", + }), + ).toBe(`cd '/srv/repos/brainlayer' && codex --dangerously-bypass-approvals-and-sandbox resume ${SESSION}`); + expect( + buildRawResumeCommand("cursor", "brainlayer", SESSION, { + cwd: "/srv/repos/brainlayer", + }), + ).toBe(`cd '/srv/repos/brainlayer' && cursor agent --force --resume ${SESSION}`); + }); + + it("quotes a cwd containing shell metacharacters", () => { + expect( + buildRawResumeCommand("codex", "brainlayer", SESSION, { + cwd: "/tmp/a b'c", + }), + ).toBe(`cd '/tmp/a b'\\''c' && codex --dangerously-bypass-approvals-and-sandbox resume ${SESSION}`); + }); + + it("lets an explicit cwd override the kiro ~/Gits assumption", () => { + expect( + buildRawResumeCommand("kiro", "brainlayer", SESSION, { + cwd: "/srv/repos/brainlayer", + }), + ).toBe( + "cd '/srv/repos/brainlayer' && MCP_CONNECTION_NONBLOCKING=1 CLAUDE_CODE_NO_FLICKER=1 kiro-cli chat --resume-id " + + SESSION, + ); + }); + + it("stays byte-identical to the pre-existing form when no cwd is supplied", () => { + expect(buildRawResumeCommand("claude", "brainlayer", SESSION)).toBe( + `MCP_CONNECTION_NONBLOCKING=1 CLAUDE_CODE_NO_FLICKER=1 claude --dangerously-skip-permissions --resume ${SESSION}`, + ); + expect(buildRawResumeCommand("codex", "brainlayer", SESSION)).toBe( + `codex --dangerously-bypass-approvals-and-sandbox resume ${SESSION}`, + ); + }); +}); + +describe("buildResumeCommand falls back to the raw CLI, never a guessed launcher", () => { + it("keeps the launcher form when a launcher name is known", () => { + expect( + buildResumeCommand("claude", "brainlayer", SESSION, "brainlayerClaude", { + cwd: "/srv/repos/brainlayer", + }), + ).toBe(`brainlayerClaude -s --resume ${SESSION}`); + expect( + buildResumeCommand("codex", "matchmat", SESSION, "mmCodex"), + ).toBe(`mmCodex --dangerously-bypass-approvals-and-sandbox resume ${SESSION}`); + }); + + it("emits raw CLI resume when no launcher was recorded (fresh install)", () => { + expect(buildResumeCommand("claude", "brainlayer", SESSION)).toBe( + `MCP_CONNECTION_NONBLOCKING=1 CLAUDE_CODE_NO_FLICKER=1 claude --dangerously-skip-permissions --resume ${SESSION}`, + ); + expect(buildResumeCommand("codex", "brainlayer", SESSION)).toBe( + `codex --dangerously-bypass-approvals-and-sandbox resume ${SESSION}`, + ); + expect(buildResumeCommand("cursor", "brainlayer", SESSION)).toBe( + `cursor agent --force --resume ${SESSION}`, + ); + }); + + it("emits raw CLI resume with the recorded cwd when one is known", () => { + expect( + buildResumeCommand("claude", "brainlayer", SESSION, null, { + cwd: "/srv/repos/brainlayer", + }), + ).toBe( + "cd '/srv/repos/brainlayer' && MCP_CONNECTION_NONBLOCKING=1 CLAUDE_CODE_NO_FLICKER=1 claude --dangerously-skip-permissions --resume " + + SESSION, + ); + }); + + it("falls back to raw when the recorded launcher name is unusable", () => { + expect( + buildResumeCommand("codex", "brainlayer", SESSION, "not a launcher!"), + ).toBe(`codex --dangerously-bypass-approvals-and-sandbox resume ${SESSION}`); + }); + + it("never emits a nonexistent ${repo}${Suffix} binary", () => { + for (const cli of ["claude", "codex", "cursor"] as const) { + expect(buildResumeCommand(cli, "brainlayer", SESSION)).not.toMatch( + /brainlayer(Claude|Codex|Cursor|Gemini)/, + ); + } + }); + + it("carries the approval bypass into every raw resume", () => { + // A resumed agent without its bypass blocks on its first tool call and + // reads as a hung pane rather than a failed resume. + expect(buildResumeCommand("claude", "brainlayer", SESSION)).toContain( + "--dangerously-skip-permissions", + ); + expect(buildResumeCommand("codex", "brainlayer", SESSION)).toContain( + "--dangerously-bypass-approvals-and-sandbox", + ); + expect(buildResumeCommand("cursor", "brainlayer", SESSION)).toContain( + "--force", + ); + }); + + it("refuses a raw gemini resume instead of emitting an index-shaped lie", () => { + // `gemini --resume` takes "latest" or an index number, never a UUID. + expect(() => + buildResumeCommand("gemini", "brainlayer", SESSION, null, { + cwd: "/srv/repos/brainlayer", + }), + ).toThrow(/No raw gemini resume exists for a session UUID.*"latest" or an index/s); + expect(() => + buildRawResumeCommand("gemini", "brainlayer", SESSION), + ).toThrow(/No raw gemini resume/); + }); + + it("still emits the registered gemini launcher resume untouched", () => { + expect( + buildResumeCommand("gemini", "brainlayer", SESSION, "brainlayerGemini"), + ).toBe(`brainlayerGemini -s --resume ${SESSION}`); + }); +}); + +describe("resumeCommandForAgent (public agent payload)", () => { + const base = { + cli: "claude" as const, + repo: "brainlayer", + cli_session_id: SESSION, + launcher_name: null, + launch_cwd: "/srv/repos/brainlayer", + worktree_path: null, + }; + + it("uses launch_cwd for the raw form", () => { + expect(resumeCommandForAgent(base)).toBe( + "cd '/srv/repos/brainlayer' && MCP_CONNECTION_NONBLOCKING=1 CLAUDE_CODE_NO_FLICKER=1 claude --dangerously-skip-permissions --resume " + + SESSION, + ); + }); + + it("prefers the worktree path when the agent runs in one", () => { + expect( + resumeCommandForAgent({ + ...base, + worktree_path: "/srv/repos/brainlayer/.worktrees/lane", + }), + ).toBe( + "cd '/srv/repos/brainlayer/.worktrees/lane' && MCP_CONNECTION_NONBLOCKING=1 CLAUDE_CODE_NO_FLICKER=1 claude --dangerously-skip-permissions --resume " + + SESSION, + ); + }); + + it("keeps the registered launcher form untouched", () => { + expect( + resumeCommandForAgent({ ...base, launcher_name: "brainlayerClaude" }), + ).toBe(`brainlayerClaude -s --resume ${SESSION}`); + }); +}); + +describe("advertised resumability and actual resume never disagree", () => { + const base = { + cli: "claude" as const, + repo: "brainlayer", + cli_session_id: SESSION, + launcher_name: null, + launch_cwd: null, + worktree_path: null, + }; + + // Regression guard for the ~/Gits divergence: the engine used to build its + // resume through harnessCwdForAgent, which never returns null, so an agent + // reported NOT resumable could still be sent `cd ~/Gits/ && claude + // --resume ` -- silently starting a new session in a lookalike tree. + it("refuses, with a reason, exactly when it declines to advertise", () => { + const invocation = resumeInvocationForAgent(base); + + expect(resumeCommandForAgent(base)).toBeUndefined(); + expect(invocation.command).toBeNull(); + expect(invocation.reason).toMatch(/needs a recorded working directory/); + }); + + it("explains a gemini refusal rather than reporting it as merely unaimed", () => { + const invocation = resumeInvocationForAgent({ + ...base, + cli: "gemini", + launch_cwd: "/srv/repos/brainlayer", + }); + + expect(invocation.command).toBeNull(); + expect(invocation.reason).toMatch(/no raw resume form that takes a session UUID/); + }); + + it("explains a malformed session id instead of flattening it to unresumable", () => { + const invocation = resumeInvocationForAgent({ + ...base, + cli: "codex", + cli_session_id: "019d9aa5", + }); + + expect(invocation.command).toBeNull(); + expect(invocation.reason).toMatch(/full session UUID/i); + }); + + it("agrees with itself whenever it DOES advertise", () => { + const record = { ...base, launch_cwd: "/srv/repos/brainlayer" }; + + expect(resumeInvocationForAgent(record).command).toBe( + resumeCommandForAgent(record), + ); + }); +}); diff --git a/tests/repo-root-fallback.test.ts b/tests/repo-root-fallback.test.ts new file mode 100644 index 00000000..cf5c94dc --- /dev/null +++ b/tests/repo-root-fallback.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { + repoRootSearchCandidates, + resolveRepoRootWithoutRegistry, +} from "../src/repo-root-fallback.js"; + +const BASE = { + cwd: "/work/checkouts/cmuxlayer", + homeDir: "/home/tester", + env: {} as Record, +}; + +describe("repo root fallback (no launcher registry)", () => { + it("orders candidates env-roots first, then cwd, sibling, and home defaults", () => { + expect( + repoRootSearchCandidates("brainlayer", { + ...BASE, + env: { CMUXLAYER_REPO_HOME: "/srv/repos:/opt/src" }, + }), + ).toEqual([ + "/srv/repos/brainlayer", + "/opt/src/brainlayer", + "/work/checkouts/brainlayer", + "/home/tester/Gits/brainlayer", + "/home/tester/brainlayer", + ]); + }); + + it("includes the running cwd itself when its basename is the repo", () => { + expect(repoRootSearchCandidates("cmuxlayer", BASE)[0]).toBe( + "/work/checkouts/cmuxlayer", + ); + }); + + it("matches the running cwd across hyphen/underscore spelling", () => { + expect( + repoRootSearchCandidates("agent_html_host", { + ...BASE, + cwd: "/work/checkouts/agent-html-host", + })[0], + ).toBe("/work/checkouts/agent-html-host"); + }); + + it("resolves to the first candidate that exists on disk", () => { + expect( + resolveRepoRootWithoutRegistry("brainlayer", { + ...BASE, + isDirectory: (path) => path === "/home/tester/Gits/brainlayer", + }), + ).toBe("/home/tester/Gits/brainlayer"); + }); + + it("throws a self-answering error listing every searched path", () => { + expect(() => + resolveRepoRootWithoutRegistry("brainlayer", { + ...BASE, + isDirectory: () => false, + }), + ).toThrow( + /Cannot resolve a working directory for repo "brainlayer".*\/work\/checkouts\/brainlayer.*\/home\/tester\/Gits\/brainlayer.*CMUXLAYER_REPO_HOME/s, + ); + }); + + it("carries a registry-status hint into the failure when one is supplied", () => { + expect(() => + resolveRepoRootWithoutRegistry("brainlayer", { + ...BASE, + isDirectory: () => false, + registryHint: "Launcher registry has no entry for \"brainlayer\"", + }), + ).toThrow(/Launcher registry has no entry for "brainlayer"/); + }); + + it("rejects repo names that are not safe path segments", () => { + expect(() => + repoRootSearchCandidates("../escape", BASE), + ).toThrow(/Invalid repo name/); + }); + + it("ignores blank segments in CMUXLAYER_REPO_HOME", () => { + expect( + repoRootSearchCandidates("brainlayer", { + ...BASE, + env: { CMUXLAYER_REPO_HOME: "/srv/repos:: :" }, + })[0], + ).toBe("/srv/repos/brainlayer"); + }); +}); diff --git a/tests/server-agent-tools.test.ts b/tests/server-agent-tools.test.ts index 6de21288..116f7f48 100644 --- a/tests/server-agent-tools.test.ts +++ b/tests/server-agent-tools.test.ts @@ -3737,7 +3737,7 @@ describe("agent lifecycle tool handlers", () => { ).toBe(false); }); - it("spawn_agent rejects an unregistered repo before worktree or focus mutation", async () => { + it("spawn_agent rejects an unresolvable repo before worktree or focus mutation", async () => { const registryPath = join(TEST_DIR, "launchers-missing-repo.zsh"); writeFileSync( registryPath, @@ -3769,7 +3769,11 @@ describe("agent lifecycle tool handlers", () => { const parsed = result.structuredContent ?? JSON.parse(result.content[0].text); expect(parsed.ok).toBe(false); - expect(parsed.error).toMatch(/Launcher registry miss.*wt-eval-scratch/s); + // Registry-optional (#392): an unregistered repo now falls through to the + // raw-CLI path, which still refuses when the repo exists nowhere on disk. + expect(parsed.error).toMatch( + /Cannot resolve a working directory for repo "wt-eval-scratch".*Searched:/s, + ); expect(worktreeExec).not.toHaveBeenCalled(); expect( exec.mock.calls.some( @@ -7220,7 +7224,7 @@ describe("agent lifecycle tool handlers", () => { }); }); - it("list_agents keeps a corrupt legacy repo visible but not resumable", async () => { + it("list_agents keeps a corrupt legacy repo visible and raw-resumable", async () => { const routeClient = makeUuidRouteClient([ { ref: "surface:healthy", @@ -7280,17 +7284,66 @@ describe("agent lifecycle tool handlers", () => { }; expect(parsed.ok).toBe(true); + // Issue #392: `codex --dangerously-bypass-approvals-and-sandbox resume ` reads a global session store, so a + // corrupt repo LABEL no longer blocks recovery -- the raw form is real and + // runnable. Previously this row advertised nothing at all. expect(parsed.agents).toEqual([ expect.objectContaining({ agent_id: "healthy-agent" }), expect.objectContaining({ agent_id: "corrupt-agent", - resumable: expect.objectContaining({ value: false }), + resumable: expect.objectContaining({ value: true }), + resume_command: "codex --dangerously-bypass-approvals-and-sandbox resume 019d9aa5-93c0-7a52-9c47-9be1f7625f4f", }), ]); - expect(parsed.agents[1]).not.toHaveProperty("resume_command"); expect(parsed.skipped_agents).toBeUndefined(); }); + it("list_agents withholds a cwd-keyed resume for a corrupt legacy repo", async () => { + const routeClient = makeUuidRouteClient([ + { + ref: "surface:corrupt-claude", + id: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeef", + workspace_ref: "workspace:1", + }, + ]); + const server = createTrackedServer({ + client: routeClient.client as any, + stateDir: TEST_DIR, + lifecycleInitializer: async () => {}, + disableSpawnPreflight: true, + sessionIdentityResolver: () => null, + }); + await serverContexts.at(-1)?.lifecycleStartPromise; + const engine = testLifecycleEngine(server); + const corrupt = makeServerAgentRecord({ + agent_id: "corrupt-claude-agent", + surface_id: "surface:corrupt-claude", + surface_uuid: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeef", + workspace_id: "workspace:1", + state: "ready", + cli: "claude", + repo: "brainlayerClaude [surface:199]", + cli_session_id: "019d9aa5-93c0-7a52-9c47-9be1f7625f4f", + }); + engine.stateMgr.writeState(corrupt); + engine.getRegistry().set(corrupt.agent_id, corrupt); + vi.spyOn(engine.getRegistry(), "listMerged").mockResolvedValue([corrupt]); + + const result = await registeredTestTool(server, "list_agents").handler( + {}, + {} as any, + ); + const parsed = parseToolResult(result) as { + agents: Array>; + }; + + expect(parsed.agents[0]).toMatchObject({ + agent_id: "corrupt-claude-agent", + resumable: expect.objectContaining({ value: false }), + }); + expect(parsed.agents[0]).not.toHaveProperty("resume_command"); + }); + it("send_to keeps repaired registry repo ownership when a title contains a surface suffix", async () => { const stableUuid = "11111111-2222-4333-8444-555555555555"; const routeClient = makeUuidRouteClient([ @@ -8759,6 +8812,8 @@ describe("agent lifecycle tool handlers", () => { const currentAgentId = resolveCurrentTestAgentId(stateMgr, agentId); const updated = stateMgr.updateRecord(currentAgentId, { cli_session_id: "019d9aa5-93c0-7a52-9c47-9be1f7625f3e", + launcher_name: "brainlayerClaude", + launch_cwd: "/Users/etanheyman/Gits/brainlayer", }); engine.getRegistry().set(currentAgentId, updated); @@ -8777,6 +8832,42 @@ describe("agent lifecycle tool handlers", () => { }); }); + it("list_agents emits a raw cd+CLI resume for a launcher-less agent (#392)", async () => { + const server = createLifecycleServer(mockExec); + const spawn = (server as any)._registeredTools["spawn_agent"]; + const list = (server as any)._registeredTools["list_agents"]; + const engine = (server as any)._registeredTools["interact"]._engine; + + const spawnResult = await spawn.handler( + { + repo: "brainlayer", + model: "sonnet", + cli: "claude", + prompt: "task 1", + }, + {} as any, + ); + const agentId = ( + spawnResult.structuredContent ?? JSON.parse(spawnResult.content[0].text) + ).agent_id; + const stateMgr = engine["stateMgr"]; + const currentAgentId = resolveCurrentTestAgentId(stateMgr, agentId); + const updated = stateMgr.updateRecord(currentAgentId, { + cli_session_id: "019d9aa5-93c0-7a52-9c47-9be1f7625f3e", + launcher_name: null, + launch_cwd: "/srv/repos/brainlayer", + }); + engine.getRegistry().set(currentAgentId, updated); + + const result = await list.handler({}, {} as any); + const parsed = + result.structuredContent ?? JSON.parse(result.content[0].text); + + expect(parsed.agents[0].resume_command).toBe( + "cd '/srv/repos/brainlayer' && MCP_CONNECTION_NONBLOCKING=1 CLAUDE_CODE_NO_FLICKER=1 claude --dangerously-skip-permissions --resume 019d9aa5-93c0-7a52-9c47-9be1f7625f3e", + ); + }); + it("get_agent_state returns full record", async () => { const server = createLifecycleServer(mockExec); const spawn = (server as any)._registeredTools["spawn_agent"]; @@ -9667,6 +9758,7 @@ codex> const currentAgentId = resolveCurrentTestAgentId(stateMgr, agentId); const updated = stateMgr.updateRecord(currentAgentId, { cli_session_id: "019d9aa5-93c0-7a52-9c47-9be1f7625f3e", + launcher_name: "golemsCodex", }); engine.getRegistry().set(currentAgentId, updated); @@ -14213,6 +14305,7 @@ codex> const currentAgentId = resolveCurrentTestAgentId(stateMgr, agentId); const updated = stateMgr.updateRecord(currentAgentId, { cli_session_id: "019d9aa5-93c0-7a52-9c47-9be1f7625f3e", + launcher_name: "voicelayerClaude", }); engine.getRegistry().set(currentAgentId, updated); diff --git a/tests/spawn-resume-parity.test.ts b/tests/spawn-resume-parity.test.ts new file mode 100644 index 00000000..ef5c7330 --- /dev/null +++ b/tests/spawn-resume-parity.test.ts @@ -0,0 +1,563 @@ +/** + * Parity matrix for issue #392: the spawn/resume contract must hold on a + * machine WITH a repoGolem launcher registry and on a fresh install WITHOUT + * one. Every case below runs the real default spawn preflight -- the only + * difference between the two lanes is whether `launchers.zsh` exists. + * + * AIDEV-NOTE: this file is the CI gate for the registry-optional contract + * (`bun run test:parity`). CI runners have no registry, so the "raw" lane also + * proves the fallback works in a genuinely launcher-free environment. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + AgentEngine, + buildLaunchCommand, + resolveSpawnLaunchPlan, +} from "../src/agent-engine.js"; +import { + loadLauncherRegistrySnapshot, + resolveLauncherPrefix, + resolveRepoRootFromLauncherRegistry, +} from "../src/launcher-registry.js"; +import { StateManager } from "../src/state-manager.js"; +import { AgentRegistry } from "../src/agent-registry.js"; +import type { CmuxClient, CmuxNewSplitResult } from "../src/cmux-client.js"; +import type { CliType } from "../src/agent-types.js"; +import type { CmuxSurface } from "../src/types.js"; + +const TEST_DIR = join(tmpdir(), "cmux-parity-registry-optional"); +const REPO = "parityrepo"; +const SESSION = "019d9aa5-93c0-7a52-9c47-9be1f7625f3e"; +const SPAWN_SURFACE_UUID = "11111111-2222-4333-8444-555555555555"; + +type LauncherPath = "registry" | "raw"; + +function makeMockClient(): CmuxClient { + return { + newSplit: vi.fn().mockImplementation(async (_direction, opts) => ({ + workspace: opts?.workspace ?? "ws:1", + surface: "surface:new", + surface_id: SPAWN_SURFACE_UUID, + pane: "pane:1", + title: "", + type: "terminal", + }) satisfies CmuxNewSplitResult), + newSurface: vi.fn().mockImplementation(async (opts) => ({ + workspace: opts?.workspace ?? "ws:1", + surface: "surface:new", + surface_id: SPAWN_SURFACE_UUID, + pane: opts.pane, + title: "", + type: "terminal", + })), + send: vi.fn().mockResolvedValue(undefined), + sendKey: vi.fn().mockResolvedValue(undefined), + readScreen: vi.fn().mockResolvedValue({ + surface: "surface:new", + text: "$ ", + lines: 20, + scrollback_used: false, + }), + focusSurface: vi.fn().mockResolvedValue(undefined), + renameTab: vi.fn().mockResolvedValue(undefined), + setStatus: vi.fn().mockResolvedValue(undefined), + closeSurface: vi.fn().mockResolvedValue(undefined), + moveSurface: vi.fn().mockResolvedValue({ + ok: true, + workspace: "ws:1", + surface: "surface:new", + pane: "pane:1", + }), + listWorkspaces: vi.fn().mockResolvedValue({ workspaces: [] }), + listPanes: vi.fn().mockResolvedValue({ panes: [] }), + listPaneSurfaces: vi.fn().mockResolvedValue({ surfaces: [] }), + selectWorkspace: vi.fn().mockResolvedValue(undefined), + clearStatus: vi.fn().mockResolvedValue(undefined), + setProgress: vi.fn().mockResolvedValue(undefined), + clearProgress: vi.fn().mockResolvedValue(undefined), + identify: vi.fn().mockResolvedValue({}), + browser: vi.fn().mockResolvedValue({}), + log: vi.fn().mockResolvedValue(undefined), + } as unknown as CmuxClient; +} + +/** Every launcher-registry CLI. `kiro` is excluded: it is raw on both lanes. */ +const CLIS: readonly CliType[] = ["claude", "codex", "cursor", "gemini"]; + +const EXPECTED_LAUNCHER_NAME: Record = { + claude: `${REPO}Claude`, + codex: `${REPO}Codex`, + cursor: `${REPO}Cursor`, + gemini: `${REPO}Gemini`, +}; + +const AGENT_ENV = "MCP_CONNECTION_NONBLOCKING=1 CLAUDE_CODE_NO_FLICKER=1"; + +/** + * Approval-bypass token each lane must carry, in BOTH its launch and its + * resume command. Asserted lane-INDEPENDENTLY below: a hand-written + * per-lane expectation table cannot catch a divergence it encodes, which is + * exactly how the first cut of this suite blessed a raw resume that had + * silently dropped `--dangerously-skip-permissions` (review MUST_FIX 1). + */ +const RAW_BYPASS: Record = { + claude: " --dangerously-skip-permissions", + codex: " --dangerously-bypass-approvals-and-sandbox", + cursor: " --force", + gemini: " -y", +}; + +const LAUNCH_BYPASS: Record> = { + // repoGolem launchers take `-s` for every harness at launch. + registry: { claude: " -s", codex: " -s", cursor: " -s", gemini: " -s" }, + raw: RAW_BYPASS, +}; + +const RESUME_BYPASS: Record> = { + // ...but the codex launcher's resume form spells it out in full. + registry: { + claude: " -s", + codex: " --dangerously-bypass-approvals-and-sandbox", + cursor: " -s", + gemini: " -s", + }, + raw: RAW_BYPASS, +}; + +/** + * `gemini --resume` takes "latest" or an index, never a UUID, so there is no + * raw gemini resume to compare. The parity contract for it is a REFUSAL on + * the raw lane, asserted explicitly rather than skipped. + */ +const RAW_RESUME_UNSUPPORTED: readonly CliType[] = ["gemini"]; +const RESUMABLE_CLIS = CLIS.filter( + (cli) => !RAW_RESUME_UNSUPPORTED.includes(cli), +); + +function expectedLaunch(cli: CliType, path: LauncherPath, root: string): string { + if (path === "registry") return `${EXPECTED_LAUNCHER_NAME[cli]} -s`; + const cd = `cd '${root}' && `; + switch (cli) { + case "claude": + return `${cd}${AGENT_ENV} claude --dangerously-skip-permissions`; + case "codex": + return `${cd}codex --dangerously-bypass-approvals-and-sandbox`; + case "cursor": + return `${cd}cursor agent --force`; + case "gemini": + return `${cd}${AGENT_ENV} gemini -y`; + default: + throw new Error(`unreachable cli ${cli}`); + } +} + +function expectedResume(cli: CliType, path: LauncherPath, root: string): string { + if (path === "registry") { + return cli === "codex" + ? `${EXPECTED_LAUNCHER_NAME[cli]} --dangerously-bypass-approvals-and-sandbox resume ${SESSION}` + : `${EXPECTED_LAUNCHER_NAME[cli]} -s --resume ${SESSION}`; + } + const cd = `cd '${root}' && `; + switch (cli) { + case "claude": + return `${cd}${AGENT_ENV} claude --dangerously-skip-permissions --resume ${SESSION}`; + case "codex": + return `${cd}codex --dangerously-bypass-approvals-and-sandbox resume ${SESSION}`; + case "cursor": + return `${cd}cursor agent --force --resume ${SESSION}`; + default: + throw new Error(`no raw resume form for ${cli}`); + } +} + +describe.each(["registry", "raw"])( + "spawn/resume contract parity — %s launcher path", + (path) => { + let stateMgr: StateManager; + let client: CmuxClient; + let engine: AgentEngine; + let repoRoot: string; + let liveSurfaces: CmuxSurface[]; + /** True when this lane read the HOST's registry instead of a stubbed one. */ + let registryIsAmbient = false; + + beforeEach(() => { + rmSync(TEST_DIR, { recursive: true, force: true }); + mkdirSync(TEST_DIR, { recursive: true }); + registryIsAmbient = false; + + const repoHome = join(TEST_DIR, "checkouts"); + repoRoot = join(repoHome, REPO); + mkdirSync(repoRoot, { recursive: true }); + + // When the host already has a registry naming this repo (the `present` + // leg of the launcher-parity CI matrix, or a dev machine with a + // `parityrepo` registration), the registry lane runs against THAT file + // rather than a stub -- so the planted registry is genuinely observed + // instead of being shadowed by an env override. + const ambient = loadLauncherRegistrySnapshot(); + const ambientRegistered = + ambient.available && resolveLauncherPrefix(REPO, ambient.entries); + + if (path === "registry" && ambientRegistered) { + registryIsAmbient = true; + repoRoot = resolveRepoRootFromLauncherRegistry(REPO); + mkdirSync(repoRoot, { recursive: true }); + } else { + const registryPath = join(TEST_DIR, "launchers.zsh"); + if (path === "registry") { + writeFileSync(registryPath, `repoGolem ${REPO} "${repoRoot}"\n`); + } + // The raw lane points at a path that does not exist: exactly what a + // brew-install machine looks like. + vi.stubEnv("CMUXLAYER_LAUNCHER_REGISTRY_PATH", registryPath); + } + vi.stubEnv("CMUXLAYER_REPO_HOME", repoHome); + delete process.env.CMUXLAYER_REQUIRE_LAUNCHER_REGISTRY; + + stateMgr = new StateManager(TEST_DIR); + client = makeMockClient(); + liveSurfaces = []; + const registry = new AgentRegistry(stateMgr, async () => liveSurfaces); + engine = new AgentEngine(stateMgr, registry, client, { + sessionIdentityResolver: () => null, + inboxOpts: { baseDir: TEST_DIR }, + codexModelListRunner: async () => ({ stdout: "codex\n" }), + }); + }); + + afterEach(() => { + engine.dispose(); + vi.unstubAllEnvs(); + rmSync(TEST_DIR, { recursive: true, force: true }); + }); + + it.each(CLIS)("spawns %s and records an equivalent receipt", async (cli) => { + const result = await engine.spawnAgent({ + repo: REPO, + cli, + prompt: "parity probe", + }); + + // --- Receipt equivalence: identical across both lanes. --- + expect(result).toMatchObject({ + parent_agent_id: null, + surface_id: "surface:new", + workspace_id: "ws:1", + state: "booting", + cwd: repoRoot, + }); + // Agent id naming is lane-independent: the seat name is derived from + // the repo+cli, never from whether a launcher binary exists. + expect(result.agent_id).toMatch( + new RegExp(`^${EXPECTED_LAUNCHER_NAME[cli]}-[0-9a-f]+$`), + ); + + const record = engine.getAgentState(result.agent_id); + expect(record).toMatchObject({ + repo: REPO, + cli, + state: "booting", + launch_cwd: repoRoot, + role: "worker", + }); + // The ONLY receipt fields that legitimately differ between lanes. + expect(record?.launcher_name).toBe( + path === "registry" ? EXPECTED_LAUNCHER_NAME[cli] : null, + ); + expect(record?.launch_mode).toBe( + path === "registry" ? "launcher" : "raw", + ); + if (registryIsAmbient) { + // Proof the planted/host registry drove this, not a stubbed one: the + // root came from launchers.zsh, outside the test's own scratch dir. + expect(repoRoot.startsWith(TEST_DIR)).toBe(false); + } + + // --- Launch command: launcher form vs raw form. --- + const [, launchCmd] = (client.send as ReturnType).mock + .calls[0]; + expect(launchCmd).toBe(expectedLaunch(cli, path, repoRoot)); + + // The tab title is a discovery contract and must NOT vary by lane. + expect(client.renameTab).toHaveBeenCalledWith( + "surface:new", + `${EXPECTED_LAUNCHER_NAME[cli]} [surface:new]`, + expect.anything(), + ); + }); + + it.each(RESUMABLE_CLIS)( + "resumes %s with a runnable command", + async (cli) => { + const spawned = await engine.spawnAgent({ + repo: REPO, + cli, + prompt: "parity probe", + }); + const updated = stateMgr.updateRecord(spawned.agent_id, { + state: "done", + cli_session_id: SESSION, + }); + engine.getRegistry().set(spawned.agent_id, updated); + (client.send as ReturnType).mockClear(); + + const resumed = await engine.resumeAgent(spawned.agent_id); + + // Public identity survives a resume on BOTH lanes (U5 spawn-resume law). + expect(resumed.agent_id).toBe(spawned.agent_id); + expect(resumed.state).toBe("booting"); + + const [, resumeCmd] = (client.send as ReturnType).mock + .calls[0]; + expect(resumeCmd).toBe(expectedResume(cli, path, repoRoot)); + // Whatever the lane, the resumed command names the captured session. + expect(resumeCmd).toContain(SESSION); + }, + ); + + // --- Lane-INDEPENDENT invariants. These read the command the engine + // actually sent and assert a property of it, so they hold no per-lane + // expected string that could encode a divergence as intentional. --- + + it.each(CLIS)( + "launches %s with an approval bypass on either lane", + async (cli) => { + await engine.spawnAgent({ repo: REPO, cli, prompt: "parity probe" }); + + const [, launchCmd] = (client.send as ReturnType).mock + .calls[0]; + expect(launchCmd).toContain(LAUNCH_BYPASS[path][cli]); + }, + ); + + it.each(RESUMABLE_CLIS)( + "RESUMES %s with the same approval bypass it was launched with", + async (cli) => { + const spawned = await engine.spawnAgent({ + repo: REPO, + cli, + prompt: "parity probe", + }); + const [, launchCmd] = (client.send as ReturnType).mock + .calls[0]; + const updated = stateMgr.updateRecord(spawned.agent_id, { + state: "done", + cli_session_id: SESSION, + }); + engine.getRegistry().set(spawned.agent_id, updated); + (client.send as ReturnType).mockClear(); + + await engine.resumeAgent(spawned.agent_id); + const [, resumeCmd] = (client.send as ReturnType).mock + .calls[0]; + + // An agent that comes back WITHOUT its bypass blocks on its first tool + // call and presents as a hung pane, not a failed resume. + expect(launchCmd).toContain(LAUNCH_BYPASS[path][cli]); + expect(resumeCmd).toContain(RESUME_BYPASS[path][cli]); + }, + ); + + it.each(RAW_RESUME_UNSUPPORTED)( + "refuses to fabricate a raw %s resume rather than sending a bogus one", + async (cli) => { + const spawned = await engine.spawnAgent({ + repo: REPO, + cli, + prompt: "parity probe", + }); + const updated = stateMgr.updateRecord(spawned.agent_id, { + state: "done", + cli_session_id: SESSION, + }); + engine.getRegistry().set(spawned.agent_id, updated); + (client.send as ReturnType).mockClear(); + + if (path === "raw") { + // `gemini --resume` takes "latest" or an index, never a UUID. + await expect(engine.resumeAgent(spawned.agent_id)).rejects.toThrow( + /no runnable resume command/i, + ); + expect(client.send).not.toHaveBeenCalled(); + expect( + engine.resolveAgentRoute(spawned.agent_id).resumable, + ).toBe(false); + } else { + await engine.resumeAgent(spawned.agent_id); + const [, resumeCmd] = (client.send as ReturnType).mock + .calls[0]; + expect(resumeCmd).toBe(expectedResume(cli, path, repoRoot)); + } + }, + ); + + it.each(CLIS)( + "reports truthful model provenance for %s", + async (cli) => { + const result = await engine.spawnAgent({ + repo: REPO, + cli, + prompt: "parity probe", + }); + const [, launchCmd] = (client.send as ReturnType).mock + .calls[0]; + + expect(result.launch_mode).toBe(path === "registry" ? "launcher" : "raw"); + expect(engine.getAgentState(result.agent_id)?.launch_mode).toBe( + result.launch_mode, + ); + + // The receipt may claim a pin ONLY if the command applied one. + const commandPinned = / (--model|-m) /.test(launchCmd); + if (path === "registry") { + expect(result.model_pin).toBe("launcher"); + } else if (commandPinned) { + expect(result.model_pin).toBe("cli_flag"); + } else { + expect(result.model_pin).toBe("cli_default"); + expect(result.warnings).toEqual( + expect.arrayContaining([ + expect.stringContaining("MODEL PIN NOT APPLIED"), + ]), + ); + } + expect(engine.getAgentState(result.agent_id)?.model_pin).toBe( + result.model_pin, + ); + }, + ); + + it("discloses a raw launch that happened past a PRESENT registry", async () => { + const result = await engine.spawnAgent({ + repo: REPO, + cli: "claude", + prompt: "parity probe", + }); + + if (path === "raw") { + expect(result.warnings).toEqual( + expect.arrayContaining([expect.stringContaining("RAW LAUNCH:")]), + ); + } else { + expect(result.warnings ?? []).not.toEqual( + expect.arrayContaining([expect.stringContaining("RAW LAUNCH:")]), + ); + } + }); + + it.each(RESUMABLE_CLIS)( + "advertises the same resumability for %s through the public projection", + async (cli) => { + const spawned = await engine.spawnAgent({ + repo: REPO, + cli, + prompt: "parity probe", + }); + const updated = stateMgr.updateRecord(spawned.agent_id, { + cli_session_id: SESSION, + }); + engine.getRegistry().set(spawned.agent_id, updated); + + const route = engine.resolveAgentRoute(spawned.agent_id); + expect(route.resumable).toBe(true); + expect(route.resume_command).toBe(expectedResume(cli, path, repoRoot)); + }, + ); + }, +); + +/** + * Ambient-environment gate. Nothing here stubs the registry path, so this runs + * against whatever the HOST actually has: a developer machine with repoGolem + * installed, and a CI runner with none. Both must satisfy the same invariant. + * The CI matrix runs it once with a registry planted and once without. + */ +describe("ambient environment (no stubbed registry path)", () => { + const repoHome = join(TEST_DIR, "ambient"); + let ambientRoot: string; + + beforeEach(() => { + rmSync(TEST_DIR, { recursive: true, force: true }); + ambientRoot = join(repoHome, REPO); + mkdirSync(ambientRoot, { recursive: true }); + vi.stubEnv("CMUXLAYER_REPO_HOME", repoHome); + delete process.env.CMUXLAYER_REQUIRE_LAUNCHER_REGISTRY; + }); + + afterEach(() => { + vi.unstubAllEnvs(); + rmSync(TEST_DIR, { recursive: true, force: true }); + }); + + it.each(CLIS)( + "resolves a launchable %s plan whether or not this host has a registry", + (cli) => { + const plan = resolveSpawnLaunchPlan(REPO, cli); + + // Invariant: exactly one of the two doors answers, and either way the + // plan carries a real working directory. A fresh install must never end + // up with "no launcher AND no cwd". + expect(plan.repoRoot).toBeTruthy(); + if (plan.launchMode === "launcher") { + expect(plan.launcherName).toBeTruthy(); + } else { + expect(plan.launchMode).toBe("raw"); + expect(plan.launcherName).toBeUndefined(); + expect(plan.repoRoot).toBe(ambientRoot); + } + + // And the launch command it produces is runnable in that directory. + const cmd = buildLaunchCommand(cli, REPO, undefined, plan.launcherName, { + cwd: plan.launchMode === "raw" ? plan.repoRoot : undefined, + launchMode: plan.launchMode, + }); + expect(cmd.trim()).not.toBe(""); + if (plan.launchMode === "raw") { + expect(cmd.startsWith(`cd '${ambientRoot}' && `)).toBe(true); + expect(cmd).not.toMatch(/parityrepo(Claude|Codex|Cursor|Gemini)/); + } + }, + ); +}); + +describe("registry-optional escape hatch", () => { + beforeEach(() => { + rmSync(TEST_DIR, { recursive: true, force: true }); + mkdirSync(TEST_DIR, { recursive: true }); + }); + afterEach(() => { + vi.unstubAllEnvs(); + rmSync(TEST_DIR, { recursive: true, force: true }); + }); + + it("keeps the strict registry requirement available for registered installs", async () => { + const repoHome = join(TEST_DIR, "checkouts"); + mkdirSync(join(repoHome, REPO), { recursive: true }); + vi.stubEnv( + "CMUXLAYER_LAUNCHER_REGISTRY_PATH", + join(TEST_DIR, "absent-launchers.zsh"), + ); + vi.stubEnv("CMUXLAYER_REPO_HOME", repoHome); + vi.stubEnv("CMUXLAYER_REQUIRE_LAUNCHER_REGISTRY", "1"); + + const stateMgr = new StateManager(TEST_DIR); + const client = makeMockClient(); + const registry = new AgentRegistry(stateMgr, async () => []); + const engine = new AgentEngine(stateMgr, registry, client, { + sessionIdentityResolver: () => null, + inboxOpts: { baseDir: TEST_DIR }, + }); + try { + await expect( + engine.spawnAgent({ repo: REPO, cli: "claude", prompt: "" }), + ).rejects.toThrow(/Launcher registry unavailable/); + expect(client.newSplit).not.toHaveBeenCalled(); + } finally { + engine.dispose(); + } + }); +});