diff --git a/plugins/codex/scripts/session-lifecycle-hook.mjs b/plugins/codex/scripts/session-lifecycle-hook.mjs index 778571e6c..1a5e82e02 100644 --- a/plugins/codex/scripts/session-lifecycle-hook.mjs +++ b/plugins/codex/scripts/session-lifecycle-hook.mjs @@ -11,7 +11,7 @@ import { loadBrokerSession, PID_FILE_ENV, sendBrokerShutdown, - teardownBrokerSession + teardownBrokerSession, } from "./lib/broker-lifecycle.mjs"; import { loadState, resolveStateFile, saveState } from "./lib/state.mjs"; import { TRANSCRIPT_PATH_ENV } from "./lib/claude-session-transfer.mjs"; @@ -33,10 +33,42 @@ function shellEscape(value) { } function appendEnvVar(name, value) { - if (!process.env.CLAUDE_ENV_FILE || value == null || value === "") { + const envFile = process.env.CLAUDE_ENV_FILE; + if (!envFile || value == null || value === "") { return; } - fs.appendFileSync(process.env.CLAUDE_ENV_FILE, `export ${name}=${shellEscape(value)}\n`, "utf8"); + + const line = `export ${name}=${shellEscape(value)}`; + let existingContent = ""; + + try { + existingContent = fs.readFileSync(envFile, "utf8"); + } catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } + } + + const existingLines = existingContent.split(/\r?\n/); + const exportPrefix = `export ${name}=`; + + let lastAssignment = null; + + for (let index = existingLines.length - 1; index >= 0; index -= 1) { + if (existingLines[index].startsWith(exportPrefix)) { + lastAssignment = existingLines[index]; + break; + } + } + + if (lastAssignment === line) { + return; + } + + const separator = + existingContent.length > 0 && !existingContent.endsWith("\n") ? "\n" : ""; + + fs.appendFileSync(envFile, `${separator}${line}\n`, "utf8"); } function cleanupSessionJobs(cwd, sessionId) { @@ -70,7 +102,7 @@ function cleanupSessionJobs(cwd, sessionId) { saveState(workspaceRoot, { ...state, - jobs: state.jobs.filter((job) => job.sessionId !== sessionId) + jobs: state.jobs.filter((job) => job.sessionId !== sessionId), }); } @@ -88,7 +120,7 @@ async function handleSessionEnd(input) { ? { endpoint: process.env[BROKER_ENDPOINT_ENV], pidFile: process.env[PID_FILE_ENV] ?? null, - logFile: process.env[LOG_FILE_ENV] ?? null + logFile: process.env[LOG_FILE_ENV] ?? null, } : null); const brokerEndpoint = brokerSession?.endpoint ?? null; @@ -108,7 +140,7 @@ async function handleSessionEnd(input) { logFile, sessionDir, pid, - killProcess: terminateProcessTree + killProcess: terminateProcessTree, }); clearBrokerSession(cwd); } @@ -128,6 +160,8 @@ async function main() { } main().catch((error) => { - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n`, + ); process.exit(1); }); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 8f276835b..252d8002e 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -7,14 +7,25 @@ import { fileURLToPath } from "node:url"; import { buildEnv, installFakeCodex } from "./fake-codex-fixture.mjs"; import { initGitRepo, makeTempDir, run } from "./helpers.mjs"; -import { loadBrokerSession, saveBrokerSession } from "../plugins/codex/scripts/lib/broker-lifecycle.mjs"; +import { + loadBrokerSession, + saveBrokerSession, +} from "../plugins/codex/scripts/lib/broker-lifecycle.mjs"; import { resolveStateDir } from "../plugins/codex/scripts/lib/state.mjs"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const PLUGIN_ROOT = path.join(ROOT, "plugins", "codex"); const SCRIPT = path.join(PLUGIN_ROOT, "scripts", "codex-companion.mjs"); -const STOP_HOOK = path.join(PLUGIN_ROOT, "scripts", "stop-review-gate-hook.mjs"); -const SESSION_HOOK = path.join(PLUGIN_ROOT, "scripts", "session-lifecycle-hook.mjs"); +const STOP_HOOK = path.join( + PLUGIN_ROOT, + "scripts", + "stop-review-gate-hook.mjs", +); +const SESSION_HOOK = path.join( + PLUGIN_ROOT, + "scripts", + "session-lifecycle-hook.mjs", +); async function waitFor(predicate, { timeoutMs = 5000, intervalMs = 50 } = {}) { const start = Date.now(); @@ -34,7 +45,7 @@ test("setup reports ready when fake codex is installed and authenticated", () => const result = run("node", [SCRIPT, "setup", "--json"], { cwd: ROOT, - env: buildEnv(binDir) + env: buildEnv(binDir), }); assert.equal(result.status, 0); @@ -53,8 +64,8 @@ test("setup is ready without npm when Codex is already installed and authenticat cwd: ROOT, env: { ...process.env, - PATH: binDir - } + PATH: binDir, + }, }); assert.equal(result.status, 0, result.stderr); @@ -71,7 +82,7 @@ test("setup trusts app-server API key auth even when login status alone would fa const result = run("node", [SCRIPT, "setup", "--json"], { cwd: ROOT, - env: buildEnv(binDir) + env: buildEnv(binDir), }); assert.equal(result.status, 0, result.stderr); @@ -89,7 +100,7 @@ test("setup is ready when the active provider does not require OpenAI login", () const result = run("node", [SCRIPT, "setup", "--json"], { cwd: ROOT, - env: buildEnv(binDir) + env: buildEnv(binDir), }); assert.equal(result.status, 0, result.stderr); @@ -98,7 +109,10 @@ test("setup is ready when the active provider does not require OpenAI login", () assert.equal(payload.auth.loggedIn, true); assert.equal(payload.auth.authMethod, null); assert.equal(payload.auth.source, "app-server"); - assert.match(payload.auth.detail, /configured and does not require OpenAI authentication/i); + assert.match( + payload.auth.detail, + /configured and does not require OpenAI authentication/i, + ); }); test("setup treats custom providers with app-server-ready config as ready", () => { @@ -107,7 +121,7 @@ test("setup treats custom providers with app-server-ready config as ready", () = const result = run("node", [SCRIPT, "setup", "--json"], { cwd: ROOT, - env: buildEnv(binDir) + env: buildEnv(binDir), }); assert.equal(result.status, 0, result.stderr); @@ -116,7 +130,10 @@ test("setup treats custom providers with app-server-ready config as ready", () = assert.equal(payload.auth.loggedIn, true); assert.equal(payload.auth.authMethod, null); assert.equal(payload.auth.source, "app-server"); - assert.match(payload.auth.detail, /configured and does not require OpenAI authentication/i); + assert.match( + payload.auth.detail, + /configured and does not require OpenAI authentication/i, + ); }); test("setup reports not ready when app-server config read fails", () => { @@ -125,7 +142,7 @@ test("setup reports not ready when app-server config read fails", () => { const result = run("node", [SCRIPT, "setup", "--json"], { cwd: ROOT, - env: buildEnv(binDir) + env: buildEnv(binDir), }); assert.equal(result.status, 0, result.stderr); @@ -142,14 +159,20 @@ test("review renders a no-findings result from app-server review/start", () => { installFakeCodex(binDir); initGitRepo(repo); fs.mkdirSync(path.join(repo, "src")); - fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = 1;\n"); + fs.writeFileSync( + path.join(repo, "src", "app.js"), + "export const value = 1;\n", + ); run("git", ["add", "src/app.js"], { cwd: repo }); run("git", ["commit", "-m", "init"], { cwd: repo }); - fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = 2;\n"); + fs.writeFileSync( + path.join(repo, "src", "app.js"), + "export const value = 2;\n", + ); const result = run("node", [SCRIPT, "review"], { cwd: repo, - env: buildEnv(binDir) + env: buildEnv(binDir), }); assert.equal(result.status, 0); @@ -168,7 +191,7 @@ test("task runs when the active provider does not require OpenAI login", () => { const result = run("node", [SCRIPT, "task", "check auth preflight"], { cwd: repo, - env: buildEnv(binDir) + env: buildEnv(binDir), }); assert.equal(result.status, 0, result.stderr); @@ -186,7 +209,7 @@ test("task runs without auth preflight so Codex can refresh an expired session", const result = run("node", [SCRIPT, "task", "check refreshable auth"], { cwd: repo, - env: buildEnv(binDir) + env: buildEnv(binDir), }); assert.equal(result.status, 0, result.stderr); @@ -209,11 +232,25 @@ test("transfer delegates the current Claude session directly to native import", sourcePath, [ { type: "custom-title", customTitle: "Native transfer" }, - { type: "user", cwd: repo, message: { role: "user", content: "Initial request" } }, - { type: "assistant", cwd: repo, message: { role: "assistant", content: "Initial answer" } }, - { type: "user", cwd: repo, message: { role: "user", content: "/codex:transfer" } } - ].map((entry) => JSON.stringify(entry)).join("\n") + "\n", - "utf8" + { + type: "user", + cwd: repo, + message: { role: "user", content: "Initial request" }, + }, + { + type: "assistant", + cwd: repo, + message: { role: "assistant", content: "Initial answer" }, + }, + { + type: "user", + cwd: repo, + message: { role: "user", content: "/codex:transfer" }, + }, + ] + .map((entry) => JSON.stringify(entry)) + .join("\n") + "\n", + "utf8", ); const result = run("node", [SCRIPT, "transfer", "--json"], { cwd: repo, @@ -221,8 +258,8 @@ test("transfer delegates the current Claude session directly to native import", ...buildEnv(binDir), HOME: home, CODEX_HOME: path.join(home, ".codex"), - CODEX_COMPANION_TRANSCRIPT_PATH: sourcePath - } + CODEX_COMPANION_TRANSCRIPT_PATH: sourcePath, + }, }); assert.equal(result.status, 0, result.stderr); @@ -233,14 +270,19 @@ test("transfer delegates the current Claude session directly to native import", assert.equal(payload.sourcePath, canonicalSourcePath); assert.equal(payload.sessionId, sessionId); - const fakeState = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")); + const fakeState = JSON.parse( + fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8"), + ); assert.equal(fakeState.threads.length, 1); assert.equal(fakeState.threads[0].ephemeral, false); assert.equal(fakeState.threads[0].name, "Native transfer"); - assert.equal(fakeState.lastExternalAgentImport.sourcePath, canonicalSourcePath); + assert.equal( + fakeState.lastExternalAgentImport.sourcePath, + canonicalSourcePath, + ); assert.deepEqual( fakeState.threads[0].visibleMessages.map((message) => message.text), - ["Initial request", "Initial answer", "/codex:transfer"] + ["Initial request", "Initial answer", "/codex:transfer"], ); }); @@ -257,17 +299,21 @@ test("transfer reports an actionable upgrade error when native import is unsuppo fs.writeFileSync( sourcePath, `${JSON.stringify({ type: "user", cwd: repo, message: { role: "user", content: "Continue this work." } })}\n`, - "utf8" + "utf8", ); - const result = run("node", [SCRIPT, "transfer", "--source", sourcePath, "--json"], { - cwd: repo, - env: { - ...buildEnv(binDir), - HOME: home, - CODEX_HOME: path.join(home, ".codex") - } - }); + const result = run( + "node", + [SCRIPT, "transfer", "--source", sourcePath, "--json"], + { + cwd: repo, + env: { + ...buildEnv(binDir), + HOME: home, + CODEX_HOME: path.join(home, ".codex"), + }, + }, + ); assert.notEqual(result.status, 0); assert.match(result.stderr, /does not support Claude session transfer/); @@ -287,7 +333,7 @@ test("transfer fails visibly when native import completes without a ledger recor fs.writeFileSync( sourcePath, `${JSON.stringify({ type: "user", cwd: repo, message: { role: "user", content: "Do not lose this request." } })}\n`, - "utf8" + "utf8", ); const result = run("node", [SCRIPT, "transfer", "--source", sourcePath], { @@ -295,8 +341,8 @@ test("transfer fails visibly when native import completes without a ledger recor env: { ...buildEnv(binDir), HOME: home, - CODEX_HOME: path.join(home, ".codex") - } + CODEX_HOME: path.join(home, ".codex"), + }, }); assert.notEqual(result.status, 0); @@ -315,12 +361,12 @@ test("transfer rejects sources outside the Claude projects directory", () => { fs.writeFileSync( sourcePath, `${JSON.stringify({ type: "user", cwd: repo, message: { role: "user", content: "Outside source." } })}\n`, - "utf8" + "utf8", ); const result = run("node", [SCRIPT, "transfer", "--source", sourcePath], { cwd: repo, - env: { ...buildEnv(binDir), HOME: home } + env: { ...buildEnv(binDir), HOME: home }, }); assert.notEqual(result.status, 0); @@ -338,7 +384,7 @@ test("task reports the actual Codex auth error when the run is rejected", () => const result = run("node", [SCRIPT, "task", "check failed auth"], { cwd: repo, - env: buildEnv(binDir) + env: buildEnv(binDir), }); assert.notEqual(result.status, 0); @@ -351,14 +397,20 @@ test("review accepts the quoted raw argument style for built-in base-branch revi installFakeCodex(binDir); initGitRepo(repo); fs.mkdirSync(path.join(repo, "src")); - fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = 1;\n"); + fs.writeFileSync( + path.join(repo, "src", "app.js"), + "export const value = 1;\n", + ); run("git", ["add", "src/app.js"], { cwd: repo }); run("git", ["commit", "-m", "init"], { cwd: repo }); - fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = 2;\n"); + fs.writeFileSync( + path.join(repo, "src", "app.js"), + "export const value = 2;\n", + ); const result = run("node", [SCRIPT, "review", "--base main"], { cwd: repo, - env: buildEnv(binDir) + env: buildEnv(binDir), }); assert.equal(result.status, 0); @@ -372,14 +424,20 @@ test("adversarial review renders structured findings over app-server turn/start" installFakeCodex(binDir); initGitRepo(repo); fs.mkdirSync(path.join(repo, "src")); - fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = items[0];\n"); + fs.writeFileSync( + path.join(repo, "src", "app.js"), + "export const value = items[0];\n", + ); run("git", ["add", "src/app.js"], { cwd: repo }); run("git", ["commit", "-m", "init"], { cwd: repo }); - fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = items[0].id;\n"); + fs.writeFileSync( + path.join(repo, "src", "app.js"), + "export const value = items[0].id;\n", + ); const result = run("node", [SCRIPT, "adversarial-review"], { cwd: repo, - env: buildEnv(binDir) + env: buildEnv(binDir), }); assert.equal(result.status, 0); @@ -392,14 +450,20 @@ test("adversarial review accepts the same base-branch targeting as review", () = installFakeCodex(binDir); initGitRepo(repo); fs.mkdirSync(path.join(repo, "src")); - fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = items[0];\n"); + fs.writeFileSync( + path.join(repo, "src", "app.js"), + "export const value = items[0];\n", + ); run("git", ["add", "src/app.js"], { cwd: repo }); run("git", ["commit", "-m", "init"], { cwd: repo }); - fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = items[0].id;\n"); + fs.writeFileSync( + path.join(repo, "src", "app.js"), + "export const value = items[0].id;\n", + ); const result = run("node", [SCRIPT, "adversarial-review", "--base", "main"], { cwd: repo, - env: buildEnv(binDir) + env: buildEnv(binDir), }); assert.equal(result.status, 0, result.stderr); @@ -414,21 +478,35 @@ test("adversarial review asks Codex to inspect larger diffs itself", () => { initGitRepo(repo); fs.mkdirSync(path.join(repo, "src")); for (const name of ["a.js", "b.js", "c.js"]) { - fs.writeFileSync(path.join(repo, "src", name), `export const value = "${name}-v1";\n`); + fs.writeFileSync( + path.join(repo, "src", name), + `export const value = "${name}-v1";\n`, + ); } run("git", ["add", "src/a.js", "src/b.js", "src/c.js"], { cwd: repo }); run("git", ["commit", "-m", "init"], { cwd: repo }); - fs.writeFileSync(path.join(repo, "src", "a.js"), 'export const value = "PROMPT_SELF_COLLECT_A";\n'); - fs.writeFileSync(path.join(repo, "src", "b.js"), 'export const value = "PROMPT_SELF_COLLECT_B";\n'); - fs.writeFileSync(path.join(repo, "src", "c.js"), 'export const value = "PROMPT_SELF_COLLECT_C";\n'); + fs.writeFileSync( + path.join(repo, "src", "a.js"), + 'export const value = "PROMPT_SELF_COLLECT_A";\n', + ); + fs.writeFileSync( + path.join(repo, "src", "b.js"), + 'export const value = "PROMPT_SELF_COLLECT_B";\n', + ); + fs.writeFileSync( + path.join(repo, "src", "c.js"), + 'export const value = "PROMPT_SELF_COLLECT_C";\n', + ); const result = run("node", [SCRIPT, "adversarial-review"], { cwd: repo, - env: buildEnv(binDir) + env: buildEnv(binDir), }); assert.equal(result.status, 0, result.stderr); - const state = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")); + const state = JSON.parse( + fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8"), + ); assert.match(state.lastTurnStart.prompt, /lightweight summary/i); assert.match(state.lastTurnStart.prompt, /read-only git commands/i); assert.doesNotMatch(state.lastTurnStart.prompt, /PROMPT_SELF_COLLECT_[ABC]/); @@ -446,12 +524,15 @@ test("review includes reasoning output when the app server returns it", () => { const result = run("node", [SCRIPT, "review"], { cwd: repo, - env: buildEnv(binDir) + env: buildEnv(binDir), }); assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /Reasoning:/); - assert.match(result.stdout, /Reviewed the changed files and checked the likely regression paths first|Reviewed the changed files and checked the likely regression paths/i); + assert.match( + result.stdout, + /Reviewed the changed files and checked the likely regression paths first|Reviewed the changed files and checked the likely regression paths/i, + ); }); test("review logs reasoning summaries and review output to the job log", () => { @@ -466,15 +547,20 @@ test("review logs reasoning summaries and review output to the job log", () => { const result = run("node", [SCRIPT, "review"], { cwd: repo, - env: buildEnv(binDir) + env: buildEnv(binDir), }); assert.equal(result.status, 0, result.stderr); const stateDir = resolveStateDir(repo); - const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8")); + const state = JSON.parse( + fs.readFileSync(path.join(stateDir, "state.json"), "utf8"), + ); const log = fs.readFileSync(state.jobs[0].logFile, "utf8"); assert.match(log, /Reasoning summary/); - assert.match(log, /Reviewed the changed files and checked the likely regression paths/); + assert.match( + log, + /Reviewed the changed files and checked the likely regression paths/, + ); assert.match(log, /Review output/); assert.match(log, /Reviewed uncommitted changes\./); }); @@ -490,17 +576,20 @@ test("task --resume-last resumes the latest persisted task thread", () => { const firstRun = run("node", [SCRIPT, "task", "initial task"], { cwd: repo, - env: buildEnv(binDir) + env: buildEnv(binDir), }); assert.equal(firstRun.status, 0, firstRun.stderr); const result = run("node", [SCRIPT, "task", "--resume-last", "follow up"], { cwd: repo, - env: buildEnv(binDir) + env: buildEnv(binDir), }); assert.equal(result.status, 0, result.stderr); - assert.equal(result.stdout, "Resumed the prior run.\nFollow-up prompt accepted.\n"); + assert.equal( + result.stdout, + "Resumed the prior run.\nFollow-up prompt accepted.\n", + ); }); test("task-resume-candidate returns the latest rescue thread from the current session", () => { @@ -524,7 +613,7 @@ test("task-resume-candidate returns the latest rescue thread from the current se sessionId: "sess-current", threadId: "thr_current", summary: "Investigate the flaky test", - updatedAt: "2026-03-24T20:00:00.000Z" + updatedAt: "2026-03-24T20:00:00.000Z", }, { id: "task-other-session", @@ -534,7 +623,7 @@ test("task-resume-candidate returns the latest rescue thread from the current se sessionId: "sess-other", threadId: "thr_other", summary: "Old rescue run", - updatedAt: "2026-03-24T20:05:00.000Z" + updatedAt: "2026-03-24T20:05:00.000Z", }, { id: "review-current", @@ -544,22 +633,22 @@ test("task-resume-candidate returns the latest rescue thread from the current se sessionId: "sess-current", threadId: "thr_review", summary: "Review main...HEAD", - updatedAt: "2026-03-24T20:10:00.000Z" - } - ] + updatedAt: "2026-03-24T20:10:00.000Z", + }, + ], }, null, - 2 + 2, )}\n`, - "utf8" + "utf8", ); const result = run("node", [SCRIPT, "task-resume-candidate", "--json"], { cwd: workspace, env: { ...process.env, - CODEX_COMPANION_SESSION_ID: "sess-current" - } + CODEX_COMPANION_SESSION_ID: "sess-current", + }, }); assert.equal(result.status, 0, result.stderr); @@ -582,32 +671,35 @@ test("task --resume-last does not resume a task from another Claude session", () const otherEnv = { ...buildEnv(binDir), - CODEX_COMPANION_SESSION_ID: "sess-other" + CODEX_COMPANION_SESSION_ID: "sess-other", }; const currentEnv = { ...buildEnv(binDir), - CODEX_COMPANION_SESSION_ID: "sess-current" + CODEX_COMPANION_SESSION_ID: "sess-current", }; const firstRun = run("node", [SCRIPT, "task", "initial task"], { cwd: repo, - env: otherEnv + env: otherEnv, }); assert.equal(firstRun.status, 0, firstRun.stderr); const candidate = run("node", [SCRIPT, "task-resume-candidate", "--json"], { cwd: repo, - env: currentEnv + env: currentEnv, }); assert.equal(candidate.status, 0, candidate.stderr); assert.equal(JSON.parse(candidate.stdout).available, false); const resume = run("node", [SCRIPT, "task", "--resume-last", "follow up"], { cwd: repo, - env: currentEnv + env: currentEnv, }); assert.equal(resume.status, 1); - assert.match(resume.stderr, /No previous Codex task thread was found for this repository\./); + assert.match( + resume.stderr, + /No previous Codex task thread was found for this repository\./, + ); const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); assert.equal(fakeState.lastTurnStart.threadId, "thr_1"); @@ -640,62 +732,91 @@ test("task --resume-last ignores running tasks from other Claude sessions", () = sessionId: "sess-other", threadId: "thr_other", summary: "Other session active task", - updatedAt: "2026-03-24T20:05:00.000Z" - } - ] + updatedAt: "2026-03-24T20:05:00.000Z", + }, + ], }, null, - 2 + 2, )}\n`, - "utf8" + "utf8", ); const env = { ...buildEnv(binDir), - CODEX_COMPANION_SESSION_ID: "sess-current" + CODEX_COMPANION_SESSION_ID: "sess-current", }; const status = run("node", [SCRIPT, "status", "--json"], { cwd: repo, - env + env, }); assert.equal(status.status, 0, status.stderr); assert.deepEqual(JSON.parse(status.stdout).running, []); const resume = run("node", [SCRIPT, "task", "--resume-last", "follow up"], { cwd: repo, - env + env, }); assert.equal(resume.status, 1); - assert.match(resume.stderr, /No previous Codex task thread was found for this repository\./); + assert.match( + resume.stderr, + /No previous Codex task thread was found for this repository\./, + ); }); -test("session start hook exports the Claude session id, transcript path, and plugin data dir", () => { +test("session start hook keeps the current managed export last", () => { const repo = makeTempDir(); const envFile = path.join(makeTempDir(), "claude-env.sh"); - fs.writeFileSync(envFile, "", "utf8"); const pluginDataDir = makeTempDir(); const transcriptPath = path.join(repo, "session.jsonl"); - const result = run("node", [SESSION_HOOK, "SessionStart"], { - cwd: repo, - env: { - ...process.env, - CLAUDE_ENV_FILE: envFile, - CLAUDE_PLUGIN_DATA: pluginDataDir - }, - input: JSON.stringify({ - hook_event_name: "SessionStart", - session_id: "sess-current", - transcript_path: transcriptPath, - cwd: repo - }) - }); + fs.writeFileSync( + envFile, + [ + "export CODEX_COMPANION_SESSION_ID='sess-current'", + "export OTHER_PLUGIN_VALUE='keep-me'", + "export CODEX_COMPANION_SESSION_ID='sess-old'", + "", + ].join("\n"), + "utf8", + ); + + const runSessionStart = () => + run("node", [SESSION_HOOK, "SessionStart"], { + cwd: repo, + env: { + ...process.env, + CLAUDE_ENV_FILE: envFile, + CLAUDE_PLUGIN_DATA: pluginDataDir, + }, + input: JSON.stringify({ + hook_event_name: "SessionStart", + session_id: "sess-current", + transcript_path: transcriptPath, + cwd: repo, + }), + }); + + const first = runSessionStart(); + assert.equal(first.status, 0, first.stderr); + + const contentAfterFirstRun = fs.readFileSync(envFile, "utf8"); + const sessionExports = contentAfterFirstRun + .trim() + .split(/\r?\n/) + .filter((line) => line.startsWith("export CODEX_COMPANION_SESSION_ID=")); - assert.equal(result.status, 0, result.stderr); assert.equal( - fs.readFileSync(envFile, "utf8"), - `export CODEX_COMPANION_SESSION_ID='sess-current'\nexport CODEX_COMPANION_TRANSCRIPT_PATH='${transcriptPath}'\nexport CLAUDE_PLUGIN_DATA='${pluginDataDir}'\n` + sessionExports.at(-1), + "export CODEX_COMPANION_SESSION_ID='sess-current'", ); + + assert.match(contentAfterFirstRun, /export OTHER_PLUGIN_VALUE='keep-me'/); + + const second = runSessionStart(); + assert.equal(second.status, 0, second.stderr); + + assert.equal(fs.readFileSync(envFile, "utf8"), contentAfterFirstRun); }); test("write task output focuses on the Codex result without generic follow-up hints", () => { @@ -707,13 +828,20 @@ test("write task output focuses on the Codex result without generic follow-up hi run("git", ["add", "README.md"], { cwd: repo }); run("git", ["commit", "-m", "init"], { cwd: repo }); - const result = run("node", [SCRIPT, "task", "--write", "fix the failing test"], { - cwd: repo, - env: buildEnv(binDir) - }); + const result = run( + "node", + [SCRIPT, "task", "--write", "fix the failing test"], + { + cwd: repo, + env: buildEnv(binDir), + }, + ); assert.equal(result.status, 0, result.stderr); - assert.equal(result.stdout, "Handled the requested task.\nTask prompt accepted.\n"); + assert.equal( + result.stdout, + "Handled the requested task.\nTask prompt accepted.\n", + ); }); test("task --resume acts like --resume-last without leaking the flag into the prompt", () => { @@ -728,13 +856,13 @@ test("task --resume acts like --resume-last without leaking the flag into the pr const firstRun = run("node", [SCRIPT, "task", "initial task"], { cwd: repo, - env: buildEnv(binDir) + env: buildEnv(binDir), }); assert.equal(firstRun.status, 0, firstRun.stderr); const result = run("node", [SCRIPT, "task", "--resume", "follow up"], { cwd: repo, - env: buildEnv(binDir) + env: buildEnv(binDir), }); assert.equal(result.status, 0, result.stderr); @@ -753,10 +881,14 @@ test("task --fresh is treated as routing control and does not leak into the prom run("git", ["add", "README.md"], { cwd: repo }); run("git", ["commit", "-m", "init"], { cwd: repo }); - const result = run("node", [SCRIPT, "task", "--fresh", "diagnose the flaky test"], { - cwd: repo, - env: buildEnv(binDir) - }); + const result = run( + "node", + [SCRIPT, "task", "--fresh", "diagnose the flaky test"], + { + cwd: repo, + env: buildEnv(binDir), + }, + ); assert.equal(result.status, 0, result.stderr); const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); @@ -773,10 +905,22 @@ test("task forwards model selection and reasoning effort to app-server turn/star run("git", ["add", "README.md"], { cwd: repo }); run("git", ["commit", "-m", "init"], { cwd: repo }); - const result = run("node", [SCRIPT, "task", "--model", "spark", "--effort", "low", "diagnose the failing test"], { - cwd: repo, - env: buildEnv(binDir) - }); + const result = run( + "node", + [ + SCRIPT, + "task", + "--model", + "spark", + "--effort", + "low", + "diagnose the failing test", + ], + { + cwd: repo, + env: buildEnv(binDir), + }, + ); assert.equal(result.status, 0, result.stderr); const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); @@ -795,15 +939,20 @@ test("task logs reasoning summaries and assistant messages to the job log", () = const result = run("node", [SCRIPT, "task", "investigate the failing test"], { cwd: repo, - env: buildEnv(binDir) + env: buildEnv(binDir), }); assert.equal(result.status, 0, result.stderr); const stateDir = resolveStateDir(repo); - const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8")); + const state = JSON.parse( + fs.readFileSync(path.join(stateDir, "state.json"), "utf8"), + ); const log = fs.readFileSync(state.jobs[0].logFile, "utf8"); assert.match(log, /Reasoning summary/); - assert.match(log, /Inspected the prompt, gathered evidence, and checked the highest-risk paths first/); + assert.match( + log, + /Inspected the prompt, gathered evidence, and checked the highest-risk paths first/, + ); assert.match(log, /Assistant message/); assert.match(log, /Handled the requested task/); }); @@ -819,20 +968,28 @@ test("task logs subagent reasoning and messages with a subagent prefix", () => { const result = run("node", [SCRIPT, "task", "challenge the current design"], { cwd: repo, - env: buildEnv(binDir) + env: buildEnv(binDir), }); assert.equal(result.status, 0, result.stderr); const stateDir = resolveStateDir(repo); - const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8")); + const state = JSON.parse( + fs.readFileSync(path.join(stateDir, "state.json"), "utf8"), + ); const log = fs.readFileSync(state.jobs[0].logFile, "utf8"); - assert.match(log, /Starting subagent design-challenger via collaboration tool: wait\./); + assert.match( + log, + /Starting subagent design-challenger via collaboration tool: wait\./, + ); assert.match(log, /Subagent design-challenger reasoning:/); - assert.match(log, /Questioned the retry strategy and the cache invalidation boundaries\./); + assert.match( + log, + /Questioned the retry strategy and the cache invalidation boundaries\./, + ); assert.match(log, /Subagent design-challenger:/); assert.match( log, - /The design assumes retries are harmless, but they can duplicate side effects without stronger idempotency guarantees\./ + /The design assumes retries are harmless, but they can duplicate side effects without stronger idempotency guarantees\./, ); }); @@ -847,11 +1004,14 @@ test("task waits for the main thread to complete before returning the final resu const result = run("node", [SCRIPT, "task", "challenge the current design"], { cwd: repo, - env: buildEnv(binDir) + env: buildEnv(binDir), }); assert.equal(result.status, 0, result.stderr); - assert.equal(result.stdout, "Handled the requested task.\nTask prompt accepted.\n"); + assert.equal( + result.stdout, + "Handled the requested task.\nTask prompt accepted.\n", + ); }); test("task ignores later subagent messages when choosing the final returned output", () => { @@ -865,11 +1025,14 @@ test("task ignores later subagent messages when choosing the final returned outp const result = run("node", [SCRIPT, "task", "challenge the current design"], { cwd: repo, - env: buildEnv(binDir) + env: buildEnv(binDir), }); assert.equal(result.status, 0, result.stderr); - assert.equal(result.stdout, "Handled the requested task.\nTask prompt accepted.\n"); + assert.equal( + result.stdout, + "Handled the requested task.\nTask prompt accepted.\n", + ); }); test("task can finish after subagent work even if the parent turn/completed event is missing", () => { @@ -883,11 +1046,14 @@ test("task can finish after subagent work even if the parent turn/completed even const result = run("node", [SCRIPT, "task", "challenge the current design"], { cwd: repo, - env: buildEnv(binDir) + env: buildEnv(binDir), }); assert.equal(result.status, 0, result.stderr); - assert.equal(result.stdout, "Handled the requested task.\nTask prompt accepted.\n"); + assert.equal( + result.stdout, + "Handled the requested task.\nTask prompt accepted.\n", + ); }); test("task using the shared broker still completes when Codex spawns subagents", () => { @@ -903,7 +1069,7 @@ test("task using the shared broker still completes when Codex spawns subagents", const env = buildEnv(binDir); const review = run("node", [SCRIPT, "review"], { cwd: repo, - env + env, }); assert.equal(review.status, 0, review.stderr); @@ -913,11 +1079,14 @@ test("task using the shared broker still completes when Codex spawns subagents", const result = run("node", [SCRIPT, "task", "challenge the current design"], { cwd: repo, - env + env, }); assert.equal(result.status, 0, result.stderr); - assert.equal(result.stdout, "Handled the requested task.\nTask prompt accepted.\n"); + assert.equal( + result.stdout, + "Handled the requested task.\nTask prompt accepted.\n", + ); }); test("task --background enqueues a detached worker and exposes per-job status", async () => { @@ -929,10 +1098,14 @@ test("task --background enqueues a detached worker and exposes per-job status", run("git", ["add", "README.md"], { cwd: repo }); run("git", ["commit", "-m", "init"], { cwd: repo }); - const launched = run("node", [SCRIPT, "task", "--background", "--json", "investigate the failing test"], { - cwd: repo, - env: buildEnv(binDir) - }); + const launched = run( + "node", + [SCRIPT, "task", "--background", "--json", "investigate the failing test"], + { + cwd: repo, + env: buildEnv(binDir), + }, + ); assert.equal(launched.status, 0, launched.stderr); const launchPayload = JSON.parse(launched.stdout); @@ -941,11 +1114,19 @@ test("task --background enqueues a detached worker and exposes per-job status", const waitedStatus = run( "node", - [SCRIPT, "status", launchPayload.jobId, "--wait", "--timeout-ms", "15000", "--json"], + [ + SCRIPT, + "status", + launchPayload.jobId, + "--wait", + "--timeout-ms", + "15000", + "--json", + ], { cwd: repo, - env: buildEnv(binDir) - } + env: buildEnv(binDir), + }, ); assert.equal(waitedStatus.status, 0, waitedStatus.stderr); @@ -954,10 +1135,14 @@ test("task --background enqueues a detached worker and exposes per-job status", assert.equal(waitedPayload.job.status, "completed"); const resultPayload = await waitFor(() => { - const result = run("node", [SCRIPT, "result", launchPayload.jobId, "--json"], { - cwd: repo, - env: buildEnv(binDir) - }); + const result = run( + "node", + [SCRIPT, "result", launchPayload.jobId, "--json"], + { + cwd: repo, + env: buildEnv(binDir), + }, + ); if (result.status !== 0) { return null; } @@ -979,10 +1164,14 @@ test("review rejects focus text because it is native-review only", () => { run("git", ["commit", "-m", "init"], { cwd: repo }); fs.writeFileSync(path.join(repo, "README.md"), "hello again\n"); - const result = run("node", [SCRIPT, "review", "--scope working-tree focus on auth"], { - cwd: repo, - env: buildEnv(binDir) - }); + const result = run( + "node", + [SCRIPT, "review", "--scope working-tree focus on auth"], + { + cwd: repo, + env: buildEnv(binDir), + }, + ); assert.equal(result.status > 0, true); assert.match(result.stderr, /does not support custom focus text/i); @@ -1002,12 +1191,15 @@ test("review rejects staged-only scope because it is native-review only", () => const result = run("node", [SCRIPT, "review", "--scope", "staged"], { cwd: repo, - env: buildEnv(binDir) + env: buildEnv(binDir), }); assert.equal(result.status > 0, true); assert.match(result.stderr, /Unsupported review scope "staged"/i); - assert.match(result.stderr, /Use one of: auto, working-tree, branch, or pass --base /i); + assert.match( + result.stderr, + /Use one of: auto, working-tree, branch, or pass --base /i, + ); }); test("adversarial review rejects staged-only scope to match review target selection", () => { @@ -1021,14 +1213,21 @@ test("adversarial review rejects staged-only scope to match review target select fs.writeFileSync(path.join(repo, "README.md"), "hello again\n"); run("git", ["add", "README.md"], { cwd: repo }); - const result = run("node", [SCRIPT, "adversarial-review", "--scope", "staged"], { - cwd: repo, - env: buildEnv(binDir) - }); + const result = run( + "node", + [SCRIPT, "adversarial-review", "--scope", "staged"], + { + cwd: repo, + env: buildEnv(binDir), + }, + ); assert.equal(result.status > 0, true); assert.match(result.stderr, /Unsupported review scope "staged"/i); - assert.match(result.stderr, /Use one of: auto, working-tree, branch, or pass --base /i); + assert.match( + result.stderr, + /Use one of: auto, working-tree, branch, or pass --base /i, + ); }); test("review accepts --background while still running as a tracked review job", () => { @@ -1043,7 +1242,7 @@ test("review accepts --background while still running as a tracked review job", const launched = run("node", [SCRIPT, "review", "--background", "--json"], { cwd: repo, - env: buildEnv(binDir) + env: buildEnv(binDir), }); assert.equal(launched.status, 0, launched.stderr); @@ -1053,7 +1252,7 @@ test("review accepts --background while still running as a tracked review job", const status = run("node", [SCRIPT, "status"], { cwd: repo, - env: buildEnv(binDir) + env: buildEnv(binDir), }); assert.equal(status.status, 0, status.stderr); @@ -1075,9 +1274,9 @@ test("status shows phases, hints, and the latest finished job", () => { "[2026-03-18T15:30:00.000Z] Starting Codex Review.", "[2026-03-18T15:30:01.000Z] Thread ready (thr_1).", "[2026-03-18T15:30:02.000Z] Turn started (turn_1).", - "[2026-03-18T15:30:03.000Z] Reviewer started: current changes" + "[2026-03-18T15:30:03.000Z] Reviewer started: current changes", ].join("\n"), - "utf8" + "utf8", ); const finishedJobFile = path.join(jobsDir, "review-done.json"); @@ -1088,12 +1287,13 @@ test("status shows phases, hints, and the latest finished job", () => { id: "review-done", status: "completed", title: "Codex Review", - rendered: "# Codex Review\n\nReviewed uncommitted changes.\nNo material issues found.\n" + rendered: + "# Codex Review\n\nReviewed uncommitted changes.\nNo material issues found.\n", }, null, - 2 + 2, ), - "utf8" + "utf8", ); fs.writeFileSync( @@ -1115,7 +1315,7 @@ test("status shows phases, hints, and the latest finished job", () => { summary: "Review working tree diff", logFile, createdAt: "2026-03-18T15:30:00.000Z", - updatedAt: "2026-03-18T15:30:03.000Z" + updatedAt: "2026-03-18T15:30:03.000Z", }, { id: "review-done", @@ -1127,25 +1327,34 @@ test("status shows phases, hints, and the latest finished job", () => { createdAt: "2026-03-18T15:10:00.000Z", startedAt: "2026-03-18T15:10:05.000Z", completedAt: "2026-03-18T15:11:10.000Z", - updatedAt: "2026-03-18T15:11:10.000Z" - } - ] + updatedAt: "2026-03-18T15:11:10.000Z", + }, + ], }, null, - 2 + 2, )}\n`, - "utf8" + "utf8", ); const result = run("node", [SCRIPT, "status"], { - cwd: workspace + cwd: workspace, }); assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /Active jobs:/); - assert.match(result.stdout, /\| Job \| Kind \| Status \| Phase \| Elapsed \| Codex Session ID \| Summary \| Actions \|/); - assert.match(result.stdout, /\| review-live \| review \| running \| reviewing \| .* \| thr_1 \| Review working tree diff \|/); - assert.match(result.stdout, /`\/codex:status review-live`
`\/codex:cancel review-live`/); + assert.match( + result.stdout, + /\| Job \| Kind \| Status \| Phase \| Elapsed \| Codex Session ID \| Summary \| Actions \|/, + ); + assert.match( + result.stdout, + /\| review-live \| review \| running \| reviewing \| .* \| thr_1 \| Review working tree diff \|/, + ); + assert.match( + result.stdout, + /`\/codex:status review-live`
`\/codex:cancel review-live`/, + ); assert.match(result.stdout, /Live details:/); assert.match(result.stdout, /Latest finished:/); assert.match(result.stdout, /Progress:/); @@ -1168,8 +1377,16 @@ test("status without a job id only shows jobs from the current Claude session", const currentLog = path.join(jobsDir, "review-current.log"); const otherLog = path.join(jobsDir, "review-other.log"); - fs.writeFileSync(currentLog, "[2026-03-18T15:30:00.000Z] Reviewer started: current changes\n", "utf8"); - fs.writeFileSync(otherLog, "[2026-03-18T15:31:00.000Z] Reviewer started: old changes\n", "utf8"); + fs.writeFileSync( + currentLog, + "[2026-03-18T15:30:00.000Z] Reviewer started: current changes\n", + "utf8", + ); + fs.writeFileSync( + otherLog, + "[2026-03-18T15:31:00.000Z] Reviewer started: old changes\n", + "utf8", + ); fs.writeFileSync( path.join(stateDir, "state.json"), @@ -1191,7 +1408,7 @@ test("status without a job id only shows jobs from the current Claude session", summary: "Current session review", logFile: currentLog, createdAt: "2026-03-18T15:30:00.000Z", - updatedAt: "2026-03-18T15:30:00.000Z" + updatedAt: "2026-03-18T15:30:00.000Z", }, { id: "review-other", @@ -1206,28 +1423,28 @@ test("status without a job id only shows jobs from the current Claude session", createdAt: "2026-03-18T15:20:00.000Z", startedAt: "2026-03-18T15:20:05.000Z", completedAt: "2026-03-18T15:21:00.000Z", - updatedAt: "2026-03-18T15:21:00.000Z" - } - ] + updatedAt: "2026-03-18T15:21:00.000Z", + }, + ], }, null, - 2 + 2, )}\n`, - "utf8" + "utf8", ); const result = run("node", [SCRIPT, "status"], { cwd: workspace, env: { ...process.env, - CODEX_COMPANION_SESSION_ID: "sess-current" - } + CODEX_COMPANION_SESSION_ID: "sess-current", + }, }); assert.equal(result.status, 0, result.stderr); assert.deepEqual( [...new Set(result.stdout.match(/review-(?:current|other)/g) ?? [])], - ["review-current"] + ["review-current"], ); }); @@ -1238,7 +1455,11 @@ test("status preserves adversarial review kind labels", () => { fs.mkdirSync(jobsDir, { recursive: true }); const logFile = path.join(jobsDir, "review-adv.log"); - fs.writeFileSync(logFile, "[2026-03-18T15:30:00.000Z] Reviewer started: adversarial review\n", "utf8"); + fs.writeFileSync( + logFile, + "[2026-03-18T15:30:00.000Z] Reviewer started: adversarial review\n", + "utf8", + ); fs.writeFileSync( path.join(stateDir, "state.json"), @@ -1258,7 +1479,7 @@ test("status preserves adversarial review kind labels", () => { summary: "Adversarial review current changes", logFile, createdAt: "2026-03-18T15:30:00.000Z", - updatedAt: "2026-03-18T15:30:00.000Z" + updatedAt: "2026-03-18T15:30:00.000Z", }, { id: "review-adv", @@ -1271,23 +1492,29 @@ test("status preserves adversarial review kind labels", () => { createdAt: "2026-03-18T15:10:00.000Z", startedAt: "2026-03-18T15:10:05.000Z", completedAt: "2026-03-18T15:11:10.000Z", - updatedAt: "2026-03-18T15:11:10.000Z" - } - ] + updatedAt: "2026-03-18T15:11:10.000Z", + }, + ], }, null, - 2 + 2, )}\n`, - "utf8" + "utf8", ); const result = run("node", [SCRIPT, "status"], { - cwd: workspace + cwd: workspace, }); assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /\| review-adv-live \| adversarial-review \| running \| reviewing \|/); - assert.match(result.stdout, /- review-adv \| completed \| adversarial-review \| Codex Adversarial Review/); + assert.match( + result.stdout, + /\| review-adv-live \| adversarial-review \| running \| reviewing \|/, + ); + assert.match( + result.stdout, + /- review-adv \| completed \| adversarial-review \| Codex Adversarial Review/, + ); assert.match(result.stdout, /Codex session ID: thr_adv_live/); assert.match(result.stdout, /Codex session ID: thr_adv_done/); }); @@ -1299,7 +1526,11 @@ test("status --wait times out cleanly when a job is still active", () => { fs.mkdirSync(jobsDir, { recursive: true }); const logFile = path.join(jobsDir, "task-live.log"); - fs.writeFileSync(logFile, "[2026-03-18T15:30:00.000Z] Starting Codex Task.\n", "utf8"); + fs.writeFileSync( + logFile, + "[2026-03-18T15:30:00.000Z] Starting Codex Task.\n", + "utf8", + ); fs.writeFileSync( path.join(jobsDir, "task-live.json"), JSON.stringify( @@ -1307,12 +1538,12 @@ test("status --wait times out cleanly when a job is still active", () => { id: "task-live", status: "running", title: "Codex Task", - logFile + logFile, }, null, - 2 + 2, ), - "utf8" + "utf8", ); fs.writeFileSync( @@ -1331,19 +1562,23 @@ test("status --wait times out cleanly when a job is still active", () => { logFile, createdAt: "2026-03-18T15:30:00.000Z", startedAt: "2026-03-18T15:30:01.000Z", - updatedAt: "2026-03-18T15:30:02.000Z" - } - ] + updatedAt: "2026-03-18T15:30:02.000Z", + }, + ], }, null, - 2 + 2, )}\n`, - "utf8" + "utf8", ); - const result = run("node", [SCRIPT, "status", "task-live", "--wait", "--timeout-ms", "25", "--json"], { - cwd: workspace - }); + const result = run( + "node", + [SCRIPT, "status", "task-live", "--wait", "--timeout-ms", "25", "--json"], + { + cwd: workspace, + }, + ); assert.equal(result.status, 0, result.stderr); const payload = JSON.parse(result.stdout); @@ -1365,18 +1600,19 @@ test("result returns the stored output for the latest finished job by default", id: "review-finished", status: "completed", title: "Codex Review", - rendered: "# Codex Review\n\nReviewed uncommitted changes.\nNo material issues found.\n", + rendered: + "# Codex Review\n\nReviewed uncommitted changes.\nNo material issues found.\n", result: { codex: { - stdout: "Reviewed uncommitted changes.\nNo material issues found." - } + stdout: "Reviewed uncommitted changes.\nNo material issues found.", + }, }, - threadId: "thr_review_finished" + threadId: "thr_review_finished", }, null, - 2 + 2, ), - "utf8" + "utf8", ); fs.writeFileSync( @@ -1394,24 +1630,24 @@ test("result returns the stored output for the latest finished job by default", threadId: "thr_review_finished", summary: "Review working tree diff", createdAt: "2026-03-18T15:00:00.000Z", - updatedAt: "2026-03-18T15:01:00.000Z" - } - ] + updatedAt: "2026-03-18T15:01:00.000Z", + }, + ], }, null, - 2 + 2, )}\n`, - "utf8" + "utf8", ); const result = run("node", [SCRIPT, "result"], { - cwd: workspace + cwd: workspace, }); assert.equal(result.status, 0, result.stderr); assert.equal( result.stdout, - "Reviewed uncommitted changes.\nNo material issues found.\n\nCodex session ID: thr_review_finished\nResume in Codex: codex resume thr_review_finished\n" + "Reviewed uncommitted changes.\nNo material issues found.\n\nCodex session ID: thr_review_finished\nResume in Codex: codex resume thr_review_finished\n", ); }); @@ -1431,14 +1667,14 @@ test("result without a job id prefers the latest finished job from the current C threadId: "thr_current", result: { codex: { - stdout: "Current session output." - } - } + stdout: "Current session output.", + }, + }, }, null, - 2 + 2, ), - "utf8" + "utf8", ); fs.writeFileSync( @@ -1451,14 +1687,14 @@ test("result without a job id prefers the latest finished job from the current C threadId: "thr_other", result: { codex: { - stdout: "Old session output." - } - } + stdout: "Old session output.", + }, + }, }, null, - 2 + 2, ), - "utf8" + "utf8", ); fs.writeFileSync( @@ -1477,7 +1713,7 @@ test("result without a job id prefers the latest finished job from the current C threadId: "thr_current", summary: "Current session review", createdAt: "2026-03-18T15:10:00.000Z", - updatedAt: "2026-03-18T15:11:00.000Z" + updatedAt: "2026-03-18T15:11:00.000Z", }, { id: "review-other", @@ -1488,28 +1724,28 @@ test("result without a job id prefers the latest finished job from the current C threadId: "thr_other", summary: "Old session review", createdAt: "2026-03-18T15:20:00.000Z", - updatedAt: "2026-03-18T15:21:00.000Z" - } - ] + updatedAt: "2026-03-18T15:21:00.000Z", + }, + ], }, null, - 2 + 2, )}\n`, - "utf8" + "utf8", ); const result = run("node", [SCRIPT, "result"], { cwd: workspace, env: { ...process.env, - CODEX_COMPANION_SESSION_ID: "sess-current" - } + CODEX_COMPANION_SESSION_ID: "sess-current", + }, }); assert.equal(result.status, 0, result.stderr); assert.equal( result.stdout, - "Current session output.\n\nCodex session ID: thr_current\nResume in Codex: codex resume thr_current\n" + "Current session output.\n\nCodex session ID: thr_current\nResume in Codex: codex resume thr_current\n", ); }); @@ -1522,19 +1758,26 @@ test("result for a finished write-capable task returns the raw Codex final respo run("git", ["add", "README.md"], { cwd: repo }); run("git", ["commit", "-m", "init"], { cwd: repo }); - const taskRun = run("node", [SCRIPT, "task", "--write", "fix the flaky integration test"], { - cwd: repo, - env: buildEnv(binDir) - }); + const taskRun = run( + "node", + [SCRIPT, "task", "--write", "fix the flaky integration test"], + { + cwd: repo, + env: buildEnv(binDir), + }, + ); assert.equal(taskRun.status, 0, taskRun.stderr); const result = run("node", [SCRIPT, "result"], { cwd: repo, - env: buildEnv(binDir) + env: buildEnv(binDir), }); assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /^Handled the requested task\.\nTask prompt accepted\.\n/); + assert.match( + result.stdout, + /^Handled the requested task\.\nTask prompt accepted\.\n/, + ); assert.match(result.stdout, /Codex session ID: thr_[a-z0-9]+/i); assert.match(result.stdout, /Resume in Codex: codex resume thr_[a-z0-9]+/i); }); @@ -1545,11 +1788,15 @@ test("cancel stops an active background job and marks it cancelled", async (t) = const jobsDir = path.join(stateDir, "jobs"); fs.mkdirSync(jobsDir, { recursive: true }); - const sleeper = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { - cwd: workspace, - detached: true, - stdio: "ignore" - }); + const sleeper = spawn( + process.execPath, + ["-e", "setInterval(() => {}, 1000)"], + { + cwd: workspace, + detached: true, + stdio: "ignore", + }, + ); sleeper.unref(); t.after(() => { @@ -1566,7 +1813,11 @@ test("cancel stops an active background job and marks it cancelled", async (t) = const logFile = path.join(jobsDir, "task-live.log"); const jobFile = path.join(jobsDir, "task-live.json"); - fs.writeFileSync(logFile, "[2026-03-18T15:30:00.000Z] Starting Codex Task.\n", "utf8"); + fs.writeFileSync( + logFile, + "[2026-03-18T15:30:00.000Z] Starting Codex Task.\n", + "utf8", + ); fs.writeFileSync( jobFile, JSON.stringify( @@ -1574,12 +1825,12 @@ test("cancel stops an active background job and marks it cancelled", async (t) = id: "task-live", status: "running", title: "Codex Task", - logFile + logFile, }, null, - 2 + 2, ), - "utf8" + "utf8", ); fs.writeFileSync( path.join(stateDir, "state.json"), @@ -1598,18 +1849,18 @@ test("cancel stops an active background job and marks it cancelled", async (t) = logFile, createdAt: "2026-03-18T15:30:00.000Z", startedAt: "2026-03-18T15:30:01.000Z", - updatedAt: "2026-03-18T15:30:02.000Z" - } - ] + updatedAt: "2026-03-18T15:30:02.000Z", + }, + ], }, null, - 2 + 2, )}\n`, - "utf8" + "utf8", ); const cancelResult = run("node", [SCRIPT, "cancel", "task-live", "--json"], { - cwd: workspace + cwd: workspace, }); assert.equal(cancelResult.status, 0, cancelResult.stderr); @@ -1624,7 +1875,9 @@ test("cancel stops an active background job and marks it cancelled", async (t) = } }); - const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8")); + const state = JSON.parse( + fs.readFileSync(path.join(stateDir, "state.json"), "utf8"), + ); const cancelled = state.jobs.find((job) => job.id === "task-live"); assert.equal(cancelled.status, "cancelled"); assert.equal(cancelled.pid, null); @@ -1657,35 +1910,40 @@ test("cancel without a job id ignores active jobs from other Claude sessions", ( sessionId: "sess-other", summary: "Other session run", updatedAt: "2026-03-24T20:05:00.000Z", - logFile - } - ] + logFile, + }, + ], }, null, - 2 + 2, )}\n`, - "utf8" + "utf8", ); const env = { ...process.env, - CODEX_COMPANION_SESSION_ID: "sess-current" + CODEX_COMPANION_SESSION_ID: "sess-current", }; const status = run("node", [SCRIPT, "status", "--json"], { cwd: workspace, - env + env, }); assert.equal(status.status, 0, status.stderr); assert.deepEqual(JSON.parse(status.stdout).running, []); const cancel = run("node", [SCRIPT, "cancel", "--json"], { cwd: workspace, - env + env, }); assert.equal(cancel.status, 1); - assert.match(cancel.stderr, /No active Codex jobs to cancel for this session\./); + assert.match( + cancel.stderr, + /No active Codex jobs to cancel for this session\./, + ); - const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8")); + const state = JSON.parse( + fs.readFileSync(path.join(stateDir, "state.json"), "utf8"), + ); assert.equal(state.jobs[0].status, "running"); }); @@ -1712,28 +1970,30 @@ test("cancel with a job id can still target an active job from another Claude se sessionId: "sess-other", summary: "Other session run", updatedAt: "2026-03-24T20:05:00.000Z", - logFile - } - ] + logFile, + }, + ], }, null, - 2 + 2, )}\n`, - "utf8" + "utf8", ); const env = { ...process.env, - CODEX_COMPANION_SESSION_ID: "sess-current" + CODEX_COMPANION_SESSION_ID: "sess-current", }; const cancel = run("node", [SCRIPT, "cancel", "task-other", "--json"], { cwd: workspace, - env + env, }); assert.equal(cancel.status, 0, cancel.stderr); assert.equal(JSON.parse(cancel.stdout).jobId, "task-other"); - const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8")); + const state = JSON.parse( + fs.readFileSync(path.join(stateDir, "state.json"), "utf8"), + ); assert.equal(state.jobs[0].status, "cancelled"); }); @@ -1748,10 +2008,20 @@ test("cancel sends turn interrupt to the shared app-server before killing a brok run("git", ["commit", "-m", "init"], { cwd: repo }); const env = buildEnv(binDir); - const launched = run("node", [SCRIPT, "task", "--background", "--json", "investigate the flaky worker timeout"], { - cwd: repo, - env - }); + const launched = run( + "node", + [ + SCRIPT, + "task", + "--background", + "--json", + "investigate the flaky worker timeout", + ], + { + cwd: repo, + env, + }, + ); assert.equal(launched.status, 0, launched.stderr); const launchPayload = JSON.parse(launched.stdout); @@ -1759,18 +2029,23 @@ test("cancel sends turn interrupt to the shared app-server before killing a brok assert.ok(jobId); const stateDir = resolveStateDir(repo); - const runningJob = await waitFor(() => { - const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8")); - const job = state.jobs.find((candidate) => candidate.id === jobId); - if (job?.status === "running" && job.threadId && job.turnId) { - return job; - } - return null; - }, { timeoutMs: 15000 }); + const runningJob = await waitFor( + () => { + const state = JSON.parse( + fs.readFileSync(path.join(stateDir, "state.json"), "utf8"), + ); + const job = state.jobs.find((candidate) => candidate.id === jobId); + if (job?.status === "running" && job.threadId && job.turnId) { + return job; + } + return null; + }, + { timeoutMs: 15000 }, + ); const cancelResult = run("node", [SCRIPT, "cancel", jobId, "--json"], { cwd: repo, - env + env, }); assert.equal(cancelResult.status, 0, cancelResult.stderr); @@ -1787,7 +2062,7 @@ test("cancel sends turn interrupt to the shared app-server before killing a brok const fakeState = JSON.parse(fs.readFileSync(fakeStatePath, "utf8")); assert.deepEqual(fakeState.lastInterrupt, { threadId: runningJob.threadId, - turnId: runningJob.turnId + turnId: runningJob.turnId, }); const cleanup = run("node", [SESSION_HOOK, "SessionEnd"], { @@ -1795,8 +2070,8 @@ test("cancel sends turn interrupt to the shared app-server before killing a brok env, input: JSON.stringify({ hook_event_name: "SessionEnd", - cwd: repo - }) + cwd: repo, + }), }); assert.equal(cleanup.status, 0, cleanup.stderr); }); @@ -1821,16 +2096,32 @@ test("session end fully cleans up jobs for the ending session", async (t) => { fs.writeFileSync(completedLog, "completed\n", "utf8"); fs.writeFileSync(runningLog, "running\n", "utf8"); fs.writeFileSync(otherSessionLog, "other\n", "utf8"); - fs.writeFileSync(completedJobFile, JSON.stringify({ id: "review-completed" }, null, 2), "utf8"); - fs.writeFileSync(otherJobFile, JSON.stringify({ id: "review-other" }, null, 2), "utf8"); + fs.writeFileSync( + completedJobFile, + JSON.stringify({ id: "review-completed" }, null, 2), + "utf8", + ); + fs.writeFileSync( + otherJobFile, + JSON.stringify({ id: "review-other" }, null, 2), + "utf8", + ); - const sleeper = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { - cwd: repo, - detached: true, - stdio: "ignore" - }); + const sleeper = spawn( + process.execPath, + ["-e", "setInterval(() => {}, 1000)"], + { + cwd: repo, + detached: true, + stdio: "ignore", + }, + ); sleeper.unref(); - fs.writeFileSync(runningJobFile, JSON.stringify({ id: "review-running" }, null, 2), "utf8"); + fs.writeFileSync( + runningJobFile, + JSON.stringify({ id: "review-running" }, null, 2), + "utf8", + ); t.after(() => { try { @@ -1858,7 +2149,7 @@ test("session end fully cleans up jobs for the ending session", async (t) => { sessionId: "sess-current", logFile: completedLog, createdAt: "2026-03-18T15:30:00.000Z", - updatedAt: "2026-03-18T15:31:00.000Z" + updatedAt: "2026-03-18T15:31:00.000Z", }, { id: "review-running", @@ -1868,7 +2159,7 @@ test("session end fully cleans up jobs for the ending session", async (t) => { pid: sleeper.pid, logFile: runningLog, createdAt: "2026-03-18T15:32:00.000Z", - updatedAt: "2026-03-18T15:33:00.000Z" + updatedAt: "2026-03-18T15:33:00.000Z", }, { id: "review-other", @@ -1877,27 +2168,27 @@ test("session end fully cleans up jobs for the ending session", async (t) => { sessionId: "sess-other", logFile: otherSessionLog, createdAt: "2026-03-18T15:34:00.000Z", - updatedAt: "2026-03-18T15:35:00.000Z" - } - ] + updatedAt: "2026-03-18T15:35:00.000Z", + }, + ], }, null, - 2 + 2, )}\n`, - "utf8" + "utf8", ); const result = run("node", [SESSION_HOOK, "SessionEnd"], { cwd: repo, env: { ...process.env, - CODEX_COMPANION_SESSION_ID: "sess-current" + CODEX_COMPANION_SESSION_ID: "sess-current", }, input: JSON.stringify({ hook_event_name: "SessionEnd", session_id: "sess-current", - cwd: repo - }) + cwd: repo, + }), }); assert.equal(result.status, 0, result.stderr); @@ -1905,7 +2196,7 @@ test("session end fully cleans up jobs for the ending session", async (t) => { assert.equal(fs.existsSync(otherJobFile), true); assert.deepEqual( fs.readdirSync(path.dirname(otherJobFile)).sort(), - [path.basename(otherJobFile), path.basename(otherSessionLog)].sort() + [path.basename(otherJobFile), path.basename(otherSessionLog)].sort(), ); await waitFor(() => { @@ -1917,8 +2208,13 @@ test("session end fully cleans up jobs for the ending session", async (t) => { } }); - const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8")); - assert.deepEqual(state.jobs.map((job) => job.id), ["review-other"]); + const state = JSON.parse( + fs.readFileSync(path.join(stateDir, "state.json"), "utf8"), + ); + assert.deepEqual( + state.jobs.map((job) => job.id), + ["review-other"], + ); const otherJob = state.jobs[0]; assert.equal(otherJob.logFile, otherSessionLog); }); @@ -1933,17 +2229,21 @@ test("stop hook runs a stop-time review task and blocks on findings when the rev run("git", ["add", "README.md"], { cwd: repo }); run("git", ["commit", "-m", "init"], { cwd: repo }); - const setup = run("node", [SCRIPT, "setup", "--enable-review-gate", "--json"], { - cwd: repo, - env: buildEnv(binDir) - }); + const setup = run( + "node", + [SCRIPT, "setup", "--enable-review-gate", "--json"], + { + cwd: repo, + env: buildEnv(binDir), + }, + ); assert.equal(setup.status, 0, setup.stderr); const setupPayload = JSON.parse(setup.stdout); assert.equal(setupPayload.reviewGateEnabled, true); const taskResult = run("node", [SCRIPT, "task", "--write", "fix the issue"], { cwd: repo, - env: buildEnv(binDir) + env: buildEnv(binDir), }); assert.equal(taskResult.status, 0, taskResult.stderr); @@ -1953,27 +2253,37 @@ test("stop hook runs a stop-time review task and blocks on findings when the rev input: JSON.stringify({ cwd: repo, session_id: "sess-stop-review", - last_assistant_message: "I completed the refactor and updated the retry logic." - }) + last_assistant_message: + "I completed the refactor and updated the retry logic.", + }), }); assert.equal(blocked.status, 0, blocked.stderr); const blockedPayload = JSON.parse(blocked.stdout); assert.equal(blockedPayload.decision, "block"); - assert.match(blockedPayload.reason, /Codex stop-time review found issues that still need fixes/i); + assert.match( + blockedPayload.reason, + /Codex stop-time review found issues that still need fixes/i, + ); assert.match(blockedPayload.reason, /Missing empty-state guard/i); const fakeState = JSON.parse(fs.readFileSync(fakeStatePath, "utf8")); assert.match(fakeState.lastTurnStart.prompt, //i); assert.match(fakeState.lastTurnStart.prompt, //i); - assert.match(fakeState.lastTurnStart.prompt, /Only review the work from the previous Claude turn/i); - assert.match(fakeState.lastTurnStart.prompt, /I completed the refactor and updated the retry logic\./); + assert.match( + fakeState.lastTurnStart.prompt, + /Only review the work from the previous Claude turn/i, + ); + assert.match( + fakeState.lastTurnStart.prompt, + /I completed the refactor and updated the retry logic\./, + ); const status = run("node", [SCRIPT, "status"], { cwd: repo, env: { ...buildEnv(binDir), - CODEX_COMPANION_SESSION_ID: "sess-stop-review" - } + CODEX_COMPANION_SESSION_ID: "sess-stop-review", + }, }); assert.equal(status.status, 0, status.stderr); assert.match(status.stdout, /Codex Stop Gate Review/); @@ -1999,7 +2309,7 @@ test("stop hook logs running tasks to stderr without blocking when the review ga { version: 1, config: { - stopReviewGate: false + stopReviewGate: false, }, jobs: [ { @@ -2010,23 +2320,23 @@ test("stop hook logs running tasks to stderr without blocking when the review ga sessionId: "sess-current", logFile: runningLog, createdAt: "2026-03-18T15:32:00.000Z", - updatedAt: "2026-03-18T15:33:00.000Z" - } - ] + updatedAt: "2026-03-18T15:33:00.000Z", + }, + ], }, null, - 2 + 2, )}\n`, - "utf8" + "utf8", ); const blocked = run("node", [STOP_HOOK], { cwd: repo, env: { ...process.env, - CODEX_COMPANION_SESSION_ID: "sess-current" + CODEX_COMPANION_SESSION_ID: "sess-current", }, - input: JSON.stringify({ cwd: repo }) + input: JSON.stringify({ cwd: repo }), }); assert.equal(blocked.status, 0, blocked.stderr); @@ -2045,16 +2355,20 @@ test("stop hook allows the stop when the review gate is enabled and the stop-tim run("git", ["add", "README.md"], { cwd: repo }); run("git", ["commit", "-m", "init"], { cwd: repo }); - const setup = run("node", [SCRIPT, "setup", "--enable-review-gate", "--json"], { - cwd: repo, - env: buildEnv(binDir) - }); + const setup = run( + "node", + [SCRIPT, "setup", "--enable-review-gate", "--json"], + { + cwd: repo, + env: buildEnv(binDir), + }, + ); assert.equal(setup.status, 0, setup.stderr); const allowed = run("node", [STOP_HOOK], { cwd: repo, env: buildEnv(binDir), - input: JSON.stringify({ cwd: repo, session_id: "sess-stop-clean" }) + input: JSON.stringify({ cwd: repo, session_id: "sess-stop-clean" }), }); assert.equal(allowed.status, 0, allowed.stderr); @@ -2068,18 +2382,22 @@ test("stop hook does not block when Codex is unavailable even if the review gate run("git", ["add", "README.md"], { cwd: repo }); run("git", ["commit", "-m", "init"], { cwd: repo }); - const setup = run(process.execPath, [SCRIPT, "setup", "--enable-review-gate", "--json"], { - cwd: repo - }); + const setup = run( + process.execPath, + [SCRIPT, "setup", "--enable-review-gate", "--json"], + { + cwd: repo, + }, + ); assert.equal(setup.status, 0, setup.stderr); const allowed = run(process.execPath, [STOP_HOOK], { cwd: repo, env: { ...process.env, - PATH: "" + PATH: "", }, - input: JSON.stringify({ cwd: repo }) + input: JSON.stringify({ cwd: repo }), }); assert.equal(allowed.status, 0, allowed.stderr); @@ -2097,20 +2415,27 @@ test("stop hook runs the actual task when auth status looks stale", () => { run("git", ["add", "README.md"], { cwd: repo }); run("git", ["commit", "-m", "init"], { cwd: repo }); - const setup = run("node", [SCRIPT, "setup", "--enable-review-gate", "--json"], { - cwd: repo, - env: buildEnv(binDir) - }); + const setup = run( + "node", + [SCRIPT, "setup", "--enable-review-gate", "--json"], + { + cwd: repo, + env: buildEnv(binDir), + }, + ); assert.equal(setup.status, 0, setup.stderr); const allowed = run("node", [STOP_HOOK], { cwd: repo, env: buildEnv(binDir), - input: JSON.stringify({ cwd: repo }) + input: JSON.stringify({ cwd: repo }), }); assert.equal(allowed.status, 0, allowed.stderr); - assert.doesNotMatch(allowed.stderr, /Codex is not set up for the review gate/i); + assert.doesNotMatch( + allowed.stderr, + /Codex is not set up for the review gate/i, + ); const payload = JSON.parse(allowed.stdout); assert.equal(payload.decision, "block"); assert.match(payload.reason, /Missing empty-state guard/i); @@ -2132,7 +2457,7 @@ test("commands lazily start and reuse one shared app-server after first use", as const review = run("node", [SCRIPT, "review"], { cwd: repo, - env + env, }); assert.equal(review.status, 0, review.stderr); @@ -2143,7 +2468,7 @@ test("commands lazily start and reuse one shared app-server after first use", as const adversarial = run("node", [SCRIPT, "adversarial-review"], { cwd: repo, - env + env, }); assert.equal(adversarial.status, 0, adversarial.stderr); @@ -2155,8 +2480,8 @@ test("commands lazily start and reuse one shared app-server after first use", as env, input: JSON.stringify({ hook_event_name: "SessionEnd", - cwd: repo - }) + cwd: repo, + }), }); assert.equal(cleanup.status, 0, cleanup.stderr); }); @@ -2177,7 +2502,7 @@ test("setup reuses an existing shared app-server without starting another one", const review = run("node", [SCRIPT, "review"], { cwd: repo, - env + env, }); assert.equal(review.status, 0, review.stderr); @@ -2188,7 +2513,7 @@ test("setup reuses an existing shared app-server without starting another one", const setup = run("node", [SCRIPT, "setup", "--json"], { cwd: repo, - env + env, }); assert.equal(setup.status, 0, setup.stderr); @@ -2200,8 +2525,8 @@ test("setup reuses an existing shared app-server without starting another one", env, input: JSON.stringify({ hook_event_name: "SessionEnd", - cwd: repo - }) + cwd: repo, + }), }); assert.equal(cleanup.status, 0, cleanup.stderr); }); @@ -2218,7 +2543,7 @@ test("status reports shared session runtime when a lazy broker is active", () => const review = run("node", [SCRIPT, "review"], { cwd: repo, - env: buildEnv(binDir) + env: buildEnv(binDir), }); assert.equal(review.status, 0, review.stderr); @@ -2228,7 +2553,7 @@ test("status reports shared session runtime when a lazy broker is active", () => const result = run("node", [SCRIPT, "status"], { cwd: repo, - env: buildEnv(binDir) + env: buildEnv(binDir), }); assert.equal(result.status, 0, result.stderr); @@ -2240,18 +2565,22 @@ test("setup and status honor --cwd when reading shared session runtime", () => { const invocationWorkspace = makeTempDir(); saveBrokerSession(targetWorkspace, { - endpoint: "unix:/tmp/fake-broker.sock" + endpoint: "unix:/tmp/fake-broker.sock", }); const status = run("node", [SCRIPT, "status", "--cwd", targetWorkspace], { - cwd: invocationWorkspace + cwd: invocationWorkspace, }); assert.equal(status.status, 0, status.stderr); assert.match(status.stdout, /Session runtime: shared session/); - const setup = run("node", [SCRIPT, "setup", "--cwd", targetWorkspace, "--json"], { - cwd: invocationWorkspace - }); + const setup = run( + "node", + [SCRIPT, "setup", "--cwd", targetWorkspace, "--json"], + { + cwd: invocationWorkspace, + }, + ); assert.equal(setup.status, 0, setup.stderr); const payload = JSON.parse(setup.stdout); assert.equal(payload.sessionRuntime.mode, "shared");