diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 201d1c7a..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.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", "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; +});