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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,10 @@ Use it when you want Codex to:
- take a faster or cheaper pass with a smaller model

> [!NOTE]
> Depending on the task and the model you choose these tasks might take a long time and it's generally recommended to force the task to be in the background or move the agent to the background.
> Depending on the task and model, a rescue can take a while. `--background`
> detaches the Claude Code subagent while its Codex command stays attached to
> that subagent, so the final result is delivered back automatically when the
> subagent completes.

It supports `--background`, `--wait`, `--resume`, and `--fresh`. If you omit `--resume` and `--fresh`, the plugin can offer to continue the latest rescue thread for this repo.

Expand Down
9 changes: 7 additions & 2 deletions plugins/codex/agents/codex-rescue.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,12 @@ Selection guidance:
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.
- Always invoke the companion `task` command in the foreground. Never add
`--background` to the companion command, even for a long or complicated task.
- `--background` controls whether Claude Code runs this subagent in the
background; it does not control the companion process. Keeping the companion
call foreground-bound lets the subagent completion notification carry the
final Codex output back to the coordinating Claude thread.
- 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 All @@ -31,6 +35,7 @@ Forwarding rules:
- If the user asks for `spark`, map that to `--model gpt-5.3-codex-spark`.
- If the user asks for a concrete model name such as `gpt-5.4-mini`, pass it through with `--model`.
- Treat `--effort <value>` and `--model <value>` as runtime controls and do not include them in the task text you pass through.
- Strip `--background` and `--wait` from the task text and command arguments.
- Default to a write-capable Codex run by adding `--write` unless the user explicitly asks for read-only behavior or only wants review, diagnosis, or research without edits.
- Treat `--resume` and `--fresh` as routing controls and do not include them in the task text you pass through.
- `--resume` means add `--resume-last`.
Expand Down
5 changes: 5 additions & 0 deletions plugins/codex/commands/rescue.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ Execution mode:
- If the request includes `--wait`, run the `codex:codex-rescue` subagent in the foreground.
- 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.
- Whether the subagent itself is foreground or background, its single companion
`task` command must remain foreground-bound. This is how the final Codex
stdout returns in the subagent completion notification.
- `--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.
- 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.
Expand All @@ -39,6 +42,8 @@ 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.
- Reject any subagent command that adds companion `task --background`; only the
Claude Code `Agent` execution may be detached.
- 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
53 changes: 45 additions & 8 deletions plugins/codex/scripts/codex-companion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
generateJobId,
getConfig,
listJobs,
resolveJobStartGateFile,
setConfig,
upsertJob,
writeJobFile
Expand Down Expand Up @@ -149,7 +150,8 @@ function parseCommandInput(argv, config = {}) {
}

function resolveCommandCwd(options = {}) {
return options.cwd ? path.resolve(process.cwd(), options.cwd) : process.cwd();
const requestedCwd = options.cwd ?? process.env.CLAUDE_PROJECT_DIR;
return requestedCwd ? path.resolve(process.cwd(), requestedCwd) : process.cwd();
}

function resolveCommandWorkspace(options = {}) {
Expand Down Expand Up @@ -668,9 +670,12 @@ async function runForegroundCommand(job, runner, options = {}) {
return execution;
}

function spawnDetachedTaskWorker(cwd, jobId) {
function spawnDetachedTaskWorker(cwd, jobId, startGate) {
const scriptPath = path.join(ROOT_DIR, "scripts", "codex-companion.mjs");
const child = spawn(process.execPath, [scriptPath, "task-worker", "--cwd", cwd, "--job-id", jobId], {
const child = spawn(process.execPath, [
scriptPath, "task-worker", "--cwd", cwd, "--job-id", jobId,
"--start-gate", startGate
], {
cwd,
env: process.env,
detached: true,
Expand All @@ -685,17 +690,36 @@ 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,
pid: null,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the detached worker PID in the queued record

When cancel runs after enqueue returns but before the worker enters runTrackedJob, this null PID makes terminateProcessTree a no-op. The worker does not check whether the stored job was cancelled, so it can subsequently overwrite the cancelled record as running and continue executing, including a --write task. Persist before spawning as intended, but update the queued record with child.pid before returning so cancellation can terminate the worker during this window.

Useful? React with 👍 / 👎.

logFile,
request
};
// The job file bootstraps the gated worker, but the shared index must not
// expose a cancellable job until its detached process has a usable PID.
writeJobFile(job.workspaceRoot, job.id, queuedRecord);
upsertJob(job.workspaceRoot, queuedRecord);

const startGate = resolveJobStartGateFile(job.workspaceRoot, job.id);
try {
const child = spawnDetachedTaskWorker(cwd, job.id, startGate);
const launchRecord = { ...queuedRecord, pid: child.pid ?? null };
writeJobFile(job.workspaceRoot, job.id, launchRecord);
upsertJob(job.workspaceRoot, launchRecord);
fs.writeFileSync(startGate, "ready\n", "utf8");
Comment on lines +708 to +711

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recheck cancellation before releasing the worker

When another process cancels the job after the initial queued record is published but before this launch record is saved, handleCancel sees a queued job with a null PID and records it as cancelled, but these lines then overwrite that state back to queued and release the worker. The worker consequently observes the overwritten status and executes the task, including a possible --write task, despite cancellation having succeeded. The new queued-before-spawn ordering is fresh evidence of this distinct race; re-read the stored status before overwriting it or make registration and cancellation atomic.

Useful? React with 👍 / 👎.

} catch (error) {
const failedRecord = {
...queuedRecord,
status: "failed",
phase: "failed",
errorMessage: error instanceof Error ? error.message : String(error)
};
writeJobFile(job.workspaceRoot, job.id, failedRecord);
upsertJob(job.workspaceRoot, failedRecord);
throw error;
}

return {
payload: {
Expand Down Expand Up @@ -837,7 +861,7 @@ async function handleTransfer(argv) {

async function handleTaskWorker(argv) {
const { options } = parseCommandInput(argv, {
valueOptions: ["cwd", "job-id"]
valueOptions: ["cwd", "job-id", "start-gate"]
});

if (!options["job-id"]) {
Expand All @@ -846,10 +870,23 @@ async function handleTaskWorker(argv) {

const cwd = resolveCommandCwd(options);
const workspaceRoot = resolveCommandWorkspace(options);
if (options["start-gate"]) {
const deadline = Date.now() + 30000;
while (!fs.existsSync(options["start-gate"])) {
if (Date.now() >= deadline) {
throw new Error(`Timed out waiting for task ${options["job-id"]} to be registered.`);
}
await sleep(10);
}
fs.unlinkSync(options["start-gate"]);
}
const storedJob = readStoredJob(workspaceRoot, options["job-id"]);
if (!storedJob) {
throw new Error(`No stored job found for ${options["job-id"]}.`);
}
if (storedJob.status === "cancelled") {
return;
}

const request = storedJob.request;
if (!request || typeof request !== "object") {
Expand Down Expand Up @@ -973,7 +1010,7 @@ async function handleCancel(argv) {
const threadId = existing.threadId ?? job.threadId ?? null;
const turnId = existing.turnId ?? job.turnId ?? null;

const interrupt = await interruptAppServerTurn(cwd, { threadId, turnId });
const interrupt = await interruptAppServerTurn(workspaceRoot, { threadId, turnId });
if (interrupt.attempted) {
appendLogLine(
job.logFile,
Expand Down
66 changes: 55 additions & 11 deletions plugins/codex/scripts/lib/job-control.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import fs from "node:fs";

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

Expand Down Expand Up @@ -210,6 +210,18 @@ function matchJobReference(jobs, reference, predicate = () => true) {
throw new Error(`No job found for "${reference}". Run /codex:status to list known jobs.`);
}

function findCrossWorkspaceJob(reference, predicate = () => true) {
const matches = findJobsAcrossWorkspaces(reference).filter(predicate);
if (matches.length === 1) {
const job = matches[0];
return { workspaceRoot: job.workspaceRoot, job };
}
if (matches.length > 1) {
throw new Error(`Job reference "${reference}" is ambiguous across workspaces. Use the full job id.`);
}
return null;
}

export function buildStatusSnapshot(cwd, options = {}) {
const workspaceRoot = resolveWorkspaceRoot(cwd);
const config = getConfig(workspaceRoot);
Expand Down Expand Up @@ -242,7 +254,19 @@ export function buildStatusSnapshot(cwd, options = {}) {
export function buildSingleJobSnapshot(cwd, reference, options = {}) {
const workspaceRoot = resolveWorkspaceRoot(cwd);
const jobs = sortJobsNewestFirst(listJobs(workspaceRoot));
const selected = matchJobReference(jobs, reference);
let selected;
try {
selected = matchJobReference(jobs, reference);
} catch (error) {
const crossWorkspace = findCrossWorkspaceJob(reference);
if (!crossWorkspace) {
throw error;
}
return {
workspaceRoot: crossWorkspace.workspaceRoot,
job: enrichJob(crossWorkspace.job, { maxProgressLines: options.maxProgressLines })
Comment on lines +261 to +267

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Recover cross-workspace jobs when cancelling

When this fallback finds an active job from another workspace, the rendered status includes /codex:cancel <id>, but resolveCancelableJob still searches only listJobs(workspaceRoot). Consequently, a background task— including one running with --write—can be monitored from the recovered scope but cannot be stopped there; explicit cancellation should use the same cross-workspace lookup with an active-status predicate.

Useful? React with 👍 / 👎.

};
}
if (!selected) {
throw new Error(`No job found for "${reference}". Run /codex:status to inspect known jobs.`);
}
Expand All @@ -256,11 +280,23 @@ export function buildSingleJobSnapshot(cwd, reference, options = {}) {
export function resolveResultJob(cwd, reference) {
const workspaceRoot = resolveWorkspaceRoot(cwd);
const jobs = sortJobsNewestFirst(reference ? listJobs(workspaceRoot) : filterJobsForCurrentSession(listJobs(workspaceRoot)));
const selected = matchJobReference(
jobs,
reference,
(job) => job.status === "completed" || job.status === "failed" || job.status === "cancelled"
);
let selected;
try {
selected = matchJobReference(
jobs,
reference,
(job) => job.status === "completed" || job.status === "failed" || job.status === "cancelled"
);
} catch (error) {
const crossWorkspace = findCrossWorkspaceJob(
reference,
(job) => job.status === "completed" || job.status === "failed" || job.status === "cancelled"
);
if (crossWorkspace) {
return crossWorkspace;
}
throw error;
}

if (selected) {
return { workspaceRoot, job: selected };
Expand All @@ -284,11 +320,19 @@ export function resolveCancelableJob(cwd, reference, options = {}) {
const activeJobs = jobs.filter((job) => job.status === "queued" || job.status === "running");

if (reference) {
const selected = matchJobReference(activeJobs, reference);
if (!selected) {
throw new Error(`No active job found for "${reference}".`);
try {
const selected = matchJobReference(activeJobs, reference);
return { workspaceRoot, job: selected };
} catch (error) {
const crossWorkspace = findCrossWorkspaceJob(
reference,
(job) => job.status === "queued" || job.status === "running"
);
if (crossWorkspace) {
return crossWorkspace;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Interrupt the owning workspace's broker

When this fallback resolves a running job from another workspace, handleCancel still calls interruptAppServerTurn(cwd, ...) with the invocation workspace rather than the returned workspaceRoot (codex-companion.mjs:1012). For a job using a workspace-scoped shared broker, that looks up the wrong broker.json and sends the interrupt to a new or unrelated app server; killing the detached worker can then leave the brokered turn—potentially a --write task—running after the command records it as cancelled. Pass the owning workspace to the interrupt call.

Useful? React with 👍 / 👎.

}
throw error;
}
return { workspaceRoot, job: selected };
}

const sessionScopedActiveJobs = filterJobsForCurrentSession(activeJobs, options);
Expand Down
43 changes: 40 additions & 3 deletions plugins/codex/scripts/lib/state.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ function defaultState() {
};
}

function resolveStateRootDir() {
const pluginDataDir = process.env[PLUGIN_DATA_ENV];
return pluginDataDir ? path.join(pluginDataDir, "state") : FALLBACK_STATE_ROOT_DIR;
}

export function resolveStateDir(cwd) {
const workspaceRoot = resolveWorkspaceRoot(cwd);
let canonicalWorkspaceRoot = workspaceRoot;
Expand All @@ -38,9 +43,36 @@ export function resolveStateDir(cwd) {
const slugSource = path.basename(workspaceRoot) || "workspace";
const slug = slugSource.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "workspace";
const hash = createHash("sha256").update(canonicalWorkspaceRoot).digest("hex").slice(0, 16);
const pluginDataDir = process.env[PLUGIN_DATA_ENV];
const stateRoot = pluginDataDir ? path.join(pluginDataDir, "state") : FALLBACK_STATE_ROOT_DIR;
return path.join(stateRoot, `${slug}-${hash}`);
return path.join(resolveStateRootDir(), `${slug}-${hash}`);
}

export function findJobsAcrossWorkspaces(reference) {
const stateRoot = resolveStateRootDir();
if (!reference || !fs.existsSync(stateRoot)) {
return [];
}

const matches = [];
for (const entry of fs.readdirSync(stateRoot, { withFileTypes: true })) {
if (!entry.isDirectory()) {
continue;
}
const stateFile = path.join(stateRoot, entry.name, STATE_FILE_NAME);
if (!fs.existsSync(stateFile)) {
continue;
}
try {
const state = JSON.parse(fs.readFileSync(stateFile, "utf8"));
for (const job of Array.isArray(state.jobs) ? state.jobs : []) {
if (job.id === reference || job.id?.startsWith(reference)) {
matches.push(job);
}
}
} catch {
// One corrupt workspace index must not hide healthy jobs elsewhere.
}
}
return matches;
}

export function resolveStateFile(cwd) {
Expand Down Expand Up @@ -189,3 +221,8 @@ export function resolveJobFile(cwd, jobId) {
ensureStateDir(cwd);
return path.join(resolveJobsDir(cwd), `${jobId}.json`);
}

export function resolveJobStartGateFile(cwd, jobId) {
ensureStateDir(cwd);
return path.join(resolveJobsDir(cwd), `${jobId}.ready`);
}
1 change: 1 addition & 0 deletions plugins/codex/skills/codex-cli-runtime/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Execution rules:
Command selection:
- Use exactly one `task` invocation per rescue handoff.
- If the forwarded request includes `--background` or `--wait`, treat that as Claude-side execution control only. Strip it before calling `task`, and do not treat it as part of the natural-language task text.
- Always run the companion `task` invocation in the foreground. Never infer or add companion `--background`; Claude Code owns subagent detachment and needs the foreground command's final stdout in the subagent completion notification.
- If the forwarded request includes `--model`, normalize `spark` to `gpt-5.3-codex-spark` and pass it through to `task`.
- If the forwarded request includes `--effort`, pass it through to `task`.
- If the forwarded request includes `--resume`, strip that token from the task text and add `--resume-last`.
Expand Down
7 changes: 5 additions & 2 deletions tests/commands.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,9 @@ test("rescue command absorbs continue semantics", () => {
assert.match(agent, /--resume/);
assert.match(agent, /--fresh/);
assert.match(agent, /thin forwarding wrapper/i);
assert.match(agent, /prefer foreground for a small, clearly bounded rescue request/i);
assert.match(agent, /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/i);
assert.match(agent, /Always invoke the companion `task` command in the foreground/i);
assert.match(agent, /Never add\s+`--background` to the companion command/i);
assert.match(agent, /subagent completion notification carry the\s+final Codex output/i);
assert.match(agent, /Use exactly one `Bash` call/i);
assert.match(agent, /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/i);
assert.match(agent, /Do not call `review`, `adversarial-review`, `status`, `result`, or `cancel`/i);
Expand All @@ -150,6 +151,8 @@ test("rescue command absorbs continue semantics", () => {
assert.match(runtimeSkill, /Map `spark` to `--model gpt-5\.3-codex-spark`/i);
assert.match(runtimeSkill, /If the forwarded request includes `--background` or `--wait`, treat that as Claude-side execution control only/i);
assert.match(runtimeSkill, /Strip it before calling `task`/i);
assert.match(runtimeSkill, /Always run the companion `task` invocation in the foreground/i);
assert.match(runtimeSkill, /Never infer or add companion `--background`/i);
assert.match(runtimeSkill, /`--effort`: accepted values are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`/i);
assert.match(runtimeSkill, /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/i);
assert.match(runtimeSkill, /If the Bash call fails or Codex cannot be invoked, return nothing/i);
Expand Down
Loading