Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions plugins/codex/agents/codex-rescue.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ Forwarding rules:
- Use exactly one `Bash` call to invoke `node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" task ...`.
- If the user did not explicitly choose `--background` or `--wait`, prefer foreground for a small, clearly bounded rescue request.
- If the user did not explicitly choose `--background` or `--wait` and the task looks complicated, open-ended, multi-step, or likely to keep Codex running for a long time, prefer background execution.
- For any task expected to exceed a few minutes, add `--background` to the `task` invocation by default so a caller timeout cannot terminate it.
- Pass `--cwd <dir>` explicitly on every `task` invocation, using the intended workspace root forwarded by the caller.
Comment thread
jugol marked this conversation as resolved.
Outdated
- You may use the `gpt-5-4-prompting` skill only to tighten the user's request into a better Codex prompt before forwarding it.
- Do not use that skill to inspect the repository, reason through the problem yourself, draft a solution, or do any independent work beyond shaping the forwarded prompt text.
- Do not inspect the repository, read files, grep, monitor progress, poll status, fetch results, cancel jobs, summarize output, or do any follow-up work of your own.
Expand Down
3 changes: 2 additions & 1 deletion plugins/codex/commands/rescue.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
description: Delegate investigation, an explicit fix request, or follow-up rescue work to the Codex rescue subagent
argument-hint: "[--background|--wait] [--resume|--fresh] [--model <model|spark>] [--effort <none|minimal|low|medium|high|xhigh>] [what Codex should investigate, solve, or continue]"
argument-hint: "[--background|--wait] [--cwd <dir>] [--resume|--fresh] [--model <model|spark>] [--effort <none|minimal|low|medium|high|xhigh>] [what Codex should investigate, solve, or continue]"
Comment thread
jugol marked this conversation as resolved.
Outdated
allowed-tools: Bash(node:*), AskUserQuestion, Agent
---

Expand Down Expand Up @@ -39,6 +39,7 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" task-resume-candidate -
Operating rules:

- The subagent is a thin forwarder only. It should use one `Bash` call to invoke `node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" task ...` and return that command's stdout as-is.
- Pass `--cwd <dir>` explicitly for the intended workspace root. For work expected to exceed a few minutes, have the subagent add the task command's own `--background` flag so a caller timeout cannot terminate it.
- Return the Codex companion stdout verbatim to the user.
- Do not paraphrase, summarize, rewrite, or add commentary before or after it.
- Do not ask the subagent to inspect files, monitor progress, poll `/codex:status`, fetch `/codex:result`, call `/codex:cancel`, summarize output, or do follow-up work of its own.
Expand Down
109 changes: 79 additions & 30 deletions plugins/codex/scripts/codex-companion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
import { resolveClaudeSessionPath } from "./lib/claude-session-transfer.mjs";
import { readStdinIfPiped } from "./lib/fs.mjs";
import { collectReviewContext, ensureGitRepository, resolveReviewTarget } from "./lib/git.mjs";
import { binaryAvailable, terminateProcessTree } from "./lib/process.mjs";
import { binaryAvailable, isProcessAlive, terminateProcessTree } from "./lib/process.mjs";
import { loadPromptTemplate, interpolateTemplate } from "./lib/prompts.mjs";
import {
generateJobId,
Expand Down Expand Up @@ -68,6 +68,7 @@ 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 TASK_WORKER_RECORD_WAIT_TIMEOUT_MS = 1000;
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.";
Expand All @@ -79,7 +80,7 @@ function printUsage() {
" node scripts/codex-companion.mjs setup [--enable-review-gate|--disable-review-gate] [--json]",
" node scripts/codex-companion.mjs review [--wait|--background] [--base <ref>] [--scope <auto|working-tree|branch>]",
" node scripts/codex-companion.mjs adversarial-review [--wait|--background] [--base <ref>] [--scope <auto|working-tree|branch>] [focus text]",
" node scripts/codex-companion.mjs task [--background] [--write] [--resume-last|--resume|--fresh] [--model <model|spark>] [--effort <none|minimal|low|medium|high|xhigh>] [prompt]",
" node scripts/codex-companion.mjs task [--background] [--write] [--cwd <dir>] [--resume-last|--resume|--fresh] [--model <model|spark>] [--effort <none|minimal|low|medium|high|xhigh>] [prompt]",
" node scripts/codex-companion.mjs transfer [--source <claude-jsonl>] [--json]",
" node scripts/codex-companion.mjs status [job-id] [--all] [--json]",
" node scripts/codex-companion.mjs result [job-id] [--json]",
Expand Down Expand Up @@ -156,10 +157,41 @@ function resolveCommandWorkspace(options = {}) {
return resolveWorkspaceRoot(resolveCommandCwd(options));
}

function resolveTaskCwd(options = {}) {
const cwd = resolveCommandCwd(options);
if (!options.cwd) {
return cwd;
}

let stats;
try {
stats = fs.statSync(cwd);
} catch (error) {
if (error?.code === "ENOENT") {
throw new Error(`Task workspace directory does not exist: ${cwd}`);
}
throw error;
}
if (!stats.isDirectory()) {
throw new Error(`Task workspace path is not a directory: ${cwd}`);
}
return cwd;
}

function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

async function waitForStoredJob(workspaceRoot, jobId) {
const deadline = Date.now() + TASK_WORKER_RECORD_WAIT_TIMEOUT_MS;
let storedJob = readStoredJob(workspaceRoot, jobId);
while (!storedJob && Date.now() < deadline) {
await sleep(25);
storedJob = readStoredJob(workspaceRoot, jobId);
}
return storedJob;
}

function shorten(text, limit = 96) {
const normalized = String(text ?? "").trim().replace(/\s+/g, " ");
if (!normalized) {
Expand Down Expand Up @@ -768,7 +800,7 @@ async function handleTask(argv) {
}
});

const cwd = resolveCommandCwd(options);
const cwd = resolveTaskCwd(options);
const workspaceRoot = resolveCommandWorkspace(options);
const model = normalizeRequestedModel(options.model);
const effort = normalizeReasoningEffort(options.effort);
Expand Down Expand Up @@ -846,9 +878,13 @@ async function handleTaskWorker(argv) {

const cwd = resolveCommandCwd(options);
const workspaceRoot = resolveCommandWorkspace(options);
const storedJob = readStoredJob(workspaceRoot, options["job-id"]);
const storedJob = await waitForStoredJob(workspaceRoot, options["job-id"]);
if (!storedJob) {
throw new Error(`No stored job found for ${options["job-id"]}.`);
return;
}
if (storedJob.status === "cancelled") {
appendLogLine(storedJob.logFile, "Skipped cancelled background job.");
return;
}

const request = storedJob.request;
Expand Down Expand Up @@ -972,6 +1008,34 @@ async function handleCancel(argv) {
const existing = readStoredJob(workspaceRoot, job.id) ?? {};
const threadId = existing.threadId ?? job.threadId ?? null;
const turnId = existing.turnId ?? job.turnId ?? null;
const completedAt = nowIso();
const cancellingJob = {
...job,
status: "cancelled",
phase: "cancelled",
completedAt,
errorMessage: "Cancelled by user."
};
const persistCancellation = (pid) => {
const cancelledJob = { ...cancellingJob, pid };
writeJobFile(workspaceRoot, job.id, {
...existing,
...cancelledJob,
cancelledAt: completedAt
});
upsertJob(workspaceRoot, {
id: job.id,
status: "cancelled",
phase: "cancelled",
pid,
errorMessage: "Cancelled by user.",
completedAt
});
return cancelledJob;
};

persistCancellation(job.pid ?? null);
Comment thread
jugol marked this conversation as resolved.
appendLogLine(job.logFile, "Cancelled by user.");

const interrupt = await interruptAppServerTurn(cwd, { threadId, turnId });
if (interrupt.attempted) {
Expand All @@ -983,32 +1047,17 @@ async function handleCancel(argv) {
);
}

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."
};
let termination;
try {
termination = terminateProcessTree(job.pid ?? Number.NaN);
} catch (error) {
persistCancellation(job.pid ?? null);
throw error;
}

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 processStopped =
!Number.isFinite(job.pid) || termination.delivered || !isProcessAlive(job.pid);
const nextJob = persistCancellation(processStopped ? null : job.pid);

const payload = {
jobId: job.id,
Expand Down
24 changes: 12 additions & 12 deletions plugins/codex/scripts/lib/args.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -77,17 +77,21 @@ export function splitRawArgumentString(raw) {
const tokens = [];
let current = "";
let quote = null;
let escaping = false;

for (const character of raw) {
if (escaping) {
current += character;
escaping = false;
continue;
}
for (let index = 0; index < raw.length; index += 1) {
const character = raw[index];

if (character === "\\") {
escaping = true;
const nextCharacter = raw[index + 1];
if (
nextCharacter !== undefined &&
(nextCharacter === "'" || nextCharacter === "\"" || nextCharacter === "\\" || /\s/.test(nextCharacter))
) {
current += nextCharacter;
Comment thread
jugol marked this conversation as resolved.
index += 1;
} else {
current += character;
}
continue;
}

Expand Down Expand Up @@ -116,10 +120,6 @@ export function splitRawArgumentString(raw) {
current += character;
}

if (escaping) {
current += "\\";
}

if (current) {
tokens.push(current);
}
Expand Down
19 changes: 17 additions & 2 deletions plugins/codex/scripts/lib/process.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,20 @@ function looksLikeMissingProcessMessage(text) {
return /not found|no running instance|cannot find|does not exist|no such process/i.test(text);
}

export function isProcessAlive(pid, options = {}) {
if (!Number.isInteger(pid) || pid <= 0) {
return false;
}

const killImpl = options.killImpl ?? process.kill.bind(process);
try {
killImpl(pid, 0);
return true;
} catch (error) {
return error?.code === "EPERM";
}
}

export function terminateProcessTree(pid, options = {}) {
if (!Number.isFinite(pid)) {
return { attempted: false, delivered: false, method: null };
Expand All @@ -66,15 +80,16 @@ export function terminateProcessTree(pid, options = {}) {
if (platform === "win32") {
const result = runCommandImpl("taskkill", ["/PID", String(pid), "/T", "/F"], {
cwd: options.cwd,
env: options.env
env: options.env,
shell: false
});

if (!result.error && result.status === 0) {
return { attempted: true, delivered: true, method: "taskkill", result };
}

const combinedOutput = `${result.stderr}\n${result.stdout}`.trim();
if (!result.error && looksLikeMissingProcessMessage(combinedOutput)) {
if (!result.error && (looksLikeMissingProcessMessage(combinedOutput) || !isProcessAlive(pid, { killImpl }))) {
return { attempted: true, delivered: false, method: "taskkill", result };
}

Expand Down
52 changes: 51 additions & 1 deletion plugins/codex/scripts/lib/state.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";

import { isProcessAlive } from "./process.mjs";
import { resolveWorkspaceRoot } from "./workspace.mjs";

const STATE_VERSION = 1;
Expand All @@ -11,6 +12,7 @@ const FALLBACK_STATE_ROOT_DIR = path.join(os.tmpdir(), "codex-companion");
const STATE_FILE_NAME = "state.json";
const JOBS_DIR_NAME = "jobs";
const MAX_JOBS = 50;
const UNREPORTED_PROCESS_EXIT_MESSAGE = "Process exited without reporting.";

function nowIso() {
return new Date().toISOString();
Expand Down Expand Up @@ -146,8 +148,56 @@ export function upsertJob(cwd, jobPatch) {
});
}

function reconcileRunningJobs(cwd, state) {
const completedAt = nowIso();
const staleJobs = [];
const jobs = state.jobs.map((job) => {
if (job.status !== "running" || !Number.isInteger(job.pid) || job.pid <= 0 || isProcessAlive(job.pid)) {
return job;
}

const failedJob = {
...job,
status: "failed",
phase: "failed",
pid: null,
completedAt,
updatedAt: completedAt,
errorMessage: UNREPORTED_PROCESS_EXIT_MESSAGE
};
staleJobs.push(failedJob);
return failedJob;
});

if (staleJobs.length === 0) {
return state.jobs;
}

const nextState = saveState(cwd, { ...state, jobs });
for (const job of staleJobs) {
const jobFile = resolveJobFile(cwd, job.id);
if (!fs.existsSync(jobFile)) {
continue;
}
try {
writeJobFile(cwd, job.id, {
...readJobFile(jobFile),
status: job.status,
phase: job.phase,
pid: job.pid,
completedAt: job.completedAt,
errorMessage: job.errorMessage
});
} catch {
// The state record is still authoritative when a per-job file is unreadable.
}
}
return nextState.jobs;
}

export function listJobs(cwd) {
return loadState(cwd).jobs;
const state = loadState(cwd);
return reconcileRunningJobs(cwd, state);
}

export function setConfig(cwd, key, value) {
Expand Down
Loading