Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
41 changes: 27 additions & 14 deletions plugins/codex/scripts/lib/args.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -73,21 +73,29 @@ export function parseArgs(argv, config = {}) {
return { options, positionals };
}

export function splitRawArgumentString(raw) {
function parseRawArgumentString(raw, literalClosingQuoteBackslash) {
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 (literalClosingQuoteBackslash && quote !== null && nextCharacter === quote) {
current += character;
continue;
}
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,13 +124,18 @@ export function splitRawArgumentString(raw) {
current += character;
}

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

if (current) {
tokens.push(current);
}

return tokens;
return { tokens, openQuote: quote };
}

export function splitRawArgumentString(raw) {
const escapePreferred = parseRawArgumentString(raw, false);
if (escapePreferred.openQuote === null) {
return escapePreferred.tokens;
}

return parseRawArgumentString(raw, true).tokens;
}
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
Loading