Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ they already have.

- `/codex:review` for a normal read-only Codex review
- `/codex:adversarial-review` for a steerable challenge review
- `/codex:rescue`, `/codex:transfer`, `/codex:status`, `/codex:result`, and `/codex:cancel` to delegate work, hand off sessions, and manage background jobs
- `/codex:rescue`, `/codex:expert`, `/codex:transfer`, `/codex:status`, `/codex:result`, and `/codex:cancel` to delegate work, request a user-approved expert pass, hand off sessions, and manage background jobs

## Requirements

Expand Down Expand Up @@ -162,6 +162,12 @@ Ask Codex to redesign the database connection to be more resilient.
- if you say `spark`, the plugin maps that to `gpt-5.3-codex-spark`
- follow-up rescue requests can continue the latest Codex task in the repo

### `/codex:expert`

The active model can invoke this command when it needs a stronger bounded pass, reaches the Luna Max effort ceiling, or encounters an ambiguous/high-risk decision. The command always asks the user to choose the expert model and reasoning effort before it starts anything; the default is Sol at High effort.

The selected expert runs in a separate persistent, named Codex thread. Its final answer and session ID are returned to the active conversation so the model can continue with the expert's evidence.

### `/codex:transfer`

Creates a persistent Codex thread from the current Claude Code session and prints a `codex resume <session-id>` command.
Expand Down
29 changes: 29 additions & 0 deletions plugins/codex/commands/expert.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
description: Automatically ask a user-selected named Codex expert when the current model needs a stronger bounded pass
argument-hint: "[--name <name>] [--write] [handoff]"
allowed-tools: Bash(node:*), AskUserQuestion
---

Use the companion expert broker for a deliberate, user-approved escalation when the current model has reached its supported effort ceiling, has failed a bounded task, or identifies an ambiguous/high-risk decision that needs a stronger pass. This command is safe for model invocation because it always pauses for the user's model-and-effort choice before starting an expert, even when raw arguments contain routing flags.

Raw handoff request:
`$ARGUMENTS`

Use `AskUserQuestion` exactly once with these two questions:

1. Which expert model should handle the handoff?
- `Sol (Recommended)` — map to `gpt-5.6-sol` and use it as the default expert.
- `Terra` — map to `gpt-5.6-terra` for a balanced expert pass.
- `Luna` — map to `gpt-5.6-luna` for a lower-cost expert pass.
2. How much reasoning effort should the expert use?
- `High (Recommended)` — the default Sol setting.
- `XHigh` — spend more effort on a difficult bounded problem.
- `Max` — use the maximum effort supported by this harness.

After the choice, invoke exactly one foreground command, adding the selected `--model` and `--effort` values after the raw handoff arguments so the user's choice is authoritative:

```bash
node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" expert "$ARGUMENTS" --model <selected-model-id> --effort <selected-effort>
Comment thread
MattSyska marked this conversation as resolved.
```

Return the command's stdout verbatim. The selected model and effort are authoritative even if raw arguments contained routing flags. Do not silently change the selected model or effort, create a second expert, or modify the handoff text. The command creates a persistent named Codex thread and returns its session ID with the expert's final answer.
2 changes: 1 addition & 1 deletion 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|max>] [what Codex should investigate, solve, or continue]"
allowed-tools: Bash(node:*), AskUserQuestion, Agent
---

Expand Down
88 changes: 78 additions & 10 deletions plugins/codex/scripts/codex-companion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url";

import { parseArgs, splitRawArgumentString } from "./lib/args.mjs";
import {
buildExpertSelectionOffer,
buildPersistentTaskThreadName,
DEFAULT_CONTINUE_PROMPT,
findLatestTaskThread,
Expand All @@ -19,6 +20,7 @@ import {
parseStructuredOutput,
readOutputSchema,
runAppServerReview,
runExpertHandoff,
runAppServerTurn
} from "./lib/codex.mjs";
import { resolveClaudeSessionPath } from "./lib/claude-session-transfer.mjs";
Expand Down Expand Up @@ -58,6 +60,8 @@ import {
renderReviewResult,
renderStoredJobResult,
renderCancelReport,
renderExpertResult,
renderExpertSelectionOffer,
renderJobStatusReport,
renderSetupReport,
renderStatusReport,
Expand All @@ -68,7 +72,7 @@ const ROOT_DIR = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
const REVIEW_SCHEMA = path.join(ROOT_DIR, "schemas", "review-output.schema.json");
const DEFAULT_STATUS_WAIT_TIMEOUT_MS = 240000;
const DEFAULT_STATUS_POLL_INTERVAL_MS = 2000;
const VALID_REASONING_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh"]);
const VALID_REASONING_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh", "max"]);
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.";

Expand All @@ -79,7 +83,8 @@ 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|max>] [prompt]",
" node scripts/codex-companion.mjs expert [--name <name>] [--write] [--model <model>] [--effort <none|minimal|low|medium|high|xhigh|max>] [handoff]",
" node scripts/codex-companion.mjs transfer [--source <claude-jsonl>] [--json]",
" node scripts/codex-companion.mjs status [job-id] [--all] [--json]",
" node scripts/codex-companion.mjs result [job-id] [--json]",
Expand Down Expand Up @@ -121,21 +126,23 @@ function normalizeReasoningEffort(effort) {
}
if (!VALID_REASONING_EFFORTS.has(normalized)) {
throw new Error(
`Unsupported reasoning effort "${effort}". Use one of: none, minimal, low, medium, high, xhigh.`
`Unsupported reasoning effort "${effort}". Use one of: none, minimal, low, medium, high, xhigh, max.`
);
}
return normalized;
}

function normalizeArgv(argv) {
if (argv.length === 1) {
const [raw] = argv;
if (!raw || !raw.trim()) {
return [];
}
return splitRawArgumentString(raw);
if (argv.length === 0) {
return [];
}

const [first, ...rest] = argv;
if (!first || !first.trim()) {
return rest;
}
return argv;

return [...splitRawArgumentString(first), ...rest];
Comment thread
MattSyska marked this conversation as resolved.
}

function parseCommandInput(argv, config = {}) {
Expand Down Expand Up @@ -759,6 +766,64 @@ async function handleReview(argv) {
});
}

async function handleExpert(argv) {
const { options, positionals } = parseCommandInput(argv, {
valueOptions: ["model", "effort", "cwd", "prompt-file", "name"],
Comment thread
MattSyska marked this conversation as resolved.
booleanOptions: ["json", "write"],
aliasMap: {
m: "model"
}
});

const cwd = resolveCommandCwd(options);
const prompt = readTaskPrompt(cwd, options, positionals);
requireTaskRequest(prompt, false);

const model = normalizeRequestedModel(options.model);
const effort = normalizeReasoningEffort(options.effort);
const expertName = options.name === undefined ? "Expert" : String(options.name).trim();
if (!expertName) {
throw new Error("Provide a non-empty expert name with --name.");
}

if (!model || !effort) {
const offer = buildExpertSelectionOffer({
expertName,
model,
effort,
prompt
});
outputCommandResult(offer, renderExpertSelectionOffer(offer), options.json);
return;
}

ensureCodexAvailable(cwd);
const result = await runExpertHandoff(cwd, {
expertName,
model,
effort,
prompt,
sandbox: options.write ? "workspace-write" : "read-only"
});
const payload = {
status: result.status === 0 ? "completed" : "failed",
expert: {
name: result.expertName,
model: result.model,
effort: result.effort,
threadId: result.threadId,
turnId: result.turnId
},
finalMessage: result.finalMessage,
reasoningSummary: result.reasoningSummary,
error: result.error?.message ?? result.error ?? result.stderr ?? null
};
outputCommandResult(payload, renderExpertResult(payload), options.json);
if (result.status !== 0) {
process.exitCode = result.status;
}
}

async function handleTask(argv) {
const { options, positionals } = parseCommandInput(argv, {
valueOptions: ["model", "effort", "cwd", "prompt-file"],
Expand Down Expand Up @@ -1043,6 +1108,9 @@ async function main() {
case "task":
await handleTask(argv);
break;
case "expert":
await handleExpert(argv);
break;
case "transfer":
await handleTransfer(argv);
break;
Expand Down
94 changes: 94 additions & 0 deletions plugins/codex/scripts/lib/codex.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,38 @@ const DEFAULT_CONTINUE_PROMPT =
const EXTERNAL_AGENT_IMPORT_COMPLETED = "externalAgentConfig/import/completed";
const EXTERNAL_AGENT_IMPORT_TIMEOUT_MS = 2 * 60 * 1000;

export const EXPERT_DEFAULT_SELECTION = Object.freeze({
model: "gpt-5.6-sol",
effort: "high"
});

export const EXPERT_MODEL_OPTIONS = Object.freeze([
Object.freeze({ id: "gpt-5.6-sol", label: "Sol", description: "Highest-capability expert" }),
Object.freeze({ id: "gpt-5.6-terra", label: "Terra", description: "Balanced expert" }),
Object.freeze({ id: "gpt-5.6-luna", label: "Luna", description: "Fast, lower-cost expert" })
]);

export const EXPERT_EFFORT_OPTIONS = Object.freeze(["none", "minimal", "low", "medium", "high", "xhigh", "max"]);

export function buildExpertSelectionOffer(options = {}) {
return {
status: "selection_required",
reason: "user_selection_required",
message: "Choose the expert model and reasoning effort before starting the handoff.",
defaultSelection: { ...EXPERT_DEFAULT_SELECTION },
modelOptions: EXPERT_MODEL_OPTIONS.map((option) => ({ ...option })),
effortOptions: [...EXPERT_EFFORT_OPTIONS],
selected: {
model: options.model ?? null,
effort: options.effort ?? null
},
request: {
expertName: options.expertName ?? "Expert",
handoff: options.prompt ?? ""
}
};
}

function cleanCodexStderr(stderr) {
return stderr
.split(/\r?\n/)
Expand Down Expand Up @@ -1159,6 +1191,68 @@ export async function runAppServerTurn(cwd, options = {}) {
});
}

export async function runExpertHandoff(cwd, options = {}) {
const expertName = String(options.expertName ?? "Expert").trim();
const prompt = String(options.prompt ?? "").trim();
const model = String(options.model ?? "").trim();
const effort = String(options.effort ?? "").trim().toLowerCase();

if (!expertName) {
throw new Error("An expert name is required.");
}
if (!prompt) {
throw new Error("A compact handoff prompt is required.");
}
if (!model || !effort) {
throw new Error("An expert model and reasoning effort must be selected before starting the handoff.");
}
if (!EXPERT_EFFORT_OPTIONS.includes(effort)) {
throw new Error(`Unsupported expert reasoning effort \"${effort}\". Use one of: ${EXPERT_EFFORT_OPTIONS.join(", ")}.`);
}

return withDirectAppServer(cwd, async (client) => {
emitProgress(options.onProgress, `Starting named expert thread \"${expertName}\".`, "starting");
const threadResponse = await startThread(client, cwd, {
model,
sandbox: options.sandbox ?? "read-only",
ephemeral: false,
threadName: expertName
});
const threadId = threadResponse.thread.id;
emitProgress(options.onProgress, `Expert thread ready (${threadId}).`, "starting", {
threadId
});

const turnState = await captureTurn(
client,
threadId,
() =>
client.request("turn/start", {
threadId,
input: buildTurnInput(prompt),
model,
effort,
outputSchema: options.outputSchema ?? null
}),
{ onProgress: options.onProgress }
);

return {
status: buildResultStatus(turnState),
expertName,
model,
effort,
threadId,
turnId: turnState.turnId,
finalMessage: turnState.lastAgentMessage,
reasoningSummary: turnState.reasoningSummary,
turn: turnState.finalTurn,
error: turnState.error,
stderr: cleanCodexStderr(client.stderr)
};
});
}

export async function findLatestTaskThread(cwd) {
const availability = getCodexAvailability(cwd);
if (!availability.available) {
Expand Down
39 changes: 39 additions & 0 deletions plugins/codex/scripts/lib/render.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,45 @@ export function renderTaskResult(parsedResult, meta) {
return `${message}\n`;
}

export function renderExpertSelectionOffer(offer) {
const defaultSelection = offer.defaultSelection ?? {};
const lines = [
"# Codex Expert Handoff",
"",
offer.message ?? "Choose the expert model and reasoning effort before starting the handoff.",
"",
`Default: ${defaultSelection.model ?? "unknown"} + ${defaultSelection.effort ?? "unknown"}`,
"",
"Model options:",
...(offer.modelOptions ?? []).map((option) => `- ${option.label} (${option.id}): ${option.description}`),
"",
`Effort options: ${(offer.effortOptions ?? []).join(", ")}`
];
return `${lines.join("\n").trimEnd()}\n`;
}

export function renderExpertResult(result) {
const lines = [
"# Codex Expert Handoff",
"",
`Expert: ${result.expert?.name ?? "Expert"}`,
`Model: ${result.expert?.model ?? "unknown"}`,
`Effort: ${result.expert?.effort ?? "unknown"}`,
`Codex session ID: ${result.expert?.threadId ?? "unknown"}`,
""
];

if (result.finalMessage) {
lines.push(result.finalMessage);
} else if (result.error) {
lines.push(`Expert handoff failed: ${result.error}`);
} else {
lines.push("The expert did not return a final message.");
}

return `${lines.join("\n").trimEnd()}\n`;
}

export function renderStatusReport(report) {
const lines = [
"# Codex Status",
Expand Down
2 changes: 1 addition & 1 deletion plugins/codex/skills/codex-cli-runtime/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Command selection:
- If the forwarded request includes `--fresh`, strip that token from the task text and do not add `--resume-last`.
- `--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`.
- `--effort`: accepted values are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`.
- `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
Loading