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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ After install, you should see:

- the slash commands listed below
- the `codex:codex-rescue` subagent in `/agents`
- three internal skills used by the rescue subagent: `codex-cli-runtime`, `codex-result-handling`, and `gpt-5-4-prompting` (these are not user-invocable)

One simple first run is:

Expand Down
4 changes: 2 additions & 2 deletions 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] [--resume|--fresh] [--model <model|spark>] [--effort <none|minimal|low|medium|high|xhigh>] [--context <text>] [what Codex should investigate, solve, or continue]"
context: fork
allowed-tools: Bash(node:*), AskUserQuestion
---
Expand All @@ -17,7 +17,7 @@ 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.
- `--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.
- `--model`, `--effort`, and `--context` 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.
- Otherwise, before starting Codex, check for a resumable rescue thread from this Claude session by running:
Expand Down
23 changes: 16 additions & 7 deletions plugins/codex/scripts/codex-companion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,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] [--resume-last|--resume|--fresh] [--model <model|spark>] [--effort <none|minimal|low|medium|high|xhigh>] [--context <text>] [prompt]",
" node scripts/codex-companion.mjs status [job-id] [--all] [--json]",
" node scripts/codex-companion.mjs result [job-id] [--json]",
" node scripts/codex-companion.mjs cancel [job-id] [--json]"
Expand Down Expand Up @@ -451,9 +451,12 @@ async function executeTaskRun(request) {
throw new Error("Provide a prompt, a prompt file, piped stdin, or use --resume-last.");
}

const contextSuffix = request.context ? `\n\n---\n\nAdditional context:\n${request.context}` : "";
const fullPrompt = request.prompt ? `${request.prompt}${contextSuffix}` : "";
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 Apply --context when resuming without a prompt

--context is silently dropped for task --resume/--resume-last calls that omit a new prompt. In executeTaskRun, fullPrompt is only built when request.prompt is truthy, so resume runs with only defaultPrompt never include the context text. This breaks the documented behavior of passing extra context and affects follow-up rescue flows where users resume a thread and provide constraints via --context only.

Useful? React with 👍 / 👎.


const result = await runAppServerTurn(workspaceRoot, {
resumeThreadId,
prompt: request.prompt,
prompt: fullPrompt,
defaultPrompt: resumeThreadId ? DEFAULT_CONTINUE_PROMPT : "",
model: request.model,
effort: request.effort,
Expand Down Expand Up @@ -570,15 +573,16 @@ function buildTaskJob(workspaceRoot, taskMetadata, write) {
});
}

function buildTaskRequest({ cwd, model, effort, prompt, write, resumeLast, jobId }) {
function buildTaskRequest({ cwd, model, effort, prompt, write, resumeLast, jobId, context }) {
return {
cwd,
model,
effort,
prompt,
write,
resumeLast,
jobId
jobId,
context
};
}

Expand Down Expand Up @@ -703,10 +707,11 @@ async function handleReview(argv) {

async function handleTask(argv) {
const { options, positionals } = parseCommandInput(argv, {
valueOptions: ["model", "effort", "cwd", "prompt-file"],
valueOptions: ["model", "effort", "cwd", "prompt-file", "context"],
booleanOptions: ["json", "write", "resume-last", "resume", "fresh", "background"],
aliasMap: {
m: "model"
m: "model",
c: "context"
}
});

Expand All @@ -727,6 +732,8 @@ async function handleTask(argv) {
resumeLast
});

const context = options.context ?? null;

if (options.background) {
ensureCodexReady(cwd);
requireTaskRequest(prompt, resumeLast);
Expand All @@ -739,7 +746,8 @@ async function handleTask(argv) {
prompt,
write,
resumeLast,
jobId: job.id
jobId: job.id,
context
});
const { payload } = enqueueBackgroundTask(cwd, job, request);
outputCommandResult(payload, renderQueuedTaskLaunch(payload), options.json);
Expand All @@ -758,6 +766,7 @@ async function handleTask(argv) {
write,
resumeLast,
jobId: job.id,
context,
onProgress: progress
}),
{ json: options.json }
Expand Down
20 changes: 16 additions & 4 deletions plugins/codex/scripts/lib/broker-lifecycle.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,19 +40,31 @@ export async function waitForBrokerEndpoint(endpoint, timeoutMs = 2000) {
return false;
}

export async function sendBrokerShutdown(endpoint) {
export async function sendBrokerShutdown(endpoint, timeoutMs = 5000) {
await new Promise((resolve) => {
const socket = connectToEndpoint(endpoint);
socket.setEncoding("utf8");

const timer = setTimeout(() => {
socket.destroy();
resolve();
}, timeoutMs);
timer.unref?.();

const cleanup = () => {
clearTimeout(timer);
resolve();
};

socket.on("connect", () => {
socket.write(`${JSON.stringify({ id: 1, method: "broker/shutdown", params: {} })}\n`);
});
socket.on("data", () => {
socket.end();
resolve();
cleanup();
});
socket.on("error", resolve);
socket.on("close", resolve);
socket.on("error", cleanup);
socket.on("close", cleanup);
});
}

Expand Down
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 @@ -33,6 +33,7 @@ Command selection:
- `--resume`: always use `task --resume-last`, even if the request text is ambiguous.
- `--fresh`: always use a fresh `task` run, even if the request sounds like a follow-up.
- `--effort`: accepted values are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`.
- `--context "<text>"`: pass additional context to Codex that will be appended to the prompt. Use this to provide extra background information, constraints, or specifications.
- `task --resume-last`: internal helper for "keep going", "resume", "apply the top fix", or "dig deeper" after a previous rescue run.

Safety rules:
Expand Down
70 changes: 70 additions & 0 deletions tests/args.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import test from "node:test";
import assert from "node:assert/strict";

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

// --- parseArgs ---

test("parseArgs: boolean flag --flag=true sets true, --flag=false sets false", () => {
const configTrue = parseArgs(["--verbose=true"], { booleanOptions: ["verbose"] });
assert.equal(configTrue.options.verbose, true);

const configFalse = parseArgs(["--verbose=false"], { booleanOptions: ["verbose"] });
assert.equal(configFalse.options.verbose, false);
});

test("parseArgs: value option --output consumes next token", () => {
const { options } = parseArgs(["--output", "/tmp/out.txt"], { valueOptions: ["output"] });
assert.equal(options.output, "/tmp/out.txt");
});

test("parseArgs: inline value --output=path uses inline value", () => {
const { options } = parseArgs(["--output=/tmp/out.txt"], { valueOptions: ["output"] });
assert.equal(options.output, "/tmp/out.txt");
});

test("parseArgs: short alias -o resolved via aliasMap", () => {
const { options } = parseArgs(["-o", "/tmp/out.txt"], {
valueOptions: ["output"],
aliasMap: { o: "output" },
});
assert.equal(options.output, "/tmp/out.txt");
});

test("parseArgs: positionals after -- land in positionals array", () => {
const { options, positionals } = parseArgs(
["--verbose", "--", "--not-a-flag", "file.txt"],
{ booleanOptions: ["verbose"] }
);
assert.equal(options.verbose, true);
assert.deepEqual(positionals, ["--not-a-flag", "file.txt"]);
});

test("parseArgs: missing value for value option throws Error", () => {
assert.throws(
() => parseArgs(["--output"], { valueOptions: ["output"] }),
{ message: "Missing value for --output" }
);
});

// --- splitRawArgumentString ---

test("splitRawArgumentString: space-separated tokens", () => {
assert.deepEqual(splitRawArgumentString("foo bar baz"), ["foo", "bar", "baz"]);
});

test("splitRawArgumentString: single-quoted string with spaces becomes one token", () => {
assert.deepEqual(splitRawArgumentString("hello 'foo bar' world"), ["hello", "foo bar", "world"]);
});

test("splitRawArgumentString: double-quoted string with spaces becomes one token", () => {
assert.deepEqual(splitRawArgumentString('hello "foo bar" world'), ["hello", "foo bar", "world"]);
});

test("splitRawArgumentString: backslash escape preserves next char", () => {
assert.deepEqual(splitRawArgumentString("foo\\ bar baz"), ["foo bar", "baz"]);
});

test("splitRawArgumentString: trailing backslash appended literally", () => {
assert.deepEqual(splitRawArgumentString("foo\\"), ["foo\\"]);
});
2 changes: 1 addition & 1 deletion tests/commands.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ test("rescue command absorbs continue semantics", () => {
assert.match(rescue, /run the `codex:codex-rescue` subagent in the background/i);
assert.match(rescue, /default to foreground/i);
assert.match(rescue, /Do not forward them to `task`/i);
assert.match(rescue, /`--model` and `--effort` are runtime-selection flags/i);
assert.match(rescue, /`--model`, `--effort`, and `--context` are runtime-selection flags/i);
assert.match(rescue, /Leave `--effort` unset unless the user explicitly asks for a specific reasoning effort/i);
assert.match(rescue, /If they ask for `spark`, map it to `gpt-5\.3-codex-spark`/i);
assert.match(rescue, /If the request includes `--resume`, do not ask whether to continue/i);
Expand Down
4 changes: 4 additions & 0 deletions tests/fake-codex-fixture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,10 @@ function structuredReviewPayload(prompt) {
}

function taskPayload(prompt, resume) {
if (BEHAVIOR === "empty-stdout") {
return "";
}

if (prompt.includes("<task>") && prompt.includes("Only review the work from the previous Claude turn.")) {
if (BEHAVIOR === "adversarial-clean") {
return "ALLOW: No blocking issues found in the previous turn.";
Expand Down
25 changes: 25 additions & 0 deletions tests/prompts.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import test from "node:test";
import assert from "node:assert/strict";

import { interpolateTemplate } from "../plugins/codex/scripts/lib/prompts.mjs";

test("interpolateTemplate: replaces {{KEY}} with provided variable", () => {
assert.equal(interpolateTemplate("Hello {{NAME}}", { NAME: "World" }), "Hello World");
});

test("interpolateTemplate: replaces multiple different keys in one pass", () => {
const result = interpolateTemplate("{{GREETING}}, {{NAME}}!", { GREETING: "Hi", NAME: "Alice" });
assert.equal(result, "Hi, Alice!");
});

test("interpolateTemplate: unknown key is replaced with empty string", () => {
assert.equal(interpolateTemplate("Hello {{MISSING}}", {}), "Hello ");
});

test("interpolateTemplate: template with no placeholders is returned unchanged", () => {
assert.equal(interpolateTemplate("no placeholders here", { KEY: "val" }), "no placeholders here");
});

test("interpolateTemplate: key appearing twice is replaced both times", () => {
assert.equal(interpolateTemplate("{{X}} and {{X}}", { X: "ok" }), "ok and ok");
});
Loading