Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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.
- Treat `--cwd <dir>` and `-C <dir>` as workspace routing controls. Pass `--cwd <dir>` explicitly on every `task` invocation, using the intended workspace root forwarded by the caller.
- 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
10 changes: 9 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>|-C <dir>] [--resume|--fresh] [--model <model|spark>] [--effort <none|minimal|low|medium|high|xhigh>] [what Codex should investigate, solve, or continue]"
allowed-tools: Bash(node:*), AskUserQuestion, Agent
---

Expand All @@ -18,6 +18,7 @@ Execution mode:
- If neither flag is present, default to foreground.
- `--background` and `--wait` are execution flags for Claude Code. Do not forward them to `task`, and do not treat them as part of the natural-language task text.
- `--model` and `--effort` are runtime-selection flags. Preserve them for the forwarded `task` call, but do not treat them as part of the natural-language task text.
- `--cwd <dir>` and `-C <dir>` are workspace-routing flags. Preserve the directory for the resume preflight and the forwarded `task` call, but do not treat either form as part of the natural-language task text.
- If the request includes `--resume`, do not ask whether to continue. The user already chose.
- If the request includes `--fresh`, do not ask whether to continue. The user already chose.
- Otherwise, before starting Codex, check for a resumable rescue thread from this Claude session by running:
Expand All @@ -26,6 +27,12 @@ Execution mode:
node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" task-resume-candidate --json
```

- If the request includes `--cwd <dir>` or `-C <dir>`, pass the same directory to that helper as `--cwd <dir>`:

```bash
node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" task-resume-candidate --json --cwd "<dir>"
```

- If that helper reports `available: true`, use `AskUserQuestion` exactly once to ask whether to continue the current Codex thread or start a new one.
- The two choices must be:
- `Continue current Codex thread`
Expand All @@ -39,6 +46,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
120 changes: 90 additions & 30 deletions plugins/codex/scripts/codex-companion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
readStoredJob,
resolveCancelableJob,
resolveResultJob,
settleCancellationAfterTermination,
sortJobsNewestFirst
} from "./lib/job-control.mjs";
import {
Expand Down Expand Up @@ -68,6 +69,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 +81,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 +158,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 +801,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 +879,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 @@ -931,8 +968,8 @@ function handleTaskResumeCandidate(argv) {
booleanOptions: ["json"]
});

const cwd = resolveCommandCwd(options);
const workspaceRoot = resolveCommandWorkspace(options);
const cwd = resolveTaskCwd(options);
const workspaceRoot = resolveWorkspaceRoot(cwd);
const sessionId = getCurrentClaudeSessionId();
const jobs = filterJobsForCurrentClaudeSession(sortJobsNewestFirst(listJobs(workspaceRoot)));
const candidate = findLatestResumableTaskJob(jobs);
Expand Down Expand Up @@ -972,6 +1009,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 +1048,27 @@ async function handleCancel(argv) {
);
}

terminateProcessTree(job.pid ?? Number.NaN);
appendLogLine(job.logFile, "Cancelled by user.");
let termination = null;
let terminationError = null;
try {
termination = terminateProcessTree(job.pid ?? Number.NaN);
} catch (error) {
terminationError = error;
}

const completedAt = nowIso();
const nextJob = {
...job,
status: "cancelled",
phase: "cancelled",
pid: null,
completedAt,
errorMessage: "Cancelled by user."
};
const outcome = settleCancellationAfterTermination(
workspaceRoot,
job,
existing,
termination,
terminationError
);
if (!outcome.processStopped) {
appendLogLine(job.logFile, outcome.job.errorMessage);
throw outcome.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 nextJob = persistCancellation(null);

const payload = {
jobId: job.id,
Expand Down
33 changes: 21 additions & 12 deletions plugins/codex/scripts/lib/args.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -77,17 +77,30 @@ 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];
const characterAfterQuote = raw[index + 2];
const closesQuotedRegion =
quote !== null &&
nextCharacter === quote &&
(characterAfterQuote === undefined || /\s/.test(characterAfterQuote));
if (closesQuotedRegion) {
current += character;
continue;
Comment thread
jugol marked this conversation as resolved.
Outdated
}
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 +129,6 @@ export function splitRawArgumentString(raw) {
current += character;
}

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

if (current) {
tokens.push(current);
}
Expand Down
47 changes: 46 additions & 1 deletion plugins/codex/scripts/lib/job-control.mjs
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import fs from "node:fs";

import { getSessionRuntimeStatus } from "./codex.mjs";
import { getConfig, listJobs, readJobFile, resolveJobFile } from "./state.mjs";
import { isProcessAlive } from "./process.mjs";
import { getConfig, listJobs, readJobFile, resolveJobFile, upsertJob, writeJobFile } from "./state.mjs";
import { SESSION_ID_ENV } from "./tracked-jobs.mjs";
import { resolveWorkspaceRoot } from "./workspace.mjs";

export const DEFAULT_MAX_STATUS_JOBS = 8;
export const DEFAULT_MAX_PROGRESS_LINES = 4;
export const CANCELLATION_TERMINATION_FAILED_MESSAGE =
"Cancellation requested but process termination failed; retry /codex:cancel.";

export function sortJobsNewestFirst(jobs) {
return [...jobs].sort((left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? "")));
Expand Down Expand Up @@ -188,6 +191,48 @@ export function readStoredJob(workspaceRoot, jobId) {
return readJobFile(jobFile);
}

export function settleCancellationAfterTermination(
workspaceRoot,
job,
existing,
termination,
terminationError = null,
options = {}
) {
const terminationFailed = Boolean(terminationError) || termination?.delivered !== true;
const pid = job.pid ?? null;
const isProcessAliveImpl = options.isProcessAliveImpl ?? isProcessAlive;

if (!terminationFailed || !Number.isInteger(pid) || pid <= 0 || !isProcessAliveImpl(pid)) {
return { processStopped: true, job: null, error: null };
}

const restoredJob = {
...existing,
...job,
status: job.status,
phase: job.phase ?? existing.phase ?? job.status,
pid,
errorMessage: CANCELLATION_TERMINATION_FAILED_MESSAGE
};
writeJobFile(workspaceRoot, job.id, restoredJob);
upsertJob(workspaceRoot, {
...job,
status: job.status,
phase: restoredJob.phase,
pid,
completedAt: job.completedAt ?? null,
cancelledAt: job.cancelledAt ?? null,
errorMessage: CANCELLATION_TERMINATION_FAILED_MESSAGE
});

return {
processStopped: false,
job: restoredJob,
error: terminationError ?? new Error(CANCELLATION_TERMINATION_FAILED_MESSAGE)
};
}

function matchJobReference(jobs, reference, predicate = () => true) {
const filtered = jobs.filter(predicate);
if (!reference) {
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
Loading