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.
- Claude Code stops a foreground Bash call after 600 seconds. Prefer background execution whenever a task could plausibly run longer than roughly eight minutes.
- A foreground task that is still running after eight minutes prints a `codex resume <thread-id>` recovery command before the host timeout.
- 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
45 changes: 43 additions & 2 deletions plugins/codex/scripts/codex-companion.mjs
Original file line number Diff line number Diff line change
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 DEFAULT_FOREGROUND_RECOVERY_HINT_MS = 8 * 60 * 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 Down Expand Up @@ -660,7 +661,47 @@ async function runForegroundCommand(job, runner, options = {}) {
logFile: options.logFile,
stderr: !options.json
});
const execution = await runTrackedJob(job, () => runner(progress), { logFile });
let recoveryTimer = null;
let recoveryThreadId = null;
const configuredDelay = Number(process.env.CODEX_COMPANION_FOREGROUND_RECOVERY_HINT_MS);
const recoveryHintDelayMs =
Number.isFinite(configuredDelay) && configuredDelay >= 0
? configuredDelay
: DEFAULT_FOREGROUND_RECOVERY_HINT_MS;
const progressWithRecovery = (event) => {
progress?.(event);
const threadId =
event && typeof event === "object" && typeof event.threadId === "string"
? event.threadId
: null;
if (
!options.recoveryHint ||
options.json ||
recoveryTimer ||
!threadId ||
threadId === recoveryThreadId
) {
return;
}
recoveryThreadId = threadId;
recoveryTimer = setTimeout(() => {
fs.writeSync(
1,
`Codex task is still running. If the host stops it, resume with: codex resume ${threadId}\n`
);
recoveryTimer = null;
}, recoveryHintDelayMs);
recoveryTimer.unref?.();
};

let execution;
try {
execution = await runTrackedJob(job, () => runner(progressWithRecovery), { logFile });
} finally {
if (recoveryTimer) {
clearTimeout(recoveryTimer);
}
}
outputResult(options.json ? execution.payload : execution.rendered, options.json);
if (execution.exitStatus !== 0) {
process.exitCode = execution.exitStatus;
Expand Down Expand Up @@ -818,7 +859,7 @@ async function handleTask(argv) {
jobId: job.id,
onProgress: progress
}),
{ json: options.json }
{ json: options.json, recoveryHint: true }
);
}

Expand Down
2 changes: 2 additions & 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,8 @@ 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.
- Claude Code enforces a 600-second ceiling on foreground Bash calls. Route work that could plausibly exceed roughly eight minutes through background execution.
- If a foreground task reaches eight minutes, preserve the recovery line containing `codex resume <thread-id>` in the returned output.
- 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
29 changes: 29 additions & 0 deletions tests/runtime.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -920,6 +920,35 @@ test("task using the shared broker still completes when Codex spawns subagents",
assert.equal(result.stdout, "Handled the requested task.\nTask prompt accepted.\n");
});

test("long foreground tasks print a recovery command before completion", () => {
const repo = makeTempDir();
const binDir = makeTempDir();
installFakeCodex(binDir, "slow-task");
initGitRepo(repo);
fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
run("git", ["add", "README.md"], { cwd: repo });
run("git", ["commit", "-m", "init"], { cwd: repo });

const result = run("node", [SCRIPT, "task", "investigate the long-running failure"], {
cwd: repo,
env: {
...buildEnv(binDir),
CODEX_COMPANION_FOREGROUND_RECOVERY_HINT_MS: "25"
}
});

assert.equal(result.status, 0, result.stderr);
assert.match(
result.stdout,
/Codex task is still running\. If the host stops it, resume with: codex resume thr_1/
);
assert.match(result.stdout, /Handled the requested task/);
assert.ok(
result.stdout.indexOf("codex resume thr_1") <
result.stdout.indexOf("Handled the requested task")
);
});

test("task --background enqueues a detached worker and exposes per-job status", async () => {
const repo = makeTempDir();
const binDir = makeTempDir();
Expand Down