Skip to content
Merged
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: 2 additions & 2 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@
},
"metadata": {
"description": "Codex plugins to use in Claude Code for delegation and code review.",
"version": "1.0.40"
"version": "1.0.41"
},
"plugins": [
{
"name": "codex",
"description": "Use Codex from Claude Code to review code or delegate tasks.",
"version": "1.0.40",
"version": "1.0.41",
"author": {
"name": "OpenAI"
},
Expand Down
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,28 @@ Examples:
Checks whether Codex is installed and authenticated.
If Codex is missing and npm is available, it can offer to install Codex for you.

You can also use `/codex:setup` to manage the optional review gate.
You can also use `/codex:setup` to manage the optional review gate and the backup model.

#### Choosing a backup model

```bash
/codex:setup --fallback-model gpt-5.6-terra
/codex:setup --clear-fallback-model
```

When a Codex run is rejected because the selected model is at capacity, the plugin retries the run once on a backup model instead of failing. Capacity rejections are transient and have nothing to do with your prompt, so the same work usually succeeds immediately on another model.

The backup model is resolved in this order:

1. the `CODEX_COMPANION_FALLBACK_MODEL` environment variable — set it to `none` to turn the fallback off entirely
2. the model configured with `/codex:setup --fallback-model`
3. whatever Codex itself advertises, picking its default model and skipping the one that was at capacity

Nothing is hardcoded, so the third step keeps working as OpenAI renames models.

The retry only happens when the rejected turn produced nothing at all — no output, no commands, no file edits. A capacity rejection that arrives after work has started leaves the run failed rather than risking a repeat of something that already ran.

Either way the failure is machine-readable: `/codex:status` and `/codex:result` report `failureClass` and `retryable` in their JSON, so a caller never has to pattern-match an error message to tell a transient capacity rejection from a real failure.

#### Enabling review gate

Expand Down Expand Up @@ -346,6 +367,8 @@ For rescue, `--background` backgrounds the subagent rather than the Codex run: t

`/codex:status` and `/codex:result <job-id>` inspect a tracked job from another turn, or recover a run whose turn ended before its result was read.

A background review pins the target it resolved at enqueue time, along with the repository state that target points at. If the repository moves before the detached worker starts — you commit the changes it was going to review, switch branches, or the base branch is deleted — the job fails with `failureClass: "state-drift"` and `retryable: true` rather than reviewing whatever is there now. Re-running the review picks up the new state. This trades a rare visible failure for the far worse alternative: a clean review of a change nobody looked at.

## Codex Integration

The Codex plugin wraps the [Codex app server](https://developers.openai.com/codex/app-server). It uses the global `codex` binary installed in your environment and [applies the same configuration](https://developers.openai.com/codex/config-basic).
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@openai/codex-plugin-cc",
"version": "1.0.40",
"version": "1.0.41",
"private": true,
"type": "module",
"description": "Use Codex from Claude Code to review code or delegate tasks.",
Expand Down
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.40",
"version": "1.0.41",
"description": "Use Codex from Claude Code to review code or delegate tasks.",
"author": {
"name": "OpenAI"
Expand Down
91 changes: 81 additions & 10 deletions plugins/codex/scripts/codex-companion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,15 @@ import {
runAppServerTurn
} from "./lib/codex.mjs";
import { resolveClaudeSessionPath } from "./lib/claude-session-transfer.mjs";
import { STATE_DRIFT } from "./lib/failure-class.mjs";
import { readStdinIfPiped } from "./lib/fs.mjs";
import { collectReviewContext, ensureGitRepository, resolveReviewTarget } from "./lib/git.mjs";
import {
captureRepoStateIdentity,
collectReviewContext,
describeRepoStateDrift,
ensureGitRepository,
resolveReviewTarget
} from "./lib/git.mjs";
import {
binaryAvailable,
getProcessStartTime,
Expand Down Expand Up @@ -87,7 +94,7 @@ const VALID_REASONING_EFFORTS = new Set(["none", "minimal", "low", "medium", "hi
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 SUBCOMMAND_USAGE = new Map([
["setup", " node scripts/codex-companion.mjs setup [--enable-review-gate|--disable-review-gate] [--json]"],
["setup", " node scripts/codex-companion.mjs setup [--enable-review-gate|--disable-review-gate] [--fallback-model <model>|--clear-fallback-model] [--json]"],
["review", " node scripts/codex-companion.mjs review [--wait|--background] [--base <ref>] [--scope <auto|working-tree|branch>]"],
["adversarial-review", " node scripts/codex-companion.mjs adversarial-review [--wait|--background] [--base <ref>] [--scope <auto|working-tree|branch>] [--model <model|spark>] [--effort <none|minimal|low|medium|high|xhigh|max|ultra>] [focus text]"],
["deep-review", " node scripts/codex-companion.mjs deep-review [--wait|--background] [--base <ref>] [--scope <auto|working-tree|branch>] [--model <model|spark>] [--effort <none|minimal|low|medium|high|xhigh|max|ultra>] [focus text]"],
Expand Down Expand Up @@ -122,8 +129,8 @@ const TASK_PARSE_CONFIG = {
stopAtFirstPositional: true
};
const SETUP_PARSE_CONFIG = {
valueOptions: ["cwd"],
booleanOptions: ["json", "enable-review-gate", "disable-review-gate"]
valueOptions: ["cwd", "fallback-model"],
booleanOptions: ["json", "enable-review-gate", "disable-review-gate", "clear-fallback-model"]
};
const TRANSFER_PARSE_CONFIG = {
valueOptions: ["cwd", "source"],
Expand Down Expand Up @@ -330,6 +337,7 @@ async function buildSetupReport(cwd, actionsTaken = []) {
auth: authStatus,
sessionRuntime: getSessionRuntimeStatus(process.env, workspaceRoot),
reviewGateEnabled: Boolean(config.stopReviewGate),
fallbackModel: config.fallbackModel ?? null,
actionsTaken,
nextSteps
};
Expand All @@ -341,6 +349,15 @@ async function handleSetup(argv) {
if (options["enable-review-gate"] && options["disable-review-gate"]) {
throw new Error("Choose either --enable-review-gate or --disable-review-gate.");
}
if (options["fallback-model"] != null && options["clear-fallback-model"]) {
throw new Error("Choose either --fallback-model or --clear-fallback-model.");
}
const fallbackModel = options["fallback-model"] == null
? null
: String(options["fallback-model"]).trim();
if (fallbackModel === "") {
throw new Error("--fallback-model requires a non-empty model name.");
}

const cwd = resolveCommandCwd(options);
const workspaceRoot = resolveCommandWorkspace(options);
Expand All @@ -353,6 +370,13 @@ async function handleSetup(argv) {
setConfig(workspaceRoot, "stopReviewGate", false);
actionsTaken.push(`Disabled the stop-time review gate for ${workspaceRoot}.`);
}
if (fallbackModel != null) {
setConfig(workspaceRoot, "fallbackModel", fallbackModel);
actionsTaken.push(`Configured fallback model ${fallbackModel} for ${workspaceRoot}.`);
} else if (options["clear-fallback-model"]) {
setConfig(workspaceRoot, "fallbackModel", null);
actionsTaken.push(`Cleared the configured fallback model for ${workspaceRoot}.`);
}

const finalReport = await buildSetupReport(cwd, actionsTaken);
outputResult(options.json ? finalReport : renderSetupReport(finalReport), options.json);
Expand Down Expand Up @@ -496,6 +520,21 @@ async function executeReviewRun(request) {
base: request.base,
scope: request.scope
});
const assertPinnedState = (completed = false) => {
if (!request.stateIdentity) {
return;
}
const drift = describeRepoStateDrift(request.cwd, target, request.stateIdentity);
if (!drift) {
return;
}
const message = completed
? `Review completed against repository state that has since moved: ${drift}. Discarding the result; re-run the review.`
: `Review target moved between enqueue and execution: ${drift}. Re-run the review.`;
throw Object.assign(new Error(message), { failureClass: STATE_DRIFT, retryable: true });
};

assertPinnedState();
const focusText = request.focusText?.trim() ?? "";
const reviewName = request.reviewName ?? "Review";
if (reviewName === "Review") {
Expand All @@ -505,11 +544,16 @@ async function executeReviewRun(request) {
model: request.model,
onProgress: request.onProgress
});
assertPinnedState(true);
const effectiveModel = result.modelFallback?.to ?? request.model;
const payload = {
review: reviewName,
target,
model: request.model ?? null,
model: effectiveModel ?? null,
effort: request.effort ?? null,
failureClass: result.failureClass,
modelFallback: result.modelFallback ?? null,
retryable: result.retryable,
threadId: result.threadId,
sourceThreadId: result.sourceThreadId,
codex: {
Expand All @@ -523,13 +567,17 @@ async function executeReviewRun(request) {
{
status: result.status,
stdout: result.reviewText,
stderr: result.stderr
stderr: result.stderr,
failureClass: result.failureClass,
retryable: result.retryable
},
{ reviewLabel: reviewName, targetLabel: target.label, model: request.model, reasoningSummary: result.reasoningSummary }
{ reviewLabel: reviewName, targetLabel: target.label, model: effectiveModel, reasoningSummary: result.reasoningSummary }
);

return {
exitStatus: result.status,
failureClass: result.failureClass,
retryable: result.retryable,
threadId: result.threadId,
turnId: result.turnId,
payload,
Expand All @@ -542,6 +590,10 @@ async function executeReviewRun(request) {
}

const context = collectReviewContext(request.cwd, target);
// Validate the state the context was actually built from, not the state a few
// statements earlier: an inline diff is frozen into the prompt here, so this is
// the last moment its content can be checked against what was pinned.
assertPinnedState();
const prompt =
reviewName === "Deep Review"
? buildDeepReviewPrompt(context, focusText)
Expand All @@ -555,16 +607,25 @@ async function executeReviewRun(request) {
outputSchema: readOutputSchema(REVIEW_SCHEMA),
onProgress: request.onProgress
});
if (context.inputMode === "self-collect") {
assertPinnedState(true);
}
const effectiveModel = result.modelFallback?.to ?? request.model;
const parsed = parseStructuredOutput(result.status === 0 ? result.finalMessage : "", {
status: result.status,
failureMessage: result.error?.message ?? result.stderr
failureMessage: result.error?.message ?? result.stderr,
failureClass: result.failureClass,
retryable: result.retryable
});
const payload = {
review: reviewName,
target,
model: request.model ?? null,
model: effectiveModel ?? null,
effort: request.effort ?? null,
effortWarning: result.effortWarning,
failureClass: result.failureClass,
modelFallback: result.modelFallback,
retryable: result.retryable,
threadId: result.threadId,
context: {
repoRoot: context.repoRoot,
Expand All @@ -585,13 +646,15 @@ async function executeReviewRun(request) {

return {
exitStatus: result.status,
failureClass: result.failureClass,
retryable: result.retryable,
threadId: result.threadId,
turnId: result.turnId,
payload,
rendered: renderReviewResult(parsed, {
reviewLabel: reviewName,
targetLabel: context.target.label,
model: request.model,
model: effectiveModel,
effort: request.effort ?? "codex default",
reasoningSummary: result.reasoningSummary
}),
Expand Down Expand Up @@ -650,6 +713,8 @@ async function executeTaskRun(request) {
rawOutput,
partialOutput,
failureMessage,
failureClass: result.failureClass,
retryable: result.retryable,
touchedFiles: result.touchedFiles,
reasoningSummary: result.reasoningSummary
},
Expand All @@ -661,6 +726,9 @@ async function executeTaskRun(request) {
);
const payload = {
status: result.status,
failureClass: result.failureClass,
modelFallback: result.modelFallback,
retryable: result.retryable,
threadId: result.threadId,
rawOutput,
partialOutput,
Expand All @@ -672,6 +740,8 @@ async function executeTaskRun(request) {

return {
exitStatus: result.status,
failureClass: result.failureClass,
retryable: result.retryable,
threadId: result.threadId,
turnId: result.turnId,
payload,
Expand Down Expand Up @@ -996,6 +1066,7 @@ async function handleReviewCommand(argv, config) {
base: options.base,
scope: options.scope,
target,
stateIdentity: captureRepoStateIdentity(cwd, target),
model,
effort,
effortOverride,
Expand Down
Loading