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

5,862 lines • 306 kB
import { exec, execSync, spawn, } from "node:child_process";
import { randomUUID } from "node:crypto";
import { existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readlinkSync, statSync, unlinkSync, writeSync, } from "node:fs";
import { isIP } from "node:net";
import { homedir, platform } from "node:os";
import { isAbsolute, join } from "node:path";
import { promisify } from "node:util";
import { getSystemStats, resetClient, resetObjectInfoCache } from "../comfyui/client.js";
import { config, getBootLocalComfyUIBaseUrl, getComfyUIBaseUrl, getComfyuiTargetGeneration, isRemoteMode, targetIsOnThisMachine, } from "../config.js";
import { comfyuiFetch, describeTargetDrift, raceAbort } from "../comfyui/fetch.js";
import { scrubLogLines } from "../comfyui/json-guard.js";
import { errorText } from "../orchestrator/error-text.js";
import { acquireInstanceWitness, } from "./instance-witness.js";
import { ProcessControlError } from "../utils/errors.js";
import { logger } from "../utils/logger.js";
import { desktopSavedLaunchArgs, describeSavedLaunchArgDrift, } from "./desktop-launch-args.js";
import { findComfyuiPython } from "./env-capabilities.js";
import { preferStabilityMatrixPackagePython, readLiveProcessEnv, resolveLaunchEnvironment, withUtf8StdioEnv, } from "./launcher-env.js";
import { classifyDesktopSupervision, classifyListenerOwnership, launchedChildStillRunning, unclassifiedOwnership, unclassifiedSupervision, } from "./listener-ownership.js";
import { isLoopbackServerUrl } from "./local-vram.js";
import { resetManagerApiCache } from "./manager-api-cache.js";
import { MANAGER_VERSION_ROUTES, parseManagerMajor } from "./manager-version.js";
import { parseListenerPidFromNetstat, findPidByPort, probePortOwner, } from "./port-owner.js";
import { recordLaunchedInterpreter, clearLaunchedInterpreter, readProcessIdentity, argv0FromCommandLine, commandLineMatchesArgv, } from "./live-interpreter.js";
import { liveRootFromArgv, resolveEffectiveComfyUIBase, resolveLiveServerRoot, markLocalComfyUILaunched, resetLocalComfyUILaunchState, } from "./workspace-env.js";
function currentTargetFence() {
    return {
        baseUrl: getComfyUIBaseUrl().replace(/\/+$/, ""),
        generation: getComfyuiTargetGeneration(),
        comfyuiPath: config.comfyuiPath,
    };
}
export function captureComfyUITargetFence() {
    return currentTargetFence();
}
export function targetFencesEqual(a, b) {
    return (a != null &&
        b != null &&
        a.baseUrl.replace(/\/+$/, "") === b.baseUrl.replace(/\/+$/, "") &&
        a.generation === b.generation &&
        a.comfyuiPath === b.comfyuiPath);
}
export function targetFenceMatchesCurrent(fence) {
    return targetFencesEqual(fence, currentTargetFence());
}
// ---------------------------------------------------------------------------
// Module-level state — persists between MCP tool calls within a session
// ---------------------------------------------------------------------------
let lastProcessInfo = null;
let supervisedChild = null;
let supervisedExitHandler = null;
let supervisedErrorHandler = null;
let supervisorRestartCount = 0;
let supervisorWindowStartedAt = 0;
let supervisorGaveUp = false;
// ---------------------------------------------------------------------------
// Cross-platform helpers
// ---------------------------------------------------------------------------
const IS_WIN = platform() === "win32";
// parseListenerPidFromNetstat / findPidByPort now live in port-owner.ts so the
// live-interpreter resolver can use them without importing this module (which would
// cycle through workspace-env). Re-exported here: this is still their public home.
export { parseListenerPidFromNetstat, findPidByPort };
function isExpectedPgrepNoMatch(error) {
    return (!IS_WIN &&
        typeof error === "object" &&
        error !== null &&
        "status" in error &&
        error.status === 1);
}
function findDesktopAppPidsWithStatus() {
    const pids = [];
    let complete = true;
    if (IS_WIN) {
        for (const exe of ["ComfyUI.exe", "Comfy Desktop.exe"]) {
            try {
                const out = execSync(`tasklist /FI "IMAGENAME eq ${exe}" /FO CSV /NH`, { encoding: "utf-8", timeout: 5000 }).trim();
                for (const line of out.split("\n")) {
                    // CSV format: "ComfyUI.exe","12345","Console","1","206,248 K"
                    // (the image name is already filtered — match any first column)
                    const match = line.match(/^"[^"]+","(\d+)"/);
                    if (match)
                        pids.push(parseInt(match[1], 10));
                }
            }
            catch {
                complete = false;
            }
        }
    }
    else {
        try {
            const out = execSync(`pgrep -f "ComfyUI.app|Comfy Desktop.app"`, {
                encoding: "utf-8",
                timeout: 5000,
            }).trim();
            for (const line of out.split("\n")) {
                const pid = parseInt(line, 10);
                if (!isNaN(pid) && pid > 0)
                    pids.push(pid);
            }
        }
        catch (error) {
            if (!isExpectedPgrepNoMatch(error))
                complete = false;
        }
    }
    return { pids, complete };
}
function findDesktopAppPids() {
    return findDesktopAppPidsWithStatus().pids;
}
/**
 * Find Python processes that may be the Desktop server when the port-owner
 * lookup is unavailable. This is only a candidate list: every candidate is
 * re-read through the process identity reader and must still pass the exact
 * command and authenticated-ancestry checks below.
 */
function findPythonProcessPids() {
    const pids = new Set();
    let complete = true;
    const names = IS_WIN ? ["python.exe", "pythonw.exe"] : ["python", "python3", "pythonw"];
    for (const name of names) {
        try {
            const out = IS_WIN
                ? execSync(`tasklist /FI "IMAGENAME eq ${name}" /FO CSV /NH`, {
                    encoding: "utf-8",
                    timeout: 5000,
                })
                : execSync(`pgrep -x "${name}"`, {
                    encoding: "utf-8",
                    timeout: 5000,
                });
            for (const line of String(out).split(/\r?\n/)) {
                const match = IS_WIN ? line.match(/^"[^"]+","(\d+)"/) : line.match(/^\s*(\d+)\s*$/);
                const pid = match ? Number.parseInt(match[1], 10) : NaN;
                if (Number.isInteger(pid) && pid > 0)
                    pids.add(pid);
            }
        }
        catch (error) {
            if (!isExpectedPgrepNoMatch(error))
                complete = false;
        }
    }
    return { pids: [...pids], complete };
}
/**
 * Did a kill fail because the target was already gone?
 *
 * Windows `taskkill /T` often kills the Python server as a child, then exits
 * non-zero because a helper PID in the same tree is already absent. The
 * message is frequently "There is no running instance of the task" — which
 * does not contain "not found" — so matching only the latter left stop
 * reporting `stopped:false` after the port had already closed (#2482).
 */
function killOutput(err) {
    if (!(err instanceof Error))
        return String(err);
    const chunks = [err.message];
    if ("stderr" in err && typeof err.stderr === "string")
        chunks.push(err.stderr);
    if ("stdout" in err && typeof err.stdout === "string")
        chunks.push(err.stdout);
    return chunks.join("\n");
}
function killErrorLooksAlreadyGone(err) {
    return /not found|no such process|does not exist|no running instance|esrch/i.test(killOutput(err));
}
function killProcessTree(pid) {
    try {
        if (IS_WIN) {
            execSync(`taskkill /PID ${pid} /T /F`, {
                encoding: "utf-8",
                timeout: 10000,
            });
        }
        else {
            // Try SIGTERM first, then SIGKILL after a short wait
            try {
                process.kill(-pid, "SIGTERM");
            }
            catch {
                process.kill(pid, "SIGTERM");
            }
            // Give it a moment, then force kill
            try {
                execSync(`sleep 1 && kill -9 ${pid} 2>/dev/null`, {
                    encoding: "utf-8",
                    timeout: 5000,
                });
            }
            catch {
                // Already dead — that's fine
            }
        }
    }
    catch (err) {
        // "not found" / "no such process" / a vanished Windows task are fine —
        // the process is already dead. Callers still re-probe the port (#2482).
        if (killErrorLooksAlreadyGone(err))
            return;
        const msg = killOutput(err);
        throw new ProcessControlError(`Failed to kill process ${pid}: ${msg}`);
    }
}
/**
 * Kill the Desktop app entirely — find all Electron shell PIDs and kill each tree.
 * Falls back to killing just the port PID if no Desktop processes found.
 */
function killDesktopApp(portPid) {
    const desktopPids = findDesktopAppPids();
    if (desktopPids.length > 0) {
        logger.info(`Killing Desktop app processes: ${desktopPids.join(", ")}`);
        for (const pid of desktopPids) {
            killProcessTree(pid);
        }
    }
    else {
        // Fallback — just kill the port process
        killProcessTree(portPid);
    }
}
/**
 * Is THIS PROCESS the Desktop supervisor — the Electron shell itself?
 *
 * Deliberately NOT `isDesktopApp`, and the difference matters. That one asks "was
 * this SERVER started by Desktop?", and answers yes on any mention of the Desktop
 * install — including a `--extra-model-paths-config` pointing into
 * `…/Comfy Desktop/…`, which is exactly how a Desktop backend identifies itself.
 * Reusing it here would let a WRAPPER that merely passes such a flag stand in for
 * the shell, and a wrapper cannot re-exec anything (codex gate).
 *
 * So this requires the Desktop binary to be the process's OWN EXECUTABLE — argv[0],
 * never merely somewhere on the command line. Searching the whole line let a wrapper
 * that PASSES the Desktop exe as an argument
 * (`python wrapper.py --desktop-exe "…/Comfy Desktop.exe"`) stand in for the shell,
 * and a wrapper re-execs nothing (codex gate round 2).
 *
 * THE ASYMMETRY IS DELIBERATE. Failing to recognise a real supervisor costs the walk
 * a hop and can end in `abandoned` — a REFUSED restart, which leaves the server
 * running and points the user at the Desktop app that would have restarted it
 * anyway. Recognising a NON-supervisor licenses a stop that nothing undoes. So when
 * the evidence is shaped awkwardly, this errs strict.
 */
function isDesktopSupervisorProcess(identity) {
    const looksLikeDesktopBinary = (path) => {
        const norm = path.replace(/\\/g, "/").toLowerCase();
        const base = norm.split("/").pop() ?? "";
        // Current and legacy Windows branding, including the electron-era install dir.
        if (base === "comfy desktop.exe" || base === "comfyui.exe")
            return true;
        // macOS: the bundle's MAIN binary. Accepting ANY binary under `Contents/MacOS/`
        // would be too loose — a venv shim or launcher script living inside the bundle
        // would pass, and a shim re-execs nothing (coordinator gate) — so BOTH halves are
        // pinned to the trusted product names. Electron HELPERS are excluded structurally:
        // they live in `Contents/Frameworks/<Helper>.app/Contents/MacOS/…`, so the first
        // bundle in the path is not followed by `Contents/MacOS/`.
        //
        // #1341 — the two names are matched INDEPENDENTLY, not tied by a backreference.
        // The convention that a bundle's binary is named after the bundle does not hold
        // for current Desktop packaging: ComfyUI Desktop 1.0.38 ships bundle `ComfyUI.app`
        // with main executable `Comfy Desktop`. The backreference rejected that real
        // supervisor, the ancestry walk ran to PID 1, and a reporter with a healthy
        // Desktop parent was told "no Desktop app is still supervising it — its parent
        // process is gone" and refused a safe restart.
        //
        // This does NOT loosen the asymmetry documented above. Both halves are still
        // drawn from the same two-name set, so nothing new becomes a supervisor; only the
        // requirement that they be the SAME name is dropped, and that requirement was
        // about Apple's naming convention rather than about trust.
        return /\/(?:comfy desktop|comfyui)\.app\/contents\/macos\/(?:comfy desktop|comfyui)$/.test(norm);
    };
    // THE KERNEL'S ANSWER FIRST, and ALONE when it exists (codex gate round 3).
    // argv[0] is a string the launcher chose: `exec -a`, or a hand-built Windows
    // command line, lets any program present itself as any other, so a check that
    // trusts it can be told "I am Comfy Desktop.exe" by something that is not. The
    // executable path comes from the OS (`Win32_Process.ExecutablePath`,
    // `/proc/<pid>/exe`) and the process cannot set it. When we have it, a NEGATIVE
    // from it is final — falling through to argv[0] afterwards would hand the claim
    // back its authority.
    if (identity.executablePath)
        return looksLikeDesktopBinary(identity.executablePath);
    // argv[0] — exact on Linux, and on Windows the launcher quotes a path with spaces
    // (which "Comfy Desktop" always has), so the tokeniser recovers it whole. Reached
    // only where the OS withheld the authenticated path (macOS `ps` has no such
    // column; an elevated Windows process may withhold it), which is the evidence this
    // check had before and is still better than nothing.
    const exe = identity.argv?.[0] ?? argv0FromCommandLine(identity.commandLine ?? "") ?? "";
    if (looksLikeDesktopBinary(exe))
        return true;
    // macOS: `ps -o command=` prints argv joined by SPACES, so an app-bundle argv[0]
    // containing one ("Comfy Desktop.app" — the current branding) cannot be tokenised
    // back out, and every such install would otherwise walk past its own shell into a
    // false `abandoned`. It is still recognisable by POSITION rather than content:
    // argv[0] OPENS the command line, and the only space in the bundle path is inside
    // the app name itself, so the directory prefix before it has none. An argument can
    // never satisfy that — reaching it would mean crossing the space that separates it
    // from argv[0].
    //
    // The BINARY NAME is required here too, for the same reason as above: a launcher
    // script or venv shim inside the bundle would otherwise be read as the shell
    // (coordinator gate). The match must end at whitespace or end-of-line so the binary
    // is the whole argv[0] rather than a prefix of some longer name.
    //
    // #1341 — and the two names are independent here as well. This fallback carried the
    // identical backreference, so fixing only the executable-path matcher above would
    // have left macOS installs failing on exactly the path macOS actually takes: `ps`
    // has no authenticated-executable column, so this IS the branch a Desktop-managed
    // mac reaches.
    const line = (identity.commandLine ?? "").trim().replace(/\\/g, "/").toLowerCase();
    return /^"?(\/[^\s"]*\/)?(?:comfy desktop|comfyui)\.app\/contents\/macos\/(?:comfy desktop|comfyui)(\s|"|$)/.test(line);
}
/**
 * The Desktop marker this argv carries, or null.
 *
 * #2784 — WHICH one matched is now returned, not just whether one did. These are
 * bare substrings over the joined command line, and this function's own doc
 * elsewhere admits it "answers yes on any mention of the Desktop install". That
 * was cheap when the answer only chose a gentler restart route; since #814 it
 * gates a REFUSAL, so a false positive strands the user permanently — and the
 * refusal asserted "ComfyUI Desktop started the server" while showing an argv the
 * reporter could see was not Desktop's, with nothing to check.
 *
 * Naming the marker makes that self-diagnosing: a user whose install merely lives
 * under a folder called `Comfy-Desktop` reads "matched `comfy-desktop`" and knows
 * in one line what happened, instead of filing a report that cannot be reproduced
 * from the redacted path.
 */
function desktopArgvMarker(argv) {
    const joined = argv.join(" ").toLowerCase();
    const markers = [
        "programs/comfyui/resources",
        "programs\\comfyui\\resources",
        "comfyui.app",
        // Current branding ("Comfy Desktop\Comfy Desktop.exe", "Comfy Desktop.app")
        // and the electron-era install dir.
        "comfy desktop",
        // Desktop-2 data root (`%LOCALAPPDATA%\Comfy-Desktop\…`) and the install
        // marker `.comfyui-desktop-2` when it appears on argv.
        "comfy-desktop",
        "comfyui-desktop-2",
        "@comfyorgcomfyui-electron",
    ];
    return markers.find((m) => joined.includes(m)) ?? null;
}
function isDesktopApp(argv) {
    return desktopArgvMarker(argv) !== null;
}
/**
 * Parent directory that works for both Windows and POSIX spellings, even when
 * this process is running on the other OS (tests feed Windows paths on Linux).
 */
function parentOfPath(p) {
    const trimmed = p.replace(/[/\\]+$/, "");
    const slash = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
    if (slash <= 0)
        return "";
    return trimmed.slice(0, slash);
}
function joinOnPath(dir, ...parts) {
    const sep = dir.includes("\\") ? "\\" : "/";
    return [dir.replace(/[/\\]+$/, ""), ...parts].join(sep);
}
/**
 * Desktop-2 writes `<install>/.comfyui-desktop-2` and snapshot JSON under
 * `<install>/.launcher/snapshots/`. Either is enough to identify the layout
 * when argv has no "Comfy Desktop" token (#2482).
 */
function hasDesktop2Markers(dir) {
    if (!dir)
        return false;
    if (fileExists(joinOnPath(dir, ".comfyui-desktop-2")))
        return true;
    const snapDir = joinOnPath(dir, ".launcher", "snapshots");
    try {
        if (!existsSync(snapDir))
            return false;
        return readdirSync(snapDir).some((name) => name.endsWith(".json"));
    }
    catch {
        return false;
    }
}
function looksLikeDesktop2Root(dir) {
    const n = dir.replace(/\\/g, "/").toLowerCase();
    return (n.includes("/comfyui-installs/") ||
        n.endsWith("/comfyui-installs") ||
        n.includes("/comfy-desktop/") ||
        n.includes("comfyui-desktop-2"));
}
function looksLikeDesktop2Argv(argv) {
    return argv.some((tok) => Boolean(tok && looksLikePath(tok) && looksLikeDesktop2Root(tok)));
}
function desktop2InstallFromArgv(argv) {
    for (const tok of argv) {
        if (!tok || !looksLikePath(tok))
            continue;
        let dir = /\.[A-Za-z0-9]+$/.test(tok.replace(/[/\\]+$/, "")) ? parentOfPath(tok) : tok;
        for (let i = 0; i < 6 && dir; i++) {
            // Only probe markers on a Desktop-2-shaped root. existsSync is stubbed
            // true in several process-control suites, so a global marker walk would
            // reclassify ordinary python installs as Desktop.
            if (looksLikeDesktop2Root(dir) && hasDesktop2Markers(dir))
                return true;
            const next = parentOfPath(dir);
            if (!next || next === dir)
                break;
            dir = next;
        }
    }
    return false;
}
/** Windows Desktop-2 NSIS layout: `…\Programs\ComfyUI\Comfy Desktop\Comfy Desktop.exe`. */
function desktop2WindowsExeCandidates() {
    const localAppData = process.env.LOCALAPPDATA;
    const userProfile = process.env.USERPROFILE;
    const out = [];
    if (localAppData) {
        out.push(`${localAppData}\\Programs\\ComfyUI\\Comfy Desktop\\Comfy Desktop.exe`);
    }
    if (userProfile) {
        out.push(`${userProfile}\\AppData\\Local\\Programs\\ComfyUI\\Comfy Desktop\\Comfy Desktop.exe`);
    }
    return out;
}
/**
 * Walk a few ancestors of the backend PID looking for the Electron shell.
 * Desktop-2's usual tree is `Comfy Desktop.exe` → python → server python (#2482).
 */
function desktopExeFromAncestor(pid) {
    let current = pid;
    const seen = new Set();
    for (let hop = 0; hop < 5; hop++) {
        if (seen.has(current))
            return undefined;
        seen.add(current);
        const parent = readParentPid(current);
        if (parent == null || parent <= 1 || parent === current)
            return undefined;
        const identity = resolveProcessIdentity(parent);
        if (identity && isDesktopSupervisorProcess(identity)) {
            const exe = identity.executablePath ?? identity.argv?.[0];
            if (exe)
                return exe;
        }
        current = parent;
    }
    return undefined;
}
function detectDesktopLaunch(pid, argv) {
    const argvMarker = desktopArgvMarker(argv);
    const fromArgv = argvMarker !== null;
    const fromMarkers = desktop2InstallFromArgv(argv);
    const desktop2Argv = looksLikeDesktop2Argv(argv);
    // Walking ancestors calls resolveProcessIdentity. Doing that on every gather
    // consumes identity-sequence steps, so a later creation-time change is never
    // seen and a recycled PID is killed (#776). Only walk when THIS argv already
    // names a Desktop-2 layout.
    const fromParent = fromMarkers || desktop2Argv ? desktopExeFromAncestor(pid) : undefined;
    const isDesktop = fromArgv || Boolean(fromParent) || fromMarkers;
    if (!isDesktop)
        return { isDesktopApp: false };
    // #2784 — WHY this install was classified Desktop-managed, in the words of the
    // check that decided it. Three signals reach the same `true` and they are not
    // equally strong: an on-disk Desktop-2 marker is a fact about the install, an
    // ancestor Desktop binary is a fact about the process tree, and an argv
    // substring is a name match a directory can satisfy by being called that. The
    // refusal downstream asserted "ComfyUI Desktop started the server" with none of
    // this attached, so a reporter who could see it was wrong had nothing to check.
    //
    // Ordered strongest-first, and it names only what was actually consulted — the
    // ancestor walk is skipped entirely unless the argv already looks Desktop-2, so
    // "no Desktop binary among the ancestors" is often a question nobody asked.
    const desktopEvidence = fromMarkers
        ? "a Desktop-2 install marker (.comfyui-desktop-2, or .launcher/snapshots/*.json) on a directory above the launch path"
        : fromParent
            ? "a ComfyUI Desktop binary among this process's ancestors"
            : `the substring "${argvMarker}" in the server's own launch command — a NAME match, which a directory merely CALLED that also satisfies`;
    if (fromParent && fileExists(fromParent)) {
        return { isDesktopApp: true, desktopExePath: fromParent, desktopEvidence };
    }
    // fileExists, not `if exist` / `test -d`: POSIX findDesktopExeFromCommonPaths
    // treats a stubbed execSync success as "the .app is there".
    if (fromMarkers || fromParent || desktop2Argv) {
        const located = desktop2WindowsExeCandidates().find((p) => fileExists(p));
        if (located)
            return { isDesktopApp: true, desktopExePath: located, desktopEvidence };
        if (fromParent)
            return { isDesktopApp: true, desktopExePath: fromParent, desktopEvidence };
    }
    return {
        isDesktopApp: true,
        desktopExePath: findDesktopExePath(argv),
        desktopEvidence,
    };
}
/**
 * Return a path without command-line quoting and with the host's path rules
 * applied. POSIX paths are intentionally not case-folded: unlike Windows,
 * `/ComfyUI/main.py` and `/comfyui/main.py` are different paths.
 */
function normalizeRecoveryPath(path) {
    const unquoted = path.trim().replace(/^['"]+|['"]+$/g, "");
    if (!IS_WIN)
        return unquoted;
    return unquoted.replace(/\\/g, "/").toLowerCase();
}
function isAbsoluteRecoveryPath(path) {
    return IS_WIN
        ? /^[a-z]:[\\/]/i.test(path) || /^\\\\/.test(path)
        : path.startsWith("/");
}
function isComfyUIServerScript(path) {
    const normalized = normalizeRecoveryPath(path);
    const segments = normalized.split("/").filter(Boolean);
    if (segments.pop() !== "main.py")
        return false;
    // Desktop's H3 launch names the ComfyUI install explicitly. A broad
    // "Desktop" marker elsewhere in argv must not make an arbitrary helper.py
    // authoritative.
    return segments.some((segment) => segment === (IS_WIN ? "comfyui" : "ComfyUI"));
}
function isDesktopConfigPath(path) {
    const normalized = normalizeRecoveryPath(path);
    if (!isAbsoluteRecoveryPath(path))
        return false;
    const segments = normalized.split("/").filter(Boolean);
    return segments.some((segment) => segment === (IS_WIN ? "comfy desktop" : "Comfy Desktop"));
}
/**
 * Is this the exact H3 server command Desktop uses for its backend?
 *
 * The old check only looked for `main.py` plus any Desktop-looking token. That
 * allowed an unrelated helper launched from the Desktop install to become a
 * restart target. Keep the accepted shape deliberately narrow: script, Manager
 * flag, one absolute Desktop model-paths config, and the requested port, in that
 * order and with no extra arguments.
 */
function isDesktopServerArgv(argv, port) {
    if (argv.length !== 6)
        return false;
    const [script, managerFlag, configFlag, configPath, portFlag, reportedPort] = argv;
    return Boolean(script &&
        managerFlag === "--enable-manager" &&
        configFlag === "--extra-model-paths-config" &&
        configPath &&
        isComfyUIServerScript(script) &&
        isDesktopConfigPath(configPath) &&
        portFlag === "--port" &&
        reportedPort === String(port));
}
function normalizeRecoveryToken(token) {
    const unquoted = token.trim().replace(/^['"]+|['"]+$/g, "");
    return IS_WIN ? unquoted.replace(/\\/g, "/").toLowerCase() : unquoted;
}
/**
 * Match the server's argv to an OS process command without the permissive
 * substring semantics used for ordinary corroboration. Requiring equal lengths
 * rejects an injected extra command or flag. Windows permits the process table to
 * report an absolute spelling for Desktop's relative `ComfyUI\\main.py`; POSIX
 * does not get that suffix fallback because its path and case rules are exact.
 */
function exactDesktopServerCommand(identity, serverArgv) {
    if (identity.argvFidelity !== "exact" ||
        !identity.commandLine ||
        !identity.argv ||
        identity.argv.length < 2) {
        return false;
    }
    const observed = identity.argv.slice(1);
    if (observed.length !== serverArgv.length)
        return false;
    return observed.every((token, index) => {
        const actual = normalizeRecoveryToken(token);
        const expected = normalizeRecoveryToken(serverArgv[index] ?? "");
        if (actual === expected)
            return true;
        // Only Windows may reconcile Desktop's relative sys.argv[0] with the
        // absolute script path exposed by the process table.
        const expectedRaw = (serverArgv[index] ?? "").trim().replace(/^['"]+|['"]+$/g, "");
        if (!IS_WIN ||
            index !== 0 ||
            /^[a-z]:[\\/]/i.test(expectedRaw) ||
            /^\\\\/.test(expectedRaw)) {
            return false;
        }
        return actual.endsWith(`/${expected}`);
    });
}
function isVerifiedExecutablePath(path) {
    return Boolean(path && isAbsoluteRecoveryPath(path));
}
function sameRecoveryPath(a, b) {
    return normalizeRecoveryPath(a) === normalizeRecoveryPath(b);
}
function hasExactProcessIdentity(identity) {
    return Boolean(identity?.startedAt &&
        identity.executablePath &&
        identity.commandLine &&
        identity.argvFidelity === "exact" &&
        identity.argv?.length);
}
/**
 * The process reader exposes two different Python paths on Windows/Linux for a
 * venv: `executablePath` is the base interpreter image, while argv[0] is the venv
 * interpreter that owns the server's site-packages. Either path alone is not
 * enough; the pair must have the shape the reader documents, and the server's
 * exact H3 command must tie the venv path to the ComfyUI install.
 */
function isPythonInterpreterPath(path) {
    if (!isVerifiedExecutablePath(path))
        return false;
    const basename = path.replace(/\\/g, "/").split("/").pop()?.toLowerCase() ?? "";
    return /^python(?:w)?(?:\d+(?:\.\d+)*)?(?:\.exe)?$/.test(basename);
}
function isAuthenticatedPython(identity, serverArgv) {
    const executable = identity.executablePath;
    const argv0 = identity.argv?.[0];
    if (!hasExactProcessIdentity(identity) ||
        !isPythonInterpreterPath(executable) ||
        !isPythonInterpreterPath(argv0)) {
        return false;
    }
    // A normal process has the same path in both fields. A venv process is the
    // documented exception: the kernel image resolves to base Python, while
    // argv[0] remains the venv trampoline. Do not accept arbitrary two-Python
    // paths; only that one-way base -> venv relationship is reader-proven.
    const samePath = sameRecoveryPath(executable, argv0);
    if (!samePath && !(isVenvInterpreterPath(argv0) && !isVenvInterpreterPath(executable))) {
        return false;
    }
    if (!serverArgv)
        return true;
    // A recovered Desktop server must use a venv interpreter under the same
    // ComfyUI install named by its H3 script. This rejects an arbitrary absolute
    // `C:\\attacker\\venv\\Scripts\\python.exe` even when its basename is valid.
    if (!isVenvInterpreterPath(argv0))
        return false;
    const script = normalizeRecoveryPath(serverArgv[0] ?? "");
    const pythonPath = normalizeRecoveryPath(argv0);
    const comfySegment = IS_WIN ? "comfyui" : "ComfyUI";
    const scriptSegments = script.split("/").filter(Boolean);
    const pythonSegments = pythonPath.split("/").filter(Boolean);
    const scriptRootIndex = scriptSegments.lastIndexOf(comfySegment);
    const pythonRootIndex = pythonSegments.lastIndexOf(comfySegment);
    if (scriptRootIndex < 0 || pythonRootIndex < 0)
        return false;
    if (isAbsoluteRecoveryPath(serverArgv[0] ?? "")) {
        const expectedRoot = scriptSegments.slice(0, scriptRootIndex + 1);
        if (!expectedRoot.every((segment, index) => pythonSegments[index] === segment)) {
            return false;
        }
    }
    return true;
}
function expectedDesktopExecutablePaths() {
    if (IS_WIN) {
        const localAppData = process.env.LOCALAPPDATA;
        const userProfile = process.env.USERPROFILE;
        return [
            "C:/Program Files/Comfy Desktop/Comfy Desktop.exe",
            "C:/Program Files/ComfyUI/ComfyUI.exe",
            ...(localAppData
                ? [
                    `${localAppData}/Programs/Comfy Desktop/Comfy Desktop.exe`,
                    // Desktop-2 NSIS: …\Programs\ComfyUI\Comfy Desktop\Comfy Desktop.exe
                    // MUST outrank the legacy v1 …\Programs\ComfyUI\ComfyUI.exe (#2482).
                    `${localAppData}/Programs/ComfyUI/Comfy Desktop/Comfy Desktop.exe`,
                    `${localAppData}/Programs/@comfyorgcomfyui-electron/ComfyUI.exe`,
                    `${localAppData}/Programs/ComfyUI/ComfyUI.exe`,
                ]
                : []),
            ...(userProfile
                ? [
                    `${userProfile}/AppData/Local/Programs/ComfyUI/Comfy Desktop/Comfy Desktop.exe`,
                    `${userProfile}/Programs/ComfyUI/ComfyUI.exe`,
                ]
                : []),
        ];
    }
    const home = homedir();
    return [
        "/Applications/Comfy Desktop.app/Contents/MacOS/Comfy Desktop",
        "/Applications/Comfy Desktop.app/Contents/MacOS/ComfyUI",
        "/Applications/ComfyUI.app/Contents/MacOS/Comfy Desktop",
        "/Applications/ComfyUI.app/Contents/MacOS/ComfyUI",
        `${home}/Applications/Comfy Desktop.app/Contents/MacOS/Comfy Desktop`,
        `${home}/Applications/Comfy Desktop.app/Contents/MacOS/ComfyUI`,
        `${home}/Applications/ComfyUI.app/Contents/MacOS/Comfy Desktop`,
        `${home}/Applications/ComfyUI.app/Contents/MacOS/ComfyUI`,
    ];
}
function isExpectedDesktopExecutablePath(path) {
    const normalized = normalizeRecoveryPath(path);
    return expectedDesktopExecutablePaths().some((candidate) => sameRecoveryPath(normalized, candidate));
}
function isAuthenticatedDesktopSupervisor(identity) {
    return Boolean(hasExactProcessIdentity(identity) &&
        identity.executablePath &&
        identity.argv?.[0] &&
        isExpectedDesktopExecutablePath(identity.executablePath) &&
        sameRecoveryPath(identity.executablePath, identity.argv[0]) &&
        isDesktopSupervisorProcess(identity));
}
function directDesktopPythonAncestry(pid, knownPids, readParent, readIdentity) {
    // The issue's accepted shape is specifically Desktop.exe -> python.exe ->
    // server python.exe. Do not walk through an arbitrary wrapper and turn a
    // merely shared Desktop ancestor into restart authority.
    const pythonPid = readParent(pid);
    if (pythonPid == null)
        return "unavailable";
    if (pythonPid <= 1 || pythonPid === pid)
        return undefined;
    const python = readIdentity(pythonPid);
    if (!python || !isAuthenticatedPython(python))
        return "unavailable";
    const desktopPid = readParent(pythonPid);
    if (desktopPid == null)
        return "unavailable";
    if (desktopPid <= 1 || desktopPid === pythonPid)
        return undefined;
    if (!knownPids.has(desktopPid))
        return undefined;
    const desktop = readIdentity(desktopPid);
    if (!desktop ||
        !hasExactProcessIdentity(desktop) ||
        !isVerifiedExecutablePath(desktop.executablePath)) {
        return "unavailable";
    }
    if (!isAuthenticatedDesktopSupervisor(desktop))
        return undefined;
    return { desktopPid, desktopIdentity: desktop };
}
/**
 * Try to find the ComfyUI Desktop exe from common install locations.
 * Used as a fallback when no process info was previously captured.
 */
function findDesktopExeFromCommonPaths() {
    if (IS_WIN) {
        const home = process.env.LOCALAPPDATA || process.env.USERPROFILE || "";
        const candidates = [
            // Current branding: "Comfy Desktop" (per-machine and per-user installs)
            `C:\\Program Files\\Comfy Desktop\\Comfy Desktop.exe`,
            `${process.env.LOCALAPPDATA}\\Programs\\Comfy Desktop\\Comfy Desktop.exe`,
            // Desktop-2 NSIS layout. Checked BEFORE legacy ComfyUI.exe — both can
            // exist after an upgrade, and spawning v1 against the shared Desktop-2
            // venv downgrades packages and breaks the 0.34+ core (#2482).
            `${process.env.LOCALAPPDATA}\\Programs\\ComfyUI\\Comfy Desktop\\Comfy Desktop.exe`,
            // Electron-era install dir
            `${process.env.LOCALAPPDATA}\\Programs\\@comfyorgcomfyui-electron\\ComfyUI.exe`,
            // Legacy names
            `${home}\\Programs\\ComfyUI\\ComfyUI.exe`,
            `${process.env.LOCALAPPDATA}\\Programs\\ComfyUI\\ComfyUI.exe`,
            `C:\\Program Files\\ComfyUI\\ComfyUI.exe`,
        ];
        for (const p of candidates) {
            try {
                const result = execSync(`if exist "${p}" echo found`, { encoding: "utf-8", timeout: 2000 });
                if (result.includes("found"))
                    return p;
            }
            catch {
                // Not found
            }
        }
    }
    else {
        // macOS
        const candidates = [
            "/Applications/Comfy Desktop.app",
            `${process.env.HOME}/Applications/Comfy Desktop.app`,
            "/Applications/ComfyUI.app",
            `${process.env.HOME}/Applications/ComfyUI.app`,
        ];
        for (const p of candidates) {
            try {
                execSync(`test -d "${p}"`, { timeout: 2000 });
                return p;
            }
            catch {
                // Not found
            }
        }
    }
    return undefined;
}
function findDesktopExePath(argv) {
    const joined = argv.join(" ");
    if (IS_WIN) {
        // Look for the main ComfyUI Desktop exe by walking up from the python/main.py path
        // Typical: C:\Users\X\AppData\Local\Programs\ComfyUI\resources\ComfyUI\main.py
        // Desktop exe: C:\Users\X\AppData\Local\Programs\ComfyUI\ComfyUI.exe
        const match = joined.match(/([A-Za-z]:[\\\/].*?[\\\/]Programs[\\\/]ComfyUI)[\\\/]resources/i);
        if (match) {
            const desktop2 = `${match[1]}\\Comfy Desktop\\Comfy Desktop.exe`;
            if (fileExists(desktop2))
                return desktop2;
            return `${match[1]}\\ComfyUI.exe`;
        }
    }
    else {
        // macOS: /Applications/ComfyUI.app/...
        const match = joined.match(/(\/.*?ComfyUI\.app)/);
        if (match)
            return match[1];
    }
    return undefined;
}
/**
 * Wait until the port is OBSERVED free.
 *
 * "Observed" is the load-bearing word: `findPidByPort` returns null both when
 * nothing is listening and when the lookup itself failed, and treating the second
 * as the first would let a transient failure certify that a still-running server
 * had gone (codex gate). So this polls the tri-state probe and returns only on a
 * definite `free`; an `unknown` keeps waiting and, if that is all we ever get,
 * surfaces as the timeout — a caller must not read it as success.
 */
/** How long to wait for the port to be observed free after a kill. Tunable for the
 *  same reason the startup probes are (COMFYUI_STARTUP_CHECK_*): a host with a slow
 *  or unavailable port probe should not be stuck with one hardcoded budget. */
function getPortFreeTimeoutMs() {
    return Math.round(parsePositiveNumberEnv("COMFYUI_PORT_FREE_TIMEOUT_S", 15) * 1000);
}
async function waitForPortFree(port, timeoutMs = getPortFreeTimeoutMs()) {
    const start = Date.now();
    let lastUnknown;
    while (Date.now() - start < timeoutMs) {
        const probe = probePortOwner(port);
        if (probe.state === "free")
            return;
        if (probe.state === "unknown")
            lastUnknown = probe.reason;
        await sleep(500);
    }
    throw new ProcessControlError(`Port ${port} still in use after ${timeoutMs / 1000}s` +
        (lastUnknown ? ` (last port probe could not complete: ${lastUnknown})` : ""));
}
function parsePositiveNumberEnv(name, fallback) {
    const raw = process.env[name];
    if (raw === undefined || raw.trim() === "")
        return fallback;
    const parsed = Number(raw);
    return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
}
function parsePositiveIntEnv(name, fallback) {
    const raw = process.env[name];
    if (raw === undefined || raw.trim() === "")
        return fallback;
    const parsed = Number(raw);
    return Number.isFinite(parsed) && parsed >= 1 ? Math.floor(parsed) : fallback;
}
function getStartupReadinessConfig() {
    return {
        intervalMs: Math.round(parsePositiveNumberEnv("COMFYUI_STARTUP_CHECK_INTERVAL_S", 1) * 1000),
        // Default budget is 60s (was 20s). ComfyUI with a normal set of custom nodes
        // routinely takes >20s to answer /system_stats on a cold start, so a 20-probe
        // budget reported `started:false` moments before a healthy instance became
        // ready (issue #367). Tunable via COMFYUI_STARTUP_CHECK_MAX_TRIES.
        maxTries: parsePositiveIntEnv("COMFYUI_STARTUP_CHECK_MAX_TRIES", 60),
    };
}
function getRestartPolicy() {
    const enabled = /^(1|true|yes)$/i.test(process.env.COMFYUI_ALWAYS_RESTART ?? "");
    return {
        enabled,
        maxRestarts: parsePositiveIntEnv("COMFYUI_RESTART_MAX_ATTEMPTS", 3),
        windowMs: Math.round(parsePositiveNumberEnv("COMFYUI_RESTART_WINDOW_S", 60) * 1000),
    };
}
/**
 * Poll `/system_stats` until ComfyUI answers 2xx. This poller is TOLERANT of the
 * down window: a thrown fetch error (ECONNRESET / socket hang up / fetch failed)
 * and any non-2xx (including a 502/503/504 from a proxy/tunnel in front of a
 * killed origin) are all swallowed and treated as "not ready yet — keep polling".
 * That property is what lets the same routine cover both a locally-spawned start
 * and a remote ComfyUI-Manager reboot (where ComfyUI briefly disappears).
 *
 * `cfg` overrides the interval/try budget — the local start uses the short
 * env-tuned default; the remote reboot passes a longer budget.
 */
async function waitForApiReady(cfg) {
    const { intervalMs, maxTries } = cfg ?? getStartupReadinessConfig();
    // ANCHORABLE (codex gate round 12). The configured target is mutable, so a
    // relaunch that resolved its instance before a retarget must be able to poll the
    // instance it actually relaunched — not whatever the config points at by the time
    // the probes run.
    const probeUrl = cfg?.probeUrl ?? `${getComfyUIBaseUrl()}/system_stats`;
    const start = Date.now();
    let attempts = 0;
    for (; attempts < maxTries; attempts++) {
        try {
            const controller = new AbortController();
            const timeout = setTimeout(() => controller.abort(), 2000);
            let res;
            try {
                res = await comfyuiFetch(probeUrl, { signal: controller.signal });
            }
            finally {
                clearTimeout(timeout);
            }
            if (res.ok) {
                logger.info("ComfyUI API is ready");
                return {
                    ready: true,
                    timed_out: false,
                    attempts: attempts + 1,
                    max_tries: maxTries,
                    interval_ms: intervalMs,
                    waited_ms: Date.now() - start,
                    probe_url: probeUrl,
                };
            }
        }
        catch {
            // Not ready yet
        }
        if (attempts < maxTries - 1)
            await sleep(intervalMs);
    }
    return {
        ready: false,
        timed_out: true,
        attempts,
        max_tries: maxTries,
        interval_ms: intervalMs,
        waited_ms: Date.now() - start,
        probe_url: probeUrl,
    };
}
function sleep(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
}
function detachSupervisor() {
    if (supervisedChild && supervisedExitHandler) {
        supervisedChild.off("exit", supervisedExitHandler);
    }
    if (supervisedChild && supervisedErrorHandler) {
        supervisedChild.off("error", supervisedErrorHandler);
    }
    supervisedChild = null;
    supervisedExitHandler = null;
    supervisedErrorHandler = null;
}
function childProcessErrorDetails(err) {
    if (!(err instanceof Error))
        return { message: String(err) };
    const nodeErr = err;
    return {
        message: err.message,
        code: typeof nodeErr.code === "string" ? nodeErr.code : undefined,
        errno: typeof nodeErr.errno === "number" ? nodeErr.errno : undefined,
        syscall: typeof nodeErr.syscall === "string" ? nodeErr.syscall : undefined,
        path: typeof nodeErr.path === "string" ? nodeErr.path : undefined,
    };
}
/** Injectable parent-pid reader (see readParentPid). */
let parentPidResolverOverride = null;
/**
 * The parent pid of `pid` — the evidence that a process IS the child we spawned.
 *
 * Unlike a creation stamp (which can only ever be read some time AFTER the spawn,
 * by which point a same-instant exit may already have handed the number to
 * somebody else), parentage does not depend on when we look: we spawned our child,
 * so its parent is this very orchestrator. Another ComfyUI — even one with a
 * byte-identical command line and an identical creation second, started by some
 * other launcher — has a different parent. Unreadable means "cannot tell", never
 * "ours". Consulted only on the ownership decision, so its cost (a PowerShell
 * spawn on Windows) is paid once per start, never per poll.
 */
function readParentPid(pid) {
    if (parentPidResolverOverride)
        return parentPidResolverOverride(pid);
    // Same OS call as the rest of the identity — never a second spawn.
    return resolveProcessIdentity(pid)?.parentPid;
}
/** The launch-environment facts to report, once a plan has been resolved (#776). */
function launchEnvInfo(info) {
    return info?.envPlan?.info;
}
/**
 * The sentence naming the DEATH of the process we launched, when it died before
 * the API answered. Far more actionable than "60/60 probes": it says the relaunch
 * itself failed (the #776 shape — ComfyUI aborting during import), not that it is
 * merely slow.
 */
function exitCause(exit) {
    if (exit.signal != null)
        return `killed by ${exit.signal}`;
    if (exit.code != null)
        return `exit code ${exit.code}`;
    return "for an unknown reason";
}
/**
 * What the child PRINTED before it died, for a relaunch that failed (#1259).
 *
 * A Stability Matrix relaunch exited code 1 and the report carried an exit code
 * and nothing else, so the user was left offline with nothing to act on. The
 * process had already said why — into `stdio: "ignore"`.
 *
 * A FILE, not a pipe. This child is `detached` and expected to outlive us: piping
 * means either draining forever or closing a descriptor the server may still
 * write to, and a closed pipe is an EPIPE that kills a server which was starting
 * fine. A file descriptor stays valid whatever happens to this process, which is
 * what "ignore" was buying and what this has to keep.
 *
 * Scrubbed through the same redactor as every other log this project surfaces
 * (#1206/#1223): a launcher command line can carry a token, and this text goes
 * into a tool result.
 */
/**
 * Open the file a launched child's output is redirected into (#1259).
 *
 * Under the app's own config dir, not the OS temp dir, because the PATH IS
 * REPORTED to the user and has to still be there when they go and look. One file
 * per launch, named by timestamp and pid-less (the pid does not exist yet), so a
 * failed launch's log is never overwritten by the retry that follows it.
 *
 * Returns undefined on any failure. A diagnostic that prevents a server from
 * starting is worse than no diagnostic (#776's cardinal rule), so every error
 * here degrades to the previous behaviour rather than propagating.
 */
export function openLaunchLog(cmd) {
    try {
        // The same data dir the rest of the project uses, honouring the override.
        const dir = join(process.env.COMFYUI_MCP_DATA_DIR?.trim() || join(homedir(), ".comfyui-mcp"), "launch-logs");
        mkdirSync(dir, { recursive: true });
        pruneLaunchLogs(dir);
        const path = join(dir, `comfyui-launch-${launchLogStamp()}.log`);
        const fd = openSync(path, "a");
        // The command itself is the first thing in the log: an exec failure prints
        // nothing, and then this header is the only evidence of what was attempted.
        // Scrubbed, because a launcher command line can carry a credential.
        writeSync(fd, scrubLogLines([
            `# comfyui-mcp launch`,
            `# exe: ${cmd.exe}`,
            `# args: ${cmd.args.join(" ")}`,
            `# cwd: ${cmd.cwd ?? "(inherited)"}`,
        ]).join("\n") + "\n");
        return { fd, path };
    }
    catch {
        return undefined;
    }
}
/** Sortable, filename-safe, second resolution — enough to keep consecutive
 *  relaunch attempts in separate files without a pid we do not have yet. */
function launchLogStamp() {
    return new Date().toISOString().replace(/[:.]/g, "-").replace(/Z$/, "");
}
/** Keep the newest few. These are written on every relaunch and nothing else
 *  ever deletes them, so without this the directory grows for the life of the
 *  install. */
function pruneLaunchLogs(dir) {
    try {
        const keep = 10;
        const files = readdirSync(dir)
            .filter((f) => f.startsWith("comfyui-launch-") && f.endsWith(".log"))
            .sort();
        for (const stale of files.slice(0, Math.max(0, files.length - keep))) {
            try {
                unlinkSync(join(dir, stale));
            }
            catch {
                /* a log we cannot delete is not worth failing a launch over */
            }
        }
    }
    catch {
        /* pruning is housekeeping; never let it block the launch */
    }
}
export function describeLaunchLog(logPath) {
    if (!logPath)
        return "";
    let raw;
    try {
        raw = readFileSync(logPath, "utf-8");
    }
    catch {
        // The log is a diagnostic aid; failing to read it must never replace the
        // failure being reported.
        return ` Its output was being written to ${logPath}, which could not be read back.`;
    }
    const lines = raw.split(/\r?\n/).filter((l) => l.trim().length > 0);
    if (lines.length === 0) {
        // AN EMPTY LOG IS EVIDENCE, and a different kind than a missing one: the
        // process died without printing, which points at the exec itself rather than
        // at ComfyUI's startup.
        return (` It printed NOTHING before exiting (log: ${logPath}) — so it failed before ComfyUI's own startup produced output, ` +
            `which points at the command or its environment rather than at ComfyUI.`);
    }
    const tail = scrubLogLines(lines.slice(-LAUNCH_LOG_TAIL_LINES));
    const omitted = lines.length - tail.length;
    return (` Its last output before exiting${omitted > 0 ? ` (last ${tail.length} of ${lines.length} lines)` : ""}:\n` +
        tail.map((l) => `    ${l}`).join("\n") +
        `\n  Full log: ${logPath}`);
}
/** Enough to carry a Python traceback, bounded so a chatty startup cannot flood
 *  a tool result. The full log stays on disk and its path is always named. */
const LAUNCH_LOG_TAIL_LINES = 40;
function describeLaunchedChildExit(exit) {
    if (!exit)
        return "";
    // WHAT THE EXIT PROVES, AND WHAT IT DOES NOT (codex gate, twice). It proves THIS
    // RELAUNCH failed — decisively, and that is the point: it separates a real
    // failure from a slow start (#367). It does NOT prove ComfyUI is down NOW: the
    // only evidence about the port is the readiness poll, whose last probe is already
    // in the past, and an external supervisor restarting the server a moment later is
    // exactly the case this file spends its length refusing to guess about.
    //
    // Nor does it prove the API never came up: a poll only establishes that no
    // SCHEDULED PROBE got a 2xx, and the server could have answered between two of
    // them. So "before the API came up" is gone too — every clause here now names an
    // observation rather than an inference from a gap between observations.
    return ` The process this call launched EXITED (${exitCause(exit)}), so THIS RELAUNCH FAILED — it was not a slow start. No readiness probe got a healthy response, the last one included.`;
}
/**
 * ComfyUI-Manager prints this then exits 0 so a supervisor can re-exec after
 * installing custom-node dependencies (#2427). Distinct from the #2009 trampoline
 * shape: that wrapper leaves a listener behind. This handoff does not.
 */
export const MANAGER_DEPENDENCY_REAPPLY_MARKER = "Restarting to reapply dependency installation";
/** Test seam: process-control tests mock `node:fs`, so a real launch log cannot
 *  be opened or read there. Production always reads the file. */
let launchLogTextOverride;
function readLaunchLogText(logPath) {
    if (launchLogTextOverride !== undefined)
        return launchLogTextOverride;
    if (!logPath)
        return undefined;
    try {
        return readFileSync(logPath, "utf-8");
    }
    catch {
        return undefined;
    }
}
/** Clean exit 0 after Manager's dependency-reapply line — a handoff, not a crash. */
export function isManagerDependencyReapplyHandoff(opts) {
    if (!opts.exit)
        return false;
    if (opts.exit.signal != null)
        return false;
    if (opts.exit.code !== 0)
        return false;
    const text = readLaunchLogText(opts.launchLogPath);
    return text != null && text.includes(MANAGER_DEPENDENCY_REAPPLY_MARKER);
}
function describeManagerDependencyReapplyFailure(opts) {
    const followup = opts.replayed
        ? `The saved launch command was replayed once, but no readiness probe got a healthy response in ${opts.readiness.waited_ms}ms (${opts.readiness.attempts}/${opts.readiness.max_tries} probes).`
        : "No replacement listener answered, and the saved launch command was not replayed because the port was not proven free.";
    return (` ComfyUI-Manager printed "${MANAGER_DEPENDENCY_REAPPLY_MARKER}" and the process this call launched EXITED (exit code 0) — a dependency-reapply handoff, not a crash. ` +
        followup +
        describeLaunchLog(opts.launchLogPath));
}
/**
 * After Manager's dependency-reapply exit 0: follow a replacement listener if one
 * already owns the port; otherwise replay the saved launch command ONCE. A second
 * copy is refused unless the port is proven free — that is the ownership argument
 * for this being a start, not a double-launch (#2427).
 */
async function followManagerDependencyReapplyHandoff(opts) {
    const portState = probePortOwner(opts.port);
    if (portState.state === "owned") {
        // Something rebound. Wait for THAT listener; do not spawn a second copy.
        const follow = await waitForApiReady({
            ...getStartupReadinessConfig(),
            probeUrl: opts.probeUrl,
        });
        return {
            launched: opts.launched,
            launchedChildExit: opts.launchedChildExit,
            launchedSpawnFailed: false,
            readiness: follow,
            replayed: false,
        };
    }
    if (portState.state !== "free") {
        logger.info("ComfyUI-Manager dependency-reapply handoff observed, but the port was not proven free — not spawning a second copy", { port: opts.port, portState: portState.state });
        return {
            launched: opts.launched,
            launchedChildExit: opts.launchedChildExit,
            launchedSpawnFailed: false,
            readiness: opts.readiness,
            replayed: false,
        };
    }
    logger.info("ComfyUI-Manager exited after a dependency-reapply handoff; replaying the saved launch command once");
    const replayed = spawnFromProcessInfo(opts.info);
    if (!replayed) {
        return {
            launched: opts.launched,
            launchedChildExit: opts.launchedChildExit,
            launchedSpawnFailed: false,
            readiness: opts.readiness,
            replayed: false,
        };
    }
    let launchedChildExit;
    let launchedSpawnFailed = false;
    const spawnError = captureChildProcessError(replayed.child);
    replayed.child.once("exit", (code, signal) => {
        launchedChildExit = { code, signal };
    });
    replayed.child.once("error", () => {
        launchedSpawnFailed = true;
    });
    replayed.child.unref();
    superviseChild(replayed.child, opts.info);
    if (!opts.info.isDesktopApp || opts.info.selfRelaunch) {
        adoptLaunchedChild(replayed.child);
    }
    const replayResult = await Promise.race([
        waitForApiReady({
            ...getStartupReadinessConfig(),
            probeUrl: opts.probeUrl,
        }).then((readiness) => ({ readiness })),
        spawnError.then((error) => ({ spawn_error: error })),
    ]);
    if ("spawn_error" in replayResult) {
        return {
            launched: replayed,
            launchedChildExit,
            launchedSpawnFailed: true,
            readiness: opts.readiness,
            replayed: true,
        };
    }
    return {
        launched: replayed,
        launchedChildExit,
        launchedSpawnFailed,
        readiness: replayResult.readiness,
        replayed: true,
    };
}
/** Whole seconds, never rounded down to a "within 0s" that reads as no wait. */
function seconds(ms) {
    return Math.max(1, Math.round(ms / 1000));
}
/**
 * How long to tell the caller to wait before looking again.
 *
 * Deliberately NOT the budget that just expired. "Re-check in another 120s" is
 * advice nobody follows, and the whole purpose of the sentence is to get the user
 * to look once more instead of reaching for the kill. A short, fixed interval is
 * also the safe direction to be wrong in: checking too early costs one cheap
 * health probe, while checking too late is the window the destructive response
 * happens in.
 */
const RECHECK_HINT_S = 30;
/** A probe interval a human can read — sub-second budgets must not render as "0s". */
function describeInterval(ms) {
    return ms >= 1000 ? `${Math.round(ms / 1000)}s` : `${ms}ms`;
}
/**
 * The argv the server is serving with RIGHT NOW, or undefined when it could not be
 * read. Bounded and total: it is called on a success path where the only thing at
 * stake is how much detail the report carries, so it must never throw and never
 * hang the tool. The timer resolves the race without awaiting anything, so it is
 * genuinely outside the window it bounds.
 */
export async function readServingArgv(timeoutMs = 3000) {
    let timer;
    try {
        const argv = await Promise.race([
            getSystemStats().then((s) => s.system.argv),
            new Promise((resolve) => {
                timer = setTimeout(() => resolve(undefined), timeoutMs);
                timer.unref?.();
            }),
        ]);
        return Array.isArray(argv) && argv.length > 0 ? [...argv] : undefined;
    }
    catch {
        return undefined;
    }
    finally {
        // THE GUARD IS ITSELF AN OPERATION THAT CAN FAIL (codex gate round 4). A throw
        // from `finally` REPLACES the guarded result and escapes this function, which
        // would turn a best-effort detail into a thrown restart. Nothing here may be
        // allowed to do that.
        try {
            clearTimeout(timer);
        }
        catch {
            /* a timer we cannot clear is a leak at worst; it is unref'd and self-resolves */
        }
    }
}
/**
 * Read `/system_stats.argv` from an explicitly anchored base. This is used only
 * for the local-proxy restart proof: the mutable client target may be the panel
 * helper, while the server-authorized boot base is the ComfyUI API we are willing
 * to reboot. Keeping this separate from `readServingArgv` prevents an accidental
 * re-read through the proxy from becoming the backend identity proof.
 */
async function readServingArgvAtBase(base, timeoutMs = 3000) {
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), timeoutMs);
    timer.unref?.();
    try {
        const res = await comfyuiFetch(`${base.replace(/\/+$/, "")}/system_stats`, {
            signal: controller.signal,
        });
        if (!res.ok)
            return undefined;
        const body = (await res.json());
        const argv = body?.system?.argv;
        return Array.isArray(argv) && argv.every((arg) => typeof arg === "string") && argv.length > 0
            ? [...argv]
            : undefined;
    }
    catch {
        return undefined;
    }
    finally {
        clearTimeout(timer);
    }
}
function explicitPortFromArgv(argv) {
    if (!argv)
        return undefined;
    for (let i = 0; i < argv.length; i++) {
        const token = argv[i]?.trim();
        const raw = token === "--port" ? argv[i + 1] : token?.startsWith("--port=") ? token.slice(7) : undefined;
        if (raw == null || !/^\d{1,5}$/.test(raw))
            continue;
        const port = Number(raw);
        if (port >= 1 && port <= 65535)
            return port;
    }
    return undefined;
}
function sameArgv(a, b) {
    return a != null && b != null && a.length === b.length && a.every((arg, i) => arg === b[i]);
}
/**
 * The proxy proof must identify concrete listeners, not names whose resolution
 * can change between the proof and the reboot. Keep this narrower than the
 * general local-VRAM locality rule, which intentionally treats `localhost` as
 * local for non-destructive provider handling.
 */
function isConcreteLoopbackHttpBase(raw) {
    let url;
    try {
        url = new URL(raw);
    }
    catch {
        return false;
    }
    if (url.protocol !== "http:" && url.protocol !== "https:")
        return false;
    const hostname = url.hostname.replace(/^\[|\]$/g, "").toLowerCase();
    return isIP(hostname) > 0 && (hostname === "127.0.0.1" || hostname === "::1");
}
/**
 * Resolve the narrow EZi/CEI shape without ever claiming the helper listener is
 * ComfyUI. A proxy is eligible only when:
 *
 *  - the mutable target and immutable boot target are both loopback endpoints on
 *    different ports;
 *  - `/system_stats.argv` is non-empty and byte-for-byte identical through the
 *    proxy and the boot endpoint; and
 *  - that argv explicitly names the immutable endpoint's port.
 *
 * The generation/base checks fence the two awaited reads against a retarget. If
 * any proof is missing, callers retain the ordinary refusal/ownership path.
 */
export async function resolveVerifiedProxyRestartTarget() {
    // Some process-control unit fixtures intentionally provide only the config
    // surface they exercise. Missing boot provenance means no proxy exception.
    let bootBaseGetter;
    try {
        bootBaseGetter = getBootLocalComfyUIBaseUrl;
    }
    catch {
        // A partial test module mock may omit this optional seam.
        return undefined;
    }
    if (typeof bootBaseGetter !== "function")
        return undefined;
    const proxyBase = getComfyUIBaseUrl().replace(/\/+$/, "");
    const backendBase = bootBaseGetter()?.replace(/\/+$/, "") ?? "";
    if (!proxyBase || !backendBase || proxyBase === backendBase)
        return undefined;
    if (!isConcreteLoopbackHttpBase(proxyBase) || !isConcreteLoopbackHttpBase(backendBase)) {
        return undefined;
    }
    let proxyUrl;
    let backendUrl;
    try {
        proxyUrl = new URL(proxyBase);
        backendUrl = new URL(backendBase);
    }
    catch {
        return undefined;
    }
    if (proxyUrl.protocol !== backendUrl.protocol || proxyUrl.port === backendUrl.port)
        return undefined;
    const backendPort = Number(backendUrl.port || (backendUrl.protocol === "https:" ? 443 : 80));
    if (!Number.isInteger(backendPort) || backendPort < 1 || backendPort > 65535)
        return undefined;
    const generation = getComfyuiTargetGeneration();
    const proxyArgv = await readServingArgv();
    const backendArgv = await readServingArgvAtBase(backendBase);
    if (!sameArgv(proxyArgv, backendArgv) || explicitPortFromArgv(backendArgv) !== backendPort)
        return undefined;
    if (!backendArgv)
        return undefined;
    if (getComfyuiTargetGeneration() !== generation || getComfyUIBaseUrl().replace(/\/+$/, "") !== proxyBase) {
        return undefined;
    }
    return { proxyBase, backendBase, generation, argv: backendArgv };
}
/**
 * The sentence about whether the launch ARGUMENTS survived a restart (#848).
 *
 * #848 is this cluster's defect pointed at a POSITIVE: "the restart succeeded" was
 * read as the answer to a question it was never computed for — "did it come back
 * with the arguments I just configured?" The server came back healthy, the report
 * said so, and the user's newly-added flag was silently absent.
 *
 * This is strictly a report of TWO OBSERVATIONS: what the server ran before, and
 * what it reports running now. It deliberately does NOT explain WHY they match. A
 * Manager reboot re-execing the running process is a plausible cause and not one we
 * observed, and naming it would assert a cause the evidence only permits as a
 * possibility. What the user needs — that the change did not take, and what does
 * apply it — is sayable from the observation alone.
 *
 * Silent when either reading is missing: an unread argv is not evidence of sameness.
 */
export function describeArgvDrift(before, after, isDesktop, 
/**
 * The saved-settings finding (#848), when one could be established. It ANSWERS the
 * question the conditional remedy below can only ask, so it REPLACES that remedy rather
 * than following it: printed together, the user reads "if you were expecting different
 * arguments…" immediately before "your --disable-dynamic-vram is not in force", which
 * hedges a fact we just established and then repeats the same remedy twice.
 */
savedNote = "") {
    if (!before?.length || !after?.length)
        return "";
    const unchanged = before.length === after.length && before.every((tok, i) => tok === after[i]);
    if (!unchanged) {
        // "before this restart REQUEST", not "across this restart" (codex gate round 4).
        // On the Manager path the only observations are an accepted request and a later
        // healthy probe — no down→up cycle is required there — so a completed restart is
        // not something these two readings may take as given.
        return (` Its launch arguments CHANGED between the reading taken before this restart request and the one taken now: before ${before
            .map(quoteToken)
            .join(" ")} / now ${after.map(quoteToken).join(" ")}.` + savedNote);
    }
    // WHAT EQUAL ARGV ESTABLISHES (codex gate, twice). Two readings matched. That is
    // all — and it is deliberately phrased as the pair of observations it is:
    //   - it is NOT a reading of the user's saved settings (we never opened them), so
    //     it cannot say a saved change "was ignored"; the edit may have been to
    //     something argv does not carry at all; and
    //   - it is NOT a causal claim about the restart either. "This restart did not
    //     change them" says the restart had no effect on argv, which two equal
    //     snapshots cannot show: a value can change and change back, and an accepted
    //     Manager request is not proof a cycle even happened.
    // What IS supportable is the present state — the arguments in force NOW are the
    // old ones — so the remedy hangs off that, conditioned on the user's own
    // expectation, which only they can check.
    const observed = ` Its launch arguments are UNCHANGED (${after.map(quoteToken).join(" ")}) — the same arguments were observed before this restart request and again now.`;
    // A read of the user's actual settings beats a guess about their expectations.
    if (savedNote)
        return observed + savedNote;
    return (observed +
        " If you were expecting different arguments" +
        (isDesktop
            ? " (after editing ComfyUI Desktop's saved launch settings, say), they are not in effect here: fully quit the ComfyUI Desktop app and relaunch it so it spawns the server from those settings."
            : " (after editing the launch command on the host, say), they are not in effect here: stop ComfyUI and start it again from its own launcher so the new arguments are used."));
}
/**
 * The sentence appended to a start/restart message when the server had to be
 * launched WITHOUT a launcher environment we could not rebuild (#776). Only
 * reachable from the already-down path — the still-running path refuses instead.
 */
function launchEnvWarning(info) {
    const plan = info?.envPlan;
    if (!plan || plan.reproducible)
        return "";
    return (` WARNING: ${plan.reason ?? plan.info.note}` +
        ` It was launched with this process's environment instead, which may not be enough for it to come up. ` +
        (plan.advice ?? "")).replace(/\s+$/, "");
}
function supervisorResult(info) {
    const policy = getRestartPolicy();
    return {
        enabled: policy.enabled,
        supported: Boolean(info && !info.isDesktopApp),
        max_restarts: policy.maxRestarts,
        window_ms: policy.windowMs,
        restart_count: supervisorRestartCount,
        gave_up: supervisorGaveUp,
        message: !policy.enabled
            ? "Auto-restart is disabled."
            : info?.isDesktopApp
                ? "Auto-restart supervision is only supported for directly spawned Python ComfyUI processes." +
                    desktopEvidenceNote(info.desktopEvidence)
                : undefined,
    };
}
function rememberRestartAttempt(policy) {
    const now = Date.now();
    if (supervisorWindowStartedAt === 0 || now - supervisorWindowStartedAt > policy.windowMs) {
        supervisorWindowStartedAt = now;
        supervisorRestartCount = 0;
        supervisorGaveUp = false;
    }
    if (supervisorRestartCount >= policy.maxRestarts) {
        supervisorGaveUp = true;
        return false;
    }
    supervisorRestartCount += 1;
    return true;
}
let recordedLaunchChild;
function adoptLaunchedChild(child) {
    recordedLaunchChild = child;
    // Env-trust ONLY (#633 P1b) — the launched interpreter itself is recorded by
    // spawnFromProcessInfo via recordLaunchedInterpreter (live-interpreter.ts), the
    // single launch record, and trusted only after PID + creation-time validation.
    markLocalComfyUILaunched();
    const clearIfCurrent = () => {
        if (recordedLaunchChild !== child)
            return;
        recordedLaunchChild = undefined;
        resetLocalComfyUILaunchState();
        // Same fail-closed reasoning for the recorded interpreter: once our child is
        // gone, a successor on that port may run a DIFFERENT python (#401).
        clearLaunchedInterpreter();
    };
    child.once("exit", clearIfCurrent);
    child.once("error", clearIfCurrent);
}
function spawnFromProcessInfo(info) {
    // #1847: a proven python command is the relaunch, not the Desktop exe.
    // Spawning the exe is the #400 failure (listener never comes back).
    if (info.isDesktopApp && !info.selfRelaunch) {
        if (IS_WIN) {
            const exe = info.desktopExePath;
            if (!exe)
                return null;
            return { child: spawn(exe, [], { detached: true, stdio: "ignore", shell: false }) };
        }
        const appPath = info.desktopExePath ?? "ComfyUI";
        return { child: spawn("open", ["-a", appPath], { detached: true, stdio: "ignore" }) };
    }
    const cmd = resolveLaunchCommand(info);
    if (!cmd)
        return null;
    // #776: the relaunch ENVIRONMENT is as load-bearing as the relaunch command.
    // Resolve it here too (not only in assessRelaunch) so EVERY spawn site — the
    // restart relaunch, a bare restart_comfyui (action:"start"), and the crash supervisor — launches
    // into the SAME environment the preflight approved.
    //
    // This site never REFUSES: reaching it means nothing is listening on the port,
    // i.e. the server is already down, and leaving it down is the one outcome worse
    // than a possibly-degraded launch (#776 cardinal rule). An irreproducible
    // launcher environment is refused earlier, by assessRelaunch, while the server
    // is still UP; here it only downgrades to "inherit + warn".
    const envPlan = ensureLaunchEnvPlan(info, cmd);
    // #1259 — the child's output goes to a FILE so a failed relaunch can say what
    // it printed. `stdio: "ignore"` discarded exactly the evidence a user needs
    // when the launch exits non-zero, which left one offline with only "exit code
    // 1". Falls back to "ignore" if the log cannot be opened: a diagnostic must
    // never be the reason a server does not come back up (#776's cardinal rule).
    const launchLog = openLaunchLog(cmd);
    const child = spawn(cmd.exe, cmd.args, {
        detached: true,
        stdio: launchLog ? ["ignore", launchLog.fd, launchLog.fd] : "ignore",
        // Omitted (undefined) for a plain install → the child inherits this process's
        // environment, exactly as before #776. Set only when we have a BETTER answer:
        // the live process's own environment, or a launcher environment reconstructed
        // from disk.
        //
        // #2693 — plus UTF-8, on Windows, because the `stdio` line ABOVE is what makes
        // it necessary: pointing the child at a log file instead of a console makes
        // Python encode to the locale codepage, and rgthree's emoji banner then kills
        // startup with a cp949 UnicodeEncodeError. We chose the destination, so the
        // encoding on it is ours. An explicit setting of the user's always wins, and
        // a launch that already had one is returned untouched.
        env: withUtf8StdioEnv(envPlan.env),
        // Prefer the cwd the command resolved against (the live process cwd or the
        // absolute install anchor for a relaunch, #535/#711); only then the
        // configured install dir — and only as an ABSOLUTE path that exists on disk.
        // A stale/relative/nonexistent config.comfyuiPath as cwd would ENOENT the
        // spawn (#711); omitting cwd inherits this process's (existing) working dir.
        cwd: cmd.cwd ?? configuredInstallCwd(),
        shell: false,
        windowsHide: true,
    });
    // GROUND TRUTH for #401: we chose this interpreter, so we KNOW which python the
    // server runs — no layout inference required. Keyed to the PID so it is discarded
    // the moment a different process owns the port. (Desktop-app launches return
    // above: that exe is a launcher, not an interpreter.)
    if (child.pid)
        recordLaunchedInterpreter(child.pid, cmd.exe);
    return { child, launchArgv: [cmd.exe, ...cmd.args], launchLogPath: launchLog?.path };
}
const ADDITIONAL_LAUNCH_FLAG = /^--[A-Za-z0-9][A-Za-z0-9-]*$/;
function normalizeAdditionalLaunchFlags(flags) {
    if (flags === undefined)
        return { flags: [] };
    if (!Array.isArray(flags))
        return { flags: [], error: "the additional launch flags were not an array" };
    const unique = [];
    for (const flag of flags) {
        if (typeof flag !== "string" || !ADDITIONAL_LAUNCH_FLAG.test(flag)) {
            return {
                flags: [],
                error: `the additional launch flag ${JSON.stringify(flag)} is not a boolean --flag`,
            };
        }
        if (!unique.includes(flag))
            unique.push(flag);
    }
    return { flags: unique };
}
/**
 * Add a flag only to the exact argv that was observed for the process being
 * restarted. The returned record is a copy: the live-process evidence remains
 * untouched until the stop has committed, and the augmented record then becomes
 * the persisted in-process relaunch recipe for manual/automatic future starts.
 */
function withAdditionalLaunchFlags(info, flags) {
    if (flags.length === 0)
        return info;
    const observed = info.argv.length > 0 ? info.argv : info.osArgvExact === true ? (info.osArgv ?? []) : [];
    if (observed.length === 0)
        return null;
    const argv = [...observed];
    for (const flag of flags) {
        if (!argv.includes(flag))
            argv.push(flag);
    }
    return info.argv.length > 0 ? { ...info, argv } : { ...info, osArgv: argv };
}
function launchFlagMutationRefusal(flag, reason) {
    const targetFence = currentTargetFence();
    return {
        stopped: false,
        started: false,
        startup: "not-attempted",
        message: `Refusing to apply ${flag}: ${reason} No launch argument was changed and ComfyUI was not stopped.`,
        target_fence: targetFence,
        target_stable: true,
        listener_ownership: unclassifiedOwnership(),
    };
}
function startTargetFenceRefusal(fence, reason) {
    return {
        started: false,
        startup: "not-attempted",
        message: `Refusing to start ComfyUI: ${reason} No process was spawned and the saved launch recipe was not consumed.`,
        target_fence: fence,
        target_stable: false,
        listener_ownership: unclassifiedOwnership(),
    };
}
function restartTargetFenceRefusal(fence, reason, stopped = false, restartHint) {
    return {
        stopped,
        started: false,
        startup: "not-attempted",
        message: stopped
            ? `Refusing to restart ComfyUI: ${reason} The old process was stopped, but no launch recipe was consumed for the changed target.`
            : `Refusing to restart ComfyUI: ${reason} Nothing was stopped and no launch recipe was consumed for the changed target.`,
        restart_hint: restartHint,
        target_fence: fence,
        target_stable: false,
        listener_ownership: unclassifiedOwnership(),
    };
}
/**
 * Turn captured process info into a spawnable (executable, args) pair.
 *
 * The argv we save comes from ComfyUI's `/system_stats` — i.e. Python's
 * `sys.argv`, whose argv[0] is the SCRIPT path (`…/main.py`), NOT the Python
 * interpreter. Spawning that script directly with `shell:false` fails on
 * Windows with `spawn EFTYPE` (the OS cannot exec a `.py` as a PE binary),
 * which is exactly the restart_comfyui relaunch failure in #330. When argv[0]
 * is a script we resolve the real ComfyUI Python interpreter and pass the whole
 * argv (main.py + flags) as its args. When argv[0] is already an interpreter
 * (e.g. a supervised child we spawned ourselves), we spawn it verbatim.
 */
/**
 * The ABSOLUTE ComfyUI dir (the one directly holding `main.py`) to anchor a
 * RELATIVE sys.argv[0] against, resolved LIVE-FIRST and consistently with the
 * canonical base download_model / the environment services already use (#476,
 * #426). Order, most-trustworthy first:
 *   1. the LIVE running server's own argv-derived install root (absolute argv[0]);
 *   2. the canonical effective base — COMFYUI_PATH or the saved default workspace
 *      — i.e. the exact absolute install download_model wrote into in the same
 *      session (resolveEffectiveComfyUIBase);
 *   3. config.comfyuiPath, when absolute, as a last resort.
 * Returns undefined only when none is absolute; callers then fall back to the raw
 * (possibly relative) argv and the refuse-safe preflight refuses a genuinely
 * unresolvable install.
 */
function resolveScriptAnchor(argv) {
    const live = liveRootFromArgv(argv);
    if (live && isAbsolute(live))
        return live;
    const base = resolveEffectiveComfyUIBase();
    if (base && isAbsolute(base))
        return base;
    if (config.comfyuiPath && isAbsolute(config.comfyuiPath)) {
        return config.comfyuiPath;
    }
    return undefined;
}
/**
 * Resolve the one embedded-python layout that process-control can prove without
 * turning the general interpreter resolver into a layout guess:
 *
 *   <root>\python\python.exe  <root>\main.py
 *
 * `python` is the interpreter observed in the already-correlated process command
 * line, and `root` is the live process anchor recovered by resolveLiveServerRoot.
 * Keep this deliberately narrower than findComfyuiPython: this is only a relaunch
 * repair for a process we are about to stop, not authority for arbitrary local
 * filesystem writes. The caller still validates the script and interpreter as
 * regular files before adopting the pair.
 */
function observedEmbeddedMainAnchor(observedPython, liveRoot, scriptToken) {
    if (!IS_WIN || !observedPython || !liveRoot)
        return undefined;
    if (!isAbsolute(observedPython) || !isAbsolute(liveRoot))
        return undefined;
    // Only the bare script form is eligible. A different relative path may name a
    // launcher/symlink or depend on the cwd in a way this narrow repair cannot prove.
    if (scriptToken.trim().toLowerCase() !== "main.py")
        return undefined;
    const python = join(liveRoot, "python", "python.exe");
    const script = join(liveRoot, "main.py");
    if (!sameInterpreterPath(observedPython, python))
        return undefined;
    // An exact observed layout must not fall through to findComfyuiPython when its
    // own files are missing, directories, or links. That would replace a narrow
    // refusal with an unverified layout guess.
    if (!isNonSymlinkDirectory(liveRoot) ||
        !isNonSymlinkDirectory(join(liveRoot, "python")) ||
        !isNonSymlinkRegularFile(python) ||
        !isNonSymlinkRegularFile(script)) {
        return null;
    }
    return { python, script };
}
/**
 * The live process's own working directory — the most authoritative anchor for a
 * RELATIVE launch script. A ComfyUI launched as `python main.py …` has argv[0] =
 * `main.py` with no path root, an unset/stale COMFYUI_PATH gives no canonical base,
 * and no absolute argv root exists — so every anchor in resolveScriptAnchor comes
 * up empty and restart refuses, even though the reachable process's cwd points at a
 * valid install containing main.py (#535). On Linux we read `/proc/<pid>/cwd`. This
 * MUST be captured while the process is still alive (gatherProcessInfo) because the
 * symlink vanishes the instant the pid is killed; other OSes have no cheap /proc
 * equivalent and return undefined (the existing refuse-safe fallback still applies).
 */
let liveCwdResolverOverride = null;
/**
 * Injectable live-ENVIRONMENT reader (#776) — same rationale and lifetime as the
 * live-cwd resolver above: it must run while the process is alive, and tests need
 * to drive it without a real `/proc`.
 */
let liveEnvResolverOverride = null;
function resolveLiveProcessEnv(pid) {
    if (liveEnvResolverOverride)
        return liveEnvResolverOverride(pid);
    return readLiveProcessEnv(pid);
}
/**
 * Injectable process-IDENTITY reader (creation time), reusing #650's identity
 * scheme from live-interpreter rather than inventing a second one.
 *
 * Native reads happen only on Linux, where `/proc/<pid>/stat` is a free file read
 * AND which is the only platform where we read `/proc/<pid>/environ` at all. The
 * Windows/macOS readers cost a PowerShell/`ps` spawn per call and would buy
 * nothing the port-ownership re-check below does not already give: a recycled pid
 * belongs to an unrelated program, which by definition is not listening on our
 * port. An injected override always wins so tests can drive recycled-pid
 * scenarios on any host.
 */
let processIdentityOverride = null;
/**
 * Read a process's identity — command line, creation time and PARENT pid — in one
 * OS call.
 *
 * This is deliberately NOT platform-gated any more. It was, on cost grounds: the
 * Windows reader is a PowerShell `Get-CimInstance` spawn, measured at ~2.4s even
 * for a pid that does not exist. But skipping it there meant Windows had NO
 * identity evidence at all, so "the re-check couldn't reach the server" collapsed
 * to a bare numeric port/pid comparison — and a replacement instance with
 * different argv was killed on the strength of the previous one's answer (codex
 * gate). That is the reported platform and the common case on it.
 *
 * The cost is bounded by WHERE it is called: once when binding the pid, once
 * immediately before the kill, and once when deciding ownership after a launch —
 * never inside a poll. A restart already spends tens of seconds; a few of them
 * buying "we are certain this is the right process" is the trade the whole issue
 * is about.
 */
function resolveProcessIdentity(pid) {
    if (processIdentityOverride)
        return processIdentityOverride(pid);
    if (!pid || pid <= 0)
        return undefined;
    try {
        return readProcessIdentity(pid);
    }
    catch {
        return undefined;
    }
}
function resolveCorroboratedIdentity(pid, argv) {
    if (argv.length === 0)
        return { kind: "unknown" };
    const identity = resolveProcessIdentity(pid);
    if (!identity)
        return { kind: "unknown" };
    if (identity.commandLine && !commandLineMatchesArgv(identity.commandLine, argv)) {
        logger.warn("PID mismatch: the OS's command line for the port owner does not match the argv ComfyUI reported", { pid, commandLine: identity.commandLine });
        return { kind: "mismatch", commandLine: identity.commandLine };
    }
    if (!identity.startedAt)
        return { kind: "unknown" };
    return { kind: "confirmed", identity };
}
/**
 * The interpreter a CORROBORATED OS identity says this server is running under
 * (#1654) — argv[0] of the observed command line.
 *
 * The command line was already matched against the server's own argv, so its first
 * token is the interpreter THAT process was launched with: the ground truth the
 * install-layout resolution in resolveLaunchCommand is only ever a guess at. The
 * guess prefers `<root>/.venv` over `<root>/venv` whenever both exist, and a
 * Stability Matrix package routinely has both (an empty `.venv` beside the working
 * `venv`), which is how a restart relaunched an environment with no
 * torch/sqlalchemy and left the server down.
 *
 * Accepted only as an ABSOLUTE path that exists on disk and is not itself the
 * script (a shebang-launched `main.py` has the script AS argv[0] — adopting that
 * as the interpreter would spawn a Python file as a program). A relative or bare
 * argv[0] (`python`, `.\python`) names no file we can verify — the process's cwd
 * is not ours — so it is honestly unknown and the layout resolution stands. Same
 * acceptance rule as live-interpreter's tier 2.
 */
function observedInterpreterFromIdentity(identity) {
    const argv0 = argv0FromCommandLine(identity.commandLine ?? "");
    if (!argv0 || /\.pyw?$/i.test(argv0))
        return undefined;
    if (!isAbsolute(argv0))
        return undefined;
    return fileExists(argv0) ? argv0 : undefined;
}
/**
 * A package venv interpreter: `<root>/venv/Scripts/python.exe` or
 * `<root>/.venv/bin/python`. The Windows trampoline that actually owns the
 * environment; its child is the BASE CPython `pyvenv.cfg` named, which has no
 * ComfyUI site-packages.
 */
function isVenvInterpreterPath(exe) {
    const segs = exe.replace(/\\/g, "/").split("/").filter(Boolean);
    if (segs.length < 3)
        return false;
    const file = segs[segs.length - 1].toLowerCase();
    if (!/^python(w)?(?:\d+(?:\.\d+)*)?(?:\.exe)?$/.test(file))
        return false;
    const dir = segs[segs.length - 2].toLowerCase();
    const env = segs[segs.length - 3].toLowerCase();
    return (dir === "scripts" || dir === "bin") && (env === "venv" || env === ".venv");
}
/**
 * Windows venv trampoline (#1704 recurrence): the port owner is the BASE
 * CPython child; its parent is `<install>/venv/Scripts/python.exe`; both carry
 * the same `main.py …` arguments. Spawning the child image relaunches an
 * interpreter with no sqlalchemy/torch (Assets CPython or any other `home`).
 *
 * The parent is an OS observation of THIS tree, not a layout guess — an
 * observed *venv* interpreter on the port owner is left alone (#1654).
 */
function observedInterpreterFromTrampolineParent(child, serverArgv) {
    const childExe = observedInterpreterFromIdentity(child);
    if (childExe && isVenvInterpreterPath(childExe))
        return undefined;
    const parentPid = child.parentPid;
    if (!parentPid || parentPid <= 0)
        return undefined;
    const parent = resolveProcessIdentity(parentPid);
    if (!parent)
        return undefined;
    const parentExe = observedInterpreterFromIdentity(parent);
    if (!parentExe || !isVenvInterpreterPath(parentExe))
        return undefined;
    if (childExe && sameInterpreterPath(childExe, parentExe))
        return undefined;
    const matchArgv = serverArgv.length > 0
        ? serverArgv
        : child.argv && child.argv.length > 1
            ? child.argv.slice(1)
            : [];
    if (matchArgv.length === 0)
        return undefined;
    if (!commandLineMatchesArgv(parent.commandLine, matchArgv))
        return undefined;
    return parentExe;
}
function sameInterpreterPath(a, b) {
    const norm = (s) => s.replace(/[\\/]+$/, "").replace(/[\\/]/g, "/").toLowerCase();
    return norm(a) === norm(b);
}
/**
 * The live environment of a pid we can still PROVE is the process we identified.
 *
 * Reading `/proc/<pid>/environ` from a bare number is not enough: between the port
 * lookup and the read, ComfyUI can exit and the OS can hand the number to an
 * unrelated process, whose environment we would then adopt as ComfyUI's launch
 * environment. So the creation time is re-verified immediately AFTER the read, and
 * any gap in the evidence (no stamp before, no stamp after, or a changed stamp)
 * discards the capture and falls through to the on-disk tiers, which refuse rather
 * than guess.
 */
function captureVerifiedLiveEnv(pid, startedAt, argv) {
    if (!startedAt)
        return undefined;
    const env = resolveLiveProcessEnv(pid);
    if (!env)
        return undefined;
    // Re-verify with the SAME corroboration used to bind in the first place, so a
    // recycled pid cannot satisfy the re-check just by agreeing with itself.
    const recheck = resolveCorroboratedIdentity(pid, argv);
    const after = recheck.kind === "confirmed" ? recheck.identity.startedAt : undefined;
    if (!after || after !== startedAt) {
        logger.warn("Discarding the captured ComfyUI environment: the pid's identity changed while reading it", { pid, before: startedAt, after: after ?? "(unavailable)" });
        return undefined;
    }
    return env;
}
/**
 * May we still act on (kill) the process we identified? Returns a refusal reason
 * when the pid provably no longer denotes that process.
 *
 * Two independent checks, cheapest first:
 *   1. it must STILL own the port we found it on - a recycled pid belongs to some
 *      unrelated program, which by definition is not listening there;
 *   2. when a creation-time stamp was captured, it must still match.
 * Missing evidence is NOT treated as failure (that would refuse restarts on hosts
 * where the reads are unavailable); only a POSITIVE mismatch refuses.
 */
function processIdentityStillValid(info) {
    // A Desktop pid may legitimately not be the port owner (it can come from the
    // Electron-shell scan when the API is unreachable), so the port check is
    // meaningless there.
    if (!info.isDesktopApp) {
        let owner = null;
        try {
            owner = findPidByPort(info.port);
        }
        catch {
            owner = null;
        }
        if (owner !== info.pid) {
            return {
                ok: false,
                reason: `the process identified on port ${info.port} (PID ${info.pid}) no longer owns that port ` +
                    `(now ${owner ?? "nothing"}), so it exited on its own - this PID may since have been ` +
                    "reused by an unrelated program and must not be killed",
            };
        }
    }
    // Same corroboration as the binding. NOTE both checks are independent of whether
    // a creation stamp was ever obtained: a command line that does not match the
    // server's argv is POSITIVE evidence this pid is somebody else, and gating it
    // behind `startedAt` would discard that evidence exactly when we have least of
    // it (codex gate).
    // WHAT THIS PROCESS SHOULD STILL BE RUNNING — the server's own account when it
    // gave one, otherwise the OS's EARLIER reading of the same pid.
    //
    // Passing `info.argv` unguarded was wrong: commandLineMatchesArgv fails CLOSED on
    // an empty argv, which is right for "is this ComfyUI?" and wrong here — a server
    // that never answered has no argv to disagree with, and reading that absence as a
    // mismatch refuses to stop exactly the wedged instance the user is recovering
    // (#767). But simply SKIPPING the check there left the wedged path with nothing
    // but numeric pid/port equality (codex gate round 5). The OS reading is the
    // answer: comparing a LATER reading against an EARLIER one is not
    // self-corroboration — they are two observations separated in time, which is
    // exactly what a substitution has to survive. Together with the creation stamp
    // below (now retained on this path), a replacement that inherited the number is
    // caught whether or not it runs the same command line.
    const expectedArgv = info.argv.length > 0 ? info.argv : (info.osArgv ?? []);
    const now = resolveProcessIdentity(info.pid);
    if (expectedArgv.length > 0 &&
        now?.commandLine &&
        !commandLineMatchesArgv(now.commandLine, expectedArgv)) {
        return {
            ok: false,
            reason: `PID ${info.pid} is running something other than the ComfyUI we identified ` +
                `(its command line does not match that server's arguments), so it must not be killed`,
        };
    }
    if (info.startedAt && now?.startedAt && now.startedAt !== info.startedAt) {
        return {
            ok: false,
            reason: `PID ${info.pid} is no longer the ComfyUI process we identified (its creation time ` +
                "changed), so the number has been reused by a different program and must not be killed",
        };
    }
    return { ok: true };
}
function resolveLiveProcessCwd(pid) {
    if (liveCwdResolverOverride)
        return liveCwdResolverOverride(pid);
    if (!pid || IS_WIN)
        return undefined;
    try {
        const cwd = readlinkSync(`/proc/${pid}/cwd`);
        return cwd && isAbsolute(cwd) ? cwd : undefined;
    }
    catch {
        return undefined;
    }
}
/**
 * `/system_stats` reports the server's actual working directory on recent
 * ComfyUI builds. Accept only an absolute path: a relative value would be
 * relative to this MCP process after the old server is stopped, which is the
 * exact ambiguity the restart preflight refuses to guess through (#2260).
 * Windows drive and UNC paths are recognized even when a cross-platform test
 * harness is exercising the parser on a POSIX host.
 */
function reportedServerCwd(raw) {
    if (typeof raw !== "string")
        return undefined;
    const cwd = raw.trim();
    return cwd && isAbsolutePath(cwd) ? cwd : undefined;
}
/**
 * The live process's working directory, reconstructed from the OS process
 * observation rather than from procfs (#535) — the Windows path, where
 * `/proc/<pid>/cwd` does not exist.
 *
 * `resolveLiveServerRoot`'s observed-process tier walks up from the interpreter the
 * OS reports for the server on our port and accepts the first ancestor under which
 * `<ancestor>/<relDir>/main.py` exists AND the interpreter belongs to that install.
 * That accepted ancestor IS the working directory the server must have had for its
 * relative `main.py` to name this install — the same fact `/proc/<pid>/cwd` states
 * directly.
 *
 * THE PID MUST MATCH. This value is used to decide a relaunch of a process we are
 * about to KILL, and the observation is anchored on "whatever is listening on our
 * port". If that is a different process than `pid` — a second ComfyUI, a proxy, a
 * pid we resolved from a stale record — then its install is not the one being
 * stopped, and anchoring the relaunch on it would restart the wrong tree after
 * killing the right one. An unconfirmed observation is discarded, leaving the
 * pre-existing refusal exactly as it was.
 *
 * Never throws: a failure here must degrade to the old refusal, never break a stop.
 */
function observedLiveCwd(pid, argv, expectedStartedAt) {
    if (!pid)
        return undefined;
    // The anchor is INFERRED, never observed: it is a directory from which this
    // relative script WOULD name this install, which is not the same claim as
    // "the directory the process was started in". A launcher that keeps
    // `C:\launcher\main.py` symlinked to `C:\bundle\main.py` is started from
    // `C:\launcher`, yet `C:\bundle` satisfies the inference (codex gate).
    //
    // For the panel base (#1133) that gap is harmless — both spellings name the same
    // install root. Here it is not: this value becomes the relaunch's WORKING
    // DIRECTORY, so any relative argument would resolve somewhere else after the
    // restart. So only adopt it when the choice of cwd CANNOT change what an
    // argument means: every token after the script must be a flag, an absolute path,
    // or a plain non-path value. Anything that could be a relative path leaves the
    // pre-existing refusal in place.
    if (argvHasRelativePathArg(argv))
        return undefined;
    try {
        const live = resolveLiveServerRoot(argv, undefined, { remote: false });
        if (live.source !== "observed-process" || !live.anchorDir)
            return undefined;
        if (live.observedPid !== pid)
            return undefined;
        // …and bind it to the process INSTANCE, not to the pid NUMBER. This resolver
        // re-queries the port owner at its own moment, after the identity bracket
        // above has already closed; a pid recycled in that window would satisfy a
        // numeric comparison while describing a different server. The codebase's rule
        // is pid + creation time, and an unreadable stamp is "did not observe", never
        // "observed the same" — so it fails closed to the old refusal (#535 codex gate).
        const nowStartedAt = resolveProcessIdentity(pid)?.startedAt;
        if (!expectedStartedAt || !nowStartedAt || nowStartedAt !== expectedStartedAt) {
            return undefined;
        }
        return isAbsolute(live.anchorDir) ? live.anchorDir : undefined;
    }
    catch {
        return undefined;
    }
}
/**
 * Does any argument AFTER the launch script depend on the working directory?
 *
 * Conservative on purpose (#535): a token counts as a possible relative path
 * unless it is a flag, an absolute path, or a plain value that cannot be one (a
 * number, a `key=value` flag payload, a bare `true`/`false`). `--port 8188` is
 * safe; `--output-directory out` is not, and neither is anything carrying a path
 * separator without a root.
 *
 * The asymmetry is deliberate. A false positive costs a Windows user the same
 * manual restart they have today; a false negative relaunches a server into a
 * directory where its own arguments point somewhere else.
 */
function argvHasRelativePathArg(argv) {
    let sawSeparator = false;
    let pendingPathFlag = false;
    for (let i = 1; i < argv.length; i++) {
        const raw = argv[i];
        if (raw === undefined)
            continue;
        // Everything after a bare `--` is positional, so nothing there is a flag and
        // a leading dash no longer means what it did.
        if (!sawSeparator && raw === "--") {
            sawSeparator = true;
            pendingPathFlag = false;
            continue;
        }
        if (sawSeparator) {
            if (!isAbsolutePath(raw))
                return true; // a positional we cannot vouch for
            continue;
        }
        // The token immediately after a path-valued flag is ITS VALUE, whatever it
        // looks like — `--output-directory -` names a directory called `-`, and
        // treating it as the next flag is how that slipped through (codex round 2).
        // A token starting with `--` is the exception: no ComfyUI path flag takes a
        // `--`-prefixed value, so that is the NEXT FLAG and the previous one was a
        // boolean we misread.
        // KEEP consuming while the flag can still take values. ComfyUI declares
        // `--extra-model-paths-config` as `nargs='+'`, so `--extra-model-paths-config
        // C:\one.yaml two.yaml` carries TWO paths and only the first was being checked
        // (codex round 3) — `two.yaml` then fell to the positional check, which lets a
        // separator-free name through. argparse ends the list at the next option.
        if (pendingPathFlag && !raw.startsWith("--")) {
            if (!isAbsolutePath(raw))
                return true;
            continue; // stay pending: there may be more values
        }
        pendingPathFlag = false;
        if (raw.startsWith("-")) {
            // `--flag=value` carries its value INSIDE the token.
            const eq = raw.indexOf("=");
            const name = eq >= 0 ? raw.slice(0, eq) : raw;
            const attached = eq >= 0 ? raw.slice(eq + 1) : undefined;
            if (!KNOWN_PATH_FLAG.test(name))
                continue; // --port, --cache-none, …
            if (attached === undefined) {
                pendingPathFlag = true; // its value is the next token
                continue;
            }
            if (attached && !isAbsolutePath(attached))
                return true;
            continue;
        }
        // Any other token: only a path-SHAPED one is a concern. A stray value that is
        // not a path (`8188` after `--port`, `10` after `--cache-lru`) cannot be
        // re-resolved into something else by a different cwd. A URL carries slashes
        // without being a path — `--comfy-api-base https://api.comfy.org` is not
        // something a working directory can move (codex round 3).
        if (raw === "." || raw === ".." || (/[\\/]/.test(raw) && !isUrlLike(raw))) {
            if (!isAbsolutePath(raw))
                return true;
        }
    }
    // A trailing path flag with no value left to take is malformed argv, not a
    // relative path — there is nothing for a different cwd to re-resolve.
    return false;
}
/**
 * ComfyUI's flags whose value is a PATH, matched on the flag NAME rather than on
 * what the value looks like (codex round 2).
 *
 * Judging the value was the wrong instinct and bypassable three ways: `auto`,
 * `8188` and `-` are all perfectly legal directory names, so an allowlist of
 * "values that cannot be paths" cannot exist. The flag name is the part whose
 * vocabulary is knowable.
 *
 * ENUMERATED, not pattern-matched. A broad `/dir|path|cache|log|…/` substring
 * test is over-broad in the direction that BREAKS the fix: `--cache-none` and
 * `--log-stdout` are BOOLEAN flags that match it, so the next token gets eaten as
 * their "value" and a perfectly ordinary launch is refused. `--cache-none` is in
 * this issue's own original report, so the heuristic would have refused the very
 * argv it exists to support.
 *
 * A path flag missing from this list degrades to the positional check below —
 * blocked when the value is path-SHAPED, allowed when it is not. That residual
 * (an unknown path flag carrying a relative value that looks like nothing,
 * e.g. `--future-dir auto`, on a symlinked launcher) is narrower than the
 * blanket refusal it replaces, and is stated rather than hidden.
 */
const KNOWN_PATH_FLAG = /^--(base|input|output|temp|user|models|custom-nodes|front-end-root)-director(y|ies)$|^--(models-dir|extra-model-paths-config|tls-keyfile|tls-certfile|log-file)$/i;
/** Absolute on either host convention — POSIX, Windows drive, or UNC. */
function isAbsolutePath(p) {
    return isAbsolute(p) || /^[a-zA-Z]:[\\/]/.test(p) || /^\\\\/.test(p);
}
/** A URL, not a filesystem path — it carries slashes but no cwd can move it. */
function isUrlLike(p) {
    return /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(p);
}
/**
 * The argv a relaunch is built from: the SERVER's own `sys.argv` when it could tell
 * us, otherwise the OS's view of the same process (#767).
 *
 * The two differ in shape and both are handled downstream: `sys.argv[0]` is the
 * SCRIPT (`main.py`, so an interpreter has to be resolved for it), while the OS's
 * argv[0] is the INTERPRETER itself — which is strictly better, since it needs no
 * resolution at all. The order is not a preference between sources of truth: the
 * server's answer simply comes with an identity binding the OS reading cannot have,
 * so it is used whenever it exists.
 */
function relaunchArgv(info) {
    if (info.argv.length > 0)
        return info.argv;
    // A FLATTENED reading is not a command. It is still reported to the user, who can
    // see where the quotes belong; we cannot, and guessing would spawn arguments the
    // server never had (codex gate round 6).
    return info.osArgvExact ? (info.osArgv ?? []) : [];
}
function resolveLaunchCommand(info) {
    const argv = relaunchArgv(info);
    if (argv.length === 0)
        return null;
    const [first, ...rest] = argv;
    // Strip surrounding quotes a launcher may leave on the script path BEFORE the
    // suffix test — otherwise `"C:\…\main.py"` fails the `.py` check, bypasses the
    // unified live-first resolver, and gets treated as the executable (#401 / PR #433
    // round 3). Kept in sync with liveRootFromArgv's quote handling.
    const firstUnquoted = first.trim().replace(/^["']+/, "").replace(/["']+$/, "");
    const looksLikeScript = /\.pyw?$/i.test(firstUnquoted);
    if (looksLikeScript) {
        // #1654: the interpreter the OS observed THIS server running under outranks the
        // layout resolution — a guess that prefers `<root>/.venv` over `<root>/venv`
        // whenever both exist is how a restart relaunched an empty environment and left
        // the server down. The observation was captured corroborated against the
        // server's own argv, so it IS the environment the healthy process imports from.
        //
        // #1704: do NOT return null yet when the layout guess is empty — a Stability
        // Matrix package venv can still be recovered from the script path even when
        // this session never observed the interpreter (ComfyUI started outside, or
        // action:"start" after another session).
        const python = info.observedInterpreter ??
            findComfyuiPython(config.comfyuiPath ?? undefined, argv);
        // sys.argv[0] can be RELATIVE (the standard Windows portable launcher runs
        // `python ComfyUI\main.py` from the portable root). We force cwd to
        // config.comfyuiPath — the ComfyUI dir that directly holds main.py — so a
        // relative script would resolve against the wrong dir (…/ComfyUI/ComfyUI/
        // main.py). Anchor it: use the absolute path as-is, otherwise main.py under
        // the resolved ComfyUI root.
        //
        // The argv mirrors the running ComfyUI's sys.argv, which is Windows-flavored
        // when ComfyUI runs on Windows — regardless of what OS this process is on.
        // So detect absoluteness and the script basename in a separator-agnostic way
        // rather than trusting the host `path` module (which mangles `C:\…` / `\`
        // paths on POSIX). The final join stays host-native to match comfyuiPath.
        const isWindowsAbsolute = /^[a-zA-Z]:[\\/]/.test(firstUnquoted) || /^\\\\/.test(firstUnquoted);
        const scriptBasename = firstUnquoted.split(/[\\/]/).pop() || firstUnquoted;
        // LIVE-FIRST anchor for a RELATIVE argv[0]: resolve the canonical ABSOLUTE
        // ComfyUI dir the same way download_model / the env services do, so restart
        // never refuses on a stale/relative COMFYUI_PATH while the reachable install
        // lives elsewhere (#476, #426). config.comfyuiPath is only ONE input to that
        // canonical base (which also honors the saved default workspace), so anchor
        // to the base — not to config.comfyuiPath directly. When no absolute anchor
        // exists we keep the raw (possibly relative) script and let assessRelaunch's
        // refuse-safe preflight catch a truly unresolvable install.
        const anchor = resolveScriptAnchor(argv);
        const scriptIsAbsolute = isAbsolute(firstUnquoted) || isWindowsAbsolute;
        // LIVE-CWD anchor (#535): before falling back to the canonical base, resolve a
        // RELATIVE script against the running process's OWN cwd (captured live, so it
        // survives the stop that kills the pid). Two hard requirements keep the stop
        // refuse-safe and env-consistent:
        //   1. Both the script AND the interpreter must be resolved from the SAME live
        //      cwd — never pair a live-cwd script with a stale COMFYUI_PATH's python,
        //      which would relaunch the live server under the wrong environment (codex
        //      round-2 P1). So the interpreter is re-resolved with the live cwd as its
        //      search root (finds that install's own venv/embedded python).
        //   2. Both must be validated as REGULAR FILES (not just existsSync): a dir
        //      named `main.py`, or a dir at the interpreter path, must NOT unlock a kill
        //      we can't actually exec afterward (codex round-2 P1).
        // Split into segments + re-join so a Windows `ComfyUI\main.py` normalizes to
        // host-native separators on a POSIX host instead of a literal one-segment name.
        const relSegments = firstUnquoted.split(/[\\/]/).filter(Boolean);
        let liveCwdScript;
        let liveCwdPython;
        if (!scriptIsAbsolute && info.liveCwd && relSegments.length > 0) {
            const candidateScript = join(info.liveCwd, ...relSegments);
            const embeddedAnchor = observedEmbeddedMainAnchor(info.observedInterpreter, info.liveCwd, firstUnquoted);
            // `null` means the observed process proved the exact embedded layout but
            // its root/interpreter/script failed validation. Do not let that explicit
            // refusal fall through to the broader canonical anchor below: it could
            // relaunch a different install after stopping the observed process.
            if (embeddedAnchor === null)
                return null;
            const candidatePython = embeddedAnchor?.python ??
                findComfyuiPython(info.liveCwd, argv);
            const pythonIsAbsolute = !!candidatePython &&
                (isAbsolute(candidatePython) || /^[a-zA-Z]:[\\/]/.test(candidatePython));
            if (isRegularFile(embeddedAnchor?.script ?? candidateScript) &&
                pythonIsAbsolute &&
                isRegularFile(candidatePython)) {
                liveCwdScript = embeddedAnchor?.script ?? candidateScript;
                liveCwdPython = candidatePython;
            }
        }
        const script = scriptIsAbsolute
            ? firstUnquoted
            : liveCwdScript
                ? liveCwdScript
                : anchor
                    ? join(anchor, scriptBasename)
                    : firstUnquoted;
        // When the script was anchored to the LIVE cwd, use that install's OWN python
        // and spawn FROM that cwd — never a stale/nonexistent config.comfyuiPath, which
        // would ENOENT the spawn after the server was already killed (#535). An
        // absolute / canonical-base script spawns from its OWN anchor dir the same way
        // (#711): the anchor is the absolute install root the script was resolved
        // against, so the relaunch never depends on a wrong/undefined working
        // directory. Only an UNANCHORED relative script leaves cwd unset — the
        // refuse-safe preflight (#476/#426) rejects that case before any stop.
        // The OBSERVED interpreter (#1654) outranks both layout-derived pythons: it is
        // the environment this exact process was seen running under, while the other
        // two are install-layout inferences that cannot tell a working `venv` from an
        // empty `.venv` sitting beside it.
        //
        // #1704: Stability Matrix's unused Assets CPython (or a bare PATH `python`
        // that resolves to it) is never a launch interpreter when the package venv
        // exists — that tree has no sqlalchemy/torch, so spawning it exits 1 and
        // leaves ComfyUI down. An observed *package* interpreter is left alone.
        const rawExe = info.observedInterpreter ?? (liveCwdScript ? liveCwdPython : python);
        const cwd = liveCwdScript ? info.liveCwd : anchor;
        const exe = preferStabilityMatrixPackagePython(rawExe, [
            script,
            rawExe,
            cwd,
            info.liveCwd,
            info.argv[0],
        ]);
        if (!exe)
            return null;
        return { exe, args: [script, ...rest], cwd };
    }
    const remapped = preferStabilityMatrixPackagePython(first, [
        first,
        ...rest,
        info.liveCwd,
        info.argv[0],
    ]);
    return { exe: remapped ?? first, args: rest };
}
/**
 * Resolve (once) the ENVIRONMENT this instance must be relaunched into (#776),
 * memoized on the ProcessInfo so the pre-stop preflight and the post-stop spawn
 * can never disagree: whatever assessRelaunch approved is exactly what gets
 * spawned. The paths handed to the resolver are the ones the relaunch actually
 * uses (resolved script, interpreter, cwd) plus the raw argv[0], so a launcher
 * layout is recognized whichever of them carries it.
 */
function ensureLaunchEnvPlan(info, cmd) {
    if (info.envPlan)
        return info.envPlan;
    const plan = resolveLaunchEnvironment({
        paths: [cmd.args[0], cmd.exe, cmd.cwd, info.argv[0], info.liveCwd],
        liveEnv: info.liveEnv,
    });
    info.envPlan = plan;
    return plan;
}
function fileExists(p) {
    if (!p)
        return false;
    try {
        return existsSync(p);
    }
    catch {
        return false;
    }
}
/**
 * config.comfyuiPath as a spawn cwd — only when it is an ABSOLUTE path to an
 * existing DIRECTORY. A stale, relative, or nonexistent COMFYUI_PATH passed as
 * cwd would ENOENT the spawn after the server was already stopped (#711), and
 * a path that resolves to a regular FILE fails the same way (ENOTDIR) — both
 * recreate the lost-server failure this guard exists to prevent (codex gate).
 * Callers then omit cwd and the child inherits this process's (existing)
 * working dir.
 */
function configuredInstallCwd() {
    if (!config.comfyuiPath || !isAbsolute(config.comfyuiPath))
        return undefined;
    try {
        return statSync(config.comfyuiPath).isDirectory()
            ? config.comfyuiPath
            : undefined;
    }
    catch {
        return undefined;
    }
}
/**
 * Stricter than fileExists: the path must be a REGULAR FILE, not a directory.
 * Used to unlock the atomic live-cwd relaunch (#535) — a directory named
 * `main.py`, or a directory at the interpreter path, exists per existsSync yet
 * cannot be exec'd, so validating it as a file keeps the stop refuse-safe. NOT a
 * drop-in for fileExists elsewhere: a macOS `.app` bundle is a directory.
 */
function isRegularFile(p) {
    if (!p)
        return false;
    try {
        return statSync(p).isFile();
    }
    catch {
        return false;
    }
}
/**
 * The exact embedded-portable repair is deliberately stricter than the existing
 * generic live-cwd path: a symlinked executable or entrypoint would make the
 * lexical `<root>` anchor mean something different from the process identity we
 * observed, so it must retain the refusal.
 */
function isNonSymlinkRegularFile(p) {
    if (!p)
        return false;
    try {
        const st = lstatSync(p);
        return st.isFile() && !st.isSymbolicLink();
    }
    catch {
        return false;
    }
}
function isNonSymlinkDirectory(p) {
    if (!p)
        return false;
    try {
        const st = lstatSync(p);
        return st.isDirectory() && !st.isSymbolicLink();
    }
    catch {
        return false;
    }
}
/** True when a token looks like a filesystem path (has a separator or a drive). */
function looksLikePath(s) {
    return /[\\/]/.test(s) || /^[a-zA-Z]:/.test(s);
}
/** Quote a token only when it needs it, so the hint can be pasted into a shell. */
function quoteToken(token) {
    return /[\s"]/.test(token) ? `"${token.replace(/"/g, '\\"')}"` : token;
}
/**
 * Everything we know about how to restart this instance by hand, captured from a
 * LIVE process (#814/#767). Both sources are reported with their provenance rather
 * than merged: see RecoveryHint.
 */
function recoveryHint(info) {
    const hint = {};
    if (info.osArgvExact && info.osArgv?.length) {
        hint.command = info.osArgv.map(quoteToken).join(" ");
    }
    else if (info.osCommandLine) {
        // Re-quoting a flattened reading would invent boundaries we do not know, so it is
        // passed through EXACTLY as the OS printed it and labelled as approximate.
        hint.command = info.osCommandLine;
        hint.command_flattened = true;
    }
    if (info.argv.length > 0)
        hint.server_argv = info.argv;
    if (info.liveCwd)
        hint.cwd = info.liveCwd;
    return hint.command || hint.server_argv || hint.cwd ? hint : undefined;
}
/** The sentence appended to a refusal so the user can act on it immediately. */
function describeRecovery(hint) {
    if (!hint)
        return "";
    if (hint.command) {
        return (` To start it by hand, run: ${hint.command}${hint.cwd ? ` (from ${hint.cwd})` : ""}.` +
            (hint.command_flattened
                ? " (This OS reports arguments flattened into one line, so any path containing" +
                    " a space needs re-quoting before you run it.)"
                : ""));
    }
    if (hint.server_argv) {
        return ` The server reported these launch arguments: ${hint.server_argv
            .map(quoteToken)
            .join(" ")}${hint.cwd ? ` (from ${hint.cwd})` : ""} — note this is Python's sys.argv, so the interpreter that ran it is not part of it.`;
    }
    return "";
}
/** Injectable existence probe, so supervision can be driven without real pids. */
let processExistsOverride = null;
/**
 * Does something hold this pid? TRI-STATE — see SupervisionEvidence.processExists.
 * EPERM is "exists but not ours to signal", which is still existence; anything else
 * unrecognised is "cannot tell" and must not be spent as either answer.
 */
function processExists(pid) {
    if (processExistsOverride)
        return processExistsOverride(pid);
    if (!pid || pid <= 0)
        return undefined;
    try {
        process.kill(pid, 0);
        return true;
    }
    catch (err) {
        const code = err.code;
        if (code === "ESRCH")
            return false;
        if (code === "EPERM")
            return true;
        return undefined;
    }
}
/**
 * Will the Desktop supervisor bring this instance back after a Manager reboot?
 *
 * A Desktop instance is NEVER killed and relaunched by us (#400) — the Electron
 * shell owns it, so the restart path asks ComfyUI-Manager to re-exec the process and
 * depends on that shell being there to do it. #814 is the case where it was not: two
 * ComfyUI backends off one install, the shell that spawned the bound one had moved
 * on, the reboot stopped it, and nothing brought it back. The tool then said it
 * "couldn't confirm it came back" — a verdict computed AFTER the stop, about a stop
 * it should never have made.
 *
 * ONLY `supervised` proceeds silently. `unconfirmed` REFUSES — with ONE disclosed
 * exception (#1647): when the walk never got off the ground because the port owner's
 * OWN parent could not be read (a host that cannot read parentage at all — the
 * reporter's macOS Desktop install), and the server's own launch arguments carry the
 * ComfyUI Desktop launch signatures (`--extra-model-paths-config` pointing into the
 * `Comfy Desktop` data directory, a `main.py` inside the Desktop install), then the
 * same argv that told us this IS a Desktop instance also tells us WHO launched it:
 * the Desktop app. In that one shape the reboot proceeds and the inference is stated
 * in the result (`note`) — what was not proven, what it was inferred from, and the
 * residual case where nothing will bring the server back (a backend someone started
 * BY HAND with Desktop's flags). Every other `unconfirmed` shape — a chain that was
 * read and found ambiguous partway up — still refuses, and `abandoned` always does:
 * a parent PROVEN gone is a fact, not a gap in the evidence.
 *
 * The asymmetry with the rest of this file is deliberate rather than an
 * inconsistency (coordinator gate):
 *
 *   `listener_ownership` reports on an action that HAS ALREADY HAPPENED — the child
 *   was spawned, the server is answering — and only the DESCRIPTION of it is
 *   uncertain. Denying `started` there would turn an uncertain description into a
 *   false one, so uncertainty is DISCLOSED.
 *
 *   A reboot has NOT happened yet and cannot be undone. Uncertainty about whether
 *   anything will restart the process is uncertainty about whether the user still has
 *   a ComfyUI afterwards, so it is REFUSED.
 *
 * Refuse before, disclose after — the same principle pointed in opposite directions
 * by whether the irreversible step is still ahead.
 *
 * The earlier reading — that proceeding on `unconfirmed` preserved #400 — conflated
 * two different claims. #400 established that a Desktop process must never be KILLED
 * locally, because respawning the exe does not reliably bring the listener back. It
 * did not establish that an UNVERIFIED Manager stop is safe. Only the first is
 * settled, and refusing here does not touch it: the refusal leaves the server
 * RUNNING and points the user at the Desktop app, which restarts it reliably.
 *
 * #1847 is a second disclosed exception, and a different one: the parent PID exists
 * but cannot be identified. Inferring Desktop from argv would be a guess about that
 * live process. If every launch component is proven on disk, the restart proceeds
 * by spawning that command after Manager stops the old server.
 */
/**
 * `--extra-model-paths-config` values from argv, as ComfyUI stores them
 * (`nargs='+'`, repeatable). Relatives are returned raw — the caller fail-closes
 * rather than resolving them against this process's cwd.
 */
function extraModelPathsConfigsFromArgv(argv) {
    const out = [];
    for (let i = 0; i < argv.length; i++) {
        const tok = argv[i];
        if (!tok)
            continue;
        if (tok.startsWith("--extra-model-paths-config=")) {
            const v = tok
                .slice("--extra-model-paths-config=".length)
                .trim()
                .replace(/^["']+|["']+$/g, "");
            if (v)
                out.push(v);
            continue;
        }
        if (tok !== "--extra-model-paths-config")
            continue;
        for (let j = i + 1; j < argv.length; j++) {
            const v = argv[j];
            if (!v || v.startsWith("--"))
                break;
            const cleaned = v.trim().replace(/^["']+|["']+$/g, "");
            if (cleaned)
                out.push(cleaned);
        }
    }
    return out;
}
/**
 * Can this Desktop instance be relaunched from facts already on disk (#1847)?
 *
 * Fail closed: every component the reporter named must resolve as a real path.
 * A missing interpreter, a missing main.py, an empty argv, or an extra-model-paths
 * config that is relative or absent is not a command we may spawn after a stop.
 */
function proveDesktopSelfRelaunch(info) {
    const argv = relaunchArgv(info);
    if (argv.length === 0) {
        return {
            ok: false,
            reason: "the running server did not report sys.argv, so the launch command cannot be proven",
        };
    }
    const cmd = resolveLaunchCommand(info);
    if (!cmd) {
        return {
            ok: false,
            reason: "the launch command could not be rebuilt from the running server's arguments",
        };
    }
    if (!looksLikePath(cmd.exe) || !fileExists(cmd.exe)) {
        return {
            ok: false,
            reason: `the interpreter does not exist on disk: ${cmd.exe || "(unresolved)"}`,
        };
    }
    const script = cmd.args[0];
    if (!script || !/\.pyw?$/i.test(script)) {
        return {
            ok: false,
            reason: "main.py could not be identified in the launch command",
        };
    }
    if (!isAbsolutePath(script) || !fileExists(script)) {
        return {
            ok: false,
            reason: `main.py does not exist on disk: ${script}`,
        };
    }
    const configs = extraModelPathsConfigsFromArgv(argv);
    if (configs.length === 0) {
        return {
            ok: false,
            reason: "sys.argv has no --extra-model-paths-config, so the instance-model-paths config cannot be proven",
        };
    }
    for (const cfg of configs) {
        if (!isAbsolutePath(cfg)) {
            return {
                ok: false,
                reason: `instance-model-paths config is not an absolute path: ${cfg}`,
            };
        }
        if (!fileExists(cfg)) {
            return {
                ok: false,
                reason: `instance-model-paths config does not exist on disk: ${cfg}`,
            };
        }
    }
    return { ok: true };
}
/**
 * The evidence clause appended to a Desktop refusal (#2784).
 *
 * A refusal that names no evidence is a claim the reader cannot check. The
 * reporter here was on ComfyUI-Easy-Install with an embedded python — they could
 * see "ComfyUI Desktop started the server" was wrong, had nothing to test it
 * against, and filed a report whose path was redacted, so the cause could not be
 * reproduced from it either.
 *
 * Says nothing when there is nothing to say: an install classified before this
 * field existed, or a code path that did not record it, gets the message it always
 * had rather than an empty "classified because: undefined".
 */
export function desktopEvidenceClause(why) {
    // Takes the FIELD, not the whole ProcessInfo: the clause depends on exactly one
    // value, and narrowing the parameter is what lets a test call this for real
    // instead of asserting that the source file contains the right substring.
    if (!why)
        return "";
    return (`\n\nClassified as Desktop-managed by: ${why}. ` +
        `If that is wrong for this install — say, an ordinary ComfyUI that happens to live under a ` +
        `directory with a Desktop-like name — this refusal is wrong with it, and the launch ` +
        `arguments below are the thing to check it against.`);
}
/**
 * The same disclosure for the surfaces that are NOT the refusal (#2784).
 *
 * The Desktop verdict drives three user-facing outcomes, not one: the restart
 * refusal, the "could not determine the Desktop executable" message, and turning
 * auto-restart supervision OFF. The refusal is the only one that carried its
 * evidence, so the other two still asserted Desktop with nothing attached — and
 * they are the ones a misclassified user is least able to connect back to a
 * directory name.
 *
 * Separate from `desktopEvidenceClause` rather than a parameter on it because the
 * wording has to differ: that one says "this refusal is wrong with it" and points
 * at "the launch arguments below", neither of which is true here. Same fact, same
 * escape hatch, sentence that fits the surface.
 */
export function desktopEvidenceNote(why) {
    if (!why)
        return "";
    return (` Classified as Desktop-managed by: ${why}. ` +
        `If that is wrong for this install — say, an ordinary ComfyUI that happens to live ` +
        `under a directory with a Desktop-like name — then this limitation does not apply ` +
        `to it, and the classification is what to fix.`);
}
function assessDesktopSupervision(info) {
    const cannotAssess = (because) => ({
        ok: false,
        supervision: unclassifiedSupervision(),
        reason: `this is a ComfyUI Desktop instance, and it could not be established that a Desktop app ` +
            `is still supervising it (${because}). A restart from here asks ComfyUI-Manager to STOP ` +
            `the process and depends on that supervisor to start it again — so without that, the ` +
            `stop could not be undone.`,
    });
    // A SHELL FOUND BY NAME IS NOT EVIDENCE ABOUT THIS PORT'S SERVER. When ComfyUI
    // could not be attributed to the port, the caller falls back to whatever Desktop
    // process is running anywhere on the machine. That pid is bound to no port and no
    // backend: a second, unrelated Desktop window would otherwise stand in as the
    // supervisor of an orphaned backend it has never heard of, and the reboot would
    // stop a server nothing restarts (codex gate round 9). There is nothing to classify
    // here — the premise is missing, not the evidence.
    if (info.pidFromDesktopScan) {
        return cannotAssess(`the server on port ${info.port} could not be identified, and the only ComfyUI Desktop ` +
            `process found (PID ${info.pid}) was located by scanning process names — nothing ties ` +
            `it to that port, so it cannot be shown to supervise the server this would stop`);
    }
    // NO SPECIAL CASE FOR A MISSING PID. An early `ok: true` here would be one more
    // permissive exit skipping the guard, and an untestable one at that. The classifier
    // already handles pid 0 the way it handles any pid it cannot read — the identity
    // and parent reads come back empty and the verdict is `unconfirmed`, which now
    // refuses — so letting it fall through inherits a path that IS tested.
    const { verdict, because, parentUnreadableAt, parentIdentityUnreadableAt } = classifyDesktopSupervision({
        pid: info.pid,
        readParentPid,
        readIdentity: resolveProcessIdentity,
        processExists,
        isSupervisorProcess: isDesktopSupervisorProcess,
    });
    if (verdict === "supervised")
        return { ok: true, supervision: verdict };
    if (verdict === "abandoned") {
        return {
            ok: false,
            supervision: verdict,
            reason: `ComfyUI Desktop started the server on port ${info.port} (PID ${info.pid}), but no ` +
                `Desktop app is still supervising it — its parent process is gone. A restart from here ` +
                `asks ComfyUI-Manager to stop the process and relies on that supervisor to start it ` +
                `again, so it would be stopped and nothing would bring it back.` +
                // #2784 — the sentence above is a CLAIM, and a reporter on a
                // ComfyUI-Easy-Install with an embedded python could see it was false while
                // having nothing to check: the refusal named no evidence, so the report
                // arrived with a redacted path and the cause could not be reproduced from
                // it. Say which signal decided, and say plainly that this one is
                // contestable — an argv NAME match is satisfied by a directory that merely
                // has the name.
                desktopEvidenceClause(info.desktopEvidence),
        };
    }
    // #1647 — the host cannot read parentage AT ALL (the FIRST link failed), and the
    // server's own argv already carries the Desktop launch signatures. The walk has
    // nothing to say, but the argv does: only the Desktop app launches its backend
    // with those flags, so Desktop is what started this server and a live Desktop app
    // restarts a backend that exits under it. Proceed on that inference — DISCLOSED,
    // via `note`, never silently — rather than refusing a restart that works.
    //
    // Restricted to the first hop on purpose: an unreadable parent DEEPER in the chain
    // means wrappers between the backend and whatever spawned it, a layout nothing here
    // can reason about. And the signature guard is the same test the Desktop routing
    // itself used, re-applied rather than assumed, so this fallback can never be
    // reached by an instance that was not independently identified as Desktop.
    //
    // The risk being accepted, and which the note names: a server started BY HAND with
    // Desktop's flags (borrowing its model-paths config) has no supervisor, and the
    // reboot would stop it for good. That is the disclosure's whole content — the user
    // who did that knows they did, and is told to check afterwards.
    if (parentUnreadableAt === info.pid &&
        isDesktopApp(info.argv.length > 0 ? info.argv : (info.osArgv ?? []))) {
        return {
            ok: true,
            supervision: verdict,
            note: `NOTE: Desktop supervision was INFERRED, not proven — ${because ?? `the process tree above PID ${info.pid} could not be read`}, ` +
                `so no live Desktop shell could be confirmed above this server. The restart proceeded on ` +
                `the strength of the server's own launch arguments, which are the ones the ComfyUI Desktop ` +
                `app passes when it launches its backend — so Desktop is what started it, and a running ` +
                `Desktop app restarts its backend when it stops. If you actually started this server BY ` +
                `HAND with Desktop's flags, nothing will bring it back: verify afterwards with ` +
                `get_system_stats (action:"health"), and start it from the ComfyUI Desktop app if it does ` +
                `not come back.`,
        };
    }
    // #1847 — the parent PID exists but what it is running could not be read, at
    // the FIRST hop. #1647 must not fire here: inferring Desktop from argv would
    // be a guess about a live process we failed to identify. If every launch
    // component is proven on disk, we can stop via Manager and spawn that exact
    // command ourselves — but only once this parent is proven gone. A free port
    // cannot tell "gone for good" from a supervised cold start still importing.
    // Any missing file keeps the refusal.
    if (parentIdentityUnreadableAt === info.pid) {
        const proven = proveDesktopSelfRelaunch(info);
        if (proven.ok) {
            return {
                ok: true,
                supervision: verdict,
                selfRelaunch: true,
                selfRelaunchParentPid: readParentPid(info.pid),
                note: `NOTE: Desktop supervision was not proven — ${because ?? `PID ${info.pid}'s parent exists but what it is running could not be read`}. ` +
                    `The restart proceeds because the launch command is proven on disk ` +
                    `(interpreter, main.py, sys.argv, instance-model-paths config). After Manager ` +
                    `stops the old server, that command is spawned only if that parent process is ` +
                    `gone and the port is free. A live parent may already be bringing the backend ` +
                    `back, and this call will not start a second one. Occupied or unreadable ports ` +
                    `are left alone. If those files have moved since this check, start ComfyUI from ` +
                    `the Desktop app.`,
            };
        }
        return {
            ...cannotAssess(`${because ?? `PID ${info.pid}'s parent exists but what it is running could not be read`}` +
                `; a self-relaunch was also not proven (${proven.reason})`),
            supervision: verdict,
        };
    }
    return {
        ...cannotAssess(because ?? `the process tree above PID ${info.pid} could not be read`),
        supervision: verdict,
    };
}
/**
 * Can we actually relaunch this instance? A restart must be atomic-ish: if we
 * can't build (and validate) a relaunch command, we must NOT stop the running
 * server — losing a restart is cheap, losing the server is not (issues
 * #368/#370). For a Desktop app this also RESOLVES + validates the launcher exe
 * (mutating `info.desktopExePath`) so the subsequent spawn uses a real path
 * rather than a regex guess that never matched the current "Comfy Desktop"
 * branding. For a script-based install it confirms the resolved interpreter and
 * `main.py` exist on disk — catching a stale COMFYUI_PATH that points at an
 * install with no runnable server.
 */
function assessRelaunch(info, opts) {
    if (info.isDesktopApp) {
        if (IS_WIN) {
            const exe = fileExists(info.desktopExePath)
                ? info.desktopExePath
                : findDesktopExeFromCommonPaths();
            if (!exe || !fileExists(exe)) {
                return {
                    ok: false,
                    reason: "Could not determine (or locate on disk) the ComfyUI Desktop executable to relaunch.",
                };
            }
            info.desktopExePath = exe;
            return { ok: true };
        }
        // macOS: relaunch via `open -a`, which accepts an app bundle path or name.
        // Prefer a bundle that still exists on disk; fall back to a located one.
        const appPath = fileExists(info.desktopExePath)
            ? info.desktopExePath
            : findDesktopExeFromCommonPaths();
        if (!appPath || !fileExists(appPath)) {
            return {
                ok: false,
                reason: "Could not determine (or locate on disk) the ComfyUI Desktop app to relaunch.",
            };
        }
        info.desktopExePath = appPath;
        return { ok: true };
    }
    const cmd = resolveLaunchCommand(info);
    if (!cmd) {
        return {
            ok: false,
            reason: "Could not build a relaunch command from the running server's launch arguments.",
        };
    }
    if (looksLikePath(cmd.exe) && !fileExists(cmd.exe)) {
        return {
            ok: false,
            reason: `Resolved Python interpreter does not exist on disk: ${cmd.exe}.`,
        };
    }
    const script = cmd.args[0];
    if (script && /\.pyw?$/i.test(script)) {
        // We could only VALIDATE the script when it was resolved to an ABSOLUTE path
        // (either argv[0] was absolute, or resolveScriptAnchor anchored a relative
        // argv[0] to a canonical absolute install). A script still RELATIVE here means
        // the live-first anchor produced nothing absolute — i.e. a truly unresolvable
        // install (stale/relative COMFYUI_PATH, no saved workspace, no argv root). We
        // cannot confirm it exists, so REFUSE rather than kill a reachable server we
        // can't relaunch (#476/#426 refuse-safe; also covers a bare `main.py`).
        const scriptIsAbsolute = isAbsolute(script) ||
            /^[a-zA-Z]:[\\/]/.test(script) ||
            /^\\\\/.test(script);
        if (!scriptIsAbsolute || !fileExists(script)) {
            return {
                ok: false,
                reason: `Resolved ComfyUI script does not exist on disk: ${script} — ` +
                    "could not locate the ComfyUI install; set COMFYUI_PATH or a default " +
                    "workspace (COMFYUI_PATH may point at a stale/old install that has no " +
                    "runnable server).",
            };
        }
    }
    // #776: the command is only half the relaunch. A launcher (Stability Matrix,
    // Pinokio) hands ComfyUI an environment we do NOT inherit — relaunching without
    // it starts a process that dies during import and leaves the server DOWN. Decide
    // it HERE, pre-stop, so an irreproducible environment refuses while the server is
    // still running rather than after it has been killed.
    if (opts?.requireReproducibleEnv) {
        const envPlan = ensureLaunchEnvPlan(info, cmd);
        if (!envPlan.reproducible) {
            return {
                ok: false,
                reason: envPlan.reason ?? envPlan.info.note,
                advice: envPlan.advice,
            };
        }
    }
    return { ok: true };
}
/**
 * Resolve the running instance to control: ComfyUI's /system_stats argv + the
 * PID on the port, falling back to OS-level Desktop-app detection when the API
 * and port are unreachable. Returns null when nothing can be found.
 */
async function acquireProcessInfo() {
    try {
        return { info: await gatherProcessInfo() };
    }
    catch (err) {
        // #449: the server answered /system_stats but we could not map its port to
        // a PID. Do NOT fall through to killing a Desktop shell we can't confirm
        // owns :PORT — surface the diagnostic and leave everything untouched.
        if (err instanceof ProcessControlError &&
            (err.reachableButNoPid || err.identityAmbiguous)) {
            return { info: null, diagnostic: err.message };
        }
        const desktopPids = findDesktopAppPids();
        if (desktopPids.length > 0) {
            logger.info(`API unreachable but found Desktop app PIDs: ${desktopPids.join(", ")}`);
            return {
                info: {
                    pid: desktopPids[0],
                    port: config.resolvedPort,
                    argv: [],
                    targetFence: currentTargetFence(),
                    isDesktopApp: true,
                    desktopExePath: findDesktopExeFromCommonPaths(),
                    // FOUND BY NAME, NOT BY PORT — nothing ties this shell to the server on
                    // config.resolvedPort. Recorded so the restart preflight cannot mistake
                    // "a Desktop app is running" for "this Desktop app supervises that server".
                    pidFromDesktopScan: true,
                },
            };
        }
        // Genuinely down (not reachable, no Desktop shell): let callers use their
        // existing friendly "no process" message.
        return { info: null };
    }
}
/**
 * TRUE while a deliberate stop is mid-kill. The supervisor must not read the exit
 * WE are causing as a crash and respawn into it — that window exists because the
 * supervisor teardown deliberately happens AFTER the kill returns, so that a kill
 * which THREW leaves supervision intact (coordinator gate P1(3)).
 */
let deliberateStop = false;
function handleSupervisedChildStop(child, reason) {
    if (supervisedChild !== child)
        return;
    // A stop WE are performing is not a crash — never respawn into it.
    if (deliberateStop)
        return;
    detachSupervisor();
    // The supervised ComfyUI is GONE (crash/exit), whether or not we go on to
    // respawn it. Whatever Manager dialect we classified belonged to that dead
    // instance, and a respawn can come back as a different Manager generation on
    // the same URL — re-probe rather than trust it (#646).
    resetManagerApiCache("supervised comfyui exited");
    if (!lastProcessInfo)
        return;
    const currentPolicy = getRestartPolicy();
    if (!currentPolicy.enabled)
        return;
    if (!rememberRestartAttempt(currentPolicy)) {
        logger.warn("ComfyUI exited unexpectedly; auto-restart limit reached", {
            code: reason.code,
            signal: reason.signal,
            error: reason.error,
            maxRestarts: currentPolicy.maxRestarts,
            windowMs: currentPolicy.windowMs,
        });
        return;
    }
    logger.warn("ComfyUI exited unexpectedly; restarting", {
        code: reason.code,
        signal: reason.signal,
        error: reason.error,
        restartCount: supervisorRestartCount,
        maxRestarts: currentPolicy.maxRestarts,
    });
    const restarted = spawnFromProcessInfo(lastProcessInfo);
    if (!restarted) {
        logger.warn("Could not auto-restart ComfyUI because launch info was incomplete");
        return;
    }
    restarted.child.unref();
    if (!lastProcessInfo.isDesktopApp)
        adoptLaunchedChild(restarted.child);
    superviseChild(restarted.child, lastProcessInfo);
}
function captureChildProcessError(child) {
    return new Promise((resolve) => {
        child.once("error", (err) => {
            const error = childProcessErrorDetails(err);
            logger.error("ComfyUI child process emitted an error", { error });
            resolve(error);
        });
    });
}
function superviseChild(child, info) {
    detachSupervisor();
    const policy = getRestartPolicy();
    if (!policy.enabled || info.isDesktopApp)
        return;
    supervisedChild = child;
    supervisedExitHandler = (code, signal) => {
        handleSupervisedChildStop(child, { code, signal });
    };
    supervisedErrorHandler = (err) => {
        const error = childProcessErrorDetails(err);
        logger.error("ComfyUI child process emitted an error", { error });
        handleSupervisedChildStop(child, { error });
    };
    child.on("exit", supervisedExitHandler);
    child.once("error", supervisedErrorHandler);
}
// ---------------------------------------------------------------------------
// Gather process info from running ComfyUI
// ---------------------------------------------------------------------------
/**
 * Re-ask the server who it is, now that a pid is in hand, and require the answer
 * to be unchanged AND that pid to still own the port.
 *
 * Returns:
 *   "confirmed" — same argv, same port owner. The pid and the HTTP response are
 *                 bound to each other as tightly as observation allows.
 *   "changed"   — the server answered with DIFFERENT launch arguments, or the port
 *                 changed hands. Positive evidence of substitution.
 *   "unknown"   — the re-fetch failed (transport hiccup). Absence of evidence, and
 *                 deliberately NOT treated as evidence of absence: a transient
 *                 network error must not refuse a restart that would work.
 */
async function reconfirmAnsweringServer(argv, pid, port) {
    let secondArgv;
    try {
        const stats = await getSystemStats();
        secondArgv = stats.system.argv ?? [];
    }
    catch {
        return "unknown";
    }
    // An empty second answer says nothing about identity.
    if (secondArgv.length === 0 && argv.length === 0)
        return "unknown";
    const same = secondArgv.length === argv.length &&
        secondArgv.every((token, i) => token === argv[i]);
    if (!same)
        return "changed";
    let ownerNow = null;
    try {
        ownerNow = findPidByPort(port);
    }
    catch {
        return "unknown";
    }
    if (ownerNow == null)
        return "unknown";
    return ownerNow === pid ? "confirmed" : "changed";
}
/**
 * Recover a Desktop backend when the listener cannot be mapped to a PID.
 *
 * This is deliberately narrower than the old "find a Desktop process by name"
 * fallback. The server has to have supplied a Desktop-shaped `sys.argv`; the OS
 * process table has to expose one exact, spawn-faithful Python command with the
 * same arguments; its direct parent has to be Python and that process's direct
 * parent has to be a name-discovered Desktop PID; and the normal supervision
 * classifier must authenticate every causality link. Missing or ambiguous evidence
 * returns undefined so the caller keeps its existing refusal.
 */
function recoverDesktopProcessFromAncestry(port, serverArgv) {
    if (!isDesktopServerArgv(serverArgv, port))
        return undefined;
    const desktopProbe = findDesktopAppPidsWithStatus();
    if (!desktopProbe.complete)
        return undefined;
    const desktopPids = new Set(desktopProbe.pids);
    if (desktopPids.size === 0)
        return undefined;
    const pythonProbe = findPythonProcessPids();
    if (!pythonProbe.complete)
        return undefined;
    const identities = new Map();
    const readIdentity = (pid) => {
        if (!identities.has(pid))
            identities.set(pid, resolveProcessIdentity(pid));
        return identities.get(pid);
    };
    const readParent = (pid) => {
        // The normal reader and the test seam both represent the same OS fact. Use
        // the seam when installed, otherwise retain the identity snapshot in the
        // cache so one recovery decision does not mix process generations.
        if (parentPidResolverOverride)
            return readParentPid(pid);
        return readIdentity(pid)?.parentPid;
    };
    const matches = [];
    let evidenceUnavailable = false;
    for (const pid of pythonProbe.pids) {
        const identity = readIdentity(pid);
        if (!hasExactProcessIdentity(identity)) {
            evidenceUnavailable = true;
            continue;
        }
        // Fully observed non-matching Python processes are ordinary background
        // processes and can be ignored. Once the exact H3 command matches, however,
        // an untrusted executable path is an ambiguous target and must fail closed.
        if (!exactDesktopServerCommand(identity, serverArgv))
            continue;
        if (!isAuthenticatedPython(identity, serverArgv)) {
            evidenceUnavailable = true;
            continue;
        }
        const ancestry = directDesktopPythonAncestry(pid, desktopPids, readParent, readIdentity);
        if (ancestry === "unavailable") {
            evidenceUnavailable = true;
            continue;
        }
        if (!ancestry)
            continue;
        const { verdict } = classifyDesktopSupervision({
            pid,
            readParentPid: readParent,
            readIdentity,
            processExists,
            isSupervisorProcess: isDesktopSupervisorProcess,
        });
        if (verdict === "supervised") {
            matches.push({ pid, identity, ...ancestry });
        }
        else if (verdict === "unconfirmed") {
            evidenceUnavailable = true;
        }
    }
    // Two identical H3 launches under Desktop are not distinguishable once the
    // port table is unavailable. Choosing one would turn ambiguity into authority.
    if (evidenceUnavailable || matches.length !== 1)
        return undefined;
    const match = matches[0];
    if (!match)
        return undefined;
    return {
        pid: match.pid,
        port,
        argv: [...serverArgv],
        osArgv: match.identity.argv,
        osArgvExact: match.identity.argvFidelity === "exact",
        osCommandLine: match.identity.commandLine,
        observedInterpreter: observedInterpreterFromIdentity(match.identity),
        isDesktopApp: true,
        desktopExePath: match.desktopIdentity.executablePath,
        startedAt: match.identity.startedAt,
    };
}
async function gatherProcessInfo() {
    const port = config.resolvedPort;
    const targetFence = currentTargetFence();
    // 1. Get argv from /system_stats, BRACKETED by port-owner lookups.
    //
    // The argv and the pid are two separate observations, and the whole hazard is
    // that they may describe two different processes: A answers, A exits, B binds
    // the port, and the pid we then look up is B's — after which B is "identified"
    // from A's answer and is what a restart would kill. Re-asking afterwards does
    // NOT settle this: it only proves the CURRENT owner is self-consistent, which a
    // replacement with identical argv satisfies perfectly (codex gate).
    //
    // So carry something across the two observations that a substitution cannot
    // reproduce: the identity of the port owner BEFORE the HTTP call. If the same
    // pid still owns the port afterwards, the answer we hold was produced while that
    // one process held the socket throughout.
    // The bracket is REQUIRED, not best-effort (codex gate): if the first lookup
    // came back empty we have no anchor, and A-answers-then-B-takes-the-port with
    // identical argv would sail through every later self-consistent check. A single
    // flaky lookup is retried; a bracket we still cannot close refuses.
    //
    // Both ends of the bracket compare a full process IDENTITY, not a pid number:
    // pid equality across a window is exactly what pid REUSE defeats (A owns 4321,
    // answers, exits; B inherits 4321 before the closing lookup and the bracket would
    // close on the number alone). So each end reads pid + creation time together, and
    // an end whose stamp cannot be read is "did not observe", never "observed the
    // same" (codex gate).
    //
    // When the host cannot stamp the creation time at all (#914 — reported on
    // macOS), the stamp comparison has nothing to work with, but the window still
    // needs a continuity proof: an instance WITNESS — a WebSocket held open across
    // the fetch (see instance-witness.ts). The witness's peer is the process holding
    // the listening socket, so a live witness at the closing end means one continuous
    // instance served both port lookups, and no pid reuse can leave it open. A
    // witness that could not be acquired or that dropped mid-fetch is NOT evidence
    // of substitution — only the absence of the fallback proof.
    //
    // `missing` records WHICH capability was unavailable, so a refusal can name it
    // rather than leaving the user to guess.
    let missingCapability;
    const observeOwner = () => {
        let owner = null;
        try {
            owner = findPidByPort(port);
        }
        catch {
            owner = null;
        }
        if (owner == null) {
            missingCapability =
                `the process listening on port ${port} could not be identified (no usable ` +
                    `port-owner lookup — on Linux this needs \`lsof\`, on Windows \`netstat\`/PowerShell)`;
            return null;
        }
        // A missing stamp does NOT null the observation (#914): the pid is real
        // evidence, and the continuity witness below stands in for the stamp.
        return { pid: owner, startedAt: resolveProcessIdentity(owner)?.startedAt };
    };
    let argv = [];
    let serverCwd;
    let pid = null;
    let ownerStartedAt;
    let bracketed = false;
    for (let attempt = 0; attempt < 2; attempt++) {
        const before = observeOwner();
        // #914: acquire the witness BEFORE the fetch, so the window it fences contains
        // the fetch — acquiring it afterwards would prove nothing about who answered.
        // Only needed when the opening observation has no stamp to compare; best-effort,
        // since a failed acquisition just leaves this attempt with the stamp evidence.
        let witness;
        if (before && !before.startedAt) {
            witness = await acquireInstanceWitness(getComfyUIBaseUrl());
        }
        // The finally is the ONLY cleanup: the bracket logic below exits by break,
        // continue AND throw, and a close that depends on each path remembering it
        // leaks a live socket the first time one doesn't (codex gate).
        try {
            try {
                const stats = await getSystemStats();
                argv = stats.system.argv ?? [];
                serverCwd = reportedServerCwd(stats.system.cwd);
            }
            catch {
                argv = [];
                serverCwd = undefined;
                logger.warn("Could not fetch system_stats — will rely on PID detection");
            }
            const after = observeOwner();
            pid = after?.pid ?? before?.pid ?? null;
            // Keep the bracketed instance stamp: the observed-cwd fallback (#535) must
            // bind to this PROCESS INSTANCE, not to a pid number it can re-observe later.
            ownerStartedAt = after?.startedAt ?? before?.startedAt;
            if (pid == null) {
                try {
                    pid = findPidByPort(port);
                }
                catch {
                    pid = null;
                }
            }
            // No answer to bind means there is nothing to mis-bind: the pid-only paths
            // below (including #449's reachable-but-unmapped) own that case.
            if (argv.length === 0)
                break;
            if (before && after) {
                if (before.pid !== after.pid) {
                    const err = new ProcessControlError(`The process listening on port ${port} changed while ComfyUI was being identified ` +
                        `(PID ${before.pid} answered; PID ${after.pid} holds the port now). Nothing was ` +
                        `stopped: the launch arguments we hold describe the instance that has gone, not ` +
                        `the one running now. Re-run once the server has settled.`);
                    err.identityAmbiguous = true;
                    throw err;
                }
                if (before.startedAt && after.startedAt) {
                    if (before.startedAt !== after.startedAt) {
                        const err = new ProcessControlError(`The process listening on port ${port} changed while ComfyUI was being identified ` +
                            `(PID ${before.pid} answered and still holds the port, but it is a different ` +
                            `process reusing that number). Nothing was stopped: the launch arguments we ` +
                            `hold describe the instance that has gone, not the one running now. Re-run ` +
                            `once the server has settled.`);
                        err.identityAmbiguous = true;
                        throw err;
                    }
                    bracketed = true;
                    break;
                }
                // At least one end could not stamp the process. A stable pid NUMBER across
                // the fetch is not identity — pid reuse defeats it — so the window closes on
                // the witness instead (#914): still open here means one continuous instance
                // held the port for the whole fetch.
                if (witness?.alive()) {
                    bracketed = true;
                    break;
                }
                missingCapability =
                    `the creation time of PID ${before.pid} could not be read at both ends of the ` +
                        `request (this host does not reliably expose process start times to us), and ` +
                        `the fallback continuity check — a WebSocket held open to the server for the ` +
                        `duration of the request — could not be established or did not stay open`;
                // One identity source may be flapping — retry once before giving up.
                continue;
            }
            // One side of the bracket was unobservable — retry once before giving up.
        }
        finally {
            witness?.close();
        }
    }
    if (argv.length > 0 && pid != null && !bracketed) {
        const err = new ProcessControlError(`ComfyUI answered on port ${port}, but its answer could not be tied to PID ${pid}: ` +
            `${missingCapability ?? "the port owner's identity could not be observed on both sides of the request"}. ` +
            `Nothing was stopped — killing a process we cannot identify risks taking down the wrong ` +
            `one. Restart ComfyUI from the launcher that owns it, then try again.`);
        err.identityAmbiguous = true;
        throw err;
    }
    if (!pid) {
        // A broken Windows listener can leave the server alive and still answering
        // through an already-established panel connection, while every port-owner
        // lookup returns unknown. Bind that server through the authenticated
        // Desktop→Python ancestry instead of treating a Desktop name scan as proof.
        // The recovery helper returns nothing unless the command and ancestry are
        // independently verified, so the ordinary fail-closed error remains intact.
        if (argv.length > 0) {
            const recovered = recoverDesktopProcessFromAncestry(port, argv);
            if (recovered)
                return { ...recovered, targetFence };
        }
        // Liveness is the reachable SERVER, not only a local PID scan. If
        // /system_stats just answered (argv populated) yet we still can't map the
        // listening socket to a PID, say so precisely instead of claiming the
        // process doesn't exist (issue #449).
        if (argv.length > 0) {
            const reachableErr = new ProcessControlError(`ComfyUI is reachable on port ${port} but its listening process could not ` +
                `be mapped to a local PID (port-owner lookup failed). The server was left ` +
                `untouched. On a portable/embedded-Python install, restart it via its ` +
                `launcher/console or trigger a ComfyUI-Manager reboot.`);
            reachableErr.reachableButNoPid = true;
            throw reachableErr;
        }
        throw new ProcessControlError(`No process found listening on port ${port}. Is ComfyUI running?`);
    }
    // THE SERVER COULD NOT SAY WHAT IT IS RUNNING — ask the OS (#767).
    //
    // An empty `argv` means `/system_stats` did not answer: the server is wedged
    // (a CUDA OOM is the reported case) or otherwise unreachable, which is EXACTLY
    // when a user reaches for stop/restart. Everything downstream then had nothing to
    // relaunch from, so the stop went ahead and the start could not follow. The OS has
    // the command line throughout.
    //
    // Read here, while the pid is alive, for the same reason `liveCwd` is: the process
    // table entry is gone the instant the kill lands.
    const osIdentity = argv.length === 0 ? resolveProcessIdentity(pid) : undefined;
    const osArgv = osIdentity?.argv;
    const osArgvExact = osIdentity?.argvFidelity === "exact";
    const osCommandLine = osIdentity?.commandLine;
    // Desktop is decided from WHATEVER account of the process we have. Deciding it
    // from `argv` alone meant an unreachable Desktop instance (argv empty) classified
    // as an ordinary Python install — and would then be KILLED, which is the one thing
    // the Desktop path exists to never do (#400).
    const identifyingArgv = argv.length > 0 ? argv : (osArgv ?? []);
    // Desktop-2's python argv often has no "Comfy Desktop" token (core lives
    // under ~/ComfyUI-Installs, venv under ~/Documents/ComfyUI). Recognise the
    // parent `Comfy Desktop.exe` and the `.comfyui-desktop-2` / snapshot
    // markers so stop can record a relaunch path (#2482).
    const desktopLaunch = detectDesktopLaunch(pid, identifyingArgv);
    const desktop = desktopLaunch.isDesktopApp;
    const desktopExe = desktopLaunch.desktopExePath;
    const desktopEvidence = desktopLaunch.desktopEvidence;
    // Capture the live process cwd NOW, while the pid is guaranteed alive — the
    // `/proc/<pid>/cwd` symlink is gone the instant a later stop kills it (#535).
    //
    // On Windows there is no `/proc`, so this returned undefined and the relative-script
    // anchor downstream could never fire — a `ComfyUI\main.py` launch was refused
    // outright with "could not locate the ComfyUI install", which is the #535 recurrence
    // that kept coming back. `observedLiveCwd` reconstructs the SAME fact from the OS
    // process observation instead of from procfs: the directory the server's relative
    // `main.py` must have been resolved against for it to name the install its own
    // interpreter lives in.
    const liveCwd = desktop
        ? undefined
        : (resolveLiveProcessCwd(pid) ??
            serverCwd ??
            observedLiveCwd(pid, argv, ownerStartedAt));
    // Same live-only window for the ENVIRONMENT (#776): read it now, while the pid
    // is guaranteed alive, so a relaunch can reproduce the launcher environment the
    // server was actually started with instead of substituting the orchestrator's.
    // The pid's IDENTITY (creation time), CORROBORATED against the argv the server
    // itself reported — so a pid recycled between the port lookup and this read is
    // never bound to, however self-consistent its own later re-reads would be.
    // POSITIVE counter-evidence refuses outright: if the OS says the process holding
    // our port is running something other than the ComfyUI that just answered us,
    // then the answer and the pid describe different processes, and everything built
    // on that pairing — the environment we would reproduce, the argv we would
    // relaunch, the process we would KILL — is about the wrong one. Applies whatever
    // the argv looked like, Desktop included (codex gate).
    const corroboration = resolveCorroboratedIdentity(pid, argv);
    if (corroboration.kind === "mismatch") {
        const err = new ProcessControlError(`The process listening on port ${port} (PID ${pid}) is not running the ComfyUI that ` +
            `answered /system_stats — the OS reports its command line as "${corroboration.commandLine}", ` +
            `which does not match that server's launch arguments. Nothing was stopped: acting on this ` +
            `pairing would control the wrong process. Re-run once the server has settled.`);
        err.identityAmbiguous = true;
        throw err;
    }
    // #1654: preserve the interpreter the healthy process is RUNNING under, from the
    // same corroborated OS observation that bound the pid — captured now, while the
    // process is alive, for the same reason `liveCwd` is. The relaunch prefers it
    // over the install-layout guess, which picks `.venv` over `venv` whenever both
    // exist regardless of which environment the server actually imports from.
    //
    // #1704: `confirmed` also requires a creation stamp (needed to bind a KILL).
    // A matching command line still names the interpreter when the stamp is
    // unreadable — the same observation `install_comfyui(action:"environment")`
    // already reports — so a server started outside this session is not relaunched
    // with Stability Matrix's unused Assets CPython.
    //
    // #1704 recurrence: the port owner can be the trampoline's BASE child. argv[0]
    // of that child is the home CPython (Assets or otherwise); the parent is the
    // package venv that actually has site-packages. Prefer that parent.
    const interpreterIdentity = corroboration.kind === "confirmed"
        ? corroboration.identity
        : (osIdentity ?? resolveProcessIdentity(pid));
    const observedInterpreter = !desktop && interpreterIdentity
        ? (observedInterpreterFromTrampolineParent(interpreterIdentity, argv) ??
            observedInterpreterFromIdentity(interpreterIdentity))
        : undefined;
    const startedAt = desktop
        ? undefined
        : corroboration.kind === "confirmed"
            ? corroboration.identity.startedAt
            : // THE WEDGED PATH KEEPS ITS STAMP (codex gate round 5). With no answer from
                // the server there is nothing to corroborate the pid against, and the
                // corroboration therefore comes back `unknown` — but the pid's own creation
                // time was read all the same, and that stamp is precisely what pid REUSE
                // defeats. Discarding it would leave the pre-kill check with nothing but
                // numeric pid/port equality, so a replacement instance that rebound the port
                // after inheriting the number would be killed as if it were the one we
                // identified. Only reachable when the server said nothing: a corroboration
                // that came back `mismatch` has already thrown above.
                osIdentity?.startedAt;
    // BIND THE PID TO THE PROCESS THAT ANSWERED (coordinator gate P1(1)).
    //
    // `argv` came from an HTTP response; the pid came from a port lookup made
    // AFTERWARDS. Nothing so far rules out: ComfyUI A answers /system_stats, exits,
    // and ComfyUI B takes the port before the lookup — B is then "identified" from
    // A's answer and is what we would kill. A creation stamp cannot see that: it
    // only proves B was stable AFTER the lookup.
    //
    // So close the loop against the observation itself: ask the server again, now
    // that we hold a pid, and require the SAME argv to come back AND the same pid to
    // still own the port. A substitution by a differently-launched instance changes
    // the argv and is caught; a transport failure proves nothing and is not treated
    // as evidence either way.
    //
    // Runs for DESKTOP too (codex gate): `desktop` is itself derived from the FIRST,
    // possibly stale argv, so skipping the recheck there is how a Desktop answer from
    // A gets a Manager reboot fired at a non-Desktop B that took the port.
    const recheck = await reconfirmAnsweringServer(argv, pid, port);
    if (recheck === "changed") {
        const err = new ProcessControlError(`The ComfyUI answering on port ${port} changed while it was being identified ` +
            `(a different instance now owns the port, or reports different launch ` +
            `arguments). Nothing was stopped. Re-run once the server has settled.`);
        err.identityAmbiguous = true;
        throw err;
    }
    // Bound to that identity - never adopted from a pid we cannot still prove.
    const liveEnv = desktop
        ? undefined
        : captureVerifiedLiveEnv(pid, startedAt, argv);
    return {
        pid,
        port,
        argv,
        osArgv,
        osArgvExact,
        osCommandLine,
        observedInterpreter,
        isDesktopApp: desktop,
        desktopExePath: desktopExe,
        desktopEvidence,
        liveCwd,
        liveEnv,
        startedAt,
        targetFence,
    };
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export async function stopComfyUI(preInfo) {
    if (isRemoteMode()) {
        throw new ProcessControlError("restart_comfyui (action:\"stop\") operates on the local machine's ComfyUI process and is not " +
            "available when targeting a remote instance via --comfyui-url.");
    }
    logger.info("Stopping ComfyUI...");
    // Gather info before we kill it (or reuse the caller's pre-validated info so a
    // relaunch preflight in restartComfyUI is not discarded).
    //
    // The generation is captured BEFORE the resolve so a retarget landing inside that
    // await is caught (codex gate round 12). `restartComfyUI` fences its own window
    // and hands us pre-validated info; a DIRECT `restart_comfyui (action:"stop")` had no fence at all,
    // and its saved launch record does not repair the loss: `restart_comfyui (action:"start")` afterwards
    // consults the NEW live target, so it can refuse as remote or find that port
    // occupied rather than relaunch what was killed. That falsifies this tool's whole
    // contract — "captures process info so it can be restarted with restart_comfyui (action:\"start\")".
    const stopGeneration = getComfyuiTargetGeneration();
    let info = preInfo ?? null;
    let acquireDiagnostic;
    if (!info) {
        const acquired = await acquireProcessInfo();
        info = acquired.info;
        acquireDiagnostic = acquired.diagnostic;
    }
    if (!info) {
        return {
            stopped: false,
            message: acquireDiagnostic ??
                `No ComfyUI process found on port ${config.resolvedPort}. Is ComfyUI running?`,
            has_restart_info: false,
        };
    }
    // LAST-MOMENT IDENTITY CHECK: the pid was resolved before the relaunch preflight
    // ran, and a pid is not a process identity. If the server exited in that window
    // and the OS recycled the number, killing it would destroy an unrelated program.
    // Refuse instead - nothing has been touched yet, so this costs a restart, never
    // the server.
    //
    // ORDERING IS LOAD-BEARING (coordinator gate): this runs BEFORE the supervisor
    // teardown below. A refusal leaves a still-RUNNING server, and tearing down its
    // crash supervision first would silently disarm auto-restart for a server we
    // then declined to touch — turning a safe refusal into a later lost server.
    // …and the same refusal for a target that moved while we resolved it. Placed
    // beside the identity check for the same reason it is: nothing has been touched
    // yet, so refusing costs a stop and never the server. `preInfo` callers were
    // already fenced by restartComfyUI, and re-checking here is harmless for them —
    // their generation has not moved either.
    if (getComfyuiTargetGeneration() !== stopGeneration) {
        const retargetHint = recoveryHint(info);
        return {
            stopped: false,
            message: "Refusing to stop: the ComfyUI target changed while the running instance was being " +
                "identified, so the instance resolved here is not provably the one this server is now " +
                "configured for — and restart_comfyui (action:\"start\") afterwards would consult the NEW target, which may " +
                "not bring this one back. Nothing was killed. Let the target settle, then retry." +
                describeRecovery(retargetHint),
            has_restart_info: false,
            restart_hint: retargetHint,
        };
    }
    const currentFence = currentTargetFence();
    if (!info.targetFence || !targetFencesEqual(info.targetFence, currentFence)) {
        const retargetHint = recoveryHint(info);
        return {
            stopped: false,
            message: "Refusing to stop: the running process is not bound to the exact current ComfyUI target, " +
                "so killing it could stop another tab's instance and leave its launch recipe stale. " +
                "Nothing was killed. Let the target settle, then retry.",
            has_restart_info: false,
            restart_hint: retargetHint,
        };
    }
    const identity = processIdentityStillValid(info);
    if (!identity.ok) {
        return {
            stopped: false,
            message: `Refusing to stop: ${identity.reason}. Nothing was killed. ` +
                "Re-run once ComfyUI is running again (or start it with restart_comfyui (action:\"start\")).",
            has_restart_info: false,
        };
    }
    logger.info("Captured process info", {
        pid: info.pid,
        port: info.port,
        isDesktopApp: info.isDesktopApp,
        argv: info.argv.join(" "),
    });
    // CAN THIS BE STARTED AGAIN? Asked BEFORE the kill, and answered honestly (#767).
    //
    // The stop used to report `has_restart_info: true` on the strength of having
    // stored a ProcessInfo — which is true even when that info holds nothing runnable.
    // A user recovering a wedged server was told the restart information was there,
    // and `restart_comfyui (action:"start")` then answered "No command-line info captured from previous
    // run". By then the server was gone.
    //
    // A REFUSAL is reserved for the genuinely unrecoverable case: no launch command
    // from the server, none from the OS, nothing to tell the user to run. Then the stop
    // is a one-way door and it is not ours to walk through. When a command WAS observed
    // the stop proceeds — `restart_comfyui (action:"stop")` is an explicit instruction to stop, and a
    // wedged server is precisely when someone means it — but `has_restart_info` states
    // whether we can do the starting, and the hint says how to do it by hand.
    //
    // `requireReproducibleEnv` is deliberately NOT set here, and the distinction is the
    // point: this flag answers "is there a command to run?", which is what #767 was
    // about and what restart_comfyui (action:"start") needs to exist at all. An irreproducible launcher
    // environment is a different, weaker fact — the spawn still happens, it may simply
    // fail during import — and it is reported as its own caveat below rather than
    // collapsed into "there is no restart information", which would say something
    // untrue about a case where the command is right there.
    const relaunch = assessRelaunch(info);
    // Resolved EXPLICITLY rather than read off `info.envPlan`, which is only populated
    // by whichever caller happened to ask for it — a caveat that appears when the stop
    // came through restartComfyUI and vanishes when the same install is stopped
    // directly would be worse than no caveat at all.
    let envPlan;
    if (relaunch.ok && !info.isDesktopApp) {
        const cmd = resolveLaunchCommand(info);
        if (cmd)
            envPlan = ensureLaunchEnvPlan(info, cmd);
    }
    const hint = recoveryHint(info);
    if (!relaunch.ok && !hint) {
        return {
            stopped: false,
            has_restart_info: false,
            relaunch_blocked: relaunch.reason,
            message: `Refusing to stop ComfyUI (PID ${info.pid}): ${relaunch.reason} Nothing was killed — ` +
                `and nothing was observed about how this server was launched, so stopping it would ` +
                `leave you with no way to bring it back, by this tool or by hand. Stop it from the ` +
                `launcher/console that owns it instead.`,
        };
    }
    // Remember HOW to relaunch before doing anything irreversible. Saving this only
    // after a committed stop meant that any later refusal — including one taken while
    // the server may already be dead — left `restart_comfyui (action:"start")` with no launch info to
    // recover from (codex gate). It is only a record of what we observed; a start
    // still re-validates and refuses to double-launch onto an occupied port.
    lastProcessInfo = info;
    // Kill process tree (for Desktop app, kill the Electron shell too).
    //
    // A KILL THAT THREW IS NOT A COMMITTED STOP (coordinator gate P1(3)). `taskkill`
    // / `kill` fail for ordinary reasons — access denied above all — and the server
    // is then still running. Tearing supervision down first would disarm auto-restart
    // for a server we did not manage to stop, which is the same "safe refusal turned
    // into a later lost server" bug one step further along. So the teardown happens
    // ONLY after the kill returns, and a throw leaves every guarantee intact.
    //
    // `deliberateStop` covers the tiny window in between: the kill can deliver our
    // supervised child's `exit` before the teardown runs, and the supervisor must not
    // read a stop we asked for as a crash to recover from.
    deliberateStop = true;
    try {
        if (info.isDesktopApp) {
            killDesktopApp(info.pid);
        }
        else {
            killProcessTree(info.pid);
        }
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        // `taskkill /T` can kill the Python server (port closes) and still throw
        // because a helper PID in the same tree was already gone. Re-probe before
        // claiming the server was left untouched (#2482).
        const probe = probePortOwner(info.port);
        const originalGone = processExists(info.pid) === false;
        const portFree = probe.state === "free";
        const portMovedOn = probe.state === "owned" && probe.pid !== info.pid;
        if (portFree || originalGone || portMovedOn) {
            logger.warn("Kill command failed but the server is gone", {
                pid: info.pid,
                port: info.port,
                probe: probe.state,
                error: msg,
            });
        }
        else {
            deliberateStop = false;
            logger.warn("Stop aborted: the kill failed, so nothing was torn down", {
                pid: info.pid,
                error: msg,
            });
            return {
                stopped: false,
                message: `Could not stop ComfyUI (PID ${info.pid}): ${msg}. The server was left as it was — ` +
                    "its crash supervision and launch record are untouched. This is usually a " +
                    "permissions problem: stop it from the launcher/console that owns it.",
                has_restart_info: false,
            };
        }
    }
    // A KILL THAT RETURNED IS STILL NOT PROOF THE PROCESS DIED (codex gate). On
    // POSIX the forced `kill -9` is issued through a shell whose failure is swallowed
    // wholesale — an ignored SIGTERM followed by a failed SIGKILL looks exactly like
    // success from here. The port is the observable that settles it: wait for it to
    // be released, and let the timeout DECIDE rather than merely warn.
    let blockedReason;
    let unverified;
    try {
        await waitForPortFree(info.port);
    }
    catch {
        // Timed out. Three outcomes, and they must NOT be conflated:
        //   • still held by OUR target → the kill did not work, so the server is UP.
        //     Refusing is safe AND correct: it costs a restart, never the server.
        //   • held by somebody else → our target did die and a successor took the port.
        //   • could not be determined → see below.
        const probe = probePortOwner(info.port);
        if (probe.state === "owned" && probe.pid === info.pid) {
            blockedReason =
                `PID ${info.pid} still holds port ${info.port} after the kill was issued, so it is ` +
                    `still running`;
        }
        else if (probe.state === "unknown") {
            // We CANNOT tell whether the process died — and unlike every other
            // uncertainty in this file, refusing here does not restore anything: the kill
            // has already been issued. If it worked, refusing leaves the user's server
            // dead AND unrelaunched, which is the one outcome this whole issue exists to
            // prevent. So we commit and let the relaunch run; if the kill in fact failed,
            // the old server is still serving and the relaunch simply finds the port
            // taken (reported honestly as `not-ours`). Proceed — and say it is unverified.
            unverified =
                `the port could not be checked after the kill (${probe.reason}), so it is not ` +
                    `confirmed that PID ${info.pid} exited`;
            logger.warn("Stop committed without port verification", {
                pid: info.pid,
                port: info.port,
                reason: probe.reason,
            });
        }
        else {
            logger.warn("Port did not free in time, but it is no longer held by the process we killed", { pid: info.pid, owner: probe.state === "owned" ? probe.pid : "(free)" });
        }
    }
    if (blockedReason) {
        deliberateStop = false;
        logger.warn("Stop aborted before commit", { pid: info.pid, port: info.port, blockedReason });
        return {
            stopped: false,
            message: `Could not stop ComfyUI: ${blockedReason}. Nothing was torn down — its crash ` +
                `supervision and launch record are untouched. Stop it from the launcher/console ` +
                `that owns it.`,
            has_restart_info: false,
        };
    }
    // COMMITTED — the process is gone, or (when `unverified`) the kill has been
    // issued and we have chosen going forward over a refusal that could not restore
    // anything. Only now may we drop supervision.
    detachSupervisor();
    // The server we may have launched is going away — clear the shares-our-env flag so
    // a differently-launched successor doesn't inherit env-trust it shouldn't (#633 P1b).
    // Forget the recorded interpreter too: a stop was requested, so the launch record
    // must not outlive the process it describes (#401).
    recordedLaunchChild = undefined;
    resetLocalComfyUILaunchState();
    clearLaunchedInterpreter();
    deliberateStop = false;
    // Reset the WebSocket client singleton + the memoized /object_info —
    // a restart is exactly when the node set may have changed. The detected
    // ComfyUI-Manager API dialect is live-derived the same way: the instance that
    // comes back on this port can be a different Manager generation (a 3.x→4.x
    // upgrade, or dropping --enable-manager-legacy-ui) at an unchanged URL, and a
    // stale dialect misroutes every later Manager call (#646).
    resetClient();
    resetObjectInfoCache();
    resetManagerApiCache("comfyui stopped");
    return {
        stopped: true,
        message: `ComfyUI (PID ${info.pid}) stopped on port ${info.port}` +
            (unverified ? ` — NOTE: ${unverified}; continuing so the server can be brought back.` : "") +
            // Said PLAINLY and up front, not left to a boolean the caller may not read:
            // restart_comfyui (action:"start") will not be able to do this, so the human has to.
            (relaunch.ok
                ? // A command exists, but the environment it was launched into may not be
                    // reproducible — a weaker claim, stated as one.
                    envPlan && !envPlan.reproducible
                        ? ` NOTE: ${envPlan.reason ?? envPlan.info.note} restart_comfyui (action:"start") will still try, but the relaunch may fail during import.` +
                            describeRecovery(hint)
                        : ""
                : ` WARNING: restart_comfyui (action:"start") will NOT be able to bring this back — ${relaunch.reason}` +
                    describeRecovery(hint)),
        // The one claim #767 was about: it now means a relaunch command was built AND
        // validated, not merely that some process info was stored.
        has_restart_info: relaunch.ok,
        launch: info.desktopExePath ? { exe: info.desktopExePath } : undefined,
        relaunch_blocked: relaunch.ok ? undefined : relaunch.reason,
        restart_hint: hint,
        auto_restart: supervisorResult(info),
        unverified_exit: unverified,
    };
}
/**
 * `anchor` pins the relaunch to a SPECIFIC instance (codex gate round 12).
 *
 * `restartComfyUI` resolves and validates its instance, then kills it, then waits
 * for the port to free and sleeps — and only then starts. The configured target is
 * mutable across all of that. Left unanchored, the relaunch spawned the right
 * command (it comes from the saved ProcessInfo) but probed the NEW target's port:
 * if that port was occupied it returned "already running" WITHOUT spawning, and the
 * instance we had just killed stayed dead. Anchoring the port and the readiness URL
 * to the values captured before the stop makes the whole sequence act on one
 * instance. A direct `restart_comfyui (action:"start")` passes nothing and reads the live config, which
 * is right for it — there is no earlier moment it is bound to.
 */
export async function startComfyUI(anchor) {
    const savedInfo = lastProcessInfo;
    const targetFence = anchor?.targetFence ?? savedInfo?.targetFence ?? currentTargetFence();
    if (anchor?.targetFence && !targetFenceMatchesCurrent(anchor.targetFence)) {
        return startTargetFenceRefusal(anchor.targetFence, "the requested target changed before the anchored start began");
    }
    if (savedInfo && !savedInfo.targetFence) {
        lastProcessInfo = null;
        return startTargetFenceRefusal(targetFence, "the saved launch recipe has no target identity and cannot be proven to belong to the current instance");
    }
    if (savedInfo?.targetFence && !targetFenceMatchesCurrent(savedInfo.targetFence)) {
        lastProcessInfo = null;
        return startTargetFenceRefusal(savedInfo.targetFence, "the saved launch recipe belongs to an older ComfyUI target");
    }
    if (anchor?.targetFence && savedInfo?.targetFence && !targetFencesEqual(anchor.targetFence, savedInfo.targetFence)) {
        return startTargetFenceRefusal(anchor.targetFence, "the anchored target does not match the saved launch recipe");
    }
    // The refusal is for a DIRECT `restart_comfyui (action:"start")`, which has no instance in mind
    // and would otherwise launch a local server while the caller is looking at a
    // remote one. An ANCHORED call is a different question: a restart already
    // stopped a specific local instance and is putting it back. Refusing there
    // because another agent retargeted to remote during the stop window left that
    // instance killed and abandoned, with the exception escaping past the point of
    // no return (codex gate P0). The anchor is the evidence that this is a
    // relaunch, not a fresh launch.
    if (isRemoteMode() && !anchor) {
        throw new ProcessControlError("restart_comfyui (action:\"start\") launches ComfyUI on the local machine and is not " +
            "available when targeting a remote instance via --comfyui-url.");
    }
    const port = anchor?.port ?? config.resolvedPort;
    // Check if already running. TRI-STATE: "the lookup could not run" is NOT "the
    // port is free". Collapsing them let us spawn into a port the old server may
    // still hold, and then report `started:true` because readiness reached THAT
    // server before the new child's bind failure surfaced — a restart claimed on
    // evidence nobody had (codex gate P1-c).
    const preLaunchProbe = probePortOwner(port);
    if (preLaunchProbe.state === "owned") {
        return {
            started: false,
            // Nothing was launched, so there is no startup of ours to have confirmed.
            startup: "not-attempted",
            message: `ComfyUI is already running on port ${port} (PID ${preLaunchProbe.pid})`,
            pid: preLaunchProbe.pid,
            // Something is serving the port and we never got as far as spawning. We did
            // not classify a launch of ours, so we may not name a definite verdict.
            listener_ownership: unclassifiedOwnership(),
        };
    }
    /** Was the port OBSERVED free before we spawned? `false` = we could not tell. */
    const portObservedFreeBeforeLaunch = preLaunchProbe.state === "free";
    if (!portObservedFreeBeforeLaunch) {
        logger.warn("Could not determine whether the port was free before launching — a start will not be claimed on readiness alone", { port, reason: preLaunchProbe.state === "unknown" ? preLaunchProbe.reason : "" });
    }
    let info = savedInfo;
    if (!info) {
        // No saved info — try to detect and launch the Desktop app
        const desktopExe = findDesktopExeFromCommonPaths();
        if (desktopExe) {
            logger.info(`No saved process info, but found Desktop app at: ${desktopExe}`);
            info = {
                pid: 0,
                port,
                argv: [],
                isDesktopApp: true,
                desktopExePath: desktopExe,
                targetFence,
            };
        }
        else {
            return {
                started: false,
                startup: "not-attempted",
                message: "No previous process info and could not find ComfyUI Desktop app. Start ComfyUI manually.",
                listener_ownership: unclassifiedOwnership(),
            };
        }
    }
    if (!targetFenceMatchesCurrent(targetFence)) {
        return startTargetFenceRefusal(targetFence, "the target changed while the launch recipe was being prepared");
    }
    logger.info("Starting ComfyUI...", {
        isDesktopApp: info.isDesktopApp,
        argv: info.argv.join(" "),
    });
    let launched = spawnFromProcessInfo(info);
    if (!launched) {
        return {
            started: false,
            // No command was ever spawned — a refusal, not a startup that went wrong.
            startup: "not-attempted",
            message: info.isDesktopApp
                ? "Could not determine ComfyUI Desktop executable path. Please start it manually." +
                    desktopEvidenceNote(info.desktopEvidence)
                : "No command-line info captured from previous run. Start ComfyUI manually.",
            auto_restart: supervisorResult(info),
            listener_ownership: unclassifiedOwnership(),
        };
    }
    const spawnError = captureChildProcessError(launched.child);
    // TRUTHFULNESS WATCH (codex gate): remember whether the process WE launched died
    // before the readiness poll finished. Readiness only proves that SOMETHING answers
    // on the port — it cannot tell our relaunch apart from an external
    // launcher/supervisor that grabbed the port meanwhile. Recording the exit (rather
    // than racing it) changes no timing and no outcome; it only lets the report name
    // what actually happened instead of implying our child is the healthy server.
    let launchedChildExit;
    launched.child.once("exit", (code, signal) => {
        launchedChildExit = { code, signal };
    });
    // Latched SEPARATELY from the readiness race below: if another launcher's server
    // answers first, the race resolves on readiness and the spawn failure would never
    // be consulted — leaving a launch that never happened reported as an unconfirmed
    // success (codex gate).
    let launchedSpawnFailed = false;
    launched.child.once("error", () => {
        launchedSpawnFailed = true;
    });
    launched.child.unref();
    lastProcessInfo = info;
    superviseChild(launched.child, info);
    // A python `spawn` inherits our process.env, so this local server shares our
    // environment — mark it so its live extra_model_paths $VAR references may be
    // expanded against process.env (#633 P1b). A Desktop-app launch's env is NOT
    // guaranteed to be ours, so it stays fail-closed (unmarked).
    if (!info.isDesktopApp || info.selfRelaunch) {
        adoptLaunchedChild(launched.child);
        // Revoke env-trust the instant OUR launched child goes away — on EXIT and on a
        // failed spawn (ERROR): a successor server that later takes the port may have a
        // DIFFERENT env, so it must NOT inherit our trust (#633 P1b stale-flag). Fail
        // closed the moment we no longer own the process (`error` covers the spawn_error
        // path where `exit` may not fire). adoptLaunchedChild wires both handlers.
    }
    // A NEW server instance is coming up on this port — whatever Manager dialect we
    // classified belonged to whatever ran here before (restart_comfyui (action:"start") is also
    // reachable without a preceding stopComfyUI, e.g. after an external kill or a
    // Manager upgrade), so re-probe rather than trust it (#646).
    resetManagerApiCache("comfyui started");
    // Wait for API to become ready — on the ANCHORED instance when we were given one,
    // so a retarget during the launch cannot make us grade a different server.
    const startupResult = await Promise.race([
        waitForApiReady(anchor?.probeUrl
            ? { ...getStartupReadinessConfig(), probeUrl: anchor.probeUrl }
            : undefined).then((readiness) => ({ readiness })),
        spawnError.then((error) => ({ spawn_error: error })),
    ]);
    if (!targetFenceMatchesCurrent(targetFence)) {
        return {
            started: false,
            ready: "readiness" in startupResult ? startupResult.readiness.ready : false,
            startup: "unconfirmed",
            readiness: "readiness" in startupResult ? startupResult.readiness : undefined,
            message: `The ComfyUI target changed while the anchored launch was starting at ${targetFence.baseUrl}. ` +
                "The target-specific launch result is unconfirmed; no success is claimed for the new target.",
            target_fence: targetFence,
            target_stable: false,
            auto_restart: supervisorResult(info),
            launch_env: launchEnvInfo(info),
            listener_ownership: unclassifiedOwnership(),
        };
    }
    if ("spawn_error" in startupResult) {
        return {
            started: false,
            ready: false,
            // OBSERVED: the spawn itself errored. This is the definite negative, and the
            // only kind of evidence allowed to produce one.
            startup: "failed",
            message: `ComfyUI process failed to launch: ${startupResult.spawn_error.message}`,
            spawn_error: startupResult.spawn_error,
            auto_restart: supervisorResult(info),
            launch_env: launchEnvInfo(info),
            // The spawn failed outright: whatever may be serving that port, it is
            // certainly not something this call started.
            listener_ownership: unclassifiedOwnership(),
        };
    }
    let readiness = startupResult.readiness;
    // WHAT A DEADLINE ACTUALLY ESTABLISHES (#367).
    //
    // The budget expiring is a fact about how long WE waited, not a fact about the
    // server. It says startup was not confirmed WITHIN it. Only an observation of
    // the launched process being GONE turns that into a failure — and we can make
    // that observation, so the two stop sharing a verdict whose message asserted
    // whichever it happened to name.
    //
    // The old branch reported the #776 failure shape (ComfyUI aborting during
    // import) for BOTH, because for a dead child it was right and nobody separated
    // the live one. #776's truthfulness is preserved exactly — a child that died
    // still reports DOWN, with the same sentence — while the live child stops being
    // told it failed.
    //
    // `launchedChildStillRunning` is tri-state on purpose: `undefined` is "cannot
    // tell", and it must not be spent as either answer, so it falls on the
    // unconfirmed side with the uncertainty stated. Only a DEFINITE death fails.
    let childAlive = launchedChildStillRunning(launched.child);
    let childIsGone = launchedSpawnFailed || launchedChildExit != null || childAlive === false;
    // #2009: the launched PID exiting is NOT proof the server is down. A Windows
    // portable / venv trampoline / wrapper exits 0 after handing off, and the last
    // scheduled probe can miss a bind that happened in the gap after it. Before
    // declaring THIS CALL's launch failed, re-probe once. A healthy answer falls
    // through to ownership classification (which already treats a departed child as
    // unconfirmed unless serving argv positively differs). A spawn that never
    // happened is not a handoff — skip the extra look.
    if (!readiness.ready && childIsGone && !launchedSpawnFailed) {
        const extra = await waitForApiReady({
            intervalMs: 0,
            maxTries: 1,
            probeUrl: readiness.probe_url,
        });
        if (extra.ready) {
            readiness = {
                ready: true,
                timed_out: false,
                attempts: readiness.attempts + extra.attempts,
                max_tries: readiness.max_tries,
                interval_ms: readiness.interval_ms,
                waited_ms: readiness.waited_ms + extra.waited_ms,
                probe_url: readiness.probe_url,
            };
        }
    }
    // #2427: Manager's "Restarting to reapply dependency installation" + exit 0 is
    // a handoff, not a crash. #2009's extra look covers a wrapper that left a
    // listener behind; this marker means Manager expected a supervisor to re-exec
    // and nothing is coming unless we replay the saved launch once — or follow a
    // replacement that already rebound. Either way this first child's death is
    // not `started:false`.
    let managerHandoffSeen = false;
    let managerHandoffReplayed = false;
    if (!readiness.ready &&
        !launchedSpawnFailed &&
        isManagerDependencyReapplyHandoff({
            exit: launchedChildExit,
            launchLogPath: launched.launchLogPath,
        })) {
        managerHandoffSeen = true;
        const followup = await followManagerDependencyReapplyHandoff({
            info,
            port,
            probeUrl: readiness.probe_url,
            launched,
            launchedChildExit,
            readiness,
        });
        launched = followup.launched;
        launchedChildExit = followup.launchedChildExit;
        launchedSpawnFailed = followup.launchedSpawnFailed;
        readiness = followup.readiness;
        managerHandoffReplayed = followup.replayed;
        if (!targetFenceMatchesCurrent(targetFence)) {
            return {
                started: false,
                ready: readiness.ready,
                startup: "unconfirmed",
                readiness,
                message: `The ComfyUI target changed while following a ComfyUI-Manager dependency-reapply handoff at ${targetFence.baseUrl}. ` +
                    "The target-specific launch result is unconfirmed; no success is claimed for the new target.",
                target_fence: targetFence,
                target_stable: false,
                auto_restart: supervisorResult(info),
                launch_env: launchEnvInfo(info),
                listener_ownership: unclassifiedOwnership(),
            };
        }
        childAlive = launchedChildStillRunning(launched.child);
        childIsGone =
            launchedSpawnFailed || launchedChildExit != null || childAlive === false;
    }
    if (!readiness.ready) {
        const env = launchEnvInfo(info);
        const waitedS = seconds(readiness.waited_ms);
        if (childIsGone) {
            return {
                started: false,
                ready: false,
                // OBSERVED death of the process WE launched. `startup` is a verdict about
                // this call's own launch, which is precisely the thing we watched — it never
                // claimed to describe whatever may be serving the port.
                startup: "failed",
                readiness,
                // TRUTHFUL FAILURE (#776): the process was spawned and then died, so the
                // relaunch failed during startup. Report that plainly — and name the
                // environment it was launched into, which is the first thing to check when a
                // relaunch of an otherwise-healthy install fails during import.
                message: 
                // "no probe got a HEALTHY response", not "the API did not become ready":
                // a poll establishes only what the SCHEDULED PROBES saw (the server could
                // have answered in a gap between two of them), and the poller treats any
                // non-2xx as not-ready — so a 503 from a half-started server IS a
                // response, and saying none came back would be false (codex gate r3/r4).
                //
                // A SPAWN FAILURE DOES NOT LEAD WITH "was launched" (codex gate round 4):
                // the readiness race can resolve first and the `error` event land before
                // the liveness check, which produced a report that said the process was
                // launched and then that it could not be spawned.
                //
                // DEFENSIVE AND UNCOVERED, stated so nobody mistakes it for tested: an
                // `error` arriving before the race settles makes `spawnError` win instead,
                // and that path returns above. Reaching HERE needs the event to land in
                // the microtask window between the readiness result resolving and this
                // line reading the flag — an interleaving no test can schedule. The branch
                // costs nothing and removes a self-contradictory verdict if it ever runs.
                (managerHandoffSeen
                    ? `ComfyUI process was launched, but no readiness probe got a healthy response in ${readiness.waited_ms}ms (${readiness.attempts}/${readiness.max_tries} probes).` +
                        describeManagerDependencyReapplyFailure({
                            readiness,
                            replayed: managerHandoffReplayed,
                            launchLogPath: launched.launchLogPath,
                        })
                    : (launchedSpawnFailed && !launchedChildExit
                        ? `The ComfyUI process could not be spawned, and no readiness probe got a healthy response in ${readiness.waited_ms}ms (${readiness.attempts}/${readiness.max_tries} probes). THIS RELAUNCH FAILED — it was not a slow start.`
                        : `ComfyUI process was launched, but no readiness probe got a healthy response in ${readiness.waited_ms}ms (${readiness.attempts}/${readiness.max_tries} probes).` +
                            (launchedChildExit
                                ? describeLaunchedChildExit(launchedChildExit)
                                : " The process this call launched is no longer running, so THIS RELAUNCH FAILED — it was not a slow start.")) +
                        // #1259 — WHAT IT PRINTED, on every failing branch. "exit code 1" and
                        // nothing else is the least actionable thing a failed launch can say,
                        // and it left a reporter offline with no way to diagnose it. The child
                        // had already explained itself; the output was going to `ignore`.
                        describeLaunchLog(launched.launchLogPath)) +
                    ' Re-check with get_system_stats (action:"health") before assuming nothing is serving the port — an external launcher or supervisor may have brought one back since.' +
                    (env ? ` Launch environment: ${env.note}.` : "") +
                    launchEnvWarning(info),
                auto_restart: supervisorResult(info),
                launch_env: env,
                // Nothing answered during the poll, so there is no listener to attribute.
                // This is the ABSENCE of an attribution, not a claim that the port is free.
                listener_ownership: unclassifiedOwnership(),
            };
        }
        return {
            // The launch HAPPENED and nothing observed contradicts it: a process was
            // spawned, no spawn error fired, and no exit has been seen. `started` reports
            // that dispatch; `ready:false` and `startup:"unconfirmed"` carry what is still
            // unknown. Refuse before, disclose after — the irreversible step is behind us,
            // so the honest move is to describe it, not to deny it happened.
            started: true,
            ready: false,
            startup: "unconfirmed",
            readiness,
            message: `ComfyUI was launched${launched.child.pid ? ` (PID ${launched.child.pid})` : ""} and ` +
                (childAlive === true
                    ? "that process is still running"
                    : "no exit has been observed from that process") +
                // "no healthy response" rather than "had not answered": the poller counts
                // any non-2xx as not-ready, so a 503 from a half-started server is an
                // answer, and claiming none came would be false (codex gate round 4).
                `, but no readiness probe got a healthy response from ${readiness.probe_url} within ${waitedS}s ` +
                `(${readiness.attempts}/${readiness.max_tries} probes). The budget expiring means ` +
                "the startup is NOT CONFIRMED YET — it does NOT mean it failed: ComfyUI with a normal " +
                "set of custom nodes routinely answers well after this window. " +
                "Do NOT kill it and do NOT launch a second copy onto this port. " +
                `Re-check with get_system_stats (action:"health") in another ${RECHECK_HINT_S}s; if it is still silent then, ` +
                "the ComfyUI logs will say why. To wait longer next time, raise " +
                `COMFYUI_STARTUP_CHECK_MAX_TRIES (currently ${readiness.max_tries} ` +
                `${readiness.max_tries === 1 ? "probe" : "probes"}, one every ` +
                `${describeInterval(readiness.interval_ms)}).` +
                (managerHandoffReplayed
                    ? ` ComfyUI-Manager previously handed off after "${MANAGER_DEPENDENCY_REAPPLY_MARKER}"; this call replayed the saved launch command once and that process is still starting.`
                    : "") +
                (env ? ` Launch environment: ${env.note}.` : "") +
                launchEnvWarning(info),
            pid: launched.child.pid,
            auto_restart: supervisorResult(info),
            launch_env: env,
            // Nothing has answered on the port yet, so there is no listener to attribute.
            // This says nothing about the launched process, which is reported above.
            listener_ownership: unclassifiedOwnership(),
        };
    }
    const newPid = findPidByPort(port);
    const env = launchEnvInfo(info);
    // The API just answered, so ask it what it is running. This is the one identity
    // signal that needs no pid at all, and it is what keeps hosts with an unusable
    // port-owner lookup from getting a permanent "cannot tell".
    let servingArgv;
    try {
        if (anchor?.targetFence) {
            servingArgv = await readServingArgvAtBase(targetFence.baseUrl);
            // Keep the proof anchored in production, but tolerate test/legacy clients
            // that expose system_stats only through the configured client. This fallback
            // is allowed only while the exact target fence is still current; the final
            // fence check below rejects any concurrent retarget.
            if (!servingArgv && targetFenceMatchesCurrent(targetFence)) {
                servingArgv = await readServingArgv();
            }
        }
        else {
            servingArgv = (await getSystemStats()).system.argv ?? undefined;
        }
    }
    catch {
        servingArgv = undefined;
    }
    if (!targetFenceMatchesCurrent(targetFence)) {
        return {
            started: false,
            ready: true,
            startup: "unconfirmed",
            readiness,
            message: `The ComfyUI target changed after the anchored launch at ${targetFence.baseUrl} became healthy. ` +
                "The serving argv was not attributed to the new target, so no success is claimed.",
            target_fence: targetFence,
            target_stable: false,
            auto_restart: supervisorResult(info),
            launch_env: env,
            listener_ownership: unclassifiedOwnership(),
        };
    }
    // Is the healthy listener actually OURS?
    //   • A Desktop launch is UNDECIDABLE by design: we spawn the Electron shell (or
    //     macOS `open`), and the process that binds the port is its child, so a pid
    //     mismatch there proves nothing.
    //   • A launched child that has ALREADY EXITED cannot be the healthy listener —
    //     that is decisive even when the port owner cannot be mapped at all (the #449
    //     shape), which is precisely the "an external supervisor restored the API"
    //     case that must never be reported as our successful restart (codex gate).
    //   • Otherwise it needs both pids. An unmappable port owner (a real, supported
    //     condition on some hosts — #449) is "unconfirmed": we assert NEITHER way.
    //     It deliberately does NOT become a failure — the child we launched is still
    //     alive and the API is ready, so denying it would report every ordinary
    //     restart as failed on any host where the port-owner lookup is unavailable,
    //     which is a far worse (and far more common) lie than an unconfirmed
    //     success. That uncertainty is carried by an EXPLICIT string state, so it
    //     survives JSON serialization instead of vanishing with an `undefined`.
    const ourPid = launched.child.pid;
    const ownership = classifyListenerOwnership({
        isDesktopApp: info.isDesktopApp,
        childExited: launchedChildExit != null,
        spawnFailed: launchedSpawnFailed,
        child: launched.child,
        launchArgv: launched.launchArgv,
        portOwnerPid: newPid,
        servingArgv,
        // The OS readers are injected so the classifier stays free of platform code
        // (and so tests can drive lineage without a real process tree).
        readParentPid,
        readIdentity: resolveProcessIdentity,
        childIsAlive: launchedChildStillRunning(launched.child),
    });
    // Only the NON-Desktop undecidable case is worth calling out: for a Desktop
    // launcher the pid relationship is indirect by design, not a gap in evidence.
    const ownershipUnconfirmed = !info.isDesktopApp && ownership === "unconfirmed";
    // The pre-launch "the port was free" observation deliberately does NOT carry the
    // claim: another process can bind between that probe and `spawn()`, so it is a
    // fact about a moment that has PASSED, not about the server now answering. It was
    // briefly used to gate `started` and that was wrong twice over — stale evidence,
    // and it made an `unconfirmed` classification report success for somebody else's
    // process. It now only ever appears as a stated ABSENCE in the message below,
    // which is a safe thing to spend.
    //
    // `started` therefore rests on the classification alone: `not-ours` is the one
    // verdict that denies it. `unconfirmed` keeps it TRUE by the standing ruling —
    // denying it would report every ordinary restart as failed on any host whose
    // port-owner lookup is unavailable, a far more common and more damaging lie than
    // an unconfirmed success that the message explicitly qualifies.
    const mayClaimStart = ownership !== "not-ours";
    return {
        // `started` means "THIS call started the server". When the healthy listener is
        // provably NOT the process we launched, we did not start it — programmatic
        // callers must not read a failed relaunch as a success just because something
        // answers (codex gate). `ready` stays TRUE because the server genuinely IS
        // ready: claiming otherwise would be its own lie and would push callers into
        // needless recovery.
        started: mayClaimStart,
        ready: true,
        // The API answered — but WHOSE API (codex gate rounds 9 and 10). `startup`
        // answers "did this call confirm that the launch IT MADE is serving?", so on
        // this path it simply MIRRORS the attribution verdict rather than reporting the
        // healthy probe and stopping there:
        //   ours        → confirmed. Our process is the listener.
        //   not-ours    → failed. Observed: something else is serving, so our relaunch
        //                 is not what came up.
        //   unconfirmed → unconfirmed. The port owner could not be mapped; the message
        //                 says so in as many words, and emitting "confirmed" beside it
        //                 handed a structured consumer the attribution the prose had
        //                 just withheld.
        // `started` deliberately does NOT follow it down on `unconfirmed`: a process of
        // ours demonstrably exists there and only its attribution is uncertain, which is
        // the standing listener_ownership ruling. `startup` is what carries that gap.
        startup: ownership === "ours"
            ? "confirmed"
            : ownership === "not-ours"
                ? "failed"
                : "unconfirmed",
        readiness,
        message: `ComfyUI ${ownership === "not-ours" ? "is ready" : "started"} on port ${port}${newPid ? ` (PID ${newPid})` : ""}` +
            // Say WHICH environment it came back in whenever that was not simply ours
            // (#776) — the user needs to know a launcher environment was restored.
            (env && env.source !== "inherited" ? ` — ${env.note}.` : "") +
            // NEVER imply our relaunch is the healthy server when the port is owned by
            // a DIFFERENT process (codex gate): an external launcher/supervisor can bind
            // the port while our child fails, and readiness alone cannot tell them apart.
            (ownership === "not-ours"
                ? ` NOTE: the healthy server on port ${port}${newPid ? ` (PID ${newPid})` : ""} is NOT the process this call launched (PID ${ourPid ?? "unknown"}${launchedChildExit ? `, which exited: ${exitCause(launchedChildExit)}` : ""}) — another launcher or supervisor owns it, so this server was not started by us.`
                : "") +
            // Undecidable ownership is stated, never implied away: the caller learns that
            // "our relaunch is the healthy server" is unconfirmed rather than proven.
            // #2009: a departed child is the trampoline/wrapper shape — saying it is
            // ALIVE would be the same over-claim as calling the relaunch failed.
            (ownershipUnconfirmed
                ? launchedChildExit
                    ? ` (The process this call launched EXITED (${exitCause(launchedChildExit)}), so this could not be confirmed as the process this call launched — a wrapper or trampoline can hand off and still leave a healthy server. The API is ready; listener ownership is unconfirmed.)`
                    : ` (Could not map the process owning port ${port}, so this could not be confirmed as the process this call launched — the launched process is alive and the API is ready.)`
                : "") +
            // The port was never observed free, so a healthy API is not evidence WE
            // produced it — it may be the server that was already there.
            (!portObservedFreeBeforeLaunch && ownership !== "ours"
                ? ` NOTE: the port was never observed free before launching, so this call cannot claim to have started the server that is answering.`
                : "") +
            (managerHandoffSeen
                ? managerHandoffReplayed
                    ? ` ComfyUI-Manager had printed "${MANAGER_DEPENDENCY_REAPPLY_MARKER}" and exited 0; this call followed that dependency-reapply handoff by replaying the saved launch command once.`
                    : ` ComfyUI-Manager had printed "${MANAGER_DEPENDENCY_REAPPLY_MARKER}" and exited 0; this call followed that dependency-reapply handoff.`
                : "") +
            launchEnvWarning(info),
        pid: newPid ?? undefined,
        auto_restart: supervisorResult(info),
        launch_env: env,
        serving_argv: servingArgv,
        target_fence: targetFence,
        target_stable: true,
        listener_ownership: ownership,
    };
}
// Match the repo's Manager path convention (node-management.ts appends these to
// getComfyUIBaseUrl() with no `/api` prefix — the panel's `/api/...` form is only
// because its browser `api.fetchApi` prepends `/api`). Canonical v4 POST route
// first, then the legacy GET route for older Manager builds.
// TWO Manager generations serve reboot on DIFFERENT routes+verbs (issue #116):
//   • v4 lineage (pip comfyui_manager ≥4.x): POST /v2/manager/reboot
//   • released Manager 3.x legacy: GET /manager/reboot — and some 3.x builds
//     register it under POST, so we try both verbs on the legacy path before
//     giving up (panel #253/#266, this repo #425). ComfyUI's frontend catchall
//     answers unknown GETs 200/404 and unregistered POSTs 405, so a wrong route
//     surfaces as 404/405 and we fall through to the next candidate.
const REBOOT_ROUTES = [
    { path: "/v2/manager/reboot", method: "POST" },
    { path: "/v2/manager/reboot", method: "GET" },
    { path: "/manager/reboot", method: "GET" },
    { path: "/manager/reboot", method: "POST" },
];
/**
 * A dropped/aborted connection is the signal this path READS AS a fired reboot: the
 * Manager handler calls exit(0) the instant it accepts the request, so the origin
 * dies before it can send an HTTP response and `fetch` rejects.
 *
 * It is an INFERENCE, not a success signal (codex gate round 11 — this contract
 * still said "SUCCESS" after the code and the messages had stopped treating it as
 * one). The same drop is produced by a tunnel or a network blip in front of a server
 * that was never rebooted, which is why the caller marks it `acked: false` and the
 * report says the request was not acknowledged.
 */
function isConnectionDrop(err) {
    const msg = err instanceof Error ? err.message : String(err);
    // NOTE: ECONNREFUSED is deliberately absent — it means "nothing is listening"
    // (the origin was ALREADY down before we called), not "we killed it mid-request".
    // A process reboot we caused surfaces as ECONNRESET / socket-hang-up / terminated.
    return /ECONNRESET|socket hang up|fetch failed|network|ECONNABORTED|EPIPE|terminated|premature close|other side closed|aborted/i.test(msg);
}
/**
 * Fire a ComfyUI-Manager reboot over HTTP against the connected (remote) base URL.
 * Classification:
 *   FIRED   (rebooting:true)  — res.ok (2xx) OR a connection drop OR HTTP 502/503/504.
 *                               A killed origin behind a proxy/Cloudflare surfaces
 *                               as a 5xx bad-gateway (NOT a raw socket drop), so we
 *                               must treat those as "reboot fired" too.
 *   REFUSED (rebooting:false) — HTTP 403 → Manager security forbids remote reboot.
 *   NO-ENDPOINT (rebooting:false) — every route gave a non-firing failure (e.g. 404).
 */
/**
 * True when a 200 response is (almost certainly) ComfyUI's frontend SPA catchall
 * rather than a real Manager reboot ack. The catchall serves the index HTML with
 * Content-Type text/html; a Manager reboot route either drops the connection or
 * returns a tiny non-HTML body. Best-effort and defensive: any read error →
 * treat as NOT a catchall (don't suppress a genuine ack on a transient read
 * failure).
 */
async function looksLikeSpaCatchall(res) {
    try {
        const ctype = (res.headers.get("content-type") ?? "").toLowerCase();
        if (ctype.includes("text/html"))
            return true;
        // No/unknown content-type: sniff the first bytes for an HTML document.
        const body = (await res.clone().text()).trimStart().slice(0, 256).toLowerCase();
        return body.startsWith("<!doctype html") || body.startsWith("<html");
    }
    catch {
        return false;
    }
}
/**
 * Total budget for the report-time version read (#2320). Generous for a bare
 * version string on a reachable server, and short enough that an unreachable one
 * costs the caller a clause rather than minutes.
 */
const REPORT_VERSION_PROBE_BUDGET_MS = 3_000;
/**
 * Read the Manager MAJOR version straight from the Manager, for the REPORT only.
 *
 * Called on one path: after every reboot candidate has failed, to decide whether
 * the report may say "legacy Manager 3.x". It never influences `rebooting` — the
 * reboot has already been given up on by the time this runs — so it cannot
 * manufacture the phantom-reboot failure mode that sank the earlier attempt at
 * #2320. Its only job is to stop the report ASSERTING a Manager generation the
 * probe never established, against a server that answers the question directly.
 *
 * Soft in every direction: any throw, any non-2xx, any unparseable body yields
 * `undefined`, and `undefined` means the report keeps its version-agnostic
 * wording. A server that went down (because an ambiguous 200 DID reboot it) lands
 * here too — correctly, since it can no longer answer for itself.
 *
 * `parseManagerMajor` is shared with node-management.ts on purpose: its strictness
 * is what stops the SPA catchall's HTML being read as a version string, and this
 * caller is behind exactly the proxy that serves such a catchall.
 */
async function probeManagerMajorForReport(base) {
    // ONE budget for the whole read, shared by both routes. comfyuiFetch otherwise
    // applies its deliberately generous 120s default ceiling, and that ceiling exists
    // for exactly the host this runs against: "a stalled reverse proxy … that accepts
    // the connection and then never answers". Two of those would add four minutes to a
    // restart call that has ALREADY given up — a worse symptom than the wording this
    // is here to improve. Losing the read costs only the version clause.
    const signal = AbortSignal.timeout(REPORT_VERSION_PROBE_BUDGET_MS);
    for (const path of MANAGER_VERSION_ROUTES) {
        try {
            // raceAbort covers the enclosing read (codex gate r2, P1). On supported Node,
            // undici errors the body stream when the fetch signal fires, so `res.text()`
            // does reject at the deadline; raceAbort still bounds a consumer that does not
            // observe abort. A host that answers headers and then stalls the body is the
            // same stalled proxy the budget is here to survive.
            const major = await raceAbort(signal, async () => {
                const res = await comfyuiFetch(`${base}${path}`, { method: "GET", signal });
                if (!res.ok)
                    return undefined;
                return parseManagerMajor(await res.text());
            });
            if (major !== undefined)
                return major;
        }
        catch {
            // Unreachable/aborted: no version claim. Never fatal — this runs only to
            // word an already-decided failure.
        }
    }
    return undefined;
}
/**
 * @param base the ALREADY-PINNED target. Not re-read from config here (codex
 * gate P0): the caller resolved which instance it is restarting before its own
 * awaits, and re-reading the mutable base at dispatch time is how a reboot
 * meant for A gets posted to B.
 */
async function rebootViaManager(base) {
    const failures = [];
    // Candidates that answered 200 but looked like the frontend catchall. They are
    // NOT evidence a reboot fired — that question is unanswerable from response
    // shape (#2320, three review rounds) and this path still concludes `rebooting:
    // false`. They ARE evidence a request reached the server, which is the one
    // thing the old report denied while telling the caller to restart by hand.
    const ambiguous = [];
    for (const { path, method } of REBOOT_ROUTES) {
        const url = `${base}${path}`;
        try {
            const res = await comfyuiFetch(url, { method });
            if (res.ok) {
                // GUARD (codex P1): ComfyUI's frontend catchall answers an UNKNOWN GET
                // with the SPA index — HTTP 200 text/html — so a 200 here does NOT prove
                // a reboot route exists. A genuine Manager reboot handler exits before it
                // can respond (→ a connection drop, handled below) or returns a tiny
                // non-HTML ack; treat a 200 that looks like the HTML catchall as "route
                // absent" and fall through to the next candidate rather than falsely
                // reporting a reboot that never fired (which readiness — a still-up
                // server — would then rubber-stamp as success).
                if (await looksLikeSpaCatchall(res)) {
                    ambiguous.push(`${method} ${path}`);
                    continue;
                }
                // The one path with a real acknowledgement from the Manager itself.
                return { rebooting: true, acked: true, endpoint: path, method };
            }
            if (res.status === 403) {
                return {
                    rebooting: false,
                    reason: "manager-security",
                    note: "Reboot refused (HTTP 403) — ComfyUI-Manager's security level (or an " +
                        "access proxy in front) forbids it; lower the Manager security level or reboot on the host.",
                };
            }
            if (res.status === 502 || res.status === 503 || res.status === 504) {
                return {
                    rebooting: true,
                    acked: false, // inferred from the proxy status, not acknowledged
                    endpoint: path,
                    method,
                    // The OBSERVED fact ONLY. Two earlier versions of this string named a
                    // cause: "the origin dropped … as it went down" (gate round 8), then
                    // "a proxy in front of ComfyUI answered …" (gate round 9) — but nothing
                    // here identifies a proxy, and ComfyUI or the Manager can return these
                    // statuses directly. All that was seen is the status.
                    note: `the request returned HTTP ${res.status}`,
                };
            }
            // 405 Method Not Allowed: the route EXISTS but does not accept this verb —
            // try a different verb on the same path, not a different path. Do not
            // report it as route-absent (issue #2320). 404 / other non-OK: wrong route
            // for this Manager build — try the next.
            if (res.status !== 405) {
                failures.push(`${method} ${path} → HTTP ${res.status}`);
            }
        }
        catch (err) {
            if (isConnectionDrop(err)) {
                return {
                    rebooting: true,
                    acked: false, // inferred from the dropped connection, not acknowledged
                    endpoint: path,
                    method,
                    note: "the connection dropped mid-request",
                };
            }
            failures.push(`${method} ${path} → ${err instanceof Error ? err.message : String(err)}`);
        }
    }
    // The generation claim is now READ from the Manager instead of inferred from
    // which probes failed. The old sentence asserted "likely runs the LEGACY Manager
    // 3.x" unconditionally — a cause no probe here establishes, and one a V4.2.2
    // server contradicts on request (#2320). Same discipline the notes above already
    // follow: state what was observed, not a cause chosen for it.
    const major = await probeManagerMajorForReport(base);
    const generation = major !== undefined && major >= 4
        ? ` ComfyUI-Manager reports V${major}.x, so the legacy-3.x explanation (no HTTP ` +
            `reboot route at all) does not apply here. Check the Manager security level, ` +
            `or an access proxy in front of ComfyUI, before assuming the route is absent.`
        : major !== undefined
            ? ` ComfyUI-Manager reports V${major}.x; some builds of that generation expose ` +
                `no HTTP reboot route, and upgrading to Manager v4+ adds one.`
            : ` The Manager version could not be read, so the build is unknown; a legacy ` +
                `Manager 3.x without an HTTP reboot route is one possibility, and upgrading ` +
                `to Manager v4+ would add one.`;
    // An ambiguous 200 is reported as what it is. It does NOT promote `rebooting`,
    // because a catchall and an instant reboot are indistinguishable from the
    // response alone — but the request DID reach the server, so the caller is warned
    // off the blind re-issue (a double restart mid-render) the old flat-failure
    // wording invited.
    const maybeLanded = ambiguous.length
        ? ` ${ambiguous.join(", ")} answered HTTP 200 with what looks like the ComfyUI ` +
            `frontend catchall rather than a reboot acknowledgement. That is not proof a ` +
            `reboot fired, but the request did reach the server and MAY have taken effect — ` +
            `confirm with get_system_stats (action:"health") before re-issuing a restart.`
        : "";
    return {
        rebooting: false,
        reason: "no-endpoint",
        note: 
        // "No REACHABLE endpoint" is itself a claim, and an ambiguous 200 refutes it:
        // something answered. Say what was established — nothing could be CONFIRMED —
        // rather than asserting unreachability over the top of a reply we received.
        `${ambiguous.length
            ? "No ComfyUI-Manager reboot endpoint could be confirmed."
            : "No reachable ComfyUI-Manager reboot endpoint."}${generation}${failures.length ? ` Tried: ${failures.join("; ")}.` : ""}${maybeLanded} For a LOCAL install, use the headless restart_comfyui tool ` +
            `(kill + relaunch); otherwise restart ComfyUI on the host.`,
    };
}
let remoteRebootTimingOverride = null;
function getRemoteRebootTiming() {
    if (remoteRebootTimingOverride)
        return remoteRebootTimingOverride;
    return {
        settleMs: Math.round(parsePositiveNumberEnv("COMFYUI_REMOTE_REBOOT_SETTLE_S", 3) * 1000),
        budgetMs: Math.round(parsePositiveNumberEnv("COMFYUI_REMOTE_REBOOT_BUDGET_S", 120) * 1000),
        intervalMs: Math.round(parsePositiveNumberEnv("COMFYUI_REMOTE_REBOOT_INTERVAL_S", 2) * 1000),
    };
}
/**
 * Restart via a ComfyUI-Manager HTTP reboot instead of killing a process.
 *
 * Used for BOTH the remote target (--comfyui-url) AND a locally-installed
 * ComfyUI **Desktop** instance. Desktop is Electron-supervised: killing its
 * Python backend (or even the Electron shell) leaves it down with no reliable
 * relaunch — spawning "Comfy Desktop.exe" does not deterministically bring the
 * :PORT listener back, which is exactly issue #400 (stopped:true, started:false
 * after 60 probes). The Manager `/v2/manager/reboot` handler asks the SAME
 * supervisor that owns the process to cycle it, so it comes back the way it
 * started. We therefore NEVER kill a Desktop instance; if the reboot can't be
 * fired we refuse and leave the server running rather than take it down with no
 * way back.
 */
async function restartViaManagerReboot(context) {
    logger.info(`Restarting ${context.label} ComfyUI via ComfyUI-Manager reboot...`);
    // #871: pin the base and open the instance WITNESS before anything below reads
    // the server, so the fenced window CONTAINS the argv read. The witness is the
    // per-instance identity the endpoint fence cannot provide: a WebSocket held open
    // across the whole call dies with the instance that accepted it, so a same-URL
    // replacement mid-call is visible here even though it moves neither the base nor
    // the generation (see instance-witness.ts for why a dropped witness is
    // inconclusive, never positive evidence of substitution).
    //
    // The base and the witness are captured together: a retarget landing during the
    // handshake leaves the witness watching the OLD base while the config names the
    // new one — which the endpoint fence below then refuses on, exactly as it would
    // for a retarget during the argv read.
    //
    // RESIDUAL GAP, stated honestly: when `prior` arrives from the caller, that
    // reading predates the witness, so a replacement in the narrow window between
    // the caller's read and this handshake is not seen. The caller's own identify
    // fences bound its reading to a live instance at the time it was taken; this
    // fences everything from here on.
    const anchoredBase = context.targetBase ?? getComfyUIBaseUrl();
    const targetFenceBase = context.targetFenceBase ?? anchoredBase;
    const witness = await acquireInstanceWitness(anchoredBase);
    try {
        return await restartViaManagerRebootDispatch(context, anchoredBase, targetFenceBase, witness);
    }
    finally {
        witness?.close();
    }
}
async function restartViaManagerRebootDispatch(context, anchoredBase, targetFenceBase, witness) {
    // #848: capture what it is running BEFORE the reboot, so the report afterwards can
    // say whether that changed. Total and bounded (see readServingArgv) — it is only
    // ever detail in a message, and must not be able to cost anyone their restart.
    //
    // INSTANCE FENCE (codex gate). Both argv reads go through the MUTABLE configured
    // target, so a retarget between them would compare instance A's arguments against
    // instance B's and narrate the difference as a change to one server. Comparing two
    // readings of DIFFERENT servers is worse than not comparing at all — it would
    // invent both the "UNCHANGED" no-op and the "CHANGED" confirmation.
    //
    // Judged by the monotonic GENERATION, not by a final-state base comparison: an
    // A→B→A round trip leaves the base equal and is exactly what the generation exists
    // to catch (the same r11 rule the panel restart's preflight uses). The generation
    // is taken FROM THE READING, not from this moment — see `prior`.
    // Captured BEFORE our own read, not after it: a retarget landing mid-read would
    // otherwise be stamped with the NEW generation and sail through the check below
    // while the reading itself came from the old instance.
    const selfReadGeneration = getComfyuiTargetGeneration();
    // anchoredBase was pinned by the caller, with the witness, and is used for every
    // step below: the dispatch, the dispatch record, and the readiness probe. Each of
    // those used to call `getComfyUIBaseUrl()` afresh, so a retarget landing in any
    // of the gaps between them sent the reboot to one server, recorded a second, and
    // reported the health of a third (codex gate P0/P1).
    const priorArgv = context.prior?.argv.length
        ? context.prior.argv
        : context.targetBase
            ? await readServingArgvAtBase(anchoredBase)
            : await readServingArgv();
    const argvGeneration = context.prior?.argv.length
        ? context.prior.generation
        : selfReadGeneration;
    // FENCE BEFORE DISPATCH. Everything above this line is observation; the reboot
    // below is the irreversible act. A retarget that landed during the argv read
    // means the arguments we hold describe a different server than the one the
    // config now names, and we cannot know which the caller meant. Nothing has
    // been dispatched yet, so this is a REFUSAL, not an uncertain outcome — the
    // one shape where refusing is strictly right (nothing has happened, and
    // proceeding would act on the wrong machine).
    //
    // Tested on the BASE, not the generation. `setComfyuiTarget` bumps the
    // generation on every successful call, including a same-URL reaffirmation
    // that moves nothing — refusing on that would be this very defect class
    // pointed the other way: a bucket ("the target was touched") standing in for
    // the question that actually matters.
    //
    // What this fences is the ENDPOINT: the reboot will go to the URL whose argv
    // we read. Same-URL instance REPLACEMENT is fenced separately, by the witness
    // (#871) — and it governs the argv COMPARISON, not this dispatch: rebooting
    // whatever serves the configured endpoint is what the caller asked for, so a
    // dropped or unreadable witness does not stop the reboot; it stops the report
    // afterwards from narrating two different instances as one server.
    //
    // The generation still governs the ARGV COMPARISON further down, where it is
    // the right test for a different question: an A→B→A round trip leaves the base
    // equal but means the two readings may not describe the same server, so that
    // comparison declines while this dispatch correctly proceeds — the reboot goes
    // to the URL we read either way.
    if (getComfyUIBaseUrl().replace(/\/+$/, "") !== targetFenceBase.replace(/\/+$/, "")) {
        return {
            stopped: false,
            started: false,
            startup: "not-attempted",
            message: "The configured ComfyUI target changed while this restart was preparing, " +
                "so the reboot was not sent — it would have gone to a different server " +
                "than the one this call read. Nothing was restarted. Re-run the restart " +
                "to act on the current target.",
            listener_ownership: unclassifiedOwnership(),
        };
    }
    // The witness fences the dispatch window by WHEN it died: a close stamped before
    // this moment means the reboot may reach a SUCCESSOR, not the instance whose
    // argv we read (#871). Captured before the dispatch so the comparison below can
    // tell the two apart.
    const dispatchedAt = Date.now();
    const reboot = await rebootViaManager(anchoredBase);
    if (!reboot.rebooting) {
        return {
            stopped: false,
            started: false,
            // No reboot was CONFIRMED dispatched, so there is no restart to wait on and
            // `not-attempted` stays the honest machine-readable verdict — promoting it on
            // an ambiguous 200 is the phantom-reboot regression #2320 round 1 reproduced.
            // Note this is not the same as "no request was sent": when a candidate answered
            // a catchall-shaped 200, `reboot.note` says so and warns against re-issuing.
            startup: "not-attempted",
            message: reboot.note ?? "ComfyUI-Manager reboot could not be triggered.",
            // The Manager reboot path never spawns a process of ours, so ownership of
            // whatever serves the port is never something this call can claim.
            listener_ownership: unclassifiedOwnership(),
        };
    }
    logger.info(reboot.acked
        ? "ComfyUI-Manager reboot request acknowledged"
        : "ComfyUI-Manager reboot dispatched, not acknowledged", { endpoint: reboot.endpoint, method: reboot.method, note: reboot.note });
    // The request is out, so the server MAY cycle at any moment from here — whatever
    // the readiness poll below concludes, and whether or not we ever see it happen.
    // (Not "HAS been accepted": on the unacknowledged branch nothing accepted
    // anything that we saw — codex gate rounds 6 and 12, which caught this claim in
    // the prose and then again in the comment and the log line beside it.) Dropping
    // the detected dialect now is the conservative move either way: the timed-out
    // branch returns early, and must not leave the pre-reboot dialect pinned for an
    // instance that may come back different (#646).
    resetManagerApiCache("comfyui reboot fired via Manager");
    // #742 r4/r5: record the dispatch — a later decline-path DOWN report may
    // name restart causation only against such a record. No session identity
    // exists here, so stamp the shared PROCESS-WIDE slot (never grounds
    // causation on its own).
    recordRestartDispatch(anchoredBase, PROCESS_WIDE_RESTART_DISPATCH_TOKEN);
    const timing = getRemoteRebootTiming();
    if (timing.settleMs > 0)
        await sleep(timing.settleMs);
    // Clamp the interval to a sane floor: a 0 (or tiny) env value would make
    // maxTries unbounded (ceil(budget/0) = Infinity) and hot-loop the poller,
    // hanging the tool call if the host never returns.
    const intervalMs = Math.max(250, timing.intervalMs);
    const maxTries = Math.max(1, Math.ceil(timing.budgetMs / intervalMs));
    const readiness = await waitForApiReady({
        intervalMs,
        maxTries,
        // Poll the instance we rebooted, not whatever the config names by now —
        // otherwise a retarget during the reboot window lets a healthy B stand in as
        // proof that A came back (codex gate P1).
        probeUrl: `${anchoredBase}/system_stats`,
    });
    if (!readiness.ready) {
        const waitedS = seconds(readiness.waited_ms);
        // #1847: Manager stopped (or we could not confirm a return). A free port
        // cannot tell "gone for good" from "coming back" — a live parent we failed
        // to identify is almost always the Electron shell, and a backend still
        // importing presents as exactly `free`. Spawn only when that parent is
        // PROVEN GONE and the port is free. Occupied, unreadable, or a still-live
        // parent: do not guess — the original unconfirmed report stands.
        let selfRelaunchHoldback = "";
        if (context.selfRelaunch && lastProcessInfo?.selfRelaunch) {
            const parentPid = lastProcessInfo.selfRelaunchParentPid;
            const parentAlive = parentPid != null && parentPid > 0 ? processExists(parentPid) : undefined;
            if (parentAlive === false) {
                const port = lastProcessInfo.port;
                const probe = probePortOwner(port);
                if (probe.state === "free") {
                    const spawned = await startComfyUI({
                        port,
                        probeUrl: `${anchoredBase}/system_stats`,
                    });
                    const note = context.supervisionNote ? ` ${context.supervisionNote}` : "";
                    if (spawned.ready) {
                        return {
                            stopped: true,
                            started: spawned.started === true,
                            ready: true,
                            startup: spawned.startup,
                            readiness: spawned.readiness,
                            message: (reboot.acked
                                ? "The ComfyUI-Manager reboot request was acknowledged"
                                : `A ComfyUI-Manager reboot was dispatched but not acknowledged (${reboot.note ?? "no reply"})`) +
                                ", the parent process was gone, the port freed, and ComfyUI was relaunched with the proven launch command." +
                                (spawned.message ? ` ${spawned.message}` : "") +
                                note,
                            listener_ownership: spawned.listener_ownership ?? unclassifiedOwnership(),
                        };
                    }
                    return {
                        stopped: true,
                        started: spawned.started === true,
                        ready: false,
                        startup: spawned.startup ?? "unconfirmed",
                        readiness: spawned.readiness ?? readiness,
                        message: (reboot.acked
                            ? "The ComfyUI-Manager reboot request was acknowledged"
                            : `A ComfyUI-Manager reboot was dispatched but not acknowledged (${reboot.note ?? "no reply"})`) +
                            `, no readiness probe got a healthy response within ${waitedS}s, the parent process was gone, the port was free, ` +
                            `and the proven launch command was spawned. ${spawned.message}` +
                            note,
                        listener_ownership: spawned.listener_ownership ?? unclassifiedOwnership(),
                    };
                }
            }
            else if (parentAlive === true) {
                selfRelaunchHoldback =
                    " The parent process is still running, so the proven launch command was not spawned — a free port is what a supervised cold start looks like, and a second backend under that parent is the worse outcome.";
            }
            else {
                selfRelaunchHoldback =
                    " Whether the parent process is still running could not be established, so the proven launch command was not spawned.";
            }
        }
        return {
            stopped: true,
            // NO POSITIVE EVIDENCE EITHER WAY, and the two halves of that are not
            // symmetric here. Unlike the local relaunch, this call spawned nothing: the
            // SUPERVISOR is what brings the process back, so there is no child of ours
            // whose liveness could stand in for a start. `started` therefore cannot be
            // claimed — but the reader must not take the `false` for the other definite
            // answer, which is exactly what `startup` is here to prevent (#367).
            started: false,
            ready: false,
            startup: "unconfirmed",
            readiness,
            message: 
            // NOT "and ComfyUI went down" (codex gate round 2). Nothing observed a down
            // transition: the poller only records that no probe got a 2xx, which is
            // equally consistent with a server that was never reachable from here. The
            // accepted reboot is the one thing we did observe, so it is the one thing
            // claimed.
            (reboot.acked
                ? "The ComfyUI-Manager reboot request was acknowledged"
                : `A ComfyUI-Manager reboot was dispatched but not acknowledged (${reboot.note ?? "no reply"})`) +
                ", but no readiness probe got a " +
                `healthy response from ${readiness.probe_url} within ${waitedS}s ` +
                `(${readiness.attempts}/${readiness.max_tries} probes over a ` +
                `COMFYUI_REMOTE_REBOOT_BUDGET_S=${seconds(timing.budgetMs)}s budget). That budget ` +
                "expiring means the restart is NOT CONFIRMED YET — it does NOT mean it failed; a " +
                'supervised cold start can take longer than this. Re-check with get_system_stats (action:"health") in ' +
                `another ${RECHECK_HINT_S}s before intervening. If it is still down then, start ` +
                "ComfyUI from whatever supervises it (" +
                (context.label === "Desktop" ? "the ComfyUI Desktop app" : "its host") +
                "), or raise that budget to wait longer next time." +
                // #1647: when the dispatch was allowed on inferred supervision, that
                // caveat is part of the outcome — especially here, where nothing
                // confirmed the server came back.
                (context.supervisionNote ? ` ${context.supervisionNote}` : "") +
                selfRelaunchHoldback,
            listener_ownership: unclassifiedOwnership(),
        };
    }
    // Back and ready — refresh the WS client singleton + memoized /object_info +
    // the detected Manager dialect, since a reboot is exactly when the node set
    // and the Manager generation may have changed (#646). The dialect is dropped a
    // SECOND time here on purpose: a probe that ran against the half-booted server
    // during the readiness wait must not stay pinned.
    resetClient();
    resetObjectInfoCache();
    resetManagerApiCache("comfyui rebooted via Manager");
    // #742 r4/r5: the instance this restart was dispatched to is ANSWERING again, so
    // the record has served its purpose — a later DOWN report can no longer be about
    // this dispatch. (Not "observed back": nothing here watched a cycle. Clearing on a
    // healthy endpoint is the conservative direction — it only ever REMOVES grounds
    // for naming causation.)
    clearRestartDispatch(PROCESS_WIDE_RESTART_DISPATCH_TOKEN);
    // #848: it is back — now say whether it came back as the SAME thing. Read after
    // readiness, on a path where nothing destructive is pending, so a slow or missing
    // answer costs only the detail (describeArgvDrift stays silent without both).
    // Suppressed entirely if the configured target moved at ANY point since the first
    // reading — including during this one — because the two readings would then
    // describe two different servers. The generation is re-checked AFTER the read, so
    // a retarget that lands mid-read is caught too.
    //
    // #871: the generation fences RETARGETS; the witness fences same-URL REPLACEMENT.
    // The comparison runs only when the witness ties the two readings to one
    // instance lineage:
    //   - witness unavailable (could not be acquired) → the identity token could
    //     not be read — an inconclusive, so the comparison DECLINES (the dispatch
    //     above still went ahead, which is what the caller asked for);
    //   - witness STILL OPEN after the reboot → the instance never went away at all
    //     (a no-op reboot, e.g. Manager accepted and did nothing) — both readings
    //     are provably one instance's, so comparing them is exactly right;
    //   - witness closed AFTER the dispatch → consistent with our own reboot
    //     killing it, so the pre-reboot reading describes the instance the reboot
    //     reached. The boundary is STRICT: the stamp is the close EVENT's delivery,
    //     so a death recorded in the same millisecond as the dispatch may actually
    //     have happened before it (event-delivery slack) — an ambiguous boundary
    //     declines, which only ever costs a message detail.
    //   - witness closed BEFORE the dispatch → the instance whose argv we read was
    //     gone before the reboot went out; the reboot reached a successor and the
    //     two readings may describe different lineages — decline.
    const afterArgv = context.targetBase
        ? await readServingArgvAtBase(anchoredBase)
        : await readServingArgv();
    const targetStable = getComfyuiTargetGeneration() === argvGeneration;
    const witnessClosedAt = witness?.closedAt();
    const identityContinuous = witness !== undefined &&
        (witnessClosedAt === undefined || witnessClosedAt > dispatchedAt);
    if (targetStable && !identityContinuous) {
        logger.info("Withholding the launch-argument comparison: the instance serving the " +
            "configured target was not observed continuous across the reboot (#871)", {
            witness: witness === undefined
                ? "unavailable"
                : witnessClosedAt === undefined
                    ? "open"
                    : // NOT "closed-before-dispatch": the boundary is strict precisely
                        // because a same-millisecond stamp is ambiguous (event-delivery
                        // slack), so the log must not assert the ordering the fence
                        // itself declined to trust (codex gate).
                        "closed-at-or-before-dispatch",
        });
    }
    const argvNote = targetStable && identityContinuous
        ? describeArgvDrift(priorArgv, afterArgv, context.isDesktop === true, 
        // #848: where the SAVED settings can be read, the finding REPLACES the
        // conditional remedy inside that function — "if you were expecting different
        // arguments" is a question, and this is the answer. Silent whenever the
        // settings could not be established: a missing file is not evidence of
        // agreement.
        context.isDesktop === true
            ? describeSavedLaunchArgDrift(desktopSavedLaunchArgs(config.comfyuiPath ?? undefined), afterArgv)
            : "")
        : "";
    // #1642: the readiness poll alone can never see a cycle — it certifies that
    // SOMETHING healthy answers now, which is why every Manager reboot used to
    // land on `started:false, startup:"unconfirmed"` no matter how cleanly the
    // server came back (the issue's exact report). But since #871 this path holds
    // the instance WITNESS across the dispatch, and on a LOOPBACK endpoint that
    // witness supplies the down→up the poller cannot: a socket on this machine
    // has no tunnel to hiccup and no proxy to idle out (instance-witness.ts's
    // reasons a close is inconclusive are all off-loopback), so its close AFTER
    // the dispatch is the instance that accepted the reboot going away, and the
    // healthy probe is the successor answering. That is precisely the positive
    // evidence the rounds-7/8 ruling below says `started` requires, so this
    // branch returns the confirmed verdict the fields below must withhold.
    //
    // Scoped to loopback on purpose: past the first hop a close still proves
    // nothing, so a remote endpoint keeps the unconfirmed verdict even with a
    // dropped witness. A loopback REVERSE PROXY could in principle drop the
    // client connection while its origin never cycled — the substitution edge
    // the witness module already declines to cover, not a reason to deny every
    // plain local install the confirmation its reboot earned. The strict `>`
    // boundary is the same one the argv fence uses: a same-millisecond stamp is
    // ambiguous (event-delivery slack), and ambiguous declines.
    const witnessSawTheCycle = witness !== undefined &&
        witnessClosedAt !== undefined &&
        witnessClosedAt > dispatchedAt &&
        isLoopbackServerUrl(anchoredBase);
    if (witnessSawTheCycle) {
        return {
            stopped: true,
            started: true,
            ready: true,
            startup: "confirmed",
            readiness,
            message: (reboot.acked
                ? "The ComfyUI-Manager reboot request was acknowledged."
                : `A ComfyUI-Manager reboot was dispatched but not acknowledged (${reboot.note ?? "no reply"}).`) +
                ` ComfyUI is healthy now (${readiness.waited_ms}ms) — ${context.label}/supervised ` +
                "restart, CONFIRMED: the instance that accepted the reboot dropped the " +
                "connection this call was holding open across the dispatch (the down), " +
                "and the same endpoint answers healthy again (the up)." +
                argvNote +
                // #1647: same disclosure as every other branch — an inferred
                // supervision is stated whatever the probes saw.
                (context.supervisionNote ? ` ${context.supervisionNote}` : ""),
            // This path still launched nothing of ours — a supervisor cycled the
            // process — so the listener is never ours to claim, confirmed or not.
            listener_ownership: unclassifiedOwnership(),
        };
    }
    return {
        stopped: true,
        // THE FIELDS MUST AGREE WITH THE SENTENCE (codex gate rounds 7 and 8). A message
        // saying the cycle was not observed, beside `startup:"confirmed"` and
        // `started:true`, hands a caller reading the JSON the definite signal the prose
        // just withheld — and the JSON is what an agent keys on.
        //
        // I twice kept `started:true` here by analogy with `listener_ownership`, where
        // an unconfirmed attribution deliberately keeps it. That analogy does not
        // transfer, and the difference is the whole point: THERE, a process of ours
        // demonstrably existed and only its attribution was uncertain. HERE this call
        // spawned nothing at all, and an acknowledged Manager request can be a no-op —
        // so there is no positive evidence that this call started anything, and
        // `started` is exactly the claim that it did. (#1642: when the loopback witness
        // DID supply that evidence — the observed down→up — the confirmed branch above
        // has already returned; this return is the unobserved case only.)
        //
        // The fear that drove the earlier choice — that `false` reads as a failed
        // restart — is answered by the fields beside it rather than by overstating this
        // one: `stopped:true` and `ready:true` say the server is up, and the message
        // leads with it. `started` now means the same thing on every path in this file:
        // THIS CALL HAS POSITIVE EVIDENCE IT STARTED SOMETHING.
        started: false,
        ready: true,
        startup: "unconfirmed",
        readiness,
        // WHAT THIS PATH ACTUALLY SAW (codex gate rounds 6 and 7): a reboot request that
        // was either acknowledged or INFERRED to have landed, and a later probe finding
        // the server healthy. It never watched ComfyUI go down and come back — this
        // poller has no down→up requirement at all, unlike the panel path, which
        // certifies only on an observed cycle. So "rebooted and came back ready" claimed
        // the one thing nobody here observed.
        //
        // The distinction is not academic: a Manager that accepts the request and then
        // does nothing — or a tunnel hiccup read as "the origin went down" in front of a
        // server that never restarted — leaves a healthy instance that was never cycled,
        // and a user told it "came back" stops looking for why their change did not
        // apply. Both halves are therefore stated as what they are.
        message: (reboot.acked
            ? "The ComfyUI-Manager reboot request was acknowledged."
            : // The OBSERVED signal is named, not a cause chosen for it: this branch
                // covers a proxy status AND a dropped connection, and the earlier sentence
                // told every user the connection dropped (codex gate round 8). The
                // inference is offered as one, which is all it is.
                // No "which usually means the handler accepted it and the server went
                // down" (codex gate round 10). Handler acceptance and a down transition
                // are both inferences, "usually" is a frequency claim nobody measured, and
                // the sentence undercut the very next one, which correctly says the cycle
                // was not observed. What was seen is enough.
                `A ComfyUI-Manager reboot was dispatched but not acknowledged (${reboot.note ?? "no reply"}).`) +
            ` ComfyUI is healthy now (${readiness.waited_ms}ms) — ${context.label}/supervised ` +
            "restart. The cycle itself was not directly observed from here, so verify with " +
            'get_system_stats (action:"health") if you need certainty that it actually restarted.' +
            argvNote +
            // #1647: same disclosure as the not-ready branch — an inferred supervision
            // is stated whatever the probes saw.
            (context.supervisionNote ? ` ${context.supervisionNote}` : ""),
        // We launched nothing on this path — a supervisor is what would have cycled the
        // process — so the listener is never ours to claim. (Nor is it established that
        // one DID cycle it; that is `startup`'s business, and it says "unconfirmed".)
        listener_ownership: unclassifiedOwnership(),
    };
}
let execAsyncImpl = null;
function execAsync(command, options) {
    execAsyncImpl ??= promisify(exec);
    return execAsyncImpl(command, options);
}
/**
 * Hard cap on the configured restart command's own runtime (panel#1262). A
 * `docker restart`/`systemctl restart` answers in seconds; this bounds the
 * pathological case (a command that hangs) so the tool call still returns.
 */
const CONFIGURED_RESTART_COMMAND_TIMEOUT_MS = 120_000;
/**
 * Restart via the user-configured COMFYUI_RESTART_COMMAND (panel#1262) — the
 * recovery path for an EXTERNALLY-MANAGED ComfyUI (a local container, a systemd
 * unit, a launcher) whose relaunch can never be proven from here: its argv[0]
 * is a bare `main.py` anchored only inside its own mount/namespace, so the
 * refuse-safe preflight (correctly) refuses a kill+relaunch, and a wedged
 * server answers no Manager reboot either. The configured command is the user
 * telling us exactly what cycles the instance, so it IS the proven relaunch.
 *
 * The honesty rules are the Manager reboot path's own: we launched nothing, so
 * `listener_ownership` is never ours to claim; `started` is claimed only when
 * the loopback witness OBSERVED the down→up (#1642); a healthy answer with no
 * observed cycle is "unconfirmed", and an expired readiness budget is NOT a
 * failure verdict (#367).
 */
async function restartViaConfiguredCommand(command) {
    logger.info("Restarting ComfyUI via the configured COMFYUI_RESTART_COMMAND...");
    // Pin the base and open the instance WITNESS before anything else, exactly as
    // the Manager reboot path does: the command runs after awaits, and the
    // configured target is mutable across every one of them.
    const anchoredBase = getComfyUIBaseUrl();
    const probeUrl = `${anchoredBase}/system_stats`;
    const witness = await acquireInstanceWitness(anchoredBase);
    try {
        // FENCE BEFORE THE IRREVERSIBLE ACT (same rule as the Manager path): a
        // retarget during the witness handshake would send the restart at one
        // server and report the health of another. Nothing has run yet — refuse.
        if (getComfyUIBaseUrl() !== anchoredBase) {
            return {
                stopped: false,
                started: false,
                startup: "not-attempted",
                message: "The configured ComfyUI target changed while this restart was preparing, " +
                    "so the configured restart command was NOT run — it would have acted on a " +
                    "different server than the one this call read. Nothing was restarted. " +
                    "Re-run the restart to act on the current target.",
                listener_ownership: unclassifiedOwnership(),
            };
        }
        const dispatchedAt = Date.now();
        try {
            await execAsync(command, {
                timeout: CONFIGURED_RESTART_COMMAND_TIMEOUT_MS,
                windowsHide: true,
            });
        }
        catch (err) {
            // The command itself reports failure (non-zero exit / timeout / signal).
            // That is OBSERVED — but only about the command, not about the server:
            // say what the command did, then probe ONCE and say what answers now.
            const probe = await waitForApiReady({
                intervalMs: 250,
                maxTries: 1,
                probeUrl,
            });
            return {
                stopped: false,
                started: false,
                ready: probe.ready,
                startup: "failed",
                readiness: probe,
                message: `The configured restart command (COMFYUI_RESTART_COMMAND) failed: ${errorText(err)}. ` +
                    (probe.ready
                        ? `Something IS answering on ${probeUrl} — the restart very likely did not ` +
                            "happen and the old instance is still serving. Fix the command and retry."
                        : `and nothing is answering on ${probeUrl} right now. Whether the command ` +
                            "stopped something before failing is not knowable from here — check the " +
                            "instance's own manager (docker ps / systemctl status / its launcher) " +
                            "before assuming either way."),
                listener_ownership: unclassifiedOwnership(),
            };
        }
        // The command claims to have cycled the instance. Record the dispatch so a
        // later decline-path DOWN report may name causation (process-wide slot only —
        // a panel caller stamps its own session token from this result).
        recordRestartDispatch(anchoredBase, PROCESS_WIDE_RESTART_DISPATCH_TOKEN);
        const timing = getRemoteRebootTiming();
        if (timing.settleMs > 0)
            await sleep(timing.settleMs);
        const intervalMs = Math.max(250, timing.intervalMs);
        const maxTries = Math.max(1, Math.ceil(timing.budgetMs / intervalMs));
        const readiness = await waitForApiReady({ intervalMs, maxTries, probeUrl });
        if (!readiness.ready) {
            return {
                stopped: true,
                started: false,
                ready: false,
                startup: "unconfirmed",
                readiness,
                message: `The configured restart command (COMFYUI_RESTART_COMMAND) ran, but no ` +
                    `readiness probe got a healthy response from ${readiness.probe_url} within ` +
                    `${seconds(readiness.waited_ms)}s (${readiness.attempts}/${readiness.max_tries} ` +
                    `probes over a COMFYUI_REMOTE_REBOOT_BUDGET_S=${seconds(timing.budgetMs)}s ` +
                    "budget). That budget expiring means the restart is NOT CONFIRMED YET — it " +
                    "does NOT mean it failed; a cold start can take longer than this. Re-check " +
                    'with get_system_stats (action:"health") in ' +
                    `another ${RECHECK_HINT_S}s before intervening. If it is still down then, ` +
                    "start ComfyUI from whatever manages it, or raise that budget to wait " +
                    "longer next time.",
                listener_ownership: unclassifiedOwnership(),
            };
        }
        // Back and ready — the caches memoized against the old instance are stale.
        resetClient();
        resetObjectInfoCache();
        resetManagerApiCache("comfyui restarted via configured command");
        // Answering again, so this dispatch can no longer be the cause of a LATER
        // down report (the Manager path clears on the same rule).
        clearRestartDispatch(PROCESS_WIDE_RESTART_DISPATCH_TOKEN);
        // #1642's rule applies unchanged: on a LOOPBACK endpoint the witness's close
        // AFTER the command ran is the observed down→up the readiness poll alone can
        // never supply; anything else (no witness, still-open witness, non-loopback)
        // reports honestly that the cycle itself was not observed.
        const witnessClosedAt = witness?.closedAt();
        const witnessSawTheCycle = witness !== undefined &&
            witnessClosedAt !== undefined &&
            witnessClosedAt > dispatchedAt &&
            isLoopbackServerUrl(anchoredBase);
        if (witnessSawTheCycle) {
            return {
                stopped: true,
                started: true,
                ready: true,
                startup: "confirmed",
                readiness,
                message: `The configured restart command (COMFYUI_RESTART_COMMAND) ran. ComfyUI is ` +
                    `healthy now (${readiness.waited_ms}ms) — CONFIRMED restart: the instance ` +
                    "dropped the connection this call was holding open across the command " +
                    "(the down), and the same endpoint answers healthy again (the up).",
                listener_ownership: unclassifiedOwnership(),
            };
        }
        return {
            stopped: true,
            started: false,
            ready: true,
            startup: "unconfirmed",
            readiness,
            message: `The configured restart command (COMFYUI_RESTART_COMMAND) ran. ComfyUI is ` +
                `healthy now (${readiness.waited_ms}ms). The cycle itself was not directly ` +
                "observed from here, so verify with " +
                'get_system_stats (action:"health") if you need certainty that it actually ' +
                "restarted.",
            listener_ownership: unclassifiedOwnership(),
        };
    }
    finally {
        witness?.close();
    }
}
export async function restartComfyUI(options = {}) {
    const targetFence = options.targetFence ?? currentTargetFence();
    if (options.targetFence && !targetFenceMatchesCurrent(options.targetFence)) {
        return restartTargetFenceRefusal(options.targetFence, "the requested target changed before the restart began");
    }
    const normalizedFlags = normalizeAdditionalLaunchFlags(options.additionalFlags);
    if (normalizedFlags.error) {
        return launchFlagMutationRefusal("the requested launch flag", normalizedFlags.error);
    }
    const additionalFlags = normalizedFlags.flags;
    if (isRemoteMode()) {
        if (additionalFlags.length > 0) {
            return launchFlagMutationRefusal(additionalFlags.join(", "), "the ComfyUI target is remote and this process does not own its launcher; add the flag to the remote launcher's configuration and restart that instance there");
        }
        // Remote target: can't process-control it, but a Manager HTTP reboot brings
        // back a self-supervised ComfyUI (e.g. the tunnelled Desktop app).
        return restartViaManagerReboot({ label: "remote" });
    }
    // panel#1262: an EXTERNALLY-MANAGED local instance (container, systemd unit,
    // launcher) offers nothing the kill+relaunch path can validate — its bare
    // `main.py` argv anchors only inside its own namespace — so without this the
    // restart refused and a wedged server had no recovery. The configured command
    // is the user's explicit statement of what cycles the instance; it outranks
    // every inferred path below (including Desktop's), and it needs no live
    // process info, so it also works when the server is too wedged to answer.
    if (config.comfyuiRestartCommand) {
        if (additionalFlags.length > 0) {
            return launchFlagMutationRefusal(additionalFlags.join(", "), "COMFYUI_RESTART_COMMAND is an opaque external launcher command that this process cannot safely edit; update that launcher command/configuration to include the flag, then restart it there");
        }
        return restartViaConfiguredCommand(config.comfyuiRestartCommand);
    }
    // EZi/CEI can serve the panel through a local helper port while the real
    // ComfyUI child remains on the immutable boot port (#2118). The helper is
    // deliberately NOT treated as an owned ComfyUI listener: when both endpoints
    // corroborate the same explicit backend argv, use Manager against that backend
    // and keep the ordinary process-ownership refusal for every other shape.
    const verifiedProxy = await resolveVerifiedProxyRestartTarget();
    if (!targetFenceMatchesCurrent(targetFence)) {
        return restartTargetFenceRefusal(targetFence, "the ComfyUI target changed while restart ownership was being resolved");
    }
    if (verifiedProxy) {
        if (additionalFlags.length > 0) {
            return launchFlagMutationRefusal(additionalFlags.join(", "), "the ComfyUI process is behind a verified proxy and its launcher is not owned by this process; add the flag to the backend launcher's configuration and restart it there");
        }
        return restartViaManagerReboot({
            label: "EZi/CEI-proxied",
            prior: { argv: verifiedProxy.argv, generation: verifiedProxy.generation },
            targetBase: verifiedProxy.backendBase,
            targetFenceBase: verifiedProxy.proxyBase,
        });
    }
    logger.info("Restarting ComfyUI...");
    // #848: the target generation as of BEFORE the instance is resolved, so the argv
    // that resolution observes can be fenced to the instance it was actually read
    // from. It travels with the argv into the Desktop reboot below; capturing it
    // there instead would leave the read itself outside the fence (codex gate r2).
    const infoGeneration = targetFence.generation;
    // …and the ADDRESS of the instance this call is about, captured at the same
    // moment. The stop, the port-free wait and the settle delay are all awaits, and
    // the configured target is mutable across every one of them, so the relaunch is
    // anchored to these rather than to whatever the config says when it finally runs
    // (codex gate round 12 — otherwise the relaunch probes the NEW target's port,
    // finds it occupied, returns "already running", and the instance we killed stays
    // dead).
    const restartProbeUrl = `${targetFence.baseUrl}/system_stats`;
    // Preflight: resolve the RUNNING instance and confirm we can relaunch it
    // BEFORE stopping anything. A restart must be atomic-ish — if the relaunch
    // command can't be built/validated (stale COMFYUI_PATH, unknown Desktop exe),
    // refuse and leave the server up rather than take it down with no way back
    // (issues #368/#370).
    const acquired = await acquireProcessInfo();
    const { info, diagnostic } = acquired;
    if (!info) {
        return {
            stopped: false,
            started: false,
            startup: "not-attempted",
            message: diagnostic ??
                `No ComfyUI process found on port ${config.resolvedPort} to restart. Is ComfyUI running?`,
            listener_ownership: unclassifiedOwnership(),
        };
    }
    if (!targetFenceMatchesCurrent(targetFence)) {
        return restartTargetFenceRefusal(targetFence, "the ComfyUI target changed while the running instance was being identified", false, recoveryHint(info));
    }
    if (!info.targetFence || !targetFencesEqual(info.targetFence, targetFence)) {
        return restartTargetFenceRefusal(targetFence, "the running process identity is not bound to the exact target this restart was authorized for", false, recoveryHint(info));
    }
    // NEVER STOP AN INSTANCE THE REST OF THIS CALL WILL NOT BE ACTING ON (codex gate
    // round 11). `acquireProcessInfo` is awaited, and the target is MUTABLE: a hello
    // retarget landing inside that await leaves `info` describing instance A while the
    // config — which `stopComfyUI`'s port waits, the Manager reboot's base URL, and
    // `startComfyUI`'s relaunch port all read LIVE — now points at B.
    //
    // The loss is concrete and is the #368/#814 shape again: A is killed from its
    // already-resolved pid, then the relaunch consults B's port, finds it occupied,
    // and returns "already running" without spawning anything. A is down, nothing
    // brings it back, and every assessment that authorized the stop was about A.
    //
    // Judged by the monotonic GENERATION captured before the resolve, so a retarget
    // that lands mid-await is caught and an A→B→A round trip cannot slip through a
    // final-state comparison. REFUSE, because nothing has been stopped yet — the
    // refuse-before/disclose-after rule this whole path is built on.
    if (getComfyuiTargetGeneration() !== infoGeneration) {
        return restartTargetFenceRefusal(targetFence, "the ComfyUI target changed while the running instance was being identified", false, recoveryHint(info));
    }
    const launchInfo = withAdditionalLaunchFlags(info, additionalFlags);
    if (additionalFlags.length > 0) {
        if (info.isDesktopApp) {
            return launchFlagMutationRefusal(additionalFlags.join(", "), "ComfyUI Desktop owns the saved launch settings and its supervised restart re-execs the existing process. Add the flag in ComfyUI Desktop's launch settings for this install, then fully quit and relaunch the Desktop app");
        }
        if (!launchInfo) {
            return launchFlagMutationRefusal(additionalFlags.join(", "), "the running ComfyUI did not expose an exact launch argv that can be safely extended; add the flag through the launcher or console that owns this instance, then restart it there");
        }
    }
    const relaunchInfo = launchInfo ?? info;
    // A locally-installed ComfyUI **Desktop** instance is Electron-supervised.
    // Killing it (Python backend or Electron shell) and re-spawning the exe does
    // not reliably bring the :PORT listener back (issue #400: stopped:true,
    // started:false after 60 probes). Route it through the Manager reboot — the
    // supervisor that owns the process cycles it — and NEVER kill it. Only
    // self-spawned Python installs fall through to the kill+relaunch path below.
    if (info.isDesktopApp) {
        // …but only when a supervisor is actually there to do the cycling. The Manager
        // reboot STOPS the process; the Electron shell is what starts it again. When the
        // shell has provably gone, firing the reboot is stopping a server we cannot
        // restart, which is the #814 lost-server. Decided BEFORE anything is dispatched.
        const desktop = assessDesktopSupervision(info);
        if (!desktop.ok) {
            const hint = recoveryHint(info);
            return {
                stopped: false,
                started: false,
                startup: "not-attempted",
                message: `Refusing to restart: ${desktop.reason} ComfyUI was left running (not stopped) so ` +
                    `you don't lose the server. Restart it from the ComfyUI Desktop app.` +
                    describeRecovery(hint),
                restart_hint: hint,
                // Nothing was stopped and nothing launched.
                listener_ownership: unclassifiedOwnership(),
            };
        }
        if (desktop.selfRelaunch) {
            info.selfRelaunch = true;
            info.selfRelaunchParentPid = desktop.selfRelaunchParentPid;
            // Captured NOW, while the process is still up: startComfyUI after the
            // Manager stop rebuilds the proven command from this record, and the
            // post-stop spawn re-checks this parent pid.
            lastProcessInfo = info;
        }
        // #848: hand over the argv already gathered by acquireProcessInfo moments ago —
        // no extra probe, and it is the reading taken closest to the stop.
        return restartViaManagerReboot({
            label: "Desktop",
            prior: { argv: info.argv, generation: infoGeneration },
            isDesktop: true,
            // #1647: set when supervision was INFERRED from the launch signatures rather
            // than proven from the process tree — the result must say so.
            // #1847: same disclosure slot when the pass is a proven self-relaunch.
            supervisionNote: desktop.note,
            selfRelaunch: desktop.selfRelaunch === true,
        });
    }
    // requireReproducibleEnv: this path KILLS the process and spawns a fresh one, so
    // the launch ENVIRONMENT has to be rebuilt as well as the command (#776).
    const relaunch = assessRelaunch(relaunchInfo, { requireReproducibleEnv: true });
    if (!relaunch.ok) {
        return {
            stopped: false,
            started: false,
            startup: "not-attempted",
            restart_hint: recoveryHint(info),
            message: `Refusing to restart: ${relaunch.reason} ComfyUI was left running (not stopped) ` +
                "so you don't lose the server. " +
                (relaunch.advice ??
                    "Fix the launch path (e.g. COMFYUI_PATH) and try again. If this ComfyUI " +
                        "is externally managed (a container, a systemd unit, a launcher), set " +
                        "COMFYUI_RESTART_COMMAND to the exact command that restarts it (e.g. " +
                        "`docker restart <container>`) — the restart then runs that command " +
                        "instead of needing the launch path resolvable from here.") +
                describeRecovery(recoveryHint(info)),
            // Refused before touching anything: the still-running server is the one that
            // was already there, which this call did not start.
            listener_ownership: unclassifiedOwnership(),
        };
    }
    // Stop — hand it the pre-validated info so the relaunch details (incl. the
    // resolved Desktop exe) survive into startComfyUI's lastProcessInfo.
    const stopResult = await stopComfyUI(info);
    if (!stopResult.stopped) {
        return {
            stopped: false,
            started: false,
            // The stop failed, so no relaunch was ever attempted.
            startup: "not-attempted",
            message: `Could not stop ComfyUI: ${stopResult.message}`,
            // Nothing was stopped and nothing launched — whatever is on the port is not
            // this call's doing.
            listener_ownership: unclassifiedOwnership(),
        };
    }
    if (!targetFenceMatchesCurrent(targetFence)) {
        return restartTargetFenceRefusal(targetFence, "the ComfyUI target changed after the old instance stopped; the old launch recipe was not replayed", true);
    }
    // #742 r4/r5: the stop DID happen — record the dispatch. No session identity
    // exists here, so stamp the shared PROCESS-WIDE slot (never grounds causation
    // on its own; a panel caller stamps its own session token from the result).
    recordRestartDispatch(targetFence.baseUrl, PROCESS_WIDE_RESTART_DISPATCH_TOKEN);
    // Brief pause to let OS fully release resources
    await sleep(1000);
    if (!targetFenceMatchesCurrent(targetFence)) {
        return restartTargetFenceRefusal(targetFence, "the ComfyUI target changed after the old instance stopped; the old launch recipe was not replayed", true);
    }
    // `stopComfyUI` records the observed recipe before killing. Replace that
    // record only after the stop committed, so a failed pre-stop safety check or
    // failed kill cannot silently turn a live server's current state into a new
    // launch configuration.
    lastProcessInfo = relaunchInfo;
    // A caveat from the stop must survive into whatever we report: the stop can
    // commit WITHOUT confirming the process exited (every port probe failed after
    // the kill), and that is exactly the situation where a bare "restarted
    // successfully" would overstate what we know.
    const stopCaveat = stopResult.unverified_exit
        ? ` NOTE from the stop: ${stopResult.unverified_exit}.`
        : "";
    // Start — ANCHORED to the instance that was just stopped (see restartProbeUrl).
    //
    // The stop already committed, so from here a THROWN error is the worst outcome
    // available: it unwinds past every report below and leaves the caller with an
    // exception instead of the fact that their server is now down (codex gate P0).
    // Whatever went wrong, the relaunch attempt is describable — so describe it.
    let startResult;
    try {
        startResult = await startComfyUI({
            port: info.port,
            probeUrl: restartProbeUrl,
            targetFence,
        });
    }
    catch (err) {
        // DISCLOSE, do not refuse: the stop is not undoable, so the caller needs a
        // description of where things stand rather than "nothing happened".
        //
        // But do not overclaim the other way either. An earlier draft of this said
        // "ComfyUI is NOT running" — an unverified negative, and this branch's own
        // defect class (a throw we could not interpret, reported as a definite
        // down). We do not know that: the throw may have landed AFTER a spawn, or a
        // supervisor may have brought the instance back while we were unwinding. So
        // ASK, once, and say only what the answer supports.
        const probe = await waitForApiReady({
            intervalMs: 250,
            maxTries: 1,
            probeUrl: restartProbeUrl,
        });
        return {
            stopped: true,
            started: false,
            ready: probe.ready,
            // Not "not-attempted": the relaunch WAS attempted and threw partway. What
            // we cannot say is whether it took effect — which is what `unconfirmed`
            // means, and why it exists (#367).
            startup: "unconfirmed",
            readiness: probe,
            message: `ComfyUI was stopped, and the relaunch failed partway: ${errorText(err)}. ` +
                (probe.ready
                    ? `Something IS answering on ${restartProbeUrl} now — possibly a supervisor ` +
                        `brought it back, or the relaunch got further than the error suggests. ` +
                        `This call cannot claim that server as its own.`
                    : `No healthy response from ${restartProbeUrl} when asked just now, so ` +
                        `ComfyUI is most likely down — but "not healthy" is not "not there": a ` +
                        `502/503/504 from a proxy counts as not-ready here, and something may ` +
                        `well be listening. The relaunch also failed in a way this call could ` +
                        `not interpret. Treat this as one observation, not a settled fact. ` +
                        `Check the server, and use restart_comfyui (action:"start") once the cause is cleared.`) +
                stopCaveat,
            listener_ownership: unclassifiedOwnership(),
        };
    }
    // #367: the relaunch was DISPATCHED and its process is alive, but the readiness
    // budget expired before the API answered. This is neither a success to claim nor
    // a failure to report, and it is checked BEFORE the `!started` branch so that it
    // can never fall through to either of them: `started` is TRUE here, so without
    // this the composition below would have printed "ComfyUI restarted successfully"
    // over a server nobody has heard from — fabricated success, the worst outcome.
    // The old `started:false` sent it down the other branch instead, printing "could
    // not be started" over a server that was usually seconds from ready. Both are the
    // same mistake: a boolean answering a question the evidence does not settle.
    // `ready !== true` is load-bearing, not belt-and-braces (caught by the suite when
    // round 10 widened `unconfirmed` to cover unmappable ATTRIBUTION as well as an
    // expired budget). Both are "we cannot tie the serving instance to our launch",
    // but only one of them has nothing serving — and this composition is about that
    // one. A healthy-but-unattributed restart taking this branch would have been told
    // "NOT CONFIRMED YET" about a server that was answering, and would also have
    // skipped the dispatch-record clear below.
    if (startResult.startup === "unconfirmed" && startResult.ready !== true) {
        return {
            stopped: true,
            started: true,
            ready: false,
            startup: "unconfirmed",
            readiness: startResult.readiness,
            message: "ComfyUI was stopped and relaunched; the restart is NOT CONFIRMED YET, and it is " +
                `not known to have failed either — ${startResult.message}` +
                stopCaveat,
            auto_restart: startResult.auto_restart,
            launch_env: startResult.launch_env,
            serving_argv: startResult.serving_argv,
            target_fence: targetFence,
            target_stable: startResult.target_stable,
            listener_ownership: startResult.listener_ownership,
        };
    }
    if (!startResult.started) {
        return {
            stopped: true,
            started: false,
            // Forwarded, never re-derived: startComfyUI is the only thing that watched
            // the launch, so it is the only thing entitled to name the verdict. Reaching
            // here with started:false means it was "failed" (observed death) or
            // "not-attempted" (nothing was spawnable), or the healthy listener is provably
            // somebody else's — never "unconfirmed", which returned above.
            startup: startResult.startup,
            ready: startResult.ready,
            readiness: startResult.readiness,
            // Two distinct failures share this branch, and they must NOT read alike: the
            // server may be DOWN (our relaunch never answered), or UP but owned by
            // somebody else (an external supervisor beat us to the port — our relaunch
            // still failed). Saying "could not be started" about a healthy server would
            // be as wrong as claiming success for it (codex gate).
            message: (startResult.ready
                ? `ComfyUI is back up, but NOT as a result of this restart: ${startResult.message}`
                : `ComfyUI was stopped but could not be started: ${startResult.message}`) +
                stopCaveat,
            auto_restart: startResult.auto_restart,
            spawn_error: startResult.spawn_error,
            launch_env: startResult.launch_env,
            serving_argv: startResult.serving_argv,
            target_fence: targetFence,
            target_stable: startResult.target_stable,
            listener_ownership: startResult.listener_ownership,
        };
    }
    // #742 r4/r5: the restart was observed back — clear OUR record (the
    // process-wide slot only; never another session's record).
    if (!targetFenceMatchesCurrent(targetFence)) {
        return restartTargetFenceRefusal(targetFence, "the ComfyUI target changed after the relaunch completed; its serving argv was not attributed to the new target", true);
    }
    clearRestartDispatch(PROCESS_WIDE_RESTART_DISPATCH_TOKEN);
    return {
        stopped: true,
        started: true,
        startup: startResult.startup,
        ready: startResult.ready,
        readiness: startResult.readiness,
        // Reached only when startComfyUI reported started:true — i.e. the healthy
        // listener is ours, or ownership was undecidable. A listener that is provably
        // NOT ours returns started:false and is handled in the branch above.
        //
        // "restarted successfully" is a claim of PROOF, so it is reserved for the case
        // where the port owner was actually matched to the process we launched. When
        // ownership could not be determined the server is genuinely up and the flags
        // stay positive (denying that would mislabel every ordinary restart on hosts
        // where the port-owner lookup is unavailable), but the sentence must not
        // ATTRIBUTE that healthy listener to this restart (coordinator gate).
        message: (startResult.listener_ownership === "unconfirmed"
            ? "ComfyUI is up and ready after the restart, though this call's own process could not be confirmed as the one serving the port. "
            : "ComfyUI restarted successfully. ") +
            startResult.message +
            stopCaveat,
        auto_restart: startResult.auto_restart,
        launch_env: startResult.launch_env,
        serving_argv: startResult.serving_argv,
        target_fence: targetFence,
        target_stable: startResult.target_stable ?? true,
        listener_ownership: startResult.listener_ownership,
    };
}
/** Token → record. Only the holder of a token may ground causation on (or
 *  clear) its record. */
const restartDispatchRecords = new Map();
/** The shared slot for session-less (headless) stamps — never causation. */
export const PROCESS_WIDE_RESTART_DISPATCH_TOKEN = "process-wide";
/** How long a recorded dispatch plausibly explains a down server. Also the
 *  prune horizon: older records can never ground causation, so they are
 *  reaped on each stamp (bounds the map across many sessions). */
export const RESTART_DISPATCH_CAUSATION_WINDOW_MS = 10 * 60_000; // 10 min
/**
 * Stamp that a restart was ACTUALLY dispatched (reboot fired / stop done) and
 * return the opaque token identifying the record. Callers WITH a session
 * identity take the fresh token and store it per-session; session-less
 * (headless) callers pass PROCESS_WIDE_RESTART_DISPATCH_TOKEN so all unheld
 * stamps share one bounded slot. Stale records are pruned on each stamp.
 */
export function recordRestartDispatch(base, token) {
    const now = Date.now();
    for (const [t, r] of restartDispatchRecords) {
        if (now - r.at > RESTART_DISPATCH_CAUSATION_WINDOW_MS) {
            restartDispatchRecords.delete(t);
        }
    }
    const t = token ?? randomUUID();
    restartDispatchRecords.set(t, { at: now, base });
    return t;
}
/** Remove ONLY the record identified by `token` — a recovery may never clear
 *  another session's record (r5). Unknown token → no-op. */
export function clearRestartDispatch(token) {
    restartDispatchRecords.delete(token);
}
/** The record identified by `token`, or null. */
export function getRestartDispatchRecord(token) {
    return restartDispatchRecords.get(token) ?? null;
}
/**
 * Refuse-safe preflight for an OUT-OF-BAND restart — one that stops ComfyUI
 * WITHOUT going through our validated kill+relaunch (e.g. the panel's
 * ComfyUI-Manager reboot, which asks the Manager to cycle the process and never
 * consults our relaunch command). The same atomic-ish invariant as
 * restartComfyUI applies (#368/#370): a stop must never happen before a
 * relaunch is proven possible. On a Pinokio-style install (#742) the running
 * server is externally supervised yet its relaunch is NOT provable from here
 * (a relative `main.py` argv with no COMFYUI_PATH/workspace anchor and no live
 * process cwd), and the supervisor does NOT re-launch after a plain Manager
 * restart — so the reboot would kill ComfyUI permanently.
 *
 * WHAT PASSES is now stated positively, because every "everything else proceeds"
 * clause here turned out to be a way in for the same loss:
 *   - remote mode — there is no local process to assess, and the Manager reboot is
 *     that target's restart path by design;
 *   - a Desktop instance a live supervisor is proven to be watching (#400's safe
 *     path, now checked rather than assumed);
 *   - a non-Desktop instance whose relaunch command can be built and validated
 *     (assessRelaunch, the #476/#426 machinery).
 * Everything else — including an instance that could not be identified at all —
 * REFUSES, and says what it could not establish.
 */
export async function preflightLocalRestart() {
    // Remote mode passes because there is no local process to assess — but that
    // reasoning only holds when the target really is another machine, and the
    // classification behind it does not establish that. `remoteUrlActive` is
    // `forceRemote || !isLoopbackHost(host)`, so a ComfyUI on THIS host reached
    // through its own LAN address is "remote" here, and this line waves the whole
    // refuse-safe check through for an install we could have assessed.
    //
    // That is the #742 recurrence, reported on 0.51.18 — a Pinokio ComfyUI on the
    // same machine addressed as `http://192.168.x.x:5000` with COMFYUI_PATH set to
    // it. The preflight passed without looking, the Manager reboot stopped the
    // server, and Pinokio does not relaunch on a plain restart, so it stayed down.
    // The guard that exists precisely to refuse that stop never ran.
    //
    // An address bound to one of our own interfaces is assessable, so assess it.
    // Forced remote is still honoured unconditionally: `--force-remote` is the
    // user telling us the instance is elsewhere regardless of how it is addressed
    // (a tunnel or port-forward makes the route say otherwise), and overriding an
    // explicit statement of intent with an inference would be its own bug.
    const remoteAddressedButOurs = isRemoteMode() && targetIsOnThisMachine();
    if (isRemoteMode() && !remoteAddressedButOurs)
        return { ok: true };
    const assessed = await assessLocalRestart();
    // An address on one of our interfaces proves the ROUTE lands here, not that
    // the instance does — a reverse proxy or port-forward bound to this machine's
    // LAN address can front a ComfyUI that is genuinely elsewhere (review, P2).
    // Such a setup used to restart through the Manager and now meets the guard,
    // so a refusal must say which assumption produced it and how to correct it.
    // Silently changing behaviour and leaving the reader to guess is the part
    // that would actually cost them time.
    if (remoteAddressedButOurs && assessed.ok === false) {
        return {
            ...assessed,
            reason: `${assessed.reason ?? "the relaunch could not be established"} ` +
                `NOTE: this target was assessed as LOCAL because its address is bound ` +
                `to one of this machine's own network interfaces. If this ComfyUI is ` +
                `actually somewhere else and merely reached through a proxy or ` +
                `port-forward on this host, set COMFYUI_MCP_FORCE_REMOTE=1 (or pass ` +
                `--force-remote) and the restart goes back through ComfyUI-Manager ` +
                `without needing a local process to account for.`,
        };
    }
    return assessed;
}
/** The assessment itself — everything after the locality decision above. */
async function assessLocalRestart() {
    const { info, diagnostic } = await acquireProcessInfo();
    // NOTHING COULD BE RESOLVED — and that is not a pass (coordinator gate).
    //
    // This was the door beside the widened gate: a local instance whose listener cannot
    // be attributed (a container with no `lsof`, a permission wall, no `/proc`) resolves
    // to NOTHING here, so the preflight "assessed" it, passed, and the reboot went out
    // without anyone having checked whether a supervisor was still there. That is the
    // container/permission form of the very #814 loss the gate exists to prevent — an
    // instance we cannot identify is an instance whose relaunch we cannot prove.
    //
    // The refusal is safe in the genuinely-nothing-running case too: there is no server
    // to lose, and the message says exactly what could not be established rather than
    // asserting something is wrong.
    if (!info) {
        return {
            ok: false,
            reason: `the ComfyUI this restart would stop could not be identified from here` +
                (diagnostic ? ` (${diagnostic})` : ` (nothing was found listening on port ${config.resolvedPort})`) +
                `, so it could not be established that stopping it is something we could undo.` +
                // #1175 — the refusal asserted "could not be identified" while the
                // orchestrator was holding the evidence that would identify it.
                //
                // A reporter's ComfyUI was started by an external Windows launcher on a
                // port that is not 8188. Their panel was connected and live graph reads
                // worked, so a ComfyUI demonstrably existed — but this preflight probes
                // only `config.resolvedPort` and reported nothing listening, which reads
                // as "ComfyUI is not running" when it plainly was.
                //
                // describeTargetDrift is the comparison comfyuiFetch already makes for a
                // transport error: it asks the bridge which origins the connected tabs
                // actually front, and says DIFFERENT / SAME / unknown. Saying which of
                // those holds turns "we found nothing" into "we looked at the wrong
                // address, and here is the right one".
                //
                // Deliberately additive. This does NOT relax the refusal: an instance we
                // cannot identify is still an instance whose relaunch we cannot prove
                // (#814), and restarting a server this process cannot even see would be
                // exactly the loss that gate exists to prevent. What changes is that the
                // caller is told where to point COMFYUI_URL instead of being told their
                // running ComfyUI does not exist.
                describeTargetDrift(getComfyUIBaseUrl()),
        };
    }
    if (info.isDesktopApp) {
        // NOT an automatic pass any more (#814). Electron supervision is what makes a
        // Desktop reboot safe, and it is a FACT about the running process tree, not a
        // property of the install. See assessDesktopSupervision for why UNCONFIRMED
        // refuses here while it is merely disclosed on the post-launch ownership path.
        const desktop = assessDesktopSupervision(info);
        if (desktop.ok) {
            return {
                ok: true,
                ...observedLaunch(info),
                note: desktop.note,
                selfRelaunch: desktop.selfRelaunch,
            };
        }
        return { ok: false, reason: `${desktop.reason}${describeRecovery(recoveryHint(info))}` };
    }
    // NOTE (#776): the launch-ENVIRONMENT check is deliberately NOT applied here.
    // This preflight guards an OUT-OF-BAND ComfyUI-Manager reboot, which re-execs
    // the SAME process — it inherits its own (launcher-supplied) environment, so a
    // Stability Matrix / Pinokio environment survives that restart for free.
    // Refusing on it would cost those users a restart path that actually works.
    const relaunch = assessRelaunch(info);
    if (relaunch.ok)
        return { ok: true, ...observedLaunch(info) };
    return { ok: false, reason: relaunch.reason };
}
/**
 * The launch facts this preflight OBSERVED, in the shape preflightLocalRestart
 * reports them. `argv` is omitted when empty — a wedged server reports none, and an
 * empty array would read as "it was running with no arguments" (#848).
 */
function observedLaunch(info) {
    return {
        observedArgv: info.argv.length > 0 ? [...info.argv] : undefined,
        isDesktopApp: info.isDesktopApp === true,
    };
}
export const __processControlTestHooks = {
    /**
     * #2784 — the SELECTION, not just the sentence. Three signals reach the same
     * `true` and the refusal names which one decided; a test that only calls
     * `desktopEvidenceClause` pins the wording and leaves the choice unpinned, so
     * reporting the weakest signal in place of the strongest would go unnoticed --
     * and that misdirects the reader exactly like the claim this replaced.
     */
    detectDesktopLaunch,
    /**
     * #2784 — the auto-restart surface, so its disclosure is a REAL call rather
     * than a grep. The Desktop verdict turns supervision off as well as refusing a
     * restart, and that message asserted Desktop with nothing attached; a source
     * check there could not fail if the note rendered empty or the field were never
     * populated. Reads the policy from env like production does.
     */
    supervisorResult,
    reset() {
        detachSupervisor();
        lastProcessInfo = null;
        supervisorRestartCount = 0;
        supervisorWindowStartedAt = 0;
        supervisorGaveUp = false;
        remoteRebootTimingOverride = null;
        liveCwdResolverOverride = null;
        liveEnvResolverOverride = null;
        processIdentityOverride = null;
        parentPidResolverOverride = null;
        processExistsOverride = null;
        deliberateStop = false;
        restartDispatchRecords.clear();
        launchLogTextOverride = undefined;
    },
    /** Inject launch-log text so tests can drive the Manager reapply marker without
     *  a real file (#2427). `undefined` restores the production file reader. */
    setLaunchLogText(text) {
        launchLogTextOverride = text;
    },
    /** Inject a fake parent-pid reader so the lineage check can be driven without a
     *  real process tree. */
    setParentPidResolver(fn) {
        parentPidResolverOverride = fn;
    },
    /** Inject a fake pid-existence probe (#814) so the Desktop supervision check can
     *  be driven without spawning and killing real processes. */
    setProcessExistsProbe(fn) {
        processExistsOverride = fn;
    },
    /** Inject a fake process-identity (creation time) reader so tests can drive
     *  recycled-PID scenarios on any host, including where the native read is
     *  deliberately skipped. */
    setProcessIdentityResolver(fn) {
        processIdentityOverride = fn;
    },
    /** Inject a fake live-process-ENVIRONMENT reader (#776) so tests can drive the
     *  `/proc/<pid>/environ` capture without a real process. */
    setLiveEnvResolver(fn) {
        liveEnvResolverOverride = fn;
    },
    /** Inject a fake live-process-cwd resolver (#535) so tests can drive the
     *  `/proc/<pid>/cwd` relative-script anchor without a real process/filesystem. */
    setLiveCwdResolver(fn) {
        liveCwdResolverOverride = fn;
    },
    setLastProcessInfo(info) {
        lastProcessInfo = {
            ...info,
            targetFence: info.targetFence ?? currentTargetFence(),
        };
    },
    /** Seed/remove a #742 r4/r5 restart-dispatch record directly (fresh/stale/
     *  foreign base) so decline-path causation tests don't run a real restart. */
    setRestartDispatchRecord(token, record) {
        if (record == null)
            restartDispatchRecords.delete(token);
        else
            restartDispatchRecords.set(token, record);
    },
    /** Inject fast remote-reboot timing so tests don't wait the real ~120s budget. */
    setRemoteRebootTimingForTests(timing) {
        remoteRebootTimingOverride = timing;
    },
};
//# sourceMappingURL=process-control.js.map