diff --git a/src/supervisor/agents/commandcode/argv.ts b/src/supervisor/agents/commandcode/argv.ts index 6e6cbdc2..6c99900e 100644 --- a/src/supervisor/agents/commandcode/argv.ts +++ b/src/supervisor/agents/commandcode/argv.ts @@ -17,18 +17,24 @@ import type { ThreadConfig } from "@/shared/contracts"; * always pin an explicit `--permission-mode ` — * except when the user picks Bypass Permissions, where we intentionally omit * it so `--yolo` selects bypass as the starting mode. - * - `command-code` has no flag to pre-assign a session id, so resume uses - * `--continue` (continue the last conversation in this cwd) rather than a - * tracked id. The initial prompt is passed as a trailing positional arg. + * - `command-code` has no flag to pre-assign or report a session id, but it + * does support `--resume ` (load that exact conversation). We discover + * the real id from `~/.commandcode/projects//.jsonl` (see + * sessionFiles.ts) and pass it here. `resumeSessionId` is: `undefined` for a + * fresh launch (no resume flag), a non-empty id for `--resume `, or `""` + * for the `--continue` fallback when no real id is known. The initial prompt + * is passed as a trailing positional arg. */ export function buildCommandCodeArgs( config: ThreadConfig, prompt: string, - resume?: boolean, + resumeSessionId?: string, ): string[] { const args: string[] = ["--trust", "--skip-onboarding"]; - if (resume) { + if (resumeSessionId) { + args.push("--resume", resumeSessionId); + } else if (resumeSessionId === "") { args.push("--continue"); } if (config.model) { diff --git a/src/supervisor/agents/commandcode/commandcode.test.ts b/src/supervisor/agents/commandcode/commandcode.test.ts index ee8bac72..9dc553d8 100644 --- a/src/supervisor/agents/commandcode/commandcode.test.ts +++ b/src/supervisor/agents/commandcode/commandcode.test.ts @@ -10,6 +10,7 @@ import { parseCommandCodeModels, } from "./detection"; import { authJsonHasApiKey, detectCommandCodeInvalidSessionRef } from "./session"; +import { commandCodeTranscriptId, isUuid, sanitizeCommandCodeCwd } from "./sessionFiles"; import { detectCommandCodeTerminalStatus } from "./terminal"; describe("buildCommandCodeArgs", () => { @@ -31,8 +32,23 @@ describe("buildCommandCodeArgs", () => { ]); }); - it("adds --continue when resuming", () => { - expect(buildCommandCodeArgs(config, "next", true)).toEqual([ + it("resumes a specific session with --resume ", () => { + expect(buildCommandCodeArgs(config, "next", "af75c40e-44dd-4369-a187-571745a01df2")).toEqual([ + "--trust", + "--skip-onboarding", + "--resume", + "af75c40e-44dd-4369-a187-571745a01df2", + "--model", + "claude-opus-4-8", + "--permission-mode", + "standard", + "--yolo", + "next", + ]); + }); + + it("falls back to --continue when the resume id is the empty string", () => { + expect(buildCommandCodeArgs(config, "next", "")).toEqual([ "--trust", "--skip-onboarding", "--continue", @@ -45,6 +61,12 @@ describe("buildCommandCodeArgs", () => { ]); }); + it("adds no resume flag for a fresh launch", () => { + const args = buildCommandCodeArgs(config, "next"); + expect(args).not.toContain("--resume"); + expect(args).not.toContain("--continue"); + }); + it("omits the prompt positional when empty", () => { expect(buildCommandCodeArgs(config, " ")).toEqual([ "--trust", @@ -114,7 +136,9 @@ describe("createCommandCodeAdapter", () => { expect(adapter.update?.npm).toBe("command-code"); }); - it("mints a synthetic sessionRef on launch so the thread is resumable", () => { + it("launches without a sessionRef so the runtime discovers the real id", () => { + // The synthetic ref is gone: returning no ref is what lets the runtime's + // discoverSessionRef path run and capture command-code's real session id. const adapter = createCommandCodeAdapter(); const launch = adapter.buildLaunchArgv(project, { model: "gpt-5.5" }, "hi"); @@ -129,31 +153,35 @@ describe("createCommandCodeAdapter", () => { "--yolo", "hi", ]); - expect(launch.sessionRef?.providerSessionId).toEqual(expect.any(String)); - expect(launch.sessionRef?.providerSessionId.length).toBeGreaterThan(0); + expect(launch.sessionRef).toBeUndefined(); + expect(adapter.discoverSessionRef).toBeTypeOf("function"); + expect(adapter.watchSessionRef).toBeTypeOf("function"); }); - it("resumes with --continue and ignores the synthetic session id", () => { + it("resumes a discovered session id with --resume", () => { const adapter = createCommandCodeAdapter(); const resume = adapter.buildResumeArgv(project, { model: "gpt-5.5" }, "next", { - providerSessionId: "synthetic-id", + providerSessionId: "af75c40e-44dd-4369-a187-571745a01df2", discoveredAt: "2026-05-20T00:00:00.000Z", }); - expect(resume).toMatchObject({ - binary: "command-code", - args: [ - "--trust", - "--skip-onboarding", - "--continue", - "--model", - "gpt-5.5", - "--permission-mode", - "standard", - "--yolo", - "next", - ], + expect(resume.args).toContain("--resume"); + expect(resume.args).toContain("af75c40e-44dd-4369-a187-571745a01df2"); + expect(resume.args).not.toContain("--continue"); + }); + + it("falls back to --continue for a non-uuid (legacy/synthetic) ref", () => { + // A non-UUID ref can't be a real command-code session id, so resume can't + // target one — it uses --continue and never passes the bogus id. A stale + // *uuid* ref instead goes through --resume and the runtime recovers it. + const adapter = createCommandCodeAdapter(); + const resume = adapter.buildResumeArgv(project, { model: "gpt-5.5" }, "next", { + providerSessionId: "synthetic-id", + discoveredAt: "2026-05-20T00:00:00.000Z", }); + + expect(resume.args).toContain("--continue"); + expect(resume.args).not.toContain("--resume"); expect(resume.args).not.toContain("synthetic-id"); }); @@ -354,9 +382,73 @@ describe("detectCommandCodeInvalidSessionRef", () => { it("detects empty-continue messages", () => { expect(detectCommandCodeInvalidSessionRef("No previous conversation found")).toBe(true); expect(detectCommandCodeInvalidSessionRef("Nothing to continue")).toBe(true); + expect(detectCommandCodeInvalidSessionRef("No conversations found to resume.")).toBe(true); + }); + + it("detects a missing --resume target and a corrupt transcript", () => { + expect( + detectCommandCodeInvalidSessionRef( + 'No session "deadbeef-0000-4000-8000-000000000000" found to resume.', + ), + ).toBe(true); + expect( + detectCommandCodeInvalidSessionRef( + "Session could not be loaded. 1 lines could not be parsed.", + ), + ).toBe(true); }); it("ignores unrelated output", () => { expect(detectCommandCodeInvalidSessionRef("Command Code ready")).toBe(false); }); }); + +describe("commandCodeTranscriptId", () => { + const uuid = "af75c40e-44dd-4369-a187-571745a01df2"; + + it("accepts a real .jsonl transcript", () => { + expect(commandCodeTranscriptId(`${uuid}.jsonl`)).toBe(uuid); + }); + + it("rejects every sidecar and audit file (the corruption guard)", () => { + // Passing any of these basenames to `--resume` is what yields + // "Session could not be loaded. N lines could not be parsed." + expect(commandCodeTranscriptId(`hooks-audit-${uuid}.jsonl`)).toBeUndefined(); + expect(commandCodeTranscriptId(`hooks-audit-hooks-audit-${uuid}.jsonl`)).toBeUndefined(); + expect(commandCodeTranscriptId(`hooks-audit-${uuid}.checkpoints.jsonl`)).toBeUndefined(); + expect(commandCodeTranscriptId(`${uuid}.checkpoints.jsonl`)).toBeUndefined(); + expect(commandCodeTranscriptId(`${uuid}.prompts.jsonl`)).toBeUndefined(); + expect(commandCodeTranscriptId(`${uuid}.meta.json`)).toBeUndefined(); + expect(commandCodeTranscriptId(`${uuid}.share.json`)).toBeUndefined(); + expect(commandCodeTranscriptId("settings.json")).toBeUndefined(); + expect(commandCodeTranscriptId("not-a-uuid.jsonl")).toBeUndefined(); + }); + + it("validates the uuid shape", () => { + expect(isUuid(uuid)).toBe(true); + expect(isUuid("hooks-audit-" + uuid)).toBe(false); + expect(isUuid("synthetic-id")).toBe(false); + }); +}); + +describe("sanitizeCommandCodeCwd", () => { + // These fixture paths don't exist on the test machine, so realpath throws and + // the raw path is sanitized — deterministic, and matching the verified + // on-disk layout (lowercase, leading slash dropped, non-alphanumeric -> '-'). + it("maps a project cwd to command-code's projects/ name", () => { + expect(sanitizeCommandCodeCwd("/Users/test-fixture-xyz/work/lightcode")).toBe( + "users-test-fixture-xyz-work-lightcode", + ); + }); + + it("lowercases and collapses dots and slashes (worktree + temp paths)", () => { + expect( + sanitizeCommandCodeCwd( + "/Users/test-fixture-xyz/.lightcode/worktrees/lc-bbea/lc-golden-pixel-8f39b4b5", + ), + ).toBe("users-test-fixture-xyz-lightcode-worktrees-lc-bbea-lc-golden-pixel-8f39b4b5"); + expect(sanitizeCommandCodeCwd("/private/var/T/cc-dbg-ca.ppww")).toBe( + "private-var-t-cc-dbg-ca-ppww", + ); + }); +}); diff --git a/src/supervisor/agents/commandcode/index.ts b/src/supervisor/agents/commandcode/index.ts index 57063d09..731e4d04 100644 --- a/src/supervisor/agents/commandcode/index.ts +++ b/src/supervisor/agents/commandcode/index.ts @@ -1,5 +1,5 @@ import type { AgentCapability, PromptSegment } from "@/shared/contracts"; -import { createKnownSessionRef, detectAgentInstall, type AgentAdapter } from "../base"; +import { detectAgentInstall, type AgentAdapter } from "../base"; import { buildCommandCodeArgs } from "./argv"; import { COMMANDCODE_DEFAULT_MODEL_ID, @@ -7,6 +7,12 @@ import { defaultCommandCodeCapabilities, } from "./detection"; import { detectCommandCodeInvalidSessionRef } from "./session"; +import { + isUuid, + makeCommandCodeDiscoverSessionRef, + makeCommandCodeWatchSessionRef, + snapshotCommandCodePreSpawnSessions, +} from "./sessionFiles"; import { detectCommandCodeTerminalStatus } from "./terminal"; export { detectCommandCodeInvalidSessionRef } from "./session"; @@ -44,17 +50,25 @@ export function createCommandCodeAdapter(): AgentAdapter { return status; }, - buildLaunchArgv(_location, config, prompt) { + buildLaunchArgv(location, config, prompt) { // `command-code` has no flag to pre-assign or report a session id, so we - // mint a synthetic ref to mark the thread resumable immediately. Resume - // then uses `--continue` (the last conversation in this cwd) — robust for - // the worktree-isolated common case; see buildResumeArgv. + // snapshot the existing transcripts here and let the runtime discover the + // real id afterward (discoverSessionRef below). Returning no sessionRef + // is what enables that discovery path; resume then targets the exact id. + const cwd = location.kind === "wsl" ? location.linuxPath : location.path; + snapshotCommandCodePreSpawnSessions(location, cwd); const args = buildCommandCodeArgs(config, prompt); - return { binary: "command-code", args, sessionRef: createKnownSessionRef() }; + return { binary: "command-code", args }; }, - buildResumeArgv(_location, config, prompt) { - const args = buildCommandCodeArgs(config, prompt, true); + buildResumeArgv(_location, config, prompt, sessionRef) { + // Resume the exact discovered session id (`--resume `). A dead/stale + // id surfaces command-code's "found to resume" error, which the runtime + // recovers by relaunching fresh (see detectCommandCodeInvalidSessionRef) + // — same contract as grok/codex. A non-UUID ref (legacy/degenerate) falls + // back to `--continue`. + const id = sessionRef?.providerSessionId; + const args = buildCommandCodeArgs(config, prompt, id && isUuid(id) ? id : ""); return { binary: "command-code", args }; }, @@ -62,6 +76,13 @@ export function createCommandCodeAdapter(): AgentAdapter { return undefined; }, + // `command-code` writes the transcript only on the first message, so poll a + // beat after launch and rely on watchSessionRef to catch the file's + // creation. Mirrors codex's discovery cadence. + initialSessionRefDiscoveryDelayMs: 1000, + discoverSessionRef: makeCommandCodeDiscoverSessionRef(), + watchSessionRef: makeCommandCodeWatchSessionRef(), + buildDirectInput(prompt) { // The TUI treats bulk writes as a paste, so an embedded `\r` becomes a // literal newline instead of submitting. Pause ~40ms between the text and diff --git a/src/supervisor/agents/commandcode/session.ts b/src/supervisor/agents/commandcode/session.ts index 67aed5d0..6a701f6b 100644 --- a/src/supervisor/agents/commandcode/session.ts +++ b/src/supervisor/agents/commandcode/session.ts @@ -31,11 +31,15 @@ export function commandCodeHasStoredCredentials(location: ProjectLocation): bool } } -// Emitted by `--continue` when there is no prior conversation in the cwd, or by -// a stale resume. Returning true here lets the runtime drop the (synthetic) -// sessionRef and relaunch fresh instead of looping on a dead resume. +// Emitted when a resume target is missing or unloadable: `--continue` with no +// prior conversation (`No conversations found to resume.`), `--resume ` +// with an unknown id (`No session "" found to resume.`), or a corrupt +// transcript (`Session could not be loaded. N lines could not be parsed.`). +// Returning true lets the runtime drop the dead ref and relaunch fresh instead +// of looping on it. The `found to resume` / `could not be loaded` anchors are +// specific enough not to fire on ordinary agent output during launch. const INVALID_SESSION_RE = - /no\s+(?:previous\s+)?conversation|nothing\s+to\s+continue|no\s+session\s+to\s+(?:resume|continue)/i; + /no\s+(?:previous\s+)?conversation|nothing\s+to\s+continue|no\s+session\s+to\s+(?:resume|continue)|found\s+to\s+resume|session\s+could\s+not\s+be\s+loaded|lines?\s+could\s+not\s+be\s+parsed/i; export function detectCommandCodeInvalidSessionRef(output: string): boolean { return INVALID_SESSION_RE.test(output); diff --git a/src/supervisor/agents/commandcode/sessionFiles.ts b/src/supervisor/agents/commandcode/sessionFiles.ts new file mode 100644 index 00000000..89c72dbc --- /dev/null +++ b/src/supervisor/agents/commandcode/sessionFiles.ts @@ -0,0 +1,185 @@ +import { existsSync, readdirSync, realpathSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { ProjectLocation, SessionRef } from "@/shared/contracts"; +import { + createKnownSessionRef, + getCachedWslHomeDirectory, + listSessionDir, + resolveWslHomeDirectoryAsync, + statSessionPaths, + watchSessionPaths, +} from "../base"; + +/** + * Hook-free session discovery for `command-code`, mirroring the grok/codex + * adapters. `command-code` has no flag to pre-assign or report a session id, + * but it writes per-cwd transcripts to + * `~/.commandcode/projects//.jsonl` (alongside + * sidecars: `.checkpoints.jsonl`, `.meta.json`, `.prompts.jsonl`, + * `.share.json`, and command-code's own `hooks-audit-.jsonl`). We + * snapshot the dir before launch, discover the brand-new transcript after, and + * resume with `command-code --resume ` — which loads that exact + * session instead of `--continue`'s ambiguous "newest .jsonl in cwd" (the + * latter happily loads a `hooks-audit-*.jsonl` sidecar, producing + * "Session could not be loaded. N lines could not be parsed."). + */ + +const CC_PROJECTS_SUBPATH = ".commandcode/projects"; +const TRANSCRIPT_SUFFIX = ".jsonl"; + +function nativeProjectsRoot(): string { + return join(homedir(), ".commandcode", "projects"); +} + +// Snapshot of the real transcript ids that existed for this cwd *before* the +// most recent launch, so discovery can tell the new session apart from older +// ones in the same dir. Module-level, mirroring grok/sessionFiles.ts. +let preSpawnIds = new Set(); +let preSpawnKey: string | null = null; + +export function isUuid(s: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s); +} + +/** + * Return the real session id for a directory entry, or `undefined` for any + * sidecar/garbage. A real transcript is `.jsonl`; the UUID-basename test + * is what rejects every sidecar (`.meta.json`/`.share.json` aren't `.jsonl`; + * `.checkpoints.jsonl`/`.prompts.jsonl` have non-UUID basenames; + * `hooks-audit-.jsonl` is non-UUID and also guarded explicitly). Passing a + * sidecar basename to `--resume` is the literal cause of the parse-error bug. + */ +export function commandCodeTranscriptId(name: string): string | undefined { + if (!name.endsWith(TRANSCRIPT_SUFFIX)) return undefined; + if (name.startsWith("hooks-audit-")) return undefined; + if (name.endsWith(".checkpoints.jsonl")) return undefined; + const id = name.slice(0, -TRANSCRIPT_SUFFIX.length); + return isUuid(id) ? id : undefined; +} + +/** + * Reproduce command-code's `cwd → projects/` mapping. Verified + * empirically against the v0.37.2 on-disk layout: resolve symlinks first + * (macOS `/tmp` and `/var` are symlinks into `/private/...`), drop the leading + * slash, lowercase, then collapse every non-alphanumeric run to a single dash. + * /Users/me/work/lc -> users-me-work-lc + * /private/var/folders/…/T/cc.x -> private-var-folders-…-t-cc-x + */ +export function sanitizeCommandCodeCwd(cwd: string): string { + let real = cwd; + try { + real = realpathSync.native(cwd); + } catch { + // A freshly created worktree dir may not be realpath-able yet; the raw + // path is then the best key we have. + } + return real + .replace(/^\/+/, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-"); +} + +// Build the project dir from an ALREADY-sanitized cwd key, so callers sanitize +// (and pay `realpathSync`) exactly once per operation. +function projectDir(location: ProjectLocation, sanitizedCwd: string): string | null { + if (location.kind === "wsl") { + const home = getCachedWslHomeDirectory(location.distro); + return home ? `${home}/${CC_PROJECTS_SUBPATH}/${sanitizedCwd}` : null; + } + return join(nativeProjectsRoot(), sanitizedCwd); +} + +async function projectDirAsync( + location: ProjectLocation, + sanitizedCwd: string, +): Promise { + if (location.kind !== "wsl") return projectDir(location, sanitizedCwd); + const home = await resolveWslHomeDirectoryAsync(location.distro); + return home ? `${home}/${CC_PROJECTS_SUBPATH}/${sanitizedCwd}` : null; +} + +function cwdFor(location: ProjectLocation): string { + return location.kind === "wsl" ? location.linuxPath : location.path; +} + +/** + * Record the real transcript ids present for this cwd right before spawning so + * discovery can ignore them and pick only the new session. Sync + native-only + * (WSL discovery ranks purely by mtime, like grok). + */ +export function snapshotCommandCodePreSpawnSessions(location: ProjectLocation, cwd: string): void { + preSpawnIds = new Set(); + preSpawnKey = sanitizeCommandCodeCwd(cwd); + if (location.kind === "wsl") return; + const dir = projectDir(location, preSpawnKey); + if (!dir || !existsSync(dir)) return; + try { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (!entry.isFile()) continue; + const id = commandCodeTranscriptId(entry.name); + if (id) preSpawnIds.add(id); + } + } catch { + // Best effort; discovery still ranks by mtime when the snapshot is empty. + } +} + +/** + * Return the newest real transcript id for this cwd that was *not* present + * before the launch. The `.jsonl` name filter excludes every sidecar / + * audit file, so the newest by mtime is this thread's session. Mirrors grok's + * discoverGrokSessionRef. + */ +export async function discoverCommandCodeSessionRef( + location: ProjectLocation, + cwd: string, +): Promise { + const key = sanitizeCommandCodeCwd(cwd); + const dir = await projectDirAsync(location, key); + if (!dir) return undefined; + + const entries = await listSessionDir(location, dir); + if (!entries) return undefined; + + const ids = entries + .filter((e) => e.type === "file") + .map((e) => commandCodeTranscriptId(e.name)) + .filter((id): id is string => Boolean(id)) + .filter((id) => !(preSpawnKey === key && preSpawnIds.has(id))); + if (ids.length === 0) return undefined; + + const stats = await statSessionPaths( + location, + ids.map((id) => `${dir}/${id}${TRANSCRIPT_SUFFIX}`), + ); + const winner = ids + .map((id) => ({ id, mtime: stats.get(`${dir}/${id}${TRANSCRIPT_SUFFIX}`)?.mtimeMs ?? 0 })) + .sort((a, b) => b.mtime - a.mtime)[0]; + + return winner ? createKnownSessionRef(winner.id) : undefined; +} + +export function makeCommandCodeDiscoverSessionRef() { + return (location: ProjectLocation): Promise => + discoverCommandCodeSessionRef(location, cwdFor(location)); +} + +export function makeCommandCodeWatchSessionRef() { + return (location: ProjectLocation, onChanged: () => void): (() => void) | undefined => { + const dir = projectDir(location, sanitizeCommandCodeCwd(cwdFor(location))); + let paths: string[]; + if (location.kind === "wsl") { + const home = getCachedWslHomeDirectory(location.distro); + const root = home ? `${home}/${CC_PROJECTS_SUBPATH}` : null; + paths = [dir, root].filter((p): p is string => Boolean(p)); + } else { + // The per-cwd dir only appears once command-code writes the first + // transcript (on the first message), so fall back to the projects root + // to catch its creation. + paths = dir && existsSync(dir) ? [dir] : [nativeProjectsRoot()]; + } + if (paths.length === 0) return undefined; + return watchSessionPaths(location, paths, onChanged, `commandcode:${location.kind}`); + }; +}