diff --git a/README.md b/README.md index 937a3037b..bd893d907 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,10 @@ Use it when you want Codex to: - take a faster or cheaper pass with a smaller model > [!NOTE] -> Depending on the task and the model you choose these tasks might take a long time and it's generally recommended to force the task to be in the background or move the agent to the background. +> Depending on the task and model, a rescue can take a while. `--background` +> detaches the Claude Code subagent while its Codex command stays attached to +> that subagent, so the final result is delivered back automatically when the +> subagent completes. It supports `--background`, `--wait`, `--resume`, and `--fresh`. If you omit `--resume` and `--fresh`, the plugin can offer to continue the latest rescue thread for this repo. diff --git a/plugins/codex/agents/codex-rescue.md b/plugins/codex/agents/codex-rescue.md index 7009ec86a..f11062884 100644 --- a/plugins/codex/agents/codex-rescue.md +++ b/plugins/codex/agents/codex-rescue.md @@ -20,8 +20,12 @@ Selection guidance: Forwarding rules: - Use exactly one `Bash` call to invoke `node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" task ...`. -- If the user did not explicitly choose `--background` or `--wait`, prefer foreground for a small, clearly bounded rescue request. -- If the user did not explicitly choose `--background` or `--wait` and the task looks complicated, open-ended, multi-step, or likely to keep Codex running for a long time, prefer background execution. +- Always invoke the companion `task` command in the foreground. Never add + `--background` to the companion command, even for a long or complicated task. +- `--background` controls whether Claude Code runs this subagent in the + background; it does not control the companion process. Keeping the companion + call foreground-bound lets the subagent completion notification carry the + final Codex output back to the coordinating Claude thread. - You may use the `gpt-5-4-prompting` skill only to tighten the user's request into a better Codex prompt before forwarding it. - Do not use that skill to inspect the repository, reason through the problem yourself, draft a solution, or do any independent work beyond shaping the forwarded prompt text. - Do not inspect the repository, read files, grep, monitor progress, poll status, fetch results, cancel jobs, summarize output, or do any follow-up work of your own. @@ -31,6 +35,7 @@ Forwarding rules: - If the user asks for `spark`, map that to `--model gpt-5.3-codex-spark`. - If the user asks for a concrete model name such as `gpt-5.4-mini`, pass it through with `--model`. - Treat `--effort ` and `--model ` as runtime controls and do not include them in the task text you pass through. +- Strip `--background` and `--wait` from the task text and command arguments. - Default to a write-capable Codex run by adding `--write` unless the user explicitly asks for read-only behavior or only wants review, diagnosis, or research without edits. - Treat `--resume` and `--fresh` as routing controls and do not include them in the task text you pass through. - `--resume` means add `--resume-last`. diff --git a/plugins/codex/commands/rescue.md b/plugins/codex/commands/rescue.md index 56de9555d..456550e64 100644 --- a/plugins/codex/commands/rescue.md +++ b/plugins/codex/commands/rescue.md @@ -17,6 +17,9 @@ Execution mode: - If the request includes `--wait`, run the `codex:codex-rescue` subagent in the foreground. - If neither flag is present, default to foreground. - `--background` and `--wait` are execution flags for Claude Code. Do not forward them to `task`, and do not treat them as part of the natural-language task text. +- Whether the subagent itself is foreground or background, its single companion + `task` command must remain foreground-bound. This is how the final Codex + stdout returns in the subagent completion notification. - `--model` and `--effort` are runtime-selection flags. Preserve them for the forwarded `task` call, but do not treat them as part of the natural-language task text. - If the request includes `--resume`, do not ask whether to continue. The user already chose. - If the request includes `--fresh`, do not ask whether to continue. The user already chose. @@ -39,6 +42,8 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" task-resume-candidate - Operating rules: - The subagent is a thin forwarder only. It should use one `Bash` call to invoke `node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" task ...` and return that command's stdout as-is. +- Reject any subagent command that adds companion `task --background`; only the + Claude Code `Agent` execution may be detached. - Return the Codex companion stdout verbatim to the user. - Do not paraphrase, summarize, rewrite, or add commentary before or after it. - Do not ask the subagent to inspect files, monitor progress, poll `/codex:status`, fetch `/codex:result`, call `/codex:cancel`, summarize output, or do follow-up work of its own. diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 83df468ad..c589c1e07 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -30,6 +30,7 @@ import { generateJobId, getConfig, listJobs, + resolveJobStartGateFile, setConfig, upsertJob, writeJobFile @@ -149,7 +150,8 @@ function parseCommandInput(argv, config = {}) { } function resolveCommandCwd(options = {}) { - return options.cwd ? path.resolve(process.cwd(), options.cwd) : process.cwd(); + const requestedCwd = options.cwd ?? process.env.CLAUDE_PROJECT_DIR; + return requestedCwd ? path.resolve(process.cwd(), requestedCwd) : process.cwd(); } function resolveCommandWorkspace(options = {}) { @@ -668,9 +670,12 @@ async function runForegroundCommand(job, runner, options = {}) { return execution; } -function spawnDetachedTaskWorker(cwd, jobId) { +function spawnDetachedTaskWorker(cwd, jobId, startGate) { const scriptPath = path.join(ROOT_DIR, "scripts", "codex-companion.mjs"); - const child = spawn(process.execPath, [scriptPath, "task-worker", "--cwd", cwd, "--job-id", jobId], { + const child = spawn(process.execPath, [ + scriptPath, "task-worker", "--cwd", cwd, "--job-id", jobId, + "--start-gate", startGate + ], { cwd, env: process.env, detached: true, @@ -685,17 +690,36 @@ function enqueueBackgroundTask(cwd, job, request) { const { logFile } = createTrackedProgress(job); appendLogLine(logFile, "Queued for background execution."); - const child = spawnDetachedTaskWorker(cwd, job.id); const queuedRecord = { ...job, status: "queued", phase: "queued", - pid: child.pid ?? null, + pid: null, logFile, request }; + // The job file bootstraps the gated worker, but the shared index must not + // expose a cancellable job until its detached process has a usable PID. writeJobFile(job.workspaceRoot, job.id, queuedRecord); - upsertJob(job.workspaceRoot, queuedRecord); + + const startGate = resolveJobStartGateFile(job.workspaceRoot, job.id); + try { + const child = spawnDetachedTaskWorker(cwd, job.id, startGate); + const launchRecord = { ...queuedRecord, pid: child.pid ?? null }; + writeJobFile(job.workspaceRoot, job.id, launchRecord); + upsertJob(job.workspaceRoot, launchRecord); + fs.writeFileSync(startGate, "ready\n", "utf8"); + } catch (error) { + const failedRecord = { + ...queuedRecord, + status: "failed", + phase: "failed", + errorMessage: error instanceof Error ? error.message : String(error) + }; + writeJobFile(job.workspaceRoot, job.id, failedRecord); + upsertJob(job.workspaceRoot, failedRecord); + throw error; + } return { payload: { @@ -837,7 +861,7 @@ async function handleTransfer(argv) { async function handleTaskWorker(argv) { const { options } = parseCommandInput(argv, { - valueOptions: ["cwd", "job-id"] + valueOptions: ["cwd", "job-id", "start-gate"] }); if (!options["job-id"]) { @@ -846,10 +870,23 @@ async function handleTaskWorker(argv) { const cwd = resolveCommandCwd(options); const workspaceRoot = resolveCommandWorkspace(options); + if (options["start-gate"]) { + const deadline = Date.now() + 30000; + while (!fs.existsSync(options["start-gate"])) { + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for task ${options["job-id"]} to be registered.`); + } + await sleep(10); + } + fs.unlinkSync(options["start-gate"]); + } const storedJob = readStoredJob(workspaceRoot, options["job-id"]); if (!storedJob) { throw new Error(`No stored job found for ${options["job-id"]}.`); } + if (storedJob.status === "cancelled") { + return; + } const request = storedJob.request; if (!request || typeof request !== "object") { @@ -973,7 +1010,7 @@ async function handleCancel(argv) { const threadId = existing.threadId ?? job.threadId ?? null; const turnId = existing.turnId ?? job.turnId ?? null; - const interrupt = await interruptAppServerTurn(cwd, { threadId, turnId }); + const interrupt = await interruptAppServerTurn(workspaceRoot, { threadId, turnId }); if (interrupt.attempted) { appendLogLine( job.logFile, diff --git a/plugins/codex/scripts/lib/job-control.mjs b/plugins/codex/scripts/lib/job-control.mjs index ad152c157..49a0184a7 100644 --- a/plugins/codex/scripts/lib/job-control.mjs +++ b/plugins/codex/scripts/lib/job-control.mjs @@ -1,7 +1,7 @@ import fs from "node:fs"; import { getSessionRuntimeStatus } from "./codex.mjs"; -import { getConfig, listJobs, readJobFile, resolveJobFile } from "./state.mjs"; +import { findJobsAcrossWorkspaces, getConfig, listJobs, readJobFile, resolveJobFile } from "./state.mjs"; import { SESSION_ID_ENV } from "./tracked-jobs.mjs"; import { resolveWorkspaceRoot } from "./workspace.mjs"; @@ -210,6 +210,18 @@ function matchJobReference(jobs, reference, predicate = () => true) { throw new Error(`No job found for "${reference}". Run /codex:status to list known jobs.`); } +function findCrossWorkspaceJob(reference, predicate = () => true) { + const matches = findJobsAcrossWorkspaces(reference).filter(predicate); + if (matches.length === 1) { + const job = matches[0]; + return { workspaceRoot: job.workspaceRoot, job }; + } + if (matches.length > 1) { + throw new Error(`Job reference "${reference}" is ambiguous across workspaces. Use the full job id.`); + } + return null; +} + export function buildStatusSnapshot(cwd, options = {}) { const workspaceRoot = resolveWorkspaceRoot(cwd); const config = getConfig(workspaceRoot); @@ -242,7 +254,19 @@ export function buildStatusSnapshot(cwd, options = {}) { export function buildSingleJobSnapshot(cwd, reference, options = {}) { const workspaceRoot = resolveWorkspaceRoot(cwd); const jobs = sortJobsNewestFirst(listJobs(workspaceRoot)); - const selected = matchJobReference(jobs, reference); + let selected; + try { + selected = matchJobReference(jobs, reference); + } catch (error) { + const crossWorkspace = findCrossWorkspaceJob(reference); + if (!crossWorkspace) { + throw error; + } + return { + workspaceRoot: crossWorkspace.workspaceRoot, + job: enrichJob(crossWorkspace.job, { maxProgressLines: options.maxProgressLines }) + }; + } if (!selected) { throw new Error(`No job found for "${reference}". Run /codex:status to inspect known jobs.`); } @@ -256,11 +280,23 @@ export function buildSingleJobSnapshot(cwd, reference, options = {}) { export function resolveResultJob(cwd, reference) { const workspaceRoot = resolveWorkspaceRoot(cwd); const jobs = sortJobsNewestFirst(reference ? listJobs(workspaceRoot) : filterJobsForCurrentSession(listJobs(workspaceRoot))); - const selected = matchJobReference( - jobs, - reference, - (job) => job.status === "completed" || job.status === "failed" || job.status === "cancelled" - ); + let selected; + try { + selected = matchJobReference( + jobs, + reference, + (job) => job.status === "completed" || job.status === "failed" || job.status === "cancelled" + ); + } catch (error) { + const crossWorkspace = findCrossWorkspaceJob( + reference, + (job) => job.status === "completed" || job.status === "failed" || job.status === "cancelled" + ); + if (crossWorkspace) { + return crossWorkspace; + } + throw error; + } if (selected) { return { workspaceRoot, job: selected }; @@ -284,11 +320,19 @@ export function resolveCancelableJob(cwd, reference, options = {}) { const activeJobs = jobs.filter((job) => job.status === "queued" || job.status === "running"); if (reference) { - const selected = matchJobReference(activeJobs, reference); - if (!selected) { - throw new Error(`No active job found for "${reference}".`); + try { + const selected = matchJobReference(activeJobs, reference); + return { workspaceRoot, job: selected }; + } catch (error) { + const crossWorkspace = findCrossWorkspaceJob( + reference, + (job) => job.status === "queued" || job.status === "running" + ); + if (crossWorkspace) { + return crossWorkspace; + } + throw error; } - return { workspaceRoot, job: selected }; } const sessionScopedActiveJobs = filterJobsForCurrentSession(activeJobs, options); diff --git a/plugins/codex/scripts/lib/state.mjs b/plugins/codex/scripts/lib/state.mjs index 2da23498f..a96d2f67f 100644 --- a/plugins/codex/scripts/lib/state.mjs +++ b/plugins/codex/scripts/lib/state.mjs @@ -26,6 +26,11 @@ function defaultState() { }; } +function resolveStateRootDir() { + const pluginDataDir = process.env[PLUGIN_DATA_ENV]; + return pluginDataDir ? path.join(pluginDataDir, "state") : FALLBACK_STATE_ROOT_DIR; +} + export function resolveStateDir(cwd) { const workspaceRoot = resolveWorkspaceRoot(cwd); let canonicalWorkspaceRoot = workspaceRoot; @@ -38,9 +43,36 @@ export function resolveStateDir(cwd) { const slugSource = path.basename(workspaceRoot) || "workspace"; const slug = slugSource.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "workspace"; const hash = createHash("sha256").update(canonicalWorkspaceRoot).digest("hex").slice(0, 16); - const pluginDataDir = process.env[PLUGIN_DATA_ENV]; - const stateRoot = pluginDataDir ? path.join(pluginDataDir, "state") : FALLBACK_STATE_ROOT_DIR; - return path.join(stateRoot, `${slug}-${hash}`); + return path.join(resolveStateRootDir(), `${slug}-${hash}`); +} + +export function findJobsAcrossWorkspaces(reference) { + const stateRoot = resolveStateRootDir(); + if (!reference || !fs.existsSync(stateRoot)) { + return []; + } + + const matches = []; + for (const entry of fs.readdirSync(stateRoot, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + const stateFile = path.join(stateRoot, entry.name, STATE_FILE_NAME); + if (!fs.existsSync(stateFile)) { + continue; + } + try { + const state = JSON.parse(fs.readFileSync(stateFile, "utf8")); + for (const job of Array.isArray(state.jobs) ? state.jobs : []) { + if (job.id === reference || job.id?.startsWith(reference)) { + matches.push(job); + } + } + } catch { + // One corrupt workspace index must not hide healthy jobs elsewhere. + } + } + return matches; } export function resolveStateFile(cwd) { @@ -189,3 +221,8 @@ export function resolveJobFile(cwd, jobId) { ensureStateDir(cwd); return path.join(resolveJobsDir(cwd), `${jobId}.json`); } + +export function resolveJobStartGateFile(cwd, jobId) { + ensureStateDir(cwd); + return path.join(resolveJobsDir(cwd), `${jobId}.ready`); +} diff --git a/plugins/codex/skills/codex-cli-runtime/SKILL.md b/plugins/codex/skills/codex-cli-runtime/SKILL.md index 0e91bfb50..7a57e4995 100644 --- a/plugins/codex/skills/codex-cli-runtime/SKILL.md +++ b/plugins/codex/skills/codex-cli-runtime/SKILL.md @@ -26,6 +26,7 @@ Execution rules: Command selection: - Use exactly one `task` invocation per rescue handoff. - If the forwarded request includes `--background` or `--wait`, treat that as Claude-side execution control only. Strip it before calling `task`, and do not treat it as part of the natural-language task text. +- Always run the companion `task` invocation in the foreground. Never infer or add companion `--background`; Claude Code owns subagent detachment and needs the foreground command's final stdout in the subagent completion notification. - If the forwarded request includes `--model`, normalize `spark` to `gpt-5.3-codex-spark` and pass it through to `task`. - If the forwarded request includes `--effort`, pass it through to `task`. - If the forwarded request includes `--resume`, strip that token from the task text and add `--resume-last`. diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs index c34b06059..6e5cf0d53 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -127,8 +127,9 @@ test("rescue command absorbs continue semantics", () => { assert.match(agent, /--resume/); assert.match(agent, /--fresh/); assert.match(agent, /thin forwarding wrapper/i); - assert.match(agent, /prefer foreground for a small, clearly bounded rescue request/i); - assert.match(agent, /If the user did not explicitly choose `--background` or `--wait` and the task looks complicated, open-ended, multi-step, or likely to keep Codex running for a long time, prefer background execution/i); + assert.match(agent, /Always invoke the companion `task` command in the foreground/i); + assert.match(agent, /Never add\s+`--background` to the companion command/i); + assert.match(agent, /subagent completion notification carry the\s+final Codex output/i); assert.match(agent, /Use exactly one `Bash` call/i); assert.match(agent, /Do not inspect the repository, read files, grep, monitor progress, poll status, fetch results, cancel jobs, summarize output, or do any follow-up work of your own/i); assert.match(agent, /Do not call `review`, `adversarial-review`, `status`, `result`, or `cancel`/i); @@ -150,6 +151,8 @@ test("rescue command absorbs continue semantics", () => { assert.match(runtimeSkill, /Map `spark` to `--model gpt-5\.3-codex-spark`/i); assert.match(runtimeSkill, /If the forwarded request includes `--background` or `--wait`, treat that as Claude-side execution control only/i); assert.match(runtimeSkill, /Strip it before calling `task`/i); + assert.match(runtimeSkill, /Always run the companion `task` invocation in the foreground/i); + assert.match(runtimeSkill, /Never infer or add companion `--background`/i); assert.match(runtimeSkill, /`--effort`: accepted values are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`/i); assert.match(runtimeSkill, /Do not inspect the repository, read files, grep, monitor progress, poll status, fetch results, cancel jobs, summarize output, or do any follow-up work of your own/i); assert.match(runtimeSkill, /If the Bash call fails or Codex cannot be invoked, return nothing/i); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 8f276835b..2d62fa597 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -8,7 +8,11 @@ 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 { resolveStateDir } from "../plugins/codex/scripts/lib/state.mjs"; +import { + resolveStateDir, + upsertJob, + writeJobFile +} 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"); @@ -969,6 +973,116 @@ test("task --background enqueues a detached worker and exposes per-job status", assert.match(resultPayload.storedJob.rendered, /Handled the requested task/); }); +test("status and result recover an explicit job id from another workspace scope", async () => { + const repo = makeTempDir(); + const wrongScope = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "slow-task"); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + const env = buildEnv(binDir); + + const launched = run("node", [SCRIPT, "task", "--background", "--json", "inspect scope recovery"], { + cwd: repo, + env + }); + assert.equal(launched.status, 0, launched.stderr); + const jobId = JSON.parse(launched.stdout).jobId; + + const status = run( + "node", + [SCRIPT, "status", jobId, "--wait", "--timeout-ms", "15000", "--json"], + { cwd: wrongScope, env } + ); + assert.equal(status.status, 0, status.stderr); + assert.equal(JSON.parse(status.stdout).job.status, "completed"); + + const result = run("node", [SCRIPT, "result", jobId, "--json"], { + cwd: wrongScope, + env + }); + assert.equal(result.status, 0, result.stderr); + assert.equal(JSON.parse(result.stdout).job.id, jobId); +}); + +test("an immediately cancelled background task cannot restart after enqueue", async () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "slow-task"); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + const env = buildEnv(binDir); + + const launched = run("node", [SCRIPT, "task", "--background", "--json", "do not survive cancellation"], { + cwd: repo, + env + }); + assert.equal(launched.status, 0, launched.stderr); + const jobId = JSON.parse(launched.stdout).jobId; + + const queued = run("node", [SCRIPT, "status", jobId, "--json"], { cwd: repo, env }); + assert.equal(queued.status, 0, queued.stderr); + assert.equal(Number.isInteger(JSON.parse(queued.stdout).job.pid), true); + + const cancelled = run("node", [SCRIPT, "cancel", jobId, "--json"], { cwd: repo, env }); + assert.equal(cancelled.status, 0, cancelled.stderr); + await new Promise((resolve) => setTimeout(resolve, 1000)); + + const finalStatus = run("node", [SCRIPT, "status", jobId, "--json"], { cwd: repo, env }); + assert.equal(finalStatus.status, 0, finalStatus.stderr); + assert.equal(JSON.parse(finalStatus.stdout).job.status, "cancelled"); +}); + +test("cancel recovers an explicit active job from another workspace scope", () => { + const repo = makeTempDir(); + const wrongScope = makeTempDir(); + const binDir = makeTempDir(); + const pluginDataDir = makeTempDir(); + installFakeCodex(binDir); + initGitRepo(repo); + const env = { ...buildEnv(binDir), CLAUDE_PLUGIN_DATA: pluginDataDir }; + const jobId = "task-cross-scope-cancel"; + const queuedJob = { + id: jobId, + kind: "task", + title: "Cross-scope cancellation fixture", + workspaceRoot: repo, + jobClass: "task", + status: "queued", + phase: "queued", + pid: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }; + + const previousPluginData = process.env.CLAUDE_PLUGIN_DATA; + process.env.CLAUDE_PLUGIN_DATA = pluginDataDir; + try { + writeJobFile(repo, jobId, queuedJob); + upsertJob(repo, queuedJob); + } finally { + if (previousPluginData == null) delete process.env.CLAUDE_PLUGIN_DATA; + else process.env.CLAUDE_PLUGIN_DATA = previousPluginData; + } + + const cancelled = run("node", [SCRIPT, "cancel", jobId, "--json"], { + cwd: wrongScope, + env + }); + assert.equal(cancelled.status, 0, cancelled.stderr); + assert.equal(JSON.parse(cancelled.stdout).status, "cancelled"); + const finalStatus = run("node", [SCRIPT, "status", jobId, "--json"], { + cwd: wrongScope, + env + }); + assert.equal(finalStatus.status, 0, finalStatus.stderr); + assert.equal(JSON.parse(finalStatus.stdout).job.status, "cancelled"); +}); + test("review rejects focus text because it is native-review only", () => { const repo = makeTempDir(); const binDir = makeTempDir(); @@ -1801,6 +1915,70 @@ test("cancel sends turn interrupt to the shared app-server before killing a brok assert.equal(cleanup.status, 0, cleanup.stderr); }); +test("cross-workspace cancel interrupts the owning workspace broker", async () => { + const repo = makeTempDir(); + const wrongScope = makeTempDir(); + const binDir = makeTempDir(); + const fakeStatePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir, "interruptible-slow-task"); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const env = buildEnv(binDir); + const launched = run("node", [SCRIPT, "task", "--background", "--json", "interrupt the owning broker"], { + cwd: repo, + env + }); + assert.equal(launched.status, 0, launched.stderr); + const jobId = JSON.parse(launched.stdout).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); + return job?.status === "running" && job.threadId && job.turnId ? job : null; + }, { timeoutMs: 15000 }); + + // This test isolates broker routing from process-tree termination. The + // existing same-workspace cancellation test covers the PID kill path. + const stateFile = path.join(stateDir, "state.json"); + const state = JSON.parse(fs.readFileSync(stateFile, "utf8")); + state.jobs = state.jobs.map((job) => job.id === jobId ? { ...job, pid: null } : job); + fs.writeFileSync(stateFile, `${JSON.stringify(state, null, 2)}\n`, "utf8"); + const jobFile = path.join(stateDir, "jobs", `${jobId}.json`); + const stored = JSON.parse(fs.readFileSync(jobFile, "utf8")); + fs.writeFileSync(jobFile, `${JSON.stringify({ ...stored, pid: null }, null, 2)}\n`, "utf8"); + + const cancelResult = run("node", [SCRIPT, "cancel", jobId, "--json"], { + cwd: wrongScope, + env + }); + assert.equal(cancelResult.status, 0, cancelResult.stderr); + const payload = JSON.parse(cancelResult.stdout); + assert.equal(payload.status, "cancelled"); + assert.equal(payload.turnInterruptAttempted, true); + assert.equal(payload.turnInterrupted, true); + + await waitFor(() => { + const fakeState = JSON.parse(fs.readFileSync(fakeStatePath, "utf8")); + return fakeState.lastInterrupt ?? null; + }); + const fakeState = JSON.parse(fs.readFileSync(fakeStatePath, "utf8")); + assert.deepEqual(fakeState.lastInterrupt, { + threadId: runningJob.threadId, + turnId: runningJob.turnId + }); + + const cleanup = run("node", [SESSION_HOOK, "SessionEnd"], { + cwd: repo, + env, + input: JSON.stringify({ hook_event_name: "SessionEnd", cwd: repo }) + }); + assert.equal(cleanup.status, 0, cleanup.stderr); +}); + test("session end fully cleans up jobs for the ending session", async (t) => { const repo = makeTempDir(); initGitRepo(repo); @@ -2257,3 +2435,19 @@ test("setup and status honor --cwd when reading shared session runtime", () => { assert.equal(payload.sessionRuntime.mode, "shared"); assert.equal(payload.sessionRuntime.endpoint, "unix:/tmp/fake-broker.sock"); }); + +test("CLAUDE_PROJECT_DIR scopes companion commands when --cwd is omitted", () => { + const targetWorkspace = makeTempDir(); + const invocationWorkspace = makeTempDir(); + saveBrokerSession(targetWorkspace, { endpoint: "unix:/tmp/project-broker.sock" }); + + const result = run("node", [SCRIPT, "status", "--json"], { + cwd: invocationWorkspace, + env: { ...process.env, CLAUDE_PROJECT_DIR: targetWorkspace } + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.workspaceRoot, targetWorkspace); + assert.equal(payload.sessionRuntime.endpoint, "unix:/tmp/project-broker.sock"); +});