UNPKG

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
// 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