comfyui-mcp
Version:
Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),
2,649 lines • 135 kB
JavaScript
// OpenAI Codex backend — the provider-specific adapter behind the AgentBackend
// port, driving Codex over the `codex app-server` JSON-RPC protocol (NOT
// `codex exec` string-scraping). This mirrors how our own `openai-codex` plugin
// drives the app-server (see the plugin's scripts/lib/app-server.mjs +
// codex.mjs); the protocol mapping is commented inline below.
//
// PanelAgent keeps all provider-agnostic orchestration (queue, turn-gate, bridge
// push, self-restart) and drives this backend via
// `for await (const ev of backend.run({...}))`. See
// design/agent-backend-injection.md.
//
// PROTOCOL MAPPING (port → app-server):
// - session = a Codex THREAD (`thread/start` new | `thread/resume` by id)
// - run() loop turn = `turn/start` (one turn per neutral channel batch);
// images ride as `localImage` input items (file paths)
// - assistant_delta ← `item/agentMessage/delta` ({itemId, delta})
// - assistant_delta(th) ← `item/reasoning/{text,summaryText}Delta` (thinking)
// - assistant (commit) ← `item/completed` for an `agentMessage` item
// - result ← `turn/completed` ({threadId, turn:{status}})
// - error ← `error` notification ({error:{message}})
// - interrupt() → `turn/interrupt` ({threadId, turnId})
// - recoverStalledTurn()→ `turn/steer` (an explicit harness-stall notice;
// never misrepresented as a user cancellation)
// - listModels() ← `config/read` (or a sensible static fallback)
//
// FULL PARITY with Claude: the Codex backend now drives the live ComfyUI canvas
// AND the headless comfyui MCP, with the panel system prompt — everything Claude
// can do. Two MCP servers are declared to the app-server at launch via `-c`
// overrides:
// - `comfyui` (stdio): the headless comfyui MCP (this build's dist/index.js),
// mirroring the env the Claude path passes (COMFYUI_URL / COMFYUI_PATH / …).
// - `panel` (http) : the orchestrator-hosted loopback HTTP MCP that exposes
// the SHARED panel_* live-graph tools, routed by tab id
// (http://127.0.0.1:<port>/<tabId>). See panel-mcp-http.ts + panel-tools.ts.
// The app-server can only host CONFIG-DECLARED MCP servers (not an in-process SDK
// server), which is exactly why panel_* is exposed over HTTP for this backend.
// The panel system prompt is prepended to the FIRST turn (the app-server's
// thread/start has no instructions field).
import { spawn, spawnSync } from "node:child_process";
import { createRequire } from "node:module";
import { copyFileSync, existsSync, mkdirSync, promises as fsp } from "node:fs";
import os from "node:os";
import path from "node:path";
import readline from "node:readline";
import { logger } from "../utils/logger.js";
import { errorText, messageText, promptText } from "./error-text.js";
import { formatCodexTurnError } from "./codex-error.js";
import { buildAgentSpawnEnv } from "../services/panel-secrets.js";
import { CODEX_CAPABILITIES, stampTurn, } from "./agent-backend.js";
import { MAX_SESSION_PREVIEW_BYTES, previewByteBudgetLabel } from "./preview-budget.js";
import { inspectMcpServers, reconnectableMcpStatus, recoveredMcpNotice, reportedFromCodexMcpListing, unrecoveredMcpNotice, } from "./mcp-session-health.js";
function msgOf(err) {
return errorText(err);
}
function normalizedHttpMcpEndpoint(value) {
try {
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:")
return undefined;
return {
origin: url.origin,
pathname: decodeURIComponent(url.pathname),
search: url.search,
};
}
catch {
return undefined;
}
}
/**
* Codex wraps a failed request to the orchestrator-hosted HTTP MCP in its
* WorkerTransport error. The URL is part of the trusted panel registration;
* without this association, an unrelated configured HTTP MCP failure could be
* mistaken for the panel and cause a panel reload.
*/
export function isCodexPanelMcpTransportFailure(message, panelUrl) {
if (!/Transport send error\s*(?::\s*(?:EventNotificationTransport\s+)?WorkerTransport\b|;\s*)/i.test(message)) {
return false;
}
const failedUrl = message.match(/HTTP request failed sending request to\s+(https?:\/\/\S+)/i)?.[1];
if (!failedUrl)
return false;
const observed = normalizedHttpMcpEndpoint(failedUrl);
const configured = normalizedHttpMcpEndpoint(panelUrl);
return (observed !== undefined &&
configured !== undefined &&
observed.origin === configured.origin &&
observed.pathname === configured.pathname &&
observed.search === configured.search);
}
/** Keep a WorkerTransport failure self-contained: PanelAgent's generic turn
* failure text says "Nothing was lost — try again", which is unsafe when a
* mutation's dispatch/outcome was not established at this boundary. */
function panelMcpTransportFailureNotice(message, retriedRead) {
const retryNote = retriedRead
? `The orchestrator retried this read once, but that retry also failed; the original transport error is preserved.`
: `The orchestrator did not retry the request.`;
return (`The local panel MCP connection failed before a tool result was returned. ` +
`${retryNote} For a render or other mutation, ` +
`treat the first attempt's outcome as UNKNOWN until you inspect queue ` +
`(action:"list") or get_history; do not re-run panel_run blindly. The panel MCP ` +
`connection is being reloaded for the next turn. If it remains unavailable, ` +
`reconnect the orchestrator (Disconnect → Connect) before retrying. (${message})`);
}
const configuredInterruptTimeoutMs = Number(process.env.COMFYUI_MCP_CODEX_INTERRUPT_TIMEOUT_MS);
const CODEX_INTERRUPT_TIMEOUT_MS = Number.isFinite(configuredInterruptTimeoutMs) && configuredInterruptTimeoutMs > 0
? configuredInterruptTimeoutMs
: 1500;
const configuredStallSteerTimeoutMs = Number(process.env.COMFYUI_MCP_CODEX_STALL_STEER_TIMEOUT_MS);
const CODEX_STALL_STEER_TIMEOUT_MS = Number.isFinite(configuredStallSteerTimeoutMs) && configuredStallSteerTimeoutMs > 0
? configuredStallSteerTimeoutMs
: CODEX_INTERRUPT_TIMEOUT_MS;
const configuredCloseTimeoutMs = Number(process.env.COMFYUI_MCP_CODEX_CLOSE_TIMEOUT_MS);
const CODEX_CLOSE_TIMEOUT_MS = Number.isFinite(configuredCloseTimeoutMs) && configuredCloseTimeoutMs > 0
? configuredCloseTimeoutMs
: 2000;
const configuredForceKillGraceMs = Number(process.env.COMFYUI_MCP_CODEX_FORCE_KILL_GRACE_MS);
const CODEX_FORCE_KILL_GRACE_MS = Number.isFinite(configuredForceKillGraceMs) && configuredForceKillGraceMs > 0
? configuredForceKillGraceMs
: 500;
const CODEX_CLIENT_CLOSE_BUDGET_MS = CODEX_CLOSE_TIMEOUT_MS * 2 + CODEX_FORCE_KILL_GRACE_MS + 100;
const configuredHostSpawnRetryMs = Number(process.env.COMFYUI_MCP_CODEX_HOST_SPAWN_RETRY_MS);
const CODEX_HOST_SPAWN_RETRY_MS = Number.isFinite(configuredHostSpawnRetryMs) && configuredHostSpawnRetryMs >= 0
? configuredHostSpawnRetryMs
: 150;
// Two attempts: a stale WinGet path (os error 3) can be followed immediately
// by a sharing-violation (os error 32) while the replacement image is copied
// or scanned (#2045). A single retry died on that second failure.
const CODEX_HOST_SPAWN_RETRIES = 2;
async function settlesWithin(promise, timeoutMs) {
let timer;
try {
return await Promise.race([
promise.then(() => true, () => true),
new Promise((resolve) => {
timer = setTimeout(() => resolve(false), timeoutMs);
}),
]);
}
finally {
if (timer)
clearTimeout(timer);
}
}
/**
* Kill an entire process tree, not just the direct child. On the Windows
* PATH/shell fallback the direct child is a cmd.exe/shim whose grandchild is the
* real `codex` node process — killing only the shell leaves the tree alive. Use
* `taskkill /T /F` (mirrors the reference client's terminateProcessTree). On
* POSIX, signal the process group (negative pid) so a shell + its child both die,
* falling back to the single pid. Best-effort + swallows errors: it runs during
* teardown and must never throw into the host process.
*/
function killProcessTree(pid, force = false) {
if (!Number.isFinite(pid))
return false;
const p = pid;
if (process.platform === "win32") {
try {
const result = spawnSync("taskkill", ["/PID", String(p), "/T", "/F"], {
windowsHide: true,
timeout: CODEX_CLOSE_TIMEOUT_MS,
});
if (!result.error && result.status === 0)
return true;
}
catch {
// Fall through to the direct-pid kill below.
}
try {
process.kill(p, "SIGKILL");
return true;
}
catch {
return false; // already gone or inaccessible
}
}
const signal = force ? "SIGKILL" : "SIGTERM";
try {
process.kill(-p, signal); // process group (we spawn detached on POSIX)
return true;
}
catch {
try {
process.kill(p, signal);
return true;
}
catch {
return false; // already gone or inaccessible
}
}
}
class AppServerClient {
bin;
cwd;
env;
extraArgs;
proc = null;
rl = null;
pending = new Map();
nextId = 1;
closed = false;
closePromise = null;
exitResolved = false;
/** The error that ended the connection (null = clean exit). Readable so a turn
* can surface a meaningful message when the child dies mid-turn. */
exitError = null;
stderr = "";
notificationHandler = null;
resolveExit;
/** Resolves when the app-server process exits or errors (P0-2): runTurn() races
* its per-turn drain against this so a child that dies after turn/start resolved
* but before turn/completed doesn't deadlock the turn forever. */
exitPromise;
constructor(bin, cwd, env,
// Extra `-c key=value` config overrides appended after `app-server` (used to
// declare the comfyui + panel MCP servers — full Codex/Claude tool parity).
extraArgs = []) {
this.bin = bin;
this.cwd = cwd;
this.env = env;
this.extraArgs = extraArgs;
this.exitPromise = new Promise((resolve) => {
this.resolveExit = resolve;
});
}
/** Spawn `codex app-server`, perform the initialize handshake, and return. */
async initialize(clientInfo) {
// On Windows the bundled bin is a node launcher script; spawn it via the
// current node so we don't depend on a `codex` shim being on PATH. When `bin`
// is a plain "codex" (PATH fallback) we still spawn it directly.
const isJs = /\.(c|m)?js$/i.test(this.bin);
const cmd = isJs ? process.execPath : this.bin;
// `-c` overrides go AFTER the `app-server` subcommand (they're app-server
// flags). They declare the comfyui (stdio) + panel (http) MCP servers.
const baseArgs = isJs ? [this.bin, "app-server"] : ["app-server"];
const args = [...baseArgs, ...this.extraArgs];
// When falling back to a `codex` on PATH on Windows, the resolvable entry is a
// `.cmd`/`.ps1` shim — spawn without a shell can't find it (ENOENT). Use a
// shell in that case (mirrors the plugin's client). The bundled-dep lane runs
// the `.js` launcher via node directly, so it never needs a shell.
const useShell = !isJs && process.platform === "win32";
this.proc = spawn(cmd, args, {
cwd: this.cwd,
env: this.env,
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
shell: useShell,
// On POSIX, put the child in its OWN process group so close() can kill the
// whole tree (shell + grandchild) with a single negative-pid signal. On
// Windows we use taskkill /T instead, so detached isn't needed there.
detached: process.platform !== "win32",
});
this.proc.stdout.setEncoding("utf8");
this.proc.stderr.setEncoding("utf8");
this.proc.stderr.on("data", (chunk) => {
this.stderr += chunk;
});
// Swallow stream errors on the child's pipes. When the app-server child dies
// mid-turn, the NEXT write to stdin (or a read on stdout) raises an async
// 'error' event (EPIPE on Windows) — with no listener Node treats it as an
// uncaughtException and the orchestrator's handler would exit the whole
// process. Route them through handleExit instead so the turn rejects cleanly
// (P0-2) and the host survives. (P0-2)
this.proc.stdin.on("error", (error) => this.handleExit(error));
this.proc.stdout.on("error", (error) => this.handleExit(error));
this.proc.on("error", (error) => this.handleExit(error));
this.proc.on("exit", (code, signal) => {
const detail = code === 0
? null
: new Error(`codex app-server exited unexpectedly (${signal ? `signal ${signal}` : `exit ${code}`}).${this.stderr ? ` ${this.stderr.trim().split(/\r?\n/).slice(-2).join(" ")}` : ""}`);
this.handleExit(detail);
});
this.rl = readline.createInterface({ input: this.proc.stdout });
this.rl.on("line", (line) => this.handleLine(line));
// JSON-RPC handshake: initialize (request) then initialized (notification).
// We opt IN to the delta notifications (by NOT opting out) so we can stream
// assistant + reasoning text token-by-token; the plugin opts them out because
// it only captures final messages.
await this.request("initialize", {
clientInfo,
capabilities: { experimentalApi: false, optOutNotificationMethods: [] },
});
this.notify("initialized", {});
}
request(method, params) {
if (this.closed)
return Promise.reject(new Error("codex app-server client is closed."));
const id = this.nextId++;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve: resolve, reject, method });
this.send({ id, method, params });
});
}
notify(method, params = {}) {
if (this.closed)
return;
// Fire-and-forget: a write failure (dead child) must not throw into the caller
// — handleExit already records it and rejects pending requests.
try {
this.send({ method, params });
}
catch {
// connection gone; pending requests already rejected via handleExit
}
}
send(message) {
const stdin = this.proc?.stdin;
// Stream gone / destroyed (child died) — surface as a connection exit so any
// pending request rejects, rather than throwing an unhandled error from a
// fire-and-forget notify() (P0-2).
if (!stdin || stdin.destroyed || stdin.writableEnded) {
this.handleExit(this.exitError ?? new Error("codex app-server stdin is not available."));
throw this.exitError ?? new Error("codex app-server stdin is not available.");
}
try {
stdin.write(`${JSON.stringify(message)}\n`);
}
catch (err) {
// Synchronous write failure (EPIPE) on a child that just died.
this.handleExit(err instanceof Error ? err : new Error(String(err)));
throw err;
}
}
/**
* The auto-approve RESULT for a server→client approval/permission/elicitation
* request, or null if the request isn't an approval we should auto-grant.
*
* Decision shapes differ per request method (from the app-server protocol):
* - execCommandApproval / applyPatchApproval → { decision: ReviewDecision }
* where ReviewDecision = "approved" | "denied" | …
* - item/commandExecution/requestApproval, item/fileChange/requestApproval,
* item/permissions/requestApproval → { decision: "accept" | … }
* - mcpServer/elicitation/request → an MCP elicitation result
* ({ action: "accept", content: {} }).
* We grant the affirmative for each so the headless background agent (same
* isolation posture as Claude's bypassPermissions) is never blocked.
*/
autoApproveDecision(method) {
switch (method) {
case "execCommandApproval":
case "applyPatchApproval":
return { decision: "approved" };
case "item/commandExecution/requestApproval":
case "item/fileChange/requestApproval":
case "item/permissions/requestApproval":
return { decision: "accept" };
case "mcpServer/elicitation/request":
return { action: "accept", content: {} };
default:
return null;
}
}
handleLine(line) {
if (!line.trim())
return;
let message;
try {
message = JSON.parse(line);
}
catch (error) {
this.handleExit(new Error(`Failed to parse codex app-server JSONL: ${msgOf(error)}`));
return;
}
// Server→client request. The app-server asks the client to approve commands,
// file edits, MCP tool elicitations, and permission requests. The panel agent
// is an ISOLATED background agent (same posture as the Claude path's
// bypassPermissions), so we AUTO-APPROVE these to keep the live-graph work
// flowing — otherwise a panel_* MCP tool call hangs on an approval prompt the
// headless orchestrator can't surface. Anything we don't recognize still gets
// a method-not-found so the protocol keeps moving.
if (message.id !== undefined && message.method) {
const decision = this.autoApproveDecision(message.method);
if (decision) {
logger.debug(`[codex-backend] auto-approving server request ${message.method}`);
this.send({ id: message.id, result: decision });
}
else {
logger.debug(`[codex-backend] unsupported server request ${message.method} — replying method-not-found`);
this.send({ id: message.id, error: { code: -32601, message: `Unsupported server request: ${message.method}` } });
}
return;
}
// Response to one of our requests.
if (message.id !== undefined) {
const p = this.pending.get(message.id);
if (!p)
return;
this.pending.delete(message.id);
if (message.error) {
// Keep the JSON-RPC error's structured data on the rejected value. A
// provider 400 can arrive as a rejected turn/start request rather than
// an `error` notification, and its request id/code may only be present
// in `error.data` (#2114). The user-facing formatter deliberately
// extracts only its safe diagnostic-shaped fields.
const error = new Error(message.error.message ?? `codex app-server ${p.method} failed.`);
Object.assign(error, message.error);
p.reject(error);
}
else
p.resolve(message.result ?? {});
return;
}
// Notification.
if (message.method)
this.notificationHandler?.(message);
}
handleExit(error) {
if (this.exitResolved)
return;
this.exitResolved = true;
this.exitError = error;
for (const p of this.pending.values())
p.reject(error ?? new Error("codex app-server connection closed."));
this.pending.clear();
this.resolveExit();
}
async close() {
if (!this.closePromise) {
this.closePromise = (async () => {
this.closed = true;
// Drop the notification handler so a late notification can't re-enter a
// torn-down turn during shutdown.
this.notificationHandler = null;
this.rl?.close();
this.rl = null;
const proc = this.proc;
let softKillTimer;
if (proc && proc.exitCode === null) {
try {
proc.stdin.end();
}
catch {
// already gone
}
// Give a graceful stdin-EOF shutdown a beat, then kill the whole tree.
softKillTimer = setTimeout(() => {
if (proc.exitCode === null)
killProcessTree(proc.pid, false);
}, 50);
softKillTimer.unref?.();
}
let exited = await settlesWithin(this.exitPromise, CODEX_CLOSE_TIMEOUT_MS);
if (softKillTimer)
clearTimeout(softKillTimer);
if (!exited && proc && proc.exitCode === null) {
logger.warn(`[codex-backend] app-server did not exit within ${CODEX_CLOSE_TIMEOUT_MS}ms -- forcing process-tree termination`);
killProcessTree(proc.pid, true);
exited = await settlesWithin(this.exitPromise, CODEX_FORCE_KILL_GRACE_MS);
}
if (!exited) {
// Queue recovery must not depend on an OS exit event. Detach the pipes
// and resolve the logical connection even if both tree-kill attempts failed.
try {
proc?.stdin.destroy();
proc?.stdout.destroy();
proc?.stderr.destroy();
proc?.unref?.();
}
catch {
// best-effort final detach
}
this.handleExit(new Error("codex app-server teardown timed out after forced termination."));
}
this.proc = null;
})();
}
await this.closePromise;
}
}
// ---- reasoning-effort scale (advertised + applied) ----
// The authoritative Codex reasoning-effort scale (none < minimal < low < medium <
// high < xhigh — the app-server `turn/start` `effort` field). Defined ONCE here
// and reused by BOTH the model advertisement below (so every Codex ModelChoice
// tells the panel it has an effort control — the panel's normalizeModels reads
// `supportedEffortLevels`/`supportsEffort`, and hides the picker if neither is
// present) AND toCodexEffort() further down (the validity check), to avoid drift.
// The backend ALREADY applies effort to every turn via toCodexEffort regardless
// of model, so advertising it for all Codex models matches current behavior.
// GPT-5.6 extends the scale with `max` and `ultra` (verified live per model:
// sol/terra accept through ultra, luna through max — issue #241's catalog).
const CODEX_EFFORT_LEVELS = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"];
// ---- model fallback ----
// config/read does not enumerate a model CATALOG (it reports the active provider
// + model), so when we can't derive a list we fall back to the current Codex
// model family. The panel picker degrades gracefully on an empty list. Each entry
// advertises the Codex effort scale so the panel enables the reasoning-effort
// dropdown for these models (the backend applies effort to every turn anyway).
// The live picker prefers the catalog's deprecated/upgrade signal when present;
// otherwise it keeps gpt-5.6* and gpt-6-astra. These static ids match the 5.6
// family plus a pre-5.6 escape hatch — older CLIs cannot run Astra or 5.6.
const CODEX_FALLBACK_MODELS = [
{ id: "gpt-5.6-sol", label: "GPT-5.6 Sol", supportsEffort: true, supportedEffortLevels: ["low", "medium", "high", "xhigh", "max", "ultra"] },
{ id: "gpt-5.6-terra", label: "GPT-5.6 Terra", supportsEffort: true, supportedEffortLevels: ["low", "medium", "high", "xhigh", "max", "ultra"] },
{ id: "gpt-5.6-luna", label: "GPT-5.6 Luna", supportsEffort: true, supportedEffortLevels: ["low", "medium", "high", "xhigh", "max"] },
// This static list only surfaces when model/list is UNAVAILABLE — i.e. an
// older CLI resolved from PATH (bundled-install failure), which cannot run
// any 5.6 / Astra model. Keep one runnable pre-5.6 escape hatch there (codex
// review); accounts on the pinned bundled CLI never see this list.
{ id: "gpt-5.5", label: "GPT-5.5 (legacy CLI fallback)", supportsEffort: true, supportedEffortLevels: ["none", "minimal", "low", "medium", "high", "xhigh"] },
];
function codexCatalogModelId(m) {
const id = m.id ?? m.model;
return id || undefined;
}
function codexCatalogRowDeprecated(m) {
if (m.deprecated === true)
return true;
const upgrade = m.upgrade;
if (typeof upgrade === "string")
return upgrade.length > 0;
if (upgrade && (upgrade.id || upgrade.model))
return true;
return Boolean(m.upgradeInfo?.model);
}
/** Picker subset of the account catalog. Prefer a server-provided
* deprecated/upgrade signal; never guess availability from a model family.
* Older clients without that signal retain every visible catalog entry. */
function filterCodexPickerEntries(visible) {
const current = visible.filter((m) => codexCatalogModelId(m) && !codexCatalogRowDeprecated(m));
return current.length ? current : visible;
}
/** Does this id look like an OpenAI/Codex model (vs. a Claude panel model)? Used
* to ignore the Claude panel model PanelAgent unconditionally passes as
* opts.model, so the configured Codex model wins (P1-1). Anthropic ids start with
* "claude"/"anthropic"; Codex ids are gpt-, o-series, codex-, or chatgpt-. */
function isCodexModel(id) {
const m = id.toLowerCase();
if (m.startsWith("claude") || m.startsWith("anthropic"))
return false;
return /^(gpt-|o\d|codex|chatgpt)/.test(m) || m.includes("codex");
}
// ---- reasoning effort mapping ----
// Codex's reasoning-effort scale differs from Claude's: Codex accepts
// none|minimal|low|medium|high|xhigh (the app-server `turn/start` `effort` field;
// see the reference openai-codex plugin's codex.mjs), while the panel/Claude
// scale is low|medium|high|xhigh|max. The shared levels map 1:1; the only
// off-scale source value is Claude "max", which has no Codex equivalent and maps
// to the nearest valid level (xhigh). Unknown/empty → null (app-server default).
const CODEX_EFFORTS = CODEX_EFFORT_LEVELS; // single source of truth (advertised == accepted)
// Low→high rank for ceiling-snapping in toCodexEffort.
const EFFORT_RANK = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"];
function toCodexEffort(effort, allowed) {
if (!effort)
return null;
const e = effort.toLowerCase();
if (!CODEX_EFFORTS.includes(e)) {
return null; // unknown level → let the app-server pick its default
}
// Snap to the active model's supported list when we know it: "max"/"ultra"
// are native on GPT-5.6 but REJECTED by pre-5.6 models and older CLIs —
// the old blanket max→xhigh downmap was removed, so the ceiling must be
// enforced here (codex review). No list known → conservative xhigh cap.
if (allowed && allowed.length) {
if (allowed.includes(e))
return e;
for (let i = EFFORT_RANK.indexOf(e); i >= 0; i--) {
if (allowed.includes(EFFORT_RANK[i]))
return EFFORT_RANK[i];
}
return allowed[allowed.length - 1] ?? null;
}
if (e === "max" || e === "ultra")
return "xhigh";
return e;
}
/**
* Derive a display name for a Codex app-server `item` (from item/started and
* item/completed) when it represents a TOOL-like action — an MCP tool call, a
* shell command, a file change, a web search, etc. Returns null for non-tool
* items (agentMessage / reasoning), which the delta/commit paths already handle,
* so the caller skips emitting a tool_call for them. Best-effort + defensive: the
* exact item shape varies by app-server version, so we probe the common name
* fields and fall back to the item `type`.
*/
export function toolNameOf(item) {
if (!item || typeof item !== "object")
return null;
const type = typeof item.type === "string" ? item.type : undefined;
// These item types are text/reasoning, not tools — they're surfaced via the
// assistant_delta / assistant commit paths, so don't double-report them.
if (type === "agentMessage" || type === "reasoning")
return null;
// MCP tool calls carry a tool name (and often a server) — prefer the most
// specific identifier available, then fall back to the item type.
const pick = (...keys) => {
for (const k of keys) {
const v = item[k];
if (typeof v === "string" && v)
return v;
}
return undefined;
};
const server = pick("server", "serverName");
const tool = pick("tool", "toolName", "name", "command");
if (tool)
return server ? `${server}.${tool}` : tool;
// No explicit name field — use the item type as the label (commandExecution,
// fileChange, webSearch, …) so the panel at least shows that a tool ran.
return type ?? null;
}
// A failed HTTP MCP send is observed outside panel-mcp-http.ts, so the backend
// has to classify the in-flight app-server item before deciding whether the
// transport may issue its one retry. Keep this allowlist intentionally narrower
// than the panel tool catalog: scope navigation and every mutation must never
// be replayed from an opaque outer transport error. Membership is the graph
// inspect reads from QUEUE_BUSY_READ_TOOLS — including panel_find_nodes, which
// is the #2395 recurrence after a completed panel_run.
export const PANEL_OUTER_TRANSPORT_RETRY_SAFE_TOOLS = new Set([
"panel_graph_outline",
"panel_query_graph",
"panel_find_nodes",
"panel_get_errors",
]);
// Completing these can leave the Codex HTTP MCP client in a racy state (notably
// the read immediately following a successful panel_open_workflow).
// Arm a one-shot window for the immediate next allowlisted read. The tools
// themselves stay off the retry allowlist so a failed arm operation is fenced.
export const PANEL_OUTER_TRANSPORT_RETRY_ARM_TOOLS = new Set([
"panel_enter_subgraph",
"panel_open_workflow",
"panel_run",
]);
function stringFieldOf(value, ...keys) {
if (!value)
return undefined;
for (const key of keys) {
if (typeof value[key] === "string" && value[key])
return value[key];
}
return undefined;
}
function isMcpToolCallItem(item) {
return stringFieldOf(item, "type") === "mcpToolCall";
}
function panelMcpToolItemOf(item) {
const qualified = toolNameOf(item);
if (!qualified)
return null;
const type = stringFieldOf(item, "type");
if (type && type !== "mcpToolCall")
return null;
const separator = qualified.lastIndexOf(".");
const server = separator >= 0 ? qualified.slice(0, separator) : undefined;
const name = separator >= 0 ? qualified.slice(separator + 1) : qualified;
// If app-server identifies the MCP server, require the configured panel
// server. An item id is required for transport correlation; the display path
// above still supports older items that only have a tool name.
if (server && server !== "panel")
return null;
const itemId = stringFieldOf(item, "id");
if (!itemId)
return null;
const requestKeys = new Set([itemId]);
for (const key of ["requestId", "request_id", "callId", "call_id"]) {
const value = stringFieldOf(item, key);
if (value)
requestKeys.add(value);
}
const retryOf = stringFieldOf(item, "retryOf", "retry_of", "parentItemId", "parent_item_id");
if (retryOf)
requestKeys.add(retryOf);
return {
name,
itemId,
requestKeys: [...requestKeys],
...(retryOf ? { retryOf } : {}),
};
}
function panelMcpTransportErrorKeys(params) {
const error = params.error;
const details = error?.additionalDetails;
const item = params.item;
const keys = new Set();
for (const value of [
stringFieldOf(params, "itemId", "item_id", "requestId", "request_id", "callId", "call_id"),
stringFieldOf(item, "id", "requestId", "request_id", "callId", "call_id"),
stringFieldOf(error, "itemId", "item_id", "requestId", "request_id", "callId", "call_id"),
stringFieldOf(details, "itemId", "item_id", "requestId", "request_id", "callId", "call_id"),
]) {
if (value)
keys.add(value);
}
return [...keys];
}
/**
* Read the current app-server status inventory without treating an absent
* inventory as evidence. The current protocol uses a name -> schema map; the
* array form is retained for older app-server test/runtime stubs.
*/
function mcpToolNames(tools) {
if (Array.isArray(tools)) {
const names = new Set();
for (const entry of tools) {
if (typeof entry === "string") {
names.add(entry);
}
else if (entry && typeof entry === "object") {
const name = entry.name;
if (typeof name === "string")
names.add(name);
}
}
return names;
}
if (tools && typeof tools === "object")
return new Set(Object.keys(tools));
return undefined;
}
/**
* Find the one safe partial-catalog shape this backend knows how to repair:
* the app-server says the explicitly-owned server is connected, but its
* present, non-empty status inventory omits a tool the orchestrator explicitly
* guarantees. An absent or empty inventory is not enough to create this signal
* because the caller must provide the guarantee and the row must say
* `connected`.
*/
function missingRequiredMcpTools(listed, required) {
if (!required)
return [];
const gaps = [];
for (const entry of listed) {
if (!entry || typeof entry.name !== "string" || !entry.name)
continue;
const expected = Object.prototype.hasOwnProperty.call(required, entry.name) ? required[entry.name] : undefined;
if (!Array.isArray(expected) || expected.length === 0)
continue;
const runtime = typeof entry.runtimeStatus === "string" ? entry.runtimeStatus.trim().toLowerCase() : "";
if (runtime !== "connected")
continue;
const actual = mcpToolNames(entry.tools);
if (!actual || actual.size === 0)
continue;
const missing = expected.filter((name) => typeof name === "string" && !actual.has(name));
if (missing.length)
gaps.push({ name: entry.name, missing });
}
return gaps;
}
/** Return only servers whose present, non-empty connected inventory proves all
* explicitly-owned required wrappers are available. */
function presentRequiredMcpTools(listed, required) {
const present = new Set();
if (!required)
return present;
for (const entry of listed) {
if (!entry || typeof entry.name !== "string" || !entry.name)
continue;
const expected = Object.prototype.hasOwnProperty.call(required, entry.name) ? required[entry.name] : undefined;
if (!Array.isArray(expected) || expected.length === 0)
continue;
const runtime = typeof entry.runtimeStatus === "string" ? entry.runtimeStatus.trim().toLowerCase() : "";
if (runtime !== "connected")
continue;
const actual = mcpToolNames(entry.tools);
if (actual && actual.size > 0 && expected.every((name) => actual.has(name)))
present.add(entry.name);
}
return present;
}
/**
* Build the `-c key=value` CLI overrides that declare the given MCP servers to
* `codex app-server`. Values are TOML literals: strings are JSON-quoted, arrays
* are JSON arrays (valid TOML). Mirrors `codex mcp add` / the config.toml format.
*
* SECURITY LIMITATION (known, accepted — Codex lane only): the comfyui stdio
* server's `env` is emitted as `-c mcp_servers.comfyui.env.KEY="value"` argv, so
* any value here — including a panel-saved secret (CIVITAI_API_TOKEN, …) — lands
* in the spawned process's argv, visible to local process inspection (ps,
* /proc/<pid>/cmdline) and any external crash/telemetry tooling that captures
* argv. This is inherent to how the bundled `codex app-server` accepts MCP config:
* the ONLY out-of-band channel is `$CODEX_HOME/config.toml`, but CODEX_HOME also
* holds the user's Codex login/auth and real config, so pointing the app-server at
* a private temp CODEX_HOME to hide the secret would break the user's sign-in and
* settings — not a safe trade. We therefore accept the argv exposure for now and
* mitigate it by NEVER logging these args (they are passed straight to spawn() and
* to no logger; do not add any logging of the returned array or `extraArgs`).
* - The DEFAULT panel transport is the Claude Agent SDK (in-process MCP), which
* has no argv exposure; the Codex backend is opt-in (PANEL_AGENT_BACKEND=codex).
* - Follow-up: revisit if codex app-server gains an env/file/stdin channel for
* per-server MCP env that doesn't clobber CODEX_HOME.
*/
export function buildMcpConfigArgs(servers) {
const args = [];
const lit = (s) => JSON.stringify(s); // safe TOML string literal
for (const [name, spec] of Object.entries(servers)) {
if (spec.transport === "stdio") {
args.push("-c", `mcp_servers.${name}.command=${lit(spec.command)}`);
if (spec.args && spec.args.length) {
args.push("-c", `mcp_servers.${name}.args=${JSON.stringify(spec.args)}`);
}
for (const [k, v] of Object.entries(spec.env ?? {})) {
args.push("-c", `mcp_servers.${name}.env.${k}=${lit(v)}`);
}
}
else {
// Streamable HTTP MCP server — `url` is what `codex mcp add --url` sets.
args.push("-c", `mcp_servers.${name}.url=${lit(spec.url)}`);
}
}
return args;
}
// ---- sandbox / approval posture ----
// The Claude panel agent runs with bypassPermissions — full autonomy on the
// user's OWN machine. To MATCH that for the Codex lane (so Codex can actually run
// shell commands instead of hitting a read-only sandbox that "rejects multi-line
// scripts" and gives up), default the codex app-server to the most permissive
// sandbox (danger-full-access) with approvals disabled. Overridable via
// COMFYUI_MCP_CODEX_SANDBOX for a cautious user who wants to dial it down to
// "workspace-write" or "read-only". Anything else falls back to the default.
const CODEX_SANDBOX_MODES = new Set(["read-only", "workspace-write", "danger-full-access"]);
const CODEX_SANDBOX_DEFAULT = "danger-full-access";
export function resolveCodexSandbox() {
const raw = (process.env.COMFYUI_MCP_CODEX_SANDBOX ?? "").trim().toLowerCase();
return CODEX_SANDBOX_MODES.has(raw) ? raw : CODEX_SANDBOX_DEFAULT;
}
/**
* #1929 — Windows can refuse CreateProcess on the bundled
* `codex-code-mode-host.exe` with ERROR_SHARING_VIOLATION (os error 32) while
* Defender / a previous host still holds the image. Codex does not retry that
* spawn; the next identical call succeeds. Match the host-spawn failure, not
* every os-error-32, so a missing binary (os error 2) stays terminal.
*/
export function isWindowsCodeModeHostSharingViolation(message) {
if (!message)
return false;
const m = message.toLowerCase();
const isHost = m.includes("code-mode host") || m.includes("codex-code-mode-host");
if (!isHost)
return false;
return (/\bos error 32\b/.test(m) ||
m.includes("error_sharing_violation") ||
m.includes("sharing violation") ||
m.includes("being used by another process") ||
m.includes("utilise par un autre processus") ||
m.includes("utilizado por otro proceso"));
}
/**
* #2045 — Windows ERROR_PATH_NOT_FOUND (os error 3) when WinGet deletes a
* versioned Node package dir (`node-vX.Y.Z-win-x64\\...`) out from under a live
* app-server. Distinct from os error 2 (file not found in an existing dir),
* which stays terminal (#1929).
*/
export function isWindowsCodeModeHostPathNotFound(message) {
if (!message)
return false;
const m = message.toLowerCase();
const isHost = m.includes("code-mode host") || m.includes("codex-code-mode-host");
if (!isHost)
return false;
if (/\bos error 2\b/.test(m))
return false;
return (/\bos error 3\b/.test(m) ||
m.includes("cannot find the path specified") ||
m.includes("chemin d'acces specifie est introuvable") ||
m.includes("chemin d'accès spécifié est introuvable") ||
m.includes("no puede encontrar la ruta"));
}
export function isWindowsCodeModeHostSpawnRetryable(message) {
return isWindowsCodeModeHostSharingViolation(message) || isWindowsCodeModeHostPathNotFound(message);
}
const CODEX_PLATFORM_PACKAGE = {
"win32-x64": { pkg: "@openai/codex-win32-x64", triple: "x86_64-pc-windows-msvc" },
"win32-arm64": { pkg: "@openai/codex-win32-arm64", triple: "aarch64-pc-windows-msvc" },
"darwin-x64": { pkg: "@openai/codex-darwin-x64", triple: "x86_64-apple-darwin" },
"darwin-arm64": { pkg: "@openai/codex-darwin-arm64", triple: "aarch64-apple-darwin" },
"linux-x64": { pkg: "@openai/codex-linux-x64", triple: "x86_64-unknown-linux-musl" },
"linux-arm64": { pkg: "@openai/codex-linux-arm64", triple: "aarch64-unknown-linux-musl" },
};
function vendorHostName(platform = process.platform) {
return platform === "win32" ? "codex-code-mode-host.exe" : "codex-code-mode-host";
}
function vendorExeName(platform = process.platform) {
return platform === "win32" ? "codex.exe" : "codex";
}
function platformOfHostPath(hostPath) {
return hostPath.toLowerCase().endsWith(".exe") ? "win32" : process.platform;
}
/** WinGet's versioned Node dir and nvm-windows layouts vanish on an update. */
export function isVolatileCodexPackagePath(p) {
const n = p.replace(/\\/g, "/").toLowerCase();
return (n.includes("/winget/packages/") ||
/\/node-v\d+\.\d+\.\d+-win/.test(n) ||
/\/nvm\/v?\d/.test(n) ||
n.includes("/.nvm/"));
}
export function codeModeHostPathFromError(message) {
const m = message.match(/code-mode host\s+(.+?):\s/i);
const p = m?.[1]?.trim();
return p || null;
}
export function resolveCodexCodeModeHostPath(opts = {}) {
const platform = opts.platform ?? process.platform;
const arch = opts.arch ?? process.arch;
const spec = CODEX_PLATFORM_PACKAGE[`${platform}-${arch}`];
if (!spec)
return null;
const exists = opts.existsSync ?? existsSync;
const resolve = opts.resolve ?? ((id) => createRequire(import.meta.url).resolve(id));
try {
const pkgJson = resolve(`${spec.pkg}/package.json`);
const host = path.join(path.dirname(pkgJson), "vendor", spec.triple, "bin", vendorHostName(platform));
return exists(host) ? host : null;
}
catch {
return null;
}
}
export function stableCodexVendorDir(home = os.homedir()) {
const override = process.env.COMFYUI_MCP_CODEX_VENDOR_DIR?.trim();
return override || path.join(home, ".comfyui-mcp", "codex-vendor");
}
/**
* Copy the vendor `codex` + `codex-code-mode-host` pair out of a volatile
* WinGet/nvm package dir into a stable location. If the source is already gone
* or the dest is locked (os error 32), keep a usable dest copy.
*/
export function ensureStableCodexVendor(srcHost, opts = {}) {
const exists = opts.existsSync ?? existsSync;
const mkdir = opts.mkdirSync ?? mkdirSync;
const copy = opts.copyFileSync ?? copyFileSync;
const destDir = opts.destDir ?? stableCodexVendorDir();
const plat = platformOfHostPath(srcHost);
const destHost = path.join(destDir, vendorHostName(plat));
const destExe = path.join(destDir, vendorExeName(plat));
const srcExe = path.join(path.dirname(srcHost), vendorExeName(plat));
const destUsable = exists(destHost) && exists(destExe);
if (!exists(srcHost) || !exists(srcExe))
return destUsable ? destHost : null;
try {
mkdir(destDir, { recursive: true });
copy(srcExe, destExe);
copy(srcHost, destHost);
return exists(destHost) && exists(destExe) ? destHost : destUsable ? destHost : null;
}
catch {
return destUsable ? destHost : null;
}
}
export function resolveCodexLaunchBin(opts = {}) {
const exists = opts.existsSync ?? existsSync;
const host = (opts.resolveHost ?? (() => resolveCodexCodeModeHostPath({ existsSync: exists })))();
if (host && isVolatileCodexPackagePath(host)) {
const ensure = opts.ensureStable ?? ((src) => ensureStableCodexVendor(src));
const stableHost = ensure(host);
if (stableHost) {
const exe = path.join(path.dirname(stableHost), vendorExeName(platformOfHostPath(stableHost)));
if (exists(exe))
return exe;
}
}
try {
const js = opts.resolveJs?.() ??
(() => {
const require = createRequire(import.meta.url);
const pkgPath = require.resolve("@openai/codex/package.json");
const pkg = require("@openai/codex/package.json");
const binRel = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.codex;
if (!binRel)
return null;
return path.join(pkgPath.replace(/[\\/]package\.json$/, ""), binRel.replace(/^\.[\\/]/, ""));
})();
if (js && exists(js))
return js;
}
catch {
// bundled package not installed — fall through to PATH.
}
return "codex";
}
/**
* The Codex app-server adapter. One instance per PanelAgent; it holds the live
* app-server client + current thread/turn ids and re-opens on each `run()`.
*/
export class CodexBackend {
id = "codex";
capabilities = CODEX_CAPABILITIES;
deps;
client = null;
/** The client currently being spun up by an in-flight prepare(), tracked so a
* concurrent close() can tear it down even before it's published to
* this.client (P0-A: close() racing prepare() must never leak the child). */
preparingClient = null;
/** Detached clients whose bounded teardown is still in flight. */
closingClients = new Map();
/** Per-turn escape hatch used when the app-server ignores turn/interrupt. */
abortActiveTurn = null;
/** Set once close() runs — a hard tripwire so an in-flight prepare() that wakes
* up after close() disposes its local client instead of publishing it (P0-A). */
disposed = false;
/** Cached resolved path to the codex binary/launcher (set in prepare()). */
bin = null;
/** Account-aware model catalog from the app-server's model/list (set by
* listModels()); resolveTurnModel() clamps every thread's model to it. */
liveCatalog = null;
/** Sandbox posture for the app-server + every thread (COMFYUI_MCP_CODEX_SANDBOX,
* default danger-full-access — mirrors Claude's bypassPermissions). Read once at
* construction so it's stable for this backend instance. */
sandbox = resolveCodexSandbox();
/** The live thread + turn ids — used for `turn/interrupt`. */
threadId = null;
turnId = null;
/** MCP servers this run currently knows are DOWN, name → reconnect attempted.
* Codex does not auto-reconnect HTTP MCP (openai/codex#11489); we poll
* `mcpServerStatus/list` at each turn end and queue one
* `config/mcpServer/reload` per down-episode (#1524). */
mcpDown = new Map();
/** Names whose `config/mcpServer/reload` was queued this down/partial episode and has
* not been judged yet. The app-server applies the refresh on the NEXT
* turn, so the following poll is the verdict — not an immediate re-read. */
mcpReloadPending = new Set();
/** Partial-catalog episodes cannot be closed by a connected row alone. */
mcpCatalogRecovery = new Set();
/** A WorkerTransport send failure is stronger evidence than a cached
* `runtimeStatus: connected` row, but it still opens only one recovery episode.
* The control-plane reload below never replays the failed tool call. */
mcpTransportRecovery = new Set();
/** The model requested for new turns (mutable for a future live setModel). */
model;
/** The Codex reasoning effort for new turns, already mapped to a valid Codex
* level (null = let the app-server choose). Captured from run(opts.effort). */
/** RAW panel effort for this session — snapped per-turn against the
* RESOLVED model's supported list (the catalog isn't loaded yet when run()
* captures it, and the active model can change via the clamp). */
effort = null;
/** True until the panel system prompt has been prepended to a turn. The
* app-server's thread/start has no instructions field, so the persona rides on
* the FIRST turn's input; reset whenever a NEW thread starts (run()). */
needsSystemPreamble = false;
/** Temp image files written for delivered turn images (the app-server's
* `localImage` input item takes a PATH, so /view bytes are spilled to disk).
* Tracked so each turn cleans up its own files, and close() sweeps any
* stragglers. */
tempImageFiles = new Set();
/** #1516 — fetched bytes of AUTOMATIC previews delivered into the CURRENT
* thread (refs flagged `automatic`; user attachments never count). Codex
* persists every localImage as an inline input_image data URL and its
* compaction recopies them unboundedly, so this is the ledger the cumulative
* byte budget enforces against. Reset when the thread changes. */
previewBytes = 0;
/** The thread {@link previewBytes} belongs to (a fresh thread starts at 0). */
previewBytesThread = null;
constructor(deps = {}) {
this.deps = deps;
this.model = deps.model;
}
beginClientClose(client, reason) {
const existing = this.closingClients.get(client);
if (existing)
return existing;
const closeOperation = Promise.resolve()
.then(() => client.close())
.catch((err) => {
logger.warn(`[codex-backend] ${reason}: ${msgOf(err)}`);
});
const closing = settlesWithin(closeOperation, CODEX_CLIENT_CLOSE_BUDGET_MS)
.then((settled) => {
if (!settled) {
logger.warn(`[codex-backend] ${reason}: close did not settle within ${CODEX_CLIENT_CLOSE_BUDGET_MS}ms; detaching`);
}
})
.finally(() => {
if (this.closingClients.get(client) === closing)
this.closingClients.delete(client);
});
this.closingClients.set(client, closing);
return closing;
}
/**
* Resolve the codex binary: prefer a stable copy of the vendor exe when the
* active package lives under a WinGet/nvm versioned dir (#2045), else the
* bundled `@openai/codex` launcher, else a `codex` on PATH. A cached path
* that no longer exists is dropped so the next spawn cannot keep a stale
* WinGet Node folder.
*/
resolveBin() {
if (this.bin && this.bin !== "codex") {
if (!existsSync(this.bin))
this.bin = null;
else if (!/\.(c|m)?js$/i.test(this.bin)) {
const host = path.join(path.dirname(this.bin), vendorHostName(platformOfHostPath(this.bin)));
if (!existsSync(host))
this.bin = null;
}
}
if (this.bin)
return this.bin;
this.bin = resolveCodexLaunchBin();
return this.bin;
}
/**
* Fetch a ComfyUI image (/view) and spill the bytes to a temp file, returning
* its absolute path and byte size — or null on any failure (the text reference
* still names the image as a fallback). The app-server `turn/start`
* `localImage` input item takes a FILE PATH (mirrors the codex CLI
* `-i, --image <FILE>`), so unlike Claude (inline base64) we must write the
* bytes to disk. Mirrors ClaudeBackend.fetchImageBlock's source/size guards.
* Each written path is tracked in tempImageFiles for per-turn + close()
* cleanup. The byte count rides along so the caller can charge the #1516
* cumulative preview ledger with the size that was ACTUALLY delivered.
*/
async fetchImageFile(ref) {
if (!this.deps.comfyuiUrl || !ref?.filename)
return null;
try {
const u = new URL("/view", this.deps.comfyuiUrl);
u.searchParams.set("filename", ref.filename);
u.searchParams.set("type", ref.type || "input");
if (ref.subfolder)
u.searchParams.set("subfolder", ref.subfolder);
const res = await fetch(u, { signal: AbortSignal.timeout(15000) });
if (!res.ok)
return null;
const mt = (res.headers.get("content-type") || "").split(";")[0].trim().toLowerCase();
const buf = Buffer.from(await res.arrayBuffer());
if (buf.length > 12 * 1024 * 1024)
return null; // keep context sane (parity with Claude)
// Preserve a recognizable extension so the model/app-server treat it as the
// right image type; default to .png (ComfyUI outputs are PNG by default).
const extFromMime = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/gif": ".gif",
"image/webp": ".webp",
};
// Recognized content-type → its extension; otherwise only trust the source
// filename extension if it's a known image type, else default to .png (don't
// preserve an arbitrary suffix — parity with Claude mapping unknowns to png).
const allowedExt = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
const fileExt = path.extname(ref.filename).toLowerCase();
const ext = extFromMime[mt] ?? (allowedExt.has(fileExt) ? fileExt : ".png");
const file = path.join(os.tmpdir(), `comfyui-codex-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
await fsp.writeFile(file, buf);
this.tempImageFiles.add(file);
return { path: file, bytes: buf.length };
}
catch {
return null;
}
}
/** #1516 — fetched bytes of AUTOMATIC previews delivered into the current
* thread, as the AgentBackend port exposes them: PanelAgent adds this to the
* durable pre-restart base when it checks the cumulative session budget. */
automaticPreviewBytes() {
return this.previewBytes;
}
/**
* How long a turn's localImage files survive after the turn ends (#1152).
*
* The app-server may read a localImage well after turn/start returns, so the
* delete has to wait for a read whose timing we do not control. There is no
* signal for "the image was consumed" in the protocol, so this is a grace
* window rather than a handshake — chosen long enough to cover a deferred read
* on a busy machine, and bounded so the files do not accumulate for a session's
* whole lifetime.
*/
static TEMP_IMAGE_GRACE_MS = 5 * 60_000;
/** Pending grace timers, so close() can clear them rather than leave them armed. */
tempImageTimers = new Set();
/** Delete this turn's temp images after the grace window (#1152). */
scheduleTempImageCleanup(files) {
const timer = setTimeout(() => {
this.tempImageTimers.delete(timer);
void this.cleanupTempImages(files);
}, CodexBackend.TEMP_IMAGE_GRACE_MS);
// Must never keep the process alive: an orchestrator that has finished its
// work should exit, and close() sweeps the files regardless.
timer.unref?.();
this.tempImageTimers.add(timer);
}
/** Delete the given temp image files (best-effort) and drop them from tracking. */
async cleanupTempImages(files) {
for (const f of files) {
this.tempImageFiles.delete(f);
try {
await fsp.unlink(f);
}
catch {
// already gone / never created — best-effort
}
}
}
/**
* Preflight: resolve + spawn `codex app-server`, perform the JSON-RPC
* handshake, and verify the account is logged in (ChatGPT login / CODEX_API_KEY
* — keyless, like the Claude OAuth lane). Fails fast with a clear reject so a
* missing binary or signed-out state surfaces immediately instead of being
* retried as a dropped session. Idempotent — reuses the live client.
*/
async prepare() {
if (this.disposed)
throw new Error("codex backend is closed.");
if (this.client)
return;
const bin = this.resolveBin();
const cwd = this.deps.cwd ?? process.cwd();
// Declare the comfyui + panel MCP servers as `-c` overrides so Codex has the
// same tool surface as Claude (full parity). Also set the sandbox + approval
// posture at the app-server CONFIG level (the per-turn thread/start `sandbox`
// param below is the effective lever, but pinning the config default keeps them
// consistent and covers any codex path that reads the config). Values are TOML
// string literals via JSON.stringify, matching buildMcpConfigArgs.
const extraArgs = [
...(this.deps.mcpServers ? buildMcpConfigArgs(this.deps.mcpServers) : []),
"-c",
`sandbox_mode=${JSON.stringify(this.sandbox)}`,
"-c",
`approval_policy=${JSON.stringify("never")}`,
// Isolate the embedded app-server from the user's GLOBAL Codex `notify`
// config (#277): an inherited legacy_notify hook can fail after a panel
// turn on Windows with `os error 206` (filename/extension too long),
// emitting a misleading warning alongside a failed turn. The panel owns
// its own UI notifications, so the embedded app-server needs no hook.
"-c",
"notify=[]",
];
// SECURITY: spawn with the agent env — process.env MINUS tool-only secrets
// (RunPod/HF/CivitAI… tokens). Those belong to the comfyui tool child
// (buildComfyuiMcpEnv), never to an LLM vendor's subprocess.
const client = new AppServerClient(bin, cwd, buildAgentSpawnEnv(), extraArgs);
// Publish the in-flight client BEFORE the startup awaits so a concurrent
// close() can find and kill it instead of seeing this.client === null and
// returning early — which would orphan the spawning app-server child (P0-A).
this.preparingClient = client;
// After EVERY await below, re-check disposed: close() may have run during the
// await and torn down our client out from under us. If so, dispose the local
// client and bail without publishing it.
const abortIfDisposed = async () => {
if (!this.disposed)
return;
if (this.preparingClient === client)
this.preparingClient = null;
await client.close().catch(() => { });
throw new Error("codex backend was closed during prepare().");
};
try {
try {
await client.initialize({ title: "comfyui-mcp panel", name: "comfyui-mcp", version: "0.16.0" });
}
catch (err) {
await client.close().catch(() => { });
throw new Error(`Could not start the Codex app-server (codex backend). Install the optional dependency with: npm i @openai/codex (or ensure \`codex\` is on PATH). Details: ${msgOf(err)}`);
}
await abortIfDisposed();
// Auth check: account/read tells us whether a ChatGPT login or API key is
// present. Mirror the plugin's app-server auth probe.
try {
const account = await client.request("account/read", { refreshToken: false });
const loggedIn = !!account?.account || account?.requiresOpenaiAuth === false;
if (!loggedIn) {
await client.close().catch(() => { });
throw new Error("Codex is not logged in. Run `codex login` (ChatGPT login) or set CODEX_API_KEY, then reconnect.");
}
}
catch (err) {
await client.close().catch(() => { });
throw err instanceof Error ? err : new Error(msgOf(err));
}
await abortIfDisposed();
this.client = client;
logger.info("[codex-backend] app-server ready (logged in)");
}
finally {
// Either we published it onto this.client, or an error/abort path already
// closed it — in all cases stop tracking it as in-flight.
if (this.preparingClient === client)
this.preparingClient = null;
}
}
/**
* Open/continue a Codex thread and yield canonical AgentEvents. The user
* channel (PanelAgent's gated queue) is consumed ONE turn at a time: each
* neutral batch becomes a `turn/start`, whose streamed notifications are
* normalized to AgentEvents, and only after the turn completes do we read the
* next batch (the channel async-iteration IS the turn-gate).
*/
async *run(opts) {
this.mcpDown.clear();
this.mcpReloadPending.clear();
this.mcpCatalogRecovery.clear();
this.mcpTransportRecovery.clear();
await this.prepare();
const client = this.client;
if (!client)
throw new Error("codex app-server not initialized");
const cwd = opts.cwd ?? this.deps.cwd ?? process.cwd();
// MODEL PRECEDENCE (P1-1): PanelAgent.start() always passes opts.model = the
// CLAUDE panel model (e.g. claude-opus-5), which is NOT a valid Codex model.
// The Codex model configured at construction (deps.model, from
// COMFYUI_MCP_CODEX_MODEL) must win. Only honor opts.model if it actually looks
// belong to the account catalog (legacy name checks only if unavailable);
// otherwise ignore it and keep the configured Codex model (or the account
// default when neither is set — model:null lets the app-server choose).
// Capture the RAW panel effort; each turn maps+snaps it against the model
// it actually runs (toCodexEffort with the model's supported list — the
// catalog isn't loaded yet here, and max/ultra are only valid on models
// that advertise them). The panel restarts run() on an effort change, so
// capturing once per session is enough.
this.effort = opts.effort ?? null;
// forkAtAnchor is false (CODEX_CAPABILITIES) → ignore opts.rewindAnchor; we
// only do whole-thread resume.
const resumeId = opts.resume ?? opts.sessionId ?? null;
// Make sure THIS instance knows the account's live model catalog before the
// thread opens — each tab's agent gets its own backend instance, so the
// catalog fetched by the connect-time advertisement lives elsewhere. Without
// this, resolveTurnModel() has nothing to clamp against and defers to the
// ~/.codex config default, which can be unrunnable (see resolveTurnModel).
if (!this.liveCatalog)
await this.listModels().catch(() => { });
// Catalog membership is authoritative even when a future model does not
// use one of the legacy name prefixes. Ignore another provider's default.
if (opts.model && (this.liveCatalog
? this.liveCatalog.some((m) => m.id === opts.model)
: isCodexModel(opts.model)))
this.model = opts.model;
let threadModel;
if (resumeId) {
// thread/resume continues an existing conversation by id.
const res = await client.request("thread/resume", {
threadId: resumeId,
cwd,
model: this.resolveTurnModel(),
approvalPolicy: "never",
sandbox: this.sandbox,
});
this.threadId = res.thread.id;
threadModel = res.model;
// A resumed thread already received the persona on its original first turn —
// don't repeat it.
this.needsSystemPreamble = false;
}
else {
// thread/start opens a fresh conversation.
const res = await client.request("thread/start", {
cwd,
model: this.resolveTurnModel(),
approvalPolicy: "never",
sandbox: this.sandbox,
ephemeral: false,
});
this.threadId = res.thread.id;
threadModel = res.model;
// Fresh thread → prepend the panel persona to the first turn's input.
this.needsSystemPreamble = !!this.deps.systemAppend;
}
// The thread id is our session id (PanelAgent persists it for resume).
// #1516 — the cumulative preview byte ledger is a property OF THE THREAD:
// a different conversation (fresh start, or a resume of another id after a
// fork) provably holds none of this process's earlier previews.
if (this.threadId !== this.previewBytesThread) {
this.previewBytesThread = this.threadId;
this.previewBytes = 0;
}
yield {
type: "session",
sessionId: this.threadId,
...(threadModel ? { model: threadModel } : {}),
};
// Process the neutral channel one turn at a time. onActivity is the LIVENESS
// signal — every raw app-server notification for the active turn re-arms
// PanelAgent's idle watchdog so a long, quiet generation doesn't falsely trip.
let turnSeq = 0;
let liveClient = client;
for await (const turn of opts.channel) {
// A timed-out interrupt nulls this.client — stop draining so PanelAgent
// can start a fresh app-server. A stale-host recycle (#2045) replaces
// the client in place; keep going on the replacement.
if (!this.client)
return;
liveClient = this.client;
yield* stampTurn(this.runTurn(liveClient, turn, opts.onActivity), ++turnSeq);
if (!this.client)
return;
liveClient = this.client;
try {
for (const ev of await this.checkMcpServers(liveClient))
yield ev;
}
catch (err) {
logger.debug(`[codex-backend] checkMcpServers: ${msgOf(err)}`);
}
}
}
/**
* Watch the Codex toolset for a MID-SESSION drop and queue one reconnect
* (#1524). Codex's MCP client is one-shot (openai/codex#11489): stdio
* `comfyui` is respawned by the client, HTTP `panel` is not. After
* `panel_restart_comfyui` the session resumes with ALL_TOOLS missing every
* `mcp__panel__*` entry while the generic comfyui gateway remains.
*
* Detection is one `mcpServerStatus/list` poll per turn end (with this
* thread's id, so `runtimeStatus` is filled) against the servers this
* session was given. Cached `tools` do not prove connected; an explicitly
* configured required wrapper missing from a non-absent connected inventory
* is the only partial-catalog case this watch repairs. Recovery is
* `config/mcpServer/reload`, which the app-server applies on the NEXT
* turn — so this turn does not claim a verdict from an immediate re-read.
* The next poll is the verdict.
* One attempt per down-episode; `needs-auth` / `disabled` are reported
* and not retried. A poll or reload that throws leaves the session as
* informed as before and never takes the turn stream down.
*/
async checkMcpServers(client) {
const names = Object.keys(this.deps.mcpServers ?? {});
if (names.length === 0)
return [];
// A WorkerTransport failure can leave Codex's status inventory cached as
// connected. Preserve the observation as a bounded, panel-only recovery
// episode so a status poll cannot erase the evidence before reload.
const transportRecovery = new Set([...this.mcpTransportRecovery].filter((name) => !this.mcpDown.has(name) && !this.mcpReloadPending.has(name)));
const polled = await this.pollMcpStatus(client);
if (!polled && transportRecovery.size === 0)
return [];
const { reported, listed } = polled ?? { reported: [], listed: [] };
const events = [];
const health = inspectMcpServers(names, reported);
const catalogGaps = missingRequiredMcpTools(listed, this.deps.requiredMcpTools);
const catalogRecovered = presentRequiredMcpTools(listed, this.deps.requiredMcpTools);
for (const gap of catalogGaps)
this.mcpCatalogRecovery.add(gap.name);
// A server reported as failed already has the authoritative failure status;
// do not replace it with the partial-catalog diagnosis if both observations
// arrive in one poll.
const degradedByName = new Map(health.degraded.map((d) => [d.name, d]));
for (const gap of catalogGaps) {
if (!degradedByName.has(gap.name))
degradedByName.set(gap.name, { name: gap.name, status: "partial" });
}
for (const name of transportRecovery) {
if (!degradedByName.has(name))
degradedByName.set(name, { name, status: "transport-error" });
}
const degraded = [...degradedByName.values()];
const catalogGapNames = new Set(catalogGaps.map((gap) => gap.name));
const downNow = new Set(degraded.map((d) => d.name));
// Verdict on a reload queued at the previous turn end. The refresh is
// applied when the next turn starts, so THIS poll is the first honest read.
const pendingVerdict = [...this.mcpReloadPending];
this.mcpReloadPending.clear();
const backByUs = [];
const backOnItsOwn = [];
for (const name of [...this.mcpDown.keys()]) {
if (downNow.has(name))
continue;
if (this.mcpCatalogRecovery.has(name) && !catalogRecovered.has(name))
continue;
const weTried = this.mcpDown.get(name) === true || pendingVerdict.includes(name);
this.mcpDown.delete(name);
this.mcpCatalogRecovery.delete(name);
(weTried ? backByUs : backOnItsOwn).push(name);
}
if (backByUs.length) {
logger.info(`[codex-backend] MCP reload brought back: ${backByUs.join(", ")}`);
events.push({ type: "error", sessionNotice: true, message: recoveredMcpNotice(backByUs, true) });
}
if (backOnItsOwn.length) {
logger.info(`[codex-backend] MCP servers connected again on their own: ${backOnItsOwn.join(", ")}`);
events.push({ type: "error", sessionNotice: true, message: recoveredMcpNotice(backOnItsOwn, false) });
}
const stillDownAfterReload = degraded.filter((d) => pendingVerdict.includes(d.name));
if (stillDownAfterReload.length) {
this.reportDegradedMcp(stillDownAfterReload, "lost");
events.push({
type: "error",
sessionNotice: true,
message: unrecoveredMcpNotice(stillDownAfterReload, "reconnect-failed"),
});
}
const triable = degraded.filter((d) => !pendingVerdict.includes(d.name) &&
this.mcpDown.get(d.name) !== true &&
(reconnectableMcpStatus(d.status) || catalogGapNames.has(d.name) || transportRecovery.has(d.name)));
if (triable.length > 0) {
for (const d of triable) {
this.mcpDown.set(d.name, true);
this.mcpTransportRecovery.delete(d.name);
}
try {
await client.request("config/mcpServer/reload", {});
for (const d of triable)
this.mcpReloadPending.add(d.name);
}
catch (err) {
logger.debug(`[codex-backend] config/mcpServer/reload: ${msgOf(err)}`);
this.reportDegradedMcp(triable, "lost");
events.push({
type: "error",
sessionNotice: true,
message: unrecoveredMcpNotice(triable, "unverified"),
});
}
}
const unreported = degraded.filter((d) => !pendingVerdict.includes(d.name) &&
!triable.some((t) => t.name === d.name) &&
!this.mcpDown.has(d.name));
const notRetriable = unreported.filter((d) => !reconnectableMcpStatus(d.status));
const unsupported = unreported.filter((d) => reconnectableMcpStatus(d.status));
for (const d of unreported)
this.mcpDown.set(d.name, true);
if (notRetriable.length) {
this.reportDegradedMcp(notRetriable, "lost");
events.push({
type: "error",
sessionNotice: true,
message: unrecoveredMcpNotice(notRetriable, "not-retriable"),
});
}
if (unsupported.length) {
this.reportDegradedMcp(unsupported, "lost");
events.push({
type: "error",
sessionNotice: true,
message: unrecoveredMcpNotice(unsupported, "unsupported"),
});
}
return events;
}
/** Record the specific HTTP MCP failure observed by the Codex app-server.
* Do not open a second episode while an existing status/reload episode is
* still unresolved; the next turn's status poll is its verdict. */
noteMcpTransportFailure(message) {
const panel = this.deps.mcpServers?.panel;
if (panel?.transport !== "http" || !isCodexPanelMcpTransportFailure(message, panel.url))
return false;
if (!this.mcpDown.has("panel") && !this.mcpReloadPending.has("panel")) {
this.mcpTransportRecovery.add("panel");
}
return true;
}
reportDegradedMcp(degraded, when) {
logger.error(`[codex-backend] session ${this.threadId?.slice(0, 8) ?? "?"} ${when} WITHOUT ` +
`${degraded.map((d) => `${d.name}=${d.status ?? "absent"}`).join(" ")}`);
}
async pollMcpStatus(client) {
try {
const listed = [];
let cursor;
for (let page = 0; page < 8; page++) {
const res = await client.request("mcpServerStatus/list", {
detail: "toolsAndAuthOnly",
limit: 50,
...(this.threadId ? { threadId: this.threadId } : {}),
...(cursor ? { cursor } : {}),
});
if (!Array.isArray(res?.data))
break;
listed.push(...res.data);
cursor = res.nextCursor ?? undefined;
if (!cursor)
break;
}
const reported = reportedFromCodexMcpListing(listed);
return reported ? { reported, listed } : undefined;
}
catch (err) {
logger.debug(`[codex-backend] mcpServerStatus/list: ${msgOf(err)}`);
return undefined;
}
}
/** Run ONE turn: turn/start + stream its notifications → AgentEvents, resolving
* when `turn/completed`/`error` for this thread+turn arrives, OR when the
* app-server child exits mid-turn (P0-2 — never deadlock). Notifications are
* buffered until the turnId is known and then filtered by belongsToTurn (P1-3)
* so a stale/interleaved same-thread notification can't complete the wrong turn. */
async *runTurn(client, turn, onActivity) {
const threadId = this.threadId;
let liveClient = client;
// Event queue bridging the push-based notification handler to this pull-based
// async generator. The handler enqueues normalized AgentEvents; we drain.
const queue = [];
let wake = null;
let done = false;
const push = (ev) => {
queue.push(ev);
wake?.();
wake = null;
};
const finish = () => {
done = true;
wake?.();
wake = null;
};
// EVERY terminal path of a turn MUST emit exactly one `result` so PanelAgent's
// turn-gate advances (it only calls completeTurn() on a result event; a missing
// result parks its channel forever — panel-agent.ts ~438). This single
// idempotent helper guarantees that: it emits an `error` event + a single
// `{type:"result", ok:false}` and finishes, and is a no-op if a result was
// already emitted (so the error notification, the exit watcher, and the
// turn/start rejection can all call it without double-finishing) (P0-B).
let finishedResult = false;
const emitTerminalError = (message, flags) => {
if (finishedResult)
return;
finishedResult = true;
closeStream();
push({ type: "error", message, ...flags });
push({ type: "result", ok: false, subtype: "error" });
finish();
};
// ---- turn-id state machine (mirrors the reference captureTurn) ----
// The turnId isn't known until the turn/start response resolves, and some
// notifications can arrive BEFORE it. Buffer those, then replay only the ones
// that belong to this turn once we know the id. After that, filter live.
let activeTurnId = null;
let turnIdKnown = false;
const buffered = [];
// #1929 — one bounded retry of turn/start after a Windows sharing-violation
// on the bundled code-mode host. While the retry is armed, drop notifications
// for the dead turn so a racing turn/completed cannot finish us first.
let hostSpawnRetries = 0;
let retryPending = false;
let retryTimer;
const belongsToTurn = (msg) => {
const params = (msg.params ?? {});
const msgThreadId = params.threadId;
// Wrong thread → not ours.
if (msgThreadId && msgThreadId !== threadId)
return false;
const t = params.turn;
const msgTurnId = params.turnId ?? t?.id ?? null;
// No active turn id yet (shouldn't happen post-buffer) or the notification
// carries no turn id → accept; otherwise require an exact match.
return activeTurnId === null || msgTurnId === null || msgTurnId === activeTurnId;
};
// A WorkerTransport error can arrive with willRetry:true, which means the
// app-server still owns the turn and may emit a retry/completion after the
// notification. Local recovery must fence that turn before exposing a
// terminal outcome to PanelAgent; otherwise a new turn could start while
// the original panel mutation is still being retried.
let mcpTransportFencePending = false;
let turnFenced = false;
const panelMcpRequests = new Map();
const panelRequestKeyToItem = new Map();
const panelReadRetries = new Map();
const inFlightMcpItemIds = new Set();
let unkeyedMcpItems = 0;
let panelArmCandidate = null;
let immediatePanelReadAfterArm = null;
// Retry contract: only the next panel request after a successful
// panel_enter_subgraph, panel_open_workflow, or panel_run completion can be
// an eligible graph read. The one-shot sequence is consumed at request
// start; a mutation or unrelated panel request therefore invalidates it
// before a later read can qualify. Correlation must resolve the transport
// error to that request; ambiguity is fail-closed. Mutations and unrelated
// reads use normal fencing.
// LIVENESS (watchdog re-arm): re-arm PanelAgent's idle watchdog ONLY for
// notifications that represent work or an outcome for THIS active turn. A
// long tool call streams item/* and turn/* notifications that carry no
// AgentEvent of their own — a multi-minute ComfyUI generation emits
// item/started, item/updated, … — so without this a healthy long run looks
// idle and the watchdog falsely trips. `error` counts too: a retrying
// provider error (willRetry) is Codex still owning the turn.
//
// It must NOT fire for the background traffic the app-server interleaves —
// account, model-catalog and token-usage notifications arrive while the
// active turn is silent — nor for notifications belonging to a stale or
// other thread/turn. Both kept the watchdog armed forever, so a genuinely
// wedged turn (nothing on its own item/turn stream while background chatter
// continued) never reached the stall deadline. Gating on belongsToTurn AND
// the active-turn method set is what makes the deadline reachable. (#307)
// item/* and turn/* are the streaming lifecycle; `error` is a (possibly
// retrying) turn error. The model/* trio are turn events the pinned
// app-server emits around provider routing/safety — they carry threadId +
// turnId, so they ARE active-turn progress and must re-arm the watchdog, but
// a bare item/turn prefix check misses them (#307 review, finding 2).
const MODEL_TURN_EVENTS = new Set([
"model/safetyBuffering/updated",
"model/rerouted",
"model/verification",
]);
const TURN_LIVENESS = (m) => m.startsWith("item/") || m.startsWith("turn/") || m === "error" || MODEL_TURN_EVENTS.has(m);
const bumpTurnActivity = (msg) => {
if (turnFenced || !belongsToTurn(msg) || !TURN_LIVENESS(msg.method ?? ""))
return;
try {
onActivity?.();
}
catch {
// a watchdog bump must never break the protocol reader
}
};
// Track the streamed item id so deltas + the final commit share one bubble id
// (the panel reconciles by id, like the Claude stream path). Reasoning and
// reply text each open/close their own stream (P2-1: reasoning was previously
// emitted without a stream_start, so the panel dropped early thinking deltas).
let streamOpen = false;
let streamKind = null;
let interrupted = false;
// Accumulate the streamed reply text so a malformed final commit (structured
// `agentMessage.text` coercing to empty/"[object Object]") can fall back to the
// text the user already saw stream in, instead of clobbering it (#421, #422).
let assistantTextBuffer = "";
const openStream = (id, kind) => {
if (streamOpen && streamKind === kind)
return;
if (streamOpen)
push({ type: "stream_end" }); // switch kinds → close the old one
streamOpen = true;
streamKind = kind;
push({ type: "stream_start", id });
};
const closeStream = () => {
if (streamOpen) {
push({ type: "stream_end" });
streamOpen = false;
streamKind = null;
}
};
// `willRetry:true` is the app-server's positive signal that it still owns
// the active connection and can retry the failed MCP send. Keep the local
// state checks as well: cancellation, timeout teardown, client replacement,
// and disposal must not wake a retry after the turn has stopped being live.
const appServerConnectionIsActive = () => !interrupted &&
!finishedResult &&
!turnFenced &&
!this.disposed &&
this.client === liveClient &&
liveClient.exitError == null;
const trackPanelMcpItem = (item, afterArmItemId) => {
const existing = panelMcpRequests.get(item.itemId);
if (existing) {
for (const key of item.requestKeys) {
existing.requestKeys = [...new Set([...existing.requestKeys, key])];
panelRequestKeyToItem.set(key, item.itemId);
}
return existing;
}
const request = {
...item,
afterArmItemId,
completed: false,
};
panelMcpRequests.set(item.itemId, request);
for (const key of item.requestKeys)
panelRequestKeyToItem.set(key, item.itemId);
return request;
};
const panelMcpRequestForTransportError = (params) => {
const keys = panelMcpTransportErrorKeys(params);
if (keys.length > 0) {
const itemIds = new Set();
let unknownKey = false;
for (const key of keys) {
const itemId = panelRequestKeyToItem.get(key) ?? (panelMcpRequests.has(key) ? key : undefined);
if (itemId)
itemIds.add(itemId);
else
unknownKey = true;
}
// An unknown explicit key must never fall back to a different request.
if (unknownKey || itemIds.size !== 1)
return null;
const request = panelMcpRequests.get([...itemIds][0]);
return request && !request.completed ? request : null;
}
// Older app-server errors omit itemId. A sole in-flight MCP request is
// still unambiguous; any parallel or unkeyed MCP item makes it ineligible.
if (unkeyedMcpItems > 0 || inFlightMcpItemIds.size !== 1)
return null;
const inFlight = [...panelMcpRequests.values()].filter((request) => !request.completed);
if (inFlight.length !== 1)
return null;
const [itemId] = inFlightMcpItemIds;
return inFlight[0].itemId === itemId ? inFlight[0] : null;
};
const panelReadRetryForRequest = (request) => {
if (!request)
return null;
for (const [originItemId, retry] of panelReadRetries) {
if (retry.itemIds.has(request.itemId) ||
request.requestKeys.some((key) => retry.requestKeys.has(key))) {
return { originItemId, retry };
}
}
return null;
};
const firstPanelReadRetry = () => panelReadRetries.values().next().value;
const abortActiveTurn = () => {
interrupted = true;
if (finishedResult)
return;
finishedResult = true;
closeStream();
// This is controlled recovery, not a provider failure. `ok: true` also
// prevents the watchdog path from painting a duplicate failure banner.
push({ type: "result", ok: true, subtype: "interrupted" });
finish();
};
this.abortActiveTurn = abortActiveTurn;
// Assigned after turn input is built — the retry timer only fires after a
// turn/start has already run, so this is never invoked while still a no-op.
let issueTurnStart = () => { };
let handler = () => { };
let hostRecycleInFlight = false;
const watchExit = (watched) => {
void watched.exitPromise.then(() => {
if (done || turnFenced)
return;
if (watched !== liveClient)
return;
// Closing the old app-server is the point of a stale-host recycle;
// its exit must not finish the replacement turn (#2045).
const retry = firstPanelReadRetry();
if (retry) {
panelReadRetries.clear();
emitTerminalError(panelMcpTransportFailureNotice(retry.originalMessage, true), {
outcomeUnknown: true,
});
return;
}
if (hostRecycleInFlight)
return;
emitTerminalError(watched.exitError ? msgOf(watched.exitError) : "codex app-server connection closed.");
});
};
const closeFencedClient = async (fencedClient) => {
if (this.client === fencedClient) {
this.client = null;
this.threadId = null;
this.turnId = null;
}
await this.beginClientClose(fencedClient, "panel MCP transport recovery fence teardown failed");
};
const finishMcpTransportFailure = (message, willRetry, retriedRead = false) => {
if (mcpTransportFencePending || finishedResult)
return;
mcpTransportFencePending = true;
// willRetry:false is already terminal at the app-server boundary. Keep
// the existing bounded recovery path and its single local error/result.
if (!willRetry) {
emitTerminalError(panelMcpTransportFailureNotice(message, retriedRead), { outcomeUnknown: true });
return;
}
// willRetry:true is different: Codex may still retry the failed MCP call.
// Fence notification handling immediately, then wait for the app-server
// to accept turn/interrupt before emitting the local terminal error. If
// the interrupt cannot be confirmed, close the client so the old turn
// cannot continue while reload/reconnect/new-turn processing starts.
turnFenced = true;
const fencedClient = liveClient;
const fencedTurnId = activeTurnId ?? this.turnId;
void (async () => {
let interruptError;
if (fencedTurnId) {
let timer;
try {
await Promise.race([
fencedClient.request("turn/interrupt", { threadId, turnId: fencedTurnId }),
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`turn/interrupt timed out after ${CODEX_INTERRUPT_TIMEOUT_MS}ms`)), CODEX_INTERRUPT_TIMEOUT_MS);
}),
]);
}
catch (err) {
interruptError = err;
}
finally {
if (timer)
clearTimeout(timer);
}
}
else {
interruptError = new Error("the active Codex turn had no interruptible turn id");
}
if (interruptError) {
logger.warn(`[codex-backend] panel MCP transport fence failed: ${msgOf(interruptError)}`);
await closeFencedClient(fencedClient);
}
if (finishedResult)
return;
const fenceNote = interruptError
? " The active app-server turn could not be interrupted; its connection was closed before recovery."
: " The active app-server turn was interrupted before recovery.";
emitTerminalError(panelMcpTransportFailureNotice(message, retriedRead) + fenceNote, {
outcomeUnknown: true,
});
})();
};
const tryRetryCodeModeHostSpawn = (message) => {
if (interrupted || finishedResult || retryPending)
return false;
if (hostSpawnRetries >= CODEX_HOST_SPAWN_RETRIES)
return false;
if (!isWindowsCodeModeHostSpawnRetryable(message))
return false;
hostSpawnRetries += 1;
retryPending = true;
const reportedHost = codeModeHostPathFromError(message);
const recycle = isWindowsCodeModeHostPathNotFound(message) &&
typeof this.bin === "string" &&
this.bin !== "codex" &&
(!reportedHost || !existsSync(reportedHost) || isVolatileCodexPackagePath(reportedHost));
logger.warn(recycle
? `[codex-backend] code-mode host spawn hit a stale path; recycling the app-server then retrying after ${CODEX_HOST_SPAWN_RETRY_MS}ms (#2045)`
: `[codex-backend] code-mode host spawn failed; retrying after ${CODEX_HOST_SPAWN_RETRY_MS}ms (#1929/#2045)`);
try {
onActivity?.();
}
catch {
// a watchdog bump must never break the protocol reader
}
retryTimer = setTimeout(() => {
retryTimer = undefined;
void (async () => {
if (interrupted || finishedResult || this.disposed) {
retryPending = false;
return;
}
if (recycle) {
hostRecycleInFlight = true;
this.bin = null;
if (this.client === liveClient)
this.client = null;
await this.beginClientClose(liveClient, "stale code-mode host recycle teardown failed");
if (interrupted || finishedResult || this.disposed) {
hostRecycleInFlight = false;
retryPending = false;
return;
}
try {
await this.prepare();
}
catch (err) {
hostRecycleInFlight = false;
retryPending = false;
emitTerminalError(msgOf(err));
return;
}
const next = this.client;
if (!next) {
hostRecycleInFlight = false;
retryPending = false;
emitTerminalError("codex app-server not initialized");
return;
}
liveClient = next;
hostRecycleInFlight = false;
liveClient.notificationHandler = handler;
watchExit(liveClient);
try {
await liveClient.request("thread/resume", {
threadId,
cwd: this.deps.cwd ?? process.cwd(),
model: this.resolveTurnModel(),
approvalPolicy: "never",
sandbox: this.sandbox,
});
}
catch {
// Thread may not survive a process recycle; still retry the turn.
}
if (interrupted || finishedResult || this.disposed) {
retryPending = false;
return;
}
}
turnIdKnown = false;
buffered.length = 0;
issueTurnStart();
})();
}, CODEX_HOST_SPAWN_RETRY_MS);
retryTimer.unref?.();
return true;
};
// Normalize ONE notification (already confirmed to belong to this turn) into
// canonical AgentEvents. Pulled out so it can be applied to both live and
// buffered (replayed) notifications.
const apply = (msg) => {
// Once ANY terminal result has fired (success turn/completed OR a terminal
// error via emitTerminalError), the turn is done — drop every later
// notification so a racing/buffered turn/completed can't push a SECOND result
// (double-completing PanelAgent's gate) or enqueue deltas into a closing
// iterator. This is the "exactly one result" invariant (P0-B).
if (finishedResult)
return;
if (turnFenced)
return;
// The previous turn died on a retryable host-spawn; wait for the replacement
// turn/start rather than treating its turn/completed as ours (#1929).
if (retryPending)
return;
const params = (msg.params ?? {});
switch (msg.method) {
case "turn/started": {
const t = params.turn;
if (t?.id) {
this.turnId = t.id;
if (!activeTurnId)
activeTurnId = t.id;
}
break;
}
case "item/agentMessage/delta": {
const delta = params.delta;
const itemId = params.itemId;
if (typeof delta === "string" && delta) {
openStream(itemId ?? null, "text");
assistantTextBuffer += delta;
push({ type: "assistant_delta", text: delta });
}
break;
}
case "item/reasoning/textDelta":
case "item/reasoning/summaryTextDelta": {
// Extended-thinking streaming (raw reasoning + the model's summary).
// Open a reasoning stream on the FIRST delta (P2-1) so PanelAgent — which
// drops assistant_delta when no stream is open — renders early thinking.
const delta = params.delta;
const itemId = params.itemId;
if (typeof delta === "string" && delta) {
openStream(itemId ?? null, "thinking");
push({ type: "assistant_delta", text: delta, thinking: true });
}
break;
}
case "item/started": {
// A non-message item began (a tool/command/MCP call or file change). Emit
// a tool_call(start) AgentEvent so the panel has TOOL VISIBILITY (the
// documented P2 gap — Codex previously surfaced no tool activity at all)
// and the watchdog re-arms on a translated event too. agentMessage /
// reasoning items aren't "tools" — they're handled by the delta/commit
// paths above — so skip them here.
const item = params.item;
if (isMcpToolCallItem(item)) {
const itemId = stringFieldOf(item, "id");
if (itemId)
inFlightMcpItemIds.add(itemId);
else
unkeyedMcpItems += 1;
}
const panelTool = panelMcpToolItemOf(item);
if (panelTool) {
const isNewPanelRequest = !panelMcpRequests.has(panelTool.itemId);
let afterArmItemId = null;
if (isNewPanelRequest) {
if (PANEL_OUTER_TRANSPORT_RETRY_ARM_TOOLS.has(panelTool.name)) {
immediatePanelReadAfterArm = null;
panelArmCandidate = { itemId: panelTool.itemId, laterPanelRequestStarted: false };
}
else {
if (panelArmCandidate)
panelArmCandidate.laterPanelRequestStarted = true;
if (immediatePanelReadAfterArm && PANEL_OUTER_TRANSPORT_RETRY_SAFE_TOOLS.has(panelTool.name)) {
afterArmItemId = immediatePanelReadAfterArm;
}
immediatePanelReadAfterArm = null;
}
}
const request = trackPanelMcpItem(panelTool, afterArmItemId);
for (const [originItemId, retry] of panelReadRetries) {
if (request.name === retry.tool &&
(request.requestKeys.some((key) => retry.requestKeys.has(key)) ||
request.retryOf === originItemId)) {
retry.itemIds.add(request.itemId);
for (const key of request.requestKeys)
retry.requestKeys.add(key);
break;
}
}
}
const name = toolNameOf(item);
if (name)
push({ type: "tool_call", name, phase: "start", detail: item });
break;
}
case "item/completed": {
// The authoritative commit for a finished item. Close any open stream
// (reasoning OR reply) then emit the canonical event: `assistant` for an
// agentMessage, or tool_call(end) for a finished tool/command/MCP item.
const item = params.item;
if (isMcpToolCallItem(item)) {
const itemId = stringFieldOf(item, "id");
if (itemId)
inFlightMcpItemIds.delete(itemId);
else if (unkeyedMcpItems > 0)
unkeyedMcpItems -= 1;
}
const itemType = item?.type;
const panelTool = panelMcpToolItemOf(item);
const request = panelTool ? panelMcpRequests.get(panelTool.itemId) : undefined;
if (request) {
request.completed = true;
const completedSuccessfully = item?.status !== "failed" &&
item?.status !== "error" &&
item?.status !== "declined" &&
item?.error == null;
if (PANEL_OUTER_TRANSPORT_RETRY_ARM_TOOLS.has(request.name) &&
panelArmCandidate?.itemId === request.itemId) {
immediatePanelReadAfterArm =
completedSuccessfully && !panelArmCandidate.laterPanelRequestStarted
? request.itemId
: null;
panelArmCandidate = null;
}
const retryMatch = panelReadRetryForRequest(request);
if (retryMatch && request.name === retryMatch.retry.tool) {
const retryMessage = retryMatch.retry.originalMessage;
panelReadRetries.delete(retryMatch.originItemId);
if (!completedSuccessfully) {
finishMcpTransportFailure(retryMessage, false, true);
break;
}
// A successful completion proves that this request's one retry
// produced a tool result; do not queue the control-plane reload
// for the transient error recovered in-band.
if (panelReadRetries.size === 0)
this.mcpTransportRecovery.delete("panel");
}
}
closeStream();
if (itemType === "agentMessage") {
// `item.text` is normally a string, but newer app-server builds can
// send structured content (arrays/objects) that String()-coerces to
// "[object Object]". Route it through the shared serializer, and if it
// still yields nothing readable, fall back to the streamed text the
// user already saw rather than committing an empty/garbage bubble that
// overwrites the good reply (#421, #422).
const committed = messageText(item?.text).trim();
const text = committed || assistantTextBuffer.trim();
const id = item?.id;
push({
type: "assistant",
text,
...(id ? { id } : {}),
// No per-turn rewind anchor for Codex (forkAtAnchor=false) — omit uuid.
});
// Reset for a possible next agentMessage in the same turn so its
// fallback can't inherit this one's streamed text.
assistantTextBuffer = "";
}
else {
const name = toolNameOf(item);
if (name)
push({ type: "tool_call", name, phase: "end", detail: item });
}
break;
}
case "error": {
// App-server uses the same notification for transient provider failures
// and terminal errors. `willRetry: true` means Codex is still owning the
// turn and will continue it after reconnecting; completing our iterator
// here would abort that recovery and make the panel report e.g.
// "Reconnecting... 2/5" as the final failure. `error` is in the turn
// liveness set, so this already re-armed the watchdog — keep waiting
// for either a later terminal error or turn/completed.
const e = params.error ?? {};
const message = formatCodexTurnError(e);
// The app-server's normal willRetry path is useful for provider
// failures, but it is unsafe to allow a WorkerTransport MCP error to
// silently replay a panel mutation. Stop this turn, remember the
// panel transport failure for the turn-boundary reload, and make the
// outcome/reconnect boundary explicit to the caller.
if (this.noteMcpTransportFailure(message)) {
const request = panelMcpRequestForTransportError(params);
const matchedRetry = panelReadRetryForRequest(request);
// Only a correlated terminal error for the armed request is its
// retry outcome. A distinct mutation or unrelated request never
// borrows, clears, or consumes that read's retry.
const retryAttempted = matchedRetry?.retry;
if (!retryAttempted &&
params.willRetry === true &&
request != null &&
request.afterArmItemId != null &&
PANEL_OUTER_TRANSPORT_RETRY_SAFE_TOOLS.has(request.name) &&
appServerConnectionIsActive()) {
// The Codex app-server owns the actual HTTP client. Let its live
// connection perform this one retry, but remember the first error
// so a second failure cannot amplify or replace the diagnosis.
panelReadRetries.set(request.itemId, {
originalMessage: message,
tool: request.name,
itemIds: new Set([request.itemId]),
requestKeys: new Set(request.requestKeys),
});
break;
}
// A transport error for a different request must not consume or
// rewrite a read retry that is already armed. The mutation still
// fences this turn, because its dispatch/outcome is unknown, but
// retain the saved read error in the terminal diagnosis and leave
// its per-item retry state untouched.
const pendingRead = firstPanelReadRetry();
const failureMessage = pendingRead
? `${pendingRead.originalMessage}\nA distinct panel MCP request also failed: ${message}`
: message;
finishMcpTransportFailure(retryAttempted?.originalMessage ?? failureMessage, params.willRetry === true, retryAttempted !== undefined);
break;
}
if (panelReadRetries.size > 0 && params.willRetry !== true) {
const retry = firstPanelReadRetry();
panelReadRetries.clear();
finishMcpTransportFailure(retry?.originalMessage ?? message, false, retry !== undefined);
break;
}
if (params.willRetry === true)
break;
// #1929 / #2045 — a Windows sharing-violation or stale WinGet path on
// the bundled code-mode host is the same transient /restart recovered.
if (tryRetryCodeModeHostSpawn(message))
break;
// A non-retrying `error` ends the turn: emit it AND finish, so a turn that
// errors out (no following turn/completed) doesn't hang (P0-2). Route it
// through the idempotent helper to avoid racing another terminal path.
emitTerminalError(message);
break;
}
case "turn/completed": {
const t = params.turn;
if (panelReadRetries.size > 0 && (t?.status === "interrupted" || interrupted)) {
// User cancellation (including a watchdog timeout that successfully
// interrupts the turn) is not a failed retry. Preserve the normal
// interrupted result and let the existing turn-end recovery observe
// the transport episode on its usual path.
panelReadRetries.clear();
}
else if (panelReadRetries.size > 0) {
const retry = firstPanelReadRetry();
panelReadRetries.clear();
// A turn completion without a successful retry item is the retry's
// terminal outcome. Preserve the original transport error rather
// than reporting a later wrapper or a false success.
finishMcpTransportFailure(retry?.originalMessage ?? "panel MCP read retry failed", false, true);
break;
}
closeStream();
// Mark a result emitted so a racing terminal-error path stays a no-op.
finishedResult = true;
push({ type: "result", ok: t?.status === "completed", ...(t?.status ? { subtype: t.status } : {}) });
finish();
break;
}
default:
break;
}
};
const prev = liveClient.notificationHandler;
handler = (msg) => {
// Until the turnId is known, buffer everything — we can't yet tell which
// turn a notification belongs to, so we can't yet decide whether it counts
// as liveness either. Replayed (and bumped) after turn/start resolves, so
// the watchdog rule lives in exactly one place: bumpTurnActivity.
if (!turnIdKnown) {
buffered.push(msg);
return;
}
bumpTurnActivity(msg);
if (!belongsToTurn(msg)) {
prev?.(msg); // stale / other-turn / other-thread → pass through
return;
}
apply(msg);
};
liveClient.notificationHandler = handler;
// Watch for the app-server child dying mid-turn: reject/finish the turn so the
// local drain below is woken instead of waiting forever (P0-2). Crucially this
// ALWAYS routes through emitTerminalError so even a child that dies while
// turn/start is still pending leaves the turn with a terminal `result` — the
// turn-start .catch() running first (rejecting the pending request) no longer
// lets this watcher finish() without a result and hang the gate (P0-B).
// A stale-host recycle (#2045) replaces liveClient; the old child's exit
// must not finish the replacement turn.
watchExit(liveClient);
// FIRST-TURN PERSONA: the app-server has no thread-level instructions field,
// so the panel system prompt is prepended to the first turn's input as a
// clearly-marked system/context preamble (later turns send plain text).
let turnText = promptText(turn.text);
if (this.needsSystemPreamble && this.deps.systemAppend) {
turnText =
`<system>\n${this.deps.systemAppend}\n</system>\n\n` +
`The user's first message follows.\n\n${turnText}`;
this.needsSystemPreamble = false;
}
// IMAGE DELIVERY (vision parity with Claude): fetch each ComfyUI image ref's
// bytes from /view, spill to a temp file, and add a `localImage` input item
// (which takes a FILE PATH — the app-server's path-based image variant, mirror
// of the codex CLI `-i, --image <FILE>`). The text item stays first so the
// prompt context is preserved; images follow. Falls back to text-only when
// there are no images (or none resolve). The files written for THIS turn are
// tracked locally so the finally block cleans them up after the turn ends.
const turnInput = [
{ type: "text", text: turnText, text_elements: [] },
];
const turnTempFiles = [];
// #1516 — the BYTE budget's point of fact. PanelAgent gates the cumulative
// session budget when it COMPOSES the turn, but it cannot see the bytes of
// the batch already in flight, so this loop is the backstop: an automatic
// preview that would arrive past the conversation's cumulative byte budget
// is NOT attached, and the turn text says so with fetchable coordinates —
// the same correct-the-claim discipline as PanelAgent's drain trim, one
// layer down where the real fetch sizes are known. A user's own attachment
// (no `automatic` flag) is never touched by this budget.
const byteWithheld = [];
for (const ref of turn.images ?? []) {
if (ref?.automatic && this.previewBytes >= MAX_SESSION_PREVIEW_BYTES) {
byteWithheld.push(ref);
continue;
}
const got = await this.fetchImageFile(ref);
if (got) {
turnTempFiles.push(got.path);
turnInput.push({ type: "localImage", path: got.path });
if (ref.automatic)
this.previewBytes += got.bytes;
}
}
if (byteWithheld.length) {
const refs = byteWithheld
.map((i) => {
const type = i.type ?? "output";
const sub = i.subfolder ? `, subfolder:"${i.subfolder}"` : "";
return type === "output" && !i.subfolder
? i.filename
: `${i.filename} (type:"${type}"${sub})`;
})
.join(", ");
turnInput[0].text +=
`\n\n[panel note: ${byteWithheld.length} automatic preview image(s) named above were NOT attached to this turn: ` +
`this conversation's cumulative automatic-preview byte budget (~${previewByteBudgetLabel()}) is spent — the bound that keeps a long session's rollout from growing without limit (every attached preview is retained inline and recopied at compaction). ` +
`The outputs are already shown to the user in the panel — fetch one with get_image action:"get" if you need to look: ${refs}.]`;
logger.info(`[codex] withheld ${byteWithheld.length} automatic preview image(s) at the cumulative byte budget ` +
`(${this.previewBytes}/${MAX_SESSION_PREVIEW_BYTES} bytes delivered to this thread); the turn says so and names them (#1516)`);
}
issueTurnStart = () => {
// turn/start delivers the user text plus any resolved image input items.
const turnModel = this.resolveTurnModel();
liveClient
.request("turn/start", {
threadId,
input: turnInput,
// Same clamp as thread/start|resume — sending the raw pin here let
// a deprecated/unrunnable model bypass the thread-level clamp on
// every turn (codex review on this branch).
model: turnModel,
// Map+snap the session effort against the model THIS turn runs:
// known catalog → the model's own supported list caps it (5.6 keeps
// max/ultra; pre-5.6 snaps down); unknown catalog → xhigh cap.
effort: toCodexEffort(this.effort ?? undefined, this.liveCatalog?.find((m) => m.id === turnModel)?.supportedEffortLevels),
outputSchema: null,
})
.then((res) => {
retryPending = false;
// Set the active turn id, flush the buffer (replaying only this turn's
// notifications), then switch the handler to live filtering.
if (res.turn?.id) {
this.turnId = res.turn.id;
activeTurnId = res.turn.id;
}
turnIdKnown = true;
for (const msg of buffered) {
// Same watchdog rule as the live path: a buffered item/*, turn/* or
// error for this turn counts as liveness now that we can attribute it.
bumpTurnActivity(msg);
if (belongsToTurn(msg))
apply(msg);
else
prev?.(msg);
}
buffered.length = 0;
})
.catch((err) => {
// A failed turn/start (or an interrupt rejecting it) ends the turn. Also
// mark the id known so any post-failure notifications stop buffering.
turnIdKnown = true;
// CRITICAL (P0-B): when the child dies mid-turn, handleExit() rejects this
// pending request BEFORE resolving exitPromise — so this .catch() runs
// first. It MUST end the turn with a terminal `result` (not just an
// `error` + bare finish()), or the exit watcher then sees done===true and
// returns without one, hanging PanelAgent's gate forever. Route through
// the idempotent helper so it always emits exactly one result.
if (interrupted) {
retryPending = false;
// Deliberate teardown (interrupt restored/closed the turn): still end
// with a result so the gate advances, but no user-facing error.
abortActiveTurn();
}
else if (turnFenced) {
// The fenced MCP recovery owns terminalization. A late
// turn/start rejection must not race it with a generic error.
return;
}
else {
const message = formatCodexTurnError(err);
if (tryRetryCodeModeHostSpawn(message)) {
// retryPending is now true; the dead turn's result is deferred.
}
else {
retryPending = false;
emitTerminalError(message);
}
}
});
};
try {
issueTurnStart();
// Drain the bridged queue until the turn completes.
while (true) {
while (queue.length) {
yield queue.shift();
}
if (done)
break;
await new Promise((resolve) => {
wake = resolve;
});
}
// Flush any trailing events queued between the last drain and done.
while (queue.length)
yield queue.shift();
}
finally {
if (retryTimer) {
clearTimeout(retryTimer);
retryTimer = undefined;
}
retryPending = false;
// Mark interrupted so a late turn/start rejection / exit doesn't surface as
// a spurious error after we've already torn the turn down.
interrupted = true;
if (this.abortActiveTurn === abortActiveTurn)
this.abortActiveTurn = null;
// Restore the prior handler ONLY if it's still ours (close() may have nulled
// it during shutdown — don't resurrect a stale handler onto a dead client).
if (liveClient.notificationHandler && !liveClient.exitError) {
liveClient.notificationHandler = prev ?? null;
}
this.turnId = null;
// #1152 — the app-server does NOT necessarily read a localImage at
// turn/start, which is what this used to assume ("the bytes were read at
// turn/start"). A reporter saw the panel render a storyboard while the
// Codex turn reported the file gone:
//
// Codex could not read the local image at …\comfyui-codex-<pid>-…png:
// The system cannot find the file specified. (os error 2)
//
// Deleting at turn teardown therefore races a read that has not happened
// yet. Hold the files for a grace period instead: the cost of keeping a few
// PNGs in the OS temp dir for minutes is nothing next to an image the agent
// cannot see.
//
// The files stay in `tempImageFiles`, so close() still sweeps them — a
// pending timer is a delay, never the only thing standing between us and a
// leak. The timer is unref'd (it must not hold the process open) and
// tracked so close() can clear it.
if (turnTempFiles.length)
this.scheduleTempImageCleanup(turnTempFiles);
}
}
/** Stop the current turn without ending the thread → `turn/interrupt`. */
async interrupt() {
const client = this.client;
if (!client || !this.threadId || !this.turnId)
return;
let timer;
let timedOut = false;
try {
await Promise.race([
client.request("turn/interrupt", { threadId: this.threadId, turnId: this.turnId }),
new Promise((_, reject) => {
timer = setTimeout(() => {
timedOut = true;
reject(new Error(`turn/interrupt timed out after ${CODEX_INTERRUPT_TIMEOUT_MS}ms`));
}, CODEX_INTERRUPT_TIMEOUT_MS);
}),
]);
}
catch (err) {
if (timedOut) {
logger.warn(`[codex-backend] interrupt timed out after ${CODEX_INTERRUPT_TIMEOUT_MS}ms -- recycling app-server`);
if (this.client === client) {
this.client = null;
this.threadId = null;
this.turnId = null;
this.abortActiveTurn?.();
}
void this.beginClientClose(client, "interrupt recycle teardown failed");
return;
}
logger.debug(`[codex-backend] interrupt: ${msgOf(err)}`);
}
finally {
if (timer)
clearTimeout(timer);
}
}
/**
* Tell Codex that the harness, not the user, observed a stalled turn. The
* app-server's `turn/interrupt` schema deliberately has no reason field and
* reports a pending tool interruption with its generic user-rejection text.
* `turn/steer` is the protocol operation for injecting an explicit message
* into the active turn, so it preserves the distinction for the agent.
*
* Older app-servers can reject `turn/steer`; return false so PanelAgent falls
* back to its existing bounded interrupt/restart recovery rather than wedging.
*/
async recoverStalledTurn(notice) {
const client = this.client;
const threadId = this.threadId;
const turnId = this.turnId;
if (!client || !threadId || !turnId || !notice.trim())
return false;
let timer;
let timedOut = false;
try {
await Promise.race([
client.request("turn/steer", {
threadId,
expectedTurnId: turnId,
input: [{ type: "text", text: notice }],
}),
new Promise((_, reject) => {
timer = setTimeout(() => {
timedOut = true;
reject(new Error(`turn/steer timed out after ${CODEX_STALL_STEER_TIMEOUT_MS}ms`));
}, CODEX_STALL_STEER_TIMEOUT_MS);
}),
]);
return true;
}
catch (err) {
logger.debug(`[codex-backend] stalled-turn steer ${timedOut ? "timed out" : "unavailable"}: ${msgOf(err)}`);
return false;
}
finally {
if (timer)
clearTimeout(timer);
}
}
/**
* Codex model enumeration — LIVE via the app-server's `model/list` (codex-cli
* ≥ ~0.14x), which is ACCOUNT-AWARE: a ChatGPT-plan login only sees models that
* plan can actually run. The old static list advertised ids the account
* couldn't use (e.g. "gpt-5.5-codex" on a ChatGPT account), so picking one
* 400'd every turn ("The 'gpt-5.5-codex' model is not supported when using
* Codex with a ChatGPT account" — Discord #help). Falls back to the static
* family only when the RPC is unavailable (older CLI) or errors.
*/
async listModels() {
try {
await this.prepare();
const client = this.client;
if (client) {
const res = await client.request("model/list", {});
const visible = [];
const live = [];
for (const m of res?.data ?? []) {
const id = codexCatalogModelId(m);
if (!id || m.hidden === true)
continue;
visible.push(m);
const efforts = (m.supportedReasoningEfforts ?? [])
.map((e) => e.reasoningEffort)
.filter((e) => typeof e === "string" && CODEX_EFFORT_LEVELS.includes(e));
live.push({
id,
...(m.displayName ? { label: m.displayName } : {}),
// Every Codex ModelChoice MUST advertise effort support so the
// panel enables the reasoning dropdown (the backend applies
// effort to every turn regardless). Prefer the model's own list.
supportsEffort: true,
supportedEffortLevels: efforts.length ? efforts : [...CODEX_EFFORT_LEVELS],
});
}
if (live.length) {
// liveCatalog keeps the FULL account catalog: it answers "can this
// account catalog lists model X" for resolveTurnModel's clamp. The PICKER gets
// the deprecation-filtered view below — hiding a model from new
// picks must not force-switch an existing pin/resume that the
// account can still legally run (codex review on this branch).
this.liveCatalog = live;
// Filter only catalog-marked legacy entries. Model names never
// determine which current or future families the picker offers.
const picker = filterCodexPickerEntries(visible);
const pickerIds = new Set(picker.map((m) => codexCatalogModelId(m)).filter((id) => !!id));
const fam = live.filter((m) => pickerIds.has(m.id));
if (fam.length && fam.length < live.length) {
logger.debug(`[codex-backend] hiding ${live.length - fam.length} deprecated model(s) from the picker`);
}
return fam.length ? fam : live;
}
}
}
catch (err) {
logger.debug(`[codex-backend] model/list unavailable (older CLI?): ${msgOf(err)} — using static fallback`);
}
return CODEX_FALLBACK_MODELS;
}
/** The model to pass to thread/start|resume, CLAMPED to the live catalog.
* Passing null defers to the user's ~/.codex config default — which can name
* a model the RESOLVED binary or the account can't run (e.g. a newer CLI
* wrote `gpt-5.6-sol` into config, our optional-dep binary is older → every
* turn 400s "requires a newer version of Codex"). If we know the catalog and
* the requested/configured model isn't in it, use the catalog's first entry
* and say so in the log. Without a catalog, behave as before. */
resolveTurnModel() {
const cat = this.liveCatalog;
if (!cat || !cat.length)
return this.model ?? null;
const wanted = this.model;
if (wanted && cat.some((m) => m.id === wanted))
return wanted;
const fallback = cat[0].id;
logger.warn(`[codex-backend] model ${wanted ?? "(config default)"} is not in the account's live catalog — using ${fallback}`);
return fallback;
}
/** Permanently dispose of the backend (AgentBackend.close): kill the app-server
* process TREE (Windows shell-fallback grandchild included), remove listeners,
* null the client. Called by PanelAgent.stop() and every agent-replacement path
* (reset / effort restart / stopAll / shutdown). Idempotent + safe when never
* prepared (client is null). Without this the codex app-server child is orphaned
* because interrupt() is a no-op when the turn is idle (P0-1). */
async close() {
// Tripwire FIRST: an in-flight prepare() re-checks this after each await and
// disposes its local client rather than publishing it (P0-A).
this.disposed = true;
const client = this.client;
// Also tear down any client a concurrent prepare() is mid-spawn on but hasn't
// published yet — without this, close() would return while that child is still
// coming up and orphan it (P0-A).
const preparing = this.preparingClient;
const closing = [...this.closingClients.keys()];
const abortActiveTurn = this.abortActiveTurn;
this.client = null;
this.preparingClient = null;
this.threadId = null;
this.turnId = null;
abortActiveTurn?.();
if (this.abortActiveTurn === abortActiveTurn)
this.abortActiveTurn = null;
const clients = new Set();
for (const candidate of [...closing, client, preparing]) {
if (candidate)
clients.add(candidate);
}
for (const candidate of clients)
candidate.notificationHandler = null;
await Promise.all([...clients].map((candidate) => this.beginClientClose(candidate, "backend close teardown failed")));
// #1152 — cancel the grace timers first. Their files are swept right below,
// so leaving them armed would only re-unlink paths that are already gone.
for (const t of this.tempImageTimers)
clearTimeout(t);
this.tempImageTimers.clear();
// Sweep any temp image files a turn didn't get to clean up (e.g. close() raced
// an in-flight turn, or a grace window was still open). Snapshot first —
// cleanupTempImages mutates the set.
if (this.tempImageFiles.size) {
await this.cleanupTempImages([...this.tempImageFiles]).catch(() => { });
}
}
}
//# sourceMappingURL=codex-backend.js.map