diff --git a/plugins/codex/commands/agents.md b/plugins/codex/commands/agents.md new file mode 100644 index 0000000..b62568e --- /dev/null +++ b/plugins/codex/commands/agents.md @@ -0,0 +1,12 @@ +--- +description: Configure agent models — switch between Claude and OpenAI models +allowed-tools: Bash(node:*) +--- + +Run the interactive TUI directly: + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/agent-config-tui.mjs" +``` + +After the TUI exits, read the applied changes from stdout and summarize them to the user. diff --git a/plugins/codex/commands/setup.md b/plugins/codex/commands/setup.md index fb33a15..697b7f6 100644 --- a/plugins/codex/commands/setup.md +++ b/plugins/codex/commands/setup.md @@ -35,3 +35,41 @@ Output rules: - Present the final setup output to the user. - If installation was skipped, present the original setup output. - If Codex is installed but not authenticated, preserve the guidance to run `!codex login`. + +After setup completes successfully (Codex installed and authenticated): +- Use `AskUserQuestion` to ask whether the user wants to configure agent models now. +- Use these two options: + - `Configure agent models` + - `Skip for now` +- If the user chooses to configure, run the agent config flow: + +**Agent config flow:** + +1. Get available models: +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/patch-agents.mjs" available-models --json +``` + +2. List current agents: +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/patch-agents.mjs" list --json +``` + +3. If there are many agents (>10), use `AskUserQuestion` first: + - `Configure all agents one by one` + - `Select specific agents to configure` + - `Skip` +- If user picks "Select specific", list agent names and let them pick. + +4. For each agent to configure, use `AskUserQuestion` with format: +``` + (current: ) +``` +Options: all available models (mark current with `(current)`), plus `Skip` at the end. + +5. For each selection that differs from current, run: +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/patch-agents.mjs" patch --json +``` + +6. Summarize all changes made. diff --git a/plugins/codex/hooks/hooks.json b/plugins/codex/hooks/hooks.json index 19e33b8..208a451 100644 --- a/plugins/codex/hooks/hooks.json +++ b/plugins/codex/hooks/hooks.json @@ -10,6 +10,15 @@ "timeout": 5 } ] + }, + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/auto-patch-agents-hook.mjs\"", + "timeout": 5 + } + ] } ], "SessionEnd": [ diff --git a/plugins/codex/scripts/agent-config-tui.mjs b/plugins/codex/scripts/agent-config-tui.mjs new file mode 100644 index 0000000..d522689 --- /dev/null +++ b/plugins/codex/scripts/agent-config-tui.mjs @@ -0,0 +1,388 @@ +#!/usr/bin/env node +/** + * Interactive Agent Model Configuration TUI + * + * Mimics Claude Code's /agents UI: + * - Grouped by source (User agents, Plugin agents, etc.) + * - Wrap-around ↑↓ navigation + * - Enter to select → model picker + * - q to quit + * + * Uses TTY hack: finds parent TTY + C input proxy for exclusive keyboard capture. + */ + +import fs from "node:fs"; +import path from "node:path"; +import tty from "node:tty"; +import { execSync, execFileSync, spawn } from "node:child_process"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const CLAUDE_MODELS = ["haiku", "sonnet", "opus"]; +const SCRIPT_DIR = path.dirname(new URL(import.meta.url).pathname); + +function getCodexModels() { + try { + const out = execSync(`node "${path.join(SCRIPT_DIR, 'patch-agents.mjs')}" available-models --json`, { encoding: "utf-8", timeout: 5000 }); + return (JSON.parse(out).codex || []).map(m => m.id); + } catch { return []; } +} + +function loadAgents() { + try { + const out = execSync(`node "${path.join(SCRIPT_DIR, 'patch-agents.mjs')}" list --json`, { encoding: "utf-8", timeout: 10000 }); + return JSON.parse(out); + } catch { return []; } +} + +const CODEX_MODELS = getCodexModels(); +const ALL_MODELS = [...CLAUDE_MODELS, ...CODEX_MODELS]; + +// Source display order (matches /agents) +const SOURCE_ORDER = ["user", "omc:oh-my-claudecode", "openai-codex:codex", "claude-inspect:claude-inspect"]; +const SOURCE_LABELS = { + "user": "User agents", + "omc:oh-my-claudecode": "Plugin agents (OMC)", + "openai-codex:codex": "Plugin agents (Codex)", + "claude-inspect:claude-inspect": "Plugin agents (Inspect)", +}; + +// --------------------------------------------------------------------------- +// TTY access +// --------------------------------------------------------------------------- + +function findParentTTY() { + try { + let pid = process.ppid; + for (let i = 0; i < 10; i++) { + const ttyName = execSync(`ps -o tty= -p ${pid} 2>/dev/null`, { encoding: "utf-8" }).trim(); + if (ttyName && ttyName !== "??" && ttyName !== "?") { + const dev = ttyName.startsWith("/dev/") ? ttyName : `/dev/${ttyName}`; + if (fs.existsSync(dev)) return dev; + } + const ppid = execSync(`ps -o ppid= -p ${pid} 2>/dev/null`, { encoding: "utf-8" }).trim(); + if (!ppid || ppid === "0" || ppid === "1") break; + pid = parseInt(ppid); + } + } catch {} + return null; +} + +function openTTY() { + if (process.stdin.isTTY) { + return { input: process.stdin, output: process.stdout, cleanup: () => {}, proxy: null }; + } + + const ttyPath = findParentTTY(); + if (!ttyPath) { + console.error("Could not find a TTY."); + process.exit(1); + } + + const outFd = fs.openSync(ttyPath, "w"); + const output = new tty.WriteStream(outFd); + + // Auto-compile input proxy if needed + let proxyPath = path.join(SCRIPT_DIR, "input_proxy"); + if (!fs.existsSync(proxyPath)) { + const srcPath = proxyPath + ".c"; + if (fs.existsSync(srcPath)) { + try { execSync(`cc -o "${proxyPath}" "${srcPath}"`, { timeout: 10000 }); } catch {} + } + } + + let input, proxy = null; + if (fs.existsSync(proxyPath)) { + proxy = spawn(proxyPath, [ttyPath], { stdio: ["ignore", "pipe", "ignore"] }); + input = proxy.stdout; + } else { + const inFd = fs.openSync(ttyPath, "r"); + input = new tty.ReadStream(inFd); + } + + return { + input, output, proxy, + cleanup: () => { + if (proxy) { try { proxy.kill("SIGTERM"); } catch {} } + try { output.destroy(); } catch {} + try { fs.closeSync(outFd); } catch {} + } + }; +} + +// --------------------------------------------------------------------------- +// ANSI +// --------------------------------------------------------------------------- + +const E = "\x1b"; +const HIDE_CURSOR = `${E}[?25l`; +const SHOW_CURSOR = `${E}[?25h`; +const BOLD = `${E}[1m`; +const DIM = `${E}[2m`; +const RESET = `${E}[0m`; +const CYAN = `${E}[36m`; +const GREEN = `${E}[32m`; +const YELLOW = `${E}[33m`; +const MAGENTA = `${E}[35m`; +const INVERSE = `${E}[7m`; +const CLEAR_EOL = `${E}[K`; + +function mcolor(model) { + if (CODEX_MODELS.includes(model)) return GREEN; + if (model === "opus") return MAGENTA; + if (model === "sonnet") return CYAN; + return YELLOW; +} + +// --------------------------------------------------------------------------- +// TUI +// --------------------------------------------------------------------------- + +class TUI { + constructor(input, output, cleanup, proxy) { + this.input = input; + this.output = output; + this.cleanup = cleanup; + this.proxy = proxy; + this.running = true; + this.mode = "list"; // "list" | "model" + this.changes = {}; + this.selectedAgent = null; + this.modelCursor = 0; + this.rows = output.rows || 24; + this.cols = output.columns || 80; + + // Build grouped flat list (matches /agents source order) + const raw = loadAgents(); + this.items = []; // { type: "header"|"agent", ... } + const bySource = new Map(); + for (const a of raw) { + const s = a.source || "user"; + if (!bySource.has(s)) bySource.set(s, []); + bySource.get(s).push(a); + } + + for (const src of SOURCE_ORDER) { + const agents = bySource.get(src); + if (!agents || !agents.length) continue; + this.items.push({ type: "header", label: SOURCE_LABELS[src] || src, source: src }); + for (const a of agents) { + this.items.push({ type: "agent", ...a }); + } + bySource.delete(src); + } + // Any remaining sources + for (const [src, agents] of bySource) { + if (!agents.length) continue; + this.items.push({ type: "header", label: SOURCE_LABELS[src] || src, source: src }); + for (const a of agents) { + this.items.push({ type: "agent", ...a }); + } + } + + // Navigation cursor — only on agent items + this.cursor = 0; + this.agentIndices = this.items.map((it, i) => it.type === "agent" ? i : -1).filter(i => i >= 0); + this.navPos = 0; // index into agentIndices + this.scroll = 0; + } + + start() { + if (this.input.setRawMode) this.input.setRawMode(true); + this.input.resume(); + this.input.setEncoding("utf-8"); + this.write(HIDE_CURSOR); + + this.input.on("data", (key) => { + if (!this.running) return; + if (key === "\x03") return this.quit(); + if (this.mode === "list") this.handleList(key); + else this.handleModel(key); + this.render(); + }); + + this.render(); + this._interval = setInterval(() => { if (this.running) this.render(); }, 33); + } + + write(s) { this.output.write(s); } + + handleList(key) { + const total = this.agentIndices.length; + if (!total) return; + + if (key === "\x1b[A" || key === "k") { + // Wrap-around up + this.navPos = this.navPos === 0 ? total - 1 : this.navPos - 1; + } else if (key === "\x1b[B" || key === "j") { + // Wrap-around down + this.navPos = this.navPos === total - 1 ? 0 : this.navPos + 1; + } else if (key === "\r") { + const idx = this.agentIndices[this.navPos]; + this.selectedAgent = this.items[idx]; + const curModel = this.selectedAgent.currentModel || "sonnet"; + this.modelCursor = ALL_MODELS.indexOf(curModel); + if (this.modelCursor === -1) this.modelCursor = 0; + this.mode = "model"; + } else if (key === "q") { + this.quit(); + } + + // Adjust scroll + this.adjustScroll(); + } + + adjustScroll() { + const visible = this.rows - 4; + const targetLine = this.agentIndices[this.navPos]; + if (targetLine < this.scroll) this.scroll = targetLine; + if (targetLine >= this.scroll + visible) this.scroll = targetLine - visible + 1; + if (this.scroll < 0) this.scroll = 0; + } + + handleModel(key) { + if (key === "\x1b[A" || key === "k") { + this.modelCursor = this.modelCursor === 0 ? ALL_MODELS.length - 1 : this.modelCursor - 1; + } else if (key === "\x1b[B" || key === "j") { + this.modelCursor = this.modelCursor === ALL_MODELS.length - 1 ? 0 : this.modelCursor + 1; + } else if (key === "\r") { + const m = ALL_MODELS[this.modelCursor]; + const a = this.selectedAgent; + if (m !== a.currentModel) { + this.changes[a.filePath] = { name: a.name, model: m }; + a.currentModel = m; + a.isCodex = CODEX_MODELS.includes(m); + } + this.mode = "list"; + } else if (key === "b" || key === "\x1b") { + this.mode = "list"; + } + } + + render() { + const { rows, cols } = this; + let out = `${E}[H`; // cursor home + if (this.mode === "list") out += this.renderList(rows, cols); + else out += this.renderModel(rows, cols); + out += `${E}[J`; // clear below + this.write(out); + } + + renderList(rows, cols) { + const cc = Object.keys(this.changes).length; + const visible = rows - 4; + const selectedIdx = this.agentIndices[this.navPos]; + + let o = `${BOLD} Codex Agent Config ${RESET}`; + if (cc > 0) o += ` ${GREEN}${cc} change(s)${RESET}`; + o += `${CLEAR_EOL}\n`; + o += `${DIM} ↑↓ Navigate · Enter Select · q Quit & Apply${RESET}${CLEAR_EOL}\n`; + o += `${DIM}${"─".repeat(Math.min(cols - 1, 70))}${RESET}${CLEAR_EOL}\n`; + + const end = Math.min(this.scroll + visible, this.items.length); + for (let i = this.scroll; i < end; i++) { + const item = this.items[i]; + if (item.type === "header") { + o += `${CLEAR_EOL}\n`; + o += ` ${BOLD}${DIM}${item.label}:${RESET}${CLEAR_EOL}\n`; + } else { + const sel = i === selectedIdx; + const mc = mcolor(item.currentModel); + const model = `${mc}${item.currentModel}${RESET}`; + const changed = this.changes[item.filePath] ? ` ${GREEN}*${RESET}` : ""; + + if (sel) { + o += `${INVERSE} ❯ ${item.name.padEnd(30)} ${item.currentModel.padEnd(18)} ${RESET}${changed}${CLEAR_EOL}\n`; + } else { + o += ` ${item.name.padEnd(30)} ${model}${"".padEnd(Math.max(0, 18 - item.currentModel.length))}${changed}${CLEAR_EOL}\n`; + } + } + } + + // Status + const total = this.agentIndices.length; + const pct = total > 0 ? Math.round(((this.navPos + 1) / total) * 100) : 0; + o += `${CLEAR_EOL}\n${DIM} ${this.navPos + 1}/${total} (${pct}%)${RESET}${CLEAR_EOL}`; + + // Fill rest + const usedLines = 3 + (end - this.scroll) + 2; + for (let i = usedLines; i < rows; i++) o += `${CLEAR_EOL}\n`; + return o; + } + + renderModel(rows, cols) { + const a = this.selectedAgent; + let o = `${BOLD} Select model: ${CYAN}${a.name}${RESET} ${DIM}[${a.source}]${RESET}${CLEAR_EOL}\n`; + o += `${DIM} ↑↓ Navigate · Enter Confirm · b/Esc Back${RESET}${CLEAR_EOL}\n`; + o += `${DIM}${"─".repeat(Math.min(cols - 1, 50))}${RESET}${CLEAR_EOL}\n`; + + o += `${CLEAR_EOL}\n`; + o += ` ${BOLD}${DIM}Anthropic:${RESET}${CLEAR_EOL}\n`; + for (let i = 0; i < CLAUDE_MODELS.length; i++) { + const m = CLAUDE_MODELS[i]; + const sel = this.modelCursor === i; + const cur = m === a.currentModel ? ` ${DIM}(current)${RESET}` : ""; + if (sel) o += ` ${INVERSE} ❯ ${m} ${RESET}${cur}${CLEAR_EOL}\n`; + else o += ` ${mcolor(m)}${m}${RESET}${cur}${CLEAR_EOL}\n`; + } + + o += `${CLEAR_EOL}\n`; + o += ` ${BOLD}${DIM}OpenAI (via Codex):${RESET}${CLEAR_EOL}\n`; + for (let i = 0; i < CODEX_MODELS.length; i++) { + const mi = CLAUDE_MODELS.length + i; + const m = CODEX_MODELS[i]; + const sel = this.modelCursor === mi; + const cur = m === a.currentModel ? ` ${DIM}(current)${RESET}` : ""; + if (sel) o += ` ${INVERSE} ❯ ${m} ${RESET}${cur}${CLEAR_EOL}\n`; + else o += ` ${GREEN}${m}${RESET}${cur}${CLEAR_EOL}\n`; + } + + // Fill rest + const usedLines = 3 + 2 + CLAUDE_MODELS.length + 2 + CODEX_MODELS.length; + for (let i = usedLines; i < rows; i++) o += `${CLEAR_EOL}\n`; + return o; + } + + quit() { + this.running = false; + if (this._interval) clearInterval(this._interval); + + // Kill proxy first — restores Claude Code as foreground + if (this.proxy) { + try { this.proxy.kill("SIGTERM"); } catch {} + try { execSync("sleep 0.2"); } catch {} + } + try { if (this.input.setRawMode) this.input.setRawMode(false); } catch {} + this.write(SHOW_CURSOR + `${E}[2J${E}[H`); + + // Apply changes + const entries = Object.entries(this.changes); + if (!entries.length) { + console.log("No changes made."); + } else { + const patchScript = path.join(SCRIPT_DIR, "patch-agents.mjs"); + for (const [filePath, { name, model }] of entries) { + try { + execFileSync(process.execPath, [patchScript, "patch", filePath, model], { encoding: "utf-8", timeout: 5000 }); + const prov = CODEX_MODELS.includes(model) ? "OpenAI" : "Anthropic"; + console.log(` ${name} → ${model} (${prov}) ✓`); + } catch (e) { + console.log(` ${name} → ${model} FAILED`); + } + } + console.log(`\n${entries.length} agent(s) updated.`); + } + + this.cleanup(); + process.exit(0); + } +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +const { input, output, cleanup, proxy } = openTTY(); +new TUI(input, output, cleanup, proxy).start(); diff --git a/plugins/codex/scripts/auto-patch-agents-hook.mjs b/plugins/codex/scripts/auto-patch-agents-hook.mjs new file mode 100644 index 0000000..d039d72 --- /dev/null +++ b/plugins/codex/scripts/auto-patch-agents-hook.mjs @@ -0,0 +1,105 @@ +#!/usr/bin/env node +/** + * SessionStart hook: auto-patch agents with OpenAI models. + * + * Scans ~/.claude/agents/*.md — if any has an OpenAI model value + * (not haiku/sonnet/opus/inherit), converts it to haiku + codex forwarder. + * + * Runs on every session start to catch model changes made via /agents UI. + */ + +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const AGENTS_DIR = path.join(process.env.HOME, ".claude", "agents"); +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const CLAUDE_MODELS = new Set(["haiku", "sonnet", "opus", "inherit", "best", "sonnet[1m]", "opus[1m]", "opusplan"]); +const BACKUP_SUFFIX = ".codex-backup"; + +function readStdin(timeoutMs = 3000) { + return new Promise((resolve) => { + const chunks = []; + let settled = false; + const timeout = setTimeout(() => { + if (!settled) { settled = true; process.stdin.removeAllListeners(); process.stdin.destroy(); resolve(Buffer.concat(chunks).toString("utf-8")); } + }, timeoutMs); + process.stdin.on("data", (chunk) => chunks.push(chunk)); + process.stdin.on("end", () => { if (!settled) { settled = true; clearTimeout(timeout); resolve(Buffer.concat(chunks).toString("utf-8")); } }); + process.stdin.on("error", () => { if (!settled) { settled = true; clearTimeout(timeout); resolve(""); } }); + if (process.stdin.readableEnded && !settled) { settled = true; clearTimeout(timeout); resolve(Buffer.concat(chunks).toString("utf-8")); } + }); +} + +function parseFrontmatter(content) { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); + if (!match) return null; + const fields = {}; + for (const line of match[1].split(/\r?\n/)) { + const idx = line.indexOf(":"); + if (idx === -1) continue; + fields[line.slice(0, idx).trim()] = line.slice(idx + 1).trim(); + } + return { fields, body: match[2], raw: match[1] }; +} + +function isOpenAIModel(model) { + if (!model) return false; + // Strip YAML quotes before checking + const clean = model.replace(/^["']|["']$/g, "").toLowerCase(); + return !CLAUDE_MODELS.has(clean); +} + +async function main() { + await readStdin(); // consume hook input + + if (!fs.existsSync(AGENTS_DIR)) { + console.log(JSON.stringify({ continue: true, suppressOutput: true })); + return; + } + + const files = fs.readdirSync(AGENTS_DIR).filter(f => f.endsWith(".md") && !f.includes(BACKUP_SUFFIX)); + let patched = 0; + + for (const file of files) { + const filePath = path.join(AGENTS_DIR, file); + const content = fs.readFileSync(filePath, "utf-8"); + const parsed = parseFrontmatter(content); + if (!parsed) continue; + + const { fields, body } = parsed; + const model = fields.model; + + // Skip if already patched (has _codex_model) or not an OpenAI model + if (fields._codex_model || !isOpenAIModel(model)) continue; + + // This agent has an OpenAI model but no _codex_model — needs patching + // Use patch-agents.mjs which handles backup + frontmatter preservation + const codexModel = model; + const agentName = file.replace(".md", ""); + + try { + const patchScript = path.join(SCRIPT_DIR, "patch-agents.mjs"); + execFileSync(process.execPath, [patchScript, "patch", agentName, codexModel], { timeout: 5000 }); + patched++; + } catch { /* skip on error */ } + } + + if (patched > 0) { + console.log(JSON.stringify({ + continue: true, + hookSpecificOutput: { + hookEventName: "SessionStart", + additionalContext: `[CODEX] Auto-patched ${patched} agent(s) with OpenAI models to use Codex forwarder.` + } + })); + } else { + console.log(JSON.stringify({ continue: true, suppressOutput: true })); + } +} + +main().catch(() => { + console.log(JSON.stringify({ continue: true, suppressOutput: true })); +}); diff --git a/plugins/codex/scripts/input_proxy.c b/plugins/codex/scripts/input_proxy.c new file mode 100644 index 0000000..b457fec --- /dev/null +++ b/plugins/codex/scripts/input_proxy.c @@ -0,0 +1,49 @@ +#include +#include +#include +#include +#include + +static int tty_fd = -1; +static pid_t orig_fg = 0; + +void cleanup(int sig) { + /* Restore original foreground group */ + if (tty_fd >= 0 && orig_fg > 0) { + tcsetpgrp(tty_fd, orig_fg); + } + if (tty_fd >= 0) close(tty_fd); + _exit(0); +} + +int main(int argc, char **argv) { + if (argc < 2) return 1; + + /* Open TTY */ + tty_fd = open(argv[1], O_RDWR); + if (tty_fd < 0) return 1; + + /* Save original foreground group */ + orig_fg = tcgetpgrp(tty_fd); + + /* New process group */ + setpgid(0, 0); + + /* Become foreground group */ + tcsetpgrp(tty_fd, getpgrp()); + + /* Restore on SIGTERM/SIGINT */ + signal(SIGTERM, cleanup); + signal(SIGINT, cleanup); + + /* Read from TTY, write to stdout (pipe to Node.js) */ + char buf[64]; + for (;;) { + ssize_t n = read(tty_fd, buf, sizeof(buf)); + if (n <= 0) break; + if (write(STDOUT_FILENO, buf, n) != n) break; + } + + cleanup(0); + return 0; +} diff --git a/plugins/codex/scripts/patch-agents.mjs b/plugins/codex/scripts/patch-agents.mjs new file mode 100644 index 0000000..22f2b05 --- /dev/null +++ b/plugins/codex/scripts/patch-agents.mjs @@ -0,0 +1,365 @@ +#!/usr/bin/env node +/** + * Agent Model Patcher + * + * Patches ~/.claude/agents/*.md to use Codex (OpenAI) models + * or switch between Claude models. + * + * Commands: + * list [--json] List agents with current model + * available-models [--json] List available models + * patch Patch agent to use specified model + * restore-all Restore all codex-patched agents to original model + */ + +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +const AGENTS_DIR = path.join(process.env.HOME, ".claude", "agents"); +const PLUGINS_DIR = path.join(process.env.HOME, ".claude", "plugins", "cache"); + +function findAllAgentDirs() { + const dirs = [{ dir: AGENTS_DIR, source: "user" }]; + // Scan plugin cache for agent directories + if (fs.existsSync(PLUGINS_DIR)) { + try { + for (const marketplace of fs.readdirSync(PLUGINS_DIR)) { + const mDir = path.join(PLUGINS_DIR, marketplace); + for (const plugin of fs.readdirSync(mDir)) { + const pDir = path.join(mDir, plugin); + // Find latest version + const versions = fs.readdirSync(pDir).sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); + const latest = versions[versions.length - 1]; + if (!latest) continue; + const agentsDir = path.join(pDir, latest, "agents"); + if (fs.existsSync(agentsDir)) { + dirs.push({ dir: agentsDir, source: `${marketplace}:${plugin}` }); + } + } + } + } catch { /* noop */ } + } + return dirs; +} +const CLAUDE_MODELS = ["haiku", "sonnet", "opus"]; + +function getCodexModels() { + const companionPath = findCodexCompanion(); + const models = new Set(); + + // Read model aliases from codex-companion + if (companionPath) { + try { + const src = fs.readFileSync(companionPath, "utf-8"); + const match = src.match(/MODEL_ALIASES\s*=\s*new Map\(\[(.*?)\]\)/s); + if (match) { + for (const m of match[1].matchAll(/"([^"]+)"\s*,\s*"([^"]+)"/g)) { + models.add(m[2]); // add the full model name + } + } + } catch { /* noop */ } + } + + // Read default model from codex config + try { + const toml = fs.readFileSync(path.join(process.env.HOME, ".codex", "config.toml"), "utf-8"); + const modelMatch = toml.match(/^model\s*=\s*"([^"]+)"/m); + if (modelMatch) models.add(modelMatch[1]); + } catch { /* noop */ } + + return [...models]; +} + +const CODEX_MODELS = getCodexModels(); + +const CODEX_COMPANION_PATH = findCodexCompanion(); + +function findCodexCompanion() { + const cacheDir = path.join(process.env.HOME, ".claude/plugins/cache/openai-codex/codex"); + try { + const versions = fs.readdirSync(cacheDir).sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); + const latest = versions[versions.length - 1]; + if (!latest) return null; + const p = path.join(cacheDir, latest, "scripts", "codex-companion.mjs"); + return fs.existsSync(p) ? p : null; + } catch { return null; } +} + +// --------------------------------------------------------------------------- +// Frontmatter parsing +// --------------------------------------------------------------------------- + +function parseFrontmatter(content) { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); + if (!match) return { fields: {}, body: content, raw: "" }; + + const raw = match[1]; + const body = match[2]; + const fields = {}; + + let currentKey = null; + for (const line of raw.split(/\r?\n/)) { + if (/^[ \t]/.test(line)) { + // Indented continuation — append to current key (YAML list item) + if (currentKey) { + const item = line.trim().replace(/^-\s*/, ""); + fields[currentKey] = fields[currentKey] ? `${fields[currentKey]}, ${item}` : item; + } + continue; + } + const idx = line.indexOf(":"); + if (idx === -1) continue; + currentKey = line.slice(0, idx).trim(); + const value = line.slice(idx + 1).trim().replace(/^["']|["']$/g, ""); + fields[currentKey] = value; + } + + return { fields, body, raw }; +} + +function setField(raw, key, value) { + // Replace top-level field + any indented continuation lines (YAML lists) + const re = new RegExp(`^${key}:.*(?:\n(?=[ \t]).*)*`, "m"); + if (re.test(raw)) { + return raw.replace(re, `${key}: ${value}`); + } + return raw + `\n${key}: ${value}`; +} + +function removeField(raw, key) { + // Remove field + any indented continuation lines + trailing newline (optional for last line) + return raw.replace(new RegExp(`^${key}:.*(?:\n(?=[ \t]).*)*\r?\n?`, "m"), "").replace(/\n{2,}/g, "\n"); +} + +function rebuildFile(raw, body) { + return `---\n${raw}\n---\n${body}`; +} + +// --------------------------------------------------------------------------- +// Agent file helpers +// --------------------------------------------------------------------------- + +function listAllAgents() { + const all = []; + for (const { dir, source } of findAllAgentDirs()) { + if (!fs.existsSync(dir)) continue; + for (const f of fs.readdirSync(dir)) { + if (!f.endsWith(".md") || f.includes(".codex-backup") || f.includes(".bak")) continue; + const name = f.replace(".md", ""); + const filePath = path.join(dir, f); + const content = fs.readFileSync(filePath, "utf-8"); + const parsed = parseFrontmatter(content); + all.push({ name, filePath, source, content, ...parsed }); + } + } + return all.sort((a, b) => a.name.localeCompare(b.name)); +} + +function readAgent(name) { + // Search all agent dirs, prefer user agents + const allDirs = findAllAgentDirs(); + let found = null; + for (const { dir, source } of allDirs) { + const filePath = path.join(dir, `${name}.md`); + if (fs.existsSync(filePath)) { + const content = fs.readFileSync(filePath, "utf-8"); + found = { filePath, source, content, ...parseFrontmatter(content) }; + if (source === "user") return found; // user takes priority + } + } + return found; +} + +function isCodexPatched(fields) { + return Boolean(fields._codex_model); +} + +function isCodexModel(model) { + return !CLAUDE_MODELS.includes(model); +} + +// --------------------------------------------------------------------------- +// Patch / Restore +// --------------------------------------------------------------------------- + +/** Removes the injected Codex forwarder preamble from an agent body, leaving only the original role content. */ +function stripCodexForwarder(body) { + return body.replace(/^You are a thin forwarding wrapper[\s\S]*?Role context[^\n]*\n/m, ""); +} + +function patchAgent(nameOrPath, targetModel) { + let agent; + if (nameOrPath.includes("/")) { + // Direct path + if (!fs.existsSync(nameOrPath)) throw new Error(`File not found: ${nameOrPath}`); + const content = fs.readFileSync(nameOrPath, "utf-8"); + agent = { filePath: nameOrPath, content, ...parseFrontmatter(content) }; + } else { + agent = readAgent(nameOrPath); + if (!agent) throw new Error(`Agent "${nameOrPath}" not found`); + } + + const { fields, body, raw, filePath } = agent; + const name = fields.name || path.basename(filePath, ".md"); + const wasCodex = isCodexPatched(fields); + const originalModel = fields._original_model || fields.model || "sonnet"; + // Strip any existing codex forwarder line from body + const cleanBody = wasCodex ? stripCodexForwarder(body) : body; + + if (CLAUDE_MODELS.includes(targetModel)) { + // Switching to Claude model — remove codex fields + let newRaw = setField(raw, "model", targetModel); + if (wasCodex) { + newRaw = removeField(newRaw, "_codex_model"); + newRaw = removeField(newRaw, "tools"); + } + if (fields.description) { + newRaw = setField(newRaw, "description", fields.description.replace(/\s*\(Codex:.*?\)/, "")); + } + + fs.writeFileSync(filePath, rebuildFile(newRaw, cleanBody), "utf-8"); + return { name, model: targetModel, type: "claude", action: "updated" }; + } + + // OpenAI model — set up as Codex forwarder + if (!CODEX_COMPANION_PATH) { + throw new Error("codex-plugin-cc not installed. Run /codex:setup first."); + } + + const disallowed = (fields.disallowedTools || "").toLowerCase(); + const tools = (fields.tools || "").toLowerCase(); + // Read-only if Write/Edit explicitly disallowed, or if tools are listed but don't include Write/Edit + const hasToolsList = tools.length > 0; + const toolsAllowWrite = !hasToolsList || tools.includes("write") || tools.includes("edit"); + const readOnly = disallowed.includes("write") || disallowed.includes("edit") || !toolsAllowWrite; + const writeFlag = readOnly ? "" : " --write"; + + let newRaw = setField(raw, "model", "haiku"); + newRaw = setField(newRaw, "tools", "Bash"); + newRaw = setField(newRaw, "_codex_model", targetModel); + const desc = (fields.description || "").replace(/\s*\(Codex:.*?\)/, ""); + newRaw = setField(newRaw, "description", `${desc} (Codex: ${targetModel})`); + + const newBody = `You are a thin forwarding wrapper around the Codex companion task runtime. + +Your only job is to forward the user's request to the Codex companion script. Do not do anything else. + +Selection guidance: + +- Do not wait for the user to explicitly ask for Codex. Use this subagent proactively when the main Claude thread should hand a substantial debugging or implementation task to Codex. +- Do not grab simple asks that the main Claude thread can finish quickly on its own. + +Forwarding rules: + +- Use exactly one \`Bash\` call to invoke \`node "${CODEX_COMPANION_PATH}" task --model ${targetModel}${writeFlag} ...\`. +- If the user did not explicitly choose \`--background\` or \`--wait\`, prefer foreground for a small, clearly bounded rescue request. +- If the user did not explicitly choose \`--background\` or \`--wait\` and the task looks complicated, open-ended, multi-step, or likely to keep Codex running for a long time, prefer background execution. +- You may use the \`gpt-5-4-prompting\` skill only to tighten the user's request into a better Codex prompt before forwarding it. +- Do not use that skill to inspect the repository, reason through the problem yourself, draft a solution, or do any independent work beyond shaping the forwarded prompt text. +- Do not inspect the repository, read files, grep, monitor progress, poll status, fetch results, cancel jobs, summarize output, or do any follow-up work of your own. +- Do not call \`review\`, \`adversarial-review\`, \`status\`, \`result\`, or \`cancel\`. This subagent only forwards to \`task\`. +- Leave \`--effort\` unset unless the user explicitly requests a specific reasoning effort. +- Treat \`--effort \` as a runtime control and do not include it in the task text you pass through. +- Default to a ${readOnly ? "read-only" : "write-capable"} Codex run${readOnly ? "" : " by adding \\`--write\\`"} unless the user explicitly asks otherwise. +- Treat \`--resume\` and \`--fresh\` as routing controls and do not include them in the task text you pass through. +- \`--resume\` means add \`--resume-last\`. +- \`--fresh\` means do not add \`--resume-last\`. +- If the user is clearly asking to continue prior Codex work in this repository, such as "continue", "keep going", "resume", "apply the top fix", or "dig deeper", add \`--resume-last\` unless \`--fresh\` is present. +- Otherwise forward the task as a fresh \`task\` run. +- Preserve the user's task text as-is apart from stripping routing flags. +- Include the role context below in the task text sent to Codex. +- Return the stdout of the \`codex-companion\` command exactly as-is. +- If the Bash call fails or Codex cannot be invoked, return nothing. + +Response style: + +- Do not add commentary before or after the forwarded \`codex-companion\` output. + +Role context (include in the task text sent to Codex): +${cleanBody.trim()} +`; + + fs.writeFileSync(filePath, rebuildFile(newRaw, newBody), "utf-8"); + return { name, model: targetModel, type: "codex", action: "patched", originalModel }; +} + +// --------------------------------------------------------------------------- +// List +// --------------------------------------------------------------------------- + +function listAgents() { + return listAllAgents().map(agent => { + const { fields, name, source } = agent; + const codexModel = fields._codex_model || null; + const originalModel = fields._original_model || null; + const currentModel = codexModel || fields.model || "sonnet"; + + return { + name, + currentModel, + source, + filePath: agent.filePath, + isCodex: Boolean(codexModel), + originalModel, + }; + }).filter(Boolean); +} + +function listAvailableModels() { + return { + claude: CLAUDE_MODELS.map(m => ({ id: m, provider: "anthropic" })), + codex: CODEX_MODELS.map(m => ({ id: m, provider: "openai" })), + }; +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +function main() { + const [command, ...args] = process.argv.slice(2); + const json = args.includes("--json"); + const cleanArgs = args.filter(a => a !== "--json"); + + try { + let result; + + switch (command) { + case "list": + result = listAgents(); + break; + + case "available-models": + result = listAvailableModels(); + break; + + case "patch": { + const [nameOrPath, model] = cleanArgs; + if (!nameOrPath || !model) throw new Error("Usage: patch "); + result = patchAgent(nameOrPath, model); + break; + } + + default: + console.error("Usage: patch-agents.mjs [args]"); + process.exitCode = 1; + return; + } + + if (json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(JSON.stringify(result)); + } + } catch (e) { + if (json) { + console.log(JSON.stringify({ error: e.message })); + } else { + console.error(e.message); + } + process.exitCode = 1; + } +} + +main();