From c62a985a58a47eff929a85891cdddcadad527af9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BD=BB=E9=A3=8F?= Date: Wed, 20 May 2026 08:30:32 -0700 Subject: [PATCH 01/35] codex: add signal file + Monitor/PushNotification callback for background tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Background tasks (detached worker processes) previously had no way to notify Claude Code on completion — the worker wrote state to disk and exited silently, leaving users to poll /codex:status manually. Worker now writes a .done signal file in the jobs directory on both success and failure. The launch payload exposes jobsDir and signalFile so the caller can set up a Monitor on the signal file and call PushNotification when it appears, waking the main thread automatically. --- plugins/codex/commands/rescue.md | 18 ++++++++- plugins/codex/scripts/codex-companion.mjs | 21 ++++++++++- plugins/codex/scripts/lib/tracked-jobs.mjs | 23 ++++++++++- tests/runtime.test.mjs | 44 ++++++++++++++++++++++ 4 files changed, 102 insertions(+), 4 deletions(-) diff --git a/plugins/codex/commands/rescue.md b/plugins/codex/commands/rescue.md index 56de9555..dd68b9b8 100644 --- a/plugins/codex/commands/rescue.md +++ b/plugins/codex/commands/rescue.md @@ -13,7 +13,23 @@ $ARGUMENTS Execution mode: -- If the request includes `--background`, run the `codex:codex-rescue` subagent in the background. +- If the request includes `--background`, run the `codex:codex-rescue` subagent in the background using `run_in_background: true`, then set up completion monitoring: + 1. Parse the subagent's JSON output to extract `jobId`, `jobsDir`, and `signalFile`. + 2. Set up a completion watcher using the `Monitor` tool so the main thread is notified when the background worker finishes: + ``` + Monitor: + command: until [ -f "" ]; do sleep 2; done; echo "done $(cat )" + description: "codex background task " + timeout_ms: 3600000 + persistent: false + ``` + Replace `` and `` with the actual values from step 1. + 3. When the Monitor fires (the worker wrote the `.done` signal file), call `PushNotification` to wake the main thread: + ``` + PushNotification: "Codex task finished: . Run /codex:result to see output." + ``` + Extract `` from the Monitor output line (`completed` or `failed`). + 4. Do not skip the Monitor setup. Do not poll `/codex:status` manually — rely on the Monitor to detect completion. - If the request includes `--wait`, run the `codex:codex-rescue` subagent in the foreground. - If neither flag is present, default to foreground. - `--background` and `--wait` are execution flags for Claude Code. Do not forward them to `task`, and do not treat them as part of the natural-language task text. diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 35222fd5..afcf2eab 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -28,6 +28,7 @@ import { generateJobId, getConfig, listJobs, + resolveJobsDir, setConfig, upsertJob, writeJobFile @@ -47,6 +48,7 @@ import { createJobRecord, createProgressReporter, nowIso, + resolveSignalFile, runTrackedJob, SESSION_ID_ENV } from "./lib/tracked-jobs.mjs"; @@ -551,7 +553,17 @@ function buildTaskRunMetadata({ prompt, resumeLast = false }) { } function renderQueuedTaskLaunch(payload) { - return `${payload.title} started in the background as ${payload.jobId}. Check /codex:status ${payload.jobId} for progress.\n`; + const lines = [`${payload.title} started in the background as ${payload.jobId}. Check /codex:status ${payload.jobId} for progress.`]; + if (payload.worktreePath) { + lines.push(` Worktree: ${payload.worktreePath}`); + if (payload.worktreeBranch) { + lines.push(` Branch: ${payload.worktreeBranch}`); + } + } + if (payload.signalFile) { + lines.push(` Signal: ${payload.signalFile}`); + } + return `${lines.join("\n")}\n`; } function getJobKindLabel(kind, jobClass) { @@ -656,12 +668,15 @@ function enqueueBackgroundTask(cwd, job, request) { appendLogLine(logFile, "Queued for background execution."); const child = spawnDetachedTaskWorker(cwd, job.id); + const jobsDir = resolveJobsDir(job.workspaceRoot); + const signalFile = resolveSignalFile(jobsDir, job.id); const queuedRecord = { ...job, status: "queued", phase: "queued", pid: child.pid ?? null, logFile, + signalFile, request }; writeJobFile(job.workspaceRoot, job.id, queuedRecord); @@ -673,7 +688,9 @@ function enqueueBackgroundTask(cwd, job, request) { status: "queued", title: job.title, summary: job.summary, - logFile + logFile, + jobsDir, + signalFile }, logFile }; diff --git a/plugins/codex/scripts/lib/tracked-jobs.mjs b/plugins/codex/scripts/lib/tracked-jobs.mjs index 90286901..11dbbbdf 100644 --- a/plugins/codex/scripts/lib/tracked-jobs.mjs +++ b/plugins/codex/scripts/lib/tracked-jobs.mjs @@ -1,7 +1,8 @@ import fs from "node:fs"; +import path from "node:path"; import process from "node:process"; -import { readJobFile, resolveJobFile, resolveJobLogFile, upsertJob, writeJobFile } from "./state.mjs"; +import { readJobFile, resolveJobFile, resolveJobLogFile, resolveJobsDir, upsertJob, writeJobFile } from "./state.mjs"; export const SESSION_ID_ENV = "CODEX_COMPANION_SESSION_ID"; @@ -48,6 +49,22 @@ export function appendLogBlock(logFile, title, body) { fs.appendFileSync(logFile, `\n[${nowIso()}] ${title}\n${String(body).trimEnd()}\n`, "utf8"); } +export function resolveSignalFile(jobsDir, jobId) { + return path.join(jobsDir, `${jobId}.done`); +} + +export function writeCompletionSignalFile(jobsDir, jobId, status, summary) { + const signalFile = resolveSignalFile(jobsDir, jobId); + const safeStatus = status === "completed" ? "completed" : "failed"; + const line = `[${nowIso()}] ${safeStatus} ${jobId}${summary ? ` ${summary}` : ""}`; + try { + fs.writeFileSync(signalFile, `${line}\n`, "utf8"); + } catch { + // Signal file is best-effort; do not fail the job if it cannot be written. + } + return signalFile; +} + export function createJobLogFile(workspaceRoot, jobId, title) { const logFile = resolveJobLogFile(workspaceRoot, jobId); fs.writeFileSync(logFile, "", "utf8"); @@ -151,6 +168,8 @@ export async function runTrackedJob(job, runner, options = {}) { writeJobFile(job.workspaceRoot, job.id, runningRecord); upsertJob(job.workspaceRoot, runningRecord); + const jobsDir = resolveJobsDir(job.workspaceRoot); + try { const execution = await runner(); const completionStatus = execution.exitStatus === 0 ? "completed" : "failed"; @@ -177,6 +196,7 @@ export async function runTrackedJob(job, runner, options = {}) { completedAt }); appendLogBlock(options.logFile ?? job.logFile ?? null, "Final output", execution.rendered); + writeCompletionSignalFile(jobsDir, job.id, completionStatus, execution.summary); return execution; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); @@ -199,6 +219,7 @@ export async function runTrackedJob(job, runner, options = {}) { errorMessage, completedAt }); + writeCompletionSignalFile(jobsDir, job.id, "failed", errorMessage); throw error; } } diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 90408372..9e3083a5 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -833,6 +833,50 @@ test("task --background enqueues a detached worker and exposes per-job status", assert.match(resultPayload.storedJob.rendered, /Handled the requested task/); }); +test("task --background writes a .done signal file on completion for Monitor-based notification", async () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "slow-task"); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const launched = run("node", [SCRIPT, "task", "--background", "--json", "investigate the failing test"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(launched.status, 0, launched.stderr); + const launchPayload = JSON.parse(launched.stdout); + assert.equal(launchPayload.status, "queued"); + assert.ok(launchPayload.signalFile, "launch payload must include signalFile"); + assert.ok(launchPayload.jobsDir, "launch payload must include jobsDir"); + assert.equal(launchPayload.signalFile, path.join(launchPayload.jobsDir, `${launchPayload.jobId}.done`)); + + // The signal file should not exist yet (task is still running). + assert.equal(fs.existsSync(launchPayload.signalFile), false, "signal file must not exist before completion"); + + // Wait for the background worker to finish. + const waitedStatus = run( + "node", + [SCRIPT, "status", launchPayload.jobId, "--wait", "--timeout-ms", "15000", "--json"], + { + cwd: repo, + env: buildEnv(binDir) + } + ); + assert.equal(waitedStatus.status, 0, waitedStatus.stderr); + const waitedPayload = JSON.parse(waitedStatus.stdout); + assert.equal(waitedPayload.job.status, "completed"); + + // The signal file should now exist and contain the completion marker. + await waitFor(() => fs.existsSync(launchPayload.signalFile)); + const signalContent = fs.readFileSync(launchPayload.signalFile, "utf8"); + assert.match(signalContent, /completed/); + assert.match(signalContent, new RegExp(launchPayload.jobId)); +}); + test("review rejects focus text because it is native-review only", () => { const repo = makeTempDir(); const binDir = makeTempDir(); From 48ea16370628a1e576040ff4855edde42c157b9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BD=BB=E9=A3=8F?= Date: Wed, 20 May 2026 08:31:09 -0700 Subject: [PATCH 02/35] codex: add --worktree isolation mode and honor user sandbox_mode config New --worktree flag for /codex:rescue creates an isolated git worktree under .claude/worktrees// on a dedicated branch so Codex can work without touching the main working directory. The worktree path flows through task request, job record, and rendered output. Also reads sandbox_mode from the user's Codex config (~/.codex/config.toml / .codex/config.toml) via a new codex-config module, falling back to workspace-write or read-only only when no explicit config is set. --- plugins/codex/agents/codex-rescue.md | 1 + plugins/codex/commands/rescue.md | 3 +- plugins/codex/scripts/codex-companion.mjs | 77 +++++++--- plugins/codex/scripts/lib/codex-config.mjs | 59 ++++++++ plugins/codex/scripts/lib/render.mjs | 64 +++++++-- plugins/codex/scripts/lib/workspace.mjs | 77 ++++++++++ tests/codex-config.test.mjs | 125 +++++++++++++++++ tests/worktree-render.test.mjs | 78 +++++++++++ tests/worktree.test.mjs | 156 +++++++++++++++++++++ 9 files changed, 612 insertions(+), 28 deletions(-) create mode 100644 plugins/codex/scripts/lib/codex-config.mjs create mode 100644 tests/codex-config.test.mjs create mode 100644 tests/worktree-render.test.mjs create mode 100644 tests/worktree.test.mjs diff --git a/plugins/codex/agents/codex-rescue.md b/plugins/codex/agents/codex-rescue.md index 7009ec86..f907ed8e 100644 --- a/plugins/codex/agents/codex-rescue.md +++ b/plugins/codex/agents/codex-rescue.md @@ -37,6 +37,7 @@ Forwarding rules: - `--fresh` means do not add `--resume-last`. - If the user is clearly asking to continue prior Codex work in this repository, such as "continue", "keep going", "resume", "apply the top fix", or "dig deeper", add `--resume-last` unless `--fresh` is present. - Otherwise forward the task as a fresh `task` run. +- `--worktree` runs the task in an isolated git worktree. Preserve it for the forwarded `task` call. `--worktree` and `--resume-last` are mutually exclusive — if both are present, report the conflict and do not forward. - 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. diff --git a/plugins/codex/commands/rescue.md b/plugins/codex/commands/rescue.md index dd68b9b8..6ffd7cb8 100644 --- a/plugins/codex/commands/rescue.md +++ b/plugins/codex/commands/rescue.md @@ -1,6 +1,6 @@ --- description: Delegate investigation, an explicit fix request, or follow-up rescue work to the Codex rescue subagent -argument-hint: "[--background|--wait] [--resume|--fresh] [--model ] [--effort ] [what Codex should investigate, solve, or continue]" +argument-hint: "[--background|--wait] [--worktree] [--resume|--fresh] [--model ] [--effort ] [what Codex should investigate, solve, or continue]" allowed-tools: Bash(node:*), AskUserQuestion, Agent --- @@ -33,6 +33,7 @@ Execution mode: - If the request includes `--wait`, run the `codex:codex-rescue` subagent in the foreground. - If neither flag is present, default to foreground. - `--background` and `--wait` are execution flags for Claude Code. Do not forward them to `task`, and do not treat them as part of the natural-language task text. +- `--worktree` runs the task in an isolated git worktree. Codex works in `.claude/worktrees//` on a separate branch, leaving the main working directory untouched. `--worktree` and `--resume`/`--resume-last` are mutually exclusive. Preserve `--worktree` for the forwarded `task` call. - `--model` and `--effort` are runtime-selection flags. Preserve them for the forwarded `task` call, but do not treat them as part of the natural-language task text. - If the request includes `--resume`, do not ask whether to continue. The user already chose. - If the request includes `--fresh`, do not ask whether to continue. The user already chose. diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index afcf2eab..8a85c86b 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -7,6 +7,7 @@ import process from "node:process"; import { fileURLToPath } from "node:url"; import { parseArgs, splitRawArgumentString } from "./lib/args.mjs"; +import { resolveCodexSandboxMode } from "./lib/codex-config.mjs"; import { buildPersistentTaskThreadName, DEFAULT_CONTINUE_PROMPT, @@ -52,7 +53,7 @@ import { runTrackedJob, SESSION_ID_ENV } from "./lib/tracked-jobs.mjs"; -import { resolveWorkspaceRoot } from "./lib/workspace.mjs"; +import { resolveWorkspaceRoot, createWorktree } from "./lib/workspace.mjs"; import { renderNativeReviewResult, renderReviewResult, @@ -459,6 +460,7 @@ async function executeReviewRun(request) { async function executeTaskRun(request) { const workspaceRoot = resolveWorkspaceRoot(request.cwd); + const codexCwd = request.worktreePath ?? workspaceRoot; ensureCodexAvailable(request.cwd); const taskMetadata = buildTaskRunMetadata({ @@ -481,13 +483,13 @@ async function executeTaskRun(request) { throw new Error("Provide a prompt, a prompt file, piped stdin, or use --resume-last."); } - const result = await runAppServerTurn(workspaceRoot, { + const result = await runAppServerTurn(codexCwd, { resumeThreadId, prompt: request.prompt, defaultPrompt: resumeThreadId ? DEFAULT_CONTINUE_PROMPT : "", model: request.model, effort: request.effort, - sandbox: request.write ? "workspace-write" : "read-only", + sandbox: resolveCodexSandboxMode(workspaceRoot) ?? (request.write ? "workspace-write" : "read-only"), onProgress: request.onProgress, persistThread: true, threadName: resumeThreadId ? null : buildPersistentTaskThreadName(request.prompt || DEFAULT_CONTINUE_PROMPT) @@ -504,7 +506,10 @@ async function executeTaskRun(request) { { title: taskMetadata.title, jobId: request.jobId ?? null, - write: Boolean(request.write) + write: Boolean(request.write), + worktreePath: request.worktreePath ?? null, + worktreeBranch: request.worktreeBranch ?? null, + worktreeBaseBranch: request.worktreeBaseBranch ?? null } ); const payload = { @@ -512,7 +517,10 @@ async function executeTaskRun(request) { threadId: result.threadId, rawOutput, touchedFiles: result.touchedFiles, - reasoningSummary: result.reasoningSummary + reasoningSummary: result.reasoningSummary, + worktreePath: request.worktreePath ?? null, + worktreeBranch: request.worktreeBranch ?? null, + worktreeBaseBranch: request.worktreeBaseBranch ?? null }; return { @@ -573,9 +581,9 @@ function getJobKindLabel(kind, jobClass) { return jobClass === "review" ? "review" : "rescue"; } -function createCompanionJob({ prefix, kind, title, workspaceRoot, jobClass, summary, write = false }) { +function createCompanionJob({ prefix, kind, title, workspaceRoot, jobClass, summary, write = false, id }) { return createJobRecord({ - id: generateJobId(prefix), + id: id ?? generateJobId(prefix), kind, kindLabel: getJobKindLabel(kind, jobClass), title, @@ -598,19 +606,31 @@ function createTrackedProgress(job, options = {}) { }; } -function buildTaskJob(workspaceRoot, taskMetadata, write) { - return createCompanionJob({ +function buildTaskJob(workspaceRoot, taskMetadata, write, worktreeInfo = null, id = null) { + const base = createCompanionJob({ prefix: "task", kind: "task", title: taskMetadata.title, workspaceRoot, jobClass: "task", summary: taskMetadata.summary, - write + write, + id }); + + if (!worktreeInfo) { + return base; + } + + return { + ...base, + worktreePath: worktreeInfo.worktreePath, + worktreeBranch: worktreeInfo.worktreeBranch, + worktreeBaseBranch: worktreeInfo.worktreeBaseBranch + }; } -function buildTaskRequest({ cwd, model, effort, prompt, write, resumeLast, jobId }) { +function buildTaskRequest({ cwd, model, effort, prompt, write, resumeLast, jobId, worktreePath = null, worktreeBranch = null, worktreeBaseBranch = null }) { return { cwd, model, @@ -618,7 +638,10 @@ function buildTaskRequest({ cwd, model, effort, prompt, write, resumeLast, jobId prompt, write, resumeLast, - jobId + jobId, + worktreePath, + worktreeBranch, + worktreeBaseBranch }; } @@ -690,7 +713,9 @@ function enqueueBackgroundTask(cwd, job, request) { summary: job.summary, logFile, jobsDir, - signalFile + signalFile, + worktreePath: job.worktreePath ?? null, + worktreeBranch: job.worktreeBranch ?? null }, logFile }; @@ -749,7 +774,7 @@ async function handleReview(argv) { async function handleTask(argv) { const { options, positionals } = parseCommandInput(argv, { valueOptions: ["model", "effort", "cwd", "prompt-file"], - booleanOptions: ["json", "write", "resume-last", "resume", "fresh", "background"], + booleanOptions: ["json", "write", "resume-last", "resume", "fresh", "background", "worktree"], aliasMap: { m: "model" } @@ -763,20 +788,32 @@ async function handleTask(argv) { const resumeLast = Boolean(options["resume-last"] || options.resume); const fresh = Boolean(options.fresh); + const worktree = Boolean(options.worktree); if (resumeLast && fresh) { throw new Error("Choose either --resume/--resume-last or --fresh."); } + if (worktree && resumeLast) { + throw new Error("Choose either --worktree or --resume/--resume-last."); + } const write = Boolean(options.write); const taskMetadata = buildTaskRunMetadata({ prompt, resumeLast }); + // Create worktree if requested (before job creation so we have the path) + let worktreeInfo = null; + let preassignedJobId = null; + if (worktree) { + preassignedJobId = generateJobId("task"); + worktreeInfo = createWorktree(workspaceRoot, preassignedJobId, prompt); + } + if (options.background) { ensureCodexAvailable(cwd); requireTaskRequest(prompt, resumeLast); - const job = buildTaskJob(workspaceRoot, taskMetadata, write); + const job = buildTaskJob(workspaceRoot, taskMetadata, write, worktreeInfo, preassignedJobId); const request = buildTaskRequest({ cwd, model, @@ -784,14 +821,17 @@ async function handleTask(argv) { prompt, write, resumeLast, - jobId: job.id + jobId: job.id, + worktreePath: worktreeInfo?.worktreePath ?? null, + worktreeBranch: worktreeInfo?.worktreeBranch ?? null, + worktreeBaseBranch: worktreeInfo?.worktreeBaseBranch ?? null }); const { payload } = enqueueBackgroundTask(cwd, job, request); outputCommandResult(payload, renderQueuedTaskLaunch(payload), options.json); return; } - const job = buildTaskJob(workspaceRoot, taskMetadata, write); + const job = buildTaskJob(workspaceRoot, taskMetadata, write, worktreeInfo, preassignedJobId); await runForegroundCommand( job, (progress) => @@ -803,6 +843,9 @@ async function handleTask(argv) { write, resumeLast, jobId: job.id, + worktreePath: worktreeInfo?.worktreePath ?? null, + worktreeBranch: worktreeInfo?.worktreeBranch ?? null, + worktreeBaseBranch: worktreeInfo?.worktreeBaseBranch ?? null, onProgress: progress }), { json: options.json } diff --git a/plugins/codex/scripts/lib/codex-config.mjs b/plugins/codex/scripts/lib/codex-config.mjs new file mode 100644 index 00000000..2a177e32 --- /dev/null +++ b/plugins/codex/scripts/lib/codex-config.mjs @@ -0,0 +1,59 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const VALID_SANDBOX_MODES = new Set(["read-only", "workspace-write", "danger-full-access"]); + +/** + * Extract `sandbox_mode` from a Codex config.toml file. + * Returns null if the file does not exist, cannot be read, or the key is absent/invalid. + * + * Only handles the simple `key = "value"` syntax used by Codex config. + * Does not attempt full TOML parsing — no arrays, tables, or inline tables. + */ +export function readSandboxModeFromFile(filePath) { + let content; + try { + content = fs.readFileSync(filePath, "utf8"); + } catch { + return null; + } + + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.replace(/#.*$/, "").trim(); + if (!line) continue; + + const match = line.match(/^sandbox_mode\s*=\s*"([^"]*)"/); + if (!match) continue; + + const value = match[1].trim(); + if (VALID_SANDBOX_MODES.has(value)) { + return value; + } + } + + return null; +} + +/** + * Resolve the effective Codex `sandbox_mode` for a workspace. + * + * Precedence (matches Codex CLI behavior): + * 1. Project-level `.codex/config.toml` in the workspace root + * 2. User-level `~/.codex/config.toml` + * + * Returns the resolved value, or null if nothing is configured. + */ +export function resolveCodexSandboxMode(workspaceRoot) { + const projectConfig = workspaceRoot + ? readSandboxModeFromFile(path.join(workspaceRoot, ".codex", "config.toml")) + : null; + if (projectConfig) return projectConfig; + + const userConfig = readSandboxModeFromFile(path.join(os.homedir(), ".codex", "config.toml")); + if (userConfig) return userConfig; + + return null; +} + +export { VALID_SANDBOX_MODES }; diff --git a/plugins/codex/scripts/lib/render.mjs b/plugins/codex/scripts/lib/render.mjs index 2ec18523..5e9d86bc 100644 --- a/plugins/codex/scripts/lib/render.mjs +++ b/plugins/codex/scripts/lib/render.mjs @@ -161,6 +161,17 @@ function pushJobDetails(lines, job, options = {}) { lines.push(` ${line}`); } } + if (job.worktreePath) { + lines.push(` Worktree path: ${job.worktreePath}`); + if (job.worktreeBranch) { + lines.push(` Worktree branch: ${job.worktreeBranch}`); + } + if (options.showWorktreeActions && job.worktreeBaseBranch) { + lines.push(` Diff: git diff ${job.worktreeBaseBranch}...${job.worktreeBranch}`); + lines.push(` Merge: git merge ${job.worktreeBranch}`); + lines.push(` Remove: git worktree remove ${job.worktreePath}`); + } + } } function appendReasoningSection(lines, reasoningSummary) { @@ -314,12 +325,38 @@ export function renderNativeReviewResult(result, meta) { export function renderTaskResult(parsedResult, meta) { const rawOutput = typeof parsedResult?.rawOutput === "string" ? parsedResult.rawOutput : ""; + const worktreeBlock = renderWorktreesBlock(meta); + if (rawOutput) { - return rawOutput.endsWith("\n") ? rawOutput : `${rawOutput}\n`; + const base = rawOutput.endsWith("\n") ? rawOutput : `${rawOutput}\n`; + return worktreeBlock ? `${base}\n${worktreeBlock}` : base; } const message = String(parsedResult?.failureMessage ?? "").trim() || "Codex did not return a final message."; - return `${message}\n`; + const base = `${message}\n`; + return worktreeBlock ? `${base}\n${worktreeBlock}` : base; +} + +export function renderWorktreesBlock(meta) { + if (!meta?.worktreePath) { + return null; + } + + const lines = [ + "Worktree:", + ` Path: ${meta.worktreePath}`, + ` Branch: ${meta.worktreeBranch ?? "unknown"}` + ]; + + if (meta.worktreeBaseBranch) { + lines.push(""); + lines.push("Next steps:"); + lines.push(` Diff: git diff ${meta.worktreeBaseBranch}...${meta.worktreeBranch}`); + lines.push(` Merge: git merge ${meta.worktreeBranch}`); + lines.push(` Remove: git worktree remove ${meta.worktreePath}`); + } + + return `${lines.join("\n")}\n`; } export function renderStatusReport(report) { @@ -382,7 +419,8 @@ export function renderJobStatusReport(job) { showLog: true, showCancelHint: true, showResultHint: true, - showReviewHint: true + showReviewHint: true, + showWorktreeActions: true }); return `${lines.join("\n").trimEnd()}\n`; } @@ -390,12 +428,15 @@ export function renderJobStatusReport(job) { export function renderStoredJobResult(job, storedJob) { const threadId = storedJob?.threadId ?? job.threadId ?? null; const resumeCommand = threadId ? `codex resume ${threadId}` : null; + const worktreeBlock = renderWorktreesBlock(job); + if (isStructuredReviewStoredResult(storedJob) && storedJob?.rendered) { const output = storedJob.rendered.endsWith("\n") ? storedJob.rendered : `${storedJob.rendered}\n`; if (!threadId) { - return output; + return worktreeBlock ? `${output}\n${worktreeBlock}` : output; } - return `${output}\nCodex session ID: ${threadId}\nResume in Codex: ${resumeCommand}\n`; + const base = `${output}\nCodex session ID: ${threadId}\nResume in Codex: ${resumeCommand}\n`; + return worktreeBlock ? `${base}\n${worktreeBlock}` : base; } const rawOutput = @@ -405,17 +446,19 @@ export function renderStoredJobResult(job, storedJob) { if (rawOutput) { const output = rawOutput.endsWith("\n") ? rawOutput : `${rawOutput}\n`; if (!threadId) { - return output; + return worktreeBlock ? `${output}\n${worktreeBlock}` : output; } - return `${output}\nCodex session ID: ${threadId}\nResume in Codex: ${resumeCommand}\n`; + const base = `${output}\nCodex session ID: ${threadId}\nResume in Codex: ${resumeCommand}\n`; + return worktreeBlock ? `${base}\n${worktreeBlock}` : base; } if (storedJob?.rendered) { const output = storedJob.rendered.endsWith("\n") ? storedJob.rendered : `${storedJob.rendered}\n`; if (!threadId) { - return output; + return worktreeBlock ? `${output}\n${worktreeBlock}` : output; } - return `${output}\nCodex session ID: ${threadId}\nResume in Codex: ${resumeCommand}\n`; + const base = `${output}\nCodex session ID: ${threadId}\nResume in Codex: ${resumeCommand}\n`; + return worktreeBlock ? `${base}\n${worktreeBlock}` : base; } const lines = [ @@ -442,7 +485,8 @@ export function renderStoredJobResult(job, storedJob) { lines.push("", "No captured result payload was stored for this job."); } - return `${lines.join("\n").trimEnd()}\n`; + const base = `${lines.join("\n").trimEnd()}\n`; + return worktreeBlock ? `${base}\n${worktreeBlock}` : base; } export function renderCancelReport(job) { diff --git a/plugins/codex/scripts/lib/workspace.mjs b/plugins/codex/scripts/lib/workspace.mjs index 89a0060b..5e203e83 100644 --- a/plugins/codex/scripts/lib/workspace.mjs +++ b/plugins/codex/scripts/lib/workspace.mjs @@ -1,4 +1,8 @@ +import fs from "node:fs"; +import path from "node:path"; + import { ensureGitRepository } from "./git.mjs"; +import { runCommand } from "./process.mjs"; export function resolveWorkspaceRoot(cwd) { try { @@ -7,3 +11,76 @@ export function resolveWorkspaceRoot(cwd) { return cwd; } } + +const WORKTREE_DIR = ".claude/worktrees"; +const WORKTREE_BRANCH_PREFIX = "codex-rescue"; +const WORKTREE_PROMPT_MAX_LENGTH = 32; + +export function resolveWorktreePath(sourceRoot, jobId) { + return path.join(sourceRoot, WORKTREE_DIR, jobId); +} + +export function generateWorktreeBranch(jobId, prompt) { + if (!prompt || !prompt.trim()) { + return `${WORKTREE_BRANCH_PREFIX}/${jobId}`; + } + + const normalized = prompt + .trim() + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); + + if (!normalized) { + return `${WORKTREE_BRANCH_PREFIX}/${jobId}`; + } + + const truncated = normalized.slice(0, WORKTREE_PROMPT_MAX_LENGTH).replace(/-$/, ""); + return `${WORKTREE_BRANCH_PREFIX}/${jobId}-${truncated}`; +} + +export function createWorktree(sourceRoot, jobId, prompt) { + const worktreePath = resolveWorktreePath(sourceRoot, jobId); + const worktreeBranch = generateWorktreeBranch(jobId, prompt); + + // Get current branch as base + const baseResult = runCommand("git", ["branch", "--show-current"], { cwd: sourceRoot }); + const baseBranch = baseResult.status === 0 && baseResult.stdout.trim() + ? baseResult.stdout.trim() + : "HEAD"; + + // Check if worktree path already exists + if (fs.existsSync(worktreePath)) { + // Check if it's already a worktree + const listResult = runCommand("git", ["worktree", "list", "--porcelain"], { cwd: sourceRoot }); + if (listResult.status === 0 && listResult.stdout.includes(worktreePath)) { + // Reuse existing worktree + return { worktreePath, worktreeBranch, worktreeBaseBranch: baseBranch }; + } + // Path exists but not as worktree - error + throw new Error( + `Worktree path already exists: ${worktreePath}\n` + + `Please remove it manually or use a different job ID.` + ); + } + + // Create parent directory + fs.mkdirSync(path.dirname(worktreePath), { recursive: true }); + + // Create worktree + const createResult = runCommand( + "git", + ["worktree", "add", "-b", worktreeBranch, worktreePath], + { cwd: sourceRoot } + ); + + if (createResult.status !== 0) { + throw new Error( + `Failed to create worktree: ${createResult.stderr || createResult.stdout}` + ); + } + + return { worktreePath, worktreeBranch, worktreeBaseBranch: baseBranch }; +} diff --git a/tests/codex-config.test.mjs b/tests/codex-config.test.mjs new file mode 100644 index 00000000..0fc1f2c5 --- /dev/null +++ b/tests/codex-config.test.mjs @@ -0,0 +1,125 @@ +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; + +import { readSandboxModeFromFile, resolveCodexSandboxMode, VALID_SANDBOX_MODES } from "../plugins/codex/scripts/lib/codex-config.mjs"; + +describe("readSandboxModeFromFile", () => { + let tempDir; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "codex-config-test-")); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("returns null for non-existent file", () => { + const result = readSandboxModeFromFile(join(tempDir, "does-not-exist.toml")); + assert.equal(result, null); + }); + + it("reads valid sandbox_mode values", () => { + for (const mode of VALID_SANDBOX_MODES) { + const file = join(tempDir, `config-${mode}.toml`); + writeFileSync(file, `sandbox_mode = "${mode}"\n`); + assert.equal(readSandboxModeFromFile(file), mode); + } + }); + + it("returns null for invalid sandbox_mode value", () => { + const file = join(tempDir, "config.toml"); + writeFileSync(file, 'sandbox_mode = "invalid-mode"\n'); + assert.equal(readSandboxModeFromFile(file), null); + }); + + it("handles whitespace and comments", () => { + const file = join(tempDir, "config.toml"); + writeFileSync(file, ' sandbox_mode = "danger-full-access" # full access\n'); + assert.equal(readSandboxModeFromFile(file), "danger-full-access"); + }); + + it("ignores commented-out sandbox_mode", () => { + const file = join(tempDir, "config.toml"); + writeFileSync(file, '# sandbox_mode = "danger-full-access"\n'); + assert.equal(readSandboxModeFromFile(file), null); + }); + + it("handles file with other config values", () => { + const file = join(tempDir, "config.toml"); + writeFileSync(file, [ + 'model = "gpt-5.4-mini"', + 'model_reasoning_effort = "high"', + 'sandbox_mode = "workspace-write"', + 'network_access = true' + ].join("\n")); + assert.equal(readSandboxModeFromFile(file), "workspace-write"); + }); +}); + +describe("resolveCodexSandboxMode", () => { + let tempDir; + let originalHome; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "codex-config-resolve-test-")); + originalHome = process.env.HOME; + }); + + afterEach(() => { + process.env.HOME = originalHome; + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("returns null when no config files exist", () => { + process.env.HOME = join(tempDir, "empty-home"); + mkdirSync(process.env.HOME, { recursive: true }); + const result = resolveCodexSandboxMode(tempDir); + assert.equal(result, null); + }); + + it("reads from user-level config when project config is absent", () => { + process.env.HOME = tempDir; + const userCodexDir = join(tempDir, ".codex"); + mkdirSync(userCodexDir, { recursive: true }); + writeFileSync(join(userCodexDir, "config.toml"), 'sandbox_mode = "read-only"\n'); + + const result = resolveCodexSandboxMode(join(tempDir, "workspace")); + assert.equal(result, "read-only"); + }); + + it("prefers project-level config over user-level", () => { + process.env.HOME = tempDir; + + const userCodexDir = join(tempDir, ".codex"); + mkdirSync(userCodexDir, { recursive: true }); + writeFileSync(join(userCodexDir, "config.toml"), 'sandbox_mode = "read-only"\n'); + + const workspaceRoot = join(tempDir, "workspace"); + const projectCodexDir = join(workspaceRoot, ".codex"); + mkdirSync(projectCodexDir, { recursive: true }); + writeFileSync(join(projectCodexDir, "config.toml"), 'sandbox_mode = "danger-full-access"\n'); + + const result = resolveCodexSandboxMode(workspaceRoot); + assert.equal(result, "danger-full-access"); + }); + + it("falls back to user-level when project config has invalid value", () => { + process.env.HOME = tempDir; + + const userCodexDir = join(tempDir, ".codex"); + mkdirSync(userCodexDir, { recursive: true }); + writeFileSync(join(userCodexDir, "config.toml"), 'sandbox_mode = "workspace-write"\n'); + + const workspaceRoot = join(tempDir, "workspace"); + const projectCodexDir = join(workspaceRoot, ".codex"); + mkdirSync(projectCodexDir, { recursive: true }); + writeFileSync(join(projectCodexDir, "config.toml"), 'sandbox_mode = "invalid"\n'); + + const result = resolveCodexSandboxMode(workspaceRoot); + assert.equal(result, "workspace-write"); + }); +}); diff --git a/tests/worktree-render.test.mjs b/tests/worktree-render.test.mjs new file mode 100644 index 00000000..11966c57 --- /dev/null +++ b/tests/worktree-render.test.mjs @@ -0,0 +1,78 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { renderWorktreesBlock, renderTaskResult } from "../plugins/codex/scripts/lib/render.mjs"; + +describe("renderWorktreesBlock", () => { + it("returns null when no worktreePath", () => { + assert.equal(renderWorktreesBlock({}), null); + assert.equal(renderWorktreesBlock({ worktreePath: null }), null); + }); + + it("renders worktree info with path and branch", () => { + const result = renderWorktreesBlock({ + worktreePath: "/repo/.claude/worktrees/task-abc123", + worktreeBranch: "codex-rescue/task-abc123-fix-bug", + worktreeBaseBranch: "main" + }); + + assert.ok(result.includes("Worktree:")); + assert.ok(result.includes("/repo/.claude/worktrees/task-abc123")); + assert.ok(result.includes("codex-rescue/task-abc123-fix-bug")); + assert.ok(result.includes("git diff main...codex-rescue/task-abc123-fix-bug")); + assert.ok(result.includes("git merge codex-rescue/task-abc123-fix-bug")); + assert.ok(result.includes("git worktree remove /repo/.claude/worktrees/task-abc123")); + }); + + it("renders without next steps when no baseBranch", () => { + const result = renderWorktreesBlock({ + worktreePath: "/repo/.claude/worktrees/task-abc123", + worktreeBranch: "codex-rescue/task-abc123" + }); + + assert.ok(result.includes("Worktree:")); + assert.ok(result.includes("/repo/.claude/worktrees/task-abc123")); + assert.ok(!result.includes("Next steps:")); + }); +}); + +describe("renderTaskResult with worktree", () => { + it("appends worktree block to raw output", () => { + const result = renderTaskResult( + { rawOutput: "Task completed successfully." }, + { + worktreePath: "/repo/.claude/worktrees/task-abc123", + worktreeBranch: "codex-rescue/task-abc123-fix-bug", + worktreeBaseBranch: "main" + } + ); + + assert.ok(result.includes("Task completed successfully.")); + assert.ok(result.includes("Worktree:")); + assert.ok(result.includes("/repo/.claude/worktrees/task-abc123")); + }); + + it("appends worktree block to failure message", () => { + const result = renderTaskResult( + { failureMessage: "Task failed." }, + { + worktreePath: "/repo/.claude/worktrees/task-abc123", + worktreeBranch: "codex-rescue/task-abc123-fix-bug", + worktreeBaseBranch: "main" + } + ); + + assert.ok(result.includes("Task failed.")); + assert.ok(result.includes("Worktree:")); + }); + + it("returns plain output when no worktree", () => { + const result = renderTaskResult( + { rawOutput: "Task completed." }, + {} + ); + + assert.equal(result, "Task completed.\n"); + assert.ok(!result.includes("Worktree:")); + }); +}); diff --git a/tests/worktree.test.mjs b/tests/worktree.test.mjs new file mode 100644 index 00000000..6fd41c1f --- /dev/null +++ b/tests/worktree.test.mjs @@ -0,0 +1,156 @@ +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, readdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; + +import { + resolveWorktreePath, + generateWorktreeBranch, + createWorktree +} from "../plugins/codex/scripts/lib/workspace.mjs"; + +function run(command, args, options = {}) { + return spawnSync(command, args, { + cwd: options.cwd, + encoding: "utf8", + windowsHide: true + }); +} + +function initGitRepo(cwd) { + run("git", ["init", "-b", "main"], { cwd }); + run("git", ["config", "user.name", "Codex Plugin Tests"], { cwd }); + run("git", ["config", "user.email", "tests@example.com"], { cwd }); + run("git", ["config", "commit.gpgsign", "false"], { cwd }); + writeFileSync(join(cwd, "README.md"), "# test\n"); + run("git", ["add", "."], { cwd }); + run("git", ["commit", "-m", "initial"], { cwd }); +} + +describe("resolveWorktreePath", () => { + it("returns path under .claude/worktrees with jobId", () => { + const result = resolveWorktreePath("/repo", "task-abc123"); + assert.equal(result, "/repo/.claude/worktrees/task-abc123"); + }); + + it("handles nested source root", () => { + const result = resolveWorktreePath("/home/user/projects/myrepo", "task-xyz"); + assert.equal(result, "/home/user/projects/myrepo/.claude/worktrees/task-xyz"); + }); +}); + +describe("generateWorktreeBranch", () => { + it("generates branch with jobId only when prompt is empty", () => { + const result = generateWorktreeBranch("task-abc123", ""); + assert.equal(result, "codex-rescue/task-abc123"); + }); + + it("generates branch with jobId only when prompt is null", () => { + const result = generateWorktreeBranch("task-abc123", null); + assert.equal(result, "codex-rescue/task-abc123"); + }); + + it("includes truncated prompt in branch name", () => { + const result = generateWorktreeBranch("task-abc123", "Fix the authentication bug"); + assert.equal(result, "codex-rescue/task-abc123-fix-the-authentication-bug"); + }); + + it("truncates long prompts to 32 characters", () => { + const longPrompt = "This is a very long prompt that should be truncated to thirty two characters"; + const result = generateWorktreeBranch("task-abc123", longPrompt); + assert.ok(result.startsWith("codex-rescue/task-abc123-")); + const suffix = result.replace("codex-rescue/task-abc123-", ""); + assert.ok(suffix.length <= 32, `suffix "${suffix}" is ${suffix.length} chars`); + }); + + it("removes special characters from prompt", () => { + const result = generateWorktreeBranch("task-abc123", "Fix bug #123 (urgent!)"); + assert.equal(result, "codex-rescue/task-abc123-fix-bug-123-urgent"); + }); + + it("converts spaces to hyphens", () => { + const result = generateWorktreeBranch("task-abc123", "add new feature"); + assert.equal(result, "codex-rescue/task-abc123-add-new-feature"); + }); + + it("collapses multiple hyphens", () => { + const result = generateWorktreeBranch("task-abc123", "fix---bug"); + assert.equal(result, "codex-rescue/task-abc123-fix-bug"); + }); + + it("strips leading and trailing hyphens from prompt part", () => { + const result = generateWorktreeBranch("task-abc123", " -fix bug- "); + assert.equal(result, "codex-rescue/task-abc123-fix-bug"); + }); + + it("handles prompt with only special characters", () => { + const result = generateWorktreeBranch("task-abc123", "!!!@@@###"); + assert.equal(result, "codex-rescue/task-abc123"); + }); +}); + +describe("createWorktree", () => { + let tempDir; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "codex-worktree-test-")); + initGitRepo(tempDir); + }); + + afterEach(() => { + // Remove worktrees first to avoid permission issues + const worktreesDir = join(tempDir, ".claude", "worktrees"); + if (existsSync(worktreesDir)) { + const entries = readdirSync(worktreesDir); + for (const entry of entries) { + const wtPath = join(worktreesDir, entry); + run("git", ["worktree", "remove", "--force", wtPath], { cwd: tempDir }); + } + } + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("creates a worktree with the correct path and branch", () => { + const result = createWorktree(tempDir, "task-abc123", "fix bug"); + + assert.ok(result.worktreePath.endsWith("/.claude/worktrees/task-abc123")); + assert.equal(result.worktreeBranch, "codex-rescue/task-abc123-fix-bug"); + assert.equal(result.worktreeBaseBranch, "main"); + assert.ok(existsSync(result.worktreePath)); + + // Verify branch exists + const branchList = run("git", ["branch", "--list"], { cwd: tempDir }); + assert.ok(branchList.stdout.includes("codex-rescue/task-abc123-fix-bug")); + }); + + it("creates worktree without prompt", () => { + const result = createWorktree(tempDir, "task-xyz", ""); + + assert.equal(result.worktreeBranch, "codex-rescue/task-xyz"); + assert.ok(existsSync(result.worktreePath)); + }); + + it("reuses existing worktree at the same path", () => { + // Create first worktree + const first = createWorktree(tempDir, "task-reuse", "first"); + + // Create again at same path (same jobId) + const second = createWorktree(tempDir, "task-reuse", "first"); + + assert.equal(first.worktreePath, second.worktreePath); + assert.ok(existsSync(second.worktreePath)); + }); + + it("throws when path exists but is not a worktree", () => { + const worktreePath = join(tempDir, ".claude", "worktrees", "task-conflict"); + mkdirSync(worktreePath, { recursive: true }); + writeFileSync(join(worktreePath, "file.txt"), "not a worktree"); + + assert.throws( + () => createWorktree(tempDir, "task-conflict", "test"), + /Worktree path already exists/ + ); + }); +}); From 7ca81309518f19de8bae0429c62d3e99990c004a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BD=BB=E9=A3=8F?= Date: Wed, 20 May 2026 08:38:02 -0700 Subject: [PATCH 03/35] codex: add thread exclusivity warning to rescue command Document that users should not manually run `codex resume` on an active thread while a task is running. The Codex backend enforces single-turn exclusivity per thread, which causes CLI sessions to block when attempting to resume an occupied thread. --- plugins/codex/commands/rescue.md | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/codex/commands/rescue.md b/plugins/codex/commands/rescue.md index 6ffd7cb8..b912389e 100644 --- a/plugins/codex/commands/rescue.md +++ b/plugins/codex/commands/rescue.md @@ -64,3 +64,4 @@ Operating rules: - Leave `--resume` and `--fresh` in the forwarded request. The subagent handles that routing when it builds the `task` command. - If the helper reports that Codex is missing or unauthenticated, stop and tell the user to run `/codex:setup`. - If the user did not supply a request, ask what Codex should investigate or fix. +- **Thread exclusivity**: While a Codex task is running, do not manually run `codex resume` on the same thread from a terminal. The Codex backend enforces single-turn exclusivity per thread, and attempting to resume an active thread will block or pause your CLI session. Wait for the task to complete (check `/codex:status`), or use `/codex:cancel` to stop the task first. If you need to run Codex in parallel, start a fresh thread with `codex` (without `--resume`). From 82af00d736546bbe9e0f865e29e1dca9f37466fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BD=BB=E9=A3=8F?= Date: Wed, 20 May 2026 08:48:26 -0700 Subject: [PATCH 04/35] test: add worktree-b test file --- tests/worktree-test-b.test.mjs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 tests/worktree-test-b.test.mjs diff --git a/tests/worktree-test-b.test.mjs b/tests/worktree-test-b.test.mjs new file mode 100644 index 00000000..fb52be2a --- /dev/null +++ b/tests/worktree-test-b.test.mjs @@ -0,0 +1,19 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' + +describe('Worktree B Test', () => { + it('should pass a boolean check', () => { + assert.strictEqual(true, true) + }) + + it('should handle object spread', () => { + const base = { a: 1, b: 2 } + const extended = { ...base, c: 3 } + assert.deepStrictEqual(extended, { a: 1, b: 2, c: 3 }) + }) + + it('should validate array length', () => { + const items = ['x', 'y', 'z'] + assert.strictEqual(items.length, 3) + }) +}) From 19b55fbe300cb227a680edba60f539b188b66aab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BD=BB=E9=A3=8F?= Date: Wed, 20 May 2026 08:48:30 -0700 Subject: [PATCH 05/35] test: add worktree-a test file --- tests/worktree-test-a.test.mjs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 tests/worktree-test-a.test.mjs diff --git a/tests/worktree-test-a.test.mjs b/tests/worktree-test-a.test.mjs new file mode 100644 index 00000000..fee365d5 --- /dev/null +++ b/tests/worktree-test-a.test.mjs @@ -0,0 +1,18 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' + +describe('Worktree A Test', () => { + it('should pass a basic assertion', () => { + assert.strictEqual(1 + 1, 2) + }) + + it('should handle string operations', () => { + const greeting = `Hello, ${'World'}` + assert.strictEqual(greeting, 'Hello, World') + }) + + it('should work with arrays', () => { + const arr = [1, 2, 3] + assert.deepStrictEqual([...arr, 4], [1, 2, 3, 4]) + }) +}) From 8da7ac3aff0d2927731d65d4b0d1e6e26c627e2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BD=BB=E9=A3=8F?= Date: Wed, 20 May 2026 08:52:29 -0700 Subject: [PATCH 06/35] docs: add --worktree docs, thread exclusivity warning, and Chinese README - Update README with --worktree isolation mode and sandbox_mode config - Add thread exclusivity warning for /codex:rescue - Add Chinese translation (README.zh-CN.md) --- README.md | 19 ++- README.zh-CN.md | 318 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 336 insertions(+), 1 deletion(-) create mode 100644 README.zh-CN.md diff --git a/README.md b/README.md index 458c39fb..3f1b12cc 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Codex plugin for Claude Code +**[中文文档](README.zh-CN.md)** + Use Codex from inside Claude Code for code reviews or to delegate tasks to Codex. This plugin is for Claude Code users who want an easy way to start using Codex from the workflow @@ -137,7 +139,9 @@ Use it when you want Codex to: > [!NOTE] > Depending on the task and the model you choose these tasks might take a long time and it's generally recommended to force the task to be in the background or move the agent to the background. -It supports `--background`, `--wait`, `--resume`, and `--fresh`. If you omit `--resume` and `--fresh`, the plugin can offer to continue the latest rescue thread for this repo. +It supports `--background`, `--wait`, `--worktree`, `--resume`, and `--fresh`. If you omit `--resume` and `--fresh`, the plugin can offer to continue the latest rescue thread for this repo. + +**Sandbox mode.** Task mode reads `sandbox_mode` from your Codex config (`~/.codex/config.toml` or `.codex/config.toml`). If not configured, it falls back to `workspace-write` (when `--write` is set) or `read-only`. Examples: @@ -148,6 +152,7 @@ Examples: /codex:rescue --model gpt-5.4-mini --effort medium investigate the flaky integration test /codex:rescue --model spark fix the issue quickly /codex:rescue --background investigate the regression +/codex:rescue --worktree investigate and fix the failing integration test ``` You can also just ask for a task to be delegated to Codex: @@ -161,6 +166,10 @@ Ask Codex to redesign the database connection to be more resilient. - if you do not pass `--model` or `--effort`, Codex chooses its own defaults. - if you say `spark`, the plugin maps that to `gpt-5.3-codex-spark` - follow-up rescue requests can continue the latest Codex task in the repo +- `--worktree` creates an isolated git worktree under `.claude/worktrees//` on a dedicated branch so Codex can work without touching your main working directory. `--worktree` and `--resume` are mutually exclusive. + +> [!WARNING] +> **Thread exclusivity**: While a Codex task is running, do not manually run `codex resume` on the same thread from a terminal. The Codex backend enforces single-turn exclusivity per thread, and attempting to resume an active thread will block or pause your CLI session. Wait for the task to complete (check `/codex:status`), or use `/codex:cancel` to stop the task first. If you need to run Codex in parallel, start a fresh thread with `codex` (without `--resume`). ### `/codex:status` @@ -242,6 +251,14 @@ When the review gate is enabled, the plugin uses a `Stop` hook to run a targeted /codex:rescue --background investigate the flaky test ``` +### Isolated Work With `--worktree` + +```bash +/codex:rescue --worktree fix the broken auth middleware +``` + +Codex works in `.claude/worktrees//` on a separate branch, leaving your main working directory untouched. This is useful when you want Codex to make changes without affecting your current branch. + Then check in with: ```bash diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 00000000..8b25b8bb --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,318 @@ +# Claude Code 的 Codex 插件 + +**[English](README.md)** + +在 Claude Code 中使用 Codex 进行代码审查或将任务委派给 Codex。 + +本插件面向 Claude Code 用户,提供一种便捷方式,让你在现有工作流中轻松使用 Codex。 + + + +## 功能一览 + +- `/codex:review` — 常规只读 Codex 代码审查 +- `/codex:adversarial-review` — 可引导的对抗性审查 +- `/codex:rescue`、`/codex:status`、`/codex:result`、`/codex:cancel` — 委派任务和管理后台作业 + +## 环境要求 + +- **ChatGPT 订阅(含免费版)或 OpenAI API Key。** + - 使用量将计入你的 Codex 用量配额。[了解更多](https://developers.openai.com/codex/pricing)。 +- **Node.js 18.18 或更高版本** + +## 安装 + +在 Claude Code 中添加插件市场: + +```bash +/plugin marketplace add openai/codex-plugin-cc +``` + +安装插件: + +```bash +/plugin install codex@openai-codex +``` + +重新加载插件: + +```bash +/reload-plugins +``` + +然后运行: + +```bash +/codex:setup +``` + +`/codex:setup` 会检测 Codex 是否就绪。如果 Codex 未安装且 npm 可用,它会提示你自动安装。 + +如果你想手动安装 Codex: + +```bash +npm install -g @openai/codex +``` + +如果 Codex 已安装但尚未登录,运行: + +```bash +!codex login +``` + +安装完成后,你应该能看到: + +- 下方列出的斜杠命令 +- `/agents` 中的 `codex:codex-rescue` 子代理 + +最简单的首次运行方式: + +```bash +/codex:review --background +/codex:status +/codex:result +``` + +## 用法 + +### `/codex:review` + +对当前代码运行常规的 Codex 审查。审查质量与直接在 Codex 中运行 `/review` 相同。 + +> [!NOTE] +> 多文件变更的代码审查可能耗时较长,通常建议在后台运行。 + +适用场景: + +- 审查当前未提交的变更 +- 审查当前分支与基础分支(如 `main`)的差异 + +使用 `--base ` 进行分支对比审查。支持 `--wait` 和 `--background`。该命令不可引导,不接受自定义关注文本。如需针对特定决策或风险区域进行挑战,请使用 [`/codex:adversarial-review`](#codexadversarial-review)。 + +示例: + +```bash +/codex:review +/codex:review --base main +/codex:review --background +``` + +该命令为只读,不会执行任何修改。在后台运行时,可使用 [`/codex:status`](#codexstatus) 查看进度,使用 [`/codex:cancel`](#codexcancel) 取消正在进行的任务。 + +### `/codex:adversarial-review` + +运行**可引导的**对抗性审查,质疑所选实现和设计。 + +可用于压力测试假设、权衡取舍、故障模式,以及是否有更安全或更简单的替代方案。 + +使用与 `/codex:review` 相同的审查目标选择方式,包括 `--base ` 进行分支审查。支持 `--wait` 和 `--background`。与 `/codex:review` 不同,它可以在标志后附加额外的关注文本。 + +适用场景: + +- 发布前审查,挑战方向而不仅仅是代码细节 +- 聚焦于设计选择、权衡、隐含假设和替代方案的审查 +- 针对特定风险区域的压力测试,如认证、数据丢失、回滚、竞态条件或可靠性 + +示例: + +```bash +/codex:adversarial-review +/codex:adversarial-review --base main challenge whether this was the right caching and retry design +/codex:adversarial-review --background look for race conditions and question the chosen approach +``` + +该命令为只读,不会修复代码。 + +### `/codex:rescue` + +通过 `codex:codex-rescue` 子代理将任务交给 Codex。 + +适用场景: + +- 调查 bug +- 尝试修复 +- 继续之前的 Codex 任务 +- 使用更小的模型进行更快或更经济的处理 + +> [!NOTE] +> 根据任务和所选模型的不同,这些任务可能耗时较长,通常建议在后台运行或将代理移至后台。 + +支持 `--background`、`--wait`、`--worktree`、`--resume` 和 `--fresh`。如果省略 `--resume` 和 `--fresh`,插件会提示是否继续该仓库最近的 rescue 线程。 + +**沙箱模式。** 任务模式会从你的 Codex 配置文件(`~/.codex/config.toml` 或 `.codex/config.toml`)中读取 `sandbox_mode`。如果未配置,则回退到 `workspace-write`(当设置了 `--write` 时)或 `read-only`。 + +示例: + +```bash +/codex:rescue investigate why the tests started failing +/codex:rescue fix the failing test with the smallest safe patch +/codex:rescue --resume apply the top fix from the last run +/codex:rescue --model gpt-5.4-mini --effort medium investigate the flaky integration test +/codex:rescue --model spark fix the issue quickly +/codex:rescue --background investigate the regression +/codex:rescue --worktree investigate and fix the failing integration test +``` + +你也可以直接用自然语言将任务委派给 Codex: + +```text +Ask Codex to redesign the database connection to be more resilient. +``` + +**说明:** + +- 如果不传 `--model` 或 `--effort`,Codex 会自行选择默认值。 +- 如果使用 `spark`,插件会映射到 `gpt-5.3-codex-spark`。 +- 后续 rescue 请求可以继续该仓库中最近的 Codex 任务。 +- `--worktree` 会在 `.claude/worktrees//` 下创建一个隔离的 git worktree,使用独立分支,让 Codex 在不影响你主工作目录的情况下工作。`--worktree` 和 `--resume` 互斥。 + +> [!WARNING] +> **线程独占性**:Codex 任务运行期间,不要在终端中手动对同一线程执行 `codex resume`。Codex 后端对每个线程强制执行单轮独占,尝试 resume 一个活跃线程会阻塞或暂停你的 CLI 会话。请等待任务完成(通过 `/codex:status` 查看),或先使用 `/codex:cancel` 停止任务。如需并行运行 Codex,请用 `codex`(不带 `--resume`)启动一个新线程。 + +### `/codex:status` + +显示当前仓库中正在运行和近期的 Codex 作业。 + +示例: + +```bash +/codex:status +/codex:status task-abc123 +``` + +用途: + +- 查看后台任务的进度 +- 查看最近完成的作业 +- 确认任务是否仍在运行 + +### `/codex:result` + +显示已完成作业的最终 Codex 输出。如果可用,还会包含 Codex 会话 ID,你可以通过 `codex resume ` 直接在 Codex 中重新打开该次运行。 + +示例: + +```bash +/codex:result +/codex:result task-abc123 +``` + +### `/codex:cancel` + +取消正在运行的后台 Codex 作业。 + +示例: + +```bash +/codex:cancel +/codex:cancel task-abc123 +``` + +### `/codex:setup` + +检查 Codex 是否已安装并完成认证。如果 Codex 未安装且 npm 可用,它会提示你自动安装。 + +你也可以用 `/codex:setup` 管理可选的审查门控。 + +#### 启用审查门控 + +```bash +/codex:setup --enable-review-gate +/codex:setup --disable-review-gate +``` + +启用审查门控后,插件会使用 `Stop` 钩子对 Claude 的响应运行定向 Codex 审查。如果审查发现问题,停止操作会被阻止,让 Claude 先处理这些问题。 + +> [!WARNING] +> 审查门控可能会产生长时间运行的 Claude/Codex 循环,并快速消耗用量配额。仅在计划主动监控会话时启用。 + +## 典型工作流 + +### 发布前审查 + +```bash +/codex:review +``` + +### 将问题交给 Codex + +```bash +/codex:rescue investigate why the build is failing in CI +``` + +### 启动长时间运行的任务 + +```bash +/codex:adversarial-review --background +/codex:rescue --background investigate the flaky test +``` + +### 使用 `--worktree` 隔离工作 + +```bash +/codex:rescue --worktree fix the broken auth middleware +``` + +Codex 在 `.claude/worktrees//` 的独立分支上工作,不会影响你的主工作目录。当你希望 Codex 进行修改但不影响当前分支时非常有用。 + +然后查看进度: + +```bash +/codex:status +/codex:result +``` + +## Codex 集成 + +Codex 插件封装了 [Codex app server](https://developers.openai.com/codex/app-server)。它使用你环境中已安装的全局 `codex` 二进制文件,并[应用相同的配置](https://developers.openai.com/codex/config-basic)。 + +### 常用配置 + +如果你想修改插件使用的默认推理强度或默认模型,可以在用户级或项目级的 `config.toml` 中定义。例如,要在特定项目中始终使用 `gpt-5.4-mini` 并将强度设为 `high`,可以在你启动 Claude 的目录根下创建 `.codex/config.toml` 文件并添加: + +```toml +model = "gpt-5.4-mini" +model_reasoning_effort = "high" +``` + +配置的加载顺序: + +- 用户级配置:`~/.codex/config.toml` +- 项目级覆盖:`.codex/config.toml` +- 项目级覆盖仅在[项目被信任](https://developers.openai.com/codex/config-advanced#project-config-files-codexconfigtoml)时才会加载 + +更多[配置选项](https://developers.openai.com/codex/config-reference)请查阅 Codex 文档。 + +### 将工作转移到 Codex + +委派的任务和任何[停止门控](#启用审查门控)运行也可以直接在 Codex 中恢复,只需运行 `codex resume`,并指定从 `/codex:result` 或 `/codex:status` 获取的会话 ID,或从列表中选择。 + +这样你可以审查 Codex 的工作或在那里继续工作。 + +## 常见问题 + +### 使用此插件需要单独的 Codex 账号吗? + +如果你已经在此机器上登录了 Codex,该账号应该可以直接使用。本插件使用你本地的 Codex CLI 认证状态。 + +如果你目前只使用 Claude Code 而从未使用过 Codex,你还需要使用 ChatGPT 账号或 API Key 登录 Codex。[Codex 可通过 ChatGPT 订阅使用](https://developers.openai.com/codex/pricing/),[`codex login`](https://developers.openai.com/codex/cli/reference/#codex-login) 同时支持 ChatGPT 和 API Key 登录。运行 `/codex:setup` 检查 Codex 是否就绪,如果未就绪则使用 `!codex login`。 + +### 插件是否使用独立的 Codex 运行时? + +不是。本插件通过你本地的 [Codex CLI](https://developers.openai.com/codex/cli/) 和同一台机器上的 [Codex app server](https://developers.openai.com/codex/app-server/) 进行委派。 + +这意味着: + +- 使用与你直接使用相同的 Codex 安装 +- 使用相同的本地认证状态 +- 使用相同的仓库检出和本地机器环境 + +### 会使用我现有的 Codex 配置吗? + +是的。如果你已经在使用 Codex,插件会读取相同的[配置](#常用配置)。 + +### 可以继续使用我现有的 API Key 或 Base URL 配置吗? + +可以。由于插件使用你本地的 Codex CLI,你现有的登录方式和配置都会继续生效。 + +如果你需要将内置的 OpenAI Provider 指向不同的端点,请在 [Codex 配置](https://developers.openai.com/codex/config-advanced/#config-and-state-locations)中设置 `openai_base_url`。 From 05df69a783b13abfb1faf9b5d7da5229fec1f301 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BD=BB=E9=A3=8F?= Date: Wed, 20 May 2026 08:53:55 -0700 Subject: [PATCH 07/35] chore: remove temporary worktree test files --- tests/worktree-test-a.test.mjs | 18 ------------------ tests/worktree-test-b.test.mjs | 19 ------------------- 2 files changed, 37 deletions(-) delete mode 100644 tests/worktree-test-a.test.mjs delete mode 100644 tests/worktree-test-b.test.mjs diff --git a/tests/worktree-test-a.test.mjs b/tests/worktree-test-a.test.mjs deleted file mode 100644 index fee365d5..00000000 --- a/tests/worktree-test-a.test.mjs +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, it } from 'node:test' -import assert from 'node:assert/strict' - -describe('Worktree A Test', () => { - it('should pass a basic assertion', () => { - assert.strictEqual(1 + 1, 2) - }) - - it('should handle string operations', () => { - const greeting = `Hello, ${'World'}` - assert.strictEqual(greeting, 'Hello, World') - }) - - it('should work with arrays', () => { - const arr = [1, 2, 3] - assert.deepStrictEqual([...arr, 4], [1, 2, 3, 4]) - }) -}) diff --git a/tests/worktree-test-b.test.mjs b/tests/worktree-test-b.test.mjs deleted file mode 100644 index fb52be2a..00000000 --- a/tests/worktree-test-b.test.mjs +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, it } from 'node:test' -import assert from 'node:assert/strict' - -describe('Worktree B Test', () => { - it('should pass a boolean check', () => { - assert.strictEqual(true, true) - }) - - it('should handle object spread', () => { - const base = { a: 1, b: 2 } - const extended = { ...base, c: 3 } - assert.deepStrictEqual(extended, { a: 1, b: 2, c: 3 }) - }) - - it('should validate array length', () => { - const items = ['x', 'y', 'z'] - assert.strictEqual(items.length, 3) - }) -}) From f8c8092ea0533044078f1e52d2727867c037b328 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BD=BB=E9=A3=8F?= Date: Wed, 20 May 2026 18:34:22 -0700 Subject: [PATCH 08/35] feat: add live observer for Codex tasks with ANSI color support - Add JSONL event stream writer for structured event logging - Implement /codex:observe slash command for real-time task monitoring - Support ANSI colors for different event types (tool calls, commands, messages) - Add 26 unit tests for event stream and observer functionality - Bump version to 1.1.0 Features: - Real-time event streaming with fs.watch fallback to polling - Color-coded output: cyan (tools), blue (commands), yellow (file changes), etc. - Safe Ctrl+C exit without affecting running Codex tasks - Support for both new terminal observation and inline execution --- .claude/commands/opsx/apply.md | 152 ++++++++ .claude/commands/opsx/archive.md | 157 ++++++++ .claude/commands/opsx/explore.md | 173 +++++++++ .claude/commands/opsx/propose.md | 106 +++++ .claude/skills/openspec-apply-change/SKILL.md | 156 ++++++++ .../skills/openspec-archive-change/SKILL.md | 114 ++++++ .claude/skills/openspec-explore/SKILL.md | 288 ++++++++++++++ .claude/skills/openspec-propose/SKILL.md | 110 ++++++ .codex/skills/openspec-apply-change/SKILL.md | 156 ++++++++ .../skills/openspec-archive-change/SKILL.md | 114 ++++++ .codex/skills/openspec-explore/SKILL.md | 288 ++++++++++++++ .codex/skills/openspec-propose/SKILL.md | 110 ++++++ CLAUDE.md | 70 ++++ .../.openspec.yaml | 2 + .../2026-05-20-rescue-worktree-mode/design.md | 101 +++++ .../proposal.md | 35 ++ .../specs/worktree-lifecycle/spec.md | 43 ++ .../specs/worktree-output/spec.md | 66 ++++ .../specs/worktree-task-dispatch/spec.md | 74 ++++ .../2026-05-20-rescue-worktree-mode/tasks.md | 41 ++ .../codex-live-observer/.openspec.yaml | 2 + .../changes/codex-live-observer/design.md | 86 ++++ .../changes/codex-live-observer/proposal.md | 27 ++ .../specs/event-stream/spec.md | 79 ++++ .../specs/observe-command/spec.md | 118 ++++++ openspec/changes/codex-live-observer/tasks.md | 82 ++++ openspec/specs/worktree-lifecycle/spec.md | 43 ++ openspec/specs/worktree-output/spec.md | 66 ++++ openspec/specs/worktree-task-dispatch/spec.md | 74 ++++ plugins/codex/.claude-plugin/plugin.json | 2 +- plugins/codex/commands/observe.md | 48 +++ plugins/codex/scripts/codex-companion.mjs | 44 ++- plugins/codex/scripts/lib/event-stream.mjs | 56 +++ plugins/codex/scripts/lib/observe.mjs | 367 ++++++++++++++++++ plugins/codex/scripts/lib/state.mjs | 6 + plugins/codex/scripts/lib/tracked-jobs.mjs | 39 +- tests/event-stream.test.mjs | 109 ++++++ tests/observe.test.mjs | 150 +++++++ 38 files changed, 3742 insertions(+), 12 deletions(-) create mode 100644 .claude/commands/opsx/apply.md create mode 100644 .claude/commands/opsx/archive.md create mode 100644 .claude/commands/opsx/explore.md create mode 100644 .claude/commands/opsx/propose.md create mode 100644 .claude/skills/openspec-apply-change/SKILL.md create mode 100644 .claude/skills/openspec-archive-change/SKILL.md create mode 100644 .claude/skills/openspec-explore/SKILL.md create mode 100644 .claude/skills/openspec-propose/SKILL.md create mode 100644 .codex/skills/openspec-apply-change/SKILL.md create mode 100644 .codex/skills/openspec-archive-change/SKILL.md create mode 100644 .codex/skills/openspec-explore/SKILL.md create mode 100644 .codex/skills/openspec-propose/SKILL.md create mode 100644 CLAUDE.md create mode 100644 openspec/changes/archive/2026-05-20-rescue-worktree-mode/.openspec.yaml create mode 100644 openspec/changes/archive/2026-05-20-rescue-worktree-mode/design.md create mode 100644 openspec/changes/archive/2026-05-20-rescue-worktree-mode/proposal.md create mode 100644 openspec/changes/archive/2026-05-20-rescue-worktree-mode/specs/worktree-lifecycle/spec.md create mode 100644 openspec/changes/archive/2026-05-20-rescue-worktree-mode/specs/worktree-output/spec.md create mode 100644 openspec/changes/archive/2026-05-20-rescue-worktree-mode/specs/worktree-task-dispatch/spec.md create mode 100644 openspec/changes/archive/2026-05-20-rescue-worktree-mode/tasks.md create mode 100644 openspec/changes/codex-live-observer/.openspec.yaml create mode 100644 openspec/changes/codex-live-observer/design.md create mode 100644 openspec/changes/codex-live-observer/proposal.md create mode 100644 openspec/changes/codex-live-observer/specs/event-stream/spec.md create mode 100644 openspec/changes/codex-live-observer/specs/observe-command/spec.md create mode 100644 openspec/changes/codex-live-observer/tasks.md create mode 100644 openspec/specs/worktree-lifecycle/spec.md create mode 100644 openspec/specs/worktree-output/spec.md create mode 100644 openspec/specs/worktree-task-dispatch/spec.md create mode 100644 plugins/codex/commands/observe.md create mode 100644 plugins/codex/scripts/lib/event-stream.mjs create mode 100644 plugins/codex/scripts/lib/observe.mjs create mode 100644 tests/event-stream.test.mjs create mode 100644 tests/observe.test.mjs diff --git a/.claude/commands/opsx/apply.md b/.claude/commands/opsx/apply.md new file mode 100644 index 00000000..ae14f0f5 --- /dev/null +++ b/.claude/commands/opsx/apply.md @@ -0,0 +1,152 @@ +--- +name: "OPSX: Apply" +description: Implement tasks from an OpenSpec change (Experimental) +category: Workflow +tags: [workflow, artifacts, experimental] +--- + +Implement tasks from an OpenSpec change. + +**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. + +**Steps** + +1. **Select the change** + + If a name is provided, use it. Otherwise: + - Infer from conversation context if the user mentioned a change + - Auto-select if only one active change exists + - If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select + + Always announce: "Using change: " and how to override (e.g., `/opsx:apply `). + +2. **Check status to understand the schema** + ```bash + openspec status --change "" --json + ``` + Parse the JSON to understand: + - `schemaName`: The workflow being used (e.g., "spec-driven") + - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others) + +3. **Get apply instructions** + + ```bash + openspec instructions apply --change "" --json + ``` + + This returns: + - `contextFiles`: artifact ID -> array of concrete file paths (varies by schema) + - Progress (total, complete, remaining) + - Task list with status + - Dynamic instruction based on current state + + **Handle states:** + - If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx:continue` + - If `state: "all_done"`: congratulate, suggest archive + - Otherwise: proceed to implementation + +4. **Read context files** + + Read every file path listed under `contextFiles` from the apply instructions output. + The files depend on the schema being used: + - **spec-driven**: proposal, specs, design, tasks + - Other schemas: follow the contextFiles from CLI output + +5. **Show current progress** + + Display: + - Schema being used + - Progress: "N/M tasks complete" + - Remaining tasks overview + - Dynamic instruction from CLI + +6. **Implement tasks (loop until done or blocked)** + + For each pending task: + - Show which task is being worked on + - Make the code changes required + - Keep changes minimal and focused + - Mark task complete in the tasks file: `- [ ]` → `- [x]` + - Continue to next task + + **Pause if:** + - Task is unclear → ask for clarification + - Implementation reveals a design issue → suggest updating artifacts + - Error or blocker encountered → report and wait for guidance + - User interrupts + +7. **On completion or pause, show status** + + Display: + - Tasks completed this session + - Overall progress: "N/M tasks complete" + - If all done: suggest archive + - If paused: explain why and wait for guidance + +**Output During Implementation** + +``` +## Implementing: (schema: ) + +Working on task 3/7: +[...implementation happening...] +✓ Task complete + +Working on task 4/7: +[...implementation happening...] +✓ Task complete +``` + +**Output On Completion** + +``` +## Implementation Complete + +**Change:** +**Schema:** +**Progress:** 7/7 tasks complete ✓ + +### Completed This Session +- [x] Task 1 +- [x] Task 2 +... + +All tasks complete! You can archive this change with `/opsx:archive`. +``` + +**Output On Pause (Issue Encountered)** + +``` +## Implementation Paused + +**Change:** +**Schema:** +**Progress:** 4/7 tasks complete + +### Issue Encountered + + +**Options:** +1.