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
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.
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
Loading