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,358 lines • 65.7 kB
/**
 * Explicitly-installed companion for panel-driven orchestrator startup.
 *
 * This file intentionally imports only Node built-ins. `launcher install`
 * copies the compiled module into ~/.comfyui-mcp/launcher/broker.mjs, so the
 * login service is stable even when the command was invoked through npx's
 * disposable cache.
 */
import { chmodSync, closeSync, copyFileSync, existsSync, linkSync, mkdirSync, openSync, readFileSync, readlinkSync, rmSync, writeFileSync, } from "node:fs";
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { execFileSync, spawn } from "node:child_process";
import { createServer, request as httpRequest } from "node:http";
import { connect as netConnect } from "node:net";
import { homedir } from "node:os";
import { basename, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
export const PANEL_LAUNCHER_PROTOCOL = 1;
export const PANEL_LAUNCHER_LABEL = "io.artokun.comfyui-mcp-launcher";
export const PANEL_LAUNCHER_TASK = "ComfyUI MCP Launcher";
export const DEFAULT_PANEL_BRIDGE_PORT = 9199;
/** Pre-#2030 default. Logitech G HUB (`lghub_agent`) occupies 9180 on many desktops. */
export const LEGACY_PANEL_BRIDGE_PORT = 9180;
/** The broker must not hand the orchestrator the broker's own login terminal. */
export const ORCHESTRATOR_RECOVERY_DELAY_MS = 15_000;
/** A broken install must not turn the login broker into an unbounded spawn loop. */
export const ORCHESTRATOR_RECOVERY_MAX_ATTEMPTS = 3;
export const ORCHESTRATOR_RECOVERY_WINDOW_MS = 5 * 60_000;
export function panelLauncherPaths(home = homedir(), 
// Derive from `home`, not the process environment: a caller passing only a
// home (test harness, sandbox, per-user install) must get every artifact
// under that home, not a Startup entry in the REAL user's %APPDATA%.
appData = roamingAppData({ home })) {
    const root = join(home, ".comfyui-mcp");
    const launcherDir = join(root, "launcher");
    return {
        root,
        launcherDir,
        config: join(root, "launcher.json"),
        lock: join(root, "launcher.lock"),
        teardown: join(root, "launcher.uninstalled"),
        broker: join(launcherDir, "broker.mjs"),
        windowsScript: join(launcherDir, "start-launcher.cmd"),
        windowsStartup: join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", "comfyui-mcp-launcher.cmd"),
        macPlist: join(home, "Library", "LaunchAgents", `${PANEL_LAUNCHER_LABEL}.plist`),
        linuxService: join(home, ".config", "systemd", "user", "comfyui-mcp-launcher.service"),
        linuxAutostart: join(home, ".config", "autostart", "comfyui-mcp-launcher.desktop"),
    };
}
function isConfig(value) {
    if (!value || typeof value !== "object")
        return false;
    const v = value;
    return (v.protocol === PANEL_LAUNCHER_PROTOCOL &&
        v.host === "127.0.0.1" &&
        Number.isInteger(v.port) &&
        Number(v.port) >= 0 &&
        Number(v.port) <= 65535 &&
        typeof v.token === "string" &&
        v.token.length >= 32 &&
        (v.install_id === undefined ||
            (typeof v.install_id === "string" && /^[A-Za-z0-9_-]{32,}$/.test(v.install_id))) &&
        (v.pid === undefined ||
            (Number.isInteger(v.pid) && Number(v.pid) > 0 && Number(v.pid) <= 0x7fffffff)) &&
        (v.broker_id === undefined ||
            (typeof v.broker_id === "string" && /^[A-Za-z0-9_-]{32,}$/.test(v.broker_id))) &&
        (v.broker_executable === undefined ||
            (typeof v.broker_executable === "string" &&
                v.broker_executable.length > 0 &&
                v.broker_executable.length <= 4096 &&
                !/[\u0000\r\n]/.test(v.broker_executable))) &&
        typeof v.updated_at === "string");
}
export function readPanelLauncherConfig(home = homedir()) {
    try {
        const parsed = JSON.parse(readFileSync(panelLauncherPaths(home).config, "utf8"));
        return isConfig(parsed) ? parsed : null;
    }
    catch {
        return null;
    }
}
function writePanelLauncherConfig(config, home) {
    const path = panelLauncherPaths(home).config;
    mkdirSync(dirname(path), { recursive: true });
    writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
    try {
        chmodSync(path, 0o600);
    }
    catch {
        // Windows ACLs are owned by the current user; chmod is best-effort there.
    }
}
function xml(value) {
    return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}
/**
 * Why a service manager refused, as a single line fit for a CLI message.
 *
 * `execFileSync` throws an Error whose `stderr` holds the tool's own words
 * ("ERROR: Access is denied."), but only when the caller piped it; the generic
 * `message` is just the command line, which tells the user nothing they can act
 * on. Prefer the tool's reason and fall back to the message.
 */
function schtasksReason(err) {
    const stderr = err?.stderr;
    const text = typeof stderr === "string"
        ? stderr
        : stderr && typeof stderr.toString === "function"
            ? stderr.toString("utf8")
            : "";
    const line = text.split(/\r?\n/).find((l) => l.trim().length > 0);
    return line?.trim() || (err instanceof Error ? err.message : String(err));
}
/** Roaming AppData for a call: explicit wins, then an overridden home, then the
 *  real `%APPDATA%`, then the conventional layout. */
function roamingAppData(opts) {
    if (opts.appData)
        return opts.appData;
    if (opts.home)
        return join(opts.home, "AppData", "Roaming");
    const env = process.env.APPDATA?.trim();
    return env || join(homedir(), "AppData", "Roaming");
}
export async function installPanelLauncher(options = {}) {
    const home = options.home ?? homedir();
    const platform = options.platform ?? process.platform;
    const nodePath = options.nodePath ?? process.execPath;
    const source = options.brokerSource ?? fileURLToPath(import.meta.url);
    const run = options.exec ?? execFileSync;
    const spawnBroker = options.spawnImpl ?? spawn;
    const paths = panelLauncherPaths(home, roamingAppData(options));
    const locked = await withPanelLauncherLock(home, async () => {
        mkdirSync(paths.launcherDir, { recursive: true });
        copyFileSync(source, paths.broker);
        const previous = readPanelLauncherConfig(home);
        const token = previous?.token ?? randomBytes(32).toString("base64url");
        const previousLive = previous?.port
            ? await queryPanelLauncher(home)
            : { running: false };
        // Preserve the generation only for a broker that positively answers the
        // recorded port. A stale nonzero port and a port-0/unfinished install get a
        // new generation, invalidating any delayed fallback from that install.
        const installId = previousLive.running && previous?.install_id
            ? previous.install_id
            : randomBytes(24).toString("base64url");
        const brokerId = previous?.broker_id ?? randomBytes(24).toString("base64url");
        // Keep the executable evidence paired with a live recorded PID. The
        // service scripts may adopt the new node path for a future restart, but
        // uninstall must still identify the currently answering old broker.
        const brokerExecutable = previousLive.running && previous?.broker_executable
            ? previous.broker_executable
            : nodePath;
        let needsFallbackBroker = false;
        /**
         * Start the broker unless the previous one is still alive.
         *
         * Every fallback path needs this: without a service manager nothing else will
         * start the broker, so the install would "succeed" and leave the panel with
         * nothing to talk to until the next logon. Shared by the Windows and Linux
         * fallbacks rather than duplicated — they answer the identical question.
         */
        const startBrokerIfIdle = async () => {
            // POSITIVE EVIDENCE, not a pid. `process.kill(pid, 0)` only proves SOME
            // process holds that number, and Windows recycles pids aggressively across a
            // reboot — so an unrelated process wearing our old pid made this skip the
            // spawn, leaving a dead port in the config and the panel telling the user to
            // re-run the install they had just run: the #1798 loop, re-entered through
            // its own fix. `queryPanelLauncher` asks the recorded port, with the recorded
            // token, whether a broker is actually answering. Anything short of a live
            // answer means start one (merge-gate P1).
            // Keyed on the PORT alone. Gating on a pid as well meant the query never ran
            // on a second install — this function's own config write used to drop the
            // pid — so a duplicate broker was spawned beside a live one (merge-gate P1).
            // The port is what the query actually uses; the pid was never evidence.
            if (existsSync(paths.teardown) ||
                readPanelLauncherConfig(home)?.token !== token ||
                readPanelLauncherConfig(home)?.install_id !== installId)
                return;
            if (previous?.port) {
                const live = (await queryPanelLauncher(home));
                if (live.running && live.broker_id === brokerId && live.install_id === installId) {
                    return; // the answering broker owns this exact generation
                }
            }
            if (existsSync(paths.teardown) ||
                readPanelLauncherConfig(home)?.token !== token ||
                readPanelLauncherConfig(home)?.install_id !== installId)
                return;
            const child = spawnBroker(nodePath, brokerRunArgs(paths.broker, brokerId, installId), {
                detached: true,
                stdio: "ignore",
            });
            const current = readPanelLauncherConfig(home);
            if (current?.token === token) {
                // Only persist a PID that this fallback launch actually owns. Keeping a
                // previous PID when a spawn implementation cannot report one would make
                // uninstall capable of targeting an unrelated recycled process.
                const next = {
                    ...current,
                    broker_id: brokerId,
                    broker_executable: brokerExecutable,
                    updated_at: new Date().toISOString(),
                };
                if (child.pid)
                    next.pid = child.pid;
                else
                    delete next.pid;
                writeOwnedPanelLauncherConfig(next, home, { token, installId });
            }
            child.unref?.();
        };
        const initialPublished = (() => {
            // Reinstall is the explicit operation that adopts this launcher root again.
            // Remove the old uninstall tombstone while holding the same lock used by
            // uninstall, so initial config publication cannot cross that teardown.
            rmSync(paths.teardown, { force: true });
            writePanelLauncherConfig({
                protocol: PANEL_LAUNCHER_PROTOCOL,
                host: "127.0.0.1",
                port: previous?.port ?? 0,
                token,
                ...(installId ? { install_id: installId } : {}),
                broker_id: brokerId,
                broker_executable: brokerExecutable,
                // The running broker's pid is the broker's to publish, not this install's
                // to erase. Dropping it made the config say "no broker has ever run here"
                // to every subsequent install (merge-gate P1) — the port and token were
                // carried over but the pid was not, which is an incoherent record of the
                // same broker.
                ...(previous?.pid ? { pid: previous.pid } : {}),
                updated_at: new Date().toISOString(),
            }, home);
            return true;
        })();
        if (initialPublished === null) {
            throw new Error("Could not acquire the panel launcher lock for install");
        }
        if (platform === "win32") {
            writeFileSync(paths.windowsScript, `@echo off\r\n"${nodePath}" "${paths.broker}" run --broker-id=${brokerId} --install-id=${installId}\r\n`, "utf8");
            // A scheduled task is the preferred registration, but it is NOT available to
            // every account: creating one can be denied outright by machine policy or by
            // the ACL on the task store, and the denial has nothing to do with this task
            // in particular (a throwaway probe task is refused identically). On such a
            // machine the install used to throw here, having already written every file
            // it needed, and the panel's Connect button kept telling the user to run an
            // install that could never succeed.
            //
            // So Windows now gets the same shape Linux has had all along: try the
            // service manager, and when it refuses, fall back to a per-user autostart
            // and start the broker directly. The Startup folder is user-writable and
            // needs no elevation, which is exactly the constraint the scheduled task
            // could not satisfy.
            // /Create and /Run get SEPARATE try blocks, because they fail for different
            // reasons and only one of them means "this account cannot register an
            // autostart". A created task whose /Run is refused right now — an /IT task
            // invoked over a non-interactive session (OpenSSH, WinRM, a CI service),
            // SCHED_E_TASK_NOT_READY — is REGISTERED and will fire at the next logon.
            // Treating that as a refusal wrote a Startup entry beside the live task, so
            // every later logon started two brokers, both rewriting launcher.json
            // (merge-gate P1). It also printed "scheduled task refused" about a task
            // that plainly exists.
            let taskRegistered = false;
            try {
                run("schtasks.exe", [
                    "/Create",
                    "/F",
                    "/SC",
                    "ONLOGON",
                    "/IT",
                    "/RL",
                    "LIMITED",
                    "/TN",
                    PANEL_LAUNCHER_TASK,
                    "/TR",
                    `"${paths.windowsScript}"`,
                ], 
                // NOT "ignore" (codex-taxonomy class 1): schtasks reports WHY it refused
                // on stderr, and swallowing it left the CLI printing a bare "Command
                // failed: schtasks.exe …" — indistinguishable from a missing binary, a
                // bad argument, or a denial, which is the one that actually happens.
                { stdio: ["ignore", "ignore", "pipe"] });
                taskRegistered = true;
            }
            catch (err) {
                // A /Create failure does NOT prove this account can never register an
                // autostart — only that THIS call failed. The Task Scheduler service being
                // stopped or set to Manual, RPC being unavailable, or a GPO applied after
                // an earlier successful install all fail here while leaving a registered
                // task intact. Writing the Startup entry anyway put it beside that task, so
                // both fired at the next logon and two brokers raced on launcher.json —
                // the same collapse the /Run branch was split out to avoid, left open on
                // this branch (merge-gate P1). So ASK whether the task exists.
                let alreadyRegistered = false;
                try {
                    run("schtasks.exe", ["/Query", "/TN", PANEL_LAUNCHER_TASK], { stdio: "ignore" });
                    alreadyRegistered = true;
                }
                catch {
                    // No such task (or we cannot even query) → the fallback is warranted.
                }
                if (alreadyRegistered) {
                    // It IS registered — that task is the autostart. This session may still
                    // need a broker, which the /Run attempt below (and then
                    // startBrokerIfIdle) provides.
                    taskRegistered = true;
                    process.stderr?.write?.(`[comfyui-mcp] schtasks /Create failed (${schtasksReason(err)}), but the ` +
                        `"${PANEL_LAUNCHER_TASK}" task is already registered — leaving it as the autostart.\n`);
                }
                else {
                    // The Startup write can fail too — EDR/AppLocker blocking Startup
                    // persistence, or a Roaming AppData redirected to an offline share, on
                    // the very same managed accounts whose policy denied the task. Unguarded,
                    // it threw before the broker was started and the session got NOTHING:
                    // #1798's stranding loop relocated from the schtasks line to this one
                    // (merge-gate P1). Losing the autostart is survivable; losing this
                    // session's broker is the bug we are here to fix, so warn and fall
                    // through to it.
                    let autostartPath = "";
                    try {
                        mkdirSync(dirname(paths.windowsStartup), { recursive: true });
                        writeFileSync(paths.windowsStartup, `@echo off\r\nstart "" /min "${paths.windowsScript}"\r\n`, "utf8");
                        autostartPath = paths.windowsStartup;
                    }
                    catch (startupErr) {
                        process.stderr?.write?.(`[comfyui-mcp] could not write the Startup autostart ` +
                            `(${startupErr instanceof Error ? startupErr.message : String(startupErr)}); ` +
                            `MCP will run for this session but will NOT start at the next logon. ` +
                            `Start it by hand with: ${paths.windowsScript}\n`);
                    }
                    // Not silent: the user asked for a launcher and got a different mechanism
                    // than the one this tool normally installs, which changes how they would
                    // later remove or debug it.
                    if (autostartPath) {
                        process.stderr?.write?.(`[comfyui-mcp] scheduled task refused (${schtasksReason(err)}); ` +
                            `registered a Startup-folder autostart instead: ${autostartPath}\n`);
                    }
                }
            }
            if (taskRegistered) {
                return {
                    paths,
                    startFallback: null,
                    // The task may need the launcher lock when its broker starts and
                    // publishes its runtime state. Starting it while install still owns
                    // that lock made the broker exit after its bounded reacquire timeout.
                    startAfterInstall: async () => {
                        if (existsSync(paths.teardown) ||
                            readPanelLauncherConfig(home)?.token !== token ||
                            readPanelLauncherConfig(home)?.install_id !== installId)
                            return;
                        try {
                            run("schtasks.exe", ["/Run", "/TN", PANEL_LAUNCHER_TASK], { stdio: "ignore" });
                        }
                        catch {
                            // Registered but not startable right now (non-interactive session).
                            // The autostart is in place for the next logon, so do NOT add a
                            // second one — just get a broker up for THIS session.
                            await startBrokerIfIdle();
                        }
                    },
                }; // the task is registered AND will be started after the lock is released
            }
            needsFallbackBroker = true;
            return { paths, startFallback: startBrokerIfIdle, startAfterInstall: null };
        }
        if (platform === "darwin") {
            mkdirSync(dirname(paths.macPlist), { recursive: true });
            writeFileSync(paths.macPlist, `<?xml version="1.0" encoding="UTF-8"?>\n` +
                `<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n` +
                `<plist version="1.0"><dict>\n` +
                `<key>Label</key><string>${PANEL_LAUNCHER_LABEL}</string>\n` +
                `<key>ProgramArguments</key><array><string>${xml(nodePath)}</string><string>${xml(paths.broker)}</string><string>run</string><string>--broker-id=${xml(brokerId)}</string><string>--install-id=${xml(installId)}</string></array>\n` +
                `<key>RunAtLoad</key><true/><key>KeepAlive</key><true/>\n` +
                `<key>StandardOutPath</key><string>${xml(join(paths.launcherDir, "launcher.log"))}</string>\n` +
                `<key>StandardErrorPath</key><string>${xml(join(paths.launcherDir, "launcher-error.log"))}</string>\n` +
                `</dict></plist>\n`, "utf8");
            try {
                run("launchctl", ["bootout", `gui/${process.getuid?.() ?? 0}`, paths.macPlist], { stdio: "ignore" });
            }
            catch {
                // It is normal for the label not to be loaded on first install.
            }
            return {
                paths,
                startFallback: null,
                // Keep launchctl outside the installer lock. The launched broker must be
                // able to reacquire that lock before it publishes its runtime config.
                startAfterInstall: () => {
                    if (existsSync(paths.teardown) ||
                        readPanelLauncherConfig(home)?.token !== token ||
                        readPanelLauncherConfig(home)?.install_id !== installId)
                        return;
                    run("launchctl", ["bootstrap", `gui/${process.getuid?.() ?? 0}`, paths.macPlist], { stdio: "ignore" });
                },
            };
        }
        mkdirSync(dirname(paths.linuxService), { recursive: true });
        writeFileSync(paths.linuxService, `[Unit]\nDescription=ComfyUI MCP panel launcher\n\n` +
            `[Service]\nExecStart="${nodePath}" "${paths.broker}" run --broker-id=${brokerId} --install-id=${installId}\nRestart=on-failure\n\n` +
            `[Install]\nWantedBy=default.target\n`, "utf8");
        const writeLinuxAutostart = () => {
            mkdirSync(dirname(paths.linuxAutostart), { recursive: true });
            writeFileSync(paths.linuxAutostart, `[Desktop Entry]\nType=Application\nName=ComfyUI MCP Launcher\n` +
                `Exec="${nodePath}" "${paths.broker}" run --broker-id=${brokerId} --install-id=${installId}\nTerminal=false\nX-GNOME-Autostart-enabled=true\n`, "utf8");
        };
        try {
            run("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
            run("systemctl", ["--user", "enable", "comfyui-mcp-launcher.service"], {
                stdio: "ignore",
            });
        }
        catch {
            writeLinuxAutostart();
            needsFallbackBroker = true;
        }
        return {
            paths,
            startFallback: needsFallbackBroker ? startBrokerIfIdle : null,
            // `enable --now` used to start the broker while the installer still held
            // the shared lock. Register under the lock, then start after it is released.
            startAfterInstall: needsFallbackBroker
                ? null
                : async () => {
                    if (existsSync(paths.teardown) ||
                        readPanelLauncherConfig(home)?.token !== token ||
                        readPanelLauncherConfig(home)?.install_id !== installId)
                        return;
                    try {
                        run("systemctl", ["--user", "start", "comfyui-mcp-launcher.service"], { stdio: "ignore" });
                    }
                    catch {
                        // A registered service that cannot start now still needs a
                        // per-user fallback and a broker for this session. Reacquire the
                        // lock only for the fallback artifact write; the broker itself is
                        // launched after that lock is released.
                        await withPanelLauncherLock(home, () => {
                            if (existsSync(paths.teardown) ||
                                readPanelLauncherConfig(home)?.token !== token ||
                                readPanelLauncherConfig(home)?.install_id !== installId)
                                return false;
                            writeLinuxAutostart();
                            return true;
                        });
                        await startBrokerIfIdle();
                    }
                },
        };
    });
    if (locked === null) {
        throw new Error("Could not acquire the panel launcher lock for install");
    }
    if (locked.startAfterInstall)
        await locked.startAfterInstall();
    if (locked.startFallback)
        await locked.startFallback();
    return locked.paths;
}
function readPanelLauncherProcessIdentity(pid, platform, run) {
    try {
        if (platform === "linux") {
            const argv = readFileSync(`/proc/${pid}/cmdline`, "utf8")
                .split("\0")
                .filter(Boolean);
            if (argv.length === 0)
                return null;
            return {
                commandLine: argv.join(" "),
                executable: readlinkSync(`/proc/${pid}/exe`),
            };
        }
        if (platform === "win32") {
            const script = `$p=Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}';` +
                `if($p){$p|Select-Object -First 1 ExecutablePath,CommandLine|ConvertTo-Json -Compress}`;
            const raw = run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
            const parsed = JSON.parse(String(raw));
            if (typeof parsed.CommandLine !== "string" ||
                parsed.CommandLine.length === 0 ||
                typeof parsed.ExecutablePath !== "string" ||
                parsed.ExecutablePath.length === 0)
                return null;
            return {
                commandLine: parsed.CommandLine,
                executable: parsed.ExecutablePath,
            };
        }
        const raw = run("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
        const commandLine = String(raw).trim();
        if (!commandLine)
            return null;
        const executable = commandLine.match(/^\s*(?:"([^"]+)"|(\S+))/)?.[1] ??
            commandLine.match(/^\s*(?:"([^"]+)"|(\S+))/)?.[2];
        return executable ? { commandLine, executable } : null;
    }
    catch {
        // If the platform/permissions do not expose identity, safe cleanup is to
        // leave the process alone. PID equality is never sufficient evidence.
        return null;
    }
}
function processIdentityOwnsBroker(identity, brokerPath, brokerId, brokerExecutable, platform) {
    if (!identity || !brokerId || !brokerExecutable || !identity.commandLine || !identity.executable)
        return false;
    const normalizePath = (value) => {
        const path = value.replaceAll("\\", "/");
        return platform === "win32" ? path.toLowerCase() : path;
    };
    const hasToken = (commandLine, token, caseInsensitive) => {
        const normalizedLine = commandLine.replaceAll("\\", "/");
        const haystack = caseInsensitive ? normalizedLine.toLowerCase() : normalizedLine;
        const needle = caseInsensitive ? token.toLowerCase() : token;
        let offset = 0;
        while (true) {
            const index = haystack.indexOf(needle, offset);
            if (index < 0)
                return false;
            const before = index === 0 ? "" : haystack[index - 1];
            const afterIndex = index + needle.length;
            const after = afterIndex >= haystack.length ? "" : haystack[afterIndex];
            if ((before === "" || /[\s"']/.test(before)) && (after === "" || /[\s"']/.test(after))) {
                return true;
            }
            offset = afterIndex;
        }
    };
    const commandLine = identity.commandLine;
    // The path and random per-install marker must both be present in the OS
    // observation. A recycled PID or unrelated process cannot satisfy this by
    // merely reusing the numeric pid. If the OS returns a shortened/opaque
    // command line, this deliberately fails closed.
    if (!hasToken(commandLine, normalizePath(brokerPath), platform === "win32") ||
        !hasToken(commandLine, `--broker-id=${brokerId}`, false) ||
        !hasToken(commandLine, "run", true)) {
        return false;
    }
    return normalizePath(identity.executable) === normalizePath(brokerExecutable);
}
function stopOwnedPanelLauncherBroker(platform, run, killImpl, pid, brokerPath, brokerId, brokerExecutable, processIdentityImpl) {
    // Never let an in-process test broker (or a future embedded caller) kill its
    // own process. The installed login broker always has a distinct PID.
    if (!pid || pid === process.pid)
        return;
    const identity = (processIdentityImpl ?? readPanelLauncherProcessIdentity)(pid, platform, run);
    if (!processIdentityOwnsBroker(identity, brokerPath, brokerId, brokerExecutable, platform))
        return;
    try {
        if (platform === "win32") {
            // Stop only the owned broker. Its orchestrator child is independent and
            // may be serving an already-connected panel; what uninstall must prevent
            // is the broker's recovery loop bringing that child back.
            run("taskkill.exe", ["/PID", String(pid), "/F"], { stdio: "ignore" });
        }
        else {
            killImpl(pid, "SIGTERM");
        }
    }
    catch {
        // The service/task may already have stopped it, or the PID may have exited
        // between reading the owned config and issuing the stop.
    }
}
function launcherProcessStartIdentity(pid, platform) {
    try {
        if (platform === "linux") {
            const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
            const endOfCommand = stat.lastIndexOf(")");
            const fields = stat.slice(endOfCommand + 2).trim().split(/\s+/);
            const startTicks = fields[19]; // field 22, after the command and state fields
            const bootId = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
            return startTicks && bootId ? `${bootId}:${startTicks}` : null;
        }
        if (platform === "win32") {
            const script = `$p=Get-CimInstance Win32_Process -Filter \"ProcessId = ${pid}\";` +
                `if($p){$p.CreationDate.ToUniversalTime().ToString(\"o\")}`;
            return String(execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
                encoding: "utf8",
                timeout: 2_000,
                windowsHide: true,
                stdio: ["ignore", "pipe", "ignore"],
            })).trim() || null;
        }
        return String(execFileSync("ps", ["-p", String(pid), "-o", "lstart="], {
            encoding: "utf8",
            timeout: 2_000,
            stdio: ["ignore", "pipe", "ignore"],
        })).trim() || null;
    }
    catch {
        return null;
    }
}
let cachedLauncherProcessStartIdentity;
function currentLauncherProcessStartIdentity() {
    if (cachedLauncherProcessStartIdentity === undefined) {
        cachedLauncherProcessStartIdentity = launcherProcessStartIdentity(process.pid, process.platform);
    }
    return cachedLauncherProcessStartIdentity;
}
function readLauncherLockOwner(path) {
    try {
        const parsed = JSON.parse(readFileSync(path, "utf8"));
        if (!Number.isInteger(parsed.pid) ||
            Number(parsed.pid) <= 0 ||
            Number(parsed.pid) > 0x7fffffff ||
            typeof parsed.token !== "string")
            return null;
        return {
            pid: Number(parsed.pid),
            token: parsed.token,
            ...(typeof parsed.started_at === "string" ? { started_at: parsed.started_at } : {}),
        };
    }
    catch {
        return null;
    }
}
function launcherLockOwnerIsDead(path, identityCache) {
    const owner = readLauncherLockOwner(path);
    if (!owner)
        return false;
    try {
        process.kill(owner.pid, 0);
    }
    catch (error) {
        return error.code === "ESRCH";
    }
    // A live PID is not enough: the original process may have died and the OS
    // may have recycled that number. If the platform cannot expose a start
    // identity, fail closed and leave the lock for manual recovery.
    if (!owner.started_at)
        return false;
    if (owner.pid === process.pid && currentLauncherProcessStartIdentity() === owner.started_at)
        return false;
    let current;
    if (identityCache?.has(owner.pid)) {
        current = identityCache.get(owner.pid) ?? null;
    }
    else {
        current = launcherProcessStartIdentity(owner.pid, process.platform);
        identityCache?.set(owner.pid, current);
    }
    return current !== null && current !== owner.started_at;
}
/**
 * Publish a complete owner record before making the lock name visible. A
 * process crash cannot leave an ownerless main/reclaim lock between exclusive
 * create and metadata publication: the completed temp file is linked into the
 * well-known name atomically, then held open until release.
 */
function tryCreateOwnedLauncherLock(path, owner) {
    const tempPath = `${path}.${owner.token}.tmp`;
    let linked = false;
    try {
        writeFileSync(tempPath, JSON.stringify(owner), { encoding: "utf8", mode: 0o600 });
        try {
            linkSync(tempPath, path);
            linked = true;
        }
        catch (error) {
            if (error.code === "EEXIST")
                return null;
            throw error;
        }
        return openSync(path, "r");
    }
    catch (error) {
        if (linked)
            rmSync(path, { force: true });
        throw error;
    }
    finally {
        rmSync(tempPath, { force: true });
    }
}
function withPanelLauncherLock(home, fn) {
    const paths = panelLauncherPaths(home);
    const reclaimPath = `${paths.lock}.reclaim`;
    mkdirSync(paths.root, { recursive: true });
    const startedAt = currentLauncherProcessStartIdentity();
    const ownerIdentityCache = new Map();
    for (let attempt = 0; attempt < 200; attempt += 1) {
        const lockToken = randomBytes(16).toString("hex");
        const owner = { pid: process.pid, token: lockToken, ...(startedAt ? { started_at: startedAt } : {}) };
        let fd;
        let reclaimFd;
        let reclaimToken;
        fd = tryCreateOwnedLauncherLock(paths.lock, owner) ?? undefined;
        if (fd === undefined) {
            if (!launcherLockOwnerIsDead(paths.lock, ownerIdentityCache))
                continue;
            try {
                reclaimToken = randomBytes(16).toString("hex");
                reclaimFd = tryCreateOwnedLauncherLock(reclaimPath, {
                    pid: process.pid,
                    token: reclaimToken,
                    ...(startedAt ? { started_at: startedAt } : {}),
                }) ?? undefined;
                if (reclaimFd === undefined) {
                    if (launcherLockOwnerIsDead(reclaimPath, ownerIdentityCache))
                        rmSync(reclaimPath, { force: true });
                }
                if (reclaimFd === undefined)
                    continue;
                // The auxiliary lock is itself a crash-recoverable owner lock. A
                // process which dies between acquiring it and releasing it must not
                // strand every later stale-main-lock recovery forever.
                const stillDead = launcherLockOwnerIsDead(paths.lock, ownerIdentityCache);
                if (stillDead) {
                    rmSync(paths.lock, { force: true });
                    fd = tryCreateOwnedLauncherLock(paths.lock, owner) ?? undefined;
                }
            }
            catch {
                // Another live reclaimer owns the auxiliary lock, or the lock changed.
            }
            if (fd === undefined) {
                if (reclaimFd !== undefined) {
                    closeSync(reclaimFd);
                    reclaimFd = undefined;
                    try {
                        const owner = JSON.parse(readFileSync(reclaimPath, "utf8"));
                        if (owner.token === reclaimToken)
                            rmSync(reclaimPath, { force: true });
                    }
                    catch {
                        // The reclaim lock was already removed or became unreadable.
                    }
                }
                continue;
            }
        }
        const lockFd = fd;
        let released = false;
        const release = () => {
            if (released)
                return;
            released = true;
            closeSync(lockFd);
            try {
                const owner = JSON.parse(readFileSync(paths.lock, "utf8"));
                if (owner.token === lockToken)
                    rmSync(paths.lock, { force: true });
            }
            catch {
                // The lock was already removed or became unreadable; do not remove a
                // replacement lock owned by another process.
            }
            if (reclaimFd !== undefined) {
                closeSync(reclaimFd);
                try {
                    const owner = JSON.parse(readFileSync(reclaimPath, "utf8"));
                    if (owner.token === reclaimToken)
                        rmSync(reclaimPath, { force: true });
                }
                catch {
                    // The reclaim lock was already removed or became unreadable; do not
                    // remove a replacement owned by another reclaimer.
                }
            }
        };
        try {
            const result = fn();
            if (result && typeof result.then === "function") {
                return Promise.resolve(result).finally(release);
            }
            release();
            return result;
        }
        catch (error) {
            release();
            throw error;
        }
    }
    return null;
}
function writeOwnedPanelLauncherConfig(config, home, ownership) {
    return withPanelLauncherLock(home, () => {
        const paths = panelLauncherPaths(home);
        // The tombstone is written before uninstall removes the config. A broker
        // which is still publishing after that point must not recreate launcher.json.
        if (existsSync(paths.teardown))
            return false;
        const current = readPanelLauncherConfig(home);
        const sameGeneration = ownership.installId !== undefined
            ? current?.install_id === ownership.installId
            : ownership.brokerId === undefined && current?.install_id === undefined;
        const legacyRuntimeMigration = ownership.installId === undefined &&
            ownership.brokerId !== undefined &&
            current?.broker_id === ownership.brokerId;
        if (current?.token !== ownership.token || (!sameGeneration && !legacyRuntimeMigration))
            return false;
        writePanelLauncherConfig(config, home);
        return true;
    }) ?? false;
}
function markPanelLauncherTornDown(home) {
    const paths = panelLauncherPaths(home);
    mkdirSync(paths.root, { recursive: true });
    writeFileSync(paths.teardown, `${new Date().toISOString()}\n`, {
        encoding: "utf8",
        mode: 0o600,
    });
}
function brokerRunArgs(broker, brokerId, installId) {
    return [broker, "run", `--broker-id=${brokerId}`, ...(installId ? [`--install-id=${installId}`] : [])];
}
export function uninstallPanelLauncher(options = {}) {
    const home = options.home ?? homedir();
    const completed = withPanelLauncherLock(home, () => {
        const platform = options.platform ?? process.platform;
        const run = options.exec ?? execFileSync;
        const killImpl = options.killImpl ?? process.kill.bind(process);
        const ownedConfig = readPanelLauncherConfig(home);
        const ownedBrokerPid = ownedConfig?.pid;
        // Same resolution as install, or uninstall deletes a path the install never
        // wrote and leaves the real autostart in place.
        const paths = panelLauncherPaths(home, roamingAppData(options));
        // This must precede service-manager teardown and config deletion. A broker
        // with an in-flight probe cannot publish while this lock is held.
        markPanelLauncherTornDown(home);
        if (platform === "win32") {
            try {
                run("schtasks.exe", ["/End", "/TN", PANEL_LAUNCHER_TASK], { stdio: "ignore" });
            }
            catch {
                // Already stopped.
            }
            try {
                run("schtasks.exe", ["/Delete", "/F", "/TN", PANEL_LAUNCHER_TASK], { stdio: "ignore" });
            }
            catch {
                // Already absent is the desired end state.
            }
            // …and the fallback autostart, which is what an account that could not
            // create the task actually has. Removing only the task would leave those
            // users with a launcher that keeps coming back after every uninstall.
            rmSync(paths.windowsStartup, { force: true });
        }
        else if (platform === "darwin") {
            try {
                run("launchctl", ["bootout", `gui/${process.getuid?.() ?? 0}`, paths.macPlist], { stdio: "ignore" });
            }
            catch {
                // Already absent.
            }
            rmSync(paths.macPlist, { force: true });
        }
        else {
            try {
                run("systemctl", ["--user", "disable", "--now", "comfyui-mcp-launcher.service"], {
                    stdio: "ignore",
                });
                run("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
            }
            catch {
                // systemd may not be the selected user-session manager.
            }
            rmSync(paths.linuxService, { force: true });
            rmSync(paths.linuxAutostart, { force: true });
        }
        // Service-manager teardown does not cover the direct broker started by a
        // Windows Startup/XDG fallback. Stop the broker after disabling every future
        // autostart, or its exit watcher can relaunch the orchestrator during remove.
        rmSync(paths.config, { force: true });
        // Remove ownership first so a recovery callback racing uninstall observes a
        // disabled install before we stop the recorded fallback process.
        stopOwnedPanelLauncherBroker(platform, run, killImpl, ownedBrokerPid, paths.broker, ownedConfig?.broker_id, ownedConfig?.broker_executable, options.processIdentityImpl);
        rmSync(paths.launcherDir, { force: true, recursive: true });
    });
    if (completed === null) {
        throw new Error("Could not acquire the panel launcher lock for uninstall");
    }
}
function safeTokenEqual(expected, actual) {
    const a = Buffer.from(expected);
    const b = Buffer.from(actual);
    return a.length === b.length && timingSafeEqual(a, b);
}
function websocketClientFrame(value) {
    const payload = Buffer.from(JSON.stringify(value));
    if (payload.length >= 126)
        throw new Error("launcher probe frame unexpectedly large");
    const mask = randomBytes(4);
    const frame = Buffer.alloc(2 + 4 + payload.length);
    frame[0] = 0x81;
    frame[1] = 0x80 | payload.length;
    mask.copy(frame, 2);
    for (let i = 0; i < payload.length; i += 1)
        frame[6 + i] = payload[i] ^ mask[i % 4];
    return frame;
}
function serverFrameTexts(buffer) {
    const out = [];
    let offset = 0;
    while (offset + 2 <= buffer.length) {
        const first = buffer[offset];
        const second = buffer[offset + 1];
        let length = second & 0x7f;
        let header = 2;
        if (length === 126) {
            if (offset + 4 > buffer.length)
                break;
            length = buffer.readUInt16BE(offset + 2);
            header = 4;
        }
        else if (length === 127) {
            break; // launcher probe frames are tiny; refuse an unexpected giant frame.
        }
        const masked = (second & 0x80) !== 0;
        if (masked)
            header += 4;
        if (offset + header + length > buffer.length)
            break;
        if ((first & 0x0f) === 1 && !masked) {
            out.push(buffer.subarray(offset + header, offset + header + length).toString("utf8"));
        }
        offset += header + length;
    }
    return out;
}
/** Proves that the bridge speaks the ComfyUI MCP panel protocol, rather than
 *  treating any process occupying the port as the orchestrator. */
export function probePanelOrchestrator(port = Number(process.env.COMFYUI_MCP_BRIDGE_PORT) || DEFAULT_PANEL_BRIDGE_PORT, timeoutMs = 1200) {
    return new Promise((resolve) => {
        const key = randomBytes(16).toString("base64");
        const accept = createHash("sha1").update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`).digest("base64");
        const socket = netConnect({ host: "127.0.0.1", port });
        let settled = false;
        let upgraded = false;
        let buffer = Buffer.alloc(0);
        const finish = (ok) => {
            if (settled)
                return;
            settled = true;
            clearTimeout(timer);
            socket.destroy();
            resolve(ok);
        };
        const timer = setTimeout(() => finish(false), timeoutMs);
        socket.once("error", () => finish(false));
        socket.once("connect", () => {
            socket.write(`GET / HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n` +
                `Sec-WebSocket-Key: ${key}\r\nSec-WebSocket-Version: 13\r\n\r\n`);
        });
        socket.on("data", (chunk) => {
            buffer = Buffer.concat([buffer, chunk]);
            if (!upgraded) {
                const end = buffer.indexOf("\r\n\r\n");
                if (end < 0)
                    return;
                const header = buffer.subarray(0, end).toString("utf8");
                if (!header.startsWith("HTTP/1.1 101") || !header.toLowerCase().includes(`sec-websocket-accept: ${accept.toLowerCase()}`)) {
                    finish(false);
                    return;
                }
                upgraded = true;
                buffer = buffer.subarray(end + 4);
                socket.write(websocketClientFrame({
                    type: "hello",
                    tab_id: `launcher-probe-${randomBytes(8).toString("hex")}`,
                    backend: "claude",
                }));
            }
            for (const text of serverFrameTexts(buffer)) {
                try {
                    const frame = JSON.parse(text);
                    if (["session_epoch", "backends", "models"].includes(String(frame.type))) {
                        finish(true);
                        return;
                    }
                }
                catch {
                    // Keep waiting for a protocol frame until the bounded timeout.
                }
            }
        });
    });
}
/**
 * Probe the port this process is pinned to, or — with no pin — the new default
 * and then the 9180-era default. A live 9180 session must not look "not
 * running" to the launcher just because this build's compiled default moved.
 */
export async function probeAnyPanelOrchestrator(timeoutMs = 1200) {
    const pinned = Number(process.env.COMFYUI_MCP_BRIDGE_PORT);
    if (Number.isInteger(pinned) && pinned > 0) {
        return probePanelOrchestrator(pinned, timeoutMs);
    }
    if (await probePanelOrchestrator(DEFAULT_PANEL_BRIDGE_PORT, timeoutMs))
        return true;
    return probePanelOrchestrator(LEGACY_PANEL_BRIDGE_PORT, timeoutMs);
}
/**
 * Command used by the installed broker. Unlike terminalCommandForPlatform(),
 * this is deliberately a headless child: the broker is the persistent login
 * companion, so the orchestrator must not inherit a disposable terminal or its
 * console-death semantics. `cmd /c` is used on Windows because npx is a .cmd
 * shim and the scheduled-task/Startup environment does not guarantee a shell.
 */
export function persistentCommandForPlatform(platform = process.platform, env = process.env) {
    if (platform === "win32") {
        return {
            executable: env.ComSpec || "cmd.exe",
            args: ["/d", "/c", "npx.cmd -y comfyui-mcp@latest connect"],
        };
    }
    return {
        executable: "npx",
        args: ["-y", "comfyui-mcp@latest", "connect"],
    };
}
export function launchPersistentMcp(platform = process.platform, env = process.env, spawnImpl = spawn) {
    const command = persistentCommandForPlatform(platform, env);
    const child = spawnImpl(command.executable, command.args, {
        detached: true,
        stdio: "ignore",
        windowsHide: platform === "win32",
    });
    child.unref?.();
    return { process: child, pid: child.pid, platform, interactive: false };
}
export function terminalCommandForPlatform(platform = process.platform, env = process.env, executableExists = existsSync) {
    if (platform === "win32") {
        return {
            executable: env.ComSpec || "cmd.exe",
            args: ["/d", "/k", "npx.cmd -y comfyui-mcp@latest connect"],
        };
    }
    if (platform === "darwin") {
        return {
            executable: "osascript",
            args: ["-e", 'tell application "Terminal" to do script "npx -y comfyui-mcp@latest connect"'],
        };
    }
    const candidates = ["x-terminal-emulator", "kgx", "gnome-terminal", "konsole", "xfce4-terminal"];
    const terminal = candidates.find((name) => {
        const dirs = (env.PATH || "").split(":").filter(Boolean);
        return dirs.some((dir) => executableExists(join(dir, name)));
    });
    if (!terminal)
        throw new Error("No supported graphical terminal was found");
    return {
        executable: terminal,
        args: terminal === "x-terminal-emulator"
            ? ["-e", "sh", "-lc", "exec npx -y comfyui-mcp@latest connect"]
            : terminal === "kgx" || terminal === "gnome-terminal"
                ? ["--", "sh", "-lc", "exec npx -y comfyui-mcp@latest connect"]
                : ["-e", "sh", "-lc", "exec npx -y comfyui-mcp@latest connect"],
    };
}
export function launchMcpTerminal(platform = process.platform) {
    const command = terminalCommandForPlatform(platform);
    const child = spawn(command.executable, command.args, {
        detached: true,
        stdio: "ignore",
        windowsHide: false,
    });
    child.unref();
    return { process: child, pid: child.pid, platform, interactive: true };
}
function minimizeOwnedTerminal(launch) {
    if (!launch?.pid || launch.interactive !== true || launch.platform !== "win32")
        return false;
    try {
        const script = `$p=Get-Process -Id ${launch.pid} -ErrorAction SilentlyContinue;` +
            `if($p -and $p.MainWindowHandle -ne 0){` +
            `Add-Type '[DllImport("user32.dll")] public static extern bool ShowWindowAsync(IntPtr hWnd,int nCmdShow);' -Name W -Namespace C;` +
            `[C.W]::ShowWindowAsync($p.MainWindowHandle,6)|Out-Null}`;
        execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", script], {
            stdio: "ignore",
        });
        return true;
    }
    catch {
        return false;
    }
}
function jsonResponse(res, status, body) {
    const data = Buffer.from(JSON.stringify(body));
    res.writeHead(status, {
        "Content-Type": "application/json",
        "Content-Length": String(data.length),
        "Cache-Control": "no-store",
    });
    res.end(data);
}
export async function startPanelLauncherBroker(home = homedir(), options = {}) {
    const current = readPanelLauncherConfig(home);
    const spawnBroker = options.spawnImpl ?? spawn;
    const probe = options.probeImpl ?? (() => probeAnyPanelOrchestrator());
    const now = options.now ?? (() => Date.now());
    const recoveryDelayMs = options.recoveryDelayMs ?? ORCHESTRATOR_RECOVERY_DELAY_MS;
    const recoveryMaxAttempts = options.recoveryMaxAttempts ?? ORCHESTRATOR_RECOVERY_MAX_ATTEMPTS;
    if (!current)
        throw new Error("Panel launcher is not installed or its config is invalid");
    // Older installs may not have a marker yet. Persist one as part of this
    // broker's runtime publication so future uninstall can fail closed.
    const brokerId = current.broker_id ?? randomBytes(24).toString("base64url");
    if (options.enforceCommandIdentity && !options.expectedBrokerId) {
        throw new Error("Panel launcher broker identity is missing from startup command");
    }
    if (options.expectedBrokerId !== undefined && brokerId !== options.expectedBrokerId) {
        throw new Error("Panel launcher broker identity changed before startup");
    }
    if (options.enforceCommandIdentity && current.install_id !== undefined && !options.expectedInstallId) {
        throw new Error("Panel launcher install generation is missing from startup command");
    }
    if (options.expectedInstallId !== undefined && current.install_id !== options.expectedInstallId) {
        throw new Error("Panel launcher install generation changed before startup");
    }
    const runtimeConfig = { ...current, broker_id: brokerId };
    const brokerConfigStillOwned = () => {
        if (existsSync(panelLauncherPaths(home).teardown))
            return false;
        const latest = readPanelLauncherConfig(home);
        if (latest?.token !== current.token)
            return false;
        return latest.install_id === current.install_id ||
            (current.install_id === undefined && latest.install_id !== undefined && latest.broker_id === brokerId);
    };
    let launch = null;
    let lastLaunchAt = 0;
    let recoveryTimer = null;
    let recoveryWindowStarted = null;
    let recoveryAttempts = 0;
    let starting = null;
    const resetRecoveryBudget = () => {
        recoveryWindowStarted = null;
        recoveryAttempts = 0;
    };
    const recoveryExhausted = () => {
        if (recoveryWindowStarted === null)
            return false;
        if (now() - recoveryWindowStarted >= ORCHESTRATOR_RECOVERY_WINDOW_MS) {
            resetRecoveryBudget();
            return false;
        }
        return recoveryAttempts >= recoveryMaxAttempts;
    };
    const scheduleRecovery = (exitCode) => {
        if (!brokerConfigStillOwned())
            return;
        if (recoveryTimer)
            return;
        const currentTime = now();
        if (recoveryWindowStarted === null || currentTime - recoveryWindowStarted >= ORCHESTRATOR_RECOVERY_WINDOW_MS) {
            recoveryWindowStarted = currentTime;
            recoveryAttempts = 0;
        }
        if (recoveryExhausted())
            return;
        // A clean exit is the normal self-update handoff. Give the replacement npx
        // process time to resolve/install and bind before deciding it needs help.
        const delay = exitCode === 0 ? recoveryDelayMs : Math.min(recoveryDelayMs, 5_000);
        recoveryTimer = setTimeout(() => {
            recoveryTimer = null;
            if (!brokerConfigStillOwned())
                return;
            if (recoveryAttempts >= recoveryMaxAttempts)
                return;
            recoveryAttempts += 1;
            // The failed child is no longer the launch in progress. This retry is
            // explicitly allowed through the ordinary launch cooldown.
            lastLaunchAt = 0;
            void ensureRunning(true).catch(() => {
                // The next panel request can retry after the normal cooldown. A broker
                // must never become an unbounded error/spawn loop on a broken install.
            });
        }, delay);
        recoveryTimer.unref?.();
    };
    const watchLaunch = (started) => {
        const child = started.process;
        if (!child)
            return;
        const onExit = (exitCode) => {
            if (launch?.process !== child)
                return;
            launch = null;
            scheduleRecovery(exitCode);
        };
        child.once("exit", onExit);
        child.once("error", () => onExit(null));
    };
    const ensureRunning = (fromRecovery = false) => {
        if (!starting) {
            const afterProbe = (running) => {
                // The probe is asynchronous. Uninstall may have removed ownership while
                // it was in flight; no result from that probe may publish or launch.
                if (!brokerConfigStillOwned()) {
                    return { already_running: false, started: false, disabled: true };
                }
                if (running) {
                    lastLaunchAt = 0;
                    if (recoveryTimer) {
                        clearTimeout(recoveryTimer);
                        recoveryTimer = null;
                    }
                    resetRecoveryBudget();
                    return { already_running: true, started: false };
                }
                // Panel polls are also recovery requests. Once the bounded supervisor
                // has used its budget, do not let a request after the normal launch
                // cooldown turn the same broken install into an unbounded spawn loop.
                if (!fromRecovery && recoveryExhausted()) {
                    return { already_running: false, started: false, recovery_exhausted: true };
                }
                if (!fromRecovery && lastLaunchAt && now() - lastLaunchAt < 90_000) {
                    return { already_running: false, started: false, start_in_progress: true };
                }
                // Serialize the final ownership check with spawn. Uninstall cannot
                // remove the tombstone/config between this check and the OS process
                // creation, including when a test seam or platform shim re-enters JS.
                const launched = withPanelLauncherLock(home, () => {
                    if (!brokerConfigStillOwned()) {
                        return { already_running: false, started: false, disabled: true };
                    }
                    lastLaunchAt = now();
                    try {
                        const started = launchPersistentMcp(process.platform, process.env, spawnBroker);
                        launch = started;
                        watchLaunch(started);
                    }
                    catch (error) {
                        // Treat a spawn refusal like an immediate child failure: surface the
                        // current request's error, but let the bounded supervisor make a few
                        // delayed recovery attempts instead of hot-looping on every panel poll.
                        scheduleRecovery(null);
                        throw error;
                    }
                    return { already_running: false, started: true };
                });
                if (launched === null) {
                    return { already_running: false, started: false, start_in_progress: true };
                }
                return launched;
            };
            const pending = brokerConfigStillOwned()
                ? probe().then(afterProbe)
                : Promise.resolve({ already_running: false, started: false, disabled: true });
            starting = pending.finally(() => {
                starting = null;
            });
        }
        return starting;
    };
    const server = createServer(async (req, res) => {
        const supplied = req.headers.authorization?.startsWith("Bearer ")
            ? req.headers.authorization.slice("Bearer ".length)
            : "";
        if (!safeTokenEqual(current.token, supplied)) {
            jsonResponse(res, 401, { ok: false, error: "unauthorized" });
            return;
        }
        if (req.method === "GET" && req.url === "/v1/status") {
            const latest = readPanelLauncherConfig(home);
            const effectiveInstallId = brokerConfigStillOwned() ? latest?.install_id : current.install_id;
            jsonResponse(res, 200, {
                ok: true,
                protocol: PANEL_LAUNCHER_PROTOCOL,
                broker_id: brokerId,
                ...(effectiveInstallId ? { install_id: effectiveInstallId } : {}),
                orchestrator_running: await probeAnyPanelOrchestrator(),
            });
            return;
        }
        if (req.method === "POST" && req.url === "/v1/ensure-running") {
            try {
                jsonResponse(res, 200, { ok: true, protocol: PANEL_LAUNCHER_PROTOCOL, ...(await ensureRunning()) });
            }
            catch (error) {
                jsonResponse(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
            }
            return;
        }
        if (req.method === "POST" && req.url === "/v1/handshake-complete") {
            jsonResponse(res, 200, { ok: true, minimized: minimizeOwnedTerminal(launch) });
            return;
        }
        jsonResponse(res, 404, { ok: false, error: "not_found" });
    });
    await new Promise((resolve, reject) => {
        server.once("error", reject);
        server.listen(0, "127.0.0.1", () => resolve());
    });
    const address = server.address();
    if (!address || typeof address === "string")
        throw new Error("Launcher failed to bind loopback");
    options.beforeConfigWrite?.();
    if (!brokerConfigStillOwned()) {
        await new Promise((resolve) => server.close(() => resolve()));
        throw new Error("Panel launcher was uninstalled while the broker was starting");
    }
    const published = writeOwnedPanelLauncherConfig({
        ...(readPanelLauncherConfig(home) ?? runtimeConfig),
        broker_id: brokerId,
        broker_executable: process.execPath,
        port: address.port,
        pid: process.pid,
        updated_at: new Date().toISOString(),
    }, home, { token: current.token, installId: current.install_id, brokerId });
    if (!published) {
        await new Promise((resolve) => server.close(() => resolve()));
        throw new Error("Panel launcher ownership changed before broker publication");
    }
    return server;
}
export async function queryPanelLauncher(home = homedir()) {
    const config = readPanelLauncherConfig(home);
    if (!config || !config.port)
        return { ok: false, installed: !!config, running: false };
    return await new Promise((resolve) => {
        const req = httpRequest({
            hostname: config.host,
            port: config.port,
            path: "/v1/status",
            method: "GET",
            headers: { Authorization: `Bearer ${config.token}` },
            timeout: 1500,
        }, (res) => {
            const chunks = [];
            res.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
            res.on("end", () => {
                try {
                    const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
                    // 200 + parseable JSON is not proof it is OUR broker: ports get
                    // recycled, and whoever holds this one next may answer anything.
                    // The body already carries the protocol — check it, or the recycled-
                    // port case this liveness probe exists to kill walks straight back in
                    // (merge-gate). Computed AFTER the spread so the body cannot set it.
                    const running = res.statusCode === 200 && body.protocol === PANEL_LAUNCHER_PROTOCOL;
                    resolve({ installed: true, ...body, running });
                }
                catch {
                    resolve({ ok: false, installed: true, running: false, error: "invalid_response" });
                }
            });
        });
        req.on("timeout", () => req.destroy(new Error("timeout")));
        req.on("error", () => resolve({ ok: false, installed: true, running: false }));
        req.end();
    });
}
// The installed copy is invoked directly by the login service. The source copy
// is imported by index.ts and therefore does not enter this branch.
const invokedPath = process.argv[1] ? basename(process.argv[1]) : "";
if (invokedPath === "broker.mjs" && process.argv[2] === "run") {
    const brokerArg = process.argv.slice(3).find((arg) => arg.startsWith("--broker-id="));
    const installArg = process.argv.slice(3).find((arg) => arg.startsWith("--install-id="));
    startPanelLauncherBroker(undefined, {
        expectedBrokerId: brokerArg?.slice("--broker-id=".length),
        expectedInstallId: installArg?.slice("--install-id=".length),
        enforceCommandIdentity: true,
    }).catch((error) => {
        process.stderr.write(`ComfyUI MCP launcher failed: ${error instanceof Error ? error.message : String(error)}\n`);
        process.exitCode = 1;
    });
}
//# sourceMappingURL=panel-launcher.js.map