From 56a1219088b22e5c1dfcb908b714bee2904c8067 Mon Sep 17 00:00:00 2001 From: Reederey Date: Sun, 12 Jul 2026 06:22:29 -0400 Subject: [PATCH 1/9] fix(grok): classify job status on stopReason, add incomplete terminal status Grok's exit code is untrustworthy in both directions: external termination ends exit 0 + stopReason Cancelled with the diff already flushed (previously mislabeled "succeeded"), and max-turns exhaustion ends exit 1 + Cancelled where work may have landed (previously mislabeled "failed"). Classify on the stopReason field instead (clean = EndTurn, ACP end_turn also accepted), and report early stops as a new "incomplete" status carrying evidence (stopReason, exit status, changed files), a rendered warning banner, and a distinct foreground exit code 2 so callers verify the diff instead of retrying or trusting the narrative. Co-Authored-By: Claude Fable 5 --- plugins/grok/scripts/grok-companion.mjs | 34 ++++++++-- plugins/grok/scripts/lib/grok-cli.mjs | 24 ++++++- plugins/grok/scripts/lib/render.mjs | 27 ++++++-- tests/fake-grok-fixture.mjs | 12 +++- tests/grok-runtime.test.mjs | 83 ++++++++++++++++++++++++- tests/render.test.mjs | 40 ++++++++++++ 6 files changed, 204 insertions(+), 16 deletions(-) diff --git a/plugins/grok/scripts/grok-companion.mjs b/plugins/grok/scripts/grok-companion.mjs index 36cbceddb..e24525478 100644 --- a/plugins/grok/scripts/grok-companion.mjs +++ b/plugins/grok/scripts/grok-companion.mjs @@ -10,6 +10,7 @@ import { fileURLToPath } from "node:url"; import { parseArgs, splitRawArgumentString } from "./lib/args.mjs"; import { collectReviewContext } from "./lib/git-context.mjs"; import { + classifyGrokCompletion, deleteGrokSession, getGrokAvailability, getGrokInspect, @@ -21,6 +22,7 @@ import { binaryAvailable, runCommand, terminateProcessTree } from "./lib/process import { renderCleanupReport, renderGrokResult, + renderIncompleteBanner, renderJobResult, renderSetup, renderStatus, @@ -286,14 +288,25 @@ async function runStoredJob(cwd, job) { onPid }); const writeSummary = before ? summarizeWriteChanges(before, captureGitStatus(running.request.cwd)) : null; + const status = classifyGrokCompletion({ + exitStatus: grokResult.status, + stopReason: grokResult.output.stopReason + }); const rendered = [ + ...(status === "incomplete" ? [...renderIncompleteBanner(grokResult.output.stopReason), ""] : []), renderGrokResult(grokResult, jobHeading(running.kind)).trimEnd(), ...(writeSummary ? ["", ...renderWriteSummary(writeSummary)] : []) ].join("\n") + "\n"; - const status = grokResult.status === 0 ? "succeeded" : "failed"; + const stopReason = grokResult.output.stopReason ?? null; const completionResult = compareAndSwapJobState(cwd, running.id, "running", { ...running, status, + stopReason, + evidence: { + stopReason, + exitStatus: grokResult.status, + changedFiles: writeSummary ? writeSummary.changedFiles : null + }, pid: null, grokPid: grokResult.pid ?? running.grokPid ?? null, completedAt: new Date().toISOString(), @@ -310,14 +323,24 @@ async function runStoredJob(cwd, job) { }, writeSummary, rendered, - errorMessage: status === "failed" ? grokResult.stderr.trim() || `Grok exited with status ${grokResult.status}.` : null + errorMessage: + status === "failed" + ? grokResult.stderr.trim() || `Grok exited with status ${grokResult.status}.` + : status === "incomplete" + ? `Grok stopped early (stopReason: ${stopReason ?? "unknown"}).` + : null }); if (!completionResult.success) { appendJobLog(cwd, running.id, "Job was cancelled while Grok was running."); return completionResult.job ?? running; } const completed = completionResult.job; - appendJobLog(cwd, completed.id, `${status === "succeeded" ? "Completed" : "Failed"} ${completed.kind} job.`); + const completionKind = status === "succeeded" + ? "Completed" + : status === "incomplete" + ? "Completed with incomplete status" + : "Failed"; + appendJobLog(cwd, completed.id, `${completionKind} ${completed.kind} job.`); return completed; } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -412,7 +435,10 @@ async function runForegroundJob(cwd, request, asJson) { } const completed = await runStoredJob(cwd, job); output(asJson ? completed : completed.rendered, asJson); - if (completed.status !== "succeeded") { + // Incomplete gets a distinct exit code because the diff often landed — callers must verify, not blindly retry. + if (completed.status === "incomplete") { + process.exitCode = 2; + } else if (completed.status !== "succeeded") { process.exitCode = 1; } } diff --git a/plugins/grok/scripts/lib/grok-cli.mjs b/plugins/grok/scripts/lib/grok-cli.mjs index b95c2cd80..27311cac6 100644 --- a/plugins/grok/scripts/lib/grok-cli.mjs +++ b/plugins/grok/scripts/lib/grok-cli.mjs @@ -122,19 +122,37 @@ export function resolveMaxTurns(explicitMaxTurns, env = process.env) { export function parseGrokOutput(stdout) { const rawOutput = String(stdout ?? "").trim(); if (!rawOutput) { - return { parsed: null, rawOutput: "", parseError: "Grok produced no stdout." }; + return { parsed: null, rawOutput: "", parseError: "Grok produced no stdout.", stopReason: null }; } try { - return { parsed: JSON.parse(rawOutput), rawOutput, parseError: null }; + const parsed = JSON.parse(rawOutput); + const stopReason = typeof parsed?.stopReason === "string" && parsed.stopReason.trim() ? parsed.stopReason : null; + return { parsed, rawOutput, parseError: null, stopReason }; } catch (error) { return { parsed: null, rawOutput, - parseError: error instanceof Error ? error.message : "Grok returned malformed JSON." + parseError: error instanceof Error ? error.message : "Grok returned malformed JSON.", + stopReason: null }; } } +/** + * Exit code alone is untrustworthy: Grok exits 0 + Cancelled when killed externally and 1 + Cancelled on max-turns, so stopReason is the source of truth. + */ +export function classifyGrokCompletion({ exitStatus, stopReason }) { + if (typeof stopReason === "string" && stopReason.trim()) { + // Accept both the observed PascalCase ("EndTurn") and the ACP wire format ("end_turn"). + const isCleanStop = stopReason.toLowerCase().replace(/_/g, "") === "endturn"; + return isCleanStop ? "succeeded" : "incomplete"; + } + if (exitStatus === 0) { + return "incomplete"; + } + return "failed"; +} + export function getGrokAvailability(cwd) { return binaryAvailable("grok", ["--no-auto-update", "version"], { cwd }); } diff --git a/plugins/grok/scripts/lib/render.mjs b/plugins/grok/scripts/lib/render.mjs index e2bd3dd26..466efd800 100644 --- a/plugins/grok/scripts/lib/render.mjs +++ b/plugins/grok/scripts/lib/render.mjs @@ -3,7 +3,10 @@ function displayJob(job) { `- ${job.id} | ${job.kind} | ${job.status}`, job.summary ? ` ${job.summary}` : null, job.pid ? ` PID: ${job.pid}` : null, - job.errorMessage ? ` Error: ${job.errorMessage}` : null + job.errorMessage && job.status !== "incomplete" ? ` Error: ${job.errorMessage}` : null, + job.status === "incomplete" + ? ` ⚠ stopped early (stopReason: ${job.stopReason ?? "unknown"}) — verify diff` + : null ] .filter(Boolean) .join("\n"); @@ -50,7 +53,14 @@ export function renderWriteSummary(writeSummary) { "Write summary:", ...(writeSummary.changedFiles.length > 0 ? writeSummary.changedFiles.map((filePath) => `- ${filePath}`) - : ["- No Git status changes detected."]) + : ["- No Git status changes detected (no edits landed)."]) + ]; +} + +export function renderIncompleteBanner(stopReason) { + return [ + `⚠ INCOMPLETE — Grok stopped early (stopReason: ${stopReason ?? "unknown"}).`, + "Changes may be partial. Verify against git status/diff before trusting any success narrative." ]; } @@ -92,9 +102,12 @@ export function renderJobResult(job) { `# Grok Result`, "", `Job: ${job.id}`, - `Status: ${job.status}`, - "" + `Status: ${job.status}` ]; + if (job.status === "incomplete" && !job.rendered?.startsWith("⚠ INCOMPLETE")) { + lines.push("", ...renderIncompleteBanner(job.stopReason)); + } + lines.push(""); if (job.rendered) { lines.push(job.rendered.trimEnd()); } else if (job.errorMessage) { @@ -105,5 +118,11 @@ export function renderJobResult(job) { if (job.writeSummary && !job.rendered) { lines.push("", ...renderWriteSummary(job.writeSummary)); } + if (job.status === "incomplete") { + lines.push( + "", + "Statuses: succeeded = clean EndTurn; incomplete = early stop, verify diff; failed = crashed; cancelled = user-cancelled." + ); + } return `${lines.join("\n")}\n`; } diff --git a/tests/fake-grok-fixture.mjs b/tests/fake-grok-fixture.mjs index 7112f24ba..c4c99aee9 100644 --- a/tests/fake-grok-fixture.mjs +++ b/tests/fake-grok-fixture.mjs @@ -60,13 +60,21 @@ if (args.includes("sessions")) { process.exit(0); } -if (behavior === "write" || behavior === "delayed-write") { +if (behavior === "write" || behavior === "delayed-write" || behavior === "cancelled-with-edits") { fs.writeFileSync(path.join(process.cwd(), "grok-write.txt"), "changed by fake Grok\\n", "utf8"); } const output = behavior === "malformed-json" ? "not valid json" - : JSON.stringify({ answer: "fake response", prompt: args[args.indexOf("-p") + 1] || null }); + : behavior === "cancelled" || behavior === "cancelled-with-edits" || behavior === "max-turns" + ? JSON.stringify({ text: "", stopReason: "Cancelled" }) + : JSON.stringify({ text: "fake response", stopReason: "EndTurn", prompt: args[args.indexOf("-p") + 1] || null }); + +if (behavior === "max-turns") { + console.log(output); + console.error("Error: max turns reached"); + process.exit(1); +} if (behavior === "delayed" || behavior === "delayed-write") { setTimeout(() => { diff --git a/tests/grok-runtime.test.mjs b/tests/grok-runtime.test.mjs index e62beb004..30cfed6ed 100644 --- a/tests/grok-runtime.test.mjs +++ b/tests/grok-runtime.test.mjs @@ -337,12 +337,89 @@ test("malformed Grok JSON is preserved as raw output", () => { const fixture = createEnvironment({ behavior: "malformed-json" }); const result = invoke(fixture, ["ask", "return", "a", "summary", "--json"]); - assert.equal(result.status, 0, result.stderr); + assert.equal(result.status, 2, result.stderr); const job = JSON.parse(result.stdout); - assert.equal(job.status, "succeeded"); + assert.equal(job.status, "incomplete"); assert.equal(job.result.grok.parsed, null); assert.equal(job.result.grok.rawOutput, "not valid json"); assert.match(job.result.grok.parseError, /Unexpected token/i); + assert.match(job.rendered, /INCOMPLETE/); +}); + +test("cancelled task jobs are incomplete and use the distinct foreground exit code", () => { + const fixture = createEnvironment({ behavior: "cancelled" }); + const result = invoke(fixture, ["task", "--fresh", "attempt", "the", "task", "--json"]); + + assert.equal(result.status, 2, result.stderr); + const job = JSON.parse(result.stdout); + assert.equal(job.status, "incomplete"); + assert.equal(job.evidence.stopReason, "Cancelled"); + assert.equal(job.stopReason, "Cancelled"); + assert.match(job.rendered, /INCOMPLETE/); +}); + +test("cancelled write jobs preserve incomplete edits and render their changed files", () => { + const fixture = createEnvironment({ behavior: "cancelled-with-edits" }); + initGitRepo(fixture.workspace); + const result = invoke(fixture, [ + "task", + "--fresh", + "--write", + "--always-approve", + "attempt", + "the", + "task", + "--json" + ]); + + assert.equal(result.status, 2, result.stderr); + const job = JSON.parse(result.stdout); + assert.equal(job.status, "incomplete"); + assert.equal(job.writeSummary.changedFiles.includes("grok-write.txt"), true); + assert.match(job.rendered, /INCOMPLETE/); + assert.match(job.rendered, /grok-write\.txt/); +}); + +test("max-turns exhaustion is incomplete despite Grok exiting 1", () => { + const fixture = createEnvironment({ behavior: "max-turns" }); + const result = invoke(fixture, ["task", "--fresh", "attempt", "the", "task", "--json"]); + + assert.equal(result.status, 2, result.stderr); + const job = JSON.parse(result.stdout); + assert.equal(job.status, "incomplete"); + assert.equal(job.evidence.exitStatus, 1); + assert.equal(job.evidence.stopReason, "Cancelled"); +}); + +test("a Grok crash without JSON remains failed", () => { + const fixture = createEnvironment({ behavior: "failure" }); + const result = invoke(fixture, ["task", "--fresh", "attempt", "the", "task", "--json"]); + + assert.equal(result.status, 1, result.stderr); + const job = JSON.parse(result.stdout); + assert.equal(job.status, "failed"); + assert.equal(job.evidence.stopReason, null); + assert.equal(job.evidence.exitStatus, 3); +}); + +test("a clean write job with no changes succeeds and explains that no edits landed", () => { + const fixture = createEnvironment(); + initGitRepo(fixture.workspace); + const result = invoke(fixture, [ + "task", + "--fresh", + "--write", + "--always-approve", + "inspect", + "without", + "editing", + "--json" + ]); + + assert.equal(result.status, 0, result.stderr); + const job = JSON.parse(result.stdout); + assert.equal(job.status, "succeeded"); + assert.match(job.rendered, /No Git status changes detected \(no edits landed\)/); }); test("review gathers bounded working-tree and base-branch context without untracked contents", () => { @@ -444,7 +521,7 @@ test("background jobs persist a queued record and status/result retrieve scoped assert.equal(result.status, 0, result.stderr); const completed = JSON.parse(result.stdout); assert.equal(completed.status, "succeeded"); - assert.equal(completed.result.grok.parsed.answer, "fake response"); + assert.equal(completed.result.grok.parsed.text, "fake response"); assert.match(completed.rendered, /Grok Rescue/); }); diff --git a/tests/render.test.mjs b/tests/render.test.mjs index ab68038e5..137f0acea 100644 --- a/tests/render.test.mjs +++ b/tests/render.test.mjs @@ -2,6 +2,10 @@ import test from "node:test"; import assert from "node:assert/strict"; import { renderReviewResult, renderStoredJobResult } from "../plugins/codex/scripts/lib/render.mjs"; +import { + renderJobResult as renderGrokJobResult, + renderStatus as renderGrokStatus +} from "../plugins/grok/scripts/lib/render.mjs"; test("renderReviewResult degrades gracefully when JSON is missing required review fields", () => { const output = renderReviewResult( @@ -57,3 +61,39 @@ test("renderStoredJobResult prefers rendered output for structured review jobs", assert.match(output, /Codex session ID: thr_123/); assert.match(output, /Resume in Codex: codex resume thr_123/); }); + +test("renderJobResult renders the incomplete banner and status legend", () => { + const output = renderGrokJobResult({ + id: "grok-123", + status: "incomplete", + stopReason: "Cancelled", + rendered: "# Grok Rescue\n\nPartial result.\n" + }); + + assert.match(output, /⚠ INCOMPLETE — Grok stopped early \(stopReason: Cancelled\)\./); + assert.match(output, /Changes may be partial\. Verify against git status\/diff/); + assert.match(output, /Statuses: succeeded = clean EndTurn; incomplete = early stop, verify diff;/); +}); + +test("displayJob renders an incomplete warning through the status report", () => { + const output = renderGrokStatus([ + { + id: "grok-123", + kind: "task", + status: "incomplete", + stopReason: "Cancelled" + } + ]); + + assert.match(output, / ⚠ stopped early \(stopReason: Cancelled\) — verify diff/); +}); + +test("an old-shaped Grok job record renders without new terminal evidence fields", () => { + assert.doesNotThrow(() => + renderGrokJobResult({ + id: "grok-old", + status: "succeeded", + rendered: "# Grok\n\nLegacy result.\n" + }) + ); +}); From e413ba9389149b0a0e75dd3ebadaac44177994e3 Mon Sep 17 00:00:00 2001 From: Reederey Date: Sun, 12 Jul 2026 06:37:14 -0400 Subject: [PATCH 2/9] fix(state): key job state by shared repo root so worktree jobs stay visible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both plugins keyed job state by git show-toplevel, so a job launched from a linked worktree was invisible to status/result/cancel run from the main checkout. Key state by the main repo root (dirname of --path-format=absolute --git-common-dir, with old-git and bare-repo fallbacks) — a no-op for non-worktree repos — and read-merge legacy worktree-keyed state without ever writing to it. Grok write locks stay per-worktree (write..lock) so parallel worktree writers keep working, jobs record their originating worktree for display, and /grok:status gains --all plus an honest count of jobs hidden by the current-session filter. Co-Authored-By: Claude Fable 5 --- plugins/codex/scripts/lib/git.mjs | 47 +++++ plugins/codex/scripts/lib/state.mjs | 52 ++++- plugins/codex/scripts/lib/workspace.mjs | 9 +- plugins/grok/scripts/grok-companion.mjs | 26 ++- plugins/grok/scripts/lib/render.mjs | 10 + plugins/grok/scripts/lib/state.mjs | 46 ++++- plugins/grok/scripts/lib/workspace.mjs | 54 +++++ tests/git.test.mjs | 75 ++++++- tests/grok-runtime.test.mjs | 98 ++++++++- tests/state.test.mjs | 257 +++++++++++++++++++++++- 10 files changed, 641 insertions(+), 33 deletions(-) diff --git a/plugins/codex/scripts/lib/git.mjs b/plugins/codex/scripts/lib/git.mjs index c401f0ca1..3092dba13 100644 --- a/plugins/codex/scripts/lib/git.mjs +++ b/plugins/codex/scripts/lib/git.mjs @@ -91,6 +91,53 @@ export function getRepoRoot(cwd) { return gitChecked(cwd, ["rev-parse", "--show-toplevel"]).stdout.trim(); } +/** + * Shared repo root across linked worktrees (dirname of git-common-dir). + * Matches getRepoRoot on a normal checkout; from a linked worktree returns the main root. + * Used for companion state/job visibility — not for review diff context. + */ +export function getSharedRepoRoot(cwd) { + const resolvedCwd = path.resolve(cwd); + + const bareResult = git(resolvedCwd, ["rev-parse", "--is-bare-repository"]); + const bareErrorCode = bareResult.error && "code" in bareResult.error ? bareResult.error.code : null; + if (bareErrorCode === "ENOENT") { + throw new Error("git is not installed. Install Git and retry."); + } + if (bareResult.status !== 0) { + throw new Error("This command must run inside a Git repository."); + } + if (bareResult.stdout.trim() === "true") { + try { + return getRepoRoot(resolvedCwd); + } catch { + return resolvedCwd; + } + } + + const absoluteCommon = git(resolvedCwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]); + if (absoluteCommon.status === 0) { + const commonDir = absoluteCommon.stdout.trim(); + try { + return path.dirname(fs.realpathSync.native(commonDir)); + } catch { + return path.dirname(path.resolve(commonDir)); + } + } + + // Older git without --path-format=absolute + const plainCommon = git(resolvedCwd, ["rev-parse", "--git-common-dir"]); + if (plainCommon.status !== 0) { + throw new Error("This command must run inside a Git repository."); + } + const resolvedCommon = path.resolve(resolvedCwd, plainCommon.stdout.trim()); + try { + return path.dirname(fs.realpathSync.native(resolvedCommon)); + } catch { + return path.dirname(resolvedCommon); + } +} + export function detectDefaultBranch(cwd) { const symbolic = git(cwd, ["symbolic-ref", "refs/remotes/origin/HEAD"]); if (symbolic.status === 0) { diff --git a/plugins/codex/scripts/lib/state.mjs b/plugins/codex/scripts/lib/state.mjs index 2da23498f..b0d380deb 100644 --- a/plugins/codex/scripts/lib/state.mjs +++ b/plugins/codex/scripts/lib/state.mjs @@ -3,6 +3,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { ensureGitRepository } from "./git.mjs"; import { resolveWorkspaceRoot } from "./workspace.mjs"; const STATE_VERSION = 1; @@ -26,8 +27,7 @@ function defaultState() { }; } -export function resolveStateDir(cwd) { - const workspaceRoot = resolveWorkspaceRoot(cwd); +function stateDirForWorkspaceRoot(workspaceRoot) { let canonicalWorkspaceRoot = workspaceRoot; try { canonicalWorkspaceRoot = fs.realpathSync.native(workspaceRoot); @@ -43,6 +43,21 @@ export function resolveStateDir(cwd) { return path.join(stateRoot, `${slug}-${hash}`); } +/** Pre-shared-root key: show-toplevel (worktree-local) or cwd. Read-only fallback only. */ +function resolveLegacyStateDir(cwd) { + let workspaceRoot; + try { + workspaceRoot = ensureGitRepository(cwd); + } catch { + workspaceRoot = cwd; + } + return stateDirForWorkspaceRoot(workspaceRoot); +} + +export function resolveStateDir(cwd) { + return stateDirForWorkspaceRoot(resolveWorkspaceRoot(cwd)); +} + export function resolveStateFile(cwd) { return path.join(resolveStateDir(cwd), STATE_FILE_NAME); } @@ -55,12 +70,7 @@ export function ensureStateDir(cwd) { fs.mkdirSync(resolveJobsDir(cwd), { recursive: true }); } -export function loadState(cwd) { - const stateFile = resolveStateFile(cwd); - if (!fs.existsSync(stateFile)) { - return defaultState(); - } - +function parseStateFile(stateFile) { try { const parsed = JSON.parse(fs.readFileSync(stateFile, "utf8")); return { @@ -77,6 +87,26 @@ export function loadState(cwd) { } } +export function loadState(cwd) { + const stateFile = resolveStateFile(cwd); + if (fs.existsSync(stateFile)) { + return parseStateFile(stateFile); + } + + // Legacy read-only: show-toplevel-keyed state when shared-root state is missing. + // Never write/move/delete the legacy directory. + const legacyDir = resolveLegacyStateDir(cwd); + const newDir = resolveStateDir(cwd); + if (legacyDir !== newDir) { + const legacyFile = path.join(legacyDir, STATE_FILE_NAME); + if (fs.existsSync(legacyFile)) { + return parseStateFile(legacyFile); + } + } + + return defaultState(); +} + function pruneJobs(jobs) { return [...jobs] .sort((left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? ""))) @@ -102,13 +132,17 @@ export function saveState(cwd, state) { jobs: nextJobs }; + const stateDir = resolveStateDir(cwd); const retainedIds = new Set(nextJobs.map((job) => job.id)); for (const job of previousJobs) { if (retainedIds.has(job.id)) { continue; } removeJobFile(resolveJobFile(cwd, job.id)); - removeFileIfExists(job.logFile); + // Never delete artifacts that live under a legacy (worktree-keyed) state dir. + if (typeof job.logFile === "string" && job.logFile.startsWith(stateDir + path.sep)) { + removeFileIfExists(job.logFile); + } } fs.writeFileSync(resolveStateFile(cwd), `${JSON.stringify(nextState, null, 2)}\n`, "utf8"); diff --git a/plugins/codex/scripts/lib/workspace.mjs b/plugins/codex/scripts/lib/workspace.mjs index 89a0060b8..669a06f9c 100644 --- a/plugins/codex/scripts/lib/workspace.mjs +++ b/plugins/codex/scripts/lib/workspace.mjs @@ -1,8 +1,13 @@ -import { ensureGitRepository } from "./git.mjs"; +import { getSharedRepoRoot } from "./git.mjs"; +/** + * Workspace key for companion state (jobs/config). Uses the shared main-repo root + * so linked worktrees see the same job listings. Review/git context still uses + * getRepoRoot/ensureGitRepository (worktree-local show-toplevel). + */ export function resolveWorkspaceRoot(cwd) { try { - return ensureGitRepository(cwd); + return getSharedRepoRoot(cwd); } catch { return cwd; } diff --git a/plugins/grok/scripts/grok-companion.mjs b/plugins/grok/scripts/grok-companion.mjs index e24525478..45a75066a 100644 --- a/plugins/grok/scripts/grok-companion.mjs +++ b/plugins/grok/scripts/grok-companion.mjs @@ -52,7 +52,7 @@ function usage() { " node scripts/grok-companion.mjs ask [--model ] [--cwd ] [question]", " node scripts/grok-companion.mjs review [--background] [--base ] [--scope working-tree|branch|repo] [--model ] [--cwd ]", " node scripts/grok-companion.mjs task [--background|--wait] [--resume|--fresh] [--write] [--always-approve|--yolo] [--model ] [--cwd ] [task]", - " node scripts/grok-companion.mjs status [job-id] [--json] [--cwd ]", + " node scripts/grok-companion.mjs status [job-id] [--all] [--json] [--cwd ]", " node scripts/grok-companion.mjs result [job-id] [--json] [--cwd ]", " node scripts/grok-companion.mjs cancel [job-id] [--json] [--cwd ]", " node scripts/grok-companion.mjs task-resume-candidate [--json] [--cwd ]", @@ -598,15 +598,33 @@ async function handleWorker(argv) { function handleStatus(argv) { const { options, positionals } = parseCommand(argv, { valueOptions: ["cwd"], - booleanOptions: ["json"] + booleanOptions: ["json", "all"] }); const cwd = resolveCwd(options); const job = positionals[0] ? resolveJobReference(cwd, positionals[0]) : null; if (positionals[0] && !job) { throw new Error(`No Grok job found for "${positionals[0]}".`); } - const payload = job ?? filterJobsForCurrentClaudeSession(listJobs(cwd)); - output(options.json ? payload : renderStatus(job ? [job] : payload), options.json); + if (job) { + output(options.json ? job : renderStatus([job]), options.json); + return; + } + + const allJobs = listJobs(cwd); + const payload = options.all ? allJobs : filterJobsForCurrentClaudeSession(allJobs); + if (options.json) { + output(payload, true); + return; + } + + let rendered = renderStatus(payload); + if (!options.all) { + const hiddenCount = allJobs.length - payload.length; + if (hiddenCount > 0) { + rendered = `${rendered.trimEnd()}\n\n${hiddenCount} other job(s) from other sessions/worktrees — run /grok:status --all\n`; + } + } + output(rendered, false); } function handleResult(argv) { diff --git a/plugins/grok/scripts/lib/render.mjs b/plugins/grok/scripts/lib/render.mjs index 466efd800..272242974 100644 --- a/plugins/grok/scripts/lib/render.mjs +++ b/plugins/grok/scripts/lib/render.mjs @@ -1,7 +1,17 @@ function displayJob(job) { + const worktreePath = job.worktreeRoot ?? job.request?.cwd ?? null; + const sharedRoot = job.workspaceRoot ?? null; + const showWorktree = + typeof worktreePath === "string" && + worktreePath && + typeof sharedRoot === "string" && + sharedRoot && + worktreePath !== sharedRoot; + return [ `- ${job.id} | ${job.kind} | ${job.status}`, job.summary ? ` ${job.summary}` : null, + showWorktree ? ` worktree: ${worktreePath}` : null, job.pid ? ` PID: ${job.pid}` : null, job.errorMessage && job.status !== "incomplete" ? ` Error: ${job.errorMessage}` : null, job.status === "incomplete" diff --git a/plugins/grok/scripts/lib/state.mjs b/plugins/grok/scripts/lib/state.mjs index 7dbd2ae34..30cbe3fa8 100644 --- a/plugins/grok/scripts/lib/state.mjs +++ b/plugins/grok/scripts/lib/state.mjs @@ -4,12 +4,11 @@ import os from "node:os"; import path from "node:path"; import { isProcessAlive } from "./process.mjs"; -import { resolveWorkspace } from "./workspace.mjs"; +import { resolveSharedWorkspace, resolveWorkspace } from "./workspace.mjs"; const PLUGIN_DATA_ENV = "CLAUDE_PLUGIN_DATA"; const FALLBACK_ROOT = path.join(os.tmpdir(), "grok-companion"); const JOB_ID_PATTERN = /^grok-(?:ask|review|task)-[a-z0-9-]+$/; -const WRITE_LOCK_NAME = "write.lock"; function nowIso() { return new Date().toISOString(); @@ -29,8 +28,12 @@ function assertJobId(jobId) { } } +function resolveLegacyJobsDir(cwd) { + return path.join(stateRoot(), "jobs", workspaceHash(resolveWorkspace(cwd))); +} + export function resolveJobsDir(cwd) { - const workspaceRoot = resolveWorkspace(cwd); + const workspaceRoot = resolveSharedWorkspace(cwd); return path.join(stateRoot(), "jobs", workspaceHash(workspaceRoot)); } @@ -44,17 +47,20 @@ export function resolveJobLogFile(cwd, jobId) { return path.join(resolveJobsDir(cwd), `${jobId}.log`); } +/** Per-worktree write lock inside the shared jobs directory. */ export function resolveWriteLockDir(cwd) { - return path.join(resolveJobsDir(cwd), WRITE_LOCK_NAME); + return path.join(resolveJobsDir(cwd), `write.${workspaceHash(resolveWorkspace(cwd))}.lock`); } export function createJob(kind, cwd, fields = {}) { - const workspaceRoot = resolveWorkspace(cwd); + const workspaceRoot = resolveSharedWorkspace(cwd); + const worktreeRoot = resolveWorkspace(cwd); const createdAt = nowIso(); return { id: `grok-${kind}-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`, kind, workspaceRoot, + worktreeRoot, status: "queued", createdAt, updatedAt: createdAt, @@ -163,8 +169,7 @@ export function compareAndSwapJobState(cwd, jobId, expectedStatus, nextJob) { } } -export function listJobs(cwd) { - const jobsDir = resolveJobsDir(cwd); +function readJobsFromDir(jobsDir) { if (!fs.existsSync(jobsDir)) { return []; } @@ -177,8 +182,31 @@ export function listJobs(cwd) { } catch { return []; } - }) - .sort((left, right) => String(right.updatedAt).localeCompare(String(left.updatedAt))); + }); +} + +export function listJobs(cwd) { + const jobsDir = resolveJobsDir(cwd); + const jobsById = new Map(); + for (const job of readJobsFromDir(jobsDir)) { + if (job?.id) { + jobsById.set(job.id, job); + } + } + + // Legacy read-only: worktree-keyed jobs dir (pre-shared-root). Never write/move/delete there. + const legacyDir = resolveLegacyJobsDir(cwd); + if (legacyDir !== jobsDir) { + for (const job of readJobsFromDir(legacyDir)) { + if (job?.id && !jobsById.has(job.id)) { + jobsById.set(job.id, job); + } + } + } + + return [...jobsById.values()].sort((left, right) => + String(right.updatedAt).localeCompare(String(left.updatedAt)) + ); } export function resolveJobReference(cwd, reference, options = {}) { diff --git a/plugins/grok/scripts/lib/workspace.mjs b/plugins/grok/scripts/lib/workspace.mjs index 71127038c..1c99262de 100644 --- a/plugins/grok/scripts/lib/workspace.mjs +++ b/plugins/grok/scripts/lib/workspace.mjs @@ -29,6 +29,47 @@ export function getGitRepositoryRoot(cwd) { return canonicalDirectory(result.stdout.trim()); } +/** + * Shared repo root across linked worktrees (dirname of git-common-dir). + * For a normal checkout this matches getGitRepositoryRoot; for a linked worktree + * it returns the main worktree root so job state can be shared. + */ +export function getSharedRepoRoot(cwd) { + const canonicalCwd = canonicalDirectory(cwd); + + const bareResult = runCommand("git", ["rev-parse", "--is-bare-repository"], { cwd: canonicalCwd }); + const bareErrorCode = bareResult.error && "code" in bareResult.error ? bareResult.error.code : null; + if (bareErrorCode === "ENOENT") { + throw new Error("git is not installed. Install Git and retry."); + } + if (bareResult.status !== 0) { + throw new Error("This command must run inside a Git repository."); + } + if (bareResult.stdout.trim() === "true") { + try { + return getGitRepositoryRoot(canonicalCwd); + } catch { + return canonicalCwd; + } + } + + const absoluteCommon = runCommand( + "git", + ["rev-parse", "--path-format=absolute", "--git-common-dir"], + { cwd: canonicalCwd } + ); + if (absoluteCommon.status === 0) { + return path.dirname(canonicalDirectory(absoluteCommon.stdout.trim())); + } + + // Older git without --path-format=absolute + const plainCommon = runCommand("git", ["rev-parse", "--git-common-dir"], { cwd: canonicalCwd }); + if (plainCommon.status !== 0) { + throw new Error("This command must run inside a Git repository."); + } + return path.dirname(canonicalDirectory(path.resolve(canonicalCwd, plainCommon.stdout.trim()))); +} + export function resolveWorkspace(cwd, options = {}) { const canonicalCwd = canonicalDirectory(cwd); if (options.requireGit) { @@ -40,3 +81,16 @@ export function resolveWorkspace(cwd, options = {}) { return canonicalCwd; } } + +/** Like resolveWorkspace, but keys state by the shared main-repo root across worktrees. */ +export function resolveSharedWorkspace(cwd, options = {}) { + const canonicalCwd = canonicalDirectory(cwd); + if (options.requireGit) { + return getSharedRepoRoot(canonicalCwd); + } + try { + return getSharedRepoRoot(canonicalCwd); + } catch { + return canonicalCwd; + } +} diff --git a/tests/git.test.mjs b/tests/git.test.mjs index 5b5c266ee..86500a7ae 100644 --- a/tests/git.test.mjs +++ b/tests/git.test.mjs @@ -3,9 +3,22 @@ import path from "node:path"; import test from "node:test"; import assert from "node:assert/strict"; -import { collectReviewContext, resolveReviewTarget } from "../plugins/codex/scripts/lib/git.mjs"; +import { + collectReviewContext, + getRepoRoot as getCodexRepoRoot, + getSharedRepoRoot as getCodexSharedRepoRoot, + resolveReviewTarget +} from "../plugins/codex/scripts/lib/git.mjs"; +import { + getGitRepositoryRoot as getGrokRepoRoot, + getSharedRepoRoot as getGrokSharedRepoRoot +} from "../plugins/grok/scripts/lib/workspace.mjs"; import { initGitRepo, makeTempDir, run } from "./helpers.mjs"; +function realpath(p) { + return fs.realpathSync.native(p); +} + test("resolveReviewTarget prefers working tree when repo is dirty", () => { const cwd = makeTempDir(); initGitRepo(cwd); @@ -210,3 +223,63 @@ test("collectReviewContext keeps untracked file content in lightweight working t assert.match(context.content, /## Untracked Files/); assert.match(context.content, /UNTRACKED_RISK_MARKER/); }); + +test("getSharedRepoRoot matches show-toplevel on a normal checkout (codex + grok)", () => { + const cwd = makeTempDir(); + initGitRepo(cwd); + fs.writeFileSync(path.join(cwd, "app.js"), "console.log('v1');\n"); + run("git", ["add", "app.js"], { cwd }); + run("git", ["commit", "-m", "init"], { cwd }); + + const sharedCodex = realpath(getCodexSharedRepoRoot(cwd)); + const toplevelCodex = realpath(getCodexRepoRoot(cwd)); + const sharedGrok = realpath(getGrokSharedRepoRoot(cwd)); + const toplevelGrok = realpath(getGrokRepoRoot(cwd)); + + assert.equal(sharedCodex, toplevelCodex); + assert.equal(sharedGrok, toplevelGrok); + assert.equal(sharedCodex, sharedGrok); +}); + +test("getSharedRepoRoot from a linked worktree returns the main root", () => { + const main = makeTempDir("shared-main-"); + initGitRepo(main); + fs.writeFileSync(path.join(main, "app.js"), "console.log('v1');\n"); + run("git", ["add", "app.js"], { cwd: main }); + run("git", ["commit", "-m", "init"], { cwd: main }); + + const worktree = path.join(path.dirname(main), `${path.basename(main)}-wt`); + const add = run("git", ["worktree", "add", worktree, "HEAD"], { cwd: main }); + assert.equal(add.status, 0, add.stderr + add.stdout); + + try { + const mainRoot = realpath(main); + assert.equal(realpath(getCodexSharedRepoRoot(worktree)), mainRoot); + assert.equal(realpath(getGrokSharedRepoRoot(worktree)), mainRoot); + assert.equal(realpath(getCodexRepoRoot(worktree)), realpath(worktree)); + assert.equal(realpath(getGrokRepoRoot(worktree)), realpath(worktree)); + assert.notEqual(realpath(getCodexRepoRoot(worktree)), mainRoot); + } finally { + run("git", ["worktree", "remove", "--force", worktree], { cwd: main }); + } +}); + +test("getSharedRepoRoot falls back for bare repositories", () => { + const bare = makeTempDir("shared-bare-"); + const init = run("git", ["init", "--bare", "-b", "main"], { cwd: bare }); + assert.equal(init.status, 0, init.stderr); + + // Bare repos have no worktree; shared root should not throw and should return a path. + const codexRoot = getCodexSharedRepoRoot(bare); + const grokRoot = getGrokSharedRepoRoot(bare); + assert.equal(typeof codexRoot, "string"); + assert.equal(typeof grokRoot, "string"); + assert.ok(codexRoot.length > 0); + assert.ok(grokRoot.length > 0); +}); + +test("getSharedRepoRoot throws outside a git repository", () => { + const cwd = makeTempDir("not-git-"); + assert.throws(() => getCodexSharedRepoRoot(cwd), /Git repository/i); + assert.throws(() => getGrokSharedRepoRoot(cwd), /Git repository/i); +}); diff --git a/tests/grok-runtime.test.mjs b/tests/grok-runtime.test.mjs index 30cfed6ed..7ed8c2ba0 100644 --- a/tests/grok-runtime.test.mjs +++ b/tests/grok-runtime.test.mjs @@ -20,17 +20,20 @@ function createEnvironment(options = {}) { fs.mkdirSync(binDir); fs.mkdirSync(workspace); const fake = options.installFake === false ? null : installFakeGrok(binDir, options.behavior); + const env = { + ...process.env, + PATH: options.installFake === false ? binDir : `${binDir}${path.delimiter}${process.env.PATH}`, + CLAUDE_PLUGIN_DATA: pluginData, + ...(options.env ?? {}) + }; + // Companion refuses nested invocation; strip the guard so tests work under Grok/Codex agents. + delete env.GROK_BUILD_COMPANION_ACTIVE; return { root, binDir, workspace, fake, - env: { - ...process.env, - PATH: options.installFake === false ? binDir : `${binDir}${path.delimiter}${process.env.PATH}`, - CLAUDE_PLUGIN_DATA: pluginData, - ...(options.env ?? {}) - } + env }; } @@ -897,3 +900,86 @@ test("cleanup reports nothing to clean up when no terminal task sessions exist", assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /Nothing to clean up\./); }); + +test("status from main checkout shows a job launched in a linked worktree", () => { + const fixture = createEnvironment(); + initGitRepo(fixture.workspace); + fs.writeFileSync(path.join(fixture.workspace, "app.js"), "console.log('v1');\n"); + commit(fixture.workspace, "init"); + + const worktree = path.join(fixture.root, "linked-worktree"); + const add = run("git", ["worktree", "add", worktree, "HEAD"], { cwd: fixture.workspace }); + assert.equal(add.status, 0, add.stderr + add.stdout); + + try { + const sessionEnv = { GROK_COMPANION_SESSION_ID: "claude-shared" }; + const launched = invoke(fixture, ["task", "--fresh", "worktree", "task", "--json"], { + cwd: worktree, + env: sessionEnv + }); + assert.equal(launched.status, 0, launched.stderr); + const job = JSON.parse(launched.stdout); + assert.ok(job.worktreeRoot); + assert.equal(fs.realpathSync.native(job.worktreeRoot), fs.realpathSync.native(worktree)); + + const statusFromMain = invoke(fixture, ["status", "--json"], { + cwd: fixture.workspace, + env: sessionEnv + }); + assert.equal(statusFromMain.status, 0, statusFromMain.stderr); + const jobs = JSON.parse(statusFromMain.stdout); + assert.equal(jobs.some((entry) => entry.id === job.id), true); + + const textStatus = invoke(fixture, ["status"], { + cwd: fixture.workspace, + env: sessionEnv + }); + assert.equal(textStatus.status, 0, textStatus.stderr); + assert.match(textStatus.stdout, new RegExp(job.id)); + assert.match(textStatus.stdout, /worktree:/); + } finally { + run("git", ["worktree", "remove", "--force", worktree], { cwd: fixture.workspace }); + } +}); + +test("status --all reveals other-session jobs; default status prints an honest hidden-jobs summary", () => { + const fixture = createEnvironment(); + const current = { GROK_COMPANION_SESSION_ID: "claude-current" }; + const other = { GROK_COMPANION_SESSION_ID: "claude-other" }; + + const currentRun = invoke(fixture, ["task", "--fresh", "current", "task", "--json"], { env: current }); + const otherRun = invoke(fixture, ["task", "--fresh", "other", "task", "--json"], { env: other }); + assert.equal(currentRun.status, 0, currentRun.stderr); + assert.equal(otherRun.status, 0, otherRun.stderr); + const currentJob = JSON.parse(currentRun.stdout); + const otherJob = JSON.parse(otherRun.stdout); + + const scoped = invoke(fixture, ["status", "--json"], { env: current }); + assert.equal(scoped.status, 0, scoped.stderr); + assert.deepEqual( + JSON.parse(scoped.stdout).map((job) => job.id), + [currentJob.id] + ); + + const textScoped = invoke(fixture, ["status"], { env: current }); + assert.equal(textScoped.status, 0, textScoped.stderr); + assert.match(textScoped.stdout, new RegExp(currentJob.id)); + assert.doesNotMatch(textScoped.stdout, new RegExp(otherJob.id)); + assert.match( + textScoped.stdout, + /1 other job\(s\) from other sessions\/worktrees — run \/grok:status --all/ + ); + + const allJson = invoke(fixture, ["status", "--all", "--json"], { env: current }); + assert.equal(allJson.status, 0, allJson.stderr); + const allIds = JSON.parse(allJson.stdout) + .map((job) => job.id) + .sort(); + assert.deepEqual(allIds, [currentJob.id, otherJob.id].sort()); + + const allText = invoke(fixture, ["status", "--all"], { env: current }); + assert.equal(allText.status, 0, allText.stderr); + assert.match(allText.stdout, new RegExp(currentJob.id)); + assert.match(allText.stdout, new RegExp(otherJob.id)); + assert.doesNotMatch(allText.stdout, /other job\(s\) from other sessions/); +}); diff --git a/tests/state.test.mjs b/tests/state.test.mjs index f61b03c96..6b10e7ed0 100644 --- a/tests/state.test.mjs +++ b/tests/state.test.mjs @@ -1,16 +1,57 @@ +import { createHash } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import test from "node:test"; import assert from "node:assert/strict"; -import { makeTempDir } from "./helpers.mjs"; -import { resolveJobFile, resolveJobLogFile, resolveStateDir, resolveStateFile, saveState } from "../plugins/codex/scripts/lib/state.mjs"; +import { initGitRepo, makeTempDir, run } from "./helpers.mjs"; +import { + listJobs as listCodexJobs, + resolveJobFile, + resolveJobLogFile, + resolveStateDir, + resolveStateFile, + saveState, + upsertJob +} from "../plugins/codex/scripts/lib/state.mjs"; +import { + acquireWriteLock, + createJob, + listJobs as listGrokJobs, + releaseWriteLock, + resolveJobsDir, + resolveWriteLockDir, + writeJob +} from "../plugins/grok/scripts/lib/state.mjs"; +import { resolveWorkspace } from "../plugins/grok/scripts/lib/workspace.mjs"; delete process.env.CLAUDE_PLUGIN_DATA; delete process.env.CODEX_COMPANION_SESSION_ID; delete process.env.CODEX_COMPANION_TRANSCRIPT_PATH; +function realpath(p) { + return fs.realpathSync.native(p); +} + +function makeLinkedWorktreePair() { + const main = makeTempDir("state-main-"); + initGitRepo(main); + fs.writeFileSync(path.join(main, "README.md"), "main\n"); + run("git", ["add", "README.md"], { cwd: main }); + run("git", ["commit", "-m", "init"], { cwd: main }); + const worktree = path.join(path.dirname(main), `${path.basename(main)}-wt`); + const add = run("git", ["worktree", "add", worktree, "HEAD"], { cwd: main }); + assert.equal(add.status, 0, add.stderr + add.stdout); + return { + main: realpath(main), + worktree: realpath(worktree), + dispose() { + run("git", ["worktree", "remove", "--force", worktree], { cwd: main }); + } + }; +} + test("resolveStateDir uses a temp-backed per-workspace directory", () => { const workspace = makeTempDir(); const stateDir = resolveStateDir(workspace); @@ -107,3 +148,215 @@ test("saveState prunes dropped job artifacts when indexed jobs exceed the cap", .sort() ); }); + +test("grok jobs created in a linked worktree are visible from the main checkout and vice versa", () => { + const pluginData = makeTempDir("grok-plugin-data-"); + const previous = process.env.CLAUDE_PLUGIN_DATA; + process.env.CLAUDE_PLUGIN_DATA = pluginData; + const pair = makeLinkedWorktreePair(); + try { + const fromWorktree = writeJob(pair.worktree, createJob("task", pair.worktree, { summary: "from-wt" })); + const fromMain = writeJob(pair.main, createJob("ask", pair.main, { summary: "from-main" })); + + const listedFromMain = listGrokJobs(pair.main); + const listedFromWorktree = listGrokJobs(pair.worktree); + const idsFromMain = listedFromMain.map((job) => job.id).sort(); + const idsFromWorktree = listedFromWorktree.map((job) => job.id).sort(); + + assert.deepEqual(idsFromMain, [fromMain.id, fromWorktree.id].sort()); + assert.deepEqual(idsFromWorktree, idsFromMain); + assert.equal(resolveJobsDir(pair.main), resolveJobsDir(pair.worktree)); + assert.equal(fromWorktree.workspaceRoot, fromMain.workspaceRoot); + assert.equal(realpath(fromWorktree.worktreeRoot), pair.worktree); + assert.equal(realpath(fromMain.worktreeRoot), pair.main); + } finally { + pair.dispose(); + if (previous == null) { + delete process.env.CLAUDE_PLUGIN_DATA; + } else { + process.env.CLAUDE_PLUGIN_DATA = previous; + } + } +}); + +test("grok write locks are distinct across linked worktrees", () => { + const pluginData = makeTempDir("grok-lock-data-"); + const previous = process.env.CLAUDE_PLUGIN_DATA; + process.env.CLAUDE_PLUGIN_DATA = pluginData; + const pair = makeLinkedWorktreePair(); + try { + const mainJob = writeJob(pair.main, createJob("task", pair.main)); + const wtJob = writeJob(pair.worktree, createJob("task", pair.worktree)); + + const mainLock = resolveWriteLockDir(pair.main); + const wtLock = resolveWriteLockDir(pair.worktree); + assert.notEqual(mainLock, wtLock); + assert.equal(path.dirname(mainLock), resolveJobsDir(pair.main)); + assert.equal(path.dirname(wtLock), resolveJobsDir(pair.worktree)); + + acquireWriteLock(pair.main, mainJob.id); + acquireWriteLock(pair.worktree, wtJob.id); + assert.equal(fs.existsSync(mainLock), true); + assert.equal(fs.existsSync(wtLock), true); + + releaseWriteLock(pair.main, mainJob.id); + releaseWriteLock(pair.worktree, wtJob.id); + } finally { + pair.dispose(); + if (previous == null) { + delete process.env.CLAUDE_PLUGIN_DATA; + } else { + process.env.CLAUDE_PLUGIN_DATA = previous; + } + } +}); + +test("grok listJobs merges legacy worktree-keyed job files without modifying them", () => { + const pluginData = makeTempDir("grok-legacy-data-"); + const previous = process.env.CLAUDE_PLUGIN_DATA; + process.env.CLAUDE_PLUGIN_DATA = pluginData; + const pair = makeLinkedWorktreePair(); + try { + const legacyHash = createHash("sha256").update(pair.worktree).digest("hex").slice(0, 32); + const legacyDir = path.join(pluginData, "jobs", legacyHash); + fs.mkdirSync(legacyDir, { recursive: true }); + const legacyJob = { + id: "grok-task-legacy01-aaaaaaaa", + kind: "task", + workspaceRoot: pair.worktree, + status: "succeeded", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + summary: "legacy-only" + }; + const legacyFile = path.join(legacyDir, `${legacyJob.id}.json`); + fs.writeFileSync(legacyFile, `${JSON.stringify(legacyJob, null, 2)}\n`, "utf8"); + const before = fs.readFileSync(legacyFile, "utf8"); + const mtimeBefore = fs.statSync(legacyFile).mtimeMs; + + // Shared-root write from main; listing from the worktree must merge legacy (worktree-keyed) files. + const sharedJob = writeJob(pair.main, createJob("ask", pair.main, { summary: "shared" })); + const listed = listGrokJobs(pair.worktree); + const ids = listed.map((job) => job.id).sort(); + assert.deepEqual(ids, [legacyJob.id, sharedJob.id].sort()); + // Shared jobs dir differs from legacy for a linked worktree. + assert.notEqual(resolveJobsDir(pair.worktree), legacyDir); + + assert.equal(fs.readFileSync(legacyFile, "utf8"), before); + assert.equal(fs.statSync(legacyFile).mtimeMs, mtimeBefore); + assert.equal(fs.existsSync(legacyFile), true); + } finally { + pair.dispose(); + if (previous == null) { + delete process.env.CLAUDE_PLUGIN_DATA; + } else { + process.env.CLAUDE_PLUGIN_DATA = previous; + } + } +}); + +test("codex jobs created in a linked worktree are visible from the main checkout", () => { + const pluginData = makeTempDir("codex-plugin-data-"); + const previous = process.env.CLAUDE_PLUGIN_DATA; + process.env.CLAUDE_PLUGIN_DATA = pluginData; + const pair = makeLinkedWorktreePair(); + try { + upsertJob(pair.worktree, { + id: "job-from-wt", + status: "completed", + kind: "task", + summary: "worktree job" + }); + upsertJob(pair.main, { + id: "job-from-main", + status: "completed", + kind: "review", + summary: "main job" + }); + + assert.equal(resolveStateDir(pair.main), resolveStateDir(pair.worktree)); + const fromMain = listCodexJobs(pair.main).map((job) => job.id).sort(); + const fromWorktree = listCodexJobs(pair.worktree).map((job) => job.id).sort(); + assert.deepEqual(fromMain, ["job-from-main", "job-from-wt"]); + assert.deepEqual(fromWorktree, fromMain); + } finally { + pair.dispose(); + if (previous == null) { + delete process.env.CLAUDE_PLUGIN_DATA; + } else { + process.env.CLAUDE_PLUGIN_DATA = previous; + } + } +}); + +test("codex loadState reads legacy show-toplevel-keyed state without modifying it", () => { + const pluginData = makeTempDir("codex-legacy-data-"); + const previous = process.env.CLAUDE_PLUGIN_DATA; + process.env.CLAUDE_PLUGIN_DATA = pluginData; + const pair = makeLinkedWorktreePair(); + try { + const legacyHash = createHash("sha256").update(pair.worktree).digest("hex").slice(0, 16); + const slug = path.basename(pair.worktree).replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "workspace"; + const legacyDir = path.join(pluginData, "state", `${slug}-${legacyHash}`); + fs.mkdirSync(path.join(legacyDir, "jobs"), { recursive: true }); + const legacyState = { + version: 1, + config: { stopReviewGate: false }, + jobs: [ + { + id: "legacy-job-1", + status: "completed", + kind: "task", + updatedAt: "2026-01-01T00:00:00.000Z", + createdAt: "2026-01-01T00:00:00.000Z" + } + ] + }; + const legacyFile = path.join(legacyDir, "state.json"); + fs.writeFileSync(legacyFile, `${JSON.stringify(legacyState, null, 2)}\n`, "utf8"); + const before = fs.readFileSync(legacyFile, "utf8"); + const mtimeBefore = fs.statSync(legacyFile).mtimeMs; + + // Shared-root state dir has no state.json; listing from the worktree should surface legacy jobs. + assert.equal(fs.existsSync(resolveStateFile(pair.worktree)), false); + const jobs = listCodexJobs(pair.worktree); + assert.deepEqual( + jobs.map((job) => job.id), + ["legacy-job-1"] + ); + + assert.equal(fs.readFileSync(legacyFile, "utf8"), before); + assert.equal(fs.statSync(legacyFile).mtimeMs, mtimeBefore); + } finally { + pair.dispose(); + if (previous == null) { + delete process.env.CLAUDE_PLUGIN_DATA; + } else { + process.env.CLAUDE_PLUGIN_DATA = previous; + } + } +}); + +test("non-worktree repos keep the same grok jobs dir key as before (shared === toplevel)", () => { + const pluginData = makeTempDir("grok-key-stable-"); + const previous = process.env.CLAUDE_PLUGIN_DATA; + process.env.CLAUDE_PLUGIN_DATA = pluginData; + const cwd = makeTempDir("plain-repo-"); + initGitRepo(cwd); + fs.writeFileSync(path.join(cwd, "a.js"), "1\n"); + run("git", ["add", "a.js"], { cwd }); + run("git", ["commit", "-m", "init"], { cwd }); + + try { + const sharedDir = resolveJobsDir(cwd); + const legacyHash = createHash("sha256").update(resolveWorkspace(cwd)).digest("hex").slice(0, 32); + const expectedLegacy = path.join(pluginData, "jobs", legacyHash); + assert.equal(sharedDir, expectedLegacy); + } finally { + if (previous == null) { + delete process.env.CLAUDE_PLUGIN_DATA; + } else { + process.env.CLAUDE_PLUGIN_DATA = previous; + } + } +}); From e99ea54fd5e6a8f977fdfd278e17bac1e7ed9ea7 Mon Sep 17 00:00:00 2001 From: Reederey Date: Sun, 12 Jul 2026 06:45:27 -0400 Subject: [PATCH 3/9] feat(status): surface job liveness in codex and grok status output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running jobs previously showed only elapsed time, so a silently dead worker was indistinguishable from one that was quietly working — the recorded remedy was polling /codex:status every 10 minutes. Codex status now derives lastActivityAt from the per-job log mtime (every app-server event already appends a timestamped line; no new writers touch the unlocked state.json) and renders an advisory once a running job has been quiet for 5+ minutes. Grok jobs stream no per-event log, so the companion instead reports pid liveness: running (pid alive), a warning when the pid is gone, or liveness unknown for legacy records. Co-Authored-By: Claude Fable 5 --- plugins/codex/scripts/lib/job-control.mjs | 17 ++++ plugins/codex/scripts/lib/render.mjs | 43 +++++++++- plugins/grok/scripts/grok-companion.mjs | 22 ++++- plugins/grok/scripts/lib/render.mjs | 35 ++++++++ tests/render.test.mjs | 74 +++++++++++++++++ tests/runtime.test.mjs | 98 +++++++++++++++++++++++ 6 files changed, 283 insertions(+), 6 deletions(-) diff --git a/plugins/codex/scripts/lib/job-control.mjs b/plugins/codex/scripts/lib/job-control.mjs index ad152c157..0a1667b8c 100644 --- a/plugins/codex/scripts/lib/job-control.mjs +++ b/plugins/codex/scripts/lib/job-control.mjs @@ -100,6 +100,17 @@ function formatElapsedDuration(startValue, endValue = null) { return `${seconds}s`; } +function resolveLastActivityAt(job) { + try { + if (job.logFile) { + return fs.statSync(job.logFile).mtime.toISOString(); + } + } catch { + // Missing log or stat failure — fall through to timestamps. + } + return job.startedAt ?? job.createdAt ?? null; +} + function looksLikeVerificationCommand(line) { return /\b(test|tests|lint|build|typecheck|type-check|check|verify|validate|pytest|jest|vitest|cargo test|npm test|pnpm test|yarn test|go test|mvn test|gradle test|tsc|eslint|ruff)\b/i.test( line @@ -174,6 +185,12 @@ export function enrichJob(job, options = {}) { : null }; + if (job.status === "queued" || job.status === "running") { + const lastActivityAt = resolveLastActivityAt(job); + enriched.lastActivityAt = lastActivityAt; + enriched.inactiveFor = lastActivityAt ? formatElapsedDuration(lastActivityAt) : null; + } + return { ...enriched, phase: enriched.phase ?? inferLegacyJobPhase(enriched, enriched.progressPreview) diff --git a/plugins/codex/scripts/lib/render.mjs b/plugins/codex/scripts/lib/render.mjs index 9d2607ca6..27423bda6 100644 --- a/plugins/codex/scripts/lib/render.mjs +++ b/plugins/codex/scripts/lib/render.mjs @@ -81,6 +81,8 @@ function isStructuredReviewStoredResult(storedJob) { ); } +const INACTIVITY_ADVISORY_MS = 5 * 60 * 1000; + function formatJobLine(job) { const parts = [job.id, `${job.status || "unknown"}`]; if (job.kindLabel) { @@ -92,6 +94,31 @@ function formatJobLine(job) { return parts.join(" | "); } +function isInactivityAdvisory(job) { + if (job.status !== "running" || !job.lastActivityAt) { + return false; + } + const lastActivityMs = Date.parse(job.lastActivityAt); + if (!Number.isFinite(lastActivityMs)) { + return false; + } + return Date.now() - lastActivityMs >= INACTIVITY_ADVISORY_MS; +} + +function formatElapsedCell(job) { + if (job.status !== "running") { + return job.elapsed ?? ""; + } + const parts = []; + if (job.elapsed) { + parts.push(job.elapsed); + } + if (job.inactiveFor) { + parts.push(`last activity ${job.inactiveFor} ago`); + } + return parts.join(", "); +} + function escapeMarkdownCell(value) { return String(value ?? "") .replace(/\|/g, "\\|") @@ -116,7 +143,7 @@ function appendActiveJobsTable(lines, jobs) { actions.push(`/codex:cancel ${job.id}`); } lines.push( - `| ${escapeMarkdownCell(job.id)} | ${escapeMarkdownCell(job.kindLabel)} | ${escapeMarkdownCell(job.status)} | ${escapeMarkdownCell(job.phase ?? "")} | ${escapeMarkdownCell(job.elapsed ?? "")} | ${escapeMarkdownCell(job.threadId ?? "")} | ${escapeMarkdownCell(job.summary ?? "")} | ${actions.map((action) => `\`${action}\``).join("
")} |` + `| ${escapeMarkdownCell(job.id)} | ${escapeMarkdownCell(job.kindLabel)} | ${escapeMarkdownCell(job.status)} | ${escapeMarkdownCell(job.phase ?? "")} | ${escapeMarkdownCell(formatElapsedCell(job))} | ${escapeMarkdownCell(job.threadId ?? "")} | ${escapeMarkdownCell(job.summary ?? "")} | ${actions.map((action) => `\`${action}\``).join("
")} |` ); } } @@ -129,8 +156,18 @@ function pushJobDetails(lines, job, options = {}) { if (job.phase) { lines.push(` Phase: ${job.phase}`); } - if (options.showElapsed && job.elapsed) { - lines.push(` Elapsed: ${job.elapsed}`); + if (options.showElapsed && (job.elapsed || (job.status === "running" && job.inactiveFor))) { + if (job.status === "running" && job.inactiveFor) { + const elapsedPart = job.elapsed ? `${job.elapsed}, ` : ""; + lines.push(` Elapsed: ${elapsedPart}last activity ${job.inactiveFor} ago`); + if (isInactivityAdvisory(job)) { + lines.push( + ` no new activity for ${job.inactiveFor} — job may be stalled or mid long tool call` + ); + } + } else if (job.elapsed) { + lines.push(` Elapsed: ${job.elapsed}`); + } } if (options.showDuration && job.duration) { lines.push(` Duration: ${job.duration}`); diff --git a/plugins/grok/scripts/grok-companion.mjs b/plugins/grok/scripts/grok-companion.mjs index 45a75066a..52b155f90 100644 --- a/plugins/grok/scripts/grok-companion.mjs +++ b/plugins/grok/scripts/grok-companion.mjs @@ -18,7 +18,7 @@ import { runReadOnlyGrok, runWriteGrok } from "./lib/grok-cli.mjs"; -import { binaryAvailable, runCommand, terminateProcessTree } from "./lib/process.mjs"; +import { binaryAvailable, isProcessAlive, runCommand, terminateProcessTree } from "./lib/process.mjs"; import { renderCleanupReport, renderGrokResult, @@ -595,6 +595,19 @@ async function handleWorker(argv) { await runStoredJob(cwd, job); } +function attachRunningLiveness(job) { + if (!job || job.status !== "running") { + return job; + } + if (typeof job.grokPid !== "number" || !Number.isFinite(job.grokPid)) { + return { ...job, liveness: "unknown" }; + } + return { + ...job, + liveness: isProcessAlive(job.grokPid) ? "alive" : "gone" + }; +} + function handleStatus(argv) { const { options, positionals } = parseCommand(argv, { valueOptions: ["cwd"], @@ -606,12 +619,15 @@ function handleStatus(argv) { throw new Error(`No Grok job found for "${positionals[0]}".`); } if (job) { - output(options.json ? job : renderStatus([job]), options.json); + const enriched = attachRunningLiveness(job); + output(options.json ? enriched : renderStatus([enriched]), options.json); return; } const allJobs = listJobs(cwd); - const payload = options.all ? allJobs : filterJobsForCurrentClaudeSession(allJobs); + const payload = (options.all ? allJobs : filterJobsForCurrentClaudeSession(allJobs)).map( + attachRunningLiveness + ); if (options.json) { output(payload, true); return; diff --git a/plugins/grok/scripts/lib/render.mjs b/plugins/grok/scripts/lib/render.mjs index 272242974..9b705daff 100644 --- a/plugins/grok/scripts/lib/render.mjs +++ b/plugins/grok/scripts/lib/render.mjs @@ -1,3 +1,37 @@ +function formatElapsedSince(startValue) { + const start = Date.parse(startValue ?? ""); + if (!Number.isFinite(start)) { + return null; + } + + const totalSeconds = Math.max(0, Math.round((Date.now() - start) / 1000)); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + if (minutes > 0) { + return `${minutes}m ${seconds}s`; + } + return `${seconds}s`; +} + +function formatRunningLivenessLine(job) { + const liveness = job.liveness ?? "unknown"; + if (liveness === "alive") { + const elapsed = formatElapsedSince(job.startedAt); + return elapsed + ? ` running (pid alive, ${elapsed} elapsed)` + : " running (pid alive)"; + } + if (liveness === "gone") { + return " ⚠ running but pid gone — likely dead or externally cancelled; check /grok:result"; + } + return " running (liveness unknown)"; +} + function displayJob(job) { const worktreePath = job.worktreeRoot ?? job.request?.cwd ?? null; const sharedRoot = job.workspaceRoot ?? null; @@ -13,6 +47,7 @@ function displayJob(job) { job.summary ? ` ${job.summary}` : null, showWorktree ? ` worktree: ${worktreePath}` : null, job.pid ? ` PID: ${job.pid}` : null, + job.status === "running" ? formatRunningLivenessLine(job) : null, job.errorMessage && job.status !== "incomplete" ? ` Error: ${job.errorMessage}` : null, job.status === "incomplete" ? ` ⚠ stopped early (stopReason: ${job.stopReason ?? "unknown"}) — verify diff` diff --git a/tests/render.test.mjs b/tests/render.test.mjs index 137f0acea..7f38ed702 100644 --- a/tests/render.test.mjs +++ b/tests/render.test.mjs @@ -1,12 +1,28 @@ import test from "node:test"; import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import process from "node:process"; import { renderReviewResult, renderStoredJobResult } from "../plugins/codex/scripts/lib/render.mjs"; +import { isProcessAlive } from "../plugins/grok/scripts/lib/process.mjs"; import { renderJobResult as renderGrokJobResult, renderStatus as renderGrokStatus } from "../plugins/grok/scripts/lib/render.mjs"; +function withRunningLiveness(job) { + if (job.status !== "running") { + return job; + } + if (typeof job.grokPid !== "number" || !Number.isFinite(job.grokPid)) { + return { ...job, liveness: "unknown" }; + } + return { + ...job, + liveness: isProcessAlive(job.grokPid) ? "alive" : "gone" + }; +} + test("renderReviewResult degrades gracefully when JSON is missing required review fields", () => { const output = renderReviewResult( { @@ -97,3 +113,61 @@ test("an old-shaped Grok job record renders without new terminal evidence fields }) ); }); + +test("running Grok job with live grokPid renders pid alive", () => { + const job = withRunningLiveness({ + id: "grok-live", + kind: "task", + status: "running", + grokPid: process.pid, + startedAt: new Date(Date.now() - 12_000).toISOString() + }); + const output = renderGrokStatus([job]); + assert.match(output, /running \(pid alive, \d+s elapsed\)/); +}); + +test("running Grok job with exited grokPid renders pid-gone warning", () => { + const child = spawnSync(process.execPath, ["-e", "process.exit(0)"], { + encoding: "utf8" + }); + assert.ok(Number.isFinite(child.pid)); + assert.equal(isProcessAlive(child.pid), false); + + const job = withRunningLiveness({ + id: "grok-dead", + kind: "task", + status: "running", + grokPid: child.pid, + startedAt: new Date(Date.now() - 30_000).toISOString() + }); + const output = renderGrokStatus([job]); + assert.match( + output, + /⚠ running but pid gone — likely dead or externally cancelled; check \/grok:result/ + ); +}); + +test("running Grok job without grokPid renders liveness unknown", () => { + const job = withRunningLiveness({ + id: "grok-unknown", + kind: "task", + status: "running", + grokPid: null, + startedAt: new Date().toISOString() + }); + const output = renderGrokStatus([job]); + assert.match(output, /running \(liveness unknown\)/); +}); + +test("displayJob stays pure for old running records missing liveness fields", () => { + assert.doesNotThrow(() => { + const output = renderGrokStatus([ + { + id: "grok-legacy-running", + kind: "task", + status: "running" + } + ]); + assert.match(output, /running \(liveness unknown\)/); + }); +}); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index f05996525..5551b2a99 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -1271,6 +1271,104 @@ test("status shows phases, hints, and the latest finished job", () => { assert.match(result.stdout, /Resume in Codex: codex resume thr_done/); }); +test("status surfaces log-mtime activity and advisory for stale running jobs", async () => { + const { enrichJob } = await import("../plugins/codex/scripts/lib/job-control.mjs"); + const workspace = makeTempDir(); + const logFile = path.join(workspace, "stale.log"); + fs.writeFileSync(logFile, "[2026-03-18T15:00:00.000Z] still working\n", "utf8"); + + const staleMs = Date.now() - 10 * 60 * 1000; + fs.utimesSync(logFile, new Date(staleMs), new Date(staleMs)); + + const startedAt = new Date(Date.now() - 15 * 60 * 1000).toISOString(); + const enriched = enrichJob({ + id: "task-stale", + kind: "task", + status: "running", + jobClass: "task", + logFile, + startedAt, + createdAt: startedAt + }); + + assert.ok(enriched.lastActivityAt); + assert.ok(enriched.inactiveFor); + const activityMs = Date.parse(enriched.lastActivityAt); + assert.ok(Number.isFinite(activityMs)); + assert.ok(Date.now() - activityMs >= 5 * 60 * 1000); + + const stateDir = resolveStateDir(workspace); + const jobsDir = path.join(stateDir, "jobs"); + fs.mkdirSync(jobsDir, { recursive: true }); + const statusLog = path.join(jobsDir, "task-stale.log"); + fs.copyFileSync(logFile, statusLog); + fs.utimesSync(statusLog, new Date(staleMs), new Date(staleMs)); + + fs.writeFileSync( + path.join(stateDir, "state.json"), + `${JSON.stringify( + { + version: 1, + config: { stopReviewGate: false }, + jobs: [ + { + id: "task-stale", + kind: "task", + kindLabel: "rescue", + status: "running", + title: "Codex Rescue", + jobClass: "task", + phase: "running", + summary: "Long running task", + logFile: statusLog, + startedAt, + createdAt: startedAt, + updatedAt: startedAt + } + ] + }, + null, + 2 + )}\n`, + "utf8" + ); + + const result = run("node", [SCRIPT, "status"], { cwd: workspace }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /last activity /); + assert.match( + result.stdout, + /no new activity for .+ — job may be stalled or mid long tool call/ + ); +}); + +test("enrichJob falls back to startedAt when logFile is missing without throwing", async () => { + const { enrichJob } = await import("../plugins/codex/scripts/lib/job-control.mjs"); + const startedAt = "2026-03-18T15:00:00.000Z"; + const enriched = enrichJob({ + id: "task-nolog", + kind: "task", + status: "running", + jobClass: "task", + logFile: path.join(makeTempDir(), "does-not-exist.log"), + startedAt, + createdAt: "2026-03-18T14:59:00.000Z" + }); + + assert.equal(enriched.lastActivityAt, startedAt); + assert.ok(enriched.inactiveFor); + assert.doesNotThrow(() => + enrichJob({ + id: "task-nopath", + kind: "task", + status: "running", + jobClass: "task", + startedAt, + createdAt: startedAt + }) + ); +}); + test("status without a job id only shows jobs from the current Claude session", () => { const workspace = makeTempDir(); const stateDir = resolveStateDir(workspace); From 4c5852156baba0aab938d50ae029df48e81c3a50 Mon Sep 17 00:00:00 2001 From: Reederey Date: Sun, 12 Jul 2026 06:54:45 -0400 Subject: [PATCH 4/9] test(codex): extend model-fallback 400 coverage to task and adversarial paths The CLAUDE_PLUGIN_OPTION_FALLBACK_MODEL retry was only pinned for the review path. Cover the task and adversarial-review paths via a turnStartCount counter in the fake codex fixture, plus the fallback-also-rejected case (exactly one retry, both errors surfaced) and the same-model guard (no retry). Co-Authored-By: Claude Fable 5 --- tests/fake-codex-fixture.mjs | 48 +++++++---- tests/runtime.test.mjs | 150 +++++++++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+), 14 deletions(-) diff --git a/tests/fake-codex-fixture.mjs b/tests/fake-codex-fixture.mjs index 466e1399a..9e66ec195 100644 --- a/tests/fake-codex-fixture.mjs +++ b/tests/fake-codex-fixture.mjs @@ -17,6 +17,7 @@ const readline = require("node:readline"); const BEHAVIOR = ${JSON.stringify(behavior)}; const interruptibleTurns = new Map(); let reviewStartCount = 0; + let turnStartCount = 0; function loadState() { if (!fs.existsSync(STATE_PATH)) { @@ -175,6 +176,30 @@ function emitTurnCompleted(threadId, turnId, item) { send({ method: "turn/completed", params: { threadId, turn: buildTurn(turnId, "completed") } }); } +function emitModelUnsupportedTurn(threadId, turnId, model) { + const modelLabel = model || "gpt-5.6"; + send({ method: "turn/started", params: { threadId, turn: buildTurn(turnId) } }); + send({ + method: "error", + params: { + threadId, + turnId, + error: { + type: "invalid_request_error", + message: "The '" + modelLabel + "' model is not supported when using Codex with a ChatGPT account." + } + } + }); + send({ method: "turn/completed", params: { threadId, turn: buildTurn(turnId, "failed") } }); +} + +function shouldRejectModelOnAttempt(attempt) { + if (BEHAVIOR === "model-error-always") { + return true; + } + return BEHAVIOR === "review-model-error" && attempt === 1; +} + function emitTurnCompletedLater(threadId, turnId, item, delayMs) { setTimeout(() => { emitTurnCompleted(threadId, turnId, item); @@ -447,20 +472,8 @@ rl.on("line", (line) => { state.reviewStartCount = reviewStartCount; saveState(state); - if (BEHAVIOR === "review-model-error" && reviewStartCount === 1) { - send({ method: "turn/started", params: { threadId: reviewThread.id, turn: buildTurn(turnId) } }); - send({ - method: "error", - params: { - threadId: reviewThread.id, - turnId, - error: { - type: "invalid_request_error", - message: "The 'gpt-5.6' model is not supported when using Codex with a ChatGPT account." - } - } - }); - send({ method: "turn/completed", params: { threadId: reviewThread.id, turn: buildTurn(turnId, "failed") } }); + if (shouldRejectModelOnAttempt(reviewStartCount)) { + emitModelUnsupportedTurn(reviewThread.id, turnId, message.params.model); break; } @@ -495,6 +508,8 @@ rl.on("line", (line) => { .join("\\n"); const turnId = nextTurnId(state); thread.updatedAt = now(); + turnStartCount += 1; + state.turnStartCount = turnStartCount; state.lastTurnStart = { threadId: message.params.threadId, turnId, @@ -505,6 +520,11 @@ rl.on("line", (line) => { saveState(state); send({ id: message.id, result: { turn: buildTurn(turnId) } }); + if (shouldRejectModelOnAttempt(turnStartCount)) { + emitModelUnsupportedTurn(thread.id, turnId, message.params.model); + break; + } + const payload = message.params.outputSchema && message.params.outputSchema.properties && message.params.outputSchema.properties.verdict ? structuredReviewPayload(prompt) : taskPayload(prompt, thread.name && thread.name.startsWith("Codex Companion Task") && prompt.includes("Continue from the current thread state")); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 5551b2a99..9b550376f 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -456,6 +456,156 @@ test("review retries once with the fallback model when Codex rejects the primary assert.equal(fakeState.reviewStartCount, 2, "exactly one retry should have called review/start a second time"); }); +test("task surfaces the underlying Codex error when no fallback model is configured", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "review-model-error"); + 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); + delete env.CLAUDE_PLUGIN_OPTION_FALLBACK_MODEL; + + const result = run("node", [SCRIPT, "task", "check failed model"], { + cwd: repo, + env + }); + + assert.notEqual(result.status, 0); + assert.match(result.stdout, /The 'gpt-5\.6' model is not supported when using Codex with a ChatGPT account\./); + + const fakeState = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")); + assert.equal(fakeState.turnStartCount, 1, "no retry should happen without a configured fallback model"); +}); + +test("task retries once with the fallback model when Codex rejects the primary model", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "review-model-error"); + 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 result = run("node", [SCRIPT, "task", "check model fallback"], { + cwd: repo, + env: { + ...buildEnv(binDir), + CLAUDE_PLUGIN_OPTION_FALLBACK_MODEL: "gpt-5.5" + } + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Handled the requested task/); + assert.match(result.stdout, /fallback model `gpt-5\.5`/); + + const fakeState = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")); + assert.equal(fakeState.turnStartCount, 2, "exactly one retry should have called turn/start a second time"); +}); + +test("adversarial-review surfaces the underlying Codex error when no fallback model is configured", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "review-model-error"); + initGitRepo(repo); + fs.mkdirSync(path.join(repo, "src")); + 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"); + + const env = buildEnv(binDir); + delete env.CLAUDE_PLUGIN_OPTION_FALLBACK_MODEL; + + const result = run("node", [SCRIPT, "adversarial-review"], { + cwd: repo, + env + }); + + assert.notEqual(result.status, 0); + assert.match(result.stdout, /The 'gpt-5\.6' model is not supported when using Codex with a ChatGPT account\./); + + const fakeState = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")); + assert.equal(fakeState.turnStartCount, 1, "no retry should happen without a configured fallback model"); +}); + +test("adversarial-review retries once with the fallback model when Codex rejects the primary model", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "review-model-error"); + initGitRepo(repo); + fs.mkdirSync(path.join(repo, "src")); + 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"); + + const result = run("node", [SCRIPT, "adversarial-review"], { + cwd: repo, + env: { + ...buildEnv(binDir), + CLAUDE_PLUGIN_OPTION_FALLBACK_MODEL: "gpt-5.5" + } + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Missing empty-state guard/); + assert.match(result.stdout, /fallback model `gpt-5\.5`/); + + const fakeState = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")); + assert.equal(fakeState.turnStartCount, 2, "exactly one retry should have called turn/start a second time"); +}); + +test("model fallback retries only once when the fallback model is also rejected", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "model-error-always"); + 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 result = run("node", [SCRIPT, "task", "check double model rejection"], { + cwd: repo, + env: { + ...buildEnv(binDir), + CLAUDE_PLUGIN_OPTION_FALLBACK_MODEL: "gpt-5.5" + } + }); + + assert.notEqual(result.status, 0); + assert.match(result.stdout, /model is not supported when using Codex with a ChatGPT account\./); + + const fakeState = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")); + assert.equal(fakeState.turnStartCount, 2, "exactly one retry should run even when the fallback model is also rejected"); +}); + +test("model fallback does not retry when the fallback model matches the requested model", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "review-model-error"); + 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 result = run("node", [SCRIPT, "task", "--model", "gpt-5.5", "check same-model guard"], { + cwd: repo, + env: { + ...buildEnv(binDir), + CLAUDE_PLUGIN_OPTION_FALLBACK_MODEL: "gpt-5.5" + } + }); + + assert.notEqual(result.status, 0); + assert.match(result.stdout, /The 'gpt-5\.5' model is not supported when using Codex with a ChatGPT account\./); + + const fakeState = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")); + assert.equal(fakeState.turnStartCount, 1, "same-model fallback guard should prevent a retry"); +}); + test("review accepts the quoted raw argument style for built-in base-branch review", () => { const repo = makeTempDir(); const binDir = makeTempDir(); From 7f7016ada68dc95b884dabcf76f967f40fb46f66 Mon Sep 17 00:00:00 2001 From: Reederey Date: Sun, 12 Jul 2026 06:56:56 -0400 Subject: [PATCH 5/9] fix(grok): replace contradictory empty status output, pin codex state-key stability Review follow-ups from the shared-root state change: when every job is hidden by the session filter, /grok:status no longer prints "No jobs recorded" and a hidden-jobs count together; and a test now pins that plain (non-worktree) repos keep the exact pre-change codex state dir key, which is what makes the legacy fallback a true no-op for them. Co-Authored-By: Claude Fable 5 --- plugins/grok/scripts/grok-companion.mjs | 5 ++++- tests/state.test.mjs | 27 +++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/plugins/grok/scripts/grok-companion.mjs b/plugins/grok/scripts/grok-companion.mjs index 52b155f90..1ab6563d2 100644 --- a/plugins/grok/scripts/grok-companion.mjs +++ b/plugins/grok/scripts/grok-companion.mjs @@ -637,7 +637,10 @@ function handleStatus(argv) { if (!options.all) { const hiddenCount = allJobs.length - payload.length; if (hiddenCount > 0) { - rendered = `${rendered.trimEnd()}\n\n${hiddenCount} other job(s) from other sessions/worktrees — run /grok:status --all\n`; + const note = `${hiddenCount} other job(s) from other sessions/worktrees — run /grok:status --all`; + rendered = payload.length === 0 + ? `# Grok Status\n\n${note}\n` + : `${rendered.trimEnd()}\n\n${note}\n`; } } output(rendered, false); diff --git a/tests/state.test.mjs b/tests/state.test.mjs index 6b10e7ed0..4d7bdc133 100644 --- a/tests/state.test.mjs +++ b/tests/state.test.mjs @@ -25,6 +25,7 @@ import { writeJob } from "../plugins/grok/scripts/lib/state.mjs"; import { resolveWorkspace } from "../plugins/grok/scripts/lib/workspace.mjs"; +import { ensureGitRepository } from "../plugins/codex/scripts/lib/git.mjs"; delete process.env.CLAUDE_PLUGIN_DATA; delete process.env.CODEX_COMPANION_SESSION_ID; @@ -360,3 +361,29 @@ test("non-worktree repos keep the same grok jobs dir key as before (shared === t } } }); + +test("non-worktree repos keep the same codex state dir key as before (shared === toplevel)", () => { + const pluginData = makeTempDir("codex-key-stable-"); + const previous = process.env.CLAUDE_PLUGIN_DATA; + process.env.CLAUDE_PLUGIN_DATA = pluginData; + const cwd = makeTempDir("plain-repo-"); + initGitRepo(cwd); + fs.writeFileSync(path.join(cwd, "a.js"), "1\n"); + run("git", ["add", "a.js"], { cwd }); + run("git", ["commit", "-m", "init"], { cwd }); + + try { + const toplevel = ensureGitRepository(cwd); + const canonical = fs.realpathSync.native(toplevel); + const slug = path.basename(toplevel).replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "workspace"; + const hash = createHash("sha256").update(canonical).digest("hex").slice(0, 16); + const expectedLegacy = path.join(pluginData, "state", `${slug}-${hash}`); + assert.equal(resolveStateDir(cwd), expectedLegacy); + } finally { + if (previous == null) { + delete process.env.CLAUDE_PLUGIN_DATA; + } else { + process.env.CLAUDE_PLUGIN_DATA = previous; + } + } +}); From 84c5895599018551b2097585b68ca073bc71162f Mon Sep 17 00:00:00 2001 From: Reederey Date: Sun, 12 Jul 2026 07:06:45 -0400 Subject: [PATCH 6/9] feat(agents): background-default grok writes, scope rules, verbatim failure output, codex write summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Encode the delegation conventions the plugins previously relied on callers to remember: grok --write tasks default to background execution (foreground writes die with a misleading Cancelled stop when the parent call is cut, and approval rules are unchanged — no silent backgrounding without --always-approve); both rescue agents carry scope rules (report only, commit nothing, no stage/reset/checkout, one writer per worktree, --cwd forwarding for foreign checkouts); and the forwarder failure contract returns companion stdout/stderr verbatim on non-zero exit instead of returning nothing, so incomplete-job warnings reach the caller. Codex write tasks now capture a grok-style git before/after write summary as independent evidence of what actually changed, rendered after the final output. Co-Authored-By: Claude Fable 5 --- plugins/codex/agents/codex-rescue.md | 9 +++- plugins/codex/scripts/codex-companion.mjs | 33 ++++++++++--- plugins/codex/scripts/lib/git.mjs | 60 +++++++++++++++++++++++ plugins/codex/scripts/lib/render.mjs | 31 ++++++++++-- plugins/grok/agents/grok-rescue.md | 14 ++++-- plugins/grok/commands/rescue.md | 3 +- tests/commands.test.mjs | 7 ++- tests/fake-codex-fixture.mjs | 10 ++++ tests/grok-commands.test.mjs | 10 ++++ tests/runtime.test.mjs | 60 ++++++++++++++++++++++- 10 files changed, 221 insertions(+), 16 deletions(-) diff --git a/plugins/codex/agents/codex-rescue.md b/plugins/codex/agents/codex-rescue.md index 7009ec86a..6de537f9c 100644 --- a/plugins/codex/agents/codex-rescue.md +++ b/plugins/codex/agents/codex-rescue.md @@ -39,7 +39,14 @@ Forwarding rules: - Otherwise forward the task as a fresh `task` run. - Preserve the user's task text as-is apart from stripping routing flags. - Return the stdout of the `codex-companion` command exactly as-is. -- If the Bash call fails or Codex cannot be invoked, return nothing. +- Always return the companion's stdout and stderr verbatim, including when the command exits non-zero (a non-zero exit may be an "incomplete" job whose warning must reach the caller). Return nothing only when the companion produced no output at all. + +Scope rules: + +- Report only; commit nothing. +- Do not stage, reset, or checkout. +- One writer per worktree. +- If the task text names a checkout/worktree path different from the invocation directory, forward it with `--cwd ` so the work lands in that checkout instead of the invocation directory. Response style: diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index bbdc33500..f2521e8af 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -28,7 +28,13 @@ import { } from "./lib/codex.mjs"; import { resolveClaudeSessionPath } from "./lib/claude-session-transfer.mjs"; import { readStdinIfPiped } from "./lib/fs.mjs"; -import { collectReviewContext, ensureGitRepository, resolveReviewTarget } from "./lib/git.mjs"; +import { + captureWriteGitStatus, + collectReviewContext, + ensureGitRepository, + resolveReviewTarget, + summarizeWriteChanges +} from "./lib/git.mjs"; import { binaryAvailable, terminateProcessTree } from "./lib/process.mjs"; import { loadPromptTemplate, interpolateTemplate } from "./lib/prompts.mjs"; import { @@ -553,13 +559,17 @@ async function executeTaskRun(request) { throw new Error("Provide a prompt, a prompt file, piped stdin, or use --resume-last."); } + const writeEnabled = Boolean(request.write); + const gitStatusCwd = request.cwd ?? workspaceRoot; + const beforeGitStatus = writeEnabled ? captureWriteGitStatus(gitStatusCwd) : null; + const taskTurnOptions = { resumeThreadId, prompt: request.prompt, defaultPrompt: resumeThreadId ? DEFAULT_CONTINUE_PROMPT : "", model: request.model, effort: request.effort, - sandbox: request.write ? "workspace-write" : "read-only", + sandbox: writeEnabled ? "workspace-write" : "read-only", onProgress: request.onProgress, persistThread: true, threadName: resumeThreadId ? null : buildPersistentTaskThreadName(request.prompt || DEFAULT_CONTINUE_PROMPT) @@ -573,6 +583,14 @@ async function executeTaskRun(request) { fallbackModelUsed = fallbackModel; } + let writeSummary = null; + if (beforeGitStatus) { + const afterGitStatus = captureWriteGitStatus(gitStatusCwd); + if (afterGitStatus) { + writeSummary = summarizeWriteChanges(beforeGitStatus, afterGitStatus); + } + } + const rawOutput = typeof result.finalMessage === "string" ? result.finalMessage : ""; const failureMessage = result.error?.message ?? result.stderr ?? ""; const rendered = renderTaskResult( @@ -584,8 +602,9 @@ async function executeTaskRun(request) { { title: taskMetadata.title, jobId: request.jobId ?? null, - write: Boolean(request.write), - fallbackModel: fallbackModelUsed + write: writeEnabled, + fallbackModel: fallbackModelUsed, + writeSummary } ); const payload = { @@ -595,7 +614,8 @@ async function executeTaskRun(request) { error: result.error?.message ?? null, fallbackModel: fallbackModelUsed, touchedFiles: result.touchedFiles, - reasoningSummary: result.reasoningSummary + reasoningSummary: result.reasoningSummary, + writeSummary }; return { @@ -607,7 +627,8 @@ async function executeTaskRun(request) { summary: firstMeaningfulLine(rawOutput, firstMeaningfulLine(failureMessage, `${taskMetadata.title} finished.`)), jobTitle: taskMetadata.title, jobClass: "task", - write: Boolean(request.write) + write: writeEnabled, + writeSummary }; } diff --git a/plugins/codex/scripts/lib/git.mjs b/plugins/codex/scripts/lib/git.mjs index 3092dba13..33afa512b 100644 --- a/plugins/codex/scripts/lib/git.mjs +++ b/plugins/codex/scripts/lib/git.mjs @@ -179,6 +179,66 @@ export function getWorkingTreeState(cwd) { }; } +/** + * Capture paths that differ from HEAD plus untracked files. + * Returns null when cwd is not a Git work tree (write-summary callers skip silently). + * @param {string} cwd + * @returns {Set | null} + */ +export function captureWriteGitStatus(cwd) { + const inside = git(cwd, ["rev-parse", "--is-inside-work-tree"]); + if (inside.error || inside.status !== 0 || inside.stdout.trim() !== "true") { + return null; + } + + const paths = new Set(); + const hasHead = git(cwd, ["rev-parse", "--verify", "HEAD"]); + if (hasHead.status === 0) { + const diff = git(cwd, ["diff", "--name-only", "HEAD", "-z"]); + if (diff.error) { + throw diff.error; + } + if (diff.status !== 0) { + throw new Error(diff.stderr.trim() || "Unable to capture Git diff for this write task."); + } + for (const entry of String(diff.stdout ?? "").split("\0")) { + if (entry) { + paths.add(entry); + } + } + } + + const untracked = git(cwd, ["ls-files", "--others", "--exclude-standard", "-z"]); + if (untracked.error) { + throw untracked.error; + } + if (untracked.status !== 0) { + throw new Error(untracked.stderr.trim() || "Unable to capture untracked files for this write task."); + } + for (const entry of String(untracked.stdout ?? "").split("\0")) { + if (entry) { + paths.add(entry); + } + } + return paths; +} + +/** + * @param {Set} before + * @param {Set} after + */ +export function summarizeWriteChanges(before, after) { + const changedFiles = [ + ...[...after].filter((filePath) => !before.has(filePath)), + ...[...before].filter((filePath) => !after.has(filePath)) + ].sort(); + return { + before: [...before].sort(), + after: [...after].sort(), + changedFiles + }; +} + export function resolveReviewTarget(cwd, options = {}) { ensureGitRepository(cwd); diff --git a/plugins/codex/scripts/lib/render.mjs b/plugins/codex/scripts/lib/render.mjs index 27423bda6..efcec9bbb 100644 --- a/plugins/codex/scripts/lib/render.mjs +++ b/plugins/codex/scripts/lib/render.mjs @@ -382,19 +382,40 @@ export function renderNativeReviewResult(result, meta) { return `${lines.join("\n").trimEnd()}\n`; } +export function renderWriteSummary(writeSummary) { + return [ + "Write summary:", + ...(writeSummary.changedFiles.length > 0 + ? writeSummary.changedFiles.map((filePath) => `- ${filePath}`) + : ["- No Git status changes detected (no edits landed)."]) + ]; +} + export function renderTaskResult(parsedResult, meta) { const rawOutput = typeof parsedResult?.rawOutput === "string" ? parsedResult.rawOutput : ""; const fallbackNote = meta?.fallbackModel ? `\nNote: retried with fallback model \`${meta.fallbackModel}\` after the primary model was rejected.\n` : ""; + const writeSummaryLines = + meta?.writeSummary && typeof meta.writeSummary === "object" + ? ["", ...renderWriteSummary(meta.writeSummary)] + : []; if (rawOutput) { const output = rawOutput.endsWith("\n") ? rawOutput : `${rawOutput}\n`; - return `${output}${fallbackNote}`; + const withSummary = + writeSummaryLines.length > 0 + ? `${output.trimEnd()}\n${writeSummaryLines.join("\n")}\n` + : output; + return `${withSummary}${fallbackNote}`; } const message = String(parsedResult?.failureMessage ?? "").trim() || "Codex did not return a final message."; - return `${message}\n${fallbackNote}`; + const withSummary = + writeSummaryLines.length > 0 + ? `${message}\n${writeSummaryLines.join("\n")}\n` + : `${message}\n`; + return `${withSummary}${fallbackNote}`; } export function renderStatusReport(report) { @@ -478,7 +499,11 @@ export function renderStoredJobResult(job, storedJob) { (typeof storedJob?.result?.codex?.stdout === "string" && storedJob.result.codex.stdout) || ""; if (rawOutput) { - const output = rawOutput.endsWith("\n") ? rawOutput : `${rawOutput}\n`; + let output = rawOutput.endsWith("\n") ? rawOutput : `${rawOutput}\n`; + const writeSummary = storedJob?.result?.writeSummary ?? storedJob?.writeSummary ?? null; + if (writeSummary && typeof writeSummary === "object") { + output = `${output.trimEnd()}\n\n${renderWriteSummary(writeSummary).join("\n")}\n`; + } if (!threadId) { return output; } diff --git a/plugins/grok/agents/grok-rescue.md b/plugins/grok/agents/grok-rescue.md index c4f5cb99f..85487d65e 100644 --- a/plugins/grok/agents/grok-rescue.md +++ b/plugins/grok/agents/grok-rescue.md @@ -12,8 +12,9 @@ Your only job is to forward the user's rescue request to the Grok companion scri Forwarding rules: - Use exactly one `Bash` call to invoke `node "${CLAUDE_PLUGIN_ROOT}/scripts/grok-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 Grok running for a long time, prefer background execution. +- If the user did not explicitly choose `--background` or `--wait`, prefer foreground for a small, clearly bounded read-only rescue request. +- If the user did not explicitly choose `--background` or `--wait` and the task is a `--write` task, prefer background execution (foreground write jobs are killed with a misleading Cancelled stop when the parent Bash call or turn is cut). A background write still requires explicit `--always-approve` or `--yolo` — if the user asked for `--write` without an approval flag, do not silently background it (reject or stay foreground per the approval rules below). +- If the user did not explicitly choose `--background` or `--wait` and a read-only task looks complicated, open-ended, multi-step, or likely to keep Grok running for a long time, prefer background execution. - `--background` and `--wait` are Claude-side execution controls. Strip them before the one companion `task` call. - Treat `--model` as a runtime control and do not include it in the task text. Leave model unset unless the user explicitly asks for one, otherwise pass it through with `--model`. - Treat `--effort`, `--max-turns`, and `--json-schema` as runtime controls and do not include them in the task text. Leave each unset unless the user explicitly supplies it, otherwise pass it through verbatim with the same flag. @@ -26,7 +27,14 @@ Forwarding rules: - Do not inspect the repository, read files, grep, monitor progress, poll status, fetch results, summarize output, or do any follow-up work of your own. - Do not call `setup`, `ask`, `review`, `status`, or `result`. This subagent only forwards to `task`. - Return the stdout of the `grok-companion` command exactly as-is. -- If the Bash call fails or Grok cannot be invoked, return nothing. +- Always return the companion's stdout and stderr verbatim, including when the command exits non-zero (a non-zero exit may be an "incomplete" job whose warning must reach the caller). Return nothing only when the companion produced no output at all. + +Scope rules: + +- Report only; commit nothing. +- Do not stage, reset, or checkout. +- One writer per worktree. +- If the task text names a checkout/worktree path different from the invocation directory, forward it with `--cwd ` so the work lands in that checkout instead of the invocation directory. Response style: diff --git a/plugins/grok/commands/rescue.md b/plugins/grok/commands/rescue.md index c2637c348..3f5e55ef6 100644 --- a/plugins/grok/commands/rescue.md +++ b/plugins/grok/commands/rescue.md @@ -15,7 +15,7 @@ Execution mode: - If the request includes `--background`, run the `grok:grok-rescue` subagent in the background. - If the request includes `--wait`, run the `grok:grok-rescue` subagent in the foreground. -- If neither flag is present, default to foreground. +- If neither flag is present, default to foreground for read-only tasks and to background for `--write` tasks (foreground write jobs die with a misleading Cancelled stop when the parent call is cut). - `--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. - `--model`, `--effort`, `--max-turns`, and `--json-schema` are runtime-selection flags. Preserve them for the forwarded `task` call, but do not treat them as part of the natural-language task text. - `--resume` and `--fresh` are routing controls. Preserve them for the forwarded `task` call, but do not treat them as part of the natural-language task text. @@ -44,6 +44,7 @@ Write safety: - Forward `--write` when the user explicitly supplied it, even when the task wording is ambiguous. - Forward `--always-approve` only when the user explicitly supplied it. Treat `--yolo` as its alias. - A background write task requires explicit `--always-approve` or `--yolo`. Do not start it without that approval. +- A job may finish with status incomplete — verify the write summary / git diff before trusting the narrative. - If the user did not supply a request, ask what Grok should investigate or fix. Operating rules: diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs index 1249af03f..444473f4f 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -138,7 +138,12 @@ test("rescue command absorbs continue semantics", () => { assert.match(agent, /If the user asks for `spark`, map that to `--model gpt-5\.3-codex-spark`/i); assert.match(agent, /If the user asks for a concrete model name such as `gpt-5\.4-mini`, pass it through with `--model`/i); assert.match(agent, /Return the stdout of the `codex-companion` command exactly as-is/i); - assert.match(agent, /If the Bash call fails or Codex cannot be invoked, return nothing/i); + assert.match(agent, /Always return the companion's stdout and stderr verbatim, including when the command exits non-zero/i); + assert.match(agent, /Return nothing only when the companion produced no output at all/i); + assert.match(agent, /Report only; commit nothing/i); + assert.match(agent, /Do not stage, reset, or checkout/i); + assert.match(agent, /One writer per worktree/i); + assert.match(agent, /--cwd /i); assert.match(agent, /gpt-5-4-prompting/); assert.match(agent, /only to tighten the user's request into a better Codex prompt/i); assert.match(agent, /Do not use that skill to inspect the repository, reason through the problem yourself, draft a solution, or do any independent work/i); diff --git a/tests/fake-codex-fixture.mjs b/tests/fake-codex-fixture.mjs index 9e66ec195..d2db2ae81 100644 --- a/tests/fake-codex-fixture.mjs +++ b/tests/fake-codex-fixture.mjs @@ -273,6 +273,14 @@ function taskPayload(prompt, resume) { return "Handled the requested task.\\nTask prompt accepted."; } +function maybeWriteFixtureFile(cwd) { + if (BEHAVIOR !== "write-file") { + return; + } + const target = path.join(cwd || process.cwd(), "codex-write.txt"); + fs.writeFileSync(target, "written by fake codex\\n", "utf8"); +} + const args = process.argv.slice(2); if (args[0] === "--version") { console.log("codex-cli test"); @@ -525,6 +533,8 @@ rl.on("line", (line) => { break; } + maybeWriteFixtureFile(thread.cwd); + const payload = message.params.outputSchema && message.params.outputSchema.properties && message.params.outputSchema.properties.verdict ? structuredReviewPayload(prompt) : taskPayload(prompt, thread.name && thread.name.startsWith("Codex Companion Task") && prompt.includes("Continue from the current thread state")); diff --git a/tests/grok-commands.test.mjs b/tests/grok-commands.test.mjs index 2b34c432d..d00d61eb7 100644 --- a/tests/grok-commands.test.mjs +++ b/tests/grok-commands.test.mjs @@ -59,15 +59,25 @@ test("Grok commands expose rescue forwarding and session hooks", () => { assert.match(rescue, /Continue current Grok task/); assert.match(rescue, /Start a new Grok task/); assert.match(rescue, /Do not forward them to `task`/); + assert.match(rescue, /default to foreground for read-only tasks and to background for `--write` tasks/i); + assert.match(rescue, /status incomplete/i); assert.match(rescue, /bounded implementation request/i); assert.match(rescue, /background write task requires explicit/i); assert.match(agent, /exactly one `Bash` call/i); assert.match(agent, /Default to a write-capable Grok run/i); assert.match(agent, /Keep review, diagnosis, research, and planning requests read-only/i); + assert.match(agent, /prefer background execution \(foreground write jobs are killed with a misleading Cancelled stop/i); + assert.match(agent, /do not silently background it/i); assert.match(agent, /--always-approve/); assert.match(agent, /--yolo/); assert.match(agent, /Do not inspect the repository/i); assert.match(agent, /stdout of the `grok-companion` command exactly as-is/i); + assert.match(agent, /Always return the companion's stdout and stderr verbatim, including when the command exits non-zero/i); + assert.match(agent, /Return nothing only when the companion produced no output at all/i); + assert.match(agent, /Report only; commit nothing/i); + assert.match(agent, /Do not stage, reset, or checkout/i); + assert.match(agent, /One writer per worktree/i); + assert.match(agent, /--cwd /i); assert.match(hooks, /SessionStart/); assert.match(hooks, /SessionEnd/); assert.match(hooks, /session-lifecycle-hook\.mjs/); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 9b550376f..5f1f7c142 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -974,7 +974,65 @@ test("write task output focuses on the Codex result without generic follow-up hi }); assert.equal(result.status, 0, result.stderr); - assert.equal(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, /Write summary:\n- No Git status changes detected \(no edits landed\)\./); + assert.doesNotMatch(result.stdout, /follow-up|next steps|\/codex:status/i); +}); + +test("write task writeSummary lists files created by the fixture", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "write-file"); + 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 jsonResult = run("node", [SCRIPT, "task", "--write", "--json", "apply the fix"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(jsonResult.status, 0, jsonResult.stderr); + const payload = JSON.parse(jsonResult.stdout); + assert.ok(payload.writeSummary); + assert.equal(payload.writeSummary.changedFiles.includes("codex-write.txt"), true); + + // File already exists from the first run; remove it so the second run's before/after + // still shows it as a newly untracked change. + fs.unlinkSync(path.join(repo, "codex-write.txt")); + const rendered = run("node", [SCRIPT, "task", "--write", "apply the fix again"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(rendered.status, 0, rendered.stderr); + assert.match(rendered.stdout, /Write summary:[\s\S]*codex-write\.txt/); +}); + +test("write task with no git status changes reports the empty write summary", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + 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 jsonResult = run("node", [SCRIPT, "task", "--write", "--json", "fix the failing test"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(jsonResult.status, 0, jsonResult.stderr); + const payload = JSON.parse(jsonResult.stdout); + assert.deepEqual(payload.writeSummary.changedFiles, []); + assert.deepEqual(payload.writeSummary.before, []); + assert.deepEqual(payload.writeSummary.after, []); + + const rendered = run("node", [SCRIPT, "task", "--write", "fix the failing test again"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(rendered.status, 0, rendered.stderr); + assert.match(rendered.stdout, /Write summary:\n- No Git status changes detected \(no edits landed\)\./); }); test("task --resume acts like --resume-last without leaking the flag into the prompt", () => { From 2a578fe2bf61f5aa18768732254f0c24633b6af9 Mon Sep 17 00:00:00 2001 From: Reederey Date: Sun, 12 Jul 2026 07:21:41 -0400 Subject: [PATCH 7/9] test: pin log-mtime as the liveness source, exercise real attachRunningLiveness Review follow-ups on the liveness change: the stale-job test now asserts lastActivityAt equals the log mtime (previously the startedAt fallback also satisfied it), and attachRunningLiveness moves into grok lib/state.mjs so the render tests exercise the production enrichment instead of a local copy. Co-Authored-By: Claude Fable 5 --- plugins/grok/scripts/grok-companion.mjs | 14 +------------- plugins/grok/scripts/lib/state.mjs | 17 +++++++++++++++++ tests/render.test.mjs | 14 +------------- tests/runtime.test.mjs | 2 ++ 4 files changed, 21 insertions(+), 26 deletions(-) diff --git a/plugins/grok/scripts/grok-companion.mjs b/plugins/grok/scripts/grok-companion.mjs index 1ab6563d2..2776527ec 100644 --- a/plugins/grok/scripts/grok-companion.mjs +++ b/plugins/grok/scripts/grok-companion.mjs @@ -31,6 +31,7 @@ import { import { acquireWriteLock, appendJobLog, + attachRunningLiveness, compareAndSwapJobState, createJob, listJobs, @@ -595,19 +596,6 @@ async function handleWorker(argv) { await runStoredJob(cwd, job); } -function attachRunningLiveness(job) { - if (!job || job.status !== "running") { - return job; - } - if (typeof job.grokPid !== "number" || !Number.isFinite(job.grokPid)) { - return { ...job, liveness: "unknown" }; - } - return { - ...job, - liveness: isProcessAlive(job.grokPid) ? "alive" : "gone" - }; -} - function handleStatus(argv) { const { options, positionals } = parseCommand(argv, { valueOptions: ["cwd"], diff --git a/plugins/grok/scripts/lib/state.mjs b/plugins/grok/scripts/lib/state.mjs index 30cbe3fa8..ab7533a87 100644 --- a/plugins/grok/scripts/lib/state.mjs +++ b/plugins/grok/scripts/lib/state.mjs @@ -88,6 +88,23 @@ export function readJob(cwd, jobId) { return JSON.parse(fs.readFileSync(filePath, "utf8")); } +/** + * Annotate a running job with pid liveness for status display. + * alive/gone from kill(pid, 0); unknown when no grokPid was recorded. + */ +export function attachRunningLiveness(job) { + if (!job || job.status !== "running") { + return job; + } + if (typeof job.grokPid !== "number" || !Number.isFinite(job.grokPid)) { + return { ...job, liveness: "unknown" }; + } + return { + ...job, + liveness: isProcessAlive(job.grokPid) ? "alive" : "gone" + }; +} + export function isActiveJob(job) { return Boolean(job) && (job.status === "queued" || job.status === "running"); } diff --git a/tests/render.test.mjs b/tests/render.test.mjs index 7f38ed702..e69783d70 100644 --- a/tests/render.test.mjs +++ b/tests/render.test.mjs @@ -9,19 +9,7 @@ import { renderJobResult as renderGrokJobResult, renderStatus as renderGrokStatus } from "../plugins/grok/scripts/lib/render.mjs"; - -function withRunningLiveness(job) { - if (job.status !== "running") { - return job; - } - if (typeof job.grokPid !== "number" || !Number.isFinite(job.grokPid)) { - return { ...job, liveness: "unknown" }; - } - return { - ...job, - liveness: isProcessAlive(job.grokPid) ? "alive" : "gone" - }; -} +import { attachRunningLiveness as withRunningLiveness } from "../plugins/grok/scripts/lib/state.mjs"; test("renderReviewResult degrades gracefully when JSON is missing required review fields", () => { const output = renderReviewResult( diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 5f1f7c142..b74dbde95 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -1504,6 +1504,8 @@ test("status surfaces log-mtime activity and advisory for stale running jobs", a const activityMs = Date.parse(enriched.lastActivityAt); assert.ok(Number.isFinite(activityMs)); assert.ok(Date.now() - activityMs >= 5 * 60 * 1000); + // Pin the source: must be the log mtime, not the startedAt fallback (15m ago). + assert.equal(enriched.lastActivityAt, fs.statSync(logFile).mtime.toISOString()); const stateDir = resolveStateDir(workspace); const jobsDir = path.join(stateDir, "jobs"); From ec89682cebbc3f4be4dd46de75b13b6a16c206dd Mon Sep 17 00:00:00 2001 From: Reederey Date: Sun, 12 Jul 2026 07:23:56 -0400 Subject: [PATCH 8/9] docs(skills): add writable-cache sandbox note to codex prompt recipes Sandboxed workers cannot write default user-level build caches (hit on both codex workspace-write and grok sandboxes with Go's GOCACHE); recipes that build or test code should carry a writable cache override in the task text. The companion guidance updates (stopReason ground truth, incomplete status contract, liveness, worktree-visible status, broker hygiene, delegation-route verification) live in the untracked personal CLAUDE.md. Co-Authored-By: Claude Fable 5 --- .../gpt-5-4-prompting/references/codex-prompt-recipes.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/plugins/codex/skills/gpt-5-4-prompting/references/codex-prompt-recipes.md b/plugins/codex/skills/gpt-5-4-prompting/references/codex-prompt-recipes.md index 7711de201..12f455875 100644 --- a/plugins/codex/skills/gpt-5-4-prompting/references/codex-prompt-recipes.md +++ b/plugins/codex/skills/gpt-5-4-prompting/references/codex-prompt-recipes.md @@ -1,5 +1,11 @@ # Codex Prompt Recipes +Sandbox note for any recipe that builds or tests code: sandboxed runs often cannot write +default user-level build caches. Include a writable cache override in the task text when the +toolchain needs one (Go: `use GOCACHE=/tmp/-gocache`; similar for other toolchains' +cache/config dirs). Symptom of the miss: permission errors on the first build, wasted turns +on retries, or a job that dies mid-retry. + Use these as starting templates for Codex task prompts or other Codex/GPT-5.4 prompt construction. Copy the smallest recipe that fits the task, then trim anything you do not need. In `codex:codex-rescue`, run diagnosis and fix-oriented recipes in write mode by default unless the user explicitly asked for read-only behavior. From fef51cc1175a569f1829633a928afab4f149ae06 Mon Sep 17 00:00:00 2001 From: Reederey Date: Sun, 12 Jul 2026 07:31:45 -0400 Subject: [PATCH 9/9] chore(release): codex 1.0.9, grok 0.3.0 with changelogs Co-Authored-By: Claude Fable 5 --- .claude-plugin/marketplace.json | 6 +++--- package-lock.json | 4 ++-- package.json | 2 +- plugins/codex/.claude-plugin/plugin.json | 2 +- plugins/codex/CHANGELOG.md | 8 ++++++++ plugins/grok/.claude-plugin/plugin.json | 2 +- plugins/grok/CHANGELOG.md | 14 ++++++++++++++ 7 files changed, 30 insertions(+), 8 deletions(-) create mode 100644 plugins/grok/CHANGELOG.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 9c219240d..af72c9a23 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -5,13 +5,13 @@ }, "metadata": { "description": "Claude Code plugins that delegate to local coding CLIs.", - "version": "1.0.8" + "version": "1.0.9" }, "plugins": [ { "name": "codex", "description": "Use Codex from Claude Code to review code or delegate tasks.", - "version": "1.0.8", + "version": "1.0.9", "author": { "name": "OpenAI" }, @@ -20,7 +20,7 @@ { "name": "grok", "description": "Use a local Grok Build CLI for read-only questions, reviews, and rescue tasks.", - "version": "0.2.0", + "version": "0.3.0", "author": { "name": "External CLI Companions" }, diff --git a/package-lock.json b/package-lock.json index feabd258a..0684648a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@openai/codex-plugin-cc", - "version": "1.0.8", + "version": "1.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@openai/codex-plugin-cc", - "version": "1.0.8", + "version": "1.0.9", "license": "Apache-2.0", "devDependencies": { "@types/node": "^25.5.0", diff --git a/package.json b/package.json index d199e0ec1..797d45cea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@openai/codex-plugin-cc", - "version": "1.0.8", + "version": "1.0.9", "private": true, "type": "module", "description": "Use Codex from Claude Code to review code or delegate tasks.", diff --git a/plugins/codex/.claude-plugin/plugin.json b/plugins/codex/.claude-plugin/plugin.json index 0c0ff22df..040a087bd 100644 --- a/plugins/codex/.claude-plugin/plugin.json +++ b/plugins/codex/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codex", - "version": "1.0.8", + "version": "1.0.9", "description": "Use Codex from Claude Code to review code or delegate tasks.", "author": { "name": "OpenAI" diff --git a/plugins/codex/CHANGELOG.md b/plugins/codex/CHANGELOG.md index 580da4132..be8f5b476 100644 --- a/plugins/codex/CHANGELOG.md +++ b/plugins/codex/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 1.0.9 + +- Job state is keyed by the shared main-repo root, so jobs launched from linked git worktrees are visible to `/codex:status`, `/codex:result`, and `/codex:cancel` from any checkout. Legacy worktree-keyed state is read-merged without being modified; plain (non-worktree) repos keep their exact previous state key. +- `/codex:status` shows "last activity Xm ago" for running jobs (derived from the job log) and adds an advisory once a running job has produced no new events for 5+ minutes, replacing blind periodic polling. +- Write-enabled task runs capture a git before/after write summary (changed files, or an explicit "no edits landed" note) rendered after the final output — independent evidence of what actually changed. +- Model-fallback regression coverage now spans the task and adversarial-review paths, including the fallback-also-rejected and same-model-guard cases. +- `codex-rescue` agent hardening: scope rules (report only, commit nothing, one writer per worktree, `--cwd` forwarding for foreign checkouts) and a failure contract that returns companion stdout/stderr verbatim on non-zero exit instead of returning nothing. + ## 1.0.8 - Failed reviews now surface the underlying Codex error (e.g. model-rejected 400s) in the rendered output instead of a bare "Reviewer failed to output a response". diff --git a/plugins/grok/.claude-plugin/plugin.json b/plugins/grok/.claude-plugin/plugin.json index 84b1ba5ed..ea3fb136e 100644 --- a/plugins/grok/.claude-plugin/plugin.json +++ b/plugins/grok/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "grok", - "version": "0.2.0", + "version": "0.3.0", "description": "Use a local Grok Build CLI from Claude Code for questions, reviews, and bounded rescue tasks.", "author": { "name": "External CLI Companions" diff --git a/plugins/grok/CHANGELOG.md b/plugins/grok/CHANGELOG.md new file mode 100644 index 000000000..f60cb90a3 --- /dev/null +++ b/plugins/grok/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +## 0.3.0 + +- Job status is classified from Grok's JSON `stopReason` (clean = `EndTurn`; ACP `end_turn` also accepted) instead of the process exit code, which is untrustworthy in both directions: external termination exits 0 + `Cancelled` with the diff already flushed, and max-turns exhaustion exits 1 + `Cancelled` where work may have landed. +- New `incomplete` terminal status for early stops: carries evidence (`stopReason`, exit status, changed files), renders a warning banner ("verify against git status/diff before trusting any success narrative"), and exits 2 for foreground jobs so callers can distinguish "verify the diff" from "discard and retry". Requires a Grok CLI that emits `stopReason` in json output. +- Job state is keyed by the shared main-repo root, so jobs launched from linked git worktrees are visible to `/grok:status`, `/grok:result`, and `/grok:cancel` from any checkout. Write locks stay per-worktree (parallel worktree writers keep working), jobs record their originating worktree for display, legacy worktree-keyed job files are read-merged without being modified, and plain repos keep their exact previous state key. +- `/grok:status --all` lists jobs from all Claude sessions; the default session-filtered view prints an honest count of hidden jobs. Running jobs show pid liveness: `running (pid alive, …)`, a warning when the pid is gone, or `liveness unknown` for legacy records. +- `--write` rescue tasks default to background execution (foreground write jobs are killed with a misleading `Cancelled` stop when the parent call is cut); approval rules are unchanged — no silent backgrounding without `--always-approve`/`--yolo`. +- `grok-rescue` agent hardening: scope rules (report only, commit nothing, one writer per worktree, `--cwd` forwarding for foreign checkouts) and a failure contract that returns companion stdout/stderr verbatim on non-zero exit instead of returning nothing. + +## 0.2.0 + +- Initial marketplace version of the Grok Build plugin for Claude Code.