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: 1 addition & 1 deletion plugins/codex/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "codex",
"version": "1.0.4",
"version": "1.0.5",

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 release version metadata in sync

Changing only this plugin manifest to 1.0.5 leaves the repository's release metadata inconsistent: package.json, package-lock.json, and .claude-plugin/marketplace.json are still 1.0.4, so npm run check-version now fails with plugins/codex/.claude-plugin/plugin.json version: expected 1.0.4, found 1.0.5. Either bump all manifests with the existing version script or leave this value aligned until release.

Useful? React with 👍 / 👎.

"description": "Use Codex from Claude Code to review code or delegate tasks.",
"author": {
"name": "OpenAI"
Expand Down
11 changes: 11 additions & 0 deletions plugins/codex/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Changelog

## 1.0.5

- Add managed image generation: `/codex:imagegen` plus an `imagegen` companion
subcommand, run over the same single serialized app-server connection the
plugin already uses for code tasks. Flags: `--out`, `--image <ref[,ref...]>`
(edit from reference images), `--background`, `--force`, `--model`. The path
captures the `imageGeneration` item the moment it arrives, writes the bytes to
`--out`, then interrupts the turn so the model's post-image shell tail never
runs. Reusing the one serialized connection also avoids the 403/429 contention
that bare parallel Codex CLI calls caused. Fixes #356.

## 1.0.0

- Initial version of the Codex plugin for Claude Code
30 changes: 30 additions & 0 deletions plugins/codex/commands/imagegen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
description: Generate an image with Codex (managed and serialized) and save it to a file
argument-hint: '[--out <path>] [--force] [--image <ref[,ref...]>] [--background] [--model <model>] [what the image should be]'
allowed-tools: Bash(node:*)
---

Generate an image through the Codex companion's managed, serialized image path and return its output verbatim.

Raw user request:
$ARGUMENTS

Run one `Bash` call:

```bash
node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" imagegen "$ARGUMENTS"
```

How it works:

- `imagegen` runs a single Codex app-server turn, captures the generated image the moment it is ready, writes it to `--out` (or reports the Codex copy under `~/.codex/generated_images/`), and interrupts the turn so the model's post-image steps do not run.
- It is serialized like every other companion job, so it never runs Codex concurrently. That is what avoids the 403/429 websocket contention and wasted quota that raw parallel `codex exec` causes.
- Pass a reference image for editing with `--image ref.png`. Comma-separate several: `--image a.png,b.png`.

Operating rules:

- Default to foreground. With `--background`, the job is queued: report the job id and tell the user they can watch it with `/codex:status <id>` and fetch it with `/codex:result <id>`.
- `--out` refuses to overwrite an existing file. If the user wants to replace it, add `--force`.
- Return the companion stdout to the user. Do not paraphrase the `Saved image:` path.
- If the companion reports that Codex is missing or unauthenticated, tell the user to run `/codex:setup`.
- If the user gave no prompt, ask what the image should be.
14 changes: 13 additions & 1 deletion plugins/codex/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
{
"description": "Optional stop-time review gate for Codex Companion.",
"description": "Stop-time review gate + managed image-gen routing for Codex Companion.",
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/imagegen-route-hook.mjs\"",
"timeout": 5
}
]
}
],
"SessionStart": [
{
"hooks": [
Expand Down
111 changes: 105 additions & 6 deletions plugins/codex/scripts/codex-companion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
interruptAppServerTurn,
parseStructuredOutput,
readOutputSchema,
runAppServerImageGen,
runAppServerReview,
runAppServerTurn
} from "./lib/codex.mjs";
Expand Down Expand Up @@ -59,7 +60,8 @@ import {
renderJobStatusReport,
renderSetupReport,
renderStatusReport,
renderTaskResult
renderTaskResult,
renderImageGenResult
} from "./lib/render.mjs";

const ROOT_DIR = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
Expand All @@ -69,6 +71,7 @@ const DEFAULT_STATUS_POLL_INTERVAL_MS = 2000;
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.";
const IMAGE_JOB_TITLE = "Codex Image";

function printUsage() {
console.log(
Expand All @@ -78,6 +81,7 @@ function printUsage() {
" 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 imagegen [--background] [--out <path>] [--force] [--image <ref[,ref...]>] [--model <model>] [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 @@ -558,6 +562,9 @@ function getJobKindLabel(kind, jobClass) {
if (kind === "adversarial-review") {
return "adversarial-review";
}
if (jobClass === "image") {
return "image";
}
return jobClass === "review" ? "review" : "rescue";
}

Expand Down Expand Up @@ -822,21 +829,110 @@ async function handleTaskWorker(argv) {
logFile: storedJob.logFile ?? null
}
);
const runner =
storedJob.jobClass === "image"
? () => executeImageGenRun({ ...request, onProgress: progress })
: () => executeTaskRun({ ...request, onProgress: progress });
await runTrackedJob(
{
...storedJob,
workspaceRoot,
logFile
},
() =>
executeTaskRun({
...request,
onProgress: progress
}),
runner,
{ logFile }
);
}

async function executeImageGenRun(request) {
ensureCodexAvailable(request.cwd);

const result = await runAppServerImageGen(request.cwd, {
prompt: request.prompt,
images: request.images,
model: request.model,
outPath: request.outPath,
overwrite: request.overwrite,
onProgress: request.onProgress
});

const rendered = renderImageGenResult(result, { title: IMAGE_JOB_TITLE });
const payload = {
status: 0,
threadId: result.threadId,
outPath: result.outPath,
savedPath: result.savedPath,
revisedPrompt: result.revisedPrompt
};

return {
exitStatus: 0,
threadId: result.threadId,
turnId: result.turnId,
payload,
rendered,
summary: `Image saved to ${result.outPath ?? result.savedPath ?? "(unknown path)"}.`,
jobTitle: IMAGE_JOB_TITLE,
jobClass: "image",
write: true
};
}

function buildImageGenJob(workspaceRoot, summary) {
return createCompanionJob({
prefix: "image",
kind: "image",
title: IMAGE_JOB_TITLE,
workspaceRoot,
jobClass: "image",
summary,
write: true
});
}

function buildImageGenRequest({ cwd, model, prompt, images, outPath, overwrite, jobId }) {
return { cwd, model, prompt, images, outPath, overwrite, jobId };
}

async function handleImageGen(argv) {
const { options, positionals } = parseCommandInput(argv, {
valueOptions: ["model", "cwd", "out", "image", "prompt-file"],
booleanOptions: ["json", "background", "force"],
aliasMap: { m: "model", o: "out" }
});

const cwd = resolveCommandCwd(options);
const workspaceRoot = resolveCommandWorkspace(options);
const model = normalizeRequestedModel(options.model);
const prompt = readTaskPrompt(cwd, options, positionals).trim();
if (!prompt) {
throw new Error("Provide an image prompt (positional text, --prompt-file, or piped stdin).");
}
const outPath = options.out ? path.resolve(cwd, options.out) : null;
const images = options.image
? options.image.split(",").map((value) => value.trim()).filter(Boolean)
: [];
const overwrite = Boolean(options.force);
const summary = `Image: ${shorten(prompt, 80)}`;

if (options.background) {
ensureCodexAvailable(cwd);
const job = buildImageGenJob(workspaceRoot, summary);
const request = buildImageGenRequest({ cwd, model, prompt, images, outPath, overwrite, jobId: job.id });
const { payload } = enqueueBackgroundTask(cwd, job, request);
outputCommandResult(payload, renderQueuedTaskLaunch(payload), options.json);
return;
}

const job = buildImageGenJob(workspaceRoot, summary);
await runForegroundCommand(
job,
(progress) =>
executeImageGenRun({ cwd, model, prompt, images, outPath, overwrite, jobId: job.id, onProgress: progress }),
{ json: options.json }
);
}

async function handleStatus(argv) {
const { options, positionals } = parseCommandInput(argv, {
valueOptions: ["cwd", "timeout-ms", "poll-interval-ms"],
Expand Down Expand Up @@ -1000,6 +1096,9 @@ async function main() {
case "task":
await handleTask(argv);
break;
case "imagegen":
await handleImageGen(argv);
break;
case "task-worker":
await handleTaskWorker(argv);
break;
Expand Down
123 changes: 123 additions & 0 deletions plugins/codex/scripts/imagegen-route-hook.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
#!/usr/bin/env node
// PreToolUse(Bash) — route ALL image generation through the managed /codex:imagegen path.
//
// Image generation must go through codex-companion's serialized app-server path.
// Every other route — garden gpt-image-2 generate.js / edit.js, baoyu-image-gen,
// image2_asset.py, a bare `codex exec ... imagegen`, or a direct POST to /v1/images
// — bypasses the single serialized connection, which is the websocket contention
// (403/429) and quota burn the managed path exists to prevent.
//
// This hook prefers REWRITE: when it can map a foreign command's prompt / output /
// reference flags onto the companion's `imagegen` subcommand, it rewrites the command
// in place (hookSpecificOutput.updatedInput) so it transparently runs through the
// managed path — no extra agent round-trip. When the mapping is not safe (prompt only
// in a file, free-text `codex exec`, raw curl), it falls back to BLOCK + redirect and
// lets the agent re-issue. It never rewrites into a guess.
//
// Coding `codex exec` (rescue / executor) and the companion's own imagegen are left
// untouched.

import { readFileSync } from "node:fs";

let raw = "";
try {
raw = readFileSync(0, "utf8");
} catch {
process.exit(0);
}

let cmd = "";
try {
cmd = (JSON.parse(raw).tool_input?.command ?? "").toString();
} catch {
process.exit(0);
}
if (!cmd.trim()) process.exit(0);

// Already the managed path — let it run.
if (/codex-companion\.mjs[\s\S]*\bimagegen\b/.test(cmd)) process.exit(0);

const COMPANION = `${process.env.CLAUDE_PLUGIN_ROOT ?? ""}/scripts/codex-companion.mjs`;

// Foreign image-gen tools and how their flags map onto the companion.
// out: flag whose value is the OUTPUT path -> companion --out
// ref: flag whose value is a REFERENCE image -> companion --image
// promptFileOnly: flags that supply the prompt via a file (can't inline) -> block
const TOOLS = [
{ re: /gpt-image-2[/\\][^\s]*\bgenerate\.js/i, prompt: ["--prompt"], out: ["--image"], ref: [], promptFileOnly: ["--promptfile"] },
{ re: /gpt-image-2[/\\][^\s]*\bedit\.js/i, prompt: ["--prompt"], out: [], ref: ["--image"], promptFileOnly: ["--promptfile"] },
{ re: /baoyu-image-gen[/\\][^\s]*main\.ts/i, prompt: ["--prompt"], out: ["--image"], ref: ["--ref", "--reference"], promptFileOnly: ["--promptfiles"] },
{ re: /image2_asset\.py/i, prompt: ["--prompt"], out: ["--output"], ref: ["--image"], promptFileOnly: [] },
];

const tool = TOOLS.find((t) => t.re.test(cmd));
if (tool) {
const prompt = flag(cmd, tool.prompt);
if (!prompt) block(promptOnlyInFile(cmd, tool) ? "a file-only prompt that cannot be inlined" : "an image-gen command with no inlinable --prompt");
const out = flag(cmd, tool.out);
const ref = flag(cmd, tool.ref);
const passBg = /(^|\s)--background(\s|$)/.test(cmd) ? " --background" : "";
const parts = [`node ${shq(COMPANION)} imagegen`];
if (out) parts.push(`--out ${shq(out)}`);
if (ref) parts.push(`--image ${shq(ref)}`);
const rewritten = parts.join(" ") + passBg + ` ${shq(prompt)}`;

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 shell context when rewriting image commands

When a foreign image command is run with required shell context, such as cd assets && node ~/.claude/skills/gpt-image-2/scripts/generate.js --prompt logo --image out.png, the rewrite replaces the entire Bash command with only the companion invocation. That drops the cd (and any env/setup prefix), so relative --out and --image paths are resolved from the original workspace instead of the intended directory, saving or reading the wrong files; preserve the leading context or avoid rewriting commands that are not a standalone tool invocation.

Useful? React with 👍 / 👎.

process.stdout.write(JSON.stringify({
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "allow",
permissionDecisionReason: "Rerouted through the managed /codex:imagegen path.",
updatedInput: { command: rewritten },
},
}));
process.exit(0);
}

// Routes we cannot safely rewrite -> block + redirect.
const stripped = cmd.replace(/'[^']*'/g, "").replace(/"[^"]*"/g, "");
// Use STRONG image-gen intent phrases only. Bare "imagegen" / "gpt-image" collide
// with file and test names (e.g. `codex exec "fix the imagegen-route-hook test"`),
// so they must NOT trip the block — only an explicit generate-an-image instruction does.
if (/codex\s+exec/.test(stripped) &&
/\bimage generation\b|\bgenerate (?:the |an |a )?image\b|built[- ]in image|generated_images/i.test(cmd)) {
block("a bare `codex exec` image generation");
}
if (/\/v1\/images\/(generations|edits)/i.test(cmd)) {

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 Scope image API blocking to real HTTP calls

When a coding or debugging command merely mentions the endpoint, such as rg '/v1/images/generations' . while updating tests or docs, this branch still exits with the image-generation block message even though no API request is being made. That prevents ordinary repository inspection for code containing the image API path; require an actual HTTP client/POST shape, or otherwise ignore quoted/search arguments, before blocking.

Useful? React with 👍 / 👎.

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 Only block actual image API calls

Because the new PreToolUse hook runs on every Bash command, this substring check blocks harmless coding commands that merely contain the endpoint text, such as rg "/v1/images/generations" while editing tests or docs, even though no POST or image generation is being attempted. Narrow this to commands that actually invoke the API, otherwise ordinary repository searches for that route exit 2 and interrupt the workflow.

Useful? React with 👍 / 👎.

block("a direct POST to /v1/images (OpenAI-compatible image API)");
}

process.exit(0);

// ── helpers ──────────────────────────────────────────────────────────────────
function flag(command, names) {
for (const n of names) {
const re = new RegExp(`${n.replace(/[-]/g, "\\$&")}(?:\\s+|=)(?:"([^"]*)"|'([^']*)'|(\\S+))`);

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 Parse flags without matching quoted prompt text

When the prompt itself contains a flag-like phrase for the same tool, for example --prompt 'draw the literal text --image on sign' --image out.png, this regex scans the raw command and matches the --image inside the quoted prompt first, rewriting the output as --out 'on' instead of out.png. Tokenize the shell command before extracting options so quoted prompt contents cannot be mistaken for real flags.

Useful? React with 👍 / 👎.

const m = command.match(re);
if (m) return m[1] ?? m[2] ?? m[3];
}
return null;
}

function promptOnlyInFile(command, tool) {
return tool.promptFileOnly.some((f) => flag(command, [f]) !== null);
}

function shq(s) {
return `'${String(s).replace(/'/g, `'\\''`)}'`;
}

function block(why) {
process.stderr.write(
`BLOCKED — image generation must go through the managed /codex:imagegen path.

Detected: ${why}.
This route can't be auto-rewritten safely, so it's blocked rather than guessed.

Do instead:
- Generate via the managed path:
/codex:imagegen --out <path> <what the image should be>
(edit with --image ref.png; queue with --background)
- To recover an image a prior run already produced:
ls -t ~/.codex/generated_images/*/*.png | head
`);
process.exit(2);
}
Loading