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
4 changes: 3 additions & 1 deletion plugins/codex/agents/codex-rescue.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Selection guidance:

Forwarding rules:

- Use exactly one `Bash` call to invoke `node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" task ...`.
- Use exactly one `Bash` call to invoke `node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" task [routing flags] -- "<prompt>"`.
- 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.
- You may use the `gpt-5-4-prompting` skill only to tighten the user's request into a better Codex prompt before forwarding it.
Expand All @@ -33,6 +33,8 @@ Forwarding rules:
- Treat `--effort <value>` and `--model <value>` as runtime controls and do not include them in the task text you pass through.
- 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.
- Place task routing flags (`--write`, `--resume-last`, `--model`, `--effort`, `--cwd`, etc.) before a `--` separator, then pass the natural-language prompt after `--`. Never put prompt text before `--`.
- `--background` and `--wait` are Claude-side execution control only. Strip them before calling `task` and never place them among the flags before `--`.
- `--resume` means add `--resume-last`.
- `--fresh` means do not add `--resume-last`.
- If the user is clearly asking to continue prior Codex work in this repository, such as "continue", "keep going", "resume", "apply the top fix", or "dig deeper", add `--resume-last` unless `--fresh` is present.
Expand Down
1 change: 1 addition & 0 deletions plugins/codex/scripts/codex-companion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -763,6 +763,7 @@ async function handleTask(argv) {
const { options, positionals } = parseCommandInput(argv, {
valueOptions: ["model", "effort", "cwd", "prompt-file"],
booleanOptions: ["json", "write", "resume-last", "resume", "fresh", "background"],
stopAtFirstPositional: true,
aliasMap: {
m: "model"
}
Expand Down
12 changes: 11 additions & 1 deletion plugins/codex/scripts/lib/args.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ export function parseArgs(argv, config = {}) {
const options = {};
const positionals = [];
let passthrough = false;
let stopOptions = false;

for (let index = 0; index < argv.length; index += 1) {
const token = argv[index];

if (passthrough) {
if (passthrough || stopOptions) {
positionals.push(token);
continue;
}
Expand All @@ -21,6 +22,9 @@ export function parseArgs(argv, config = {}) {

if (!token.startsWith("-") || token === "-") {
positionals.push(token);
if (config.stopAtFirstPositional) {
stopOptions = true;
}
Comment on lines 23 to +27

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 Stop parsing after any token becomes positional

When a raw task prompt starts with an unrecognized dash-prefixed token, such as --coverage run -m pytest, that token is added to positionals without enabling stopOptions because this new block only handles non-dash tokens. The later -m pytest is consequently still consumed as the model, so the prompt-parsing failure this option is intended to prevent remains for option-looking prompt prefixes. Set stopOptions whenever an unrecognized option is treated as positional as well.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 790d49c: when \stopAtFirstPositional\ is set, unrecognized long/short options that fall through as positionals now arm \stopOptions, so later tokens like -m\ stay in the prompt instead of being parsed as --model. Covered in \ ests/args.test.mjs.

continue;
}

Expand All @@ -46,6 +50,9 @@ export function parseArgs(argv, config = {}) {
}

positionals.push(token);
if (config.stopAtFirstPositional) {
stopOptions = true;
}
continue;
}

Expand All @@ -68,6 +75,9 @@ export function parseArgs(argv, config = {}) {
}

positionals.push(token);
if (config.stopAtFirstPositional) {
stopOptions = true;
}
}

return { options, positionals };
Expand Down
30 changes: 28 additions & 2 deletions plugins/codex/scripts/lib/codex.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -535,8 +535,20 @@ function applyTurnNotification(state, message) {
}
break;
case "error":
if (message.params?.willRetry) {
emitProgress(
state.onProgress,
`Codex error (retrying): ${message.params.error?.message ?? "unknown error"}`,
resolveErrorProgressPhase(message.params)
);
Comment on lines +539 to +543

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 Keep retriable errors out of the failed phase

When the app server emits an error with willRetry: true, this progress event is persisted by createJobProgressUpdater as the running job's phase, and /codex:status renders that phase directly. The job can therefore appear as running with phase failed while Codex is still retrying, potentially causing users or callers monitoring a background task to treat an active run as failed. Report a retrying/running phase here and reserve failed for terminal errors.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 790d49c: retriable app-server errors (\willRetry: true) now emit progress phase
etrying\ via
esolveErrorProgressPhase; terminal errors still use \ ailed. Covered in \ ests/codex-turn-status.test.mjs.

break;
}
state.error = message.params.error;
emitProgress(state.onProgress, `Codex error: ${message.params.error.message}`, "failed");
emitProgress(
state.onProgress,
`Codex error: ${message.params.error.message}`,
resolveErrorProgressPhase(message.params)
);
break;
case "turn/completed":
if ((message.params.threadId ?? null) !== state.threadId) {
Expand All @@ -550,6 +562,9 @@ function applyTurnNotification(state, message) {
"finalizing"
);
completeTurn(state, message.params.turn);
if (message.params.turn.status === "completed") {
state.error = null;
}
break;
default:
break;
Expand Down Expand Up @@ -751,7 +766,18 @@ async function resumeThread(client, threadId, cwd, options = {}) {
return client.request("thread/resume", buildResumeParams(threadId, cwd, options));
}

function buildResultStatus(turnState) {
export function shouldStoreTurnError(params = {}) {
return Boolean(params.error) && params.willRetry !== true;
}

export function resolveErrorProgressPhase(params = {}) {
return params.willRetry === true ? "retrying" : "failed";
}

export function buildResultStatus(turnState) {
if (turnState.error) {
return 1;
Comment thread
SomSamantray marked this conversation as resolved.
}
return turnState.finalTurn?.status === "completed" ? 0 : 1;
}

Expand Down
8 changes: 7 additions & 1 deletion plugins/codex/scripts/lib/process.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import process from "node:process";

export function runCommand(command, args = [], options = {}) {
Expand Down Expand Up @@ -36,7 +37,12 @@ export function runCommandChecked(command, args = [], options = {}) {
}

export function binaryAvailable(command, versionArgs = ["--version"], options = {}) {
const result = runCommand(command, versionArgs, options);
const probeOptions = { ...options };
if (probeOptions.cwd && !fs.existsSync(probeOptions.cwd)) {
delete probeOptions.cwd;
}

const result = runCommand(command, versionArgs, probeOptions);
if (result.error && /** @type {NodeJS.ErrnoException} */ (result.error).code === "ENOENT") {
return { available: false, detail: "not found" };
}
Expand Down
4 changes: 3 additions & 1 deletion plugins/codex/skills/codex-cli-runtime/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ user-invocable: false
Use this skill only inside the `codex:codex-rescue` subagent.

Primary helper:
- `node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" task "<raw arguments>"`
- `node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" task [routing flags] -- "<prompt>"`

Execution rules:
- The rescue subagent is a forwarder, not an orchestrator. Its only job is to invoke `task` once and return that stdout unchanged.
Expand Down Expand Up @@ -38,6 +38,8 @@ Command selection:
Safety rules:
- Default to write-capable Codex work in `codex:codex-rescue` unless the user explicitly asks for read-only behavior.
- Preserve the user's task text as-is apart from stripping routing flags.
- Always insert `--` immediately before the natural-language prompt. Put task routing flags (`--write`, `--resume-last`, `--model`, `--effort`, `--cwd`, etc.) before `--` and never include prompt text before `--`.
- Never place `--background` or `--wait` before `--`; those are Claude-side only and must already have been stripped.
- 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.
- Return the stdout of the `task` command exactly as-is.
- If the Bash call fails or Codex cannot be invoked, return nothing.
84 changes: 84 additions & 0 deletions tests/args.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import assert from "node:assert/strict";
import test from "node:test";

import { parseArgs } from "../plugins/codex/scripts/lib/args.mjs";

const TASK_CONFIG = {
valueOptions: ["model", "effort", "cwd", "prompt-file"],
booleanOptions: ["json", "write", "resume-last", "resume", "fresh", "background"],
aliasMap: {
m: "model",
C: "cwd"
}
};

test("parseArgs with stopAtFirstPositional keeps prompt fragments out of options", () => {
const result = parseArgs(["--write", "review", "-m", "pytest"], {
...TASK_CONFIG,
stopAtFirstPositional: true
});

assert.equal(result.options.write, true);
assert.equal(result.options.model, undefined);
assert.equal(result.positionals.join(" "), "review -m pytest");
});

test("parseArgs without stopAtFirstPositional still consumes -m as model", () => {
const result = parseArgs(["--write", "review", "-m", "pytest"], TASK_CONFIG);

assert.equal(result.options.write, true);
assert.equal(result.options.model, "pytest");
assert.equal(result.positionals.join(" "), "review");
});

test("parseArgs honors -- passthrough before stopAtFirstPositional matters", () => {
const result = parseArgs(["--write", "--", "-m", "pytest"], {
...TASK_CONFIG,
stopAtFirstPositional: true
});

assert.equal(result.options.write, true);
assert.equal(result.options.model, undefined);
assert.equal(result.positionals.join(" "), "-m pytest");
});

test("stopAtFirstPositional stops after unrecognized long option becomes positional", () => {
const result = parseArgs(["--write", "--coverage", "run", "-m", "pytest"], {
...TASK_CONFIG,
stopAtFirstPositional: true
});

assert.equal(result.options.write, true);
assert.equal(result.options.model, undefined);
assert.equal(result.positionals.join(" "), "--coverage run -m pytest");
});

test("without stopAtFirstPositional unrecognized long then -m still sets model", () => {
const result = parseArgs(["--write", "--coverage", "run", "-m", "pytest"], TASK_CONFIG);

assert.equal(result.options.write, true);
assert.equal(result.options.model, "pytest");
assert.equal(result.positionals.join(" "), "--coverage run");
});

test("stopAtFirstPositional stops after unrecognized short option becomes positional", () => {
const result = parseArgs(["--write", "-z", "-m", "pytest"], {
...TASK_CONFIG,
stopAtFirstPositional: true
});

assert.equal(result.options.write, true);
assert.equal(result.options.model, undefined);
assert.equal(result.positionals.join(" "), "-z -m pytest");
});

test("stopAtFirstPositional still parses recognized flags before first positional", () => {
const result = parseArgs(["--write", "--model", "spark", "fix it"], {
...TASK_CONFIG,
stopAtFirstPositional: true
});

assert.equal(result.options.write, true);
assert.equal(result.options.model, "spark");
assert.equal(result.positionals.join(" "), "fix it");
});
72 changes: 72 additions & 0 deletions tests/codex-turn-status.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
buildResultStatus,
resolveErrorProgressPhase,
shouldStoreTurnError
} from "../plugins/codex/scripts/lib/codex.mjs";

test("resolveErrorProgressPhase uses retrying for willRetry errors", () => {
assert.equal(
resolveErrorProgressPhase({
willRetry: true,
error: { message: "x" }
}),
"retrying"
);
assert.equal(resolveErrorProgressPhase({ error: { message: "x" } }), "failed");
assert.equal(resolveErrorProgressPhase({}), "failed");
});

test("shouldStoreTurnError ignores retriable app-server errors", () => {
assert.equal(
shouldStoreTurnError({
error: { message: "transient" },
willRetry: true
}),
false
);
assert.equal(
shouldStoreTurnError({
error: { message: "terminal" }
}),
true
);
});

test("buildResultStatus fails when turnState.error is set despite completed finalTurn", () => {
const status = buildResultStatus({
error: { message: "boom" },
finalTurn: { id: "turn_1", status: "completed" }
});

assert.equal(status, 1);
});

test("buildResultStatus succeeds for completed turn without error", () => {
const status = buildResultStatus({
error: null,
finalTurn: { id: "turn_1", status: "completed" }
});

assert.equal(status, 0);
});

test("buildResultStatus fails for non-completed finalTurn", () => {
const status = buildResultStatus({
error: null,
finalTurn: { id: "turn_1", status: "failed" }
});

assert.equal(status, 1);
});

test("buildResultStatus fails when only error is present", () => {
const status = buildResultStatus({
error: { message: "x" },
finalTurn: null
});

assert.equal(status, 1);
});
12 changes: 11 additions & 1 deletion tests/commands.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,14 @@ test("rescue command absorbs continue semantics", () => {
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, /Use exactly one `Bash` call/i);
assert.match(agent, /task \[routing flags\] -- "<prompt>"/);
assert.match(agent, /Never put prompt text before `--`/i);
assert.match(agent, /`--background` and `--wait` are Claude-side execution control only/i);
assert.match(agent, /never place them among the flags before `--`/i);
assert.doesNotMatch(
agent,
/Place (?:task )?routing flags \(`--write`[^)]*`--background`/
);
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);
assert.match(agent, /Leave `--effort` unset unless the user explicitly requests a specific reasoning effort/i);
Expand Down Expand Up @@ -192,7 +200,9 @@ test("internal docs use task terminology for rescue runs", () => {
const promptingSkill = read("skills/gpt-5-4-prompting/SKILL.md");
const promptRecipes = read("skills/gpt-5-4-prompting/references/codex-prompt-recipes.md");

assert.match(runtimeSkill, /codex-companion\.mjs" task "<raw arguments>"/);
assert.match(runtimeSkill, /codex-companion\.mjs" task \[routing flags\] -- "<prompt>"/);
assert.match(runtimeSkill, /Never include prompt text before `--`/i);
assert.match(runtimeSkill, /Never place `--background` or `--wait` before `--`/i);
assert.match(runtimeSkill, /Use `task` for every rescue request/i);
assert.match(runtimeSkill, /task --resume-last/i);
assert.match(promptingSkill, /Use `task` when the task is diagnosis/i);
Expand Down
20 changes: 19 additions & 1 deletion tests/process.test.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,25 @@
import test from "node:test";
import assert from "node:assert/strict";

import { terminateProcessTree } from "../plugins/codex/scripts/lib/process.mjs";
import process from "node:process";

import { binaryAvailable, terminateProcessTree } from "../plugins/codex/scripts/lib/process.mjs";

test("binaryAvailable ignores a missing cwd for version probes", () => {
const status = binaryAvailable(process.execPath, ["--version"], {
cwd: "/does/not/exist"
});

assert.equal(status.available, true);
});

test("binaryAvailable still reports a missing binary when cwd is invalid", () => {
const status = binaryAvailable("definitely-not-a-real-binary-xyz", ["--version"], {
cwd: "/does/not/exist"
});

assert.equal(status.available, false);
});

test("terminateProcessTree uses taskkill on Windows", () => {
let captured = null;
Expand Down
Loading