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}` : "";

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
5 changes: 3 additions & 2 deletions plugins/codex/scripts/lib/state.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import path from "node:path";
import { resolveWorkspaceRoot } from "./workspace.mjs";

const STATE_VERSION = 1;
const PLUGIN_DATA_ENV = "CLAUDE_PLUGIN_DATA";
const CODEX_PLUGIN_DATA_ENV = "CODEX_PLUGIN_DATA";
const CLAUDE_PLUGIN_DATA_ENV = "CLAUDE_PLUGIN_DATA";
const FALLBACK_STATE_ROOT_DIR = path.join(os.tmpdir(), "codex-companion");
const STATE_FILE_NAME = "state.json";
const JOBS_DIR_NAME = "jobs";
Expand Down Expand Up @@ -38,7 +39,7 @@ 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 pluginDataDir = process.env[CODEX_PLUGIN_DATA_ENV] || process.env[CLAUDE_PLUGIN_DATA_ENV];
const stateRoot = pluginDataDir ? path.join(pluginDataDir, "state") : FALLBACK_STATE_ROOT_DIR;
return path.join(stateRoot, `${slug}-${hash}`);
}
Expand Down
7 changes: 5 additions & 2 deletions plugins/codex/scripts/session-lifecycle-hook.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ import { loadState, resolveStateFile, saveState } from "./lib/state.mjs";
import { resolveWorkspaceRoot } from "./lib/workspace.mjs";

export const SESSION_ID_ENV = "CODEX_COMPANION_SESSION_ID";
const PLUGIN_DATA_ENV = "CLAUDE_PLUGIN_DATA";
const CODEX_PLUGIN_DATA_ENV = "CODEX_PLUGIN_DATA";
const CLAUDE_PLUGIN_DATA_ENV = "CLAUDE_PLUGIN_DATA";

function readHookInput() {
const raw = fs.readFileSync(0, "utf8").trim();
Expand Down Expand Up @@ -75,7 +76,9 @@ function cleanupSessionJobs(cwd, sessionId) {

function handleSessionStart(input) {
appendEnvVar(SESSION_ID_ENV, input.session_id);
appendEnvVar(PLUGIN_DATA_ENV, process.env[PLUGIN_DATA_ENV]);
// Export the Claude-provided plugin data dir under a codex-specific name
// to avoid polluting the global session environment with CLAUDE_PLUGIN_DATA.
appendEnvVar(CODEX_PLUGIN_DATA_ENV, process.env[CLAUDE_PLUGIN_DATA_ENV]);
}

async function handleSessionEnd(input) {
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