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),

1,066 lines • 52.6 kB
// Google Gemini backend — the provider-specific adapter behind the AgentBackend
// port, driving the Gemini CLI over its **ACP (Agent Client Protocol)** mode
// (`gemini --acp`), a JSON-RPC 2.0 client over stdio. This is a faithful MIRROR
// of codex-backend.ts (the Codex app-server adapter): same self-contained
// line-framed JSON-RPC client, same per-turn event-queue bridge, same
// terminal-result invariant, same Windows/POSIX process-tree kill. Only the
// wire protocol differs (ACP instead of the codex app-server protocol).
//
// 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 (AgentBackend ↔ ACP, per agentclientprotocol.com + the
// Gemini CLI docs/cli/acp-mode.md):
//   - prepare()           = spawn `gemini --acp` + `initialize` handshake
//   - session             = an ACP SESSION (`session/new` new | `session/load` resume)
//   - run() loop turn     = `session/prompt` (ONE request per neutral channel batch);
//                           the request RESOLVES with a `stopReason` at turn end
//                           (unlike Codex, where turn/start returns immediately
//                           and a separate turn/completed notification ends it).
//                           Images ride inline as base64 `image` ContentBlocks.
//   - assistant_delta     ← `session/update` { update.sessionUpdate:"agent_message_chunk",
//                            content:{type:"text",text} }
//   - assistant_delta(th) ← `session/update` { ...:"agent_thought_chunk", ... } (thinking)
//   - assistant (commit)  ← the accumulated agent_message_chunk text, emitted once
//                            when the session/prompt request resolves (ACP has no
//                            separate "final message" notification — the chunks ARE
//                            the message; the prompt response is the turn boundary)
//   - tool_call(start)    ← `session/update` { ...:"tool_call", toolCallId, title, kind }
//   - tool_call(end)      ← `session/update` { ...:"tool_call_update", status:
//                            "completed"|"failed" }
//   - result              ← the `session/prompt` response { stopReason }
//   - error               ← a failed `session/prompt` / the child dying mid-turn
//   - interrupt()         → `session/cancel` (notification); the in-flight prompt
//                            then resolves with stopReason:"cancelled"
//   - listModels()        ← a static catalog (gemini-2.5-pro / -flash) — ACP exposes
//                            no model enumeration; the model is selected at SPAWN via
//                            the CLI `--model` flag (see resolveBin/spawn below)
//
// AUTH (the CLI owns auth): Gemini CLI authenticates either via a Gemini API key
// (USE_GEMINI — read from GEMINI_API_KEY, which we forward through the spawned
// process.env) or via Google OAuth / Code Assist (paid/enterprise only — the free
// individual login was retired 2026-06-18). If the CLI is unauthenticated,
// `session/new` returns an `auth_required` error — we then call `authenticate`
// with the API-key method when GEMINI_API_KEY is set (see pickAuthMethod), else
// the first advertised method, and on failure surface a clear message pointing at
// the GEMINI_API_KEY path. API-key auth completes headlessly; the OAuth browser
// flow does not and must be done once by running `gemini` directly.
//
// PARITY with Codex/Claude: the Gemini backend gets the SAME tool surface — the
// headless `comfyui` stdio MCP plus the `panel` HTTP MCP for live-graph panel_*
// tools — declared to `session/new` as ACP McpServers. The panel system prompt
// is prepended to the FIRST turn's prompt (ACP `session/new` has no system /
// instructions field, mirroring the Codex app-server's thread/start).
//
// ASSUMPTIONS we could NOT verify without the live `gemini` CLI (flagged inline,
// see also the PR body): the exact ACP McpServer http variant shape, the
// session/load resume semantics, the auth_required retry, and live model
// switching (we set the model at spawn via --model since ACP exposes no standard
// per-session model setter). Each is the closest faithful mapping to the
// documented ACP spec.
import { spawn, spawnSync } from "node:child_process";
import { createRequire } from "node:module";
import readline from "node:readline";
import { logger } from "../utils/logger.js";
import { errorText, promptText } from "./error-text.js";
import { buildAgentSpawnEnv } from "../services/panel-secrets.js";
import { GEMINI_CAPABILITIES, stampTurn, } from "./agent-backend.js";
function msgOf(err) {
    return errorText(err);
}
/**
 * Kill an entire process tree, not just the direct child. On the Windows
 * PATH/shell fallback the direct child is a cmd.exe/`.cmd` shim whose grandchild
 * is the real `gemini` node process — killing only the shell leaves the tree
 * alive. Use `taskkill /T /F`. 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.
 * (Identical to codex-backend's killProcessTree — same spawn posture.)
 */
function killProcessTree(pid) {
    if (!Number.isFinite(pid))
        return;
    const p = pid;
    if (process.platform === "win32") {
        try {
            spawnSync("taskkill", ["/PID", String(p), "/T", "/F"], { windowsHide: true });
        }
        catch {
            try {
                process.kill(p);
            }
            catch {
                // already gone
            }
        }
        return;
    }
    try {
        process.kill(-p, "SIGTERM"); // process group (we spawn detached on POSIX)
    }
    catch {
        try {
            process.kill(p, "SIGTERM");
        }
        catch {
            // already gone
        }
    }
}
/** An Error carrying the JSON-RPC error `data` so the auth_required reason
 *  survives the request rejection (used to drive the authenticate retry). */
class RpcError extends Error {
    code;
    data;
    constructor(message, code, data) {
        super(message);
        this.code = code;
        this.data = data;
    }
}
class AcpClient {
    cmd;
    args;
    cwd;
    env;
    useShell;
    proc = null;
    rl = null;
    pending = new Map();
    nextId = 1;
    closed = false;
    exitResolved = false;
    /** The error that ended the connection (null = clean exit). */
    exitError = null;
    stderr = "";
    notificationHandler = null;
    resolveExit;
    /** Resolves when the `gemini` process exits or errors — runTurn() races its
     *  per-turn drain against this so a child that dies mid-prompt never deadlocks
     *  the turn forever (mirrors codex P0-2). */
    exitPromise;
    constructor(cmd, args, cwd, env, useShell) {
        this.cmd = cmd;
        this.args = args;
        this.cwd = cwd;
        this.env = env;
        this.useShell = useShell;
        this.exitPromise = new Promise((resolve) => {
            this.resolveExit = resolve;
        });
    }
    /** Spawn `gemini --acp` and perform the ACP `initialize` handshake. Returns the
     *  agent's initialize result (capabilities + authMethods). NOTE: ACP has NO
     *  `initialized` notification (that's MCP, not ACP) — initialize is a plain
     *  request/response, after which we go straight to session/new. */
    async initialize(clientInfo) {
        this.proc = spawn(this.cmd, this.args, {
            cwd: this.cwd,
            env: this.env,
            stdio: ["pipe", "pipe", "pipe"],
            windowsHide: true,
            shell: this.useShell,
            // POSIX: own process group so close() can kill the whole tree with one
            // negative-pid signal. Windows uses taskkill /T instead.
            detached: process.platform !== "win32",
        });
        this.proc.stdout.setEncoding("utf8");
        this.proc.stderr.setEncoding("utf8");
        this.proc.stderr.on("data", (chunk) => {
            this.stderr += chunk;
        });
        // Route pipe errors (EPIPE when the child dies mid-turn) through handleExit so
        // the turn rejects cleanly instead of crashing the host as an uncaught error.
        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(`gemini --acp 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));
        // ACP initialize: negotiate protocol version + advertise client capabilities.
        // We do NOT implement the client fs/terminal methods, so advertise them false
        // (the agent then won't issue fs/read_text_file, terminal/*, etc.).
        const result = await this.request("initialize", {
            protocolVersion: 1,
            clientCapabilities: {
                fs: { readTextFile: false, writeTextFile: false },
                terminal: false,
            },
            clientInfo,
        });
        return result;
    }
    request(method, params) {
        if (this.closed)
            return Promise.reject(new Error("gemini --acp 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.
        try {
            this.send({ method, params });
        }
        catch {
            // connection gone; pending requests already rejected via handleExit
        }
    }
    send(message) {
        const stdin = this.proc?.stdin;
        if (!stdin || stdin.destroyed || stdin.writableEnded) {
            this.handleExit(this.exitError ?? new Error("gemini --acp stdin is not available."));
            throw this.exitError ?? new Error("gemini --acp stdin is not available.");
        }
        // ACP is strict JSON-RPC 2.0 — every outbound frame carries `jsonrpc:"2.0"`.
        const framed = { jsonrpc: "2.0", ...message };
        try {
            stdin.write(`${JSON.stringify(framed)}\n`);
        }
        catch (err) {
            this.handleExit(err instanceof Error ? err : new Error(String(err)));
            throw err;
        }
    }
    /**
     * The auto-approve RESULT for a server→client request, or null if it isn't one
     * we should auto-grant. The panel agent is an ISOLATED background agent (same
     * posture as Claude's bypassPermissions / Codex's auto-approve), so we grant
     * tool-permission requests to keep the live-graph work flowing.
     *
     * ACP permission flow: the agent sends `session/request_permission`
     * ({ sessionId, toolCall, options:[{ optionId, name, kind }] }) and expects
     * { outcome: { outcome:"selected", optionId } }. We pick the most-permissive
     * "allow" option (allow_always > allow_once); if none is offered we cancel.
     */
    autoApproveResult(msg) {
        if (msg.method !== "session/request_permission")
            return null;
        const params = (msg.params ?? {});
        const options = Array.isArray(params.options) ? params.options : [];
        const pick = options.find((o) => o.kind === "allow_always") ??
            options.find((o) => o.kind === "allow_once") ??
            // Fall back to any option whose id/kind reads as an allow.
            options.find((o) => /allow/i.test(o.kind ?? "") || /allow/i.test(o.optionId ?? ""));
        if (pick?.optionId) {
            return { outcome: { outcome: "selected", optionId: pick.optionId } };
        }
        // No allow option offered → decline gracefully so the agent moves on.
        return { outcome: { outcome: "cancelled" } };
    }
    handleLine(line) {
        if (!line.trim())
            return;
        let message;
        try {
            message = JSON.parse(line);
        }
        catch (error) {
            this.handleExit(new Error(`Failed to parse gemini --acp JSONL: ${msgOf(error)}`));
            return;
        }
        // Server→client request (id + method). Auto-approve permission prompts; reply
        // method-not-found to anything else so the protocol keeps moving (we declared
        // no fs/terminal client capabilities, so those shouldn't arrive).
        if (message.id !== undefined && message.method) {
            const result = this.autoApproveResult(message);
            if (result) {
                logger.debug(`[gemini-backend] auto-approving server request ${message.method}`);
                this.send({ id: message.id, result });
            }
            else {
                logger.debug(`[gemini-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) {
                p.reject(new RpcError(message.error.message ?? `gemini --acp ${p.method} failed.`, message.error.code, message.error.data));
            }
            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("gemini --acp connection closed."));
        this.pending.clear();
        this.resolveExit();
    }
    async close() {
        if (this.closed) {
            await this.exitPromise;
            return;
        }
        this.closed = true;
        this.notificationHandler = null;
        this.rl?.close();
        this.rl = null;
        if (this.proc && this.proc.exitCode === null) {
            try {
                this.proc.stdin.end();
            }
            catch {
                // already gone
            }
            const proc = this.proc;
            // Give a graceful stdin-EOF shutdown a beat, then KILL THE WHOLE TREE — on
            // the Windows shell fallback the direct child is a shim whose grandchild is
            // the real gemini node process, so proc.kill() alone would orphan it.
            setTimeout(() => {
                if (proc.exitCode === null)
                    killProcessTree(proc.pid);
            }, 50).unref?.();
        }
        await this.exitPromise;
        this.proc = null;
    }
}
// ---- model catalog ----
// ACP exposes no model enumeration, and the model is fixed at SPAWN via the CLI
// `--model` flag — so we surface a static catalog of the current Gemini family.
// Gemini's "thinking" is a token BUDGET, not a discrete effort scale, so we do
// NOT advertise supportsEffort/supportedEffortLevels: the panel's normalizeModels
// then hides the effort dropdown (omission is the documented "no effort control"
// signal).
//
// PIN THE FLOATING ALIASES FIRST. A static catalog rots: as of 2026-07-20 Google
// returns 404 "no longer available to new users" for BOTH former entries
// (gemini-2.5-pro AND gemini-2.5-flash) on a newly-issued API key, so the old
// default was dead on arrival for every new user. `gemini-pro-latest` /
// `gemini-flash-latest` are Google's floating aliases (verified resolving to
// gemini-3.1-pro-preview / gemini-3.5-flash) and keep tracking the current
// generation without another code change. The pinned ids below are the concrete
// models those aliases resolved to, kept for reproducibility.
// Verified against generativelanguage.googleapis.com on 2026-07-20: the four
// entries below return 200; gemini-2.5-pro / gemini-2.5-flash / gemini-3-pro-preview
// return 404.
const GEMINI_MODELS = [
    { id: "gemini-pro-latest", label: "Gemini Pro (latest)" },
    { id: "gemini-flash-latest", label: "Gemini Flash (latest)" },
    { id: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro (preview)" },
    { id: "gemini-3.5-flash", label: "Gemini 3.5 Flash" },
];
const GEMINI_DEFAULT_MODEL = "gemini-pro-latest";
/** Does this id look like a Gemini model (vs. the Claude panel model PanelAgent
 *  unconditionally passes as opts.model)? Used so the configured Gemini model
 *  wins — mirrors codex-backend's isCodexModel guard (P1-1). */
function isGeminiModel(id) {
    return /^gemini[-/]/i.test(id) || id.toLowerCase().startsWith("models/gemini");
}
/**
 * Convert our MCP server specs into the ACP `session/new` `mcpServers` array.
 * ACP stdio McpServer: { name, command, args, env:[{name,value}] }. Streamable
 * HTTP MCP rides the SSE variant ({ type:"sse", name, url, headers:[] }) — the
 * live Gemini (and Grok) CLIs reject { type:"http" } with Invalid params on the
 * FIRST user message, after a successful connect ack, so the old "http" mapping
 * made a panel-MCP-attached tab look connected but fail on first use.
 */
export function buildAcpMcpServers(servers) {
    const out = [];
    for (const [name, spec] of Object.entries(servers)) {
        if (spec.transport === "stdio") {
            out.push({
                name,
                command: spec.command,
                args: spec.args ?? [],
                env: Object.entries(spec.env ?? {}).map(([k, v]) => ({ name: k, value: v })),
            });
        }
        else {
            out.push({ type: "sse", name, url: spec.url, headers: [] });
        }
    }
    return out;
}
/**
 * The Gemini CLI ACP adapter. One instance per PanelAgent; it holds the live ACP
 * client + current session id and re-opens on each `run()`.
 */
export class GeminiBackend {
    id = "gemini";
    capabilities = GEMINI_CAPABILITIES;
    deps;
    client = null;
    /** The client an in-flight prepare() is spinning up, tracked so a concurrent
     *  close() can tear it down before it's published (P0-A). */
    preparingClient = null;
    /** Set once close() runs — a tripwire so an in-flight prepare() disposes its
     *  local client instead of publishing it (P0-A). */
    disposed = false;
    /** Cached resolved spawn command/args/shell (set in prepare()). */
    spawnSpec = null;
    /** The live ACP session id — used for session/prompt + session/cancel. */
    sessionId = null;
    /** The model requested for new sessions (applied at SPAWN via --model). */
    model;
    /** The model the LIVE `gemini --acp` child was actually spawned with. Gemini
     *  pins the model at spawn, so when this drifts from `this.model` (a live
     *  setModel) the run loop respawns the CLI before the next turn (P1). */
    spawnedModel;
    /** Capabilities the agent advertised at initialize (loadSession / image / http). */
    agentCaps = undefined;
    authMethods = [];
    /** True until the panel system prompt has been prepended to a turn. Reset
     *  whenever a NEW session starts (run()). */
    needsSystemPreamble = false;
    constructor(deps = {}) {
        this.deps = deps;
        this.model = deps.model;
    }
    /**
     * Resolve how to spawn `gemini --acp`. Prefer the bundled `@google/gemini-cli`
     * launcher (via require.resolve of its package bin) so no separate install is
     * needed; fall back to a `gemini` on PATH. The `--model` flag pins the model at
     * spawn (ACP has no standard per-session model setter). Mirrors codex-backend's
     * resolveBin + the Windows `.cmd`/`.ps1` shim handling.
     */
    resolveSpawn() {
        if (this.spawnSpec)
            return this.spawnSpec;
        let bin = null;
        try {
            const require = createRequire(import.meta.url);
            const pkgPath = require.resolve("@google/gemini-cli/package.json");
            const pkg = require("@google/gemini-cli/package.json");
            const binRel = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.gemini;
            if (binRel) {
                const sep = pkgPath.includes("\\") ? "\\" : "/";
                const pkgDir = pkgPath.replace(/[\\/]package\.json$/, "");
                bin = `${pkgDir}${sep}${binRel.replace(/^\.[\\/]/, "")}`;
            }
        }
        catch {
            // bundled package not installed — fall through to PATH.
        }
        const isJs = !!bin && /\.(c|m)?js$/i.test(bin);
        // The bundled bin is a node launcher script → run it via the current node so
        // we don't depend on a `gemini` shim being on PATH. A PATH `gemini` on Windows
        // resolves to a `.cmd`/`.ps1` shim, which spawn can't find without a shell.
        const cmd = isJs ? process.execPath : bin ?? "gemini";
        const modelArgs = this.model ? ["--model", this.model] : [];
        const args = isJs ? [bin, "--acp", ...modelArgs] : ["--acp", ...modelArgs];
        const useShell = !isJs && process.platform === "win32";
        this.spawnSpec = { cmd, args, useShell };
        return this.spawnSpec;
    }
    /**
     * Fetch a ComfyUI image (/view) and return it as an ACP base64 `image`
     * ContentBlock ({ type:"image", mimeType, data }) — or null on any failure (the
     * text reference still names the image as a fallback). Unlike Codex (which
     * spills to a temp file for its path-based localImage item), ACP takes inline
     * base64, so this mirrors ClaudeBackend.fetchImageBlock exactly (only the key
     * names differ: ACP uses `mimeType`/`data`).
     */
    async fetchImageBlock(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;
            let mt = (res.headers.get("content-type") || "").split(";")[0].trim().toLowerCase();
            if (!["image/png", "image/jpeg", "image/gif", "image/webp"].includes(mt)) {
                mt = "image/png"; // ComfyUI outputs are PNG by default
            }
            const buf = Buffer.from(await res.arrayBuffer());
            if (buf.length > 12 * 1024 * 1024)
                return null; // keep context sane (parity with Claude)
            return { type: "image", mimeType: mt, data: buf.toString("base64") };
        }
        catch {
            return null;
        }
    }
    /**
     * Preflight: resolve + spawn `gemini --acp` and perform the ACP `initialize`
     * handshake. Fails fast with a clear reject so a missing binary surfaces
     * immediately instead of being retried as a dropped session. Idempotent —
     * reuses the live client. NOTE: the actual login (Google OAuth) is verified
     * lazily at `session/new` (ACP returns auth_required there) — see run() — since
     * ACP has no pre-session account probe; flagged in the PR body.
     */
    async prepare() {
        if (this.disposed)
            throw new Error("gemini backend is closed.");
        if (this.client)
            return;
        const { cmd, args, useShell } = this.resolveSpawn();
        const cwd = this.deps.cwd ?? process.cwd();
        // SECURITY: spawn with the agent env — process.env MINUS tool-only secrets
        // (RunPod/HF/CivitAI… tokens; they belong only to the comfyui tool child).
        // The GEMINI/GOOGLE keys are kept: they are this provider's OWN credential —
        // the CLI authenticates with GEMINI_API_KEY from its spawn env (see run()).
        const client = new AcpClient(cmd, args, cwd, buildAgentSpawnEnv(process.env, {
            keep: ["GEMINI_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY", "GOOGLE_API_KEY"],
        }), useShell);
        // Publish the in-flight client BEFORE the startup awaits so a concurrent
        // close() can find and kill it (P0-A).
        this.preparingClient = client;
        const abortIfDisposed = async () => {
            if (!this.disposed)
                return;
            if (this.preparingClient === client)
                this.preparingClient = null;
            await client.close().catch(() => { });
            throw new Error("gemini backend was closed during prepare().");
        };
        try {
            let init;
            try {
                init = await client.initialize({
                    name: "comfyui-mcp",
                    title: "comfyui-mcp panel",
                    version: "0.16.0",
                });
            }
            catch (err) {
                await client.close().catch(() => { });
                throw new Error(`Could not start the Gemini CLI in ACP mode (gemini backend). Install the Gemini CLI (npm i -g @google/gemini-cli, or ensure \`gemini\` is on PATH) and sign in with \`gemini\`. Details: ${msgOf(err)}`);
            }
            await abortIfDisposed();
            this.agentCaps = init.agentCapabilities;
            this.authMethods = Array.isArray(init.authMethods) ? init.authMethods : [];
            this.client = client;
            // Record what the live child was spawned with so a later setModel can detect
            // the model drifted and respawn (the model is spawn-pinned via --model) (P1).
            this.spawnedModel = this.model;
            logger.info(`[gemini-backend] ACP ready (protocol ${init.protocolVersion ?? "?"}, agent ${init.agentInfo?.name ?? "gemini"}${this.authMethods.length ? `, ${this.authMethods.length} auth method(s)` : ""})`);
        }
        finally {
            if (this.preparingClient === client)
                this.preparingClient = null;
        }
    }
    /** Ensure a live ACP session exists, creating (session/new) or resuming
     *  (session/load) one. Handles an `auth_required` error from session/new by
     *  attempting a single `authenticate` with the API-key method when GEMINI_API_KEY
     *  is set (else the first advertised method — see pickAuthMethod), then retrying
     *  — surfacing a clear sign-in message if it still fails. Returns the session
     *  id. */
    async ensureSession(client, cwd, resumeId) {
        const mcpServers = this.deps.mcpServers ? buildAcpMcpServers(this.deps.mcpServers) : [];
        const canLoad = this.agentCaps?.loadSession === true;
        // RESUME (session/load) — whole-session only (forkAtAnchor=false). Only if the
        // agent advertised loadSession; otherwise fall through to a fresh session.
        if (resumeId && canLoad) {
            try {
                await client.request("session/load", { sessionId: resumeId, cwd, mcpServers });
                this.sessionId = resumeId;
                this.needsSystemPreamble = false; // persona already delivered on the original first turn
                return resumeId;
            }
            catch (err) {
                logger.warn(`[gemini-backend] session/load failed (${msgOf(err)}) — starting a fresh session`);
            }
        }
        // NEW session, with one auth_required retry.
        const createNew = async () => {
            const res = await client.request("session/new", { cwd, mcpServers });
            if (!res?.sessionId)
                throw new Error("gemini --acp session/new returned no sessionId.");
            return res.sessionId;
        };
        // PROACTIVE API-key auth. When GEMINI_API_KEY is set we authenticate up front
        // rather than only reacting to `auth_required`. Why: a CLI that was previously
        // signed in keeps `security.auth.selectedType: "oauth-personal"` + cached OAuth
        // creds, which OUTRANK GEMINI_API_KEY — so `session/new` SUCCEEDS via the (now
        // dead, post-2026-06-18) individual OAuth without ever raising `auth_required`,
        // then fails at request time ("session keeps ending"). Authenticating with the
        // API-key method here switches the CLI to USE_GEMINI and clears the stale creds
        // (the ACP `authenticate` handler drops cached creds when the method changes),
        // so the reactive retry below only matters for a never-signed-in CLI.
        if (process.env.GEMINI_API_KEY?.trim()) {
            const apiKeyMethod = this.pickAuthMethod();
            if (apiKeyMethod) {
                try {
                    await client.request("authenticate", { methodId: apiKeyMethod });
                }
                catch (err) {
                    logger.warn(`[gemini-backend] proactive API-key authenticate failed (${msgOf(err)}) — continuing to session/new`);
                }
            }
        }
        try {
            this.sessionId = await createNew();
        }
        catch (err) {
            if (!this.isAuthRequired(err))
                throw err;
            // The CLI owns auth. The free "Sign in with Google" / Code-Assist login for
            // individuals was retired 2026-06-18; API keys (USE_GEMINI) still work. Pick
            // the API-key auth method when GEMINI_API_KEY is set rather than blindly the
            // first advertised method (OAuth — now a dead loop for individuals). The key
            // itself already reaches the CLI via the spawned process.env.
            const methodId = this.pickAuthMethod();
            if (!methodId)
                throw new Error(this.authFailureMessage());
            try {
                await client.request("authenticate", { methodId });
                this.sessionId = await createNew();
            }
            catch {
                throw new Error(this.authFailureMessage());
            }
        }
        this.needsSystemPreamble = !!this.deps.systemAppend; // fresh session → persona on first turn
        return this.sessionId;
    }
    /** Choose the ACP auth method for the `authenticate` retry. Prefers the Gemini
     *  API-key method (USE_GEMINI) when GEMINI_API_KEY is set — the free Google /
     *  Code-Assist OAuth login for individuals was retired 2026-06-18, so blindly
     *  taking authMethods[0] (OAuth) now dead-loops. Falls back to the first
     *  advertised method when no key is set or no API-key method is advertised. The
     *  methodId is the CLI's AuthType value (e.g. "gemini-api-key"); we match against
     *  the CLI's own advertised methods rather than hardcode the literal, so this
     *  survives AuthType value changes across gemini-cli versions. */
    pickAuthMethod() {
        const methods = this.authMethods;
        if (methods.length === 0)
            return undefined;
        if (process.env.GEMINI_API_KEY?.trim()) {
            const hay = (m) => `${m.id ?? ""} ${m.name ?? ""} ${m.description ?? ""}`.toLowerCase();
            // Strong match: the canonical AuthType value, or a method naming BOTH gemini
            // and api-key (so a Vertex/OAuth method that merely mentions "api key" in its
            // blurb can't win).
            const strong = methods.find((m) => m.id === "gemini-api-key" ||
                m.id === "use_gemini" ||
                (/gemini/.test(hay(m)) && /api.?key/.test(hay(m))));
            if (strong?.id)
                return strong.id;
            // Loose fallback: an api-key-ish method that is clearly NOT oauth/vertex.
            const loose = methods.find((m) => {
                const h = hay(m);
                return /api.?key|use_gemini/.test(h) && !/oauth|vertex|sign.?in|log.?in/.test(h);
            });
            if (loose?.id)
                return loose.id;
        }
        return methods[0]?.id;
    }
    /** A clear sign-in error reflecting the 2026-06-18 individual-OAuth sunset:
     *  points key-holders at a likely bad/unrestricted key, and everyone else at the
     *  GEMINI_API_KEY path (the free Google login no longer works for individuals). */
    authFailureMessage() {
        return process.env.GEMINI_API_KEY?.trim()
            ? "Gemini CLI rejected the API key. Ensure GEMINI_API_KEY is a valid, API-restricted key " +
                "(unrestricted standard keys are rejected as of 2026-06-19), then reconnect."
            : "Gemini CLI is not authenticated. The free Google / Code-Assist login for individuals ended " +
                "2026-06-18 — set GEMINI_API_KEY (a restricted AI Studio key) in ~/.comfyui-mcp/.env, or run " +
                "`gemini` once and sign in with a paid / enterprise account, then reconnect.";
    }
    /** Does this error look like an ACP `auth_required`? Reads the JSON-RPC error
     *  data.reason carried by RpcError, falling back to the message text. */
    isAuthRequired(err) {
        if (err instanceof RpcError) {
            const data = err.data;
            if (data?.reason === "auth_required")
                return true;
        }
        return /auth.?required|authenticat|not.*(logged|signed).*in/i.test(msgOf(err));
    }
    /**
     * Open/continue an ACP session and yield canonical AgentEvents. The user
     * channel (PanelAgent's gated queue) is consumed ONE turn at a time: each
     * neutral batch becomes a `session/prompt`, whose streamed session/update
     * notifications are normalized to AgentEvents, and only after the prompt
     * resolves (stopReason) do we read the next batch (the channel async-iteration
     * IS the turn-gate).
     */
    async *run(opts) {
        // MODEL PRECEDENCE (P1): apply the panel-selected model BEFORE prepare() so the
        // FIRST spawn uses it (the model is spawn-pinned via `--model`; preparing first
        // would spawn the wrong model). PanelAgent.start() usually passes opts.model =
        // the CLAUDE panel model, which is NOT a valid Gemini model — so the configured
        // Gemini model (deps.model, from COMFYUI_MCP_GEMINI_MODEL) wins; only honor
        // opts.model when it actually looks like a Gemini model (e.g. the user picked
        // one in the panel, which arrives as opts.model on a fresh spawn).
        if (opts.model && isGeminiModel(opts.model))
            this.model = opts.model;
        await this.prepare();
        if (!this.client)
            throw new Error("gemini --acp not initialized");
        const cwd = opts.cwd ?? this.deps.cwd ?? process.cwd();
        // forkAtAnchor is false → ignore opts.rewindAnchor; whole-session resume only.
        const resumeId = opts.resume ?? opts.sessionId ?? null;
        let sessionId = await this.ensureSession(this.client, cwd, resumeId);
        // The session id is our session id (PanelAgent persists it for resume).
        yield {
            type: "session",
            sessionId,
            ...(this.model ? { model: this.model } : {}),
        };
        // Process the neutral channel one turn at a time.
        let turnSeq = 0;
        for await (const turn of opts.channel) {
            // LIVE MODEL SWITCH (P1): PanelAgent treats setModel as live and does NOT
            // restart run() for a model-only change, so the persistent loop adopts it
            // here. The model is spawn-pinned, so a switch means respawning the CLI with
            // the new --model — which necessarily starts a FRESH session (a model swap
            // can't carry the old session forward). Done transparently before the turn;
            // we emit a new `session` event so PanelAgent persists the new id.
            if (this.spawnedModel !== this.model) {
                await this.respawnForModelChange();
                if (!this.client)
                    throw new Error("gemini --acp respawn failed");
                sessionId = await this.ensureSession(this.client, cwd, null);
                yield {
                    type: "session",
                    sessionId,
                    ...(this.model ? { model: this.model } : {}),
                };
            }
            yield* stampTurn(this.runTurn(this.client, turn, opts.onActivity), ++turnSeq);
        }
    }
    /** Tear down the live `gemini --acp` child (process-tree kill) and re-spawn it
     *  with the current `this.model`'s `--model` flag, so a live setModel takes
     *  effect. The model is spawn-pinned, so this is the only way to switch it. The
     *  caller then opens a fresh session on the new child. */
    async respawnForModelChange() {
        const old = this.client;
        this.client = null;
        this.sessionId = null;
        if (old) {
            old.notificationHandler = null;
            await old.close().catch(() => { });
        }
        this.spawnSpec = null; // force resolveSpawn to rebuild argv with the new --model
        logger.info(`[gemini-backend] model switch → respawning gemini --acp with --model ${this.model ?? "(default)"}`);
        await this.prepare(); // spawns with this.model; records spawnedModel
    }
    /** Run ONE turn: send session/prompt + stream its session/update notifications →
     *  AgentEvents, resolving when the prompt request returns a stopReason, OR when
     *  the child exits mid-turn (never deadlock). ACP's prompt request IS the turn
     *  boundary, so — unlike Codex — there is no separate completion notification and
     *  no turn-id buffering: the sessionId is known before the prompt is sent. */
    async *runTurn(client, turn, onActivity) {
        const sessionId = this.sessionId;
        // Event queue bridging the push-based notification handler to this pull-based
        // async generator (identical pattern to codex-backend).
        const queue = [];
        let wake = null;
        let done = false;
        const push = (ev) => {
            queue.push(ev);
            wake?.();
            wake = null;
        };
        const finish = () => {
            done = true;
            wake?.();
            wake = null;
        };
        // Accumulate the assistant reply text across agent_message_chunk so we can emit
        // ONE authoritative `assistant` commit when the turn ends (ACP has no separate
        // final-message notification). messageId (when present) groups the deltas + the
        // commit under one bubble id, mirroring the Claude/Codex stream reconciliation.
        let assistantText = "";
        let messageId = null;
        // Stream bubble state (reasoning vs reply each open/close their own stream).
        let streamOpen = false;
        let streamKind = null;
        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;
            }
        };
        // EXACTLY ONE terminal `result` (PanelAgent's turn-gate only advances on a
        // result; a missing one parks the channel forever). This idempotent helper
        // emits an `error` + `{result, ok:false}` and finishes; no-op once a result
        // has fired (so the prompt rejection AND the exit watcher can both call it).
        let finishedResult = false;
        const emitTerminalError = (message) => {
            if (finishedResult)
                return;
            finishedResult = true;
            closeStream();
            push({ type: "error", message });
            push({ type: "result", ok: false, subtype: "error" });
            finish();
        };
        let interrupted = false;
        // tool_call carries the title/kind; tool_call_update (ACP) repeats only the
        // toolCallId — so remember each call's display name to label its end event.
        const toolNames = new Map();
        // Normalize ONE session/update notification into canonical AgentEvents.
        const apply = (msg) => {
            if (finishedResult)
                return;
            const params = (msg.params ?? {});
            // Only our session's updates.
            if (params.sessionId && params.sessionId !== sessionId)
                return;
            const update = (params.update ?? {});
            const kind = update.sessionUpdate;
            switch (kind) {
                case "agent_message_chunk": {
                    const content = update.content;
                    const text = content?.type === "text" ? content.text : undefined;
                    const id = update.messageId ?? null;
                    if (typeof text === "string" && text) {
                        if (id)
                            messageId = id;
                        openStream(messageId, "text");
                        assistantText += text;
                        push({ type: "assistant_delta", text });
                    }
                    break;
                }
                case "agent_thought_chunk": {
                    // Extended-thinking streaming. Open a reasoning stream on the FIRST delta
                    // so PanelAgent (which drops assistant_delta when no stream is open)
                    // renders early thinking, mirroring codex P2-1.
                    const content = update.content;
                    const text = content?.type === "text" ? content.text : undefined;
                    if (typeof text === "string" && text) {
                        openStream(messageId, "thinking");
                        push({ type: "assistant_delta", text, thinking: true });
                    }
                    break;
                }
                case "tool_call": {
                    // A tool call was requested — emit tool_call(start) for panel visibility.
                    const id = update.toolCallId;
                    const name = update.title ||
                        update.kind ||
                        id ||
                        "tool";
                    if (id)
                        toolNames.set(id, name);
                    push({ type: "tool_call", name, phase: "start", detail: update });
                    break;
                }
                case "tool_call_update": {
                    // Progress + completion of a tool call. Emit tool_call(end) only on a
                    // TERMINAL status; intermediate in_progress updates just keep the
                    // watchdog armed (onActivity already fired for them). ACP's update
                    // repeats only the toolCallId, so reuse the remembered title for the name.
                    const status = update.status;
                    if (status === "completed" || status === "failed") {
                        const id = update.toolCallId;
                        const name = update.title ||
                            (id ? toolNames.get(id) : undefined) ||
                            update.kind ||
                            id ||
                            "tool";
                        push({ type: "tool_call", name, phase: "end", detail: update });
                    }
                    break;
                }
                // plan / available_commands_update / session_info_update / current_mode_update
                // carry no AgentEvent — onActivity (below) already re-armed the watchdog.
                default:
                    break;
            }
        };
        const prev = client.notificationHandler;
        client.notificationHandler = (msg) => {
            // LIVENESS: ANY notification while this turn is in flight means the agent is
            // alive — fire onActivity BEFORE filtering/translating so even updates that
            // produce no AgentEvent (a long MCP tool call mid-generation) keep
            // PanelAgent's idle watchdog armed. A genuine zero-event freeze never
            // reaches here, so the real freeze-catch is preserved.
            try {
                onActivity?.();
            }
            catch {
                // a watchdog bump must never break the protocol reader
            }
            if (msg.method === "session/update")
                apply(msg);
            else
                prev?.(msg); // anything else (other methods) → pass through
        };
        // Watch for the child dying mid-turn: end the turn with a terminal result so
        // the local drain is woken instead of waiting forever. emitTerminalError is a
        // no-op if a result already fired, so it's safe alongside the prompt rejection.
        void client.exitPromise.then(() => {
            if (done)
                return;
            emitTerminalError(client.exitError ? msgOf(client.exitError) : "gemini --acp connection closed.");
        });
        // FIRST-TURN PERSONA: ACP session/new has no instructions field, so the panel
        // system prompt is prepended to the first turn's prompt as a clearly-marked
        // system/context preamble (later turns send plain text). Mirrors codex.
        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;
        }
        // Build the prompt ContentBlock[]: the text block first (preserves prompt
        // context), then any resolved inline base64 image blocks (vision parity).
        // Images are only attached when the agent advertised promptCapabilities.image
        // (default-allow when the capability is unknown).
        const prompt = [{ type: "text", text: turnText }];
        const imagesAllowed = this.agentCaps?.promptCapabilities?.image !== false;
        if (imagesAllowed) {
            for (const ref of turn.images ?? []) {
                const block = await this.fetchImageBlock(ref);
                if (block)
                    prompt.push(block);
            }
        }
        try {
            // session/prompt is a REQUEST that RESOLVES with a stopReason at turn end.
            client
                .request("session/prompt", { sessionId, prompt })
                .then((res) => {
                if (finishedResult)
                    return;
                finishedResult = true;
                closeStream();
                const stop = res?.stopReason;
                // Commit the accumulated assistant text (if any) as the authoritative
                // turn-ending message — no per-turn rewind anchor (forkAtAnchor=false).
                const text = assistantText.trim();
                if (text)
                    push({ type: "assistant", text, ...(messageId ? { id: messageId } : {}) });
                // end_turn / max_tokens / max_turn_requests = a real completion; cancelled
                // (user interrupt) and refusal are not "ok".
                const ok = !!stop && stop !== "cancelled" && stop !== "refusal";
                push({ type: "result", ok, ...(stop ? { subtype: stop } : {}) });
                finish();
            })
                .catch((err) => {
                // A failed prompt ends the turn. When the child dies mid-turn handleExit
                // rejects this BEFORE exitPromise resolves, so this .catch runs first —
                // it MUST end with a terminal result (the idempotent helper guarantees
                // exactly one), or the exit watcher then sees done and hangs the gate.
                if (interrupted)
                    emitTerminalError("gemini turn interrupted.");
                else
                    emitTerminalError(msgOf(err));
            });
            // 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;
                });
            }
            while (queue.length)
                yield queue.shift();
        }
        finally {
            // Mark interrupted so a late prompt rejection doesn't surface a spurious
            // error after teardown.
            interrupted = true;
            // 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 (client.notificationHandler && !client.exitError)
                client.notificationHandler = prev ?? null;
        }
    }
    /** Stop the current turn without ending the session → `session/cancel`
     *  (notification). The in-flight session/prompt then resolves with
     *  stopReason:"cancelled", which the run-turn path turns into a terminal result. */
    async interrupt() {
        const client = this.client;
        if (!client || !this.sessionId)
            return;
        try {
            client.notify("session/cancel", { sessionId: this.sessionId });
        }
        catch (err) {
            logger.debug(`[gemini-backend] interrupt: ${msgOf(err)}`);
        }
    }
    /** Switch the model live. ACP pins the model at spawn (`--model`), so this can't
     *  reconfigure a running child — instead it marks the model dirty (this.model !=
     *  this.spawnedModel) and invalidates the cached spawn spec. The persistent run()
     *  loop then RESPAWNS the `gemini --acp` CLI with the new --model (and a fresh
     *  session) transparently before the next turn (see run()'s live-switch branch).
     *  If the backend hasn't spawned yet, the first prepare() simply uses the new
     *  model. Ignores non-Gemini ids (PanelAgent may pass the Claude panel model). */
    async setModel(model) {
        if (!isGeminiModel(model))
            return;
        this.model = model;
        this.spawnSpec = null; // next spawn rebuilds argv with the new --model
    }
    /**
     * Gemini model enumeration. ACP exposes no model catalog, so we surface a static
     * set (the current Gemini family); the panel picker degrades gracefully on an
     * empty list. No effort metadata (Gemini uses a thinking BUDGET, not a discrete
     * effort scale) → the panel hides the effort dropdown.
     */
    async listModels() {
        return GEMINI_MODELS;
    }
    /** Permanently dispose of the backend (AgentBackend.close): kill the gemini
     *  process TREE (Windows shell-fallback grandchild included), remove listeners,
     *  null the client. Idempotent + safe when never prepared. Mirrors codex (P0-1):
     *  interrupt() is a no-op when idle, so without this the child is orphaned. */
    async close() {
        this.disposed = true; // tripwire FIRST (an in-flight prepare() bails) (P0-A)
        const client = this.client;
        const preparing = this.preparingClient;
        this.client = null;
        this.preparingClient = null;
        this.sessionId = null;
        if (client) {
            client.notificationHandler = null;
            await client.close().catch(() => { });
        }
        if (preparing && preparing !== client) {
            preparing.notificationHandler = null;
            await preparing.close().catch(() => { });
        }
    }
}
// Expose the default model id for the orchestrator wiring (COMFYUI_MCP_GEMINI_MODEL
// fallback) without duplicating the literal.
export { GEMINI_DEFAULT_MODEL };
//# sourceMappingURL=gemini-backend.js.map