From 16b613c8b6e6cb11fd28fc6de658e4a970fe1f3c Mon Sep 17 00:00:00 2001 From: D2758695161 <13510221939@163.com> Date: Sun, 5 Apr 2026 05:02:37 +0800 Subject: [PATCH 1/2] feat: add --full-access flag for danger-full-access sandbox Implements #145 --- plugins/codex/scripts/codex-companion.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 201d1c7a..17c61e8a 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -457,7 +457,7 @@ async function executeTaskRun(request) { defaultPrompt: resumeThreadId ? DEFAULT_CONTINUE_PROMPT : "", model: request.model, effort: request.effort, - sandbox: request.write ? "workspace-write" : "read-only", + sandbox: request.fullAccess ? "danger-full-access" : (request.write ? "workspace-write" : "read-only"), onProgress: request.onProgress, persistThread: true, threadName: resumeThreadId ? null : buildPersistentTaskThreadName(request.prompt || DEFAULT_CONTINUE_PROMPT) @@ -704,7 +704,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", "full-access", "resume-last", "resume", "fresh", "background"], aliasMap: { m: "model" } From d44135bb5a85843fd0773cbb852e988dc2b321d3 Mon Sep 17 00:00:00 2001 From: D2758695161 <13510221939@163.com> Date: Tue, 14 Apr 2026 01:31:09 +0800 Subject: [PATCH 2/2] Fix #202: zombie job blocks subsequent task calls when process crashes When a Codex task process crashes or is killed externally, its job record remains in status running permanently. All subsequent task calls in the same Claude session fail with Task {job-id} is still running. In resolveLatestTrackedTaskThread(), before blocking on an active task, verify the PID is actually alive using process.kill(pid, 0). If the process is dead, mark the job as failed and continue - rather than throwing an error that blocks all subsequent task calls. Fixes #202 --- plugins/codex/scripts/codex-companion.mjs | 2038 +++++++++++---------- 1 file changed, 1031 insertions(+), 1007 deletions(-) diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 17c61e8a..762c65d9 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -1,1007 +1,1031 @@ -#!/usr/bin/env node - -import { spawn } from "node:child_process"; -import fs from "node:fs"; -import path from "node:path"; -import process from "node:process"; -import { fileURLToPath } from "node:url"; - -import { parseArgs, splitRawArgumentString } from "./lib/args.mjs"; -import { - buildPersistentTaskThreadName, - DEFAULT_CONTINUE_PROMPT, - findLatestTaskThread, - getCodexAvailability, - getCodexLoginStatus, - getSessionRuntimeStatus, - interruptAppServerTurn, - parseStructuredOutput, - readOutputSchema, - runAppServerReview, - runAppServerTurn - } from "./lib/codex.mjs"; -import { readStdinIfPiped } from "./lib/fs.mjs"; -import { collectReviewContext, ensureGitRepository, resolveReviewTarget } from "./lib/git.mjs"; -import { binaryAvailable, terminateProcessTree } from "./lib/process.mjs"; -import { loadPromptTemplate, interpolateTemplate } from "./lib/prompts.mjs"; -import { - generateJobId, - getConfig, - listJobs, - setConfig, - upsertJob, - writeJobFile -} from "./lib/state.mjs"; -import { - buildSingleJobSnapshot, - buildStatusSnapshot, - readStoredJob, - resolveCancelableJob, - resolveResultJob, - sortJobsNewestFirst -} from "./lib/job-control.mjs"; -import { - appendLogLine, - createJobLogFile, - createJobProgressUpdater, - createJobRecord, - createProgressReporter, - nowIso, - runTrackedJob, - SESSION_ID_ENV -} from "./lib/tracked-jobs.mjs"; -import { resolveWorkspaceRoot } from "./lib/workspace.mjs"; -import { - renderNativeReviewResult, - renderReviewResult, - renderStoredJobResult, - renderCancelReport, - renderJobStatusReport, - renderSetupReport, - renderStatusReport, - renderTaskResult -} from "./lib/render.mjs"; - -const ROOT_DIR = path.resolve(fileURLToPath(new URL("..", import.meta.url))); -const REVIEW_SCHEMA = path.join(ROOT_DIR, "schemas", "review-output.schema.json"); -const DEFAULT_STATUS_WAIT_TIMEOUT_MS = 240000; -const DEFAULT_STATUS_POLL_INTERVAL_MS = 2000; -const VALID_REASONING_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh"]); -const MODEL_ALIASES = new Map([["spark", "gpt-5.3-codex-spark"]]); -const STOP_REVIEW_TASK_MARKER = "Run a stop-gate review of the previous Claude turn."; - -function printUsage() { - console.log( - [ - "Usage:", - " node scripts/codex-companion.mjs setup [--enable-review-gate|--disable-review-gate] [--json]", - " node scripts/codex-companion.mjs review [--wait|--background] [--base ] [--scope ]", - " node scripts/codex-companion.mjs adversarial-review [--wait|--background] [--base ] [--scope ] [focus text]", - " node scripts/codex-companion.mjs task [--background] [--write] [--resume-last|--resume|--fresh] [--model ] [--effort ] [prompt]", - " node scripts/codex-companion.mjs status [job-id] [--all] [--json]", - " node scripts/codex-companion.mjs result [job-id] [--json]", - " node scripts/codex-companion.mjs cancel [job-id] [--json]" - ].join("\n") - ); -} - -function outputResult(value, asJson) { - if (asJson) { - console.log(JSON.stringify(value, null, 2)); - } else { - process.stdout.write(value); - } -} - -function outputCommandResult(payload, rendered, asJson) { - outputResult(asJson ? payload : rendered, asJson); -} - -function normalizeRequestedModel(model) { - if (model == null) { - return null; - } - const normalized = String(model).trim(); - if (!normalized) { - return null; - } - return MODEL_ALIASES.get(normalized.toLowerCase()) ?? normalized; -} - -function normalizeReasoningEffort(effort) { - if (effort == null) { - return null; - } - const normalized = String(effort).trim().toLowerCase(); - if (!normalized) { - return null; - } - if (!VALID_REASONING_EFFORTS.has(normalized)) { - throw new Error( - `Unsupported reasoning effort "${effort}". Use one of: none, minimal, low, medium, high, xhigh.` - ); - } - return normalized; -} - -function normalizeArgv(argv) { - if (argv.length === 1) { - const [raw] = argv; - if (!raw || !raw.trim()) { - return []; - } - return splitRawArgumentString(raw); - } - return argv; -} - -function parseCommandInput(argv, config = {}) { - return parseArgs(normalizeArgv(argv), { - ...config, - aliasMap: { - C: "cwd", - ...(config.aliasMap ?? {}) - } - }); -} - -function resolveCommandCwd(options = {}) { - return options.cwd ? path.resolve(process.cwd(), options.cwd) : process.cwd(); -} - -function resolveCommandWorkspace(options = {}) { - return resolveWorkspaceRoot(resolveCommandCwd(options)); -} - -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function shorten(text, limit = 96) { - const normalized = String(text ?? "").trim().replace(/\s+/g, " "); - if (!normalized) { - return ""; - } - if (normalized.length <= limit) { - return normalized; - } - return `${normalized.slice(0, limit - 3)}...`; -} - -function firstMeaningfulLine(text, fallback) { - const line = String(text ?? "") - .split(/\r?\n/) - .map((value) => value.trim()) - .find(Boolean); - return line ?? fallback; -} - -function buildSetupReport(cwd, actionsTaken = []) { - const workspaceRoot = resolveWorkspaceRoot(cwd); - const nodeStatus = binaryAvailable("node", ["--version"], { cwd }); - const npmStatus = binaryAvailable("npm", ["--version"], { cwd }); - const codexStatus = getCodexAvailability(cwd); - const authStatus = getCodexLoginStatus(cwd); - const config = getConfig(workspaceRoot); - - const nextSteps = []; - if (!codexStatus.available) { - nextSteps.push("Install Codex with `npm install -g @openai/codex`."); - } - if (codexStatus.available && !authStatus.loggedIn) { - nextSteps.push("Run `!codex login`."); - nextSteps.push("If browser login is blocked, retry with `!codex login --device-auth` or `!codex login --with-api-key`."); - } - if (!config.stopReviewGate) { - nextSteps.push("Optional: run `/codex:setup --enable-review-gate` to require a fresh review before stop."); - } - - return { - ready: nodeStatus.available && codexStatus.available && authStatus.loggedIn, - node: nodeStatus, - npm: npmStatus, - codex: codexStatus, - auth: authStatus, - sessionRuntime: getSessionRuntimeStatus(), - reviewGateEnabled: Boolean(config.stopReviewGate), - actionsTaken, - nextSteps - }; -} - -function handleSetup(argv) { - const { options } = parseCommandInput(argv, { - valueOptions: ["cwd"], - booleanOptions: ["json", "enable-review-gate", "disable-review-gate"] - }); - - if (options["enable-review-gate"] && options["disable-review-gate"]) { - throw new Error("Choose either --enable-review-gate or --disable-review-gate."); - } - - const cwd = resolveCommandCwd(options); - const workspaceRoot = resolveCommandWorkspace(options); - const actionsTaken = []; - - if (options["enable-review-gate"]) { - setConfig(workspaceRoot, "stopReviewGate", true); - actionsTaken.push(`Enabled the stop-time review gate for ${workspaceRoot}.`); - } else if (options["disable-review-gate"]) { - setConfig(workspaceRoot, "stopReviewGate", false); - actionsTaken.push(`Disabled the stop-time review gate for ${workspaceRoot}.`); - } - - const finalReport = buildSetupReport(cwd, actionsTaken); - outputResult(options.json ? finalReport : renderSetupReport(finalReport), options.json); -} - -function buildAdversarialReviewPrompt(context, focusText) { - const template = loadPromptTemplate(ROOT_DIR, "adversarial-review"); - return interpolateTemplate(template, { - REVIEW_KIND: "Adversarial Review", - TARGET_LABEL: context.target.label, - USER_FOCUS: focusText || "No extra focus provided.", - REVIEW_INPUT: context.content - }); -} - -function ensureCodexReady(cwd) { - const authStatus = getCodexLoginStatus(cwd); - if (!authStatus.available) { - throw new Error("Codex CLI is not installed or is missing required runtime support. Install it with `npm install -g @openai/codex`, then rerun `/codex:setup`."); - } - if (!authStatus.loggedIn) { - throw new Error("Codex CLI is not authenticated. Run `!codex login` and retry."); - } -} - -function buildNativeReviewTarget(target) { - if (target.mode === "working-tree") { - return { type: "uncommittedChanges" }; - } - - if (target.mode === "branch") { - return { type: "baseBranch", branch: target.baseRef }; - } - - return null; -} - -function validateNativeReviewRequest(target, focusText) { - if (focusText.trim()) { - throw new Error( - `\`/codex:review\` now maps directly to the built-in reviewer and does not support custom focus text. Retry with \`/codex:adversarial-review ${focusText.trim()}\` for focused review instructions.` - ); - } - - const nativeTarget = buildNativeReviewTarget(target); - if (!nativeTarget) { - throw new Error("This `/codex:review` target is not supported by the built-in reviewer. Retry with `/codex:adversarial-review` for custom targeting."); - } - - return nativeTarget; -} - -function renderStatusPayload(report, asJson) { - return asJson ? report : renderStatusReport(report); -} - -function isActiveJobStatus(status) { - return status === "queued" || status === "running"; -} - -async function waitForSingleJobSnapshot(cwd, reference, options = {}) { - const timeoutMs = Math.max(0, Number(options.timeoutMs) || DEFAULT_STATUS_WAIT_TIMEOUT_MS); - const pollIntervalMs = Math.max(100, Number(options.pollIntervalMs) || DEFAULT_STATUS_POLL_INTERVAL_MS); - const deadline = Date.now() + timeoutMs; - let snapshot = buildSingleJobSnapshot(cwd, reference); - - while (isActiveJobStatus(snapshot.job.status) && Date.now() < deadline) { - await sleep(Math.min(pollIntervalMs, Math.max(0, deadline - Date.now()))); - snapshot = buildSingleJobSnapshot(cwd, reference); - } - - return { - ...snapshot, - waitTimedOut: isActiveJobStatus(snapshot.job.status), - timeoutMs - }; -} - -async function resolveLatestTrackedTaskThread(cwd, options = {}) { - const workspaceRoot = resolveWorkspaceRoot(cwd); - const jobs = sortJobsNewestFirst(listJobs(workspaceRoot)).filter((job) => job.id !== options.excludeJobId); - const activeTask = jobs.find((job) => job.jobClass === "task" && (job.status === "queued" || job.status === "running")); - if (activeTask) { - throw new Error(`Task ${activeTask.id} is still running. Use /codex:status before continuing it.`); - } - - const trackedTask = jobs.find((job) => job.jobClass === "task" && job.status === "completed" && job.threadId); - if (trackedTask) { - return { id: trackedTask.threadId }; - } - - return findLatestTaskThread(workspaceRoot); -} - -async function executeReviewRun(request) { - ensureCodexReady(request.cwd); - ensureGitRepository(request.cwd); - - const target = resolveReviewTarget(request.cwd, { - base: request.base, - scope: request.scope - }); - const focusText = request.focusText?.trim() ?? ""; - const reviewName = request.reviewName ?? "Review"; - if (reviewName === "Review") { - const reviewTarget = validateNativeReviewRequest(target, focusText); - const result = await runAppServerReview(request.cwd, { - target: reviewTarget, - model: request.model, - onProgress: request.onProgress - }); - const payload = { - review: reviewName, - target, - threadId: result.threadId, - sourceThreadId: result.sourceThreadId, - codex: { - status: result.status, - stderr: result.stderr, - stdout: result.reviewText, - reasoning: result.reasoningSummary - } - }; - const rendered = renderNativeReviewResult( - { - status: result.status, - stdout: result.reviewText, - stderr: result.stderr - }, - { reviewLabel: reviewName, targetLabel: target.label, reasoningSummary: result.reasoningSummary } - ); - - return { - exitStatus: result.status, - threadId: result.threadId, - turnId: result.turnId, - payload, - rendered, - summary: firstMeaningfulLine(result.reviewText, `${reviewName} completed.`), - jobTitle: `Codex ${reviewName}`, - jobClass: "review", - targetLabel: target.label - }; - } - - const context = collectReviewContext(request.cwd, target); - const prompt = buildAdversarialReviewPrompt(context, focusText); - const result = await runAppServerTurn(context.repoRoot, { - prompt, - model: request.model, - sandbox: "read-only", - outputSchema: readOutputSchema(REVIEW_SCHEMA), - onProgress: request.onProgress - }); - const parsed = parseStructuredOutput(result.finalMessage, { - status: result.status, - failureMessage: result.error?.message ?? result.stderr - }); - const payload = { - review: reviewName, - target, - threadId: result.threadId, - context: { - repoRoot: context.repoRoot, - branch: context.branch, - summary: context.summary - }, - codex: { - status: result.status, - stderr: result.stderr, - stdout: result.finalMessage, - reasoning: result.reasoningSummary - }, - result: parsed.parsed, - rawOutput: parsed.rawOutput, - parseError: parsed.parseError, - reasoningSummary: result.reasoningSummary - }; - - return { - exitStatus: result.status, - threadId: result.threadId, - turnId: result.turnId, - payload, - rendered: renderReviewResult(parsed, { - reviewLabel: reviewName, - targetLabel: context.target.label, - reasoningSummary: result.reasoningSummary - }), - summary: parsed.parsed?.summary ?? parsed.parseError ?? firstMeaningfulLine(result.finalMessage, `${reviewName} finished.`), - jobTitle: `Codex ${reviewName}`, - jobClass: "review", - targetLabel: context.target.label - }; -} - - -async function executeTaskRun(request) { - const workspaceRoot = resolveWorkspaceRoot(request.cwd); - ensureCodexReady(request.cwd); - - const taskMetadata = buildTaskRunMetadata({ - prompt: request.prompt, - resumeLast: request.resumeLast - }); - - let resumeThreadId = null; - if (request.resumeLast) { - const latestThread = await resolveLatestTrackedTaskThread(workspaceRoot, { - excludeJobId: request.jobId - }); - if (!latestThread) { - throw new Error("No previous Codex task thread was found for this repository."); - } - resumeThreadId = latestThread.id; - } - - if (!request.prompt && !resumeThreadId) { - throw new Error("Provide a prompt, a prompt file, piped stdin, or use --resume-last."); - } - - const result = await runAppServerTurn(workspaceRoot, { - resumeThreadId, - prompt: request.prompt, - defaultPrompt: resumeThreadId ? DEFAULT_CONTINUE_PROMPT : "", - model: request.model, - effort: request.effort, - sandbox: request.fullAccess ? "danger-full-access" : (request.write ? "workspace-write" : "read-only"), - onProgress: request.onProgress, - persistThread: true, - threadName: resumeThreadId ? null : buildPersistentTaskThreadName(request.prompt || DEFAULT_CONTINUE_PROMPT) - }); - - const rawOutput = typeof result.finalMessage === "string" ? result.finalMessage : ""; - const failureMessage = result.error?.message ?? result.stderr ?? ""; - const rendered = renderTaskResult( - { - rawOutput, - failureMessage, - reasoningSummary: result.reasoningSummary - }, - { - title: taskMetadata.title, - jobId: request.jobId ?? null, - write: Boolean(request.write) - } - ); - const payload = { - status: result.status, - threadId: result.threadId, - rawOutput, - touchedFiles: result.touchedFiles, - reasoningSummary: result.reasoningSummary - }; - - return { - exitStatus: result.status, - threadId: result.threadId, - turnId: result.turnId, - payload, - rendered, - summary: firstMeaningfulLine(rawOutput, firstMeaningfulLine(failureMessage, `${taskMetadata.title} finished.`)), - jobTitle: taskMetadata.title, - jobClass: "task", - write: Boolean(request.write) - }; -} - -function buildReviewJobMetadata(reviewName, target) { - return { - kind: reviewName === "Adversarial Review" ? "adversarial-review" : "review", - title: reviewName === "Review" ? "Codex Review" : `Codex ${reviewName}`, - summary: `${reviewName} ${target.label}` - }; -} - -function buildTaskRunMetadata({ prompt, resumeLast = false }) { - if (!resumeLast && String(prompt ?? "").includes(STOP_REVIEW_TASK_MARKER)) { - return { - title: "Codex Stop Gate Review", - summary: "Stop-gate review of previous Claude turn" - }; - } - - const title = resumeLast ? "Codex Resume" : "Codex Task"; - const fallbackSummary = resumeLast ? DEFAULT_CONTINUE_PROMPT : "Task"; - return { - title, - summary: shorten(prompt || fallbackSummary) - }; -} - -function renderQueuedTaskLaunch(payload) { - return `${payload.title} started in the background as ${payload.jobId}. Check /codex:status ${payload.jobId} for progress.\n`; -} - -function getJobKindLabel(kind, jobClass) { - if (kind === "adversarial-review") { - return "adversarial-review"; - } - return jobClass === "review" ? "review" : "rescue"; -} - -function createCompanionJob({ prefix, kind, title, workspaceRoot, jobClass, summary, write = false }) { - return createJobRecord({ - id: generateJobId(prefix), - kind, - kindLabel: getJobKindLabel(kind, jobClass), - title, - workspaceRoot, - jobClass, - summary, - write - }); -} - -function createTrackedProgress(job, options = {}) { - const logFile = options.logFile ?? createJobLogFile(job.workspaceRoot, job.id, job.title); - return { - logFile, - progress: createProgressReporter({ - stderr: Boolean(options.stderr), - logFile, - onEvent: createJobProgressUpdater(job.workspaceRoot, job.id) - }) - }; -} - -function buildTaskJob(workspaceRoot, taskMetadata, write) { - return createCompanionJob({ - prefix: "task", - kind: "task", - title: taskMetadata.title, - workspaceRoot, - jobClass: "task", - summary: taskMetadata.summary, - write - }); -} - -function buildTaskRequest({ cwd, model, effort, prompt, write, resumeLast, jobId }) { - return { - cwd, - model, - effort, - prompt, - write, - resumeLast, - jobId - }; -} - -function readTaskPrompt(cwd, options, positionals) { - if (options["prompt-file"]) { - return fs.readFileSync(path.resolve(cwd, options["prompt-file"]), "utf8"); - } - - const positionalPrompt = positionals.join(" "); - return positionalPrompt || readStdinIfPiped(); -} - -function requireTaskRequest(prompt, resumeLast) { - if (!prompt && !resumeLast) { - throw new Error("Provide a prompt, a prompt file, piped stdin, or use --resume-last."); - } -} - -async function runForegroundCommand(job, runner, options = {}) { - const { logFile, progress } = createTrackedProgress(job, { - logFile: options.logFile, - stderr: !options.json - }); - const execution = await runTrackedJob(job, () => runner(progress), { logFile }); - outputResult(options.json ? execution.payload : execution.rendered, options.json); - if (execution.exitStatus !== 0) { - process.exitCode = execution.exitStatus; - } - return execution; -} - -function spawnDetachedTaskWorker(cwd, jobId) { - const scriptPath = path.join(ROOT_DIR, "scripts", "codex-companion.mjs"); - const child = spawn(process.execPath, [scriptPath, "task-worker", "--cwd", cwd, "--job-id", jobId], { - cwd, - env: process.env, - detached: true, - stdio: "ignore", - windowsHide: true - }); - child.unref(); - return child; -} - -function enqueueBackgroundTask(cwd, job, request) { - const { logFile } = createTrackedProgress(job); - appendLogLine(logFile, "Queued for background execution."); - - const child = spawnDetachedTaskWorker(cwd, job.id); - const queuedRecord = { - ...job, - status: "queued", - phase: "queued", - pid: child.pid ?? null, - logFile, - request - }; - writeJobFile(job.workspaceRoot, job.id, queuedRecord); - upsertJob(job.workspaceRoot, queuedRecord); - - return { - payload: { - jobId: job.id, - status: "queued", - title: job.title, - summary: job.summary, - logFile - }, - logFile - }; -} - -async function handleReviewCommand(argv, config) { - const { options, positionals } = parseCommandInput(argv, { - valueOptions: ["base", "scope", "model", "cwd"], - booleanOptions: ["json", "background", "wait"], - aliasMap: { - m: "model" - } - }); - - const cwd = resolveCommandCwd(options); - const workspaceRoot = resolveCommandWorkspace(options); - const focusText = positionals.join(" ").trim(); - const target = resolveReviewTarget(cwd, { - base: options.base, - scope: options.scope - }); - - config.validateRequest?.(target, focusText); - const metadata = buildReviewJobMetadata(config.reviewName, target); - const job = createCompanionJob({ - prefix: "review", - kind: metadata.kind, - title: metadata.title, - workspaceRoot, - jobClass: "review", - summary: metadata.summary - }); - await runForegroundCommand( - job, - (progress) => - executeReviewRun({ - cwd, - base: options.base, - scope: options.scope, - model: options.model, - focusText, - reviewName: config.reviewName, - onProgress: progress - }), - { json: options.json } - ); -} - -async function handleReview(argv) { - return handleReviewCommand(argv, { - reviewName: "Review", - validateRequest: validateNativeReviewRequest - }); -} - -async function handleTask(argv) { - const { options, positionals } = parseCommandInput(argv, { - valueOptions: ["model", "effort", "cwd", "prompt-file"], - booleanOptions: ["json", "write", "full-access", "resume-last", "resume", "fresh", "background"], - aliasMap: { - m: "model" - } - }); - - const cwd = resolveCommandCwd(options); - const workspaceRoot = resolveCommandWorkspace(options); - const model = normalizeRequestedModel(options.model); - const effort = normalizeReasoningEffort(options.effort); - const prompt = readTaskPrompt(cwd, options, positionals); - - const resumeLast = Boolean(options["resume-last"] || options.resume); - const fresh = Boolean(options.fresh); - if (resumeLast && fresh) { - throw new Error("Choose either --resume/--resume-last or --fresh."); - } - const write = Boolean(options.write); - const taskMetadata = buildTaskRunMetadata({ - prompt, - resumeLast - }); - - if (options.background) { - ensureCodexReady(cwd); - requireTaskRequest(prompt, resumeLast); - - const job = buildTaskJob(workspaceRoot, taskMetadata, write); - const request = buildTaskRequest({ - cwd, - model, - effort, - prompt, - write, - resumeLast, - jobId: job.id - }); - const { payload } = enqueueBackgroundTask(cwd, job, request); - outputCommandResult(payload, renderQueuedTaskLaunch(payload), options.json); - return; - } - - const job = buildTaskJob(workspaceRoot, taskMetadata, write); - await runForegroundCommand( - job, - (progress) => - executeTaskRun({ - cwd, - model, - effort, - prompt, - write, - resumeLast, - jobId: job.id, - onProgress: progress - }), - { json: options.json } - ); -} - -async function handleTaskWorker(argv) { - const { options } = parseCommandInput(argv, { - valueOptions: ["cwd", "job-id"] - }); - - if (!options["job-id"]) { - throw new Error("Missing required --job-id for task-worker."); - } - - const cwd = resolveCommandCwd(options); - const workspaceRoot = resolveCommandWorkspace(options); - const storedJob = readStoredJob(workspaceRoot, options["job-id"]); - if (!storedJob) { - throw new Error(`No stored job found for ${options["job-id"]}.`); - } - - const request = storedJob.request; - if (!request || typeof request !== "object") { - throw new Error(`Stored job ${options["job-id"]} is missing its task request payload.`); - } - - const { logFile, progress } = createTrackedProgress( - { - ...storedJob, - workspaceRoot - }, - { - logFile: storedJob.logFile ?? null - } - ); - await runTrackedJob( - { - ...storedJob, - workspaceRoot, - logFile - }, - () => - executeTaskRun({ - ...request, - onProgress: progress - }), - { logFile } - ); -} - -async function handleStatus(argv) { - const { options, positionals } = parseCommandInput(argv, { - valueOptions: ["cwd", "timeout-ms", "poll-interval-ms"], - booleanOptions: ["json", "all", "wait"] - }); - - const cwd = resolveCommandCwd(options); - const reference = positionals[0] ?? ""; - if (reference) { - const snapshot = options.wait - ? await waitForSingleJobSnapshot(cwd, reference, { - timeoutMs: options["timeout-ms"], - pollIntervalMs: options["poll-interval-ms"] - }) - : buildSingleJobSnapshot(cwd, reference); - outputCommandResult(snapshot, renderJobStatusReport(snapshot.job), options.json); - return; - } - - if (options.wait) { - throw new Error("`status --wait` requires a job id."); - } - - const report = buildStatusSnapshot(cwd, { all: options.all }); - outputResult(renderStatusPayload(report, options.json), options.json); -} - -function handleResult(argv) { - const { options, positionals } = parseCommandInput(argv, { - valueOptions: ["cwd"], - booleanOptions: ["json"] - }); - - const cwd = resolveCommandCwd(options); - const reference = positionals[0] ?? ""; - const { workspaceRoot, job } = resolveResultJob(cwd, reference); - const storedJob = readStoredJob(workspaceRoot, job.id); - const payload = { - job, - storedJob - }; - - outputCommandResult(payload, renderStoredJobResult(job, storedJob), options.json); -} - -function handleTaskResumeCandidate(argv) { - const { options } = parseCommandInput(argv, { - valueOptions: ["cwd"], - booleanOptions: ["json"] - }); - - const cwd = resolveCommandCwd(options); - const workspaceRoot = resolveCommandWorkspace(options); - const sessionId = process.env[SESSION_ID_ENV] ?? null; - const jobs = sortJobsNewestFirst(listJobs(workspaceRoot)); - const candidate = - jobs.find( - (job) => - job.jobClass === "task" && - job.threadId && - job.status !== "queued" && - job.status !== "running" && - (!sessionId || job.sessionId === sessionId) - ) ?? null; - - const payload = { - available: Boolean(candidate), - sessionId, - candidate: - candidate == null - ? null - : { - id: candidate.id, - status: candidate.status, - title: candidate.title ?? null, - summary: candidate.summary ?? null, - threadId: candidate.threadId, - completedAt: candidate.completedAt ?? null, - updatedAt: candidate.updatedAt ?? null - } - }; - - const rendered = candidate - ? `Resumable task found: ${candidate.id} (${candidate.status}).\n` - : "No resumable task found for this session.\n"; - outputCommandResult(payload, rendered, options.json); -} - -async function handleCancel(argv) { - const { options, positionals } = parseCommandInput(argv, { - valueOptions: ["cwd"], - booleanOptions: ["json"] - }); - - const cwd = resolveCommandCwd(options); - const reference = positionals[0] ?? ""; - const { workspaceRoot, job } = resolveCancelableJob(cwd, reference); - const existing = readStoredJob(workspaceRoot, job.id) ?? {}; - const threadId = existing.threadId ?? job.threadId ?? null; - const turnId = existing.turnId ?? job.turnId ?? null; - - const interrupt = await interruptAppServerTurn(cwd, { threadId, turnId }); - if (interrupt.attempted) { - appendLogLine( - job.logFile, - interrupt.interrupted - ? `Requested Codex turn interrupt for ${turnId} on ${threadId}.` - : `Codex turn interrupt failed${interrupt.detail ? `: ${interrupt.detail}` : "."}` - ); - } - - terminateProcessTree(job.pid ?? Number.NaN); - appendLogLine(job.logFile, "Cancelled by user."); - - const completedAt = nowIso(); - const nextJob = { - ...job, - status: "cancelled", - phase: "cancelled", - pid: null, - completedAt, - errorMessage: "Cancelled by user." - }; - - writeJobFile(workspaceRoot, job.id, { - ...existing, - ...nextJob, - cancelledAt: completedAt - }); - upsertJob(workspaceRoot, { - id: job.id, - status: "cancelled", - phase: "cancelled", - pid: null, - errorMessage: "Cancelled by user.", - completedAt - }); - - const payload = { - jobId: job.id, - status: "cancelled", - title: job.title, - turnInterruptAttempted: interrupt.attempted, - turnInterrupted: interrupt.interrupted - }; - - outputCommandResult(payload, renderCancelReport(nextJob), options.json); -} - -async function main() { - const [subcommand, ...argv] = process.argv.slice(2); - if (!subcommand || subcommand === "help" || subcommand === "--help") { - printUsage(); - return; - } - - switch (subcommand) { - case "setup": - handleSetup(argv); - break; - case "review": - await handleReview(argv); - break; - case "adversarial-review": - await handleReviewCommand(argv, { - reviewName: "Adversarial Review" - }); - break; - case "task": - await handleTask(argv); - break; - case "task-worker": - await handleTaskWorker(argv); - break; - case "status": - await handleStatus(argv); - break; - case "result": - handleResult(argv); - break; - case "task-resume-candidate": - handleTaskResumeCandidate(argv); - break; - case "cancel": - await handleCancel(argv); - break; - default: - throw new Error(`Unknown subcommand: ${subcommand}`); - } -} - -main().catch((error) => { - const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`${message}\n`); - process.exitCode = 1; -}); +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +import { parseArgs, splitRawArgumentString } from "./lib/args.mjs"; +import { + buildPersistentTaskThreadName, + DEFAULT_CONTINUE_PROMPT, + findLatestTaskThread, + getCodexAvailability, + getCodexLoginStatus, + getSessionRuntimeStatus, + interruptAppServerTurn, + parseStructuredOutput, + readOutputSchema, + runAppServerReview, + runAppServerTurn + } from "./lib/codex.mjs"; +import { readStdinIfPiped } from "./lib/fs.mjs"; +import { collectReviewContext, ensureGitRepository, resolveReviewTarget } from "./lib/git.mjs"; +import { binaryAvailable, terminateProcessTree } from "./lib/process.mjs"; +import { loadPromptTemplate, interpolateTemplate } from "./lib/prompts.mjs"; +import { + generateJobId, + getConfig, + listJobs, + setConfig, + upsertJob, + writeJobFile +} from "./lib/state.mjs"; +import { + buildSingleJobSnapshot, + buildStatusSnapshot, + readStoredJob, + resolveCancelableJob, + resolveResultJob, + sortJobsNewestFirst +} from "./lib/job-control.mjs"; +import { + appendLogLine, + createJobLogFile, + createJobProgressUpdater, + createJobRecord, + createProgressReporter, + nowIso, + runTrackedJob, + SESSION_ID_ENV +} from "./lib/tracked-jobs.mjs"; +import { resolveWorkspaceRoot } from "./lib/workspace.mjs"; +import { + renderNativeReviewResult, + renderReviewResult, + renderStoredJobResult, + renderCancelReport, + renderJobStatusReport, + renderSetupReport, + renderStatusReport, + renderTaskResult +} from "./lib/render.mjs"; + +const ROOT_DIR = path.resolve(fileURLToPath(new URL("..", import.meta.url))); +const REVIEW_SCHEMA = path.join(ROOT_DIR, "schemas", "review-output.schema.json"); +const DEFAULT_STATUS_WAIT_TIMEOUT_MS = 240000; +const DEFAULT_STATUS_POLL_INTERVAL_MS = 2000; +const VALID_REASONING_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh"]); +const MODEL_ALIASES = new Map([["spark", "gpt-5.3-codex-spark"]]); +const STOP_REVIEW_TASK_MARKER = "Run a stop-gate review of the previous Claude turn."; + +function printUsage() { + console.log( + [ + "Usage:", + " node scripts/codex-companion.mjs setup [--enable-review-gate|--disable-review-gate] [--json]", + " node scripts/codex-companion.mjs review [--wait|--background] [--base ] [--scope ]", + " node scripts/codex-companion.mjs adversarial-review [--wait|--background] [--base ] [--scope ] [focus text]", + " node scripts/codex-companion.mjs task [--background] [--write] [--resume-last|--resume|--fresh] [--model ] [--effort ] [prompt]", + " node scripts/codex-companion.mjs status [job-id] [--all] [--json]", + " node scripts/codex-companion.mjs result [job-id] [--json]", + " node scripts/codex-companion.mjs cancel [job-id] [--json]" + ].join("\n") + ); +} + +function outputResult(value, asJson) { + if (asJson) { + console.log(JSON.stringify(value, null, 2)); + } else { + process.stdout.write(value); + } +} + +function outputCommandResult(payload, rendered, asJson) { + outputResult(asJson ? payload : rendered, asJson); +} + +function normalizeRequestedModel(model) { + if (model == null) { + return null; + } + const normalized = String(model).trim(); + if (!normalized) { + return null; + } + return MODEL_ALIASES.get(normalized.toLowerCase()) ?? normalized; +} + +function normalizeReasoningEffort(effort) { + if (effort == null) { + return null; + } + const normalized = String(effort).trim().toLowerCase(); + if (!normalized) { + return null; + } + if (!VALID_REASONING_EFFORTS.has(normalized)) { + throw new Error( + `Unsupported reasoning effort "${effort}". Use one of: none, minimal, low, medium, high, xhigh.` + ); + } + return normalized; +} + +function normalizeArgv(argv) { + if (argv.length === 1) { + const [raw] = argv; + if (!raw || !raw.trim()) { + return []; + } + return splitRawArgumentString(raw); + } + return argv; +} + +function parseCommandInput(argv, config = {}) { + return parseArgs(normalizeArgv(argv), { + ...config, + aliasMap: { + C: "cwd", + ...(config.aliasMap ?? {}) + } + }); +} + +function resolveCommandCwd(options = {}) { + return options.cwd ? path.resolve(process.cwd(), options.cwd) : process.cwd(); +} + +function resolveCommandWorkspace(options = {}) { + return resolveWorkspaceRoot(resolveCommandCwd(options)); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function shorten(text, limit = 96) { + const normalized = String(text ?? "").trim().replace(/\s+/g, " "); + if (!normalized) { + return ""; + } + if (normalized.length <= limit) { + return normalized; + } + return `${normalized.slice(0, limit - 3)}...`; +} + +function firstMeaningfulLine(text, fallback) { + const line = String(text ?? "") + .split(/\r?\n/) + .map((value) => value.trim()) + .find(Boolean); + return line ?? fallback; +} + +function buildSetupReport(cwd, actionsTaken = []) { + const workspaceRoot = resolveWorkspaceRoot(cwd); + const nodeStatus = binaryAvailable("node", ["--version"], { cwd }); + const npmStatus = binaryAvailable("npm", ["--version"], { cwd }); + const codexStatus = getCodexAvailability(cwd); + const authStatus = getCodexLoginStatus(cwd); + const config = getConfig(workspaceRoot); + + const nextSteps = []; + if (!codexStatus.available) { + nextSteps.push("Install Codex with `npm install -g @openai/codex`."); + } + if (codexStatus.available && !authStatus.loggedIn) { + nextSteps.push("Run `!codex login`."); + nextSteps.push("If browser login is blocked, retry with `!codex login --device-auth` or `!codex login --with-api-key`."); + } + if (!config.stopReviewGate) { + nextSteps.push("Optional: run `/codex:setup --enable-review-gate` to require a fresh review before stop."); + } + + return { + ready: nodeStatus.available && codexStatus.available && authStatus.loggedIn, + node: nodeStatus, + npm: npmStatus, + codex: codexStatus, + auth: authStatus, + sessionRuntime: getSessionRuntimeStatus(), + reviewGateEnabled: Boolean(config.stopReviewGate), + actionsTaken, + nextSteps + }; +} + +function handleSetup(argv) { + const { options } = parseCommandInput(argv, { + valueOptions: ["cwd"], + booleanOptions: ["json", "enable-review-gate", "disable-review-gate"] + }); + + if (options["enable-review-gate"] && options["disable-review-gate"]) { + throw new Error("Choose either --enable-review-gate or --disable-review-gate."); + } + + const cwd = resolveCommandCwd(options); + const workspaceRoot = resolveCommandWorkspace(options); + const actionsTaken = []; + + if (options["enable-review-gate"]) { + setConfig(workspaceRoot, "stopReviewGate", true); + actionsTaken.push(`Enabled the stop-time review gate for ${workspaceRoot}.`); + } else if (options["disable-review-gate"]) { + setConfig(workspaceRoot, "stopReviewGate", false); + actionsTaken.push(`Disabled the stop-time review gate for ${workspaceRoot}.`); + } + + const finalReport = buildSetupReport(cwd, actionsTaken); + outputResult(options.json ? finalReport : renderSetupReport(finalReport), options.json); +} + +function buildAdversarialReviewPrompt(context, focusText) { + const template = loadPromptTemplate(ROOT_DIR, "adversarial-review"); + return interpolateTemplate(template, { + REVIEW_KIND: "Adversarial Review", + TARGET_LABEL: context.target.label, + USER_FOCUS: focusText || "No extra focus provided.", + REVIEW_INPUT: context.content + }); +} + +function ensureCodexReady(cwd) { + const authStatus = getCodexLoginStatus(cwd); + if (!authStatus.available) { + throw new Error("Codex CLI is not installed or is missing required runtime support. Install it with `npm install -g @openai/codex`, then rerun `/codex:setup`."); + } + if (!authStatus.loggedIn) { + throw new Error("Codex CLI is not authenticated. Run `!codex login` and retry."); + } +} + +function buildNativeReviewTarget(target) { + if (target.mode === "working-tree") { + return { type: "uncommittedChanges" }; + } + + if (target.mode === "branch") { + return { type: "baseBranch", branch: target.baseRef }; + } + + return null; +} + +function validateNativeReviewRequest(target, focusText) { + if (focusText.trim()) { + throw new Error( + `\`/codex:review\` now maps directly to the built-in reviewer and does not support custom focus text. Retry with \`/codex:adversarial-review ${focusText.trim()}\` for focused review instructions.` + ); + } + + const nativeTarget = buildNativeReviewTarget(target); + if (!nativeTarget) { + throw new Error("This `/codex:review` target is not supported by the built-in reviewer. Retry with `/codex:adversarial-review` for custom targeting."); + } + + return nativeTarget; +} + +function renderStatusPayload(report, asJson) { + return asJson ? report : renderStatusReport(report); +} + +function isActiveJobStatus(status) { + return status === "queued" || status === "running"; +} + +async function waitForSingleJobSnapshot(cwd, reference, options = {}) { + const timeoutMs = Math.max(0, Number(options.timeoutMs) || DEFAULT_STATUS_WAIT_TIMEOUT_MS); + const pollIntervalMs = Math.max(100, Number(options.pollIntervalMs) || DEFAULT_STATUS_POLL_INTERVAL_MS); + const deadline = Date.now() + timeoutMs; + let snapshot = buildSingleJobSnapshot(cwd, reference); + + while (isActiveJobStatus(snapshot.job.status) && Date.now() < deadline) { + await sleep(Math.min(pollIntervalMs, Math.max(0, deadline - Date.now()))); + snapshot = buildSingleJobSnapshot(cwd, reference); + } + + return { + ...snapshot, + waitTimedOut: isActiveJobStatus(snapshot.job.status), + timeoutMs + }; +} + +async function resolveLatestTrackedTaskThread(cwd, options = {}) { + const workspaceRoot = resolveWorkspaceRoot(cwd); + const jobs = sortJobsNewestFirst(listJobs(workspaceRoot)).filter((job) => job.id !== options.excludeJobId); + const activeTask = jobs.find((job) => job.jobClass === "task" && (job.status === "queued" || job.status === "running")); + if (activeTask) { + // Check if the process is actually alive before blocking + const pid = activeTask.pid; + let processAlive = false; + if (pid) { + try { + process.kill(pid, 0); + processAlive = true; + } catch (e) { + processAlive = e.code === "EPERM"; // exists but no permission to signal + } + } + + if (processAlive) { + throw new Error(`Task ${activeTask.id} is still running. Use /codex:status before continuing it.`); + } + + // Process is dead — clean up the zombie job and continue + upsertJob(workspaceRoot, { + id: activeTask.id, + status: "failed", + phase: "failed", + pid: null, + errorMessage: "Process exited without updating job status.", + completedAt: new Date().toISOString() + }); + } + + const trackedTask = jobs.find((job) => job.jobClass === "task" && job.status === "completed" && job.threadId); + if (trackedTask) { + return { id: trackedTask.threadId }; + } + + return findLatestTaskThread(workspaceRoot); +} + +async function executeReviewRun(request) { + ensureCodexReady(request.cwd); + ensureGitRepository(request.cwd); + + const target = resolveReviewTarget(request.cwd, { + base: request.base, + scope: request.scope + }); + const focusText = request.focusText?.trim() ?? ""; + const reviewName = request.reviewName ?? "Review"; + if (reviewName === "Review") { + const reviewTarget = validateNativeReviewRequest(target, focusText); + const result = await runAppServerReview(request.cwd, { + target: reviewTarget, + model: request.model, + onProgress: request.onProgress + }); + const payload = { + review: reviewName, + target, + threadId: result.threadId, + sourceThreadId: result.sourceThreadId, + codex: { + status: result.status, + stderr: result.stderr, + stdout: result.reviewText, + reasoning: result.reasoningSummary + } + }; + const rendered = renderNativeReviewResult( + { + status: result.status, + stdout: result.reviewText, + stderr: result.stderr + }, + { reviewLabel: reviewName, targetLabel: target.label, reasoningSummary: result.reasoningSummary } + ); + + return { + exitStatus: result.status, + threadId: result.threadId, + turnId: result.turnId, + payload, + rendered, + summary: firstMeaningfulLine(result.reviewText, `${reviewName} completed.`), + jobTitle: `Codex ${reviewName}`, + jobClass: "review", + targetLabel: target.label + }; + } + + const context = collectReviewContext(request.cwd, target); + const prompt = buildAdversarialReviewPrompt(context, focusText); + const result = await runAppServerTurn(context.repoRoot, { + prompt, + model: request.model, + sandbox: "read-only", + outputSchema: readOutputSchema(REVIEW_SCHEMA), + onProgress: request.onProgress + }); + const parsed = parseStructuredOutput(result.finalMessage, { + status: result.status, + failureMessage: result.error?.message ?? result.stderr + }); + const payload = { + review: reviewName, + target, + threadId: result.threadId, + context: { + repoRoot: context.repoRoot, + branch: context.branch, + summary: context.summary + }, + codex: { + status: result.status, + stderr: result.stderr, + stdout: result.finalMessage, + reasoning: result.reasoningSummary + }, + result: parsed.parsed, + rawOutput: parsed.rawOutput, + parseError: parsed.parseError, + reasoningSummary: result.reasoningSummary + }; + + return { + exitStatus: result.status, + threadId: result.threadId, + turnId: result.turnId, + payload, + rendered: renderReviewResult(parsed, { + reviewLabel: reviewName, + targetLabel: context.target.label, + reasoningSummary: result.reasoningSummary + }), + summary: parsed.parsed?.summary ?? parsed.parseError ?? firstMeaningfulLine(result.finalMessage, `${reviewName} finished.`), + jobTitle: `Codex ${reviewName}`, + jobClass: "review", + targetLabel: context.target.label + }; +} + + +async function executeTaskRun(request) { + const workspaceRoot = resolveWorkspaceRoot(request.cwd); + ensureCodexReady(request.cwd); + + const taskMetadata = buildTaskRunMetadata({ + prompt: request.prompt, + resumeLast: request.resumeLast + }); + + let resumeThreadId = null; + if (request.resumeLast) { + const latestThread = await resolveLatestTrackedTaskThread(workspaceRoot, { + excludeJobId: request.jobId + }); + if (!latestThread) { + throw new Error("No previous Codex task thread was found for this repository."); + } + resumeThreadId = latestThread.id; + } + + if (!request.prompt && !resumeThreadId) { + throw new Error("Provide a prompt, a prompt file, piped stdin, or use --resume-last."); + } + + const result = await runAppServerTurn(workspaceRoot, { + resumeThreadId, + prompt: request.prompt, + defaultPrompt: resumeThreadId ? DEFAULT_CONTINUE_PROMPT : "", + model: request.model, + effort: request.effort, + sandbox: request.fullAccess ? "danger-full-access" : (request.write ? "workspace-write" : "read-only"), + onProgress: request.onProgress, + persistThread: true, + threadName: resumeThreadId ? null : buildPersistentTaskThreadName(request.prompt || DEFAULT_CONTINUE_PROMPT) + }); + + const rawOutput = typeof result.finalMessage === "string" ? result.finalMessage : ""; + const failureMessage = result.error?.message ?? result.stderr ?? ""; + const rendered = renderTaskResult( + { + rawOutput, + failureMessage, + reasoningSummary: result.reasoningSummary + }, + { + title: taskMetadata.title, + jobId: request.jobId ?? null, + write: Boolean(request.write) + } + ); + const payload = { + status: result.status, + threadId: result.threadId, + rawOutput, + touchedFiles: result.touchedFiles, + reasoningSummary: result.reasoningSummary + }; + + return { + exitStatus: result.status, + threadId: result.threadId, + turnId: result.turnId, + payload, + rendered, + summary: firstMeaningfulLine(rawOutput, firstMeaningfulLine(failureMessage, `${taskMetadata.title} finished.`)), + jobTitle: taskMetadata.title, + jobClass: "task", + write: Boolean(request.write) + }; +} + +function buildReviewJobMetadata(reviewName, target) { + return { + kind: reviewName === "Adversarial Review" ? "adversarial-review" : "review", + title: reviewName === "Review" ? "Codex Review" : `Codex ${reviewName}`, + summary: `${reviewName} ${target.label}` + }; +} + +function buildTaskRunMetadata({ prompt, resumeLast = false }) { + if (!resumeLast && String(prompt ?? "").includes(STOP_REVIEW_TASK_MARKER)) { + return { + title: "Codex Stop Gate Review", + summary: "Stop-gate review of previous Claude turn" + }; + } + + const title = resumeLast ? "Codex Resume" : "Codex Task"; + const fallbackSummary = resumeLast ? DEFAULT_CONTINUE_PROMPT : "Task"; + return { + title, + summary: shorten(prompt || fallbackSummary) + }; +} + +function renderQueuedTaskLaunch(payload) { + return `${payload.title} started in the background as ${payload.jobId}. Check /codex:status ${payload.jobId} for progress.\n`; +} + +function getJobKindLabel(kind, jobClass) { + if (kind === "adversarial-review") { + return "adversarial-review"; + } + return jobClass === "review" ? "review" : "rescue"; +} + +function createCompanionJob({ prefix, kind, title, workspaceRoot, jobClass, summary, write = false }) { + return createJobRecord({ + id: generateJobId(prefix), + kind, + kindLabel: getJobKindLabel(kind, jobClass), + title, + workspaceRoot, + jobClass, + summary, + write + }); +} + +function createTrackedProgress(job, options = {}) { + const logFile = options.logFile ?? createJobLogFile(job.workspaceRoot, job.id, job.title); + return { + logFile, + progress: createProgressReporter({ + stderr: Boolean(options.stderr), + logFile, + onEvent: createJobProgressUpdater(job.workspaceRoot, job.id) + }) + }; +} + +function buildTaskJob(workspaceRoot, taskMetadata, write) { + return createCompanionJob({ + prefix: "task", + kind: "task", + title: taskMetadata.title, + workspaceRoot, + jobClass: "task", + summary: taskMetadata.summary, + write + }); +} + +function buildTaskRequest({ cwd, model, effort, prompt, write, resumeLast, jobId }) { + return { + cwd, + model, + effort, + prompt, + write, + resumeLast, + jobId + }; +} + +function readTaskPrompt(cwd, options, positionals) { + if (options["prompt-file"]) { + return fs.readFileSync(path.resolve(cwd, options["prompt-file"]), "utf8"); + } + + const positionalPrompt = positionals.join(" "); + return positionalPrompt || readStdinIfPiped(); +} + +function requireTaskRequest(prompt, resumeLast) { + if (!prompt && !resumeLast) { + throw new Error("Provide a prompt, a prompt file, piped stdin, or use --resume-last."); + } +} + +async function runForegroundCommand(job, runner, options = {}) { + const { logFile, progress } = createTrackedProgress(job, { + logFile: options.logFile, + stderr: !options.json + }); + const execution = await runTrackedJob(job, () => runner(progress), { logFile }); + outputResult(options.json ? execution.payload : execution.rendered, options.json); + if (execution.exitStatus !== 0) { + process.exitCode = execution.exitStatus; + } + return execution; +} + +function spawnDetachedTaskWorker(cwd, jobId) { + const scriptPath = path.join(ROOT_DIR, "scripts", "codex-companion.mjs"); + const child = spawn(process.execPath, [scriptPath, "task-worker", "--cwd", cwd, "--job-id", jobId], { + cwd, + env: process.env, + detached: true, + stdio: "ignore", + windowsHide: true + }); + child.unref(); + return child; +} + +function enqueueBackgroundTask(cwd, job, request) { + const { logFile } = createTrackedProgress(job); + appendLogLine(logFile, "Queued for background execution."); + + const child = spawnDetachedTaskWorker(cwd, job.id); + const queuedRecord = { + ...job, + status: "queued", + phase: "queued", + pid: child.pid ?? null, + logFile, + request + }; + writeJobFile(job.workspaceRoot, job.id, queuedRecord); + upsertJob(job.workspaceRoot, queuedRecord); + + return { + payload: { + jobId: job.id, + status: "queued", + title: job.title, + summary: job.summary, + logFile + }, + logFile + }; +} + +async function handleReviewCommand(argv, config) { + const { options, positionals } = parseCommandInput(argv, { + valueOptions: ["base", "scope", "model", "cwd"], + booleanOptions: ["json", "background", "wait"], + aliasMap: { + m: "model" + } + }); + + const cwd = resolveCommandCwd(options); + const workspaceRoot = resolveCommandWorkspace(options); + const focusText = positionals.join(" ").trim(); + const target = resolveReviewTarget(cwd, { + base: options.base, + scope: options.scope + }); + + config.validateRequest?.(target, focusText); + const metadata = buildReviewJobMetadata(config.reviewName, target); + const job = createCompanionJob({ + prefix: "review", + kind: metadata.kind, + title: metadata.title, + workspaceRoot, + jobClass: "review", + summary: metadata.summary + }); + await runForegroundCommand( + job, + (progress) => + executeReviewRun({ + cwd, + base: options.base, + scope: options.scope, + model: options.model, + focusText, + reviewName: config.reviewName, + onProgress: progress + }), + { json: options.json } + ); +} + +async function handleReview(argv) { + return handleReviewCommand(argv, { + reviewName: "Review", + validateRequest: validateNativeReviewRequest + }); +} + +async function handleTask(argv) { + const { options, positionals } = parseCommandInput(argv, { + valueOptions: ["model", "effort", "cwd", "prompt-file"], + booleanOptions: ["json", "write", "full-access", "resume-last", "resume", "fresh", "background"], + aliasMap: { + m: "model" + } + }); + + const cwd = resolveCommandCwd(options); + const workspaceRoot = resolveCommandWorkspace(options); + const model = normalizeRequestedModel(options.model); + const effort = normalizeReasoningEffort(options.effort); + const prompt = readTaskPrompt(cwd, options, positionals); + + const resumeLast = Boolean(options["resume-last"] || options.resume); + const fresh = Boolean(options.fresh); + if (resumeLast && fresh) { + throw new Error("Choose either --resume/--resume-last or --fresh."); + } + const write = Boolean(options.write); + const taskMetadata = buildTaskRunMetadata({ + prompt, + resumeLast + }); + + if (options.background) { + ensureCodexReady(cwd); + requireTaskRequest(prompt, resumeLast); + + const job = buildTaskJob(workspaceRoot, taskMetadata, write); + const request = buildTaskRequest({ + cwd, + model, + effort, + prompt, + write, + resumeLast, + jobId: job.id + }); + const { payload } = enqueueBackgroundTask(cwd, job, request); + outputCommandResult(payload, renderQueuedTaskLaunch(payload), options.json); + return; + } + + const job = buildTaskJob(workspaceRoot, taskMetadata, write); + await runForegroundCommand( + job, + (progress) => + executeTaskRun({ + cwd, + model, + effort, + prompt, + write, + resumeLast, + jobId: job.id, + onProgress: progress + }), + { json: options.json } + ); +} + +async function handleTaskWorker(argv) { + const { options } = parseCommandInput(argv, { + valueOptions: ["cwd", "job-id"] + }); + + if (!options["job-id"]) { + throw new Error("Missing required --job-id for task-worker."); + } + + const cwd = resolveCommandCwd(options); + const workspaceRoot = resolveCommandWorkspace(options); + const storedJob = readStoredJob(workspaceRoot, options["job-id"]); + if (!storedJob) { + throw new Error(`No stored job found for ${options["job-id"]}.`); + } + + const request = storedJob.request; + if (!request || typeof request !== "object") { + throw new Error(`Stored job ${options["job-id"]} is missing its task request payload.`); + } + + const { logFile, progress } = createTrackedProgress( + { + ...storedJob, + workspaceRoot + }, + { + logFile: storedJob.logFile ?? null + } + ); + await runTrackedJob( + { + ...storedJob, + workspaceRoot, + logFile + }, + () => + executeTaskRun({ + ...request, + onProgress: progress + }), + { logFile } + ); +} + +async function handleStatus(argv) { + const { options, positionals } = parseCommandInput(argv, { + valueOptions: ["cwd", "timeout-ms", "poll-interval-ms"], + booleanOptions: ["json", "all", "wait"] + }); + + const cwd = resolveCommandCwd(options); + const reference = positionals[0] ?? ""; + if (reference) { + const snapshot = options.wait + ? await waitForSingleJobSnapshot(cwd, reference, { + timeoutMs: options["timeout-ms"], + pollIntervalMs: options["poll-interval-ms"] + }) + : buildSingleJobSnapshot(cwd, reference); + outputCommandResult(snapshot, renderJobStatusReport(snapshot.job), options.json); + return; + } + + if (options.wait) { + throw new Error("`status --wait` requires a job id."); + } + + const report = buildStatusSnapshot(cwd, { all: options.all }); + outputResult(renderStatusPayload(report, options.json), options.json); +} + +function handleResult(argv) { + const { options, positionals } = parseCommandInput(argv, { + valueOptions: ["cwd"], + booleanOptions: ["json"] + }); + + const cwd = resolveCommandCwd(options); + const reference = positionals[0] ?? ""; + const { workspaceRoot, job } = resolveResultJob(cwd, reference); + const storedJob = readStoredJob(workspaceRoot, job.id); + const payload = { + job, + storedJob + }; + + outputCommandResult(payload, renderStoredJobResult(job, storedJob), options.json); +} + +function handleTaskResumeCandidate(argv) { + const { options } = parseCommandInput(argv, { + valueOptions: ["cwd"], + booleanOptions: ["json"] + }); + + const cwd = resolveCommandCwd(options); + const workspaceRoot = resolveCommandWorkspace(options); + const sessionId = process.env[SESSION_ID_ENV] ?? null; + const jobs = sortJobsNewestFirst(listJobs(workspaceRoot)); + const candidate = + jobs.find( + (job) => + job.jobClass === "task" && + job.threadId && + job.status !== "queued" && + job.status !== "running" && + (!sessionId || job.sessionId === sessionId) + ) ?? null; + + const payload = { + available: Boolean(candidate), + sessionId, + candidate: + candidate == null + ? null + : { + id: candidate.id, + status: candidate.status, + title: candidate.title ?? null, + summary: candidate.summary ?? null, + threadId: candidate.threadId, + completedAt: candidate.completedAt ?? null, + updatedAt: candidate.updatedAt ?? null + } + }; + + const rendered = candidate + ? `Resumable task found: ${candidate.id} (${candidate.status}).\n` + : "No resumable task found for this session.\n"; + outputCommandResult(payload, rendered, options.json); +} + +async function handleCancel(argv) { + const { options, positionals } = parseCommandInput(argv, { + valueOptions: ["cwd"], + booleanOptions: ["json"] + }); + + const cwd = resolveCommandCwd(options); + const reference = positionals[0] ?? ""; + const { workspaceRoot, job } = resolveCancelableJob(cwd, reference); + const existing = readStoredJob(workspaceRoot, job.id) ?? {}; + const threadId = existing.threadId ?? job.threadId ?? null; + const turnId = existing.turnId ?? job.turnId ?? null; + + const interrupt = await interruptAppServerTurn(cwd, { threadId, turnId }); + if (interrupt.attempted) { + appendLogLine( + job.logFile, + interrupt.interrupted + ? `Requested Codex turn interrupt for ${turnId} on ${threadId}.` + : `Codex turn interrupt failed${interrupt.detail ? `: ${interrupt.detail}` : "."}` + ); + } + + terminateProcessTree(job.pid ?? Number.NaN); + appendLogLine(job.logFile, "Cancelled by user."); + + const completedAt = nowIso(); + const nextJob = { + ...job, + status: "cancelled", + phase: "cancelled", + pid: null, + completedAt, + errorMessage: "Cancelled by user." + }; + + writeJobFile(workspaceRoot, job.id, { + ...existing, + ...nextJob, + cancelledAt: completedAt + }); + upsertJob(workspaceRoot, { + id: job.id, + status: "cancelled", + phase: "cancelled", + pid: null, + errorMessage: "Cancelled by user.", + completedAt + }); + + const payload = { + jobId: job.id, + status: "cancelled", + title: job.title, + turnInterruptAttempted: interrupt.attempted, + turnInterrupted: interrupt.interrupted + }; + + outputCommandResult(payload, renderCancelReport(nextJob), options.json); +} + +async function main() { + const [subcommand, ...argv] = process.argv.slice(2); + if (!subcommand || subcommand === "help" || subcommand === "--help") { + printUsage(); + return; + } + + switch (subcommand) { + case "setup": + handleSetup(argv); + break; + case "review": + await handleReview(argv); + break; + case "adversarial-review": + await handleReviewCommand(argv, { + reviewName: "Adversarial Review" + }); + break; + case "task": + await handleTask(argv); + break; + case "task-worker": + await handleTaskWorker(argv); + break; + case "status": + await handleStatus(argv); + break; + case "result": + handleResult(argv); + break; + case "task-resume-candidate": + handleTaskResumeCandidate(argv); + break; + case "cancel": + await handleCancel(argv); + break; + default: + throw new Error(`Unknown subcommand: ${subcommand}`); + } +} + +main().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`${message}\n`); + process.exitCode = 1; +});