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,242 lines • 305 kB
// Installs / updates / reinstalls the ComfyUI sidebar panel
// ("comfyui-agent-panel" on the Comfy Registry; repo comfyui-mcp-panel) into a
// LOCAL ComfyUI's custom_nodes, and auto-ensures it on MCP load.
//
// Policy (decided by the user):
//   - on load → install if MISSING (install-if-missing only, see ensurePanelInstalled).
//   - explicit `update` action → pull the latest nightly on demand. When
//     ComfyUI-Manager provably no-ops the update (stale legacy 3.x, #724) and
//     the panel dir is a real git checkout, fall back to a pinned `git merge --ff-only`
//     on the panel repo, verified on disk by the same #639 machinery.
//   - target version is always "nightly" (the registry git-HEAD channel) — there
//     is no clean semver to diff, so we never churn an existing install on load.
//
// SAFETY:
//   - LOCAL-only: no COMFYUI_PATH → no-op cleanly (remote/cloud modes).
//   - NEVER touch a dev install: custom_nodes/comfyui-mcp-panel is often a
//     SYMLINK/junction to the developer's working repo. lstat → skip/refuse.
//   - on-load ensure is fire-and-forget, hard-timed-out, and never throws.
//   - opt-out env COMFYUI_MCP_PANEL_AUTOINSTALL=0/false disables auto-ensure.
//   - install/update/reinstall queue via ComfyUI-Manager; ComfyUI must be
//     RESTARTED to load the new/updated node (we never auto-restart).
import { execFileSync } from "node:child_process";
import { closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
import { createHash } from "node:crypto";
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
import { getComfyUIBaseUrl, isLocalMode } from "../config.js";
import { logger } from "../utils/logger.js";
import { parsePyproject } from "./node-authoring.js";
import { installCustomNode, updateCustomNode, reinstallCustomNode, nonInteractiveGitEnv, probeManagerQueueAvailability as probeManagerQueueAvailabilityOnHost, } from "./node-management.js";
import { getSystemStats } from "../comfyui/client.js";
import { describePanelPin, getPanelPinState, PANEL_PIN_ENV_VAR, } from "./panel-settings.js";
import { withPanelMutationLock } from "./panel-pin-guard.js";
import { describeInstallPanelAction, describePanelUpdateRecovery, PANEL_REPO_URL, } from "./panel-recovery.js";
import { compareSemver } from "./self-update.js";
import { SEMVER_RE } from "./ui-bridge.js";
import { clearPanelDiskObservation, isLiveDerivedBase, lastPanelBaseResolution, panelBaseSync, panelTargetGeneration, primePanelBase, recordPanelDiskObservation, } from "./panel-workspace.js";
/** Comfy Registry id (also pyproject [project].name). Authoritative for detection. */
export const PANEL_REGISTRY_ID = "comfyui-agent-panel";
/** Always install/update/reinstall the panel from the registry git-HEAD channel. */
export const PANEL_VERSION = "nightly";
/**
 * Fast-path directory names to probe first. The panel installs to a custom_nodes
 * subdir named after the REPO ("comfyui-mcp-panel"), but the registry name is
 * "comfyui-agent-panel" — so check both quickly, then fall back to a full scan.
 * The pyproject `name == comfyui-agent-panel` match is always authoritative.
 */
// Exported so panel-recovery's manual instructions can be pinned to the SAME
// accepted names in a test: guidance that judged "present" by only one of them
// told a repo-checkout user to clone a second serving copy (#641 in miniature).
export const FAST_PATH_DIRS = ["comfyui-mcp-panel", "comfyui-agent-panel"];
/** Hard cap so the on-load ensure can never block startup. */
const ENSURE_TIMEOUT_MS = 20_000;
/** How long the fire-and-forget on-load ensure will wait for the panel op lock
 *  before giving up (well inside ENSURE_TIMEOUT_MS, so a lock held by another
 *  orchestrator process degrades to `unavailable` rather than a timeout). */
const ENSURE_LOCK_WAIT_MS = 3_000;
export class PanelInstallError extends Error {
    constructor(message) {
        super(message);
        this.name = "PanelInstallError";
    }
}
export function readManagerReservation(comfyuiPath, packName, deps) {
    if (!comfyuiPath)
        return "unknown";
    const candidates = [
        join(comfyuiPath, "user", "__manager", "startup-scripts", "install-scripts.txt"),
        join(comfyuiPath, "user", "default", "ComfyUI-Manager", "startup-scripts", "install-scripts.txt"),
    ];
    let sawDir = false;
    for (const file of candidates) {
        // The DIRECTORY existing is what makes an absent file meaningful: Manager
        // creates startup-scripts/ at boot and only writes the txt when something
        // is actually reserved, so "dir there, file not" IS proof of no staging.
        // Without that distinction an install whose user dir we cannot see would
        // read as "nothing staged" — the wrong half of the tri-state.
        if (deps.existsSync(dirname(file)))
            sawDir = true;
        let contents;
        try {
            if (!deps.existsSync(file))
                continue;
            contents = deps.readFile(file);
        }
        catch {
            // The file is there and unreadable: strictly "could not determine".
            return "unknown";
        }
        for (const line of contents.split(/\r?\n/)) {
            if (!line.includes("#LAZY-CNR-SWITCH-SCRIPT"))
                continue;
            // Manager writes a repr'd Python list: ['<pack>', '#LAZY-CNR-SWITCH-SCRIPT', …].
            // Match the pack as a whole quoted token so `comfyui-agent-panel` is not
            // matched by `comfyui-agent-panel-extras`.
            if (new RegExp(`['"]${escapeForRegExp(packName)}['"]`).test(line))
                return "reserved";
        }
    }
    return sawDir ? "absent" : "unknown";
}
/** Escape a literal for embedding in a RegExp. */
function escapeForRegExp(s) {
    return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
 * Resolve the current commit sha of a git checkout at `dir` by reading its
 * `.git` metadata directly (no subprocess). Handles a normal `.git/` dir, a
 * `.git` FILE pointer (worktrees/submodules: `gitdir: <path>`), a symbolic HEAD
 * (`ref: refs/heads/<branch>`) resolved via loose refs then `packed-refs`, and a
 * detached HEAD (raw sha). Returns undefined and NEVER throws on any failure.
 */
export function resolveGitRevision(dir) {
    const read = (p) => {
        try {
            return readFileSync(p, "utf-8");
        }
        catch {
            return undefined;
        }
    };
    try {
        let base = join(dir, ".git");
        let st;
        try {
            st = lstatSync(base);
        }
        catch {
            return undefined;
        }
        if (st.isFile()) {
            const pointer = (read(base) ?? "").trim();
            const m = pointer.match(/^gitdir:\s*(.+)$/);
            if (!m)
                return undefined;
            base = isAbsolute(m[1]) ? m[1] : join(dir, m[1]);
        }
        const head = (read(join(base, "HEAD")) ?? "").trim();
        if (!head)
            return undefined;
        const refM = head.match(/^ref:\s*(.+)$/);
        if (!refM) {
            // Detached HEAD → the sha is inline.
            // A FULL SHA-1 (40) or SHA-256 (64) object id — never a truncated value.
            return /^([0-9a-f]{40}|[0-9a-f]{64})$/i.test(head) ? head : undefined;
        }
        const refPath = refM[1].trim();
        // A resolved ref value must be a real commit sha — never return transient or
        // symbolic content (e.g. "ref: ...", "updating") that would look like a
        // spurious HEAD move and fabricate a successful update.
        const asSha = (v) => {
            const t = (v ?? "").trim();
            // FULL SHA-1 (40) or SHA-256 (64) only — reject truncated/abbreviated ids.
            return /^([0-9a-f]{40}|[0-9a-f]{64})$/i.test(t) ? t : undefined;
        };
        // A linked worktree keeps HEAD in its per-worktree gitdir but the shared refs
        // (loose + packed-refs) in `commondir`. Search the gitdir first, then it.
        const searchDirs = [base];
        const commondir = (read(join(base, "commondir")) ?? "").trim();
        if (commondir) {
            searchDirs.push(isAbsolute(commondir) ? commondir : join(base, commondir));
        }
        for (const d of searchDirs) {
            const loose = asSha(read(join(d, refPath)));
            if (loose)
                return loose;
            const packed = read(join(d, "packed-refs"));
            if (packed) {
                for (const line of packed.split(/\r?\n/)) {
                    if (!line || line.startsWith("#") || line.startsWith("^"))
                        continue;
                    const sp = line.indexOf(" ");
                    if (sp <= 0)
                        continue;
                    if (line.slice(sp + 1).trim() === refPath) {
                        const sha = asSha(line.slice(0, sp));
                        if (sha)
                            return sha;
                    }
                }
            }
        }
        return undefined;
    }
    catch {
        return undefined;
    }
}
/** Hard cap on the #724 fallback git fetch/merge (a fetch + fast-forward). */
const PANEL_GIT_PULL_TIMEOUT_MS = 180_000;
/** `git status` is local metadata — a short cap is plenty. */
const PANEL_GIT_STATUS_TIMEOUT_MS = 30_000;
/**
 * Run a read-or-mutate git command in the panel checkout, never prompting for
 * credentials (nonInteractiveGitEnv). Returns trimmed stdout. THROWS a
 * PanelInstallError with git's stderr on any failure — a clean exit is the
 * only signal the command actually did its work.
 */
function runGit(dir, args, timeoutMs) {
    try {
        const out = execFileSync("git", args, {
            cwd: dir,
            encoding: "utf-8",
            timeout: timeoutMs,
            env: nonInteractiveGitEnv(),
            stdio: ["ignore", "pipe", "pipe"],
        });
        return (out ?? "").trim();
    }
    catch (err) {
        const e = err;
        const detail = [e.stdout, e.stderr]
            .map((b) => (b == null ? "" : b.toString()))
            .join("")
            .trim();
        throw new PanelInstallError(`git ${args.join(" ")} in ${dir} failed: ${detail || e.message || "unknown error"}`);
    }
}
/**
 * #2671 — git's refusal to operate on a repository whose on-disk owner is not
 * the account running us ("detected dubious ownership"). It is NOT a broken
 * checkout: git exits 128 on EVERY subcommand, verified locally against
 * `GIT_TEST_ASSUME_DIFFERENT_OWNER=1` for rev-parse, status, fetch and
 * rev-parse @{upstream} — all four produce the identical fatal.
 *
 * That is what makes it worth naming. Each gate in the fallback catches its own
 * git failure and narrates a cause specific to that gate ("could not prove the
 * worktree root", "could not confirm the repo is clean"), and every one of
 * those readings is WRONG here — the repo is fine, and the "update it manually
 * with git pull" remedy they prescribe fails with the same fatal.
 *
 * Anchored on the whole remediation COMMAND git prints — `git config …
 * --add safe.directory` on one line — and NOT on the prose "detected dubious
 * ownership". Git wraps the entire message in one translatable string, so under
 * a translated catalog the prose is gone while the command inside it stays
 * verbatim. The command anchor therefore covers every case the prose would
 * have, and the prose alternative was pure spoofing surface, so it is gone.
 *
 * That the advice is ALWAYS printed is measured, not assumed (git 2.54.0): the
 * line appears for status, fetch, rev-parse, rev-parse @{upstream}, merge, log
 * and ls-files, and in a bare repository too. It is part of the same die()
 * string as the fatal, not a separately suppressible advice hint.
 *
 * The predicate got here by three tightenings, each closing a way REPO-DERIVED
 * TEXT could spoof it — every gate message embeds strings the user controls:
 *   r1 — the bare key `safe.directory` matched a directory NAMED that.
 *   r2 — the panel dir is embedded in every gate message, so any pattern could
 *        be spoofed by the checkout path. The dir is stripped before testing.
 *   r3 — `git fetch` relays server text on `remote:` lines. Excised too.
 *   r5 — the dirty-checkout gate embeds arbitrary FILENAMES from `git status
 *        --porcelain`, so a file named `--add safe.directory` reclassified a
 *        dirty checkout as an ownership refusal. Hence the full command anchor.
 *
 * DIAGNOSTIC ONLY. This classification never authorizes a mutation — it
 * rewrites a message and nothing else. comfyui-mcp deliberately does not add
 * the safe.directory exception itself: that is the user's security decision.
 */
const GIT_OWNERSHIP_REFUSAL_RE = /\bgit\s+config\b[^\n]*--add\s+safe\.directory/i;
/** Every rendering of `dir` that could appear in git's or a gate's message. */
function pathVariants(dir) {
    const variants = new Set([dir, dir.replace(/\\/g, "/"), dir.replace(/\//g, "\\")]);
    return [...variants].filter(Boolean);
}
export function isGitOwnershipRefusal(message, dir) {
    // Drop everything the REMOTE said (codex gate r3). `git fetch` relays server
    // text verbatim on `remote:` lines, so a remote could put "--add
    // safe.directory" in front of us and turn its own auth failure into a
    // fabricated ownership diagnosis — one that tells the user to grant a trust
    // exception it has no business asking for. Text an external party controls
    // must never drive a local classification.
    //
    // Excised from the marker to end-of-line rather than by dropping whole
    // lines: runGit prefixes the stderr with `git <args> in <dir> failed: `, so
    // the FIRST relayed line does not begin at a line start. Git's own messages
    // never contain "remote:" except when relaying, so nothing local is lost.
    let haystack = message.replace(/\bremote:.*$/gim, "");
    if (dir) {
        for (const v of pathVariants(dir)) {
            // Windows paths are case-insensitive, and git may echo a different case
            // than we hold.
            haystack = haystack.replace(new RegExp(v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi"), "");
        }
    }
    return GIT_OWNERSHIP_REFUSAL_RE.test(haystack);
}
/**
 * The self-contained, actionable replacement for whichever gate happened to
 * catch an ownership refusal first (#2671). Names the real cause, the exact
 * scoped remediation, and why we will not apply it on the user's behalf.
 *
 * It deliberately does NOT relay the caught message as a "git said" tail. By
 * the time a gate throws, git's fatal is nested inside that gate's own
 * narration — the very misdiagnosis this replaces — so quoting it would put
 * "could not prove … is the panel repo's worktree root" back in front of the
 * user. Git's actual advice is not lost: it is reproduced below, and with the
 * directory QUOTED, which git's own line omits (it breaks on a path with a
 * space, and the default ComfyUI layouts have several).
 *
 * The take-ownership remedy is PLATFORM-SPECIFIC (codex gate r4). This refusal
 * is not a Windows-only condition — on Linux it fires routinely for a panel dir
 * left owned by root because ComfyUI was installed under sudo — and handing
 * those users `takeown` names a command their system does not have. Naming a
 * remedy that cannot run is the same defect this whole message replaces.
 */
export function gitOwnershipRefusalMessage(dir, managerReason, platform = process.platform) {
    const takeOwnership = platform === "win32"
        ? `take ownership back from an ELEVATED terminal:\n` +
            `       takeown /f "${dir}" /r /d y`
        : `take ownership back (it is commonly root-owned after an install run ` +
            `under sudo):\n` +
            `       sudo chown -R "$(id -un)" "${dir}"`;
    return (`Panel update did NOT apply: ${managerReason}, and the git fallback is ` +
        `REFUSED: git will not touch the panel checkout at ${dir} because that ` +
        `directory is owned by a DIFFERENT account than the one running ComfyUI. ` +
        `The checkout is not damaged and nothing was changed — but every git ` +
        `command there fails this way, including the "git pull" a manual retry ` +
        `would use, so retrying by hand as this same user will not work either.\n` +
        `Pick ONE of these; comfyui-mcp will not do either for you, because both ` +
        `hand git back the right to run config-controlled hooks out of that ` +
        `directory and that is your call to make:\n` +
        `  1. Trust just this one checkout, then retry the update:\n` +
        `       git config --global --add safe.directory "${dir}"\n` +
        `  2. Or, if the directory should not belong to another account at all, ` +
        `${takeOwnership}\n` +
        `Either way, RESTART ComfyUI afterwards.`);
}
/**
 * `git status --porcelain` in the panel's checkout — the #724 fallback's
 * CLEANLINESS gate. Empty output = clean; anything else (modified tracked
 * files, staged changes, untracked files) means the fallback must REFUSE:
 * `git pull --ff-only` only refuses local edits that OVERLAP the fast-forward,
 * so edits in untouched paths would be silently carried through our mutation.
 * THROWS when git itself fails, which callers must treat as "cannot prove
 * clean" — never as clean.
 */
export function gitStatusPorcelain(dir) {
    return runGit(dir, ["status", "--porcelain"], PANEL_GIT_STATUS_TIMEOUT_MS);
}
/**
 * `git fetch --quiet` — refresh the tracked remote ref without touching the
 * worktree. THROWS a PanelInstallError with git's stderr on any failure.
 */
export function gitFetch(dir) {
    runGit(dir, ["fetch", "--quiet"], PANEL_GIT_PULL_TIMEOUT_MS);
}
/**
 * `git merge --ff-only <rev>` in the panel's own checkout — the #724 fallback
 * for hosts where ComfyUI-Manager provably no-ops updates (stale legacy 3.x).
 * The rev is PINNED: the caller fetched once, proved no ignored-file
 * collision against this exact sha, and only then merges it — a `git pull`
 * would fetch AGAIN and could apply a newer, uninspected commit (codex gate).
 * `--ff-only` can only fast-forward: it never merges, never rebases, and
 * refuses a diverged checkout. Callers MUST gate it on gitStatusPorcelain
 * first — a fast-forward does NOT refuse dirty paths it doesn't overlap, and
 * comfyui-mcp never mutates a dirty checkout. THROWS a PanelInstallError with
 * git's stderr on any failure.
 */
export function gitMergeFfOnly(dir, rev) {
    return runGit(dir, ["merge", "--ff-only", rev], PANEL_GIT_PULL_TIMEOUT_MS);
}
/**
 * `git rev-parse --show-toplevel` for the #724 fallback: a `.git` pointer
 * proves metadata exists, NOT that this directory is the worktree ROOT — a
 * copied/stale gitdir could make status/pull mutate a sibling checkout while
 * the report credits the panel repo (codex gate). THROWS on any failure;
 * callers refuse unless the resolved root IS the panel dir.
 */
/**
 * #771 — shallow-clone the panel repo into `destDir`, which MUST NOT exist.
 *
 * Run from `destDir`'s PARENT rather than from destDir (which isn't there yet),
 * and with `--end-of-options` so a path that begins with `-` can never be read
 * as a git flag. The clone URL is our own compile-time constant, never caller
 * input, so there is nothing to inject through it.
 */
export function gitClonePanel(destDir) {
    runGit(dirname(destDir), ["clone", "--depth", "1", "--end-of-options", PANEL_REPO_URL, destDir], PANEL_GIT_PULL_TIMEOUT_MS);
}
export function gitWorktreeRoot(dir) {
    return runGit(dir, ["rev-parse", "--show-toplevel"], PANEL_GIT_STATUS_TIMEOUT_MS).trim();
}
/** `git rev-parse @{upstream}` — the sha the checkout tracks. THROWS when no
 *  upstream is configured; the "at upstream tip" claim requires this proof. */
export function gitUpstreamRev(dir) {
    return runGit(dir, ["rev-parse", "@{upstream}"], PANEL_GIT_STATUS_TIMEOUT_MS).trim();
}
/**
 * Ignored-file collision proof for the #724 fallback (codex gate): `git status
 * --porcelain` omits ignored files, and a fast-forward does NOT protect them —
 * when the remote newly tracks a path that is locally ignored, the merge
 * silently overwrites the local file. Intersect the ignored untracked files
 * with the diff HEAD..upstreamRev, where upstreamRev is the SAME pinned sha
 * the caller then merges, so the proof covers the exact mutation. THROWS
 * (PanelInstallError) when the proof cannot be computed — callers treat that
 * as "cannot prove safe", never as safe.
 */
export function gitIgnoredPullConflicts(dir, upstreamRev) {
    const ignored = runGit(dir, ["ls-files", "--others", "--ignored", "--exclude-standard", "-z"], PANEL_GIT_STATUS_TIMEOUT_MS)
        .split("\0")
        .filter(Boolean);
    if (ignored.length === 0)
        return [];
    const incoming = new Set(runGit(dir, ["diff", "--name-only", "-z", "HEAD", upstreamRev], PANEL_GIT_STATUS_TIMEOUT_MS)
        .split("\0")
        .filter(Boolean));
    return ignored.filter((f) => incoming.has(f));
}
export const defaultDeps = {
    isLocalMode: () => isLocalMode(),
    managerBase: () => getComfyUIBaseUrl(),
    // #766/#769 — LIVE-FIRST. The panel is not a file the user reads; it is a web
    // extension the RUNNING ComfyUI serves to the browser tab, so the only
    // custom_nodes that can matter is the one belonging to the server we are
    // connected to. `panelBaseSync` prefers the live server's `--base-directory`
    // (Comfy Desktop keeps custom_nodes there, NOT next to main.py — #766) and
    // then its argv root (#769), falling back to the ordinary sync resolver —
    // COMFYUI_PATH, then a saved default workspace (#700) — when the server is
    // unreachable or its argv yields nothing. See panel-workspace.ts.
    comfyuiPath: () => panelBaseSync(),
    env: () => process.env,
    existsSync,
    probeFile: (p) => {
        try {
            return statSync(p).isFile(); // true = regular file, false = exists but a dir
        }
        catch (e) {
            const code = e?.code;
            // ENOENT/ENOTDIR = confirmed no servable file here; else indeterminate.
            return code === "ENOENT" || code === "ENOTDIR" ? false : undefined;
        }
    },
    isSymlink: (p) => {
        try {
            return lstatSync(p).isSymbolicLink();
        }
        catch {
            return false;
        }
    },
    isDirectory: (p) => {
        try {
            // statSync follows symlinks: a dir symlink IS web-served, so it counts.
            return statSync(p).isDirectory();
        }
        catch {
            // Could not determine — return undefined so the caller fails closed rather
            // than treating a served backup as a skippable "file".
            return undefined;
        }
    },
    realPath: (p) => {
        try {
            // .native returns the real on-disk case on Windows/macOS.
            return realpathSync.native(p);
        }
        catch {
            return undefined;
        }
    },
    readdir: (p) => readdirSync(p),
    readFile: (p) => readFileSync(p, "utf-8"),
    gitRevision: (dir) => resolveGitRevision(dir),
    gitStatusPorcelain: (dir) => gitStatusPorcelain(dir),
    gitFetch: (dir) => gitFetch(dir),
    gitMergeFfOnly: (dir, rev) => gitMergeFfOnly(dir, rev),
    gitWorktreeRoot: (dir) => gitWorktreeRoot(dir),
    gitUpstreamRev: (dir) => gitUpstreamRev(dir),
    gitIgnoredPullConflicts: (dir, rev) => gitIgnoredPullConflicts(dir, rev),
    gitClonePanel: (destDir) => gitClonePanel(destDir),
    mkdirp: (p) => {
        mkdirSync(p, { recursive: true });
    },
    rename: (from, to) => renameSync(from, to),
    removeDir: (p) => rmSync(p, { recursive: true, force: true }),
    // DURABLE, because this is a crash-recovery record and a crash is exactly
    // when it has to exist. A buffered write can be lost or reordered against the
    // rename that immediately follows it, which would leave no canonical panel
    // AND no record of where the backup went — the one outcome the journal is
    // there to prevent. Write, fsync the file, then fsync the containing
    // directory so the new entry itself is durable (a no-op on platforms that
    // refuse it, which is not a failure).
    syncDir: (p) => {
        try {
            const fd = openSync(p, "r");
            try {
                fsyncSync(fd);
            }
            finally {
                closeSync(fd);
            }
        }
        catch {
            // Unsupported on this platform (Windows cannot fsync a directory handle).
            // Not an error: see the barrier comment in updateViaRegistryZipReinstall.
        }
    },
    fileDigest: (p) => {
        try {
            const buf = readFileSync(p);
            return {
                size: buf.byteLength,
                sha256: createHash("sha256").update(buf).digest("hex"),
            };
        }
        catch {
            return undefined;
        }
    },
    hashString: (v) => createHash("sha256").update(v, "utf-8").digest("hex"),
    mtimeMs: (p) => {
        try {
            return statSync(p).mtimeMs;
        }
        catch {
            return undefined;
        }
    },
    writeFile: (p, contents) => {
        const fd = openSync(p, "w");
        try {
            writeFileSync(fd, contents, "utf-8");
            fsyncSync(fd);
        }
        finally {
            closeSync(fd);
        }
        try {
            const dirFd = openSync(dirname(p), "r");
            try {
                fsyncSync(dirFd);
            }
            finally {
                closeSync(dirFd);
            }
        }
        catch {
            // Directory fsync is unsupported on some platforms (notably Windows).
            // The file's own fsync still ordered it before the rename.
        }
    },
    readPin: () => getPanelPinState(),
    isReachable: async () => {
        try {
            await getSystemStats();
            return true;
        }
        catch {
            return false;
        }
    },
    install: (opts) => installCustomNode(opts),
    update: (opts) => updateCustomNode(opts),
    reinstall: (opts) => reinstallCustomNode(opts),
};
/**
 * Marks a dep set derived from `defaultDeps`. `pinPanelBase` returns a NEW
 * object, so identity against `defaultDeps` no longer answers "are these the
 * real deps?" — and that question governs whether we probe the live server and
 * whether an on-disk read is worth recording. Carry the answer explicitly.
 */
const REAL_DEPS = Symbol("panel-installer.realDeps");
/**
 * The ComfyUI target generation as it stood when the base was FROZEN — captured
 * before the live probe, so a retarget that completed during the freeze is
 * still visible afterwards. Reading the generation at re-entry instead would
 * miss exactly that: the orchestrator launches the panel sync and then
 * retargets, so by the time the mutation begins the change has already landed
 * and looks like the status quo.
 */
const PINNED_GENERATION = Symbol("panel-installer.pinnedGeneration");
/** The base this dep set was frozen to — re-asserted before any mutation. */
const PINNED_BASE = Symbol("panel-installer.pinnedBase");
/** Is this the production dep set (rather than an injected test one)? */
function isRealDeps(deps) {
    return deps === defaultDeps || deps[REAL_DEPS] === true;
}
/**
 * Resolve the LIVE ComfyUI base once and FREEZE it for the rest of the
 * operation (#766/#769).
 *
 * Priming alone is not enough, and assuming it was is a real bug: the live
 * resolution is cached with a short TTL, and `panelBaseSync()` falls back to the
 * configured base once that expires. A single panel update can easily outlive
 * it — ComfyUI-Manager operations are slow. On a Comfy Desktop split install the
 * pre-op detection would then inspect the live `--base-directory` tree while the
 * post-op verification inspected the configured one, and if the configured tree
 * happened to hold a newer panel the "did it move?" proof would compare two
 * different directories and report a success that never happened. That is
 * precisely the fabricated-success class #639 exists to prevent.
 *
 * So the base is read ONCE and pinned into a dep set for the whole operation.
 * Pinning is idempotent — an already-pinned set re-pins to the same value — and
 * for an injected dep set (whose `comfyuiPath` is already a constant) it is a
 * no-op that preserves existing behaviour exactly.
 */
export async function pinPanelBase(deps, opts = {}) {
    const real = isRealDeps(deps);
    // CAPTURE THE GENERATION FIRST, before the probe's await. The orchestrator
    // launches the panel sync and THEN retargets, so a retarget can complete
    // while we are freezing — and a generation read afterwards would record the
    // new target as though it had always been the one we froze against, making
    // the divergence invisible to every later check.
    // ONCE FROZEN, STAY FROZEN. runPanelActionInner re-pins on entry, and if that
    // re-pin re-read the generation it would erase the whole point: the retarget
    // we are trying to detect happens BETWEEN performPanelSync's freeze and that
    // re-entry, so a second capture would record the new target as the original
    // one. An already-pinned dep set keeps its first answer.
    const generation = pinnedGenerationOf(deps) ?? panelTargetGeneration();
    // Only probe the live server for the REAL deps. An injected dep set has
    // already declared where ComfyUI is; probing would be pointless and, in
    // tests, a network call nobody asked for.
    if (real)
        await primePanelBase({ force: opts.force });
    const base = deps.comfyuiPath();
    const pinned = { ...deps, comfyuiPath: () => base };
    if (real)
        pinned[REAL_DEPS] = true;
    pinned[PINNED_GENERATION] = generation;
    if (base !== undefined)
        pinned[PINNED_BASE] = base;
    return pinned;
}
/** The generation this dep set was frozen at, if it carries one. */
function pinnedGenerationOf(deps) {
    return deps[PINNED_GENERATION];
}
/** The base this dep set was frozen to, if it carries one. */
function pinnedBaseOf(deps) {
    return deps[PINNED_BASE];
}
/**
 * Refuse if the ComfyUI target changed since this dep set was frozen.
 *
 * Shared by EVERY path that mutates the panel, not just the explicit ones. The
 * automatic paths need it most: the on-load ensure and the interrupted-swap
 * repair both freeze a local base and then act on it, and a hello retarget can
 * land in between — restoring or installing into tree A while the Manager
 * request, and the answer we hand back, belong to B.
 *
 * A dep set with no pinned generation was never frozen (a direct call with
 * injected deps), so there is nothing to compare and nothing to refuse.
 */
export function assertPinnedTarget(deps, action, stage) {
    const frozen = pinnedGenerationOf(deps);
    if (frozen === undefined || panelTargetGeneration() === frozen)
        return;
    throw new PanelInstallError(`Panel ${action} ABORTED ${stage}: the ComfyUI this orchestrator targets changed ` +
        `while the operation was in flight, so the local directory it was working in ` +
        `(${deps.comfyuiPath() ?? "unresolved"}) may no longer belong to the install this ` +
        `result would describe. NOT acting on, or reporting, a possibly-wrong tree. ` +
        `Re-run install_comfyui(action:'panel', panel_action:'status'), then retry.`);
}
// ---------------------------------------------------------------------------
// Version pin
//
// A pin is the user's explicit "hold the panel here". Every mutating path in
// this file consults it FIRST and refuses while it is in force — the on-load
// ensure, install, update and reinstall alike. The escape hatch is to clear the
// pin (install_comfyui(action:'panel', panel_action:'unpin'), or COMFYUI_MCP_PANEL_PIN=off), never for
// us to decide the pin was probably fine to ignore.
// ---------------------------------------------------------------------------
/**
 * Read the pin so that NO failure mode reads as "unpinned". A reader that throws
 * is reported as an indeterminate pin, which counts as pinned: silently moving a
 * user off a pin we merely failed to read is the exact bug this guards.
 */
function readPinSafe(deps) {
    try {
        return deps.readPin();
    }
    catch (err) {
        logger.warn(`[panel] could not read the panel version pin: ${err instanceof Error ? err.message : String(err)} — treating the panel as PINNED (refusing to move it).`);
        return { pinned: true, source: "settings", indeterminate: true };
    }
}
/*
 * Serializes every panel MUTATION (the on-load ensure and each
 * install/update/reinstall). Two overlapping panel ops would each read the
 * other's half-applied disk state, and the #639 "did it move?" proof compares a
 * pre-image against a post-image — interleave them and both comparisons are
 * meaningless. One at a time makes each op's before/after its own.
 *
 * It also closes the pin race: the final pin check (assertNotPinned, immediately
 * before the Manager call) and the call itself sit inside this critical section,
 * so a pin cannot be written in between — by this process OR another one.
 */
/**
 * Exported so PIN WRITES take the same lock. Without that, a pin could be
 * committed after an in-flight update passed its final pin check but before the
 * Manager actually touched disk — the update would then land on a now-pinned
 * install and report success. Serializing both means a pin either lands before
 * an op starts (and blocks it) or after it finishes (and blocks the next one);
 * it never slices one in half.
 *
 * The underlying lock is a FILE, not module state: running more than one
 * orchestrator process (one per MCP client) is ordinary here, and two processes
 * do not share a promise chain. See panel-pin-guard.ts.
 */
export function withPanelOpLock(fn, opts = {}) {
    return withPanelMutationLock(fn, opts);
}
/** The refusal a pin produces for a mutating action, with the way out. */
function pinRefusalMessage(action, pin) {
    return (`Refusing to ${action} the panel: it is ${describePanelPin(pin)}. ` +
        `A pin is honoured even when a newer panel exists — clear it first with ` +
        `install_comfyui(action:'panel', panel_action:'unpin')` +
        (pin.source === "env"
            ? ` (this pin comes from the ${PANEL_PIN_ENV_VAR} environment variable, so ` +
                `it must be unset/changed in the environment — unpin cannot remove it)`
            : ``) +
        `, then re-run the ${action}.`);
}
/**
 * Throw if a pin is in force. Called BOTH on entry (fail fast with a good
 * message before any work) and again immediately before the ComfyUI-Manager
 * call inside the op lock — detection can take a while, and a pin set during
 * that window must still be honoured.
 */
function assertNotPinned(action, deps) {
    const pin = readPinSafe(deps);
    if (pin.pinned)
        throw new PanelInstallError(pinRefusalMessage(action, pin));
}
/**
 * Scan <COMFYUI_PATH>/custom_nodes for a subdir whose pyproject.toml
 * `[project].name == "comfyui-agent-panel"`. LOCAL-only: with no comfyuiPath
 * (remote/cloud) returns applicable:false / installed:false.
 */
export async function detectPanelInstall(deps = defaultDeps) {
    const comfyPath = deps.comfyuiPath();
    // LOCAL-only: in remote/cloud mode the local FS is the wrong filesystem to
    // reason about, so detection is not applicable even if COMFYUI_PATH is set.
    if (!deps.isLocalMode() || !comfyPath) {
        return { applicable: false, installed: false, isDevSymlink: false };
    }
    const customNodes = join(comfyPath, "custom_nodes");
    // P1a — DEV-JUNCTION GUARD, FIRST and INDEPENDENT of pyproject parsing.
    // lstat the KNOWN panel target dirs directly: if either is a symlink/junction
    // it is a dev install and must be protected from any mutation, EVEN when its
    // pyproject.toml is missing, corrupt, or unreadable. (A missing/bad pyproject
    // must never downgrade a junction to "not installed" — that would let
    // install/reinstall clobber the developer's working repo.)
    for (const name of FAST_PATH_DIRS) {
        const dir = join(customNodes, name);
        if (deps.isSymlink(dir)) {
            let version;
            const pyproject = join(dir, "pyproject.toml");
            if (deps.existsSync(pyproject)) {
                try {
                    version = parsePyproject(deps.readFile(pyproject)).version;
                }
                catch {
                    version = undefined;
                }
            }
            return {
                applicable: true,
                installed: true,
                dir,
                version,
                gitRev: deps.gitRevision(dir),
                isDevSymlink: true,
            };
        }
    }
    // Candidate dirs: fast-path names first, then any other subdir.
    const candidates = FAST_PATH_DIRS.map((n) => join(customNodes, n));
    let scanReliable = true;
    // `existsSync` collapses EVERY error to false, so an unreadable custom_nodes
    // (EACCES/EIO) is indistinguishable from a fresh install that has none — and
    // the second is a proven absence while the first is not. The tri-state probe
    // tells them apart: only `undefined` (could not determine) makes the scan
    // unreliable, so an install is never recommended over a directory we simply
    // could not read into.
    if (deps.isDirectory(customNodes) === undefined)
        scanReliable = false;
    if (deps.existsSync(customNodes)) {
        let entries = [];
        try {
            entries = deps.readdir(customNodes);
        }
        catch {
            // Enumeration FAILED — a "not installed" verdict from here is unreliable.
            entries = [];
            scanReliable = false;
        }
        for (const e of entries) {
            const full = join(customNodes, e);
            if (!candidates.includes(full))
                candidates.push(full);
        }
    }
    for (const dir of candidates) {
        // Never resolve a backup/copy-shaped dir (e.g. ".comfyui-agent-panel.bak-*")
        // as the canonical install — it is a shadow, handled by findPanelShadows
        // (#641). The FAST_PATH canonical names are never backup-shaped.
        if (looksLikePanelBackupName(basename(dir)))
            continue;
        // Nor the in-flight replacement of a swap that was interrupted. It IS a
        // real panel, but it is not the CANONICAL install, and resolving it as one
        // would make an update compare a directory that is about to be renamed.
        // findPanelShadows still reports it (it serves panel assets), and
        // reconcilePanelSwap is what resolves it.
        if (basename(dir) === PANEL_INCOMING_NAME)
            continue;
        const pyproject = join(dir, "pyproject.toml");
        if (!deps.existsSync(pyproject))
            continue;
        let parsed;
        try {
            parsed = parsePyproject(deps.readFile(pyproject));
        }
        catch {
            // A candidate pyproject EXISTS but could not be read/parsed — this dir
            // MIGHT be the panel we failed to read. A resulting "not installed" verdict
            // is therefore NOT conclusive: mark the scan unreliable so callers don't
            // treat it as a proven absence (which would fabricate an absent→present
            // install). Read reliability is folded into scanReliable.
            scanReliable = false;
            continue;
        }
        if (parsed.projectName === PANEL_REGISTRY_ID) {
            return {
                applicable: true,
                installed: true,
                dir,
                version: parsed.version,
                gitRev: deps.gitRevision(dir),
                isDevSymlink: deps.isSymlink(dir),
                scanReliable,
            };
        }
    }
    return { applicable: true, installed: false, isDevSymlink: false, scanReliable };
}
/**
 * EXACT (case-sensitive) canonical panel dir basename. Used only to avoid
 * flagging the real install when no canonical dir was resolved; a case-VARIANT
 * (e.g. "ComfyUI-MCP-Panel" on a case-sensitive volume) is NOT exempted here —
 * it is content-checked like any other dir so a distinct serving copy is caught.
 */
function isExactCanonicalPanelName(name) {
    return FAST_PATH_DIRS.includes(name);
}
/**
 * Whether two paths are the SAME on-disk directory, by PHYSICAL identity:
 * realpath resolves symlinks + the real filesystem case, which is authoritative
 * regardless of case-sensitivity. On a case-INSENSITIVE volume "ComfyUI-MCP-Panel"
 * and "comfyui-mcp-panel" resolve to the same real path (one dir → exempt the
 * canonical); on a case-SENSITIVE volume (Linux, or a case-sensitive APFS/macOS
 * volume) they resolve to DISTINCT real paths (two dirs → never exempt the
 * shadow). When realpath cannot resolve EITHER side, physical identity is
 * unknown, so we FAIL CLOSED and exempt only an EXACT string match — never
 * case-fold a possibly-distinct directory into the canonical.
 */
function samePathCI(a, b, deps) {
    if (!a || !b)
        return false;
    if (a === b)
        return true;
    const ra = deps.realPath(a);
    const rb = deps.realPath(b);
    if (ra && rb)
        return ra === rb; // authoritative physical identity
    // realpath unavailable → can't prove identity → only exact-equal is "same".
    return false;
}
/**
 * The remainder (after the canonical base) of a panel BACKUP name: only
 * separators / digits / dots may precede the FIRST backup marker, which is
 * either "~" or a whole-word backup keyword. Descriptive text AFTER the marker is
 * allowed (e.g. "-backup-2026-final", "~snapshot"); a non-backup word BEFORE the
 * marker ("-tools-old", "-holder-backup") means it is a backup of a DIFFERENT
 * (sibling) node, not the panel, so it must NOT match.
 */
const PANEL_BACKUP_REST = /^[._\-0-9]*(?:~|(?:bak|backup|old|copy|orig|save|prev|previous)(?![a-z]))/i;
/**
 * A dir NAME that looks like a leftover copy/backup OF THE PANEL — the shadowing
 * trap from #641. The name must START WITH a canonical panel base name (after an
 * optional leading dot) and then be EITHER exactly that name while hidden (a
 * dot-prefixed copy of the real panel) OR a panel-backup suffix (see
 * PANEL_BACKUP_REST). Copies that dropped their name are caught by the CONTENT
 * signal instead.
 */
export function looksLikePanelBackupName(name) {
    const hidden = name.startsWith(".");
    const core = (hidden ? name.slice(1) : name).toLowerCase();
    const base = FAST_PATH_DIRS.find((n) => core === n || core.startsWith(n));
    if (!base)
        return false;
    const rest = core.slice(base.length);
    // Exactly a canonical name: a HIDDEN copy (".comfyui-agent-panel") shadows the
    // real panel; a plain "comfyui-agent-panel" IS the real install (not a backup).
    if (rest === "")
        return hidden;
    return PANEL_BACKUP_REST.test(rest);
}
/**
 * Web-extension asset paths ComfyUI serves for the panel. A custom_nodes dir that
 * contains ANY of these is served as the panel's frontend (at /extensions/<dir>/…)
 * and therefore shadows the canonical install regardless of its dir name or
 * pyproject — this is the #641 CONTENT signal, spelling-independent.
 */
const PANEL_WEB_MARKERS = [
    ["web", "js", "comfyui-mcp-panel.js"],
    ["web", "img", "comfyui-mcp-wordmark.svg"],
];
/**
 * THREE DIFFERENT QUESTIONS ARE ASKED ABOUT A PANEL DIRECTORY. Confusing them
 * has now caused the same class of bug three times, so they are named here and
 * each has exactly one predicate:
 *
 *  1. IDENTITY — "is this the panel pack?" Answered by the pyproject
 *     `[project].name`, in detectPanelInstall. Deliberately does NOT consider
 *     whether the pack works: a broken install must still be FOUND, or update
 *     could never repair it.
 *
 *  2. SERVE — "would ComfyUI serve anything out of here?" Answered by
 *     `servesPanelWebAssets`, and used ONLY by shadow detection. EITHER marker
 *     counts, because either is enough for the browser to load something from
 *     this directory and shadow the real panel. Fails closed on an
 *     indeterminate probe.
 *
 *  3. VIABILITY — "would this directory work as the panel?" Answered by
 *     `dirHasPanelFiles`: identity plus the bundle the browser loads. Used for
 *     an ALREADY-INSTALLED panel (a backup, or the current install), which we
 *     did not stage and so cannot have a manifest for. Strictly stronger than
 *     SERVE — a directory holding only the wordmark passes (2) and fails (3).
 *
 *  4. INTEGRITY — "did this tree survive intact?" Answered EXACTLY by
 *     `verifyStagedTree` against a manifest written when the tree was staged.
 *     This is the question a staged replacement must answer before it is
 *     promoted over a working panel; content-inspection was the wrong tool for
 *     it and has been removed.
 *
 * Using (2) where (3) was meant is what let a husk be installed over a working
 * panel. If a new call site appears, say which of the three it is asking.
 */
/**
 * (2) SERVE. Tri-state: does `dir` serve the panel's web-extension assets? true
 * = a marker is present, false = all markers CONFIRMED absent, undefined = a
 * probe FAILED (indeterminate). Callers must treat undefined as a POSSIBLE
 * shadow (fail closed), never as "no assets". Never throws.
 *
 * NOT a shape or integrity test — see dirHasPanelFiles and verifyStagedTree.
 */
function servesPanelWebAssets(dir, deps) {
    let probeFailed = false;
    for (const seg of PANEL_WEB_MARKERS) {
        const r = deps.probeFile(join(dir, ...seg));
        if (r === true)
            return true;
        if (r === undefined)
            probeFailed = true; // indeterminate — can't confirm absent
    }
    return probeFailed ? undefined : false;
}
/**
 * Find panel-serving dirs under custom_nodes that would SHADOW the canonical
 * install — any dir (other than the true canonical) that ComfyUI would SERVE as
 * the panel's web extension. Detection is CONTENT-first (the dir serves the
 * panel's web assets → spelling-independent), plus a cheap name heuristic and an
 * exact pyproject-name match. FAILS CLOSED on uncertainty: a served copy whose
 * pyproject is unreadable/absent is still flagged (as a possible shadow with no
 * version), never silently omitted. LOCAL-only.
 *
 * THROWS if custom_nodes exists but cannot be enumerated: shadow inspection is
 * then INDETERMINATE and the ACTION paths must fail closed rather than assume
 * "no shadow". (panelStatus wraps this and stays non-throwing.)
 */
export function findPanelShadows(canonicalDir, deps = defaultDeps) {
    const comfyPath = deps.comfyuiPath();
    if (!deps.isLocalMode() || !comfyPath)
        return [];
    const customNodes = join(comfyPath, "custom_nodes");
    // Enumerate custom_nodes. A missing dir (ENOENT) legitimately means "no
    // shadows"; ANY other failure (EACCES/EIO/…) is INDETERMINATE — NOT swallowed,
    // so the action paths fail closed rather than assume "no shadows".
    let entries;
    try {
        entries = deps.readdir(customNodes);
    }
    catch (err) {
        if (err?.code === "ENOENT")
            return [];
        throw err;
    }
    const shadows = [];
    for (const name of entries) {
        const dir = join(customNodes, name);
        // ComfyUI serves DIRECTORIES as web extensions — a regular FILE that happens
        // to share the name is not served and must not block actions. Skip ONLY on a
        // CONFIRMED non-directory; an undefined (stat error) is indeterminate and
        // must NOT omit a possible served backup → fail closed by continuing to check.
        if (deps.isDirectory(dir) === false)
            continue;
        // PHYSICAL IDENTITY FIRST: a symlink/junction/alias that resolves to the
        // canonical dir serves the SAME (updated) assets → it is NOT a shadow, even
        // if its NAME is backup-shaped (e.g. ".comfyui-agent-panel.bak" ->
        // comfyui-mcp-panel). realpath-unavailable falls back to exact-string only.
        if (samePathCI(dir, canonicalDir, deps))
            continue;
        // With no canonical resolved, don't flag a dir whose name is EXACTLY a
        // canonical basename (it is most likely the real install). Case-variant or
        // backup-shaped names are still content-checked below — never exempted here.
        if (!canonicalDir && isExactCanonicalPanelName(name))
            continue;
        const isBackup = looksLikePanelBackupName(name);
        // CONTENT signal: does this dir serve the panel's web assets? If so it is a
        // shadow no matter how it is named or whether its pyproject is readable. An
        // INDETERMINATE probe (undefined) is treated as a possible shadow (fail
        // closed) — only a CONFIRMED-absent (false) clears the content signal.
        const servesPanel = servesPanelWebAssets(dir, deps) !== false;
        let isPanelCopy = isBackup || servesPanel;
        let version;
        const pyproject = join(dir, "pyproject.toml");
        if (deps.existsSync(pyproject)) {
            try {
                const parsed = parsePyproject(deps.readFile(pyproject));
                if (parsed.projectName === PANEL_REGISTRY_ID) {
                    isPanelCopy = true;
                    version = parsed.version;
                }
                // else: pyproject readable but a different name. If it still SERVES panel
                // assets (isPanelCopy) it remains a shadow with an unknown panel version.
            }
            catch {
                // FAIL CLOSED: unreadable pyproject does NOT clear a content/name signal.
                // A served copy we cannot identify is a POSSIBLE shadow, not "no shadow".
            }
        }
        if (isPanelCopy)
            shadows.push({ name, version });
    }
    return shadows;
}
/** Fail closed when a shadowing panel dir exists (used by install/update/reinstall). */
function assertNoPanelShadow(action, canonicalDir, deps) {
    let shadows;
    try {
        shadows = findPanelShadows(canonicalDir, deps);
    }
    catch (err) {
        // Indeterminate: we could not enumerate custom_nodes, so we CANNOT rule out a
        // shadowing backup. Fail closed rather than fabricate success.
        throw new PanelInstallError(`Panel ${action} cannot be confirmed: unable to inspect custom_nodes for ` +
            `shadowing panel copies (${err instanceof Error ? err.message : String(err)}). ` +
            `A leftover backup dir there could shadow the real panel in the browser ` +
            `(#641). NOT reporting success — check custom_nodes for stray panel copies ` +
            `(especially dot-prefixed ".comfyui-agent-panel.bak-*"), then retry.`);
    }
    if (shadows.length === 0)
        return;
    const names = shadows
        .map((s) => `"${s.name}"${s.version ? ` (${s.version})` : " (identity could not be verified)"}`)
        .join(", ");
    throw new PanelInstallError(`Panel ${action} cannot be confirmed: a SHADOW copy of the panel exists in ` +
        `custom_nodes — ${names}. ComfyUI serves EVERY dir under custom_nodes as a web ` +
        `extension (including dot-prefixed ones the node loader hides), and such a ` +
        `copy can WIN registration by sort order (e.g. ".comfyui-agent-panel.bak-*" ` +
        `sorts before "comfyui-agent-panel"), so the BROWSER may keep loading the old ` +
        `panel even though the real dir on disk is up to date (#641). NOT reporting ` +
        `success. Remove or MOVE the offending dir OUT of custom_nodes (e.g. to a temp ` +
        `folder) — or, if its identity could not be verified, make its pyproject.toml ` +
        `readable so it can be identified — then hard-refresh the ComfyUI tab. A ` +
        `backup belongs anywhere EXCEPT under custom_nodes.`);
}
/**
 * Non-throwing shadow describe for the fire-and-forget on-load ensure. Returns a
 * human warning when a shadow exists OR the inspection was indeterminate, else
 * undefined. (The explicit tool paths use the throwing assertNoPanelShadow.)
 */
function describePanelShadow(canonicalDir, deps) {
    let shadows;
    try {
        shadows = findPanelShadows(canonicalDir, deps);
    }
    catch (err) {
        return (`could not inspect custom_nodes for shadowing panel copies ` +
            `(${err instanceof Error ? err.message : String(err)}) — a stray ` +
            `".comfyui-agent-panel.bak-*" there could shadow the panel in the browser (#641)`);
    }
    if (shadows.length === 0)
        return undefined;
    const names = shadows.map((s) => `"${s.name}"`).join(", ");
    return (`${shadows.length} shadow copy/copies in custom_nodes (${names}) are ALSO ` +
        `web-served and can win registration by sort order — the browser may load the ` +
        `OLD panel. Move them OUT of custom_nodes, then hard-refresh the ComfyUI tab (#641)`);
}
/**
 * The explicit opt-out applies to every unattended panel mutation. A user who
 * disables on-load installation must not get an automatic version sync later
 * merely because a desktop tab says hello.
 */
export function isPanelAutoInstallDisabled(env = process.env) {
    const v = (env.COMFYUI_MCP_PANEL_AUTOINSTALL ?? "").trim().toLowerCase();
    return v === "0" || v === "false" || v === "no" || v === "off";
}
function withTimeout(p, ms) {
    return new Promise((resolve, reject) => {
        const t = setTimeout(() => reject(new Error(`panel ensure timed out after ${ms}ms`)), ms);
        p.then((v) => {
            clearTimeout(t);
            resolve(v);
        }, (e) => {
            clearTimeout(t);
            reject(e);
        });
    });
}
async function ensureInner(deps) {
    if (isPanelAutoInstallDisabled(deps.env())) {
        return {
            action: "skipped",
            reason: "COMFYUI_MCP_PANEL_AUTOINSTALL disabled",
        };
    }
    if (!deps.isLocalMode()) {
        return {
            action: "unavailable",
            reason: "Panel auto-install is local-only (remote/cloud mode active).",
        };
    }
    if (!deps.comfyuiPath()) {
        return {
            action: "unavailable",
            reason: "No local ComfyUI (COMFYUI_PATH unset); panel auto-install is local-only.",
        };
    }
    if (!(await deps.isReachable())) {
        return { action: "unavailable", reason: "ComfyUI is not reachable." };
    }
    // An explicit pin outranks auto-install: installing the `nightly` channel over
    // a pinned version is exactly the silent move the pin forbids. Skip and say so.
    const pin = readPinSafe(deps);
    if (pin.pinned) {
        return {
            action: "skipped",
            reason: `panel version pin in force — ${describePanelPin(pin)}`,
        };
    }
    // REPAIR AN INTERRUPTED SWAP AT STARTUP. Replacing a directory takes two
    // renames and cannot be made atomic, so a process kill or host crash between
    // them leaves custom_nodes with NO panel and the working copy in
    // custom_nodes_backup. Until this ran here, recovery required the user to
    // know to invoke another panel mutation — an in-product dead end for the one
    // failure that actually takes the panel away. This path runs on every load
    // and already holds the op lock, so it is where the repair belongs.
    const ensureBase = deps.comfyuiPath();
    if (ensureBase) {
        // Same target binding the explicit mutations use: this runs unattended on
        // load, and the hello that triggers it retargets asynchronously, so a
        // retarget can land between the freeze and this restore.
        assertPinnedTarget(deps, "auto-install", "before repairing an interrupted swap");
        const repaired = reconcilePanelSwap(ensureBase, deps, { repair: true });
        if (repaired.note) {
            logger.warn(`[panel] on load:${repaired.note}`);
        }
        if (!repaired.ok) {
            return {
                action: "unavailable",
                reason: `A previous panel update was interrupted and could not be repaired ` +
                    `automatically.${repaired.note ?? ""}`,
            };
        }
    }
    const detection = await detectPanelInstall(deps);
    if (detection.isDevSymlink) {
        return {
            action: "skipped-dev",
            reason: "dev install (symlink) — managed manually",
            dir: detection.dir,
            installedVersion: detection.version,
        };
    }
    if (!detection.installed) {
        // #639 — if the pre-op enumeration FAILED, "not installed" is unreliable: a
        // pre-existing panel in a non-fast-path dir may have been missed. Installing
        // blind risks a duplicate (shadow), and we could not honestly claim a fresh
        // install, so skip and report unavailable rather than fabricate "installed".
        if (detection.scanReliable === false) {
            return {
                action: "unavailable",
                reason: "Could not enumerate custom_nodes to confirm the panel is missing; " +
                    "skipping auto-install to avoid a duplicate/unverified install.",
            };
        }
        // #766/#820 — and the same caution about WHICH TREE we looked in. An
        // unattended install into a root the running server never named would land
        // in a custom_nodes nothing loads, and the user would see no panel and no
        // error. On a split install (Comfy Desktop's --base-directory) the
        // configured path is exactly that root. Live-derived is not sufficient on
        // its own (#820): the resolution is re-read HERE while the tree was frozen
        // at pin time, and the two can diverge with NO retarget — the pin-time
        // probe failed (configured fallback frozen), then a re-resolution of the
        // SAME target recovered and named the server's real --base-directory. The
        // pinned-generation check cannot see that (no retarget, no bump), so a
        // live-derived resolution naming tree B would license an install into
        // frozen tree A — an observation of one tree licensing a mutation of
        // another. The resolution must name the tree we are about to install into.
        //
        // And it must be FRESH: the resolution cache lives for 60s, and a ComfyUI
        // restart at the same URL (new --base-directory, no retarget, no bump)
        // leaves the cache naming the OLD tree. The pin froze from that cache, so
        // a cached A-to-A match can certify a tree the server no longer serves.
        // The reachability probe above succeeded, so re-resolve FORCED — a live
        // answer is available right now.
        if (isRealDeps(deps)) {
            const installTree = pinnedBaseOf(deps) ?? deps.comfyuiPath();
            const resolution = await primePanelBase({ force: true });
            const liveBase = isLiveDerivedBase(resolution) ? resolution?.base : undefined;
            if (!liveBase || !samePathCI(installTree, liveBase, deps)) {
                return {
                    action: "unavailable",
                    reason: liveBase
                        ? `The panel appears absent in ${installTree ?? "the resolved tree"}, ` +
                            `but the running ComfyUI's own launch arguments name a DIFFERENT tree ` +
                            `(${liveBase}), so the tree that would be installed into is not proven ` +
                            `to be the one ComfyUI serves from (#766/#820). Skipping auto-install ` +
                            `rather than installing into a custom_nodes that may never be loaded.`
                        : "The panel appears absent, but the ComfyUI root scanned came from " +
                            "configuration rather than the running server, so it is not proven to be " +
                            "the tree ComfyUI serves from (#766). Skipping auto-install rather than " +
                            "installing into a custom_nodes that may never be loaded.",
                };
            }
        }
        // Final pin check adjacent to the mutation (see runPanelActionInner): the
        // reachability probe and detection above are not instantaneous.
        const latePin = readPinSafe(deps);
        if (latePin.pinned) {
            return {
                action: "skipped",
                reason: `panel version pin in force — ${describePanelPin(latePin)}`,
            };
        }
        assertPinnedTarget(deps, "auto-install", "before the ComfyUI-Manager install");
        await deps.install({ id: PANEL_REGISTRY_ID, version: PANEL_VERSION });
        // #639 — VERIFY it actually landed (fresh re-read); never log "installed"
        // from the Manager result alone (a stale 3.x no-op drains the queue trivially).
        const post = await detectPanelInstall(deps);
        const landed = post.installed && !!post.version;
        // #641 — report a shadow FIRST: a served backup copy explains a wrong panel in
        // the browser whether or not the canonical install landed this run.
        const shadow = describePanelShadow(post.dir, deps);
        if (shadow) {
            return {
                action: "shadowed",
                reason: landed
                    ? `Installed ${PANEL_REGISTRY_ID} (${post.version}) but ${shadow}.`
                    : `Panel auto-install could not be verified on disk (likely a stale ` +
                        `ComfyUI-Manager no-op, #639/#424), AND ${shadow}`,
                dir: post.dir,
                installedVersion: post.version,
                restartRequired: landed ? true : undefined,
            };
        }
        if (!landed) {
            return {
                action: "unavailable",
                reason: `Panel auto-install could not be verified on disk — ComfyUI-Manager ` +
                    `reported the queue drained but the pack is not present. Likely a stale ` +
                    `ComfyUI-Manager 3.x no-op (#639/#424). Install the panel from source or ` +
                    `update ComfyUI-Manager, then restart ComfyUI.`,
            };
        }
        assertPinnedTarget(deps, "auto-install", "before reporting the install");
        return {
            action: "installed",
            reason: `Installed ${PANEL_REGISTRY_ID} (${post.version}).`,
            dir: post.dir,
            installedVersion: post.version,
            restartRequired: true,
        };
    }
    // Present already. We never diff nightly on load (no clean version), so we
    // leave it untouched — the explicit `update` action refreshes on demand. But a
    // #641 shadow copy still mis-serves the panel, so surface it if present.
    const shadow = describePanelShadow(detection.dir, deps);
    if (shadow) {
        return {
            action: "shadowed",
            reason: shadow,
            dir: detection.dir,
            installedVersion: detection.version,
        };
    }
    // #806 — say what was actually determined. This branch proved the pack EXISTS;
    // it did not compare `detection.version` against anything, so it must not imply
    // currency. The reason travels with the result because the result is what gets
    // logged and pasted into bug reports.
    return {
        action: "present",
        reason: `The panel pack is installed${detection.version ? ` (${detection.version})` : ""} and was ` +
            `left untouched — the on-load check only installs a MISSING panel and does not compare ` +
            `versions, so this is not a statement that the panel is current. Run ` +
            `install_comfyui(action:'panel', panel_action:'status') to compare it against what this orchestrator needs, or ` +
            `install_comfyui(action:'panel', panel_action:'update') to pull the latest panel.`,
        dir: detection.dir,
        installedVersion: detection.version,
    };
}
/**
 * The on-load policy engine. LOCAL + reachable only; install-if-missing.
 * Hard-timed-out and swallows every error (returns `unavailable` on failure),
 * so it can be fired-and-forgotten from startup without ever blocking/crashing.
 */
export async function ensurePanelInstalled(opts = {}) {
    const deps = opts.deps ?? defaultDeps;
    try {
        // Serialized with the explicit actions: the on-load ensure must not race an
        // install_comfyui(action:'panel') call the user fired at the same moment. Its lock wait is
        // SHORT — this is fire-and-forget at startup, so if another process holds
        // the lock we give up quickly (returning `unavailable`) rather than eating
        // the whole ensure budget waiting.
        return await withTimeout(
        // Resolve and FREEZE the live base for the unattended install too. This
        // path runs on load with nobody watching, so an unprimed `panelBaseSync()`
        // would fall back to the configured tree and quietly inspect — or install
        // into — a custom_nodes the running server never reads (#766).
        withPanelOpLock(async () => ensureInner(await pinPanelBase(deps)), {
            timeoutMs: ENSURE_LOCK_WAIT_MS,
        }), opts.timeoutMs ?? ENSURE_TIMEOUT_MS);
    }
    catch (err) {
        logger.debug("panel: ensure failed", {
            err: err instanceof Error ? err.message : String(err),
        });
        return {
            action: "unavailable",
            reason: err instanceof Error ? err.message : String(err),
        };
    }
}
/**
 * Repair an interrupted panel swap, under the op lock. Safe to call from any
 * read path that is NOT already holding the lock.
 *
 * A crash between the swap's two renames is the one failure mode that takes the
 * panel AWAY rather than merely misreporting it: custom_nodes ends up with no
 * panel at all, and without this the user could only recover by knowing to
 * invoke another panel mutation, or by moving directories by hand. So the
 * ordinary "what's going on?" call repairs it.
 *
 * The lock is what makes this safe from a read path — it cannot cut across
 * another process's in-flight swap. It is taken only when a journal is actually
 * present AND the canonical directory is missing (both cheap, lock-free checks),
 * so the common case costs nothing; if the lock cannot be had quickly, the swap
 * that wrote the journal is most likely still running, and we leave it alone.
 *
 * Never throws.
 */
export async function repairInterruptedPanelSwap(deps = defaultDeps) {
    // Declared OUTSIDE the try so the catch can still report a repair that
    // already landed on disk before something later threw.
    const performed = {};
    // The root this call actually examined, captured for the catch: a retarget is
    // the likeliest reason we end up there, and re-resolving would describe the
    // NEW install while the swap we found is under the old one.
    const examined = {};
    try {
        const pinned = await pinPanelBase(deps);
        const base = pinned.comfyuiPath();
        examined.base = base;
        if (!base || !pinned.isLocalMode())
            return undefined;
        // Cheap pre-check, no lock: is there anything to look at? Gated on EITHER
        // marker — the in-flight directory is the authority (a journal can be lost
        // while the rename that created it survives), and the journal alone still
        // deserves a report even though it is never grounds to move anything.
        //
        // TRI-STATE, because `existsSync` folds EACCES/EIO into `false`: an
        // UNREADABLE `.incoming` or journal would otherwise read as "there is
        // nothing here" and this whole path would silently no-op on the one state
        // it exists to catch. `isDirectory` and `probeFile` both answer `undefined`
        // for "could not determine", which is neither present nor absent.
        const incoming = join(base, "custom_nodes", PANEL_INCOMING_NAME);
        const journalFile = swapJournalPath(base);
        // `probeFile` is the right probe for this: its FALSE is a determination
        // (ENOENT/ENOTDIR, or "it is a directory"), whereas `isDirectory` answers
        // `undefined` for a merely-absent path and would make every ordinary
        // no-swap-in-flight call look indeterminate.
        const incomingProbe = pinned.probeFile(incoming);
        const journalState = pinned.probeFile(journalFile);
        if (incomingProbe === undefined || journalState === undefined) {
            return (` WARNING: whether an interrupted panel update is present could NOT be determined ` +
                `— "${incoming}" or "${journalFile}" could not be inspected. Nothing has been ` +
                `changed. Check that ${base}/custom_nodes is readable, then re-run ` +
                `${describeInstallPanelAction("status", "a re-check on the ComfyUI host")}.`);
        }
        // Presence of the DIRECTORY still comes from existsSync — probeFile says
        // "not a regular file" for a directory, which is not the same as absent.
        if (!pinned.existsSync(incoming) && journalState !== true)
            return undefined;
        // The note is captured OUTSIDE the lock body so a throw AFTER a successful
        // repair still reports it. The earlier version returned undefined from the
        // catch below, which swallowed the disclosure along with the error — the
        // same defect as hiding a repair behind a failed action, one layer down.
        const locked = await withPanelOpLock(async () => {
            // A PIN IS A PROMISE, and this repair MOVES a directory. "Nothing moves
            // the panel while a pin is in force" has to include a well-intentioned
            // restore — a pinned user who was interrupted gets the state reported
            // (panelStatus says exactly where the copy is), not rearranged for them.
            assertNotPinned("repair", pinned);
            // And the target must still be the one we froze against: a retarget
            // landing here would restore tree A while the caller is asking about B.
            assertPinnedTarget(pinned, "repair", "before restoring the previous panel");
            const outcome = reconcilePanelSwap(base, pinned, { repair: true });
            // Recorded BEFORE the post-assert, because by this point any repair has
            // already happened on disk — if the assert then throws, the user still
            // needs to be told what was moved.
            performed.note = outcome.note;
            assertPinnedTarget(pinned, "repair", "before reporting the restore");
            return outcome.note;
        }, { timeoutMs: ENSURE_LOCK_WAIT_MS });
        return locked;
    }
    catch (err) {
        const detail = err instanceof Error ? err.message : String(err);
        logger.warn(`[panel] interrupted-swap repair did not complete: ${detail}`);
        // A repair that ALREADY HAPPENED is reported even though the call went on to
        // fail. Returning undefined here would hide a change to the user's install
        // behind an unrelated error.
        if (performed.note)
            return performed.note;
        // AND SILENCE IS NOT AN OPTION EITHER. This runs on the path whose entire
        // purpose is to tell a user their panel is missing and where the copy went;
        // swallowing the failure into `undefined` means the caller reports nothing
        // at all, which is the refuse-and-disclose contract failing at precisely the
        // moment it matters. A pre-check already established there was SOMETHING to
        // look at, so reaching here means we could not finish looking — say so.
        // ABSOLUTE PATHS, not directory names. This is the message a user gets when
        // their panel is missing, and "look in custom_nodes" is not something they
        // can act on — the point of disclosing is that they can find the thing.
        //
        // THE PINNED ROOT, not a fresh resolution. The most likely reason we are in
        // this catch is that the target changed mid-repair — which is exactly when
        // `panelBaseSync()` returns the NEW root, so the paths handed to the user
        // would describe install B while the interrupted swap we detected is under
        // install A. A disclosed refusal whose recovery paths point at the wrong
        // machine is the same defect as inventing a location.
        const root = examined.base;
        const incomingPath = root
            ? join(root, "custom_nodes", PANEL_INCOMING_NAME)
            : `<ComfyUI>/custom_nodes/${PANEL_INCOMING_NAME}`;
        const backupRoot = root
            ? join(root, "custom_nodes_backup")
            : "<ComfyUI>/custom_nodes_backup";
        return (` WARNING: an interrupted panel update was detected but could not be examined ` +
            `(${detail}). Nothing has been changed. A staged replacement may be at ` +
            `"${incomingPath}", and your previous panel may be preserved under ` +
            `"${backupRoot}" — if there is a copy there and no panel in custom_nodes, move it ` +
            `back. Then re-run ` +
            `${describeInstallPanelAction("status", "a re-check on the ComfyUI host")}.`);
    }
}
/** status action — never throws. */
export async function panelStatus(depsIn = defaultDeps) {
    // Freeze the base for this whole read: detection, the shadow scan and the
    // reported path must all describe ONE directory (see pinPanelBase).
    const deps = await pinPanelBase(depsIn);
    const detection = await detectPanelInstall(deps).catch(() => ({ applicable: false, installed: false, isDevSymlink: false }));
    let shadows = [];
    let shadowInspectFailed = false;
    if (detection.applicable) {
        try {
            shadows = findPanelShadows(detection.dir, deps);
        }
        catch {
            shadowInspectFailed = true;
        }
    }
    const shadowNote = shadowInspectFailed
        ? ` NOTE (#641): could not enumerate custom_nodes to check for shadowing panel ` +
            `backups — a stray ".comfyui-agent-panel.bak-*" there could shadow the real ` +
            `panel in the browser. Check manually.`
        : shadows.length > 0
            ? ` WARNING (#641): ${shadows.length} shadow copy/copies in custom_nodes (${shadows
                .map((s) => `"${s.name}"`)
                .join(", ")}) are ALSO served as web extensions and may shadow the real ` +
                `panel in the browser (dot-prefixed dirs win by sort order). Remove/move ` +
                `them OUT of custom_nodes, then hard-refresh the ComfyUI tab.`
            : "";
    // #766/#769 — say WHICH ComfyUI root this answer is about, and how it was
    // chosen. Both issues were, at bottom, a status report about a directory the
    // user did not think they were asking about; naming it makes that visible
    // instead of leaving "installed: false" to be read as "the panel is missing".
    const baseResolution = isRealDeps(deps) ? lastPanelBaseResolution() : undefined;
    const comfyuiPath = deps.comfyuiPath();
    const baseSource = baseResolution?.source;
    // A live-derived resolution taken NOW only certifies the tree FROZEN for this
    // read when it names that same tree (#820). The two can diverge without any
    // retarget (a pin-time probe failure froze the configured fallback, a later
    // re-resolution naming the server's real root), and attributing this scan to
    // the resolution's tree anyway would certify the wrong root.
    const resolutionNamesScannedTree = !!baseResolution?.base && samePathCI(comfyuiPath, baseResolution.base, deps);
    // The corroboration verdict for THIS read, computed ONCE here: the recording
    // step below (clearPanelDiskObservation) drops the resolution cache, so any
    // re-read afterwards answers "nothing resolved" — a cache wipe is not
    // evidence the tree was uncorroborated.
    const scannedTreeCorroborated = isLiveDerivedBase(baseResolution) && resolutionNamesScannedTree;
    const baseNote = (() => {
        if (!baseResolution?.base || !resolutionNamesScannedTree)
            return "";
        if (baseResolution.source === "live-base-directory") {
            return (` Resolved against the RUNNING ComfyUI's --base-directory (${baseResolution.base})` +
                (baseResolution.overriddenConfiguredBase
                    ? `, not the configured workspace ` +
                        `(${baseResolution.overriddenConfiguredBase}) — a Comfy Desktop-style split ` +
                        `install keeps custom_nodes under the base directory (#766).`
                    : `.`));
        }
        if (baseResolution.source === "live-argv-root") {
            return (` Resolved from the RUNNING ComfyUI's own install root (${baseResolution.base})` +
                (baseResolution.overriddenConfiguredBase
                    ? `, not the configured workspace (${baseResolution.overriddenConfiguredBase}).`
                    : ` — no COMFYUI_PATH or saved workspace was needed (#769).`));
        }
        if (baseResolution.source === "live-observed-root") {
            return (` Resolved from the RUNNING ComfyUI process itself (${baseResolution.base}): it ` +
                `reported a relative main.py with no working directory (the Comfy Desktop / ` +
                `Windows portable shape), so its install root was re-anchored on the ` +
                `interpreter the OS reports for the process serving this port (#1133).` +
                (baseResolution.overriddenConfiguredBase
                    ? ` This is NOT the configured workspace ` +
                        `(${baseResolution.overriddenConfiguredBase}).`
                    : ``));
        }
        return "";
    })();
    // An interrupted swap is REPORTED here, never repaired: panelStatus holds no
    // lock and is called from everywhere, so moving directories from it could cut
    // across another process's in-flight swap. The mutation paths do the repair.
    const swapNote = comfyuiPath
        ? (reconcilePanelSwap(comfyuiPath, deps, { repair: false }).note ?? "")
        : "";
    let note;
    if (!detection.applicable) {
        note = !deps.isLocalMode()
            ? `Remote/cloud mode — panel install is managed on the ComfyUI host, not from ` +
                `here. ${describePanelUpdateRecovery()}`
            : `Panel management is local-only, and no local ComfyUI could be resolved — ` +
                `neither COMFYUI_PATH, a saved default workspace, nor the running server's ` +
                `own launch arguments yielded a root containing custom_nodes. Set ` +
                `COMFYUI_PATH, or save a default workspace with workspace(action='set_default').`;
    }
    else if (detection.isDevSymlink) {
        note = "dev install (symlink) — managed manually; install/update/reinstall are refused.";
    }
    else if (!detection.installed) {
        // "Not installed" is a claim about a PARTICULAR tree, and it is only as
        // good as our reason for believing that is the tree ComfyUI reads. When the
        // base is merely the configured path — the running server never named it —
        // this is exactly the #766 report: a perfectly good panel sitting in the
        // tree the server actually serves, reported as missing, with an invitation
        // to install a second copy where nothing would load it. Say which tree was
        // scanned and that it is uncorroborated, rather than asserting absence.
        //
        // The same applies, more sharply, when the scan itself did not complete:
        // `installed: false` from a failed enumeration is "we could not look", and
        // inviting an install on that basis could clobber a panel the scan simply
        // missed — the loss `scanReliable` was introduced to prevent.
        // uncorroborated means the resolution does not certify THIS tree — either
        // it is not live-derived at all, or it is live-derived but names a
        // DIFFERENT tree (#820). The two causes are stated separately below:
        // folding them into one message would assert whichever cause it happened
        // to name.
        const liveMismatchBase = isRealDeps(deps) &&
            isLiveDerivedBase(baseResolution) &&
            !resolutionNamesScannedTree
            ? baseResolution?.base
            : undefined;
        const uncorroborated = isRealDeps(deps) && !scannedTreeCorroborated;
        note = swapNote
            ? // An unresolved interrupted swap OWNS this message. Leading with "Not
                // installed — run install" would tell the user to start fresh in the
                // same breath as telling them their previous panel was preserved and
                // exactly how to put it back: two contradictory instructions, the
                // destructive-sounding one first. The swap note carries the full remedy.
                `No panel was FOUND at ${comfyuiPath ?? "custom_nodes"}, and an ` +
                    `unresolved panel update is the likely reason — see the WARNING below BEFORE ` +
                    `installing a fresh copy.`
            : detection.scanReliable === false
                ? `Whether the panel is installed is UNKNOWN: ${comfyuiPath ?? "custom_nodes"}` +
                    `/custom_nodes could not be fully enumerated (or a candidate's pyproject.toml ` +
                    `could not be read), so this is NOT a proven absence and no install is ` +
                    `recommended — one could clobber a panel the scan missed. Make custom_nodes ` +
                    `readable, then re-check.`
                : uncorroborated
                    ? liveMismatchBase
                        ? `No panel found in ${comfyuiPath ?? "custom_nodes"} — and the only live ` +
                            `reading available names a DIFFERENT tree (${liveMismatchBase}), so whether ` +
                            `the running ComfyUI serves from this one is UNCORROBORATED: "not installed" ` +
                            `here may simply be the wrong tree (#766/#820). Check that tree, or ` +
                            `install_comfyui (action:"environment")'s local.workspace_path, before installing — a blind install ` +
                            `here would land in a custom_nodes nothing loads.`
                        : `No panel found in ${comfyuiPath ?? "custom_nodes"} — but this is the ` +
                            `CONFIGURED path, and the running ComfyUI did not confirm it is the tree it ` +
                            `serves from, so "not installed" is UNCORROBORATED. On a split install ` +
                            `(Comfy Desktop's --base-directory, #766) the panel lives elsewhere and ` +
                            `installing here would land in a custom_nodes nothing loads. ` +
                            // #796 — THREE causes, three remedies. The unreadable case used to take
                            // one of the other two branches, and both give advice that cannot work:
                            // the argv was fine (so relaunching with an absolute main.py changes
                            // nothing) and ComfyUI is reachable (so "start/reach ComfyUI" is already
                            // true). Naming a remedy that cannot apply is worse than naming none.
                            (baseResolution?.liveRootUnreadable
                                ? `ComfyUI IS running and DID identify an install root — ` +
                                    `${baseResolution.liveRootUnreadable} — but its custom_nodes could not be ` +
                                    `READ from here (a permission error, an IO error, or a network path that ` +
                                    `is not available to this process), so it could not be confirmed as the ` +
                                    `serving tree. That is not evidence it lacks one. Make that directory ` +
                                    `readable by whoever runs this MCP — or run install_comfyui ` +
                                    `(action:"panel") from a process that can read it — before installing.`
                                : baseResolution?.liveRootUnderivable
                                    ? `ComfyUI IS running, but its reported launch arguments did not identify ` +
                                        `an install root holding custom_nodes (a relative main.py with no ` +
                                        `reported working directory), so reaching it again will not change this — ` +
                                        `check install_comfyui (action:"environment")'s local.workspace_path, or ` +
                                        `relaunch ComfyUI with an ABSOLUTE path to main.py, before installing.`
                                    : `Start/reach ` +
                                        `ComfyUI so its own install root can be read, or check install_comfyui ` +
                                        `(action:"environment")'s local.workspace_path, before installing.`)
                    : `Not installed. Run install_comfyui(action:'panel', panel_action:'install') to add the panel (${PANEL_VERSION}). Restart ComfyUI afterwards.`;
    }
    else {
        note = `Installed${detection.version ? ` (${detection.version})` : ""}. Run install_comfyui(action:'panel', panel_action:'update') to pull the latest ${PANEL_VERSION}. Restart ComfyUI after updating.`;
    }
    // Record what we just READ OFF DISK, so the bridge's write-gate refusal can
    // tell a stale browser BUNDLE from a stale INSTALL. The orchestrator runs the
    // panel sync (and therefore this) on every panel hello, so the observation and
    // the tab's advertised version describe the same moment. Only a real read is
    // recorded; an absent or unreadable pack CLEARS it rather than leaving a stale
    // "your install is fine" behind.
    if (isRealDeps(deps)) {
        // A SHADOW DISQUALIFIES THE READING. The observation exists to answer one
        // question — "is the panel the browser loads already current?" — and a
        // served copy in custom_nodes means the browser is loading something OTHER
        // than the canonical pack whose version we just read (#641). Telling that
        // user "your install is fine, just hard-refresh" is doubly wrong: the
        // refresh re-loads the shadow. An indeterminate scan counts as a shadow,
        // because `shadows: []` from an enumeration that failed is not an all-clear.
        const shadowClean = !shadowInspectFailed && shadows.length === 0;
        if (detection.applicable && detection.installed && detection.version && shadowClean) {
            // Bind the observation to the base that was actually scanned, so it can
            // never be replayed as evidence about a different ComfyUI tree.
            recordPanelDiskObservation(detection.version, detection.dir, comfyuiPath);
        }
        else {
            clearPanelDiskObservation();
        }
    }
    const pin = readPinSafe(deps);
    const pinNote = pin.pinned
        ? ` PIN: ${describePanelPin(pin)} — install/update/reinstall are refused ` +
            `until it is cleared by ${describeInstallPanelAction("unpin", "removing the pin on the ComfyUI host and restarting the orchestrator there")}` +
            (pin.source === "env" ? ` (env pins must be unset in the environment).` : `.`)
        : "";
    return {
        applicable: detection.applicable,
        installed: detection.installed,
        dir: detection.dir,
        installedVersion: detection.version,
        isDevSymlink: detection.isDevSymlink,
        targetVersion: PANEL_VERSION,
        shadows,
        shadowInspectFailed,
        scanReliable: detection.scanReliable,
        comfyuiPath,
        baseSource,
        absenceProven: detection.installed
            ? undefined
            : detection.applicable &&
                detection.scanReliable !== false &&
                (!isRealDeps(deps) || scannedTreeCorroborated),
        pin,
        note: note + baseNote + swapNote + shadowNote + pinNote,
    };
}
/**
 * Read the panel status for a caller-selected LOCAL ComfyUI root.
 *
 * `apply_manifest` may adopt a saved/default/live root for one call while
 * `config.comfyuiPath` remains unset. Verifying through the ordinary default
 * status in that case would inspect no directory (or a different one) and turn
 * a Manager queue result into fabricated panel success. This narrow adapter
 * keeps the status scan — including #641's served-shadow check — on the same
 * root the manifest install targeted without mutating process-global config.
 */
export function panelStatusAt(comfyuiPath) {
    return panelStatus({
        ...defaultDeps,
        isLocalMode: () => true,
        comfyuiPath: () => comfyuiPath,
    });
}
/**
 * Best-effort extraction of ComfyUI-Manager queue counts from a NodeOpResult's
 * raw `details`. Returns `{}` when `details` isn't a manager-http queue status
 * object (e.g. the cm-cli path returns a string). Never throws.
 */
export function readQueueCounts(details) {
    if (!details || typeof details !== "object")
        return {};
    const d = details;
    const num = (v) => (typeof v === "number" ? v : undefined);
    return {
        total: num(d.total_count),
        done: num(d.done_count),
        inProgress: num(d.in_progress_count),
        pending: num(d.pending_count),
        processing: typeof d.is_processing === "boolean" ? d.is_processing : undefined,
    };
}
/**
 * The stale ComfyUI-Manager 3.x silent no-op signature: the queue "drained" but
 * the requested task was never really run. Either nothing was ever enqueued
 * (total_count:0 — the drain check `done >= total` passes trivially) or the
 * counts are incoherent (done > total, impossible in a real queue). These are
 * NEVER produced by a task that actually executed, so they are a safe FAILURE
 * signal (used only to fail closed / sharpen diagnostics, never to claim work).
 */
export function looksLikeManagerNoOp(details) {
    const c = readQueueCounts(details);
    const nothingEnqueued = c.total === 0;
    const incoherent = c.total !== undefined && c.done !== undefined && c.done > c.total;
    return nothingEnqueued || incoherent;
}
/**
 * The PROVEN legacy-3.x empty-queue signature — the ONLY queue state that may
 * authorize the #724 git fallback (codex gate). total_count, done_count,
 * in_progress_count and is_processing are REQUIRED by the Manager's status
 * contract (QueueStatus in node-management): each must be PRESENT and exactly
 * 0/false — a missing field is unproven, never a default-safe zero. The one
 * optional field, pending_count (absent on legacy 3.x), must be 0 when
 * reported. The broader looksLikeManagerNoOp also matches incoherent counts
 * (done > total), which is fine as a FAILURE diagnostic but must never
 * authorize a git mutation: a partial, malformed, or contradictory response
 * is not proof of the stale-3.x no-op.
 */
export function isProvenLegacyEmptyQueue(details) {
    const c = readQueueCounts(details);
    if (!(c.total === 0 && c.done === 0 && c.inProgress === 0 && c.processing === false)) {
        return false;
    }
    // pending_count is optional (absent on legacy 3.x) — but a REPORTED value
    // must be a real numeric 0. A malformed one ("1", null, …) normalizes to
    // undefined in readQueueCounts, which must read as UNPROVEN, not absent.
    if (details && typeof details === "object" && "pending_count" in details) {
        const p = details.pending_count;
        return typeof p === "number" && p === 0;
    }
    return true;
}
/**
 * #824 — the LIVE queue-idle proof. pending_count, in_progress_count and
 * is_processing are the Manager's live work indicators, unlike total/done,
 * which are historical drain counters and can contradict each other (the
 * incoherent done > total signature). All three must be PRESENT and report no
 * queued or running work — a missing field is unproven, never a default-safe
 * zero (the same discipline as isProvenLegacyEmptyQueue). A provably idle
 * queue means no Manager task is in flight against the checkout, so a direct
 * git verification (fetch + compare, then a pinned fast-forward when behind)
 * races nothing the Manager is doing, and its warrant comes from the
 * checkout's own state rather than from any Manager signature.
 */
export function isProvenQuiescentQueue(details) {
    const c = readQueueCounts(details);
    return c.pending === 0 && c.inProgress === 0 && c.processing === false;
}
/**
 * Re-read the Manager's LIVE queue counts. NEVER throws: any failure is
 * `undefined`, which every caller must treat as "idleness could not be
 * determined" — the refusing answer — and never as idle (#824 codex gate).
 */
async function readLiveQueueCounts(deps, base) {
    try {
        if (deps.fetchLiveQueueCounts)
            return await deps.fetchLiveQueueCounts(base);
        const { fetchManagerQueueCounts } = await import("./node-management.js");
        return await fetchManagerQueueCounts(base);
    }
    catch {
        return undefined;
    }
}
/**
 * A missing queue count is normally an unreadable queue and must refuse. The
 * one safe exception is a probe that saw HTTP 404 from BOTH Manager dialects on
 * this same base: there is no Manager queue that could be writing the checkout.
 * Injected test deps without the probe stay fail-closed; production deps carry
 * the real probe through the default dependency set.
 */
async function readLiveQueueObservation(deps, base) {
    const live = await readLiveQueueCounts(deps, base);
    if (live)
        return live;
    try {
        let availability;
        if (deps.probeManagerQueueAvailability) {
            availability = await deps.probeManagerQueueAvailability(base);
        }
        else if (isRealDeps(deps)) {
            availability = await probeManagerQueueAvailabilityOnHost(base);
        }
        else {
            return undefined;
        }
        return availability === "absent" ? { managerAbsent: true } : undefined;
    }
    catch {
        return undefined;
    }
}
/** True only when the probe SUCCEEDED and every live indicator reports clear. */
function isLiveQueueIdle(c) {
    return (!!c &&
        ("managerAbsent" in c ||
            (c.pending === 0 && c.inProgress === 0 && c.processing === false)));
}
/** Render a live probe for a message — including the "no answer at all" case. */
function describeLiveQueue(c) {
    if (c && "managerAbsent" in c) {
        return `ComfyUI-Manager is confirmed absent (both queue/status endpoints returned HTTP 404)`;
    }
    return c
        ? `pending=${c.pending}, in_progress=${c.inProgress}, is_processing=${c.processing}`
        : `the live queue could not be read at all`;
}
/**
 * Turn a queue-history record into a verdict.
 *
 * Deliberately ASYMMETRIC. `status.status_str` is Manager's own structured enum
 * (OperationResult: success | failed | skipped | error | skip) and is the only
 * field classified on. When it is absent — it is Optional in Manager's model,
 * so an older v4 may omit it — a `result` that is EXACTLY the literal "success"
 * or "skip" is still accepted, because those two literals are what Manager's
 * own task_worker compares against to decide the status it would have written.
 * Nothing else is accepted: an unrecognised `result` is free prose, and
 * inferring FAILURE from prose would commit the mirror image of the bug this
 * file exists to prevent — reporting a failure that was never observed.
 * Unrecognised means `unknown`.
 */
export function classifyManagerTaskRecord(record) {
    // `undefined` = we could not ask, or could not trust the answer.
    if (record === undefined)
        return { kind: "unknown" };
    // `null` = the Manager PROVED it holds no completed task under that id.
    if (record === null)
        return { kind: "never-ran" };
    const status = record.statusStr?.trim().toLowerCase();
    const result = record.result?.trim();
    if (status === "success" || status === "skip" || status === "skipped") {
        return { kind: "succeeded", result: result || status };
    }
    if (status === "failed" || status === "error") {
        // The prose is the only place the CAUSE lives, so it is quoted — but the
        // decision to call this a failure came from the structured field.
        return { kind: "failed", result: result || status };
    }
    if (status === undefined && (result === "success" || result === "skip")) {
        return { kind: "succeeded", result };
    }
    return { kind: "unknown" };
}
/**
 * The Manager-side clause that every "nothing moved" message narrates around.
 *
 * It states what was OBSERVED and nothing more. The stale-3.x cause is asserted
 * only where it is actually proven (`provenLegacyEmptyQueue`); everywhere else
 * the counters are reported as the drain counters they are, and the sentence
 * ends in an honest "could not be determined" plus the way to determine it.
 */
export function describeManagerTaskVerdict(verdict, counts, provenLegacyEmptyQueue) {
    // Manager's `result` is whatever the failing handler produced, and on an
    // unhandled exception that is a full Python repr — one measured here ran to
    // several hundred characters of QueueTaskItem(...). Quoting it whole turns a
    // diagnostic into a wall. Clamp it and SAY it is clamped, with the log named
    // as the place the rest lives: a silently truncated error message is its own
    // small dishonesty.
    const quote = (v) => v.length <= 400
        ? v
        : `${v.slice(0, 400)}… (truncated — the full text is in the ComfyUI server log)`;
    const c = `total_count=${counts.total ?? "?"}, done_count=${counts.done ?? "?"}, ` +
        `pending_count=${counts.pending ?? "?"}, in_progress_count=${counts.inProgress ?? "?"}`;
    switch (verdict.kind) {
        case "failed":
            return (`ComfyUI-Manager RAN the task and it FAILED — its own per-task record ` +
                `reports: "${quote(verdict.result)}". The queue drained afterwards (${c}), ` +
                `which ` +
                `is why the drain on its own looked like a success`);
        case "succeeded":
            return (`ComfyUI-Manager recorded this task as "${quote(verdict.result)}", yet ` +
                `nothing ` +
                `moved on disk (${c}) — the task ran, and whether it changed anything ` +
                `could NOT be established`);
        case "never-ran":
            return (`ComfyUI-Manager holds NO completed task under the id this operation was ` +
                `enqueued with, so it never ran (${c})`);
        default:
            return provenLegacyEmptyQueue
                ? `ComfyUI-Manager has no record of running ANY task: every queue counter ` +
                    `is zero (${c}). On a legacy ComfyUI-Manager 3.x that is the known silent ` +
                    `no-op (#639/#424) — the enqueue is accepted and nothing runs`
                : `ComfyUI-Manager's queue drained (${c}) and what THIS task did could NOT ` +
                    `be established. Those are queue-WIDE drain counters, not a per-task ` +
                    `result: on ComfyUI-Manager v4 an idle queue always reports ` +
                    `total_count=0 with done_count as the whole session's history length, so ` +
                    `they read identically whether the task succeeded or failed. The record ` +
                    `that would say (GET /v2/manager/queue/history?ui_id=...) could not be ` +
                    `read here, and ComfyUI-Manager older than v4 does not keep one at all`;
    }
}
/**
 * The remedy half of the message, chosen by what was OBSERVED rather than by a
 * single assumed cause. Sending a user to "update ComfyUI-Manager, it is stale"
 * when the Manager in fact ran the task and reported an error is what made
 * #1999 unfixable from the report: the advice named a cause the evidence did
 * not support, and the evidence that did exist was never read.
 */
export function describeManagerTaskRemedy(verdict, dir) {
    const gitPull = `update the panel repo manually (git pull in ${dir ?? "the panel's custom_nodes dir"})`;
    switch (verdict.kind) {
        case "failed":
            return (`Fix what ComfyUI-Manager reported — its full traceback is in the ComfyUI ` +
                `server log next to that message — then retry. If the pack is a git ` +
                `checkout, ${gitPull} is the direct route, and a checkout with local ` +
                `modifications must be committed or stashed first. Then RESTART ComfyUI.`);
        case "succeeded":
            return (`RESTART ComfyUI and re-check the version with ` +
                `${describeInstallPanelAction("status", "a re-check on the ComfyUI host")}: ` +
                `ComfyUI-Manager cannot swap a directory the running server is serving, so ` +
                `it may have reserved the switch for the next startup. Do NOT reinstall ` +
                `from source before checking that.`);
        default:
            return (`Check the ComfyUI server log for what ComfyUI-Manager did with the task, ` +
                `and confirm what is on disk with ` +
                `${describeInstallPanelAction("status", "a re-check on the ComfyUI host")}. ` +
                `Then either update ComfyUI-Manager on the host (pip install -U ` +
                `comfyui_manager, or git pull in custom_nodes/ComfyUI-Manager) and retry, ` +
                `or ${gitPull}. Then RESTART ComfyUI.`);
    }
}
/**
 * Ask ComfyUI-Manager what OUR task did. NEVER throws: any failure — and any
 * answer that cannot be correlated — is `{kind:"unknown"}`, which callers
 * report as an unknown outcome and never as either verdict.
 *
 * A missing `uiId` is `unknown` too, never `never-ran`: a path that did not
 * capture an id has proved nothing about whether a task ran.
 */
async function readManagerTaskVerdict(deps, uiId, base) {
    if (!uiId)
        return { kind: "unknown" };
    try {
        if (deps.fetchManagerTaskRecord) {
            return classifyManagerTaskRecord(await deps.fetchManagerTaskRecord(uiId, base));
        }
        const { fetchManagerTaskHistoryEntry } = await import("./node-management.js");
        return classifyManagerTaskRecord(await fetchManagerTaskHistoryEntry(uiId, base));
    }
    catch {
        return { kind: "unknown" };
    }
}
/**
 * Decide whether an `update` actually advanced the panel on disk. Heart of the
 * #639 fix.
 *
 * SUCCESS REQUIRES PROVEN MOVEMENT. We compare the pre/post ON-DISK identity —
 * the pyproject version AND the git-HEAD commit sha (post RE-READ fresh from
 * disk, never cached) — and only report `updated` when one of them actually
 * MOVED. The panel tracks the `nightly` (git-HEAD) channel, so a commit can
 * advance WITHOUT a version bump; a moved sha therefore also counts as updated.
 *
 * Crucially, an UNCHANGED local git-HEAD is NOT proof of being current: it only
 * proves nothing was pulled locally — which is exactly the #639 no-op. Local
 * HEAD says nothing about the upstream tip, so we never treat "HEAD unchanged"
 * as success. When nothing moved we fail closed: `no-op` when the Manager counts
 * show the stale-3.x signature (total_count:0, or the incoherent done>total), and
 * `unverified` otherwise. The queue counts are queue-WIDE drain counters (not
 * correlated to this task), so they are used ONLY to sharpen the FAILURE
 * diagnostic — NEVER as positive proof that work happened.
 */
export function classifyPanelUpdate(identity, details) {
    const { previousVersion, installedVersion, previousRev, installedRev } = identity;
    const counts = readQueueCounts(details);
    const base = { ...identity, counts };
    // Can't read ANY post-update identity → cannot confirm anything landed.
    if (!installedVersion && !installedRev) {
        return { ...base, outcome: "unverified" };
    }
    // MOVED IS NOT THE SAME AS FORWARD. Direction is decided HERE, in the
    // classifier, rather than at one of the call sites: this is the single place
    // a version PAIR becomes a verdict, so a guard anywhere else covers only the
    // branch it sits on. A Manager that resolves an older build and lands it
    // would otherwise be classified "updated" and reported as
    // "Panel updated (0.11.40 → 0.11.38)" — the user congratulated for being
    // downgraded. Only a READABLE, COMPARABLE regression counts; `compareSemver`
    // returns 0 for anything it cannot parse, so it is never trusted alone.
    const comparableRegression = !!previousVersion &&
        !!installedVersion &&
        SEMVER_RE.test(previousVersion.trim()) &&
        SEMVER_RE.test(installedVersion.trim()) &&
        compareSemver(installedVersion, previousVersion) < 0;
    if (comparableRegression)
        return { ...base, outcome: "downgraded" };
    // Something moved on disk (version bump OR git-HEAD advance) → update applied.
    const versionMoved = !!previousVersion && !!installedVersion && installedVersion !== previousVersion;
    const revMoved = !!previousRev && !!installedRev && installedRev !== previousRev;
    // A CHANGED VERSION WE CANNOT COMPARE IS NOT A KNOWN-FORWARD ONE. The
    // regression check above needs both sides to parse, so a pair like
    // "0.11.34" -> "nightly" (or any unparseable string) slipped past it and was
    // then classified "updated" purely because the strings differ — announcing a
    // direction that was never established. It moved; which way is unknown, and
    // that is exactly the "could not determine" this file refuses to round up.
    //
    // A git-HEAD advance with an UNCHANGED version stays a legitimate update:
    // that is the nightly channel working normally, and nothing about it is
    // ambiguous.
    const versionComparable = !!previousVersion &&
        !!installedVersion &&
        SEMVER_RE.test(previousVersion.trim()) &&
        SEMVER_RE.test(installedVersion.trim());
    if (versionMoved && !versionComparable) {
        return { ...base, outcome: "moved-unknown-direction" };
    }
    if (versionMoved || revMoved)
        return { ...base, outcome: "updated" };
    // Nothing provably moved. Use the Manager counts ONLY to name the failure:
    // the stale-3.x no-op signature → the reported no-op; otherwise we simply
    // couldn't confirm.
    if (looksLikeManagerNoOp(details))
        return { ...base, outcome: "no-op" };
    return { ...base, outcome: "unverified" };
}
/*
 * REFUSE vs DISCLOSE — they are not two severities of the same thing, and
 * picking the wrong one is its own defect. The distinction is WHETHER THE
 * ACTION HAS ALREADY HAPPENED:
 *
 *   REFUSE   when the action has NOT yet happened and proceeding without
 *            certainty would be unsafe. A refusal PREVENTS something. Every
 *            pre-mutation guard in this file is of this kind: unproven absence,
 *            an uncorroborated tree, an unverifiable staged copy, a pin.
 *
 *   DISCLOSE when the action is ALREADY COMPLETE and only its CHARACTERISATION
 *            is uncertain. There is nothing left to prevent; the only remaining
 *            duty is to describe honestly.
 *
 * Reporting a failure for work that succeeded is a fabrication too — the mirror
 * image of the fabricated-success bug this file exists to prevent, and here the
 * worse of the two: an error invites the user to RE-RUN a destructive swap in
 * order to fix something that already worked, turning an honest uncertainty into
 * an actual risk.
 *
 * That is why `moved-unknown-direction` below returns rather than throws. The
 * bytes are on disk; what was wrong was never the reporting, it was the
 * unqualified claim "Panel updated". And note the sync layer already expresses
 * this state correctly one level up (`stillBehind: null` + "could NOT be
 * confirmed") — replacing a working could-not-determine with a false definite
 * is a regression whichever definite you pick.
 */
/** Turn an update verdict into an honest result — or throw when it did not apply.
 *
 * `taskVerdict` (#1999) is what ComfyUI-Manager recorded for the EXACT task this
 * update enqueued, where that could be established. It is the only evidence that
 * can explain a drained queue which moved nothing, so both fail-closed branches
 * narrate it instead of asserting a cause from the queue-wide counters. Callers
 * that could not correlate a task pass `{kind:"unknown"}`, which reports the
 * outcome as undetermined and says how to determine it — never as a failure and
 * never as a success. */
function finalizeUpdate(verdict, post, result, taskVerdict = { kind: "unknown" }) {
    const { outcome, previousVersion, installedVersion, counts } = verdict;
    const dirNote = post.dir ? ` at ${post.dir}` : "";
    const managerSaid = () => describeManagerTaskVerdict(taskVerdict, counts, isProvenLegacyEmptyQueue(result.details));
    if (outcome === "updated") {
        const from = verdict.previousVersion ?? verdict.previousRev?.slice(0, 8) ?? "?";
        const to = verdict.installedVersion ?? verdict.installedRev?.slice(0, 8) ?? "?";
        return {
            action: "update",
            result,
            restartRequired: true,
            message: `Panel updated (${from} → ${to}) via ComfyUI-Manager (${PANEL_VERSION}). ` +
                `RESTART ComfyUI to load the updated panel node.`,
            previousVersion,
            installedVersion,
        };
    }
    // The version changed, but not in a direction we could establish.
    //
    // DISCLOSED, NOT REFUSED — deliberately. The change really happened and is on
    // disk, so throwing would report a failure for work that was done: the user
    // would not be told to restart, and a retry would re-run the whole swap. The
    // defect being fixed is the unqualified claim "Panel updated", not the fact
    // of reporting. So this returns, and the message says exactly what is known —
    // it CHANGED, from what to what, and that the direction is unknown. That is
    // the same shape as the sync layer's `stillBehind: null`, which already
    // handles this honestly one level up.
    if (outcome === "moved-unknown-direction") {
        const message = `Panel CHANGED${dirNote}: "${previousVersion ?? "unknown"}" → ` +
            `"${installedVersion ?? "unreadable"}". At least one of those is not a comparable ` +
            `version number, so whether this moved FORWARD or BACKWARD could NOT be ` +
            `established — this is not being reported as an upgrade. RESTART ComfyUI to load ` +
            `what is now on disk, then check the version with ` +
            `${describeInstallPanelAction("status", "a re-check on the ComfyUI host")}.`;
        return {
            action: "update",
            result: { ...result, message },
            restartRequired: true,
            message,
            previousVersion,
            installedVersion,
        };
    }
    // The pack moved BACKWARDS. It applied, so this is not a no-op — but it is
    // certainly not an update, and reporting it as one would leave the user on an
    // older panel than they started with, told they had just upgraded.
    if (outcome === "downgraded") {
        throw new PanelInstallError(`Panel update did NOT go forward: the pack${dirNote} is now ` +
            `${installedVersion ?? "unreadable"}, which is OLDER than the ` +
            `${previousVersion ?? "unknown"} that was installed before. ComfyUI-Manager ` +
            `resolved and applied an earlier build. NOT reporting this as an update — you ` +
            `are now on an older panel than you started with. Update ComfyUI-Manager on ` +
            `the host (git pull in custom_nodes/ComfyUI-Manager, or pip install -U ` +
            `comfyui_manager) and retry, or reinstall the panel from source, then RESTART ` +
            `ComfyUI.`);
    }
    // Nothing provably moved → NEVER report success. An unchanged local git-HEAD /
    // version cannot distinguish "already at the upstream tip" from "the update
    // silently no-op'd", so we fail closed with an honest, actionable diagnostic.
    if (outcome === "no-op") {
        throw new PanelInstallError(`Panel update did NOT apply: nothing changed on disk${dirNote} (installed ` +
            `version still ${previousVersion ?? "unknown"}). ${managerSaid()}. NOT ` +
            `reporting success. ${describeManagerTaskRemedy(taskVerdict, post.dir)}`);
    }
    // unverified — no proof it advanced, and no clear no-op signature either.
    throw new PanelInstallError(`Could not verify the panel update applied: the installed version ` +
        `(${installedVersion ?? "unreadable"}) and git-HEAD did not change` +
        `${dirNote}. ${managerSaid()}. NOT reporting success — an unchanged checkout ` +
        `cannot prove you are at the latest nightly versus a silent no-op, and you ` +
        `may already be current. ${describeManagerTaskRemedy(taskVerdict, post.dir)}`);
}
/**
 * The #724/#824 fallback for the update path: the Manager produced no usable
 * verdict — either it provably no-op'd the update (the stale legacy-3.x
 * signature) or its drained-queue counts are incoherent yet provably idle
 * (#824) — and the panel dir is a REAL git checkout. Update it (or prove it
 * already current) without the Manager, exactly like the manual workaround
 * from the issues (`git pull --ff-only` in the panel repo), then run
 * the SAME #639 verification over the result (fresh on-disk re-read, #641
 * shadow scan, proven movement) rather than trusting git's output text.
 * Every message narrates the caller-supplied `managerReason`, so the cause
 * asserted is the one the caller actually proved — never a guessed one.
 *
 * Two refusals guard the pull itself:
 *  - DIRTY WORKTREE: a fast-forward only refuses local edits it OVERLAPS, so
 *    the checkout must be PROVEN clean (git status --porcelain) before we
 *    mutate it. Anything else — or a cleanliness check that itself fails —
 *    refuses the fallback rather than carry/clobber local state.
 *  - DIRECTORY BINDING: `dir` is the checkout the pre-op git proof belongs to
 *    (enforced by the caller). The post-pull verification must describe that
 *    SAME directory; a re-detection landing elsewhere makes the pre/post
 *    comparison meaningless and is refused.
 *
 * Success still REQUIRES proven movement on disk. The one new honest outcome
 * is "already current": a pinned `git merge --ff-only` fast-forwarded to the fetched remote and
 * found HEAD not behind — proof of currency the Manager queue counts could
 * never give — so that reports "already up to date" (NOT "updated"; nothing
 * changed, no restart needed). A failed pull throws with BOTH diagnostics; it
 * never reads as "nothing to pull".
 */
/**
 * The corroboration refusal shared by BOTH direct fallbacks.
 *
 * A "direct" fallback bypasses ComfyUI-Manager and mutates a directory this
 * process picked. That is only defensible when the running server itself named
 * the tree — otherwise, on a Comfy Desktop split install, we would fast-forward
 * (or replace) a dormant checkout and report a verified success while the
 * browser's panel never moved.
 */
function assertSwapTreeCorroborated(deps, comfyuiPath, what, managerReason) {
    // An injected dep set declared its own base; nothing to corroborate against.
    if (!isRealDeps(deps))
        return;
    // ONE read for the verdict AND the message: a cache expiry between two
    // reads would narrate a different-tree refusal as "the CONFIGURED path".
    const resolution = lastPanelBaseResolution();
    if (liveResolutionNamesTree(deps, pinnedBaseOf(deps) ?? deps.comfyuiPath(), resolution)) {
        return;
    }
    // "Not corroborated" covers two different situations — no live-derived
    // reading at all, or a live reading that names ANOTHER tree (#820) — and
    // only the second tells the user where their served panel actually lives.
    // Asserting "this is the CONFIGURED path" for both would mislabel the
    // mismatch case, so name it when it is the cause.
    const liveBase = isLiveDerivedBase(resolution) ? resolution?.base : undefined;
    const mismatch = liveBase && !samePathCI(comfyuiPath, liveBase, deps) ? liveBase : undefined;
    if (mismatch) {
        throw new PanelInstallError(`Panel update did NOT apply (${managerReason}), and ${what} is REFUSED: the ` +
            `running ComfyUI's own launch arguments name a DIFFERENT tree (${mismatch}) ` +
            `than ${comfyuiPath}, so updating here could move a copy nobody serves while ` +
            `the browser keeps loading the real one — reported as a verified success. ` +
            `Check which install this orchestrator is targeting (COMFYUI_PATH / saved ` +
            `workspace), then retry. ${describePanelUpdateRecovery()}`);
    }
    throw new PanelInstallError(`Panel update did NOT apply (${managerReason}), and ${what} is REFUSED: the ` +
        (resolution?.baseDirUnresolvable
            ? // The server named a --base-directory we cannot resolve: it is
                // RELATIVE and no working directory was reported. Every root we could
                // otherwise derive (the install root, the observed process root) is a
                // tree that flag has already overridden, so none of them may license a
                // swap. Note the remedy is NOT the absolute-main.py one below — the
                // script path is not what failed to resolve here.
                `running ComfyUI answered with a --base-directory we cannot resolve (it is a ` +
                    `RELATIVE path and the server reported no working directory), and that flag — ` +
                    `not the main.py location — is where ComfyUI derives custom_nodes from. So ` +
                    `${comfyuiPath} cannot be confirmed as the tree it serves, and neither can any ` +
                    `root derived from its launch arguments: the flag overrides them. Updating here ` +
                    `could move a copy nobody serves while the browser keeps loading the real one — ` +
                    `reported as a verified success. Relaunch ComfyUI with an ABSOLUTE ` +
                    `--base-directory (or update the pack by hand under the directory that flag ` +
                    `resolves to), then retry. ${describePanelUpdateRecovery()}`
            : resolution?.liveRootUnderivable
                ? // The server ANSWERED but its launch arguments yielded no install
                    // root holding custom_nodes (#890/#916 — the common `python main.py`
                    // / portable-launcher shape reports a relative argv and no cwd). The
                    // "start ComfyUI" remedy is dead here: it is already running. What
                    // works is updating the pack by hand where it lies, or relaunching
                    // in a way that reports an absolute main.py.
                    `running ComfyUI answered but its reported launch arguments did not identify ` +
                        `an install root holding custom_nodes (a relative main.py and no reported ` +
                        `working directory cannot be resolved), so it could not confirm that ` +
                        `${comfyuiPath} is the tree it serves from — this is the CONFIGURED path ` +
                        `rather than a corroborated one. On a split install (Comfy Desktop's ` +
                        `--base-directory, #766) those differ, and updating the panel here could ` +
                        `move a copy nobody serves while the browser keeps loading the real one — ` +
                        `reported as a verified success. Two ways forward: update the pack by hand ` +
                        `where it is installed (if ${comfyuiPath}/custom_nodes/${PANEL_REGISTRY_ID} ` +
                        `is a git checkout: git -C "${comfyuiPath}/custom_nodes/${PANEL_REGISTRY_ID}" ` +
                        `pull --ff-only, then restart ComfyUI and hard-refresh the tab), or relaunch ` +
                        `ComfyUI with an ABSOLUTE path to main.py so its root can be corroborated, ` +
                        `then retry. ${describePanelUpdateRecovery()}`
                : `running ComfyUI did not confirm that ${comfyuiPath} is the tree it serves from, ` +
                    `so this is the CONFIGURED path rather than a corroborated one. On a split ` +
                    `install (Comfy Desktop's --base-directory, #766) those differ, and updating the ` +
                    `panel here could move a copy nobody serves while the browser keeps loading the ` +
                    `real one — reported as a verified success. Start ComfyUI so its install root can ` +
                    `be read, then retry. ${describePanelUpdateRecovery()}`));
}
/**
 * #2671 — re-map an ownership refusal onto its real cause, wherever in the
 * fallback it surfaced.
 *
 * Deliberately wrapped at the FUNCTION boundary rather than patched into the
 * gate that happens to run first today. Every git call in the body refuses
 * identically under this condition, so which gate reports it is an artefact of
 * gate ORDER; pinning the classification to one of them would silently stop
 * working the day the gates are reordered. Non-ownership failures fall through
 * untouched — their own gate-specific diagnosis is the right one.
 */
async function updateViaGitCheckoutFallback(opts) {
    try {
        return await updateViaGitCheckoutFallbackInner(opts);
    }
    catch (err) {
        const raw = err instanceof Error ? err.message : String(err);
        if (!isGitOwnershipRefusal(raw, opts.dir))
            throw err;
        throw new PanelInstallError(gitOwnershipRefusalMessage(opts.dir, opts.managerReason));
    }
}
async function updateViaGitCheckoutFallbackInner(opts) {
    const { deps, dir, previousVersion, previousRev, result, managerReason, managerBase, assertSameTarget, } = opts;
    // PRE-PULL REVISION GATE — the post-Manager detection's HEAD must be
    // readable before we mutate. An unreadable revision at this point means the
    // no-op verdict itself is unprovable (a head-only Manager advance looks
    // identical), so firing a pull would mutate on a false premise and then
    // misreport git-applied work or a tip it cannot see (codex gate). Fail
    // closed BEFORE any mutation, never pull.
    const prePullRev = deps.gitRevision(dir);
    if (!prePullRev) {
        throw new PanelInstallError(`Panel update did NOT apply: ComfyUI-Manager reported the queue drained ` +
            `but the panel checkout's HEAD revision is unreadable at ${dir}, so ` +
            `whether it updated (a nightly advance with no version bump) is ` +
            `UNVERIFIABLE — the git fallback does not fire on an unprovable no-op. ` +
            `Check the panel repo (git log) and ComfyUI-Manager on the host, then ` +
            `re-check install_comfyui(action:'panel', panel_action:'status').`);
    }
    // WORKTREE-ROOT GATE — `.git` proves metadata exists, not that this dir is
    // the checkout ROOT. A copied/stale gitdir would let status/pull mutate a
    // sibling repo and credit the panel with that repo's moved HEAD. Require the
    // resolved toplevel to BE the panel dir (same path identity as the #641
    // shadow code). Any failure here is "cannot prove", which refuses.
    let worktreeRoot;
    try {
        worktreeRoot = deps.gitWorktreeRoot(dir);
    }
    catch (err) {
        throw new PanelInstallError(`Panel update did NOT apply: ${managerReason}, and the git fallback is ` +
            `REFUSED: could not prove ${dir} is the panel repo's worktree root (git ` +
            `rev-parse failed — ${err instanceof Error ? err.message : String(err)}). ` +
            `Update the panel repo manually, then RESTART ComfyUI.`);
    }
    if (!samePathCI(worktreeRoot, dir, deps)) {
        throw new PanelInstallError(`Refusing the panel update git fallback: the git worktree root resolves to ` +
            `${worktreeRoot}, NOT the panel directory ${dir} — status/pull would ` +
            `mutate a DIFFERENT checkout than the one we verified (a copied or stale ` +
            `gitdir pointer). Nothing was done; check how the panel dir's .git got ` +
            `there, then update the correct repo manually and RESTART ComfyUI.`);
    }
    // CLEANLINESS GATE — never mutate a dirty checkout. A porcelain failure is
    // "cannot prove clean", which refuses exactly like a dirty one.
    let porcelain;
    try {
        porcelain = deps.gitStatusPorcelain(dir).trim();
    }
    catch (err) {
        throw new PanelInstallError(`Panel update did NOT apply: ${managerReason}, and the git fallback is ` +
            `REFUSED: could not confirm the panel repo at ${dir} is clean (git ` +
            `status failed — ${err instanceof Error ? err.message : String(err)}). ` +
            `Update the panel repo manually (git pull in ${dir}), then RESTART ComfyUI.`);
    }
    if (porcelain !== "") {
        throw new PanelInstallError(`Refusing the panel update git fallback: the panel repo at ${dir} has ` +
            `UNCOMMITTED changes or untracked files (git status --porcelain):\n` +
            `${porcelain}\ncomfyui-mcp never mutates a dirty checkout — a ` +
            `fast-forward could carry or clobber that local state. Commit, stash, or ` +
            `discard the changes (or update the panel repo manually), then retry. ` +
            // #1999 — this used to end "so updating ComfyUI-Manager on the host
            // restores that path", which asserts that a stale Manager is the cause.
            // It sometimes is; when the Manager in fact RAN the task and reported an
            // error, upgrading it fixes nothing and sends the user down a dead end.
            // State the Manager's own reason and let it name its own remedy.
            `(The Manager path also produced no update here — ${managerReason}.)`);
    }
    // PINNED FETCH — fetch ONCE, then bind every later proof and the mutation
    // itself to the fetched upstream sha. A `git pull` would fetch a SECOND
    // time; a commit landing between the two fetches could newly track an
    // ignored local path and slip past the collision proof into the merge
    // (codex gate). The rev we inspect is the rev we merge — no race.
    let targetRev;
    try {
        deps.gitFetch(dir);
        targetRev = deps.gitUpstreamRev(dir);
    }
    catch (err) {
        throw new PanelInstallError(`Panel update did NOT apply: ${managerReason}, and the git fallback is ` +
            `REFUSED: could not fetch/resolve the upstream revision for ${dir} ` +
            `(${err instanceof Error ? err.message : String(err)}). Update the ` +
            `panel repo manually (git pull in ${dir}), then RESTART ComfyUI.`);
    }
    // IGNORED-FILE GATE — porcelain omits ignored files, but a fast-forward does
    // NOT protect them: when the remote newly tracks a path that is locally
    // ignored, the merge silently overwrites the local file (codex gate). Refuse
    // on any proven collision against the PINNED rev; a proof failure also
    // refuses (fail closed).
    let ignoredConflicts;
    try {
        ignoredConflicts = deps.gitIgnoredPullConflicts(dir, targetRev);
    }
    catch (err) {
        throw new PanelInstallError(`Panel update did NOT apply: ${managerReason}, and the git fallback is ` +
            `REFUSED: could not prove the pull would not silently overwrite ignored ` +
            `local files in ${dir} (${err instanceof Error ? err.message : String(err)}). ` +
            `Update the panel repo manually (git pull in ${dir}), then RESTART ComfyUI.`);
    }
    if (ignoredConflicts.length > 0) {
        const shown = ignoredConflicts.slice(0, 5).join(", ");
        const more = ignoredConflicts.length > 5 ? ` (+${ignoredConflicts.length - 5} more)` : "";
        throw new PanelInstallError(`Refusing the panel update git fallback: ${ignoredConflicts.length} locally-IGNORED ` +
            `file(s) in ${dir} would be SILENTLY OVERWRITTEN by the fast-forward ` +
            `(git protects untracked files from checkout, but not ignored ones): ` +
            `${shown}${more}. Move or delete them (or git add them), then retry — ` +
            `or update the panel repo manually. (The Manager path produced no ` +
            `update here — ${managerReason}.)`);
    }
    // ---- CONCURRENT-WRITE BRACKET (#824 codex gate) -------------------------
    // Everything above proves things about a MOMENT: the queue counts came back
    // with the update call, `git status` ran several awaits ago. None of that
    // says "nothing is writing to this tree NOW", and git's index lock does not
    // hold back an arbitrary ComfyUI-Manager write (a pip install, a file copy,
    // an unpack) — only another git process. So bracket the mutation:
    //
    //   1. RE-PROBE the live queue, so the idleness that licenses the merge is
    //      milliseconds old rather than however long the Manager call took. An
    //      unreadable probe is "cannot determine", which REFUSES — never idle.
    //   2. COMPARE-AND-SWAP the tree itself: HEAD and the worktree must still be
    //      EXACTLY what the gates above inspected. A writer that never touches
    //      the queue is caught here and nowhere else.
    //   3. After the merge, re-read all three (below), so a write that landed
    //      INSIDE the window is DISCLOSED rather than reported as a clean update.
    //
    // What this is NOT: mutual exclusion. ComfyUI-Manager is a separate,
    // third-party process with no lease or lock protocol we could join, so
    // nothing here can PREVENT a concurrent write — it can only make the
    // unobserved window as small as an out-of-process check can be, and turn a
    // write that lands anywhere near it into a disclosure instead of a silent
    // success. Two residues are known and deliberately left:
    //   - a Manager task that both STARTS and FINISHES inside the merge, leaving
    //     a tracked tree that still matches the pinned rev, is undetectable from
    //     here by construction;
    //   - `git status --porcelain` omits IGNORED paths, so a write confined to
    //     one is invisible to the CAS. What bounds the damage there is the pinned
    //     ignored-collision gate above: the merge's file set is fixed at
    //     `targetRev` and was already proven not to overlap an ignored local
    //     file, so such a write is not clobbered by this merge.
    // Neither residue is asserted away in any message we emit; nothing below
    // claims the window was exclusive, only what was actually observed in it.
    //
    // Each pre-merge check refuses BEFORE any mutation, so its own failure is
    // free — the guard cannot itself damage the tree it is protecting.
    {
        const live = await readLiveQueueObservation(deps, managerBase);
        if (!isLiveQueueIdle(live)) {
            throw new PanelInstallError(`Panel update did NOT apply: ${managerReason}, and the git fallback is ` +
                `REFUSED: the queue counts that authorized it were a snapshot, so ` +
                `ComfyUI-Manager was re-probed immediately before the merge — and ` +
                `${describeLiveQueue(live)}. That does not exclude a Manager task ` +
                `writing to ${dir} while a git merge --ff-only rewrites it, and git's ` +
                `index lock does not hold back a non-git writer. NO git mutation was ` +
                `made — though whether the Manager call itself changed anything is ` +
                `precisely what its own status could not say. Wait for the Manager ` +
                `queue to drain and retry, or update the panel repo manually (git ` +
                `pull in ${dir}), then RESTART ComfyUI.`);
        }
    }
    let casRev;
    let casPorcelain;
    try {
        casRev = deps.gitRevision(dir);
        casPorcelain = deps.gitStatusPorcelain(dir).trim();
    }
    catch (err) {
        throw new PanelInstallError(`Panel update did NOT apply: ${managerReason}, and the git fallback is ` +
            `REFUSED: the panel checkout at ${dir} could not be re-confirmed ` +
            `unchanged immediately before the merge (${err instanceof Error ? err.message : String(err)}), ` +
            `so nothing rules out a concurrent write. NO git mutation was made — ` +
            `though whether the Manager call itself changed anything is precisely ` +
            `what its own status could not say. Update the panel repo manually ` +
            `(git pull in ${dir}), then RESTART ComfyUI.`);
    }
    // An ABSENT revision is its own answer. `gitRevision` reports an unresolvable
    // HEAD as undefined rather than by throwing, so folding it into the
    // "HEAD moved" arm would narrate "could not determine what HEAD is" as
    // "determined HEAD changed" — the defect this file keeps being fixed for.
    // Both arms REFUSE (that is right: this is before the mutation, and an
    // unverifiable tree does not license a merge), but only the arm with two
    // readable, DIFFERENT revisions may say something changed.
    //
    // A DIRTY worktree is answered first, because it is established evidence in
    // its own right and does not depend on HEAD resolving: the cleanliness gate
    // proved this tree clean, so a non-empty porcelain here IS proof it changed.
    // Only over a still-clean worktree does an unreadable HEAD mean "the
    // comparison could not be made" — saying "not proof that anything changed"
    // while holding a dirty status would be the mirror defect (codex gate r4).
    if (casPorcelain === "" && !casRev) {
        throw new PanelInstallError(`Panel update did NOT apply: ${managerReason}, and the git fallback is ` +
            `REFUSED: the panel checkout at ${dir} has an UNREADABLE HEAD (git ` +
            `resolved no revision at all) ` +
            `immediately before the merge, so whether it is still the tree the safety ` +
            `gates inspected (${prePullRev.slice(0, 8)}) could NOT be determined — that ` +
            `is unknown, NOT proof that anything changed. NO git mutation was made — ` +
            `though whether the Manager call itself changed anything is precisely what ` +
            `its own status could not say. Check the panel repo (git status / git log), ` +
            `then re-check install_comfyui(action:'panel', panel_action:'status').`);
    }
    if (casRev !== prePullRev || casPorcelain !== "") {
        throw new PanelInstallError(`Panel update did NOT apply: ${managerReason}, and the git fallback is ` +
            `REFUSED: the panel checkout at ${dir} CHANGED between the safety gates ` +
            `and the merge — ` +
            `${casPorcelain !== "" ? `the worktree is no longer clean:\n${casPorcelain}` : `HEAD moved ${prePullRev.slice(0, 8)} → ${casRev ? casRev.slice(0, 8) : "an unresolvable revision"}`}. ` +
            `Something other than this update is writing to that tree, and a ` +
            `fast-forward would race it. NO git mutation was made by this fallback ` +
            `— but something else evidently DID change the checkout, so re-read it ` +
            `rather than assuming the pre-update state. Let the other operation ` +
            `finish, then re-check install_comfyui(action:'panel', panel_action:'status').`);
    }
    let gitOutput;
    try {
        assertSameTarget("before fast-forwarding the panel checkout");
        gitOutput = deps.gitMergeFfOnly(dir, targetRev);
    }
    catch (err) {
        // The Manager path failed AND the direct merge failed — name both,
        // truthfully. "Truthfully" is the whole point of the re-read below: a
        // failed `merge --ff-only` USUALLY leaves the tree exactly as it was, but
        // "usually" is not "provably", and the very thing most likely to make it
        // fail here is another writer landing in this checkout — which by
        // definition already changed it. Asserting "nothing changed on disk" from
        // the merge's exit status alone would be a bucket ("the merge failed")
        // narrated as a fact about the disk (codex gate). So OBSERVE it: report
        // unchanged only when a fresh read says so, report what moved when it did,
        // and say UNKNOWN when the read itself fails.
        //
        // The HEADLINE moves with that observation too (codex gate r2). "Panel
        // update did NOT apply" is a claim about the disk, so it is only earned by
        // the reading that shows an untouched tree. When the tree moved, or could
        // not be read, the merge may have applied PARTIALLY — the register there is
        // disclosure, not a flat denial that anything happened. Both reads live in
        // ONE try, so an unreadable HEAD and an unreadable status land in the same
        // honest answer; neither can escape and lose the merge error below.
        let headline;
        let treeNote;
        try {
            const afterRev = deps.gitRevision(dir);
            const afterPorcelain = deps.gitStatusPorcelain(dir).trim();
            if (afterPorcelain === "" && !afterRev) {
                // `gitRevision` answers an unresolvable HEAD with undefined, not by
                // throwing. "Could not read HEAD" is the same answer as a failed
                // status read — UNKNOWN — and must not be routed into the arm that
                // tells the user the tree is no longer in its pre-update state.
                // A dirty status is checked FIRST though: that IS evidence of change,
                // and it stands whether or not HEAD resolved (codex gate r4).
                headline = `Panel update could NOT be confirmed`;
                treeNote =
                    `The checkout was re-read afterwards but has an UNREADABLE HEAD ` +
                        `(git resolved no revision at all), so ` +
                        `whether the fast-forward applied partially — or anything else changed ` +
                        `${dir} — is UNKNOWN. Inspect it (git status / git log) before retrying.`;
            }
            else if (afterRev === prePullRev && afterPorcelain === "") {
                headline = `Panel update did NOT apply`;
                treeNote =
                    `The checkout was re-read afterwards and is unchanged (HEAD still ` +
                        `${prePullRev.slice(0, 8)}, worktree clean), so the installed version is ` +
                        `still ${previousVersion ?? "unknown"}.`;
            }
            else {
                headline = `Panel update could NOT be confirmed`;
                treeNote =
                    `The checkout was re-read afterwards and is NOT what it was before the ` +
                        `attempt — ` +
                        `${afterPorcelain !== "" ? `the worktree is dirty:\n${afterPorcelain}` : `HEAD is ${afterRev ? afterRev.slice(0, 8) : "unresolvable"}, not ${prePullRev.slice(0, 8)}`}. ` +
                        `The fast-forward may therefore have applied PARTIALLY, or another ` +
                        `writer touched ${dir} — either way the panel directory is NOT in the ` +
                        `pre-update state. Inspect it (git status / git log) before retrying, ` +
                        `and do not assume the version above is what is on disk.`;
            }
        }
        catch (readErr) {
            headline = `Panel update could NOT be confirmed`;
            treeNote =
                `The checkout could not be re-read afterwards ` +
                    `(${readErr instanceof Error ? readErr.message : String(readErr)}), so ` +
                    `whether the fast-forward applied partially — or anything else changed ` +
                    `${dir} — is UNKNOWN. Inspect it (git status / git log) before retrying.`;
        }
        throw new PanelInstallError(`${headline}: ${managerReason}, and the direct git fallback ` +
            `on the panel repo failed too — ` +
            `${err instanceof Error ? err.message : String(err)}. ${treeNote} ` +
            `Fix: update ComfyUI-Manager on the host (git pull in custom_nodes/` +
            `ComfyUI-Manager, or pip install -U comfyui_manager) and retry, or update ` +
            `the panel repo manually (git pull in ${dir}), then RESTART ComfyUI.`);
    }
    // ---- CONCURRENT-WRITE BRACKET, closing half (#824 codex gate) -----------
    // The ff-only merge is the only write this path authorized. A fast-forward
    // from a clean tree leaves HEAD at exactly the rev we pinned (or, when the
    // checkout already contained it, exactly where it was) and the worktree
    // clean. Anything else — a dirty tree, a third HEAD, a Manager task that
    // appeared mid-window — is evidence that another writer touched this
    // checkout while we rewrote it. Absence of that evidence is not proof of
    // exclusivity (see the residues noted above); it is only the absence of
    // evidence, and nothing below is worded as more than that.
    //
    // Note the change of register: the merge HAS run, so these do not "refuse".
    // They DISCLOSE — the tree may be inconsistent and the caller must be told
    // that, rather than handed a success report the evidence does not support.
    let postMergeRev;
    let postMergePorcelain;
    try {
        postMergeRev = deps.gitRevision(dir);
        postMergePorcelain = deps.gitStatusPorcelain(dir).trim();
    }
    catch (err) {
        throw new PanelInstallError(`Could not verify the panel update: the pinned fast-forward ALREADY RAN in ` +
            `${dir}, but the checkout could not be re-read afterwards ` +
            `(${err instanceof Error ? err.message : String(err)}), so whether ` +
            `anything else wrote to it during the merge is UNKNOWN. NOT reporting ` +
            `success. Inspect the panel repo (git status / git log) before ` +
            `restarting ComfyUI.`);
    }
    // Same distinction as the pre-merge CAS: an unresolvable HEAD comes back as
    // undefined, not as a throw, and "we could not read HEAD" is not "another
    // writer touched this tree". Both DISCLOSE — the merge has run either way —
    // but only the readable third-revision arm may name a cause. A DIRTY
    // worktree is answered first either way: that is established evidence of an
    // outside write, and it stands whether or not HEAD resolved (codex gate r4).
    if (postMergePorcelain === "" && !postMergeRev) {
        throw new PanelInstallError(`Could not verify the panel update: the pinned fast-forward ALREADY RAN in ` +
            `${dir}, but the checkout afterwards has an UNREADABLE HEAD (git ` +
            `resolved no revision at all), so what ` +
            `it now contains — and whether anything else wrote to it during the merge ` +
            `— could NOT be determined. That is unknown, NOT evidence that something ` +
            `else did. NOT reporting success. Inspect the panel repo (git status / ` +
            `git log) before restarting ComfyUI.`);
    }
    if (postMergePorcelain !== "" || (postMergeRev !== targetRev && postMergeRev !== prePullRev)) {
        throw new PanelInstallError(`Could not verify the panel update: the pinned fast-forward ALREADY RAN in ` +
            `${dir}, but the checkout afterwards is not what that merge alone would ` +
            `leave — ` +
            `${postMergePorcelain !== "" ? `the worktree is dirty:\n${postMergePorcelain}` : `HEAD is ${postMergeRev ? postMergeRev.slice(0, 8) : "unresolvable"}, neither the merged upstream (${targetRev.slice(0, 8)}) nor the pre-merge revision (${prePullRev.slice(0, 8)})`}. ` +
            `Another writer (a ComfyUI-Manager task, or a manual git operation) ` +
            `touched this tree during the merge, so the panel directory may now be ` +
            `INCONSISTENT. NOT reporting success. Inspect the panel repo (git ` +
            `status / git log) and repair it before restarting ComfyUI.`);
    }
    {
        const live = await readLiveQueueObservation(deps, managerBase);
        if (!isLiveQueueIdle(live)) {
            throw new PanelInstallError(`Could not verify the panel update: the pinned fast-forward ALREADY RAN ` +
                `in ${dir}, but re-probing ComfyUI-Manager afterwards showed ` +
                `${describeLiveQueue(live)} — the idle window this merge was ` +
                `authorized inside did not hold all the way through it, so whether a ` +
                `Manager task wrote to the panel directory during the merge is ` +
                `UNKNOWN. NOT reporting success. Let the Manager queue drain, inspect ` +
                `the panel repo (git status / git log), then re-check ` +
                `install_comfyui(action:'panel', panel_action:'status').`);
        }
    }
    // The merge ran clean — VERIFY with the same machinery (#639/#641): re-read
    // the installed identity FRESH from disk, fail closed on a shadow, require
    // proven movement. Never infer success from git's output.
    const post = await detectPanelInstall(deps);
    if (!post.installed || !post.version) {
        throw new PanelInstallError(`Could not verify the panel update applied: the pack is ${post.installed ? "present but its version is unreadable" : "not present"} at ${dir} after the pinned fast-forward succeeded. NOT reporting success. ` +
            `Re-check the pack and retry, then RESTART ComfyUI.`);
    }
    // DIRECTORY BINDING — the verification must describe the SAME checkout we
    // pulled; a re-detection resolving a different dir means the pre/post
    // identity comparison would span two repos. Refuse rather than claim either.
    if (!post.dir || !samePathCI(dir, post.dir, deps)) {
        throw new PanelInstallError(`Could not verify the panel update applied: after the pinned fast-forward in ` +
            `${dir}, re-detection resolved a DIFFERENT panel dir ` +
            `(${post.dir ?? "none"}), so the before/after comparison would not ` +
            `describe the checkout that was pulled. NOT reporting success — check ` +
            `custom_nodes for duplicate or moved panel dirs, then re-check the ` +
            `installed version.`);
    }
    assertNoPanelShadow("update", post.dir, deps);
    // REVISION PROOF — after a pull, the checkout's HEAD must be READABLE. An
    // unreadable rev is NOT "at tip": a pull that advanced HEAD without a
    // version bump would otherwise fall through to "already up to date" with
    // zero evidence either way (codex gate). Fail closed, never at-tip.
    if (!post.gitRev) {
        throw new PanelInstallError(`Could not verify the panel update applied: the pinned fast-forward ran clean ` +
            `in ${dir}, but the checkout's HEAD revision is unreadable afterward, ` +
            `so whether it moved (a nightly advance with no version bump) is ` +
            `UNVERIFIABLE. NOT reporting success and NOT reporting "already at ` +
            `tip" — check the panel repo (git log), then re-check ` +
            `install_comfyui(action:'panel', panel_action:'status').`);
    }
    const verdict = classifyPanelUpdate({
        previousVersion,
        installedVersion: post.version,
        previousRev,
        installedRev: post.gitRev,
    }, result.details);
    // A fast-forward can only advance HEAD, but the pyproject version it lands is
    // whatever upstream committed — so a proven regression is still possible and
    // must never fall through to the "already at upstream tip" report below.
    if (verdict.outcome === "downgraded") {
        throw new PanelInstallError(`Panel update did NOT go forward: the pinned fast-forward in ${dir} left the pack ` +
            `at ${post.version}, which is OLDER than the ${previousVersion ?? "unknown"} ` +
            `installed before it. NOT reporting this as an update, and NOT reporting the ` +
            `checkout as current — check the panel repo (git log) before restarting ComfyUI.`);
    }
    if (verdict.outcome === "updated") {
        const from = verdict.previousVersion ?? verdict.previousRev?.slice(0, 8) ?? "?";
        const to = verdict.installedVersion ?? verdict.installedRev?.slice(0, 8) ?? "?";
        // The embedded result came from the Manager call that no-op'd — its own
        // message may credit ComfyUI-Manager. The report must not contradict the
        // git-fallback path that actually did the work (codex gate).
        assertSameTarget("before reporting the fast-forward");
        const fallbackMessage = `Panel updated (${from} → ${to}) via a pinned git merge --ff-only on the panel repo ` +
            `(${dir}), verified on disk: ${managerReason}, so the update was applied ` +
            `directly from git. RESTART ComfyUI to load the updated panel node.`;
        const honestResult = { ...result, message: fallbackMessage };
        return {
            action: "update",
            result: honestResult,
            restartRequired: true,
            message: fallbackMessage,
            previousVersion,
            installedVersion: post.version,
        };
    }
    // Nothing moved even though git pull exited clean. Before claiming “at the
    // upstream tip”, PROVE it: HEAD must equal the tracked upstream sha — a
    // locally-AHEAD checkout (committed local work) also exits pull cleanly and
    // would otherwise be blessed as upstream currency (codex gate). No upstream
    // configured or a mismatch → unverifiable, never “at tip”.
    let upstreamRev;
    try {
        upstreamRev = deps.gitUpstreamRev(dir);
    }
    catch {
        upstreamRev = undefined;
    }
    if (!upstreamRev || upstreamRev !== post.gitRev) {
        throw new PanelInstallError(`Could not verify the panel is current: the pinned fast-forward ran clean in ` +
            `${dir}, but ${!upstreamRev ? "no upstream is configured, so currency is UNPROVABLE" : `HEAD (${post.gitRev?.slice(0, 8)}) does not match the tracked upstream (${upstreamRev.slice(0, 8)}) — the checkout has committed local work upstream doesn't have`}. ` +
            `NOT reporting "at upstream tip" and NOT reporting an update. Check the ` +
            `panel repo (git status / git log), then re-check install_comfyui(action:'panel', panel_action:'status').`);
    }
    // HEAD === upstream: git FETCHED the remote and proved the checkout current.
    // That is genuine proof of currency — report "already up to date" honestly
    // (NOT "updated"; nothing changed, no restart). The embedded result came
    // from the Manager call that no-op'd — override its message too, so the
    // report never credits the Manager for a verification git did (codex gate).
    assertSameTarget("before reporting the checkout as current");
    // Scope the closing claim to what was actually established. HEAD equalling
    // the FETCHED upstream proves the tracked checkout is current; it does not
    // prove the directory was untouched — `git status --porcelain` never reports
    // ignored paths, and nothing is observed after the final read. So say what
    // this fallback did (nothing: there was nothing to fast-forward) rather than
    // issuing a whole-disk guarantee it is not in a position to make (codex gate).
    const atTipMessage = `Panel is already at the upstream tip (${post.version}) — ${managerReason}, ` +
        `but a pinned git merge --ff-only on ${dir} verified the checkout is ` +
        `current (git: ${gitOutput || "no output"}). HEAD already WAS the fetched ` +
        `upstream, so this fallback fast-forwarded nothing and moved nothing itself; ` +
        `no restart is needed on its account. (That verifies the TRACKED checkout — ` +
        `git does not compare ignored files.)`;
    return {
        action: "update",
        result: { ...result, message: atTipMessage },
        restartRequired: false,
        message: atTipMessage,
        previousVersion,
        installedVersion: post.version,
    };
}
// ---------------------------------------------------------------------------
// Swap journal — crash recovery for the one window that cannot be made atomic.
//
// Replacing a directory takes TWO renames (a rename onto a non-empty directory
// fails), so there is an instant where the panel is out of custom_nodes and its
// replacement is not yet in. A caught failure is handled inline — the backup is
// moved straight back. A process kill or host crash in that instant is not
// caught by anything, and would leave the user with NO panel in custom_nodes,
// the working copy stranded in custom_nodes_backup, and nothing on disk saying
// what happened or how to undo it.
//
// So the intent is written down BEFORE the first rename, to a file beside
// custom_nodes (never inside it — ComfyUI serves everything in there). The next
// panel operation reads it back and repairs.
//
// Reconciliation is deliberately timid. It restores ONLY when the canonical
// directory is missing — the single state that is unambiguously a broken swap —
// and it restores the copy that was already on disk and working, never the
// staged one, whose validation may not have finished. Anything else it merely
// reports. It runs under the panel op lock (both mutation entry points hold
// it), so it cannot fire in the middle of another process's swap.
// ---------------------------------------------------------------------------
const PANEL_SWAP_JOURNAL = ".comfyui-agent-panel.swap.json";
/**
 * The in-flight replacement, parked INSIDE custom_nodes under a fixed name.
 *
 * This is the mechanism that makes the swap survivable, and it replaces the
 * journal as the authority on "is a swap in progress?".
 *
 * The old ordering moved the working panel OUT and then moved the new one IN,
 * so between those two renames custom_nodes held NO panel — and recovery
 * depended on a journal file whose *directory entry* is not durable just because
 * the file's contents were fsync'd. A power loss could persist the rename and
 * lose the journal, leaving the user with no panel and nothing to recover from.
 * A journal that is not durable before the action it guards is not a journal.
 *
 * So the new panel is moved IN first, under this name, and the old one is moved
 * out afterwards. At every interruptible point at least one panel directory is
 * present in custom_nodes and ComfyUI serves it (the web layer serves
 * dot-prefixed dirs too — the same fact that makes #641 shadows possible works
 * in our favour here). And the recovery state is derivable from the FILESYSTEM
 * ALONE: this directory existing means a swap was interrupted, no journal
 * required. Nothing else ever creates this name.
 */
const PANEL_INCOMING_NAME = ".comfyui-agent-panel.incoming";
function swapJournalPath(comfyuiPath) {
    return join(comfyuiPath, PANEL_SWAP_JOURNAL);
}
/**
 * (3) VIABILITY — is this directory a panel that would actually WORK if ComfyUI
 * loaded it?
 *
 * Deliberately stricter than "the directory is there": recovery decisions delete
 * things, and a husk — an emptied directory, or one left by a half-finished move
 * — must never be mistaken for a healthy install and used to justify discarding
 * the only good copy. Requires the panel's own pyproject identity AND the built
 * web bundle; every uncertainty (unreadable file, indeterminate probe) answers
 * "no".
 */
/**
 * (1) IDENTITY — is this directory the panel PACK, whatever state it is in?
 *
 * The same question `detectPanelInstall` asks, and the same answer: the
 * pyproject `[project].name`. Deliberately says nothing about whether the pack
 * works — a broken panel is still the panel, and must be recognised as such or
 * recovery would mistake an unrelated custom node for it.
 *
 * "Has any pyproject.toml at all" is NOT this test. Every custom node pack has
 * one, so using it to resolve the canonical panel directory let an unrelated
 * node occupying `custom_nodes/comfyui-mcp-panel` be selected as the panel.
 */
/**
 * Tri-state presence of a DIRECTORY: true / false / undefined ("could not tell").
 *
 * `existsSync` alone cannot express the third answer — it folds EACCES, EIO and
 * every other error into `false`, which is how an unreadable directory came to
 * be reported as an absent one. `probeFile` is the probe whose FALSE is a real
 * determination (ENOENT/ENOTDIR, or "it is a directory"), so it supplies the
 * indeterminate case, and `existsSync` distinguishes present-directory from
 * absent once we know the probe itself succeeded.
 *
 * Every status claim about whether something is THERE goes through this, because
 * a claim is only as strong as the observation behind it: an observation that
 * failed must weaken the sentence, never quietly become its negative.
 */
/**
 * The qualifier every sentence about the RUNNING SERVER must carry.
 *
 * Whether a directory is on disk here, and whether the ComfyUI the user is
 * actually running serves from here, are different questions — and the second
 * is frequently unproven, because the base may be a configured path the live
 * server never confirmed (#766). Saying "ComfyUI serves it, so the panel works"
 * from an unconfirmed root presents evidence about one subject as evidence
 * about another.
 *
 * Guarding one such sentence and leaving its neighbours bare is how this got
 * missed twice, so this exists as the single thing to append: any sentence
 * about what ComfyUI is loading appends this, and inherits the guard by
 * construction rather than by remembering.
 *
 * THE SERVING CLAIM LIVES HERE, NOT IN THE SENTENCE BEFORE IT. The first
 * revision left the claim in the base clause and appended a parenthetical
 * retraction — "ComfyUI is currently serving that staged copy (assuming X is
 * the install ComfyUI actually runs from — that could NOT be confirmed here)".
 * A reader takes the main clause and skims the parenthetical, so the guard
 * bought nothing: the sentence still ASSERTED and then took it back. Base
 * clauses are therefore NEUTRAL about the running server — they name a copy, a
 * path and its state, and stop — and this supplies the serving claim itself,
 * in the present tense only when the root is live-derived. The two directions
 * then agree, and nothing is asserted before it is qualified.
 *
 * `subject` is the noun phrase the base clause just established ("that staged
 * copy", "your panel at <dir>"), so the appended sentence has an antecedent.
 *
 * AND IT MUST BE THIS TREE THAT WAS CORROBORATED — the same rule, and the same
 * reasoning, as the swap's corroboration gate (`liveResolutionNamesTree`). Asking only "is the CURRENT
 * resolution live-derived?" answers a question about NOW, while the path this
 * sentence names was pinned earlier. A retarget in between (status pins A, the
 * target moves to B, a live probe caches B) would let an observation
 * identifying server B certify a sentence about server A's tree — the guard
 * built to stop us claiming an unverified root doing exactly that, on somebody
 * else's evidence. The resolution's base and the path being described have to
 * be the same directory.
 */
function servingQualifier(deps, comfyuiPath, subject) {
    // ONE read, for the verdict AND the message: the cache can expire between
    // two reads, and a mismatch claim derived from a second, empty read would
    // assert "a DIFFERENT tree" on nothing more than a lost cache entry.
    const resolution = isRealDeps(deps) ? lastPanelBaseResolution() : undefined;
    const liveBase = isLiveDerivedBase(resolution) ? resolution?.base : undefined;
    const confirmed = !isRealDeps(deps) || (!!liveBase && samePathCI(comfyuiPath, liveBase, deps));
    if (confirmed) {
        return (` The running ComfyUI loads its panel from ${comfyuiPath}, so ${subject} is in ` +
            `the tree it serves.`);
    }
    // NAME THE MISMATCH WHEN THERE IS ONE. "Could not be confirmed" is true of
    // both failures, but they are different situations and only one of them puts
    // a second tree in front of the user — which on a Comfy Desktop split install
    // (#766) is the whole diagnosis. Still no assertion of the negative: a
    // resolution taken after a retarget describes a different server, not proof
    // that this tree is unserved.
    const mismatch = liveBase
        ? ` — the only live reading available describes a DIFFERENT tree (${liveBase})`
        : ``;
    return (` Whether the running ComfyUI loads its panel from ${comfyuiPath} could NOT be ` +
        `confirmed here${mismatch}, so whether ${subject} is in the tree it serves is unknown.`);
}
function dirPresence(p, deps) {
    // FOUR STATES, because the earlier three folded a positive observation into
    // the wrong neighbour. `probeFile === true` means "this is a regular file" —
    // a determination, not an ambiguity — so a stray file named
    // `.comfyui-agent-panel.incoming` (a failed extract, a half-written temp)
    // was reported as a present staged replacement and then described as a
    // directory, which nobody had observed. Answer it precisely instead.
    const probe = deps.probeFile(p);
    if (probe === undefined)
        return "unknown";
    if (probe === true)
        return "other";
    return deps.existsSync(p) ? "directory" : "absent";
}
/** The panel version recorded in a directory's pyproject, if readable. */
function readPanelVersionAt(dir, deps) {
    try {
        return parsePyproject(deps.readFile(join(dir, "pyproject.toml"))).version;
    }
    catch {
        return undefined;
    }
}
function dirIsPanelByIdentity(dir, deps) {
    try {
        const pyproject = join(dir, "pyproject.toml");
        if (!deps.existsSync(pyproject))
            return false;
        return parsePyproject(deps.readFile(pyproject)).projectName === PANEL_REGISTRY_ID;
    }
    catch {
        return false;
    }
}
/**
 * Find the panel backup this ComfyUI root holds, WITHOUT depending on the
 * journal to name it.
 *
 * The design premise is that recovery derives from the filesystem, and the
 * journal is advisory — so it must not be the only way to find our own backup.
 * When `.incoming` proves an interruption but the journal is gone, a perfectly
 * good previous panel can be sitting in custom_nodes_backup, discoverable by
 * looking. Refusing then, and telling the user no previous copy exists while one
 * does, is a plain failure rather than the ambiguity the refusal policy covers.
 *
 * Only VIABLE panels are candidates, every path is containment-checked, and the
 * most recent wins — the backup names carry the `Date.now()` they were made
 * with, and a later swap's backup is the one that matches what was just replaced.
 */
/**
 * Does this name match the backup directories the swap itself creates?
 *
 * The swap names them `<panel-dir-name>-<version>-<epoch-ms>`. Anything else
 * under custom_nodes_backup belongs to the user, and restoring from it would be
 * us choosing a copy we know nothing about over the one we made.
 */
function isPanelSwapBackupName(name) {
    return FAST_PATH_DIRS.some((base) => name.startsWith(`${base}-`) && /-\d{10,}$/.test(name));
}
function discoverPanelBackup(comfyuiPath, deps) {
    const backupRoot = join(comfyuiPath, "custom_nodes_backup");
    let entries;
    try {
        entries = deps.readdir(backupRoot);
    }
    catch {
        return undefined; // absent or unreadable — nothing discoverable
    }
    const candidates = [];
    for (const name of entries) {
        const dir = join(backupRoot, name);
        if (!isInsideDir(backupRoot, dir))
            continue;
        // OURS ONLY. Any panel-shaped directory used to qualify, so a copy the user
        // deliberately parked here ("saved-copy-0.11.20") competed with the backup
        // an interrupted swap actually made — and could win it. Restore only from
        // something WE created, recognisable by the name the swap builds.
        if (!isPanelSwapBackupName(name))
            continue;
        if (!dirHasPanelFiles(dir, deps))
            continue;
        // REAL TIME, not a number parsed out of the name. That suffix is a string on
        // disk that anything can rename; mtime is the filesystem's own record of
        // when the directory was put there.
        const mtime = deps.mtimeMs?.(dir);
        if (mtime === undefined)
            continue; // cannot order it — do not guess
        candidates.push({ dir, mtime });
    }
    if (candidates.length === 0)
        return undefined;
    // DETERMINISTIC. Filesystem timestamps are often coarse (whole seconds on
    // some filesystems), so ties are ordinary rather than exotic — and a stable
    // sort over `readdir`'s unspecified order would then pick a different backup
    // on different machines, or on the same machine twice. Break ties on the name
    // so the choice is always reproducible and explainable.
    candidates.sort((a, b) => b.mtime - a.mtime || b.dir.localeCompare(a.dir));
    return candidates[0].dir;
}
/**
 * Is `child` genuinely INSIDE `parent`?
 *
 * A `startsWith` prefix test is not a containment test, and the difference is
 * not academic here: the journal is an on-disk record we do not control, and
 * `<root>/custom_nodes_backup/../../Other/ComfyUI/custom_nodes/comfyui-mcp-panel`
 * has the right prefix while resolving into a DIFFERENT ComfyUI installation.
 * Restoring from there would move another install's panel into this one and
 * leave that one with none. Resolve both sides first, then compare on path
 * SEGMENTS via `relative` — a result that is empty, absolute, or starts with
 * ".." means it is not inside.
 */
function isInsideDir(parent, child) {
    try {
        const rel = relative(resolve(parent), resolve(child));
        return rel.length > 0 && !isAbsolute(rel) && !rel.split(/[\\/]/).includes("..");
    }
    catch {
        return false;
    }
}
/**
 * Where the panel canonically lives under THIS ComfyUI root.
 *
 * The parent is always this root's custom_nodes — never anything the journal
 * says. The NAME, though, cannot simply be the registry id: the pack ordinarily
 * installs to a directory named after the REPO ("comfyui-mcp-panel") while its
 * registry name is "comfyui-agent-panel" (see FAST_PATH_DIRS). Deriving the
 * registry name unconditionally would make recovery promote a replacement to a
 * directory the existing install does not use, leaving BOTH served — two
 * competing panel extensions, created by the repair.
 *
 * So: an existing panel directory wins; failing that, the journal may pick
 * between the two KNOWN names (a whitelist, not free choice); failing that, the
 * registry name is the default.
 */
function resolveCanonicalPanelDir(comfyuiPath, deps, journal) {
    const customNodes = join(comfyuiPath, "custom_nodes");
    for (const name of FAST_PATH_DIRS) {
        const candidate = join(customNodes, name);
        if (dirIsPanelByIdentity(candidate, deps))
            return candidate;
    }
    const journalName = journal?.dir ? basename(journal.dir) : undefined;
    if (journalName && FAST_PATH_DIRS.includes(journalName)) {
        return join(customNodes, journalName);
    }
    return join(customNodes, PANEL_REGISTRY_ID);
}
function panelIdentityVerdict(dir, deps) {
    const pyproject = join(dir, "pyproject.toml");
    // THREE STEPS, THREE ANSWERS. `probeFile` answers `undefined` for a stat that
    // did not complete (EACCES on the parent, EIO, a vanished mount) — that is an
    // UNPERFORMED read, not a failed one, and reporting it as "could not be read"
    // states a mechanism nobody observed and sends the user to check the wrong
    // thing. Only ENOENT/ENOTDIR are a determination, and they mean "not there".
    const pyprobe = deps.probeFile(pyproject);
    if (pyprobe === undefined)
        return "probe-failed";
    if (pyprobe !== true)
        return "not-a-panel";
    let raw;
    try {
        raw = deps.readFile(pyproject);
    }
    catch {
        return "unreadable"; // located, and the OPEN failed
    }
    let identity;
    try {
        identity = parsePyproject(raw).projectName;
    }
    catch {
        return "malformed"; // read fine; the CONTENTS are damaged
    }
    return identity === PANEL_REGISTRY_ID ? "panel" : "not-a-panel";
}
/**
 * ONE SENTENCE PER VERDICT, WRITTEN ONCE.
 *
 * Two messages narrate this verdict, and each did it with a two-way conditional
 * over a four-way answer — so `usable` and `malformed` both fell through into
 * "does NOT look like a working panel", stating the fail-closed DECISION as
 * though it were the observation. Rendering lives here so the two cannot drift
 * and so a new verdict value cannot be silently absorbed by an `else`.
 *
 * Every clause begins with a lowercase word (never the path), so a caller can
 * either open a sentence with it or splice it after "…interrupted, and ".
 */
function describePanelShape(dir, verdict) {
    switch (verdict) {
        case "usable":
            // The speaking verdict CONTRADICTS the fail-closed decision that routed us
            // here (the two reads were taken at different moments). Repeating the
            // decision as an observation would assert something this probe denies.
            return (`a re-check of ${dir} DID find a complete panel there — a readable panel ` +
                `pyproject.toml and a non-empty web bundle — disagreeing with the check this ` +
                `refusal was decided on, so nothing is being moved while the two readings differ`);
        case "not-a-panel":
            return (`the directory at ${dir} is present but does NOT hold a working panel (no ` +
                `readable panel pyproject.toml, or no built web bundle)`);
        case "malformed":
            return (`whether the directory at ${dir} holds a working panel could NOT be determined ` +
                `— its pyproject.toml was read, but its contents would not parse, so this is ` +
                `not a finding that it is broken`);
        case "unreadable":
            // CLAIM-NEUTRAL, BECAUSE THIS VERDICT IS A BUCKET AND ONE OF ITS MEMBERS
            // IS NOT A FILESYSTEM FAILURE AT ALL. Two paths land here: an open that
            // genuinely failed, and a dep set carrying no `fileDigest`, where nothing
            // was opened because there was no way to ask. "Is there but could not be
            // opened" asserts an open failure that, on the second path, never
            // happened. The renderer cannot tell them apart — so the sentence says
            // only what is true of both: the contents were not established. The
            // action/message split one level in — the BUCKET a value falls into is
            // not the SENTENCE it earns.
            //
            // The IDENTITY verdict's `unreadable` has exactly one member (a real open
            // failure, since identity never consults `fileDigest`), which is why its
            // narration keeps the precise cause and the permissions remedy.
            return (`whether the directory at ${dir} holds a working panel could NOT be determined ` +
                `— one of its files was located, but its contents could not be checked, so this ` +
                `is not a finding that it is broken`);
        case "probe-failed":
            return (`whether the directory at ${dir} holds a working panel could NOT be determined ` +
                `— one of its files could not even be located, so nothing about its contents ` +
                `was established`);
    }
}
/** Open a sentence with a clause that deliberately starts lowercase. */
function startSentence(clause) {
    const first = clause.charAt(0);
    return first >= "a" && first <= "z" ? first.toUpperCase() + clause.slice(1) : clause;
}
function panelShapeVerdict(dir, deps) {
    const identity = panelIdentityVerdict(dir, deps);
    if (identity !== "panel")
        return identity;
    const bundle = join(dir, ...PANEL_WEB_MARKERS[0]);
    // Same three causes on the bundle: a stat that did not complete is an
    // UNPERFORMED read, not a failed one.
    const bundleProbe = deps.probeFile(bundle);
    if (bundleProbe === undefined)
        return "probe-failed";
    if (bundleProbe !== true)
        return "not-a-panel";
    // A dep set with no `fileDigest` is a DEP-SHAPE GAP, not a filesystem event —
    // production always wires it (defaultDeps, and panelStatusAt's spread of it).
    // It fails closed here, with the same verdict as a failed read, and that is
    // deliberate: a FOURTH verdict would put a non-observation into a vocabulary
    // about what was seen on disk, and the next reader would try to produce it
    // from a probe. Nothing on disk was observed to be wrong here — so the cost is
    // paid in the SENTENCE instead, which is why `describePanelShape` makes no
    // filesystem claim for `unreadable`.
    if (!deps.fileDigest)
        return "unreadable";
    const digest = deps.fileDigest(bundle);
    if (!digest)
        return "unreadable"; // located, and the read of it failed
    return digest.size > 0 ? "usable" : "not-a-panel";
}
function dirHasPanelFiles(dir, deps) {
    if (!dirIsPanelByIdentity(dir, deps))
        return false;
    const bundle = join(dir, ...PANEL_WEB_MARKERS[0]);
    if (deps.probeFile(bundle) !== true)
        return false;
    // EMPTY IS NOT PRESENT. A zero-length file is still a regular file, so the
    // presence check alone accepted it — and an empty bundle is a directory entry
    // whose data is gone, which is the crash shape, not a panel. This is not a
    // size heuristic: there is no threshold to tune, only the difference between
    // "there are bytes" and "there are none". Anything beyond that (is the
    // JavaScript complete?) is the integrity manifest's job, not a guess made
    // from content.
    //
    // AND "COULD NOT READ" IS NOT "FINE". When the digest dep exists, a bundle it
    // cannot read — EIO on a failing disk, EACCES from an ACL — must not be judged
    // viable: this predicate decides whether to rename a backup over the canonical
    // name and then delete the staged copy, so answering "yes" on an unreadable
    // file leaves the user with no readable panel at all. Same fold as everywhere
    // else tonight: an unanswered question is not a positive answer.
    // FAIL CLOSED ON THE MISSING DEP TOO. An earlier revision returned true here,
    // which stated the principle in the comment above and then did the opposite —
    // and disagreed with `verifyStagedTree`, which answers `unverifiable` for the
    // identical condition. Production always wires `fileDigest` (defaultDeps, and
    // the one spread of it in panelStatusAt), so the only callers this can refuse
    // are dep sets that cannot occur in production; refusing them removes
    // coverage that was passing for the wrong reason rather than losing anything
    // real. This predicate decides whether to rename a backup over the canonical
    // name and then delete the staged copy — it is the last place that should be
    // guessing.
    const digest = deps.fileDigest?.(bundle);
    if (!digest)
        return false; // unreadable, or no way to ask
    return digest.size > 0;
}
/**
 * Integrity manifest, written INTO a staged tree at the moment we create it.
 *
 * This is the answer to "did this tree survive intact?", and it is exact. At
 * stage time we hold the freshly cloned files, so we can record what they are:
 * every path, its byte size, and its content hash, plus a hash over that list.
 * Recovery then re-derives the same values and compares.
 *
 * Why this and not a content check: comparing bytes to what we ourselves staged
 * cannot false-reject a valid bundle — minified single-line output, a trailing
 * regex literal, CR line endings and every other lexical curiosity are simply
 * irrelevant. And it is strictly stronger, because it catches things no parse
 * could: a file that is syntactically perfect but is the wrong half of another
 * file, a missing asset, a partially-written image.
 *
 * Every failure mode is benign and distinguishable:
 *   - manifest absent or unreadable  -> UNVERIFIABLE -> refuse and disclose
 *   - manifest present, tree differs -> CORRUPT      -> restore the backup
 *   - everything matches             -> INTACT       -> promote
 */
const PANEL_MANIFEST_NAME = ".comfyui-mcp-integrity.json";
/**
 * Is this a well-formed manifest entry?
 *
 * Checked BEFORE anything dereferences it. A manifest that was half-written when
 * the process died can still parse as JSON — `{"files":[null]}` is valid JSON —
 * and reaching into that throws from inside the verifier, where the exception
 * escapes into a catch that reports nothing. A partially-written file is the
 * ordinary crash shape here, so malformed must be a VERDICT (`unverifiable`),
 * never an exception.
 */
function isManifestEntry(v) {
    if (!v || typeof v !== "object")
        return false;
    const e = v;
    return (typeof e.path === "string" &&
        e.path.length > 0 &&
        typeof e.size === "number" &&
        Number.isFinite(e.size) &&
        e.size >= 0 &&
        typeof e.sha256 === "string" &&
        e.sha256.length > 0);
}
/** Stable digest over the file list. */
function manifestDigest(files, deps) {
    const canonical = files
        .map((f) => `${f.path} | ${f.size} | ${f.sha256}`)
        .join("\n");
    return deps.hashString?.(canonical) ?? "";
}
/**
 * Every file under `dir`, as paths relative to it, sorted for a stable digest.
 * Returns undefined if any directory could not be read — a partial listing must
 * never be mistaken for a complete one.
 *
 * `.git` is skipped: it is enormous, it is rewritten by git itself, and it is
 * not what ComfyUI loads. The manifest covers what the panel IS.
 */
function collectTreeFiles(dir, deps, prefix = "") {
    let entries;
    try {
        entries = deps.readdir(dir);
    }
    catch {
        return undefined;
    }
    const out = [];
    for (const name of entries) {
        if (prefix === "" && (name === ".git" || name === PANEL_MANIFEST_NAME))
            continue;
        const full = join(dir, name);
        const rel = prefix === "" ? name : `${prefix}/${name}`;
        // NEVER FOLLOW A LINK. `isDirectory` follows symlinks, so a link inside the
        // staged tree would be walked as though its target were part of it: the
        // manifest would verify `intact` while the served panel actually depends on
        // mutable content OUTSIDE `.incoming`, and a later unrelated edit there
        // would silently change the installed panel. Junctions and git-linked dev
        // checkouts make this an ordinary situation on this project, not an exotic
        // one. We cannot describe such a tree honestly, so we decline to describe
        // it: the writer refuses to stage it and the verifier reports unverifiable.
        if (deps.isSymlink(full))
            return undefined;
        const isDir = deps.isDirectory(full);
        if (isDir === undefined)
            return undefined; // could not classify — not a complete listing
        if (isDir) {
            const nested = collectTreeFiles(full, deps, rel);
            if (!nested)
                return undefined;
            out.push(...nested);
        }
        else {
            out.push(rel);
        }
    }
    return out.sort();
}
/**
 * Record what we just staged. THROWS on any failure: a swap whose result cannot
 * later be verified is not one we should start, because the whole recovery
 * design rests on being able to tell an intact tree from a damaged one.
 */
function writeStagedManifest(dir, deps) {
    if (!deps.fileDigest || !deps.hashString || !deps.writeFile) {
        throw new PanelInstallError("this build cannot compute an integrity manifest (missing filesystem digest support)");
    }
    const rels = collectTreeFiles(dir, deps);
    if (!rels) {
        throw new PanelInstallError(`could not enumerate the staged tree at ${dir}`);
    }
    const files = [];
    for (const rel of rels) {
        const d = deps.fileDigest(join(dir, ...rel.split("/")));
        if (!d)
            throw new PanelInstallError(`could not hash the staged file ${rel}`);
        files.push({ path: rel, size: d.size, sha256: d.sha256 });
    }
    const manifest = {
        version: 1,
        files,
        digest: manifestDigest(files, deps),
    };
    deps.writeFile(join(dir, PANEL_MANIFEST_NAME), JSON.stringify(manifest));
}
/**
 * Compare a staged tree against the manifest written when it was staged.
 *
 * `unverifiable` is a first-class answer, deliberately distinct from `corrupt`:
 * "we cannot tell" must not be reported as "it is broken", because the two lead
 * to different actions — disclose and stop, versus restore the backup.
 */
function verifyStagedTree(dir, deps) {
    if (!deps.fileDigest || !deps.hashString)
        return "unverifiable";
    const manifestPath = join(dir, PANEL_MANIFEST_NAME);
    if (!deps.existsSync(manifestPath))
        return "unverifiable";
    let manifest;
    try {
        manifest = JSON.parse(deps.readFile(manifestPath));
    }
    catch {
        return "unverifiable"; // truncated or corrupt manifest — cannot tell
    }
    if (manifest?.version !== 1 || !Array.isArray(manifest.files))
        return "unverifiable";
    // Shape before use — see isManifestEntry.
    if (!manifest.files.every(isManifestEntry))
        return "unverifiable";
    // A PANEL IS NEVER ZERO FILES. An empty (or required-file-less) list with a
    // matching digest is far likelier to come from a bug in our own WRITER — a
    // manifest written before the walk, an exception swallowed mid-collection —
    // than from anything else, and it would otherwise "verify" an empty
    // directory as intact. This is a sanity check on what we produce, not a
    // defence against anyone.
    if (manifest.files.length === 0)
        return "unverifiable";
    const listed = new Set(manifest.files.map((f) => f.path));
    const requiredPaths = ["pyproject.toml", PANEL_WEB_MARKERS[0].join("/")];
    if (!requiredPaths.every((p) => listed.has(p)))
        return "unverifiable";
    if (manifest.digest !== manifestDigest(manifest.files, deps))
        return "unverifiable";
    const actual = collectTreeFiles(dir, deps);
    if (!actual)
        return "unverifiable";
    if (actual.length !== manifest.files.length)
        return "corrupt";
    const expected = new Map(manifest.files.map((f) => [f.path, f]));
    for (const rel of actual) {
        const want = expected.get(rel);
        if (!want)
            return "corrupt"; // a file we never staged
        const got = deps.fileDigest(join(dir, ...rel.split("/")));
        if (!got)
            return "unverifiable"; // could not read it back
        if (got.size !== want.size || got.sha256 !== want.sha256)
            return "corrupt";
    }
    return "intact";
}
function reconcilePanelSwap(comfyuiPath, deps, opts) {
    const journalPath = swapJournalPath(comfyuiPath);
    const incomingDir = join(comfyuiPath, "custom_nodes", PANEL_INCOMING_NAME);
    // TRI-STATE, so an unreadable staged directory cannot be reported as absent.
    // "There is no interrupted swap" and "we could not tell whether there is one"
    // lead to opposite messages, and only one of them is safe to guess.
    const incomingState = dirPresence(incomingDir, deps);
    if (incomingState === "other") {
        // Positively the wrong KIND of thing. A regular file with this name is an
        // ordinary accident (a failed extract, a half-written temp), and it is
        // neither a staged replacement nor an absence — announcing "a staged
        // replacement is sitting at …" and then describing it as a directory would
        // assert something nobody observed.
        return {
            ok: false,
            note: ` WARNING: "${incomingDir}" exists but is a FILE, not a staged panel directory. ` +
                `That is not something this recovery path created, so nothing has been changed ` +
                `and no conclusion about an interrupted update should be drawn from it. Remove ` +
                `or rename that file, then re-run ` +
                `${describeInstallPanelAction("status", "a re-check on the ComfyUI host")}.`,
        };
    }
    if (incomingState === "unknown") {
        return {
            ok: false,
            note: ` WARNING: whether a panel update was interrupted could NOT be determined — ` +
                `"${incomingDir}" could not be inspected. Nothing has been changed, and no ` +
                `conclusion about your panel should be drawn from this report. Check that ` +
                `${join(comfyuiPath, "custom_nodes")} is readable, then re-run ` +
                `${describeInstallPanelAction("status", "a re-check on the ComfyUI host")}.`,
        };
    }
    const incomingPresent = incomingState === "directory";
    const readJournal = () => {
        if (!deps.existsSync(journalPath))
            return undefined;
        try {
            const j = JSON.parse(deps.readFile(journalPath));
            return j?.dir && j?.backupDir ? j : undefined;
        }
        catch {
            return undefined;
        }
    };
    const dropJournal = () => {
        try {
            deps.removeDir?.(journalPath);
        }
        catch {
            // A stale journal is noise, not damage — the next reconcile re-reports it.
        }
    };
    // THE FILESYSTEM IS THE AUTHORITY, not the journal.
    //
    // A swap is in progress if and only if the incoming directory exists: nothing
    // else ever creates that name, and unlike a journal it is created by the very
    // rename that begins the operation, so it cannot be lost while the operation's
    // effects survive. The journal is now only a convenience — it names where the
    // old copy went — and is never, on its own, grounds to move anything.
    if (!incomingPresent) {
        // The journal is advisory, but "there is no journal" is still a CLAIM, and
        // an unreadable one must not become it — that is what would let a genuinely
        // interrupted state be reported as nothing at all.
        if (deps.probeFile(journalPath) === undefined) {
            return {
                ok: false,
                note: ` WARNING: no staged replacement is present, but whether a panel swap record ` +
                    `exists could NOT be determined — "${journalPath}" could not be inspected. ` +
                    `Nothing has been changed. Check that ${comfyuiPath} is readable, then re-run ` +
                    `${describeInstallPanelAction("status", "a re-check on the ComfyUI host")}.`,
            };
        }
        const stale = readJournal();
        if (!stale)
            return { ok: true };
        // DERIVED, not taken from the journal. Asking `existsSync(stale.dir)` let an
        // untrusted (stale, or hand-edited) path decide whether a panel exists: a
        // `dir` pointing at any unrelated directory that happens to exist reads as
        // "the panel is fine", so the journal is deleted and success returned — in
        // exactly the state where the ruling requires the user be TOLD their backup
        // is preserved. That is not the refusal that was ruled for; it is the
        // refusal being suppressed, which is worse than either option weighed.
        const staleCanonical = resolveCanonicalPanelDir(comfyuiPath, deps, stale);
        // Same rule as the in-flight path: the journal is preferred but not the only
        // way to find our backup. Here it matters for DISCLOSURE rather than for a
        // move — the refusal has to be able to tell the user where their panel is,
        // and "the journal named a path that no longer resolves" is no reason to
        // report that no copy exists when one is sitting in custom_nodes_backup.
        const staleBackup = (isInsideDir(join(comfyuiPath, "custom_nodes_backup"), stale.backupDir) &&
            dirHasPanelFiles(stale.backupDir, deps)
            ? stale.backupDir
            : undefined) ?? discoverPanelBackup(comfyuiPath, deps);
        // IDENTITY, not existence — the fifth-question defect, guarded here for
        // good. `resolveCanonicalPanelDir` can return a NAME that no panel
        // occupies, and an unrelated custom node sitting at exactly that name would
        // then read as "the panel is fine": the journal gets cleared as spent, a
        // success is reported, and the preserved backup is never mentioned —
        // suppressing the very disclosure this branch exists to make.
        if (dirIsPanelByIdentity(staleCanonical, deps)) {
            // Panel present and no swap in flight — the record is simply spent.
            dropJournal();
            return { ok: true };
        }
        // A JOURNAL WITHOUT AN INCOMING DIRECTORY IS STALE, and stale means UNKNOWN,
        // not "a swap is half-done". The likeliest cause is a swap that COMPLETED
        // and died before deleting its journal — after which the user may well have
        // deliberately uninstalled the panel. Restoring the backup would then
        // resurrect software they intentionally removed. "There is a journal" only
        // ever meant "a journal exists". So: report, never mutate.
        // REFUSING IS ONLY ACCEPTABLE IF THE USER IS HANDED THEIR DATA BACK.
        // "Cannot determine, not restoring" abandons someone at their worst moment —
        // panel gone, and no idea a backup even exists. So the refusal states all
        // five things they need: that the canonical path is empty, that a backup
        // exists AND where, why we will not move it for them, and the exact command
        // to restore it plus the exact command to start fresh.
        const backupPresent = !!staleBackup && deps.existsSync(staleBackup);
        const reinstall = describeInstallPanelAction("install", "a fresh install on the ComfyUI host");
        // SAY WHAT THE PROBE FOUND, not what its failure would have implied. The
        // decision above is `dirIsPanelByIdentity`, which is fail-closed and stays
        // that way — but its `false` also covers "the pyproject could not be read",
        // and building "there is NO panel at <path>" out of that reports an absence
        // nobody observed. A real panel whose pyproject hit EACCES was told it was
        // gone, in the one message a worried user reads.
        // THREE ANSWERS, THREE SENTENCES. Folding `panel` in with `unreadable` would
        // repeat the defect one layer down: the decision above read `false`, but if
        // this later read DOES find the pack (the two observations were taken at
        // different moments), "that directory could not be read" is then a failure
        // that did not happen. The refusal stands either way — it is the sentence
        // that has to match what was seen.
        // ONE CAUSE PER SENTENCE, AND ONE REMEDY PER CAUSE — see PanelIdentityVerdict
        // for why `unreadable` is three answers rather than one. A stat that did not
        // complete never opened the file, so telling that user their pyproject
        // "could not be read" and to go and check permissions on it describes a
        // mechanism nobody observed and points at the wrong thing to fix.
        const staleIdentity = panelIdentityVerdict(staleCanonical, deps);
        const staleUndetermined = (because) => `whether a panel remains at ${staleCanonical} could NOT be determined (${because})`;
        const staleCanonicalClause = staleIdentity === "not-a-panel"
            ? `there is NO panel at ${staleCanonical}`
            : staleIdentity === "probe-failed"
                ? staleUndetermined(`its pyproject.toml could not even be checked for — the probe failed ` +
                    `before anything was opened`)
                : staleIdentity === "unreadable"
                    ? staleUndetermined(`its pyproject.toml is there, but opening it failed`)
                    : staleIdentity === "malformed"
                        ? staleUndetermined(`its pyproject.toml was read, but its contents would not parse`)
                        : `a re-read of ${staleCanonical} DID find the panel's pyproject.toml there, ` +
                            `disagreeing with the check this refusal was decided on`;
        // Its own sentence, not an aside inside the first one — an em-dash caveat
        // wedged mid-clause is exactly the shape a reader skims past.
        const staleNotGone = ` Do not read that as the panel being gone:`;
        const staleCanonicalCaveat = staleIdentity === "not-a-panel"
            ? ``
            : staleIdentity === "probe-failed"
                ? // The pyproject sits INSIDE this directory, so that is what to point at.
                    // `dirname` named custom_nodes — which the occupancy sentence below
                    // may have just demonstrated is perfectly traversable, sending the
                    // user to check something this very message shows is fine.
                    `${staleNotGone} nothing about that file was established, not even whether ` +
                        `it is there. That points at the directory holding it or at the filesystem ` +
                        `— check that ${staleCanonical} is reachable and readable — not at the file ` +
                        `itself, which was never opened.`
                : staleIdentity === "unreadable"
                    ? `${staleNotGone} the file was found and could not be opened, so nothing ` +
                        `was established either way — check permissions on that file.`
                    : staleIdentity === "malformed"
                        ? `${staleNotGone} the file is there and it read fine, it is its CONTENTS ` +
                            `that are damaged — repairing or replacing that pyproject.toml is the ` +
                            `remedy, not a permissions change.`
                        : ` Treat the panel at that path as possibly intact — nothing is being moved ` +
                            `while two readings of it disagree.`;
        // "NOT THE PANEL" IS NOT "NOT THERE", and the commands care about the second.
        // Identity answers which pack is at that path; it establishes NOTHING about
        // whether the path is free. An unrelated package sitting at
        // custom_nodes/comfyui-mcp-panel yields a correct `not-a-panel`, and the
        // ungated `mv backup canonical` then NESTS the backup inside that directory
        // instead of restoring it — after which the `&&` is satisfied and `rm` wipes
        // the swap record on the strength of a restore that did not happen. So both
        // commands are gated on PRESENCE, the only observation that speaks to it,
        // and only a confirmed absence counts. Gating one of a pair is the pointwise
        // mistake this file keeps paying for; this is its third instance.
        const staleCanonicalPresence = dirPresence(staleCanonical, deps);
        const canonicalProvenEmpty = staleCanonicalPresence === "absent";
        // Purely what was seen — no identity claim, so it composes with any of the
        // four clauses above without contradicting one.
        const occupancyNote = staleCanonicalPresence === "directory"
            ? ` A directory still exists at ${staleCanonical}.`
            : staleCanonicalPresence === "other"
                ? ` A regular FILE still exists at ${staleCanonical}.`
                : staleCanonicalPresence === "unknown"
                    ? ` Whether anything still exists at ${staleCanonical} could not be determined.`
                    : ``;
        // AND THE RATIONALE IS PRESENCE-SHAPED TOO. A directory move NESTS onto a
        // directory and FAILS outright onto a regular file — opposite mechanisms,
        // opposite remedies — and onto an indeterminate path neither can be claimed.
        // `other` exists so a regular file is not narrated as a directory; explaining
        // the directory hazard for one put the same defect back in the sentence that
        // was added to prevent it.
        //
        // The warning also names the thing THIS branch is moving: "the backup" has no
        // antecedent where no backup was found.
        const moveHazard = (moved) => {
            switch (staleCanonicalPresence) {
                case "directory":
                    return (`Moving onto a path that still exists nests ${moved} INSIDE it rather than ` +
                        `replacing what is there.`);
                case "other":
                    return (`A directory cannot be moved onto a regular file: the move would simply ` +
                        `FAIL and leave you exactly where you are. Remove or rename that file first.`);
                case "unknown":
                    return (`What is at that path could not be determined, so what a move onto it ` +
                        `would do could not be determined either.`);
                default:
                    return ``;
            }
        };
        // ONLY WHERE THE MOVE WOULD SUCCEED. The `&&` runs `rm` on the exit status of
        // `mv`, so it clears the record only when the move REPORTS success — which a
        // nest does and a failed move onto a file does not. Attaching this to `other`
        // warned about a deletion that cannot happen there.
        const recordAlsoLost = staleCanonicalPresence === "directory"
            ? ` The "&&" would then count that as success and delete the swap record too.`
            : ``;
        const restoreCommand = canonicalProvenEmpty
            ? `To put it back: mv "${staleBackup}" "${staleCanonical}" && rm "${journalPath}" — ` +
                `then restart ComfyUI.`
            : `${moveHazard("the backup")}${recordAlsoLost} ONLY once nothing remains at ` +
                `${staleCanonical}, put it back with: mv "${staleBackup}" "${staleCanonical}" && ` +
                `rm "${journalPath}" — then restart ComfyUI.`;
        const elsewhereCommand = canonicalProvenEmpty
            ? `If you have a copy elsewhere, move it to "${staleCanonical}"`
            : `${moveHazard("that copy")} So only once nothing remains at ` +
                `${staleCanonical}, and if you have a copy elsewhere, move that copy to ` +
                `"${staleCanonical}"`;
        return {
            ok: false,
            note: backupPresent
                ? ` WARNING: ${staleCanonicalClause}, and a swap journal is still ` +
                    `present at ${journalPath}.${staleCanonicalCaveat}${occupancyNote} Your previous ` +
                    `panel HAS been preserved — it is at ` +
                    `${staleBackup}. Nothing has been moved back automatically, because this ` +
                    `state cannot be told apart from a completed update followed by a DELIBERATE ` +
                    `uninstall, and restoring would then resurrect something you removed on ` +
                    `purpose. ${restoreCommand} To start fresh instead: delete ` +
                    `"${journalPath}" and run ${reinstall}.`
                : ` WARNING: ${staleCanonicalClause}, a swap journal is still present ` +
                    `at ${journalPath}, and no preserved copy of the panel could be found under ` +
                    `${join(comfyuiPath, "custom_nodes_backup")}.${staleCanonicalCaveat}` +
                    `${occupancyNote} Nothing has ` +
                    `been moved. ${elsewhereCommand}; otherwise delete ` +
                    `"${journalPath}" and run ${reinstall}.`,
        };
    }
    // A swap WAS interrupted. Which half depends on whether the canonical dir is
    // still there — and either way a panel is present in custom_nodes right now,
    // because the incoming directory is itself served.
    const journal = readJournal();
    // THE JOURNAL INFORMS; IT NEVER DESIGNATES. Taking the destination from
    // journal.dir means a stale — or hand-edited — record can send the incoming
    // directory anywhere on disk. The canonical path is a known constant under
    // THIS ComfyUI root, so derive it and use the journal only for the one thing
    // it alone knows: where the old copy was parked. Even that is accepted only
    // when it points inside this root's custom_nodes_backup.
    const canonicalDir = resolveCanonicalPanelDir(comfyuiPath, deps, journal);
    const backupRootPath = join(comfyuiPath, "custom_nodes_backup");
    // Containment, not prefix — see isInsideDir. The journal names the SOURCE of a
    // restore, so a traversal here reaches another installation.
    // The journal is PREFERRED (it names the copy this specific swap made) but is
    // not required: when it is gone, or names something unusable, the backup is
    // discovered by scanning. Depending on the journal to locate our own backup
    // would make a recoverable panel undiscoverable for no reason other than a
    // lost advisory file — in a state where `.incoming` has already PROVEN an
    // interrupted swap, so there is no ambiguity to be cautious about.
    // The SAME predicate as discovery. These two paths were disagreeing about
    // what counts as one of our backups — the journal route accepted any
    // panel-shaped directory under custom_nodes_backup while discovery required
    // the swap's own naming — so whether a given directory qualified depended on
    // which route happened to run. One question, one answer.
    const namedBackup = journal?.backupDir &&
        isInsideDir(backupRootPath, journal.backupDir) &&
        isPanelSwapBackupName(basename(journal.backupDir)) &&
        dirHasPanelFiles(journal.backupDir, deps)
        ? journal.backupDir
        : undefined;
    const journalBackup = namedBackup ?? discoverPanelBackup(comfyuiPath, deps);
    // "EXISTS" IS WEAKER THAN "WORKS", and the difference decides whether we are
    // allowed to delete anything. The discard branch below throws away the staged
    // panel on the strength of the canonical one being fine — so the canonical one
    // has to be PROVEN fine: a real panel pyproject and the built web bundle
    // ComfyUI actually serves. A husk left by a half-finished move, or a directory
    // someone emptied, would otherwise cost the user the only working copy.
    // Tri-state as well: an unreadable-but-present panel must not be announced as
    // "there is no panel at <path>".
    const canonicalState = dirPresence(canonicalDir, deps);
    const canonicalPresent = canonicalState === "directory";
    const canonicalUnknown = canonicalState === "unknown";
    // A regular file at the canonical name is present-but-wrong-kind: not a panel,
    // and not an absence either.
    const canonicalIsFile = canonicalState === "other";
    const canonicalUsable = canonicalPresent && dirHasPanelFiles(canonicalDir, deps);
    // The REASON, for the sentence. dirHasPanelFiles stays fail-closed for the
    // decision; this says whether its  was established or merely unread.
    const canonicalShape = canonicalPresent
        ? panelShapeVerdict(canonicalDir, deps)
        : "not-a-panel";
    // REPAIR ONLY UNDER THE OP LOCK. panelStatus is called from many places and
    // holds no lock, so repairing from there could move directories out from
    // under another process's in-flight swap. From a read it only reports.
    if (!opts.repair) {
        // ONLY SAY WHAT WAS ESTABLISHED. This is a REPORT, and it was asserting two
        // things it had not checked: that the existing panel "is intact" (from a
        // bare existsSync) and that the staged copy "still loads" (without asking
        // the integrity predicate at all). A damaged incoming tree or an unreadable
        // canonical directory therefore produced a confident, wrong reassurance —
        // the same fabricated-success shape as claiming an update that did not
        // happen, in the one message a worried user is reading.
        const canonicalWorks = canonicalUsable;
        const stagedVerdictForReport = verifyStagedTree(incomingDir, deps);
        // WHERE THE PREVIOUS COPY IS — or plainly that we could not find one.
        // `journalBackup ?? <backup root>` invented a location: with no validated
        // backup (an interrupted FIRST install, or a backup since lost) it still
        // asserted the panel "is at" a directory that may hold nothing. That is
        // worse than saying nothing, because it ends the user's search somewhere
        // empty — and the whole reason a manual-recovery residual is acceptable is
        // that the message tells them where the copy actually is.
        const backupSentence = journalBackup
            ? `the previous panel is preserved at ${journalBackup}`
            : `NO preserved copy of the previous panel could be found under ` +
                `${backupRootPath} — do not assume one is there`;
        // AND WHAT WE CAN SAY ABOUT SERVING. Manifest integrity proves this TREE
        // matches what we staged; it proves nothing about which install the running
        // ComfyUI serves. The root here may be a configured one the live server
        // never confirmed, so "ComfyUI is currently serving that staged copy" was
        // evidence about one subject presented as evidence about another. The claim
        // now lives entirely inside the qualifier, which puts it in the present
        // tense only for a live-derived root — the clauses before it say nothing
        // about the running server at all.
        const stagedServing = servingQualifier(deps, comfyuiPath, `that staged copy`);
        // FOUR STATES, NOT TWO — and this chain was collapsing them again at the
        // consumer. `dirPresence` distinguishes a directory, a regular FILE at the
        // canonical name, a confirmed absence and a failed probe; reading "other" as
        // absence reported "There is no panel at <path>" about a path where a file
        // had been positively observed. Say which one was seen.
        const canonicalGoneClause = canonicalIsFile
            ? `There is a FILE, not a directory, at ${canonicalDir}, so no panel is installed ` +
                `under that name`
            : `There is no panel at ${canonicalDir}`;
        return {
            ok: false,
            note: ` WARNING: a panel update was interrupted — a staged replacement is sitting at ` +
                `${incomingDir}. ${canonicalWorks
                    ? `Your existing panel at ${canonicalDir} looks complete — a readable panel ` +
                        `pyproject.toml and the built web bundle are both there.` +
                        servingQualifier(deps, comfyuiPath, `that panel`)
                    : canonicalUnknown
                        ? `Whether a panel remains at ${canonicalDir} could NOT be determined — that ` +
                            `directory could not be inspected, so do not read this as it being absent. ` +
                            `${backupSentence}.`
                        : canonicalPresent
                            ? `${startSentence(describePanelShape(canonicalDir, canonicalShape))}.`
                            : stagedVerdictForReport === "intact"
                                ? `${canonicalGoneClause}. The staged copy verifies intact, and ` +
                                    `${backupSentence}.${stagedServing}`
                                : `${canonicalGoneClause}, and the staged copy ` +
                                    `${stagedVerdictForReport === "corrupt"
                                        ? "did NOT survive intact"
                                        : "could NOT be verified"}; ${backupSentence}.`} Run ${describeInstallPanelAction("update", "an update on the ComfyUI host")} ` +
                `to finish or undo it automatically.`,
        };
    }
    if (canonicalPresent && !canonicalUsable) {
        // There is something at the canonical name, but it is not a working panel —
        // so neither branch below is safe. Discarding the staged copy could remove
        // the ONLY usable panel, and completing the move cannot rename onto an
        // occupied name. The user is not broken in the meantime: the staged copy is
        // dot-prefixed, so ComfyUI serves it. Report and touch nothing.
        //
        // THE REFUSAL IS RIGHT; THE SENTENCE HAS TO MATCH IT. `canonicalUsable` is
        // fail-closed, so an EACCES on the bundle lands here too — and saying the
        // directory "is NOT a usable panel" then states a verdict the read never
        // reached. Same action/message split as everywhere else: refuse either way,
        // but only claim what was established.
        // AND SO DOES THE INSTRUCTION. "Remove it if it is a leftover" is advice to
        // DELETE, and only `not-a-panel` establishes that there is nothing there
        // worth keeping. On an unread, unparsed or contradicting verdict it would be
        // inviting the user to delete a directory we just said we could not judge.
        const recheck = describeInstallPanelAction("status", "a re-check on the ComfyUI host");
        const nextStep = canonicalShape === "not-a-panel"
            ? `Inspect ${canonicalDir}, remove it if it is a leftover, then re-run ${recheck}.`
            : `Inspect ${canonicalDir} yourself before removing anything, then re-run ` +
                `${recheck}.`;
        return {
            ok: false,
            note: ` WARNING: a panel update was interrupted, and ` +
                `${describePanelShape(canonicalDir, canonicalShape)}. Nothing has ` +
                `been moved: discarding the staged replacement at ${incomingDir} could remove ` +
                `the only working copy.` +
                servingQualifier(deps, comfyuiPath, `that staged copy`) +
                ` ${nextStep}`,
        };
    }
    if (canonicalUsable) {
        // Interrupted BEFORE the old panel was moved aside. The working panel is
        // untouched, so the conservative repair is to ABANDON the staged copy rather
        // than complete a swap whose remaining steps were never verified. The update
        // simply did not happen, and can be retried.
        try {
            deps.removeDir?.(incomingDir);
            dropJournal();
            logger.warn(`[panel] cleared a staged panel replacement left by an interrupted update ` +
                `(${incomingDir}); the existing panel at ${canonicalDir} is untouched.`);
            return {
                ok: true,
                note: ` NOTE: a previous panel update was interrupted before it replaced anything. ` +
                    `The staged copy has been discarded and your existing panel at ${canonicalDir} ` +
                    `is intact — nothing was changed. Retry the update when convenient.`,
            };
        }
        catch (err) {
            return {
                ok: false,
                note: ` WARNING: a staged panel replacement at ${incomingDir} could not be cleared ` +
                    `(${err instanceof Error ? err.message : String(err)}). ComfyUI serves every ` +
                    `directory under custom_nodes, so it may SHADOW your real panel at ` +
                    `${canonicalDir}. Remove it manually, then restart ComfyUI.`,
            };
        }
    }
    // Interrupted AFTER the old panel was moved aside: the staged copy is the only
    // panel in custom_nodes, and ComfyUI is already serving it.
    //
    // VALIDATE IT BEFORE PROMOTING IT. The clone was checked before step 1, but a
    // crash can retain the directory ENTRY while losing file data that was still
    // unwritten — so the thing sitting there now may be a husk. Renaming a husk
    // into the canonical name would make it the panel of record and strand the
    // user's good copy in backup: the recovery destroying the very thing it
    // exists to protect. Re-check the tree as it is NOW, and when it does not
    // hold up, prefer the copy we know worked.
    const stagedVerdict = verifyStagedTree(incomingDir, deps);
    if (stagedVerdict === "unverifiable") {
        // "Cannot tell" is not "broken", and must not be treated as either outcome.
        // Promoting an unverified tree risks installing a husk over a working panel;
        // restoring the backup would discard a replacement that may be perfectly
        // fine. So: change nothing, and say exactly what is where.
        //
        // "ComfyUI is serving that copy, so the panel still loads" was TWO unchecked
        // claims in one breath — which install the running server reads, and that
        // the copy is a working panel (this branch could not even verify it). The
        // serving half goes through the qualifier; the loads-fine half is simply not
        // something this branch established, so it is not said.
        return {
            ok: false,
            note: ` WARNING: a panel update was interrupted, and whether the replacement left at ` +
                `${incomingDir} survived intact CANNOT be determined — its integrity manifest is ` +
                `missing or unreadable. Nothing has been moved; that copy is still sitting where ` +
                `the interrupted update left it.` +
                servingQualifier(deps, comfyuiPath, `that copy`) +
                (journalBackup
                    ? ` Your previous panel is preserved at ${journalBackup}; to go back to it: mv ` +
                        `"${journalBackup}" "${canonicalDir}" and remove "${incomingDir}", then restart ` +
                        `ComfyUI.`
                    : ` No previous copy could be located under ${backupRootPath}.`),
        };
    }
    if (stagedVerdict === "corrupt") {
        // journalBackup is already shape-checked by resolution (named or
        // discovered), so this only asks whether we HAVE one.
        const backupUsable = !!journalBackup;
        if (!backupUsable || !deps.rename) {
            return {
                ok: false,
                note: ` WARNING: a panel update was interrupted, and the replacement left at ` +
                    `${incomingDir} FAILED its integrity check — its files no longer match what was ` +
                    `staged, so it was not ` +
                    `moved into place. ${journalBackup
                        ? `Your previous panel is at ${journalBackup}, but it could not be ` +
                            `restored automatically either. Move it to "${canonicalDir}" manually`
                        : `No previous copy could be located under ${backupRootPath}. Reinstall ` +
                            `with ${describeInstallPanelAction("install", "a fresh install on the ComfyUI host")}`}, remove "${incomingDir}", then restart ComfyUI.`,
            };
        }
        // SAY SO IF THE RESTORE GOES BACKWARDS. The backup we select is the most
        // recent one we made, but "most recent" is not "newest version" — a coarse
        // mtime tie, or a backup left by an earlier downgrade, can mean the copy we
        // are about to reinstate is OLDER than the one the interrupted swap was
        // replacing. Restoring is still the right move (it is the copy we know
        // worked), but reporting it as plain recovery would hide a version
        // regression the user should know about.
        const restoringVersion = readPanelVersionAt(journalBackup, deps);
        const replacedVersion = journal?.previousVersion;
        const wentBackwards = !!restoringVersion &&
            !!replacedVersion &&
            SEMVER_RE.test(restoringVersion.trim()) &&
            SEMVER_RE.test(replacedVersion.trim()) &&
            compareSemver(restoringVersion, replacedVersion) < 0;
        // RESTORE FIRST, then clear the husk. Doing it the other way round would
        // leave custom_nodes with no panel at all in between, which is the state
        // this whole ordering exists to avoid.
        try {
            deps.rename(journalBackup, canonicalDir);
        }
        catch (err) {
            // INTEGRITY MISMATCH IS THE OBSERVATION; "INCOMPLETE" IS NOT. The manifest
            // says these files no longer match what was staged — which a single
            // differing README produces, with the pyproject and the served bundle
            // perfectly fine. Refusing to promote it is right either way; calling it
            // incomplete states a shape the check never looked at.
            return {
                ok: false,
                note: ` WARNING: a panel update was interrupted; the replacement at ${incomingDir} ` +
                    `FAILED its integrity check — its files no longer match what was staged — and ` +
                    `your previous panel at ${journalBackup} could not be restored automatically ` +
                    `(${err instanceof Error ? err.message : String(err)}). That failed replacement ` +
                    `is still sitting at ${incomingDir}.` +
                    servingQualifier(deps, comfyuiPath, `that failed replacement`) +
                    ` Move "${journalBackup}" to "${canonicalDir}" and remove "${incomingDir}", then ` +
                    `restart ComfyUI.`,
            };
        }
        let huskCleared = true;
        try {
            // Out of custom_nodes first (atomic), then delete — a partial recursive
            // delete in place would leave a damaged directory still shadowing.
            const rejected = join(comfyuiPath, `.comfyui-agent-panel.rejected-${Date.now()}`);
            deps.rename(incomingDir, rejected);
            try {
                deps.removeDir?.(rejected);
            }
            catch {
                // Outside custom_nodes — leftover bytes, not a shadow.
            }
        }
        catch {
            huskCleared = false;
        }
        dropJournal();
        logger.warn(`[panel] an interrupted panel update left a replacement at ${incomingDir} that ` +
            `FAILED its integrity check; restored the previous panel from ${journalBackup} ` +
            `instead.`);
        return {
            ok: huskCleared,
            note: ` NOTE: a previous panel update was interrupted and the replacement it left ` +
                `behind FAILED its integrity check — its files no longer match what was staged — ` +
                `so it was NOT installed. Your ` +
                `previous panel has been RESTORED to ${canonicalDir} — nothing was lost.` +
                (wentBackwards
                    ? ` NOTE: the restored copy is ${restoringVersion}, which is OLDER than the ` +
                        `${replacedVersion} the interrupted update was replacing — it is the most ` +
                        `recent backup on disk, but it is a step back. Re-run the update to move ` +
                        `forward again.`
                    : ``) +
                (huskCleared
                    ? ` Retry the update when convenient.`
                    : ` The rejected replacement at ${incomingDir} could not be removed and may ` +
                        `SHADOW the restored panel — delete that directory.`) +
                ` RESTART ComfyUI.`,
        };
    }
    // The replacement holds up. Finish the move so it sits under its proper name.
    if (!deps.rename) {
        return {
            ok: false,
            note: ` WARNING: a panel update was interrupted with the replacement still at ` +
                `${incomingDir}; it cannot be moved into place from here. Rename it to ` +
                `${canonicalDir} manually, then restart ComfyUI.`,
        };
    }
    try {
        deps.rename(incomingDir, canonicalDir);
        dropJournal();
        logger.warn(`[panel] completed an interrupted panel update: moved ${incomingDir} into place ` +
            `at ${canonicalDir}. RESTART ComfyUI.`);
        return {
            ok: true,
            note: ` NOTE: a previous panel update was interrupted after it had moved the old copy ` +
                `aside. The new panel has been moved into place at ${canonicalDir}` +
                `${journalBackup ? ` (the previous copy remains at ${journalBackup})` : ""}. ` +
                `RESTART ComfyUI to load it.`,
        };
    }
    catch (err) {
        return {
            ok: false,
            note: ` WARNING: a panel update was interrupted and its replacement at ${incomingDir} ` +
                `could not be moved into place (${err instanceof Error ? err.message : String(err)}). ` +
                `That replacement verified intact against its staging manifest; it is simply still ` +
                `under the staging name rather than ${canonicalDir}.` +
                servingQualifier(deps, comfyuiPath, `that replacement`) +
                ` Rename it to ${canonicalDir} manually, then restart ComfyUI.`,
        };
    }
}
/**
 * The four primitives the swap needs, or undefined when the dep set does not
 * carry all of them. Required as a SET: half a swap is worse than none, so a
 * partial dep set disables the fallback entirely rather than starting work it
 * cannot finish or undo.
 */
function resolveSwapOps(deps) {
    const { gitClonePanel, mkdirp, rename, removeDir, writeFile } = deps;
    if (!gitClonePanel || !mkdirp || !rename || !removeDir || !writeFile)
        return undefined;
    return { clone: gitClonePanel, mkdirp, rename, removeDir, writeFile };
}
/**
 * Does the CURRENT base resolution corroborate THIS tree — i.e. the running
 * server itself named a root, and the root it named IS this one?
 *
 * Both halves are load-bearing and neither implies the other:
 *
 *  - LIVE-DERIVED: the base resolution falls back to the configured path
 *    whenever `/system_stats` cannot be reached — and on a Comfy Desktop split
 *    install the configured path is exactly the tree the running server does
 *    NOT read (#766). A transient probe failure is indistinguishable from
 *    "there is no split", so the fallback can hand us a directory that holds a
 *    panel nobody is serving. REACHABLE IS NOT ENOUGH either: a /system_stats
 *    response with absent or unparseable argv proves only that something
 *    answered on the URL; it does not prove COMFYUI_PATH is the tree that
 *    server reads. Only a root the running server actually named qualifies.
 *
 *  - SAME TREE: asking only "is the CURRENT resolution live-derived?" answers
 *    a question about NOW, while the tree we are about to mutate or describe
 *    was frozen earlier. The two can diverge WITHOUT any retarget — a probe
 *    that failed at pin time (configured fallback frozen) recovering when the
 *    same target is re-resolved — so the pinned-generation check cannot see it
 *    (#820). A live-derived resolution naming root B is not evidence about
 *    root A: an observation of one tree must never license a mutation of, or
 *    a claim about, another.
 *
 * This asks only the resolution question. What an INJECTED dep set means
 * (there is no live server to corroborate against) is each caller's decision.
 */
function liveResolutionNamesTree(deps, tree, 
// Callers that also MESSAGE about the resolution pass their own read, so a
// cache expiry cannot split the verdict from the narration.
resolution = lastPanelBaseResolution()) {
    if (!isLiveDerivedBase(resolution))
        return false;
    return !!tree && !!resolution?.base && samePathCI(tree, resolution.base, deps);
}
async function updateViaRegistryZipReinstall(opts) {
    const { deps, ops, dir, comfyuiPath, previousVersion, result, managerReason, assertSameTarget, } = opts;
    // CORROBORATE THE TREE before replacing anything in it.
    assertSwapTreeCorroborated(deps, comfyuiPath, "the reinstall-from-source fallback", managerReason);
    // NOT A GIT CHECKOUT. This is the whole precondition: this path replaces the
    // directory wholesale, which would discard a checkout's branch, remotes and
    // any local commits.
    //
    // PROVE IT, do not infer it. `resolveGitRevision` returns undefined for BOTH
    // "there is no .git" and "there is a .git but its HEAD/ref/pointer could not
    // be read", so an unreadable or momentarily-malformed checkout looks exactly
    // like a Registry zip. Treating the second as the first would rename a
    // developer's working repo out of custom_nodes. So the absence of `.git`
    // itself is the requirement, and an unresolvable revision beside a present
    // `.git` is a refusal, not a licence.
    // The probes are TRI-STATE on purpose. `existsSync` collapses every error to
    // false, so an EACCES on `.git` would read as "there is no .git" and license
    // the replacement of a real checkout — the very inference this guard exists to
    // stop. `.git` is a directory in an ordinary clone and a FILE in a worktree or
    // submodule, so both shapes are probed, and only a CONFIRMED absence of both
    // (false, not undefined) counts as proof.
    const gitDir = join(dir, ".git");
    // `probeFile` is the only dep here whose FALSE is a real determination
    // (ENOENT/ENOTDIR, or "it is a directory"); `isDirectory` returns undefined
    // for a missing path too, so it cannot express absence. Combining the two
    // signals covers both shapes `.git` takes:
    //   undefined                     → the probe FAILED → refuse, prove nothing
    //   true                          → a regular file → worktree/submodule pointer
    //   false + existsSync            → present but not a file → a .git DIRECTORY
    //   false + !existsSync           → PROVEN absent → the zip shape, proceed
    const gitProbe = deps.probeFile(gitDir);
    if (gitProbe === undefined) {
        throw new PanelInstallError(`Panel update did NOT apply (${managerReason}), and the reinstall-from-source ` +
            `fallback is REFUSED: whether ${gitDir} exists could NOT be determined, so it ` +
            `cannot be PROVEN that ${dir} is not a git checkout. Replacing a checkout ` +
            `wholesale would discard its branch, remotes and any local commits, and this ` +
            `path never infers absence from a failed probe. Fix the permissions on ${dir} ` +
            `and retry, or update the pack manually.`);
    }
    if (gitProbe === true || deps.existsSync(gitDir)) {
        const rev = deps.gitRevision(dir);
        throw new PanelInstallError(`Panel update did NOT apply (${managerReason}), and the reinstall-from-source ` +
            `fallback is REFUSED: ${dir} has a .git${rev ? "" : " whose revision could not be read"}, so it is${rev ? "" : " (or may be)"} a git checkout, and replacing it ` +
            `wholesale would discard its branch, remotes and any local commits. The ` +
            `fast-forward path handles a checkout; this one exists only for a Comfy ` +
            `Registry zip install, which has no .git at all. Update the panel repo ` +
            `manually (git pull in ${dir})${rev ? "" : `, or repair its .git metadata first`}, then RESTART ComfyUI.`);
    }
    // Final pin check, adjacent to the mutation (same contract as every other
    // path): a pin written while we were probing must still be honoured.
    assertNotPinned("update", deps);
    // A shadowing copy must be cleared FIRST. Replacing the canonical dir while a
    // shadow is served would leave the browser on the old panel and make a
    // genuinely-applied update look like it failed.
    assertNoPanelShadow("update", dir, deps);
    // Stage OUTSIDE custom_nodes (a sibling of it), for two reasons: a
    // half-written clone inside custom_nodes would be served as a web extension
    // and shadow the real panel, and ComfyUI would try to import it as a node
    // pack. Same volume as the panel dir, so the swap is a rename, not a copy.
    const staging = join(comfyuiPath, `.comfyui-agent-panel.staging-${process.pid}-${Date.now()}`);
    if (deps.existsSync(staging)) {
        throw new PanelInstallError(`Panel update did NOT apply (${managerReason}), and the reinstall-from-source ` +
            `fallback is REFUSED: the staging path ${staging} already exists. Remove it ` +
            `and retry.`);
    }
    let stagedVersion;
    try {
        ops.clone(staging);
        // VALIDATE THE REPLACEMENT BEFORE ANYTHING MOVES.
        const stagedPyproject = join(staging, "pyproject.toml");
        if (!deps.existsSync(stagedPyproject)) {
            throw new PanelInstallError(`the freshly cloned panel at ${staging} has no pyproject.toml, so it cannot ` +
                `be identified as the panel pack`);
        }
        const parsed = parsePyproject(deps.readFile(stagedPyproject));
        if (parsed.projectName !== PANEL_REGISTRY_ID) {
            throw new PanelInstallError(`the freshly cloned panel at ${staging} declares [project].name ` +
                `"${parsed.projectName ?? "(none)"}", not "${PANEL_REGISTRY_ID}"`);
        }
        if (!parsed.version) {
            throw new PanelInstallError(`the freshly cloned panel at ${staging} has no readable version, so the ` +
                `update could not be verified afterwards`);
        }
        stagedVersion = parsed.version;
        // The BUILT bundle must be present. The panel ships prebuilt web assets, but
        // if a clone ever landed without them, swapping it in would serve the
        // browser nothing — an "update" that silently uninstalls the panel. An
        // indeterminate probe is not a pass.
        // (3) SHAPE. Pre-swap, the question is only "did the clone complete" —
        // answered by git exiting cleanly, the pack identifying itself, and the
        // bundle being present. Truncation-by-crash is a RECOVERY-time concern,
        // answered exactly by the integrity manifest below; trying to detect it
        // here by inspecting file contents was the wrong tool for the job.
        if (!dirHasPanelFiles(staging, deps)) {
            throw new PanelInstallError(`the freshly cloned panel at ${staging} is not a complete panel pack ` +
                `(pyproject identity or web/js/comfyui-mcp-panel.js missing), so installing it ` +
                `would leave the ComfyUI tab with no panel at all`);
        }
        // (4) INTEGRITY — record what we staged, so recovery can later prove whether
        // this exact tree survived. Throwing here is deliberate: a swap whose result
        // could never be verified is not one worth starting.
        writeStagedManifest(staging, deps);
        // NEVER BACKWARDS, and never a pointless swap.
        //
        // BOTH versions must be strictly comparable before a WHOLESALE REPLACEMENT.
        // Skipping the comparison when either side is unparseable would let a
        // `dev`/`0.11.38.1` clone overwrite a working 0.11.40 and then report
        // "Panel updated (0.11.40 → dev)" — an unproven downgrade of the live
        // panel, dressed as success. `compareSemver` returns 0 for anything it
        // cannot parse, so it cannot be trusted to catch this itself. No comparison
        // ⇒ no replacement.
        if (!SEMVER_RE.test(stagedVersion.trim())) {
            throw new PanelInstallError(`the freshly cloned panel reports version "${stagedVersion}", which is not a ` +
                `comparable version number, so it cannot be shown to be NEWER than the ` +
                `installed ${previousVersion ?? "(unreadable)"} — refusing to replace a ` +
                `working panel with an unverifiable one`);
        }
        if (!previousVersion || !SEMVER_RE.test(previousVersion.trim())) {
            throw new PanelInstallError(`the installed panel's version (${previousVersion ?? "unreadable"}) is not a ` +
                `comparable version number, so replacing it with the published ` +
                `${stagedVersion} could silently move you BACKWARDS — refusing. Check the ` +
                `pack's pyproject.toml at ${dir} first`);
        }
        {
            const delta = compareSemver(stagedVersion, previousVersion);
            if (delta < 0) {
                throw new PanelInstallError(`the published panel (${stagedVersion}) is OLDER than the one installed ` +
                    `(${previousVersion}) — refusing to move the panel backwards`);
            }
            if (delta === 0) {
                // Honest non-mutation — but "already current" is still a CLAIM about the
                // disk, and `previousVersion` was read before the Manager call and the
                // clone. Re-read it now, exactly as the mutating paths do: a pack that
                // was removed, moved or changed in that window must not be certified
                // current off a stale value (#639 discipline applies to claims of
                // currency, not only to claims of change).
                const still = await detectPanelInstall(deps);
                if (!still.installed ||
                    !still.version ||
                    !still.dir ||
                    !samePathCI(dir, still.dir, deps) ||
                    still.version !== previousVersion) {
                    throw new PanelInstallError(`Could not confirm the panel is current: a fresh clone carries ` +
                        `${stagedVersion}, but re-reading ${dir} afterwards found ` +
                        `${!still.installed
                            ? "no panel there"
                            : !still.version
                                ? "an unreadable version"
                                : !still.dir || !samePathCI(dir, still.dir, deps)
                                    ? `the panel resolved at a different directory (${still.dir ?? "none"})`
                                    : `version ${still.version}, not the ${previousVersion} this ` +
                                        `check was based on`}. NOT reporting "already up to date" on a stale reading. Re-run ` +
                        `install_comfyui(action:'panel', panel_action:'status').`);
                }
                // A shadow makes any statement about what the BROWSER loads untrue,
                // including "you are already current".
                assertNoPanelShadow("update", still.dir, deps);
                assertSameTarget("before reporting the panel as current");
                ops.removeDir(staging);
                const atTip = `Panel is already at the published version (${still.version}, re-read from ` +
                    `${still.dir}) — ${managerReason}, and a fresh clone of the panel repo ` +
                    `carries the SAME version, so there is nothing to install. Nothing changed ` +
                    `on disk; no restart needed.`;
                return {
                    action: "update",
                    result: result ?? {
                        mechanism: "git-clone",
                        message: atTip,
                        details: { staged_version: stagedVersion },
                    },
                    restartRequired: false,
                    message: atTip,
                    previousVersion,
                    installedVersion: still.version,
                };
            }
        }
    }
    catch (err) {
        // Nothing has moved yet — clean up the staging dir and report truthfully.
        ops.removeDir(staging);
        const detail = err instanceof Error ? err.message : String(err);
        throw new PanelInstallError(`Panel update did NOT apply: nothing was changed on disk at ${dir} (installed ` +
            `version still ${previousVersion ?? "unknown"}). ${managerReason}, and the ` +
            `reinstall-from-source fallback could not proceed — ${detail}. Fix: update ` +
            `ComfyUI-Manager on the host (git pull in custom_nodes/ComfyUI-Manager, or ` +
            `pip install -U comfyui_manager) and retry, or replace the panel manually ` +
            `(clone ${PANEL_REPO_URL} and swap it in, keeping the old copy OUT of ` +
            `custom_nodes), then RESTART ComfyUI.`);
    }
    // THE SWAP. Backup goes to a sibling of custom_nodes — never inside it (#641).
    const backupRoot = join(comfyuiPath, "custom_nodes_backup");
    const backupDir = join(backupRoot, `${basename(dir)}-${previousVersion ?? "unknown"}-${Date.now()}`);
    // THE SWAP, ordered so that a panel is ALWAYS present in custom_nodes.
    //
    // Replacing a directory cannot be done in one rename (renaming onto a
    // non-empty directory fails on every platform we support), so there is no
    // atomic version of this. What CAN be arranged is that no interruptible point
    // leaves custom_nodes empty of panels:
    //
    //   1. new panel  -> custom_nodes/.comfyui-agent-panel.incoming   (both present)
    //   2. old panel  -> custom_nodes_backup/...                      (incoming served)
    //   3. incoming   -> custom_nodes/comfyui-agent-panel             (done)
    //
    // After step 1 the old panel still serves; after step 2 the incoming one does
    // (ComfyUI serves dot-prefixed directories too — the fact behind #641 shadows,
    // working for us here). The earlier ordering did step 2 first and left a
    // window with NO panel at all, recoverable only via a journal whose directory
    // entry a power loss can drop even after the file's contents are fsync'd.
    //
    // Recovery is therefore derivable from the FILESYSTEM alone: the incoming
    // directory existing IS the evidence of an interrupted swap. The journal below
    // only records where the old copy went, and is never grounds to move anything
    // by itself (see reconcilePanelSwap).
    const incomingDir = join(comfyuiPath, "custom_nodes", PANEL_INCOMING_NAME);
    if (deps.existsSync(incomingDir)) {
        ops.removeDir(staging);
        throw new PanelInstallError(`Panel update did NOT apply (${managerReason}), and the reinstall-from-source ` +
            `fallback refused to start: a staged replacement is already sitting at ` +
            `${incomingDir}, which means an earlier update was interrupted and has not been ` +
            `reconciled yet. Re-run install_comfyui(action:'panel', panel_action:'status') to have it repaired, ` +
            `then retry.`);
    }
    const journalPath = swapJournalPath(comfyuiPath);
    assertSameTarget("before replacing the panel directory");
    try {
        ops.mkdirp(backupRoot);
        ops.writeFile(journalPath, JSON.stringify({
            dir,
            backupDir,
            staging: incomingDir,
            startedAt: Date.now(),
            previousVersion,
        }, null, 2));
    }
    catch (err) {
        ops.removeDir(staging);
        throw new PanelInstallError(`Panel update did NOT apply: nothing was changed on disk. ${managerReason}, and ` +
            `the reinstall-from-source fallback refused to start because it could not ` +
            `record where the previous panel will be moved, at ${journalPath} ` +
            `(${err instanceof Error ? err.message : String(err)}). Check permissions on ` +
            `${comfyuiPath} and retry.`);
    }
    // STEP 1 — bring the new panel IN. Both panels are now present; the incoming
    // one is dot-prefixed and would win registration, but this window is closed by
    // step 3 (and, if we are interrupted, by reconciliation).
    try {
        ops.rename(staging, incomingDir);
    }
    catch (err) {
        ops.removeDir(journalPath);
        ops.removeDir(staging);
        throw new PanelInstallError(`Panel update did NOT apply: nothing was changed on disk. ${managerReason}, and ` +
            `the reinstall-from-source fallback could not move the new panel into ` +
            `custom_nodes (${staging} → ${incomingDir}: ` +
            `${err instanceof Error ? err.message : String(err)}). The existing panel is ` +
            `untouched. Check permissions on ${comfyuiPath}/custom_nodes and retry.`);
    }
    // BARRIER BETWEEN STEP 1 AND STEP 2.
    //
    // The recovery guarantee rests on this: if "old panel moved aside" survives a
    // crash, the earlier "new panel moved in" must have survived too, or there is
    // no panel and no marker to find one by.
    //
    // BE PRECISE ABOUT WHAT THIS DOES AND DOES NOT GUARANTEE. It is tempting to
    // say "journaling filesystems commit metadata in order, so a later rename
    // cannot land while an earlier one is lost" — but that is NOT a durability
    // contract, on NTFS especially, absent an explicit flush. So the reordering
    // case is real, not exotic.
    //
    // What this barrier does give: the entry is confirmed VISIBLE before the old
    // panel is moved aside, so we never act on a rename that did not take, and the
    // platform is asked for a durability barrier where it can provide one (it
    // cannot on Windows, which has no directory handle to fsync).
    //
    // The residual — losing the .incoming entry while old→backup survives — lands
    // in the journal-without-incoming state, which deliberately REPORTS instead of
    // restoring: that state is indistinguishable from a completed swap followed by
    // a deliberate uninstall, so restoring would be acting on evidence we do not
    // have. The refusal names the backup path and the exact command to restore it.
    if (!deps.existsSync(incomingDir)) {
        ops.removeDir(staging);
        ops.removeDir(journalPath);
        throw new PanelInstallError(`Panel update did NOT apply: nothing was changed on disk. ${managerReason}, and ` +
            `the reinstall-from-source fallback stopped because the staged panel is not ` +
            `visible at ${incomingDir} after being moved there. The existing panel is ` +
            `untouched. Check ${comfyuiPath}/custom_nodes and retry.`);
    }
    deps.syncDir?.(join(comfyuiPath, "custom_nodes"));
    // STEP 2 — move the old panel OUT, to a sibling of custom_nodes. Leaving it
    // beside the new one would shadow it in the browser (#641).
    //
    // #771 r2 — there may be NO old panel to move. When ComfyUI-Manager unlinks
    // the pack and then fails to put it back, this fallback is reached with
    // `dir` already gone, and `rename` fails ENOENT. That failure was then
    // reported as "could not move the existing panel out of custom_nodes … your
    // panel at <dir> is untouched", which is false twice over: there is no panel
    // there, and it is precisely the case where a clean install is the only
    // remedy.
    //
    // Skipping the move is not a relaxation of the #641 guard. That guard exists
    // so an OLD copy cannot shadow the new one, and here there is no old copy.
    //
    // The question is "is there a directory ENTRY in the way", not "does a panel
    // resolve there", and those differ for a DANGLING SYMLINK (review finding):
    // `existsSync` follows the link and reports false, while the entry is still
    // very much present — so skipping the move on `existsSync` alone would leave
    // it sitting there and STEP 3's rename onto it would fail. That is milder
    // than the bug it replaces (a safe failure, not a shadow) but it would defeat
    // this very recovery on a real filesystem shape. `isSymlink` lstats, so it
    // sees the link itself.
    //
    // Requires PROVEN absence in BOTH senses — no resolvable target and no link
    // entry — so a probe that merely failed still takes the careful path below.
    const hadExistingPanel = deps.existsSync(dir) || deps.isSymlink(dir) === true;
    if (hadExistingPanel) {
        try {
            ops.rename(dir, backupDir);
        }
        catch (err) {
            // The old panel is still in place and serving; get the staged copy OUT of
            // custom_nodes so it cannot shadow it, and leave the install exactly as we
            // found it.
            //
            // MOVE FIRST, DELETE SECOND. A plain recursive delete can fail partway (a
            // locked file, a permission fault) and leave a half-deleted directory still
            // sitting in custom_nodes — still served, still shadowing, and now damaged.
            // A rename back out to the staging path is a single atomic metadata
            // operation on the same volume, so it either fully succeeds or changes
            // nothing; only then is the deletion attempted, where a failure is harmless
            // because the directory is no longer under custom_nodes.
            let cleared = true;
            try {
                ops.rename(incomingDir, staging);
                try {
                    ops.removeDir(staging);
                }
                catch {
                    // Outside custom_nodes now — leftover bytes, not a shadow.
                }
            }
            catch {
                cleared = false;
            }
            if (cleared)
                ops.removeDir(journalPath);
            throw new PanelInstallError(`Panel update did NOT apply: ${managerReason}, and the reinstall-from-source ` +
                `fallback could not move the existing panel out of custom_nodes ` +
                `(${dir} → ${backupDir}: ${err instanceof Error ? err.message : String(err)}). ` +
                `Your panel at ${dir} is untouched — nothing was moved out from under it.` +
                servingQualifier(deps, comfyuiPath, `that panel`) +
                (cleared
                    ? ` The staged replacement was discarded.`
                    : ` The staged replacement at ${incomingDir} could NOT be discarded and may ` +
                        `shadow it — remove that directory, then restart ComfyUI.`) +
                ` Check permissions / that ComfyUI does not hold the directory open (stop ` +
                `ComfyUI and retry).`);
        }
    }
    // STEP 3 — put the new panel under its proper name. Until this lands the
    // incoming directory is what ComfyUI serves, so the panel is never absent.
    try {
        ops.rename(incomingDir, dir);
    }
    catch (err) {
        // DO NOT "ROLL BACK" HERE. The obvious-looking recovery — delete the
        // incoming copy, move the backup home — deletes the ONLY panel currently
        // under custom_nodes before it knows whether the restore will succeed. If
        // that second step then fails, the user is left with no panel at all: the
        // exact outage this whole ordering exists to prevent, reintroduced on a
        // path that was trying to help.
        //
        // Nothing needs undoing anyway. The incoming directory is a fully validated
        // panel (identity, version, built web bundle — all checked before it was
        // staged) and ComfyUI is serving it right now. This is a NAMING problem, not
        // an outage, and reconciliation finishes it later. So leave the filesystem
        // exactly as it is, keep the journal, and say so.
        throw new PanelInstallError(`Panel update did NOT complete: ${managerReason}, and the reinstall-from-source ` +
            `fallback failed while moving the new panel into place ` +
            `(${incomingDir} → ${dir}: ${err instanceof Error ? err.message : String(err)}). ` +
            `NOTHING WAS LOST: the new panel is a fully validated copy sitting at ` +
            `${incomingDir}, and the previous copy is at ${backupDir}.` +
            servingQualifier(deps, comfyuiPath, `that new copy`) +
            ` Nothing was rolled back, ` +
            `because undoing this would have meant deleting the only panel in custom_nodes ` +
            `first. Re-run install_comfyui(action:'panel', panel_action:'status') to have it moved into place ` +
            `automatically, then RESTART ComfyUI.`);
    }
    // Both renames landed: there is a panel in custom_nodes again, so the
    // crash-repair window is CLOSED and the journal must go before verification —
    // a later throw here is an honest "could not verify", not a broken swap, and
    // leaving the journal would make the next operation "repair" a directory that
    // is already in place.
    ops.removeDir(journalPath);
    // VERIFY — same machinery as every other path (#639/#641): re-read the
    // installed identity FRESH from disk, fail closed on a shadow, and require
    // proven movement. The version we report is the one we OBSERVED, never
    // `stagedVersion` (what we intended to install).
    const post = await detectPanelInstall(deps);
    if (!post.installed || !post.version) {
        throw new PanelInstallError(`Panel reinstall-from-source moved the new pack into ${dir}, but re-reading it ` +
            `afterwards found it ${post.installed ? "present with an unreadable version" : "absent"}. ` +
            `NOT reporting success. The previous panel is at ${backupDir} — restore it ` +
            `if the new one is broken, then RESTART ComfyUI.`);
    }
    if (!post.dir || !samePathCI(dir, post.dir, deps)) {
        throw new PanelInstallError(`Panel reinstall-from-source cannot be verified: after swapping ${dir}, ` +
            `re-detection resolved a DIFFERENT panel dir (${post.dir ?? "none"}), so the ` +
            `before/after comparison would not describe the directory that changed. NOT ` +
            `reporting success — check custom_nodes for duplicate panel dirs. The ` +
            `previous panel is at ${backupDir}.`);
    }
    assertNoPanelShadow("update", post.dir, deps);
    if (previousVersion && post.version === previousVersion) {
        throw new PanelInstallError(`Panel reinstall-from-source did NOT change the installed version (still ` +
            `${post.version}) at ${dir}, even though a fresh clone was swapped in. NOT ` +
            `reporting an update — something is serving the old pack. The previous panel ` +
            `is at ${backupDir}.`);
    }
    // AND IT MUST HAVE GONE FORWARD. The STAGED version was checked before the
    // swap, but this is a fresh read taken afterwards, and the two can disagree:
    // a concurrent Manager run or a manual edit between the final rename and this
    // read can leave something older in place. Reporting that as "updated" is the
    // same fabricated progress this file refuses everywhere else, so the OBSERVED
    // version is checked, not just the intended one.
    // AN UNCOMPARABLE RESULT IS "COULD NOT COMPARE", NOT "FINE". Both versions
    // were required to be strictly comparable before the swap, so a post-swap
    // version that no longer parses means something changed the pack underneath
    // us — and a value like "0.11.33.0" would otherwise slip past the regression
    // check below (four components fail the strict grammar) and be reported as
    // "Panel updated (0.11.34 → 0.11.33.0)". Refuse rather than claim.
    if (!SEMVER_RE.test(post.version.trim())) {
        throw new PanelInstallError(`Panel reinstall-from-source cannot be verified: after the swap, ${dir} reports ` +
            `version "${post.version}", which is not a comparable version number, so whether ` +
            `it moved FORWARD from ${previousVersion ?? "the previous version"} cannot be ` +
            `established. NOT reporting an update. The previous panel is at ${backupDir}; ` +
            `check the pack's pyproject.toml before restarting ComfyUI.`);
    }
    if (previousVersion &&
        SEMVER_RE.test(previousVersion.trim()) &&
        compareSemver(post.version, previousVersion) < 0) {
        throw new PanelInstallError(`Panel reinstall-from-source did NOT go forward: after the swap, ${dir} reads ` +
            `${post.version}, which is OLDER than the ${previousVersion} it replaced — even ` +
            `though the clone that was staged was newer. Something changed the pack between ` +
            `the swap and this check. NOT reporting an update. The previous panel is at ` +
            `${backupDir}; check custom_nodes before restarting ComfyUI.`);
    }
    assertSameTarget("before reporting the reinstall");
    const message = `Panel updated (${previousVersion ?? "unknown"} → ${post.version}) by reinstalling ` +
        `from source, verified on disk at ${dir}: ${managerReason}, and the pack is a ` +
        `Comfy Registry ZIP install with no .git, so neither ComfyUI-Manager nor a ` +
        `fast-forward could move it (#771). A fresh clone of ${PANEL_REPO_URL} was ` +
        `swapped in and the previous copy was moved OUT of custom_nodes to ${backupDir} ` +
        `(leaving it beside the panel would shadow the new one in the browser). RESTART ` +
        `ComfyUI to load the updated panel node, then hard-refresh the ComfyUI tab.`;
    return {
        action: "update",
        result: result
            ? { ...result, message }
            : {
                mechanism: "git-clone",
                message,
                details: { backup_dir: backupDir, installed_version: post.version },
            },
        restartRequired: true,
        message,
        previousVersion,
        installedVersion: post.version,
    };
}
/**
 * install/update/reinstall the panel. LOCAL-only and refuses dev symlinks.
 * Targets the "nightly" channel unless a version is given. Caller must RESTART
 * ComfyUI to load the change.
 */
export function runPanelAction(action, deps = defaultDeps, opts = {}) {
    // Serialized: never let two panel mutations interleave (see withPanelOpLock).
    return withPanelOpLock(() => runPanelActionInner(action, deps, opts));
}
/**
 * Public entry: runs the action, then makes sure any REPAIR it had to perform
 * first is visible to the caller.
 *
 * Restoring somebody's panel — or discarding a staged replacement — is a
 * material change to their install. Reporting it only in the server log would
 * leave the caller believing nothing happened but the update they asked for. It
 * rides on BOTH outcomes: a failed action that nonetheless repaired something
 * must say so just as loudly as a successful one.
 */
export async function runPanelActionInner(action, depsIn, opts = {}) {
    const carried = {};
    try {
        const result = await runPanelActionCore(action, depsIn, opts, carried);
        if (!carried.note)
            return result;
        return {
            ...result,
            recoveryNote: carried.note,
            message: `${result.message}${carried.note}`,
        };
    }
    catch (err) {
        // ANY error, not just ours. install/reinstall await ComfyUI-Manager
        // operations directly, which reject with NodeManagementError and friends —
        // so restricting this to PanelInstallError meant a repair could happen and
        // then be silently dropped because the action failed for an unrelated
        // reason. The message is appended in place rather than re-wrapped, so the
        // original error's CLASS and details survive for callers that check them.
        if (carried.note && err instanceof Error) {
            err.message = `${err.message}${carried.note}`;
        }
        throw err;
    }
}
async function runPanelActionCore(action, depsIn, opts, carried) {
    const targetVersion = opts.version?.trim() || PANEL_VERSION;
    // Resolve the LIVE ComfyUI base ONCE and FREEZE it for the whole operation,
    // so detection, the shadow scan, the staging/backup paths and the post-op
    // re-read all describe the same directory (#766/#769). A base that drifted
    // mid-op would make the "did the pack move?" proof compare two different
    // trees — see pinPanelBase.
    const deps = await pinPanelBase(depsIn);
    // P1b — truly LOCAL-only. Refuse in remote/cloud mode even when COMFYUI_PATH
    // is set: installCustomNode/reinstallCustomNode would queue Manager mutations
    // against the REMOTE host while our symlink guard inspected the LOCAL disk —
    // the wrong filesystem. The panel must be managed on the ComfyUI host itself.
    if (!deps.isLocalMode()) {
        throw new PanelInstallError(`Panel ${action} is local-only and is refused in remote/cloud mode ` +
            `(a remote COMFYUI_URL / Comfy Cloud is active). Install the panel on ` +
            `the ComfyUI host itself.`);
    }
    // Bind the base ONCE. Every later read in this operation — the registry-zip
    // fallback's staging/backup paths especially — must be rooted at the SAME
    // ComfyUI the detection scanned, never at a value re-resolved mid-flight.
    const comfyPath = deps.comfyuiPath();
    if (!comfyPath) {
        throw new PanelInstallError(`Panel ${action} is local-only and requires a local ComfyUI install, and none ` +
            `could be resolved — neither COMFYUI_PATH, a saved default workspace, nor ` +
            `the running server's own launch arguments yielded a root containing ` +
            `custom_nodes (#769). Set COMFYUI_PATH or save a default workspace with ` +
            `workspace(action='set_default'). (This is a no-op in remote/cloud mode.)`);
    }
    // PIN GUARD — before any Manager mutation is queued. This is the single choke
    // point that makes "we never move a pinned user" true for every caller (the
    // sync skill, the panel, a hand-written install_comfyui(action:'panel') call), not just the ones
    // that remembered to check. It is re-checked once more immediately before the
    // Manager call, since detection below is not instantaneous.
    //
    // It runs BEFORE the interrupted-swap repair below, which also moves
    // directories. "Nothing here moves the panel while a pin is in force" has to
    // mean every mutation, including a well-intentioned repair: a pinned user who
    // was interrupted mid-update gets the broken state REPORTED (panelStatus says
    // where the copy is), not silently rearranged.
    assertNotPinned(action, deps);
    // REPAIR AN INTERRUPTED SWAP. If a previous reinstall-from-source was killed
    // between its two renames, custom_nodes has no panel right now and the working
    // copy is sitting in custom_nodes_backup. Detection would read that as "not
    // installed" and this operation would happily install over the top of a broken
    // state. Both mutation entry points hold the panel op lock, so this is the
    // safe place to put it back.
    // BIND THE OPERATION TO ONE TARGET.
    //
    // This function freezes a LOCAL filesystem base, but the ComfyUI-Manager
    // mutation it delegates to captures the HTTP target independently, at its own
    // call time. A retarget landing between the two would dispatch Manager work to
    // one server and then verify — or clone-swap — the other server's tree, and
    // report the second's result for the first's request. The orchestrator makes
    // this reachable in practice: it starts the panel sync on a hello and
    // retargets in the same handler.
    //
    // We cannot make the two captures atomic from here, but we can refuse to
    // report anything once they may have diverged: the generation is checked
    // immediately before the mutation and again before any success is returned.
    //
    // Use the generation captured when the base was FROZEN, not one read here.
    // performPanelSync freezes the base and only then calls this function, and a
    // retarget can land in between — by the time we re-enter, that change has
    // already happened and reading the generation now would record it as the
    // status quo, making the very divergence we are guarding against invisible.
    const opGeneration = pinnedGenerationOf(deps) ?? panelTargetGeneration();
    const assertSameTarget = (stage) => {
        if (panelTargetGeneration() === opGeneration)
            return;
        throw new PanelInstallError(`Panel ${action} ABORTED ${stage}: the ComfyUI this orchestrator targets changed ` +
            `while the operation was in flight, so the local directory it was working in ` +
            `(${comfyPath}) and the server the ComfyUI-Manager request went to may no ` +
            `longer be the same install. NOT reporting a result that could describe the ` +
            `wrong one. Re-run install_comfyui(action:'panel', panel_action:'status'), then retry.`);
    };
    // The repair MOVES directories (it completes or discards a staged swap), so it
    // is bound to the frozen target exactly like every other mutation here. Without
    // this, a retarget landing between the freeze and this point could discard or
    // complete the PREVIOUS target's interrupted swap before the later assertions
    // ever ran.
    assertSameTarget("before repairing an interrupted swap");
    const swapRepair = reconcilePanelSwap(comfyPath, deps, { repair: true });
    if (!swapRepair.ok) {
        // A broken swap we could NOT put right. Proceeding would install a fresh
        // panel into the empty canonical path and report success while the user's
        // real one stayed stranded in the backup dir — and the next reconcile,
        // seeing a directory there again, would delete the journal that says where
        // it went. Refuse until a human resolves it.
        throw new PanelInstallError(`Panel ${action} REFUSED: a previous panel update was interrupted and could not ` +
            `be repaired automatically, so custom_nodes is in an unknown state and ` +
            `${action}ing now could strand your existing panel.${swapRepair.note ?? ""}`);
    }
    if (swapRepair.note) {
        logger.info(`[panel] before ${action}:${swapRepair.note}`);
    }
    // Carried onto every result this operation returns (see PanelActionResult
    // .recoveryNote): the caller must be told their panel was repaired, not just
    // that the update they asked for finished.
    carried.note = swapRepair.note;
    const detection = await detectPanelInstall(deps);
    if (detection.isDevSymlink) {
        throw new PanelInstallError(`Refusing to ${action} the panel: it is a dev install (symlink at ${detection.dir}) ` +
            `— managed manually. Update it via your repo/git instead.`);
    }
    // Capture the on-disk identity BEFORE the op (from the guard detection we just
    // did — read fresh, not cached elsewhere).
    const wasPresent = detection.installed;
    const previousVersion = detection.version;
    const previousRev = detection.gitRev;
    if (action === "update") {
        // Final pin check, inside the op lock and adjacent to the mutation: a pin
        // set while detection was running must still be honoured.
        assertNotPinned(action, deps);
        // #771 — STATUS AND UPDATE MUST NOT CONTRADICT EACH OTHER.
        //
        // `deps.update` is the GENERIC node-pack update. Its post-op presence gate
        // (#730) resolves the pack through ComfyUI-Manager's installed/registry
        // list, keyed on the registry id — a completely different question from the
        // one `panelStatus` answers by scanning custom_nodes and reading
        // pyproject.toml. For a Comfy Registry ZIP install those two disagree: the
        // Manager list does not resolve `comfyui-agent-panel`, so the generic gate
        // throws "…is not installed locally and was not found in the
        // ComfyUI-Manager registry", asserting an absence it never checked on disk —
        // about a pack `status` had just reported at a concrete dir and version.
        //
        // We hold the authoritative on-disk answer right here in `detection`. So a
        // failure from the generic path is recorded as "ComfyUI-Manager could not
        // update it" and we continue to the verified fallbacks. It is only allowed
        // to propagate when the disk AGREES the pack is absent — then the message
        // is true and there is genuinely nothing to update.
        assertSameTarget("before the ComfyUI-Manager update");
        // updateCustomNode captures this same base before its first await, but a
        // Manager-absent failure has no NodeOpResult to carry it back. Capture it
        // here too so the absence probe cannot silently drift to a different
        // configured ComfyUI while this operation is in flight.
        let managerBaseForFailure;
        try {
            managerBaseForFailure = deps.managerBase?.();
        }
        catch {
            managerBaseForFailure = undefined;
        }
        let result;
        let managerFailure;
        try {
            result = await deps.update({ id: PANEL_REGISTRY_ID });
        }
        catch (err) {
            // Propagate only when the DISK agrees the pack is absent — and only when
            // that reading is trustworthy. A failed custom_nodes enumeration also
            // yields `installed: false` (scanReliable === false), which is "we could
            // not look", not "it is not there"; rethrowing the Manager's absence
            // claim on that basis would put the #771 contradiction straight back.
            if (!detection.installed) {
                if (detection.scanReliable === false) {
                    throw new PanelInstallError(`Panel update could not be attempted: ComfyUI-Manager reported an error ` +
                        `(${err instanceof Error ? err.message : String(err)}), and custom_nodes ` +
                        `could not be enumerated to check what is actually installed, so whether ` +
                        `the panel is present is UNKNOWN. NOT accepting "not installed" from a ` +
                        `scan that did not run. Make ${comfyPath}/custom_nodes readable, then ` +
                        `re-run install_comfyui(action:'panel', panel_action:'status').`);
                }
                throw err;
            }
            managerFailure = err instanceof Error ? err.message : String(err);
            logger.warn(`[panel] ComfyUI-Manager could not update the panel (${managerFailure}) — the ` +
                `pack IS installed at ${detection.dir ?? "custom_nodes"}` +
                `${detection.version ? ` (${detection.version})` : ""}, so falling back to a ` +
                `direct, verified update (#771).`);
        }
        if (!result) {
            // RE-READ BEFORE FALLING BACK. A thrown Manager error does not prove the
            // Manager did nothing: its post-op presence check is the thing that
            // failed, so the update itself may already have landed. Falling back on
            // the PRE-call reading would then compare the fresh clone against a stale
            // version and could swap a just-installed newer panel out for an older
            // published one — a downgrade, reported as an update.
            const afterManager = await detectPanelInstall(deps);
            const managerMoved = afterManager.installed &&
                !!afterManager.version &&
                // SAME PHYSICAL DIRECTORY, like every other movement proof here. A
                // before/after comparison that spans two directories is not evidence of
                // an update: if the panel re-resolved from A to B, the "previous"
                // version is A's and the "installed" version is B's, and crediting the
                // difference to the Manager would invent an update that never happened.
                !!detection.dir &&
                !!afterManager.dir &&
                samePathCI(detection.dir, afterManager.dir, deps) &&
                ((!!previousVersion && afterManager.version !== previousVersion) ||
                    (!!previousRev && !!afterManager.gitRev && afterManager.gitRev !== previousRev));
            // MOVED IS NOT THE SAME AS FORWARD. The Manager can resolve the pack to
            // an older build and land it, then throw its bad presence check — and a
            // bare "the version changed" test would report that regression as an
            // update, leaving the user quietly downgraded by a message congratulating
            // them. A git-HEAD advance with an unchanged version is still a legitimate
            // nightly move, so only a READABLE, COMPARABLE regression is caught here.
            if (managerMoved &&
                !!previousVersion &&
                !!afterManager.version &&
                SEMVER_RE.test(previousVersion.trim()) &&
                SEMVER_RE.test(afterManager.version.trim()) &&
                compareSemver(afterManager.version, previousVersion) < 0) {
                throw new PanelInstallError(`Panel update did NOT go forward: ComfyUI-Manager replaced the pack at ` +
                    `${afterManager.dir ?? "custom_nodes"} with an OLDER version ` +
                    `(${previousVersion} → ${afterManager.version}) and then errored while ` +
                    `checking its own installed-pack list (${managerFailure}). NOT reporting ` +
                    `this as an update — you are now on an older panel than you started with. ` +
                    `Update ComfyUI-Manager on the host and retry, or restore the newer panel, ` +
                    `then RESTART ComfyUI.`);
            }
            // AND THE SAME RULE FOR AN UNCOMPARABLE PAIR. The regression check above
            // needs both sides to parse; a changed-but-unparseable pair would sail
            // past it and be announced as an update whose direction was never
            // established. A git-HEAD advance with an unchanged version is unaffected.
            const managerVersionMoved = !!previousVersion &&
                !!afterManager.version &&
                afterManager.version !== previousVersion;
            if (managerMoved &&
                managerVersionMoved &&
                !(SEMVER_RE.test(previousVersion.trim()) &&
                    SEMVER_RE.test(afterManager.version.trim()))) {
                // Disclosed rather than refused, for the same reason as finalizeUpdate:
                // the change is on disk, so reporting a failure would cost the user the
                // restart instruction and invite a retry that re-runs the whole swap.
                assertNoPanelShadow("update", afterManager.dir, deps);
                assertSameTarget("before reporting a changed panel");
                const changed = `Panel CHANGED at ${afterManager.dir}: "${previousVersion}" → ` +
                    `"${afterManager.version}". At least one of those is not a comparable version ` +
                    `number, so whether this moved FORWARD or BACKWARD could NOT be established — ` +
                    `this is not being reported as an upgrade. (ComfyUI-Manager also errored while ` +
                    `checking its own installed-pack list: ${managerFailure}.) RESTART ComfyUI, then ` +
                    `check the version with ` +
                    `${describeInstallPanelAction("status", "a re-check on the ComfyUI host")}.`;
                return {
                    action: "update",
                    result: {
                        mechanism: "manager-http",
                        message: changed,
                        details: { manager_error: managerFailure },
                    },
                    restartRequired: true,
                    message: changed,
                    previousVersion,
                    installedVersion: afterManager.version,
                };
            }
            if (managerMoved && afterManager.dir) {
                // It DID work; only the reporting failed. Verify it the same way every
                // other success is verified, then report the movement we observed.
                assertNoPanelShadow("update", afterManager.dir, deps);
                assertSameTarget("before reporting a verified update");
                const moved = `Panel updated (${previousVersion ?? "unknown"} → ${afterManager.version}), ` +
                    `verified on disk at ${afterManager.dir}. ComfyUI-Manager applied the update ` +
                    `but then errored while checking its own installed-pack list ` +
                    `(${managerFailure}) — that check reads the Manager's registry view, not the ` +
                    `disk, so it is wrong about this pack, not about the update. RESTART ComfyUI ` +
                    `to load it.`;
                return {
                    action: "update",
                    result: {
                        mechanism: "manager-http",
                        message: moved,
                        details: { manager_error: managerFailure },
                    },
                    restartRequired: true,
                    message: moved,
                    previousVersion,
                    installedVersion: afterManager.version,
                };
            }
            // Nothing moved. Continue with the FRESH reading, so the fallback's
            // never-backwards check compares against what is on disk NOW.
            const freshVersion = afterManager.installed ? afterManager.version : undefined;
            const freshDir = afterManager.installed ? afterManager.dir : detection.dir;
            const freshRev = afterManager.installed ? afterManager.gitRev : previousRev;
            // Route to whichever direct path fits this install SHAPE — a fast-forward
            // for a checkout, a verified reinstall for a registry zip. Both prove
            // movement on disk before reporting anything.
            //
            // The Manager's RAW text is deliberately NOT quoted into what we report.
            // It is precisely the sentence we have just disproved ("it is not
            // installed locally…"), and carrying it into a success message would
            // reintroduce the contradiction one layer down — the user would read a
            // verified update that still insists the pack does not exist. The raw
            // text is logged above and travels in `details` for diagnosis; the
            // human-facing summary states only what is true.
            const managerReason = `ComfyUI-Manager could not update the panel (it does not resolve ` +
                `"${PANEL_REGISTRY_ID}" in its own installed-pack list, though the pack IS ` +
                `on disk at ${freshDir ?? "custom_nodes"})`;
            if (freshRev && freshDir) {
                // Same corroboration the ZIP swap demands: a DIRECT mutation must act
                // on a tree the running server named, not merely on a configured one.
                assertSwapTreeCorroborated(deps, comfyPath, "the git fallback", managerReason);
                return updateViaGitCheckoutFallback({
                    deps,
                    dir: freshDir,
                    previousVersion: freshVersion,
                    previousRev: freshRev,
                    assertSameTarget,
                    managerReason,
                    // The Manager errored on its OWN pack list, not on its queue — and
                    // an erroring Manager is no less likely to have a task writing to
                    // this checkout, so the merge is bracketed here exactly as it is
                    // everywhere else. The base was captured immediately before the
                    // Manager call even though the thrown path has no NodeOpResult to
                    // carry it back; the assertSameTarget checks around the mutation
                    // still catch a local retarget in the meantime.
                    managerBase: managerBaseForFailure,
                    // Synthesize the shape the fallback reports around; there is no real
                    // Manager result because the Manager call failed.
                    result: {
                        mechanism: "manager-http",
                        message: managerReason,
                        details: { manager_error: managerFailure },
                    },
                });
            }
            const swapOps = freshDir ? resolveSwapOps(deps) : undefined;
            if (freshDir && swapOps) {
                return updateViaRegistryZipReinstall({
                    deps,
                    ops: swapOps,
                    dir: freshDir,
                    comfyuiPath: comfyPath,
                    previousVersion: freshVersion,
                    assertSameTarget,
                    // The raw Manager text rides along in `details` for diagnosis; the
                    // message the fallback composes never quotes it.
                    result: {
                        mechanism: "manager-http",
                        message: managerReason,
                        details: { manager_error: managerFailure },
                    },
                    managerReason,
                });
            }
            throw new PanelInstallError(`Panel update did NOT apply: ${managerReason}. The panel IS installed` +
                `${freshDir ? ` at ${freshDir}` : ""}` +
                `${freshVersion ? ` (version ${freshVersion})` : ""} — whatever the ` +
                `Manager's error says, that is what install_comfyui(action:'panel', panel_action:'status') reads ` +
                `off the disk — but no direct update path is available here (it is not a ` +
                `git checkout, so there is nothing to fast-forward). ` +
                `${describePanelUpdateRecovery()} (ComfyUI-Manager reported: ${managerFailure})`);
        }
        // #639 — VERIFY the update actually advanced the pack. Re-read the installed
        // identity FRESH from disk (never trust Manager's queue-drained signal, nor
        // any value captured before the op), then classify honestly.
        const post = await detectPanelInstall(deps);
        // #641 — a shadowing copy makes the SERVED panel differ from post.version, so
        // even a real version advance is a fabricated success. Fail closed first.
        assertNoPanelShadow(action, post.dir, deps);
        // #639 req — the installed VERSION must be readable post-update, else we
        // cannot verify the applied version (fail closed; never trust a HEAD move
        // alone when the pyproject version can't be read).
        if (!post.installed || !post.version) {
            // #771 r2 — REACHABILITY, not a missing remedy. The verified reinstall
            // ~200 lines below (`zipSwapOps` → updateViaRegistryZipReinstall) exists
            // for exactly this situation: "Manager drained its queue without moving
            // the pack, and this is a registry-zip install with no .git to
            // fast-forward". But it is gated on `post.dir`, and THIS throw fires
            // first whenever the post-op reading is unusable.
            //
            // So the one state where a fresh clone is the ONLY possible remedy — the
            // pack is GONE after a drained queue — was the state routed to a bare
            // throw, whose own text tells the reader to "reinstall the panel from
            // source": an operation this file already implements and had just
            // decided not to run. That is the #771 self-blocking loop, one install
            // shape over from the one it was filed about.
            //
            // The asymmetry made it easy to miss: when Manager THROWS, the sibling
            // path substitutes `detection.dir` for a missing post-op dir and the
            // rescue fires. When Manager LIES with a drained queue — the more common
            // failure, and the reporter's case — it did not.
            //
            // Same substitution as that sibling path rather than a second rule, and
            // the same shape gate: a registry zip (`!previousRev`) that WAS installed
            // before. A checkout keeps the throw — it has a git history, so a
            // destructive re-clone is not the right answer for it.
            const rescueDir = post.dir ?? detection.dir;
            const rescueOps = !previousRev && detection.installed && rescueDir ? resolveSwapOps(deps) : undefined;
            if (rescueOps && rescueDir) {
                return updateViaRegistryZipReinstall({
                    deps,
                    ops: rescueOps,
                    dir: rescueDir,
                    comfyuiPath: comfyPath,
                    previousVersion,
                    result,
                    assertSameTarget,
                    managerReason: post.installed
                        ? `ComfyUI-Manager reported the queue drained but the pack's version is unreadable at ${rescueDir}`
                        : `ComfyUI-Manager reported the queue drained and the pack is no longer present at ${rescueDir}`,
                });
            }
            throw new PanelInstallError(`Could not verify the panel update applied: the pack is ${post.installed ? "present but its version is unreadable" : "not present"} after ComfyUI-Manager reported the queue drained. NOT reporting success. ` +
                `Re-check the pack and retry, or reinstall the panel from source, then ` +
                `RESTART ComfyUI.`);
        }
        const verdict = classifyPanelUpdate({
            previousVersion,
            installedVersion: post.version,
            previousRev,
            installedRev: post.gitRev,
        }, result.details);
        /** #1999 — what ComfyUI-Manager recorded for THIS update's task, once the
         *  on-disk check has shown nothing moved. Stays `unknown` on every path
         *  that never got that far, and on every Manager that cannot answer. */
        let managerTaskVerdict = { kind: "unknown" };
        // A PROVEN REGRESSION SHORT-CIRCUITS EVERYTHING. It must not fall through
        // to the no-op/zip fallbacks: those would quietly re-install over the top
        // and the user would never learn that ComfyUI-Manager had just moved them
        // backwards. Surface it instead.
        if (verdict.outcome === "downgraded") {
            assertSameTarget("before reporting the update verdict");
            return finalizeUpdate(verdict, post, result); // throws, honestly
        }
        // #1133 — STAGED IS NOT FAILED, and it short-circuits every fallback below.
        //
        // Manager cannot swap a directory the running server is serving, so for the
        // panel specifically it reserves the switch for the next startup and returns
        // success. The pack legitimately has not moved, which is exactly the shape
        // the no-op/unverified branches read as failure. Reporting that sends the
        // agent to reinstall-from-source — a destructive path — for something a
        // restart finishes, and on ComfyUI Desktop (where the reinstall fallback
        // refuses and restart_comfyui declines to act) it leaves no route at all.
        //
        // Only a POSITIVE "reserved" reading diverts here. "absent" and "unknown"
        // both fall through to the existing verdicts unchanged: an install whose
        // user directory we cannot read must not have its failure re-labelled as a
        // pending success, which would be this same defect pointing the other way.
        //
        // And only where NOTHING MOVED. install-scripts.txt is append-only until the
        // next boot consumes it, so on an outcome where the pack demonstrably did
        // change on disk a leftover line would describe some earlier reservation,
        // not this update — "moved-unknown-direction" keeps its own verdict.
        if (verdict.outcome === "no-op" || verdict.outcome === "unverified") {
            // #1999 — NOTHING MOVED, so ask the one surface that can say why: the
            // per-task record for the EXACT ui_id this update was enqueued under.
            // Read here, once, before any fallback narrates a cause — a drained queue
            // on ComfyUI-Manager v4 reads the same whether our task succeeded or
            // errored, and this is the only place the difference survives. It never
            // throws and it never changes what any branch DOES; it changes only what
            // they are able to say (and on a pre-v4 Manager it honestly says nothing).
            managerTaskVerdict = await readManagerTaskVerdict(deps, result.taskUiId, result.managerBase);
            const reservation = readManagerReservation(comfyPath, PANEL_REGISTRY_ID, deps);
            if (reservation === "reserved") {
                assertSameTarget("before reporting a staged panel update");
                const message = `Panel update is STAGED, not failed: ComfyUI-Manager could not swap ` +
                    `${PANEL_REGISTRY_ID} while the running ComfyUI is serving it, so it reserved the ` +
                    `switch for the next startup ("Installation reserved" in the ComfyUI log) and ` +
                    `recorded it in startup-scripts/install-scripts.txt. The pack on disk is still ` +
                    `${post.version ?? "unreadable"} and WILL NOT change until ComfyUI restarts — this ` +
                    `is expected, not a no-op. RESTART ComfyUI to apply it, then confirm with ` +
                    `${describeInstallPanelAction("status", "a re-check on the ComfyUI host")}. ` +
                    `Do NOT reinstall from source and do NOT re-run the update — each retry only ` +
                    `reserves it again.`;
                return {
                    action: "update",
                    result: { ...result, message },
                    restartRequired: true,
                    staged: true,
                    message,
                    previousVersion,
                    installedVersion: post.version,
                };
            }
        }
        // #724/#824 — the Manager produced no usable verdict (the proven stale
        // legacy-3.x no-op, OR an incoherent-but-provably-idle drained queue), and
        // the panel dir is a real git checkout (a pre-op HEAD resolved; a registry
        // zip has no .git, and a dev symlink was refused above). The verified
        // Manager path is a dead end here, so fall back to
        // a pinned `git merge --ff-only` on the panel repo and verify THAT the same way
        // instead of only surfacing the error.
        if (verdict.outcome === "no-op" && previousRev && post.dir) {
            const c = readQueueCounts(result.details);
            const provenLegacyEmptyQueue = isProvenLegacyEmptyQueue(result.details);
            // The Manager-side clause every fallback message narrates around — the
            // caller knows what the Manager actually reported; the fallback must not
            // assert the stale-3.x cause for a signature that doesn't prove it, and
            // must not call a merely-PARTIAL status "incoherent" either (a missing
            // field is unproven, not contradictory).
            const countsContradict = c.total !== undefined && c.done !== undefined && c.done > c.total;
            // #1999 — the per-task record OUTRANKS the counters whenever it exists.
            // `done > total` was narrated here as "incoherent … can neither confirm
            // nor explain", which is what the reporter was handed four times. On
            // ComfyUI-Manager v4 those counters are not contradictory at all
            // (total_count is live-only, done_count is the session history length),
            // and the thing that CAN explain the no-op — the task's own error — was
            // sitting one request away the whole time.
            const managerReason = managerTaskVerdict.kind !== "unknown"
                ? describeManagerTaskVerdict(managerTaskVerdict, c, provenLegacyEmptyQueue)
                : provenLegacyEmptyQueue
                    ? `ComfyUI-Manager never enqueued the update (the stale legacy 3.x silent no-op, #639/#724)`
                    : countsContradict
                        ? `ComfyUI-Manager's drained queue reported done_count=${c.done ?? "?"} ` +
                            `against total_count=${c.total ?? "?"} (pending=${c.pending ?? "?"}, ` +
                            `in_progress=${c.inProgress ?? "?"}) — queue-WIDE drain counters that ` +
                            `cannot say what this task did, and no per-task record was readable`
                        : `ComfyUI-Manager's drained-queue status is partial (total=` +
                            `${c.total ?? "?"}, done=${c.done ?? "?"}, pending=${c.pending ?? "?"}, ` +
                            `in_progress=${c.inProgress ?? "?"} — fields the status contract requires ` +
                            `are missing or malformed) and can neither confirm nor explain the no-op`;
            if (!provenLegacyEmptyQueue) {
                // #824 — SIGNATURE GATE, second form. An incoherent/partial signature
                // (e.g. total 0, done 1) is not proof of the stale-3.x no-op, so it
                // cannot authorize a git mutation ON THAT PREMISE. But the no-op
                // verdict still has two truthfully different meanings — "already at
                // the upstream tip" vs "behind, the update genuinely did not apply" —
                // and a clean, corroborated git checkout can distinguish them DIRECTLY
                // (fetch + compare, then a pinned ff-only when behind), independent of
                // what the Manager claimed. That verification is only safe when the
                // queue is PROVABLY idle (pending/in-progress/is-processing all
                // reported clear): with live Manager work not disproven, a direct git
                // operation could race a task still writing to the checkout, so refuse.
                if (!isProvenQuiescentQueue(result.details)) {
                    throw new PanelInstallError(`Panel update did NOT apply: nothing changed on disk, and the Manager's ` +
                        `queue counts are neither the proven legacy-3.x empty-queue signature ` +
                        `nor provably idle (got total=${c.total ?? "?"}, done=${c.done ?? "?"}, ` +
                        `pending=${c.pending ?? "?"}, in_progress=${c.inProgress ?? "?"}, ` +
                        `is_processing=${c.processing ?? "?"}) — with live Manager work not ` +
                        `disproven, a direct git verification could race a task still in ` +
                        `flight, so the git fallback does not fire. Update ComfyUI-Manager ` +
                        `on the host and retry, or update the panel repo manually (git pull ` +
                        `in ${post.dir}), then RESTART ComfyUI.`);
                }
                // The DIALECT gate below is skipped on purpose: the git verification's
                // warrant comes from the checkout's own state (clean, corroborated,
                // behind-or-at-tip proven against the fetched upstream), not from any
                // Manager signature, so the Manager's API dialect is irrelevant to it.
                //
                // What this snapshot does NOT establish is the thing a destructive
                // merge actually needs. "pending/in_progress/is_processing were clear
                // when we asked" is a statement about one poll; "nothing is writing to
                // this tree" is a statement about the merge window, and git's index
                // lock only excludes other GIT processes — a Manager pip install or
                // file copy is not held back by it. The fallback brackets its own
                // mutation with a live re-probe and a tree compare-and-swap for
                // exactly that reason (and does so on EVERY entry, not just this one).
            }
            else {
                // DIALECT GATE — the empty-queue signature only MEANS "stale Manager 3.x"
                // on a legacy host. On a v4 host (or with the dialect unproven) the same
                // signature is an outage/failed enqueue, and a git mutation is not
                // warranted (codex gate): report the unverified no-op instead of pulling.
                // Probe the dialect of the SAME Manager the update call used — the
                // base travels back on the result (a mid-op retarget must not let a
                // v2-host outage read as 'legacy' from another endpoint, codex gate).
                let dialect;
                if (deps.detectManagerDialect) {
                    dialect = await deps.detectManagerDialect(result.managerBase);
                }
                else {
                    try {
                        const { detectManagerApi } = await import("./node-management.js");
                        dialect = await detectManagerApi(result.managerBase);
                    }
                    catch {
                        dialect = undefined;
                    }
                }
                if (dialect !== "legacy") {
                    throw new PanelInstallError(`Panel update did NOT apply: nothing changed on disk, and the Manager's ` +
                        `API dialect here is ${dialect ?? "unproven"}, NOT the legacy 3.x whose ` +
                        `silent no-op this matches (#724) — so the git fallback does not fire ` +
                        `(an empty queue on this host is an outage or a failed enqueue, not the ` +
                        `stale-3.x signature). Update ComfyUI-Manager on the host and retry, or ` +
                        `update the panel repo manually (git pull in ${post.dir}), then RESTART ComfyUI.`);
                }
            }
            // Bind the fallback to ONE directory: the git proof (previousRev) belongs
            // to the ORIGINAL detection, so the pull and the post verification must
            // target that same checkout. A re-detection that resolves a DIFFERENT
            // panel dir means the two reads disagree about what "the panel" is —
            // pulling there could mutate a repo we never proved, so refuse.
            if (!detection.dir || !samePathCI(detection.dir, post.dir, deps)) {
                throw new PanelInstallError(`Panel update did NOT apply: ${managerReason}, and the git fallback ` +
                    `is REFUSED: the panel dir re-detected after the Manager call ` +
                    `(${post.dir}) is not the checkout the pre-update git proof belongs ` +
                    `to (${detection.dir ?? "unresolved"}). Pulling it could mutate the ` +
                    `WRONG repo, so nothing was done. Check custom_nodes for duplicate ` +
                    `or moved panel dirs, then retry.`);
            }
            // Same corroboration the ZIP swap demands: a DIRECT mutation must act on
            // a tree the running server named, not merely on a configured one (#766).
            assertSwapTreeCorroborated(deps, comfyPath, "the git fallback", managerReason);
            return updateViaGitCheckoutFallback({
                deps,
                dir: detection.dir,
                previousVersion,
                previousRev,
                result,
                managerReason,
                managerBase: result.managerBase,
                assertSameTarget,
            });
        }
        // #771 — the same dead end, one install shape over. The Manager did nothing
        // AND there is no `previousRev`, which for a pack that IS installed and is
        // NOT a dev symlink (both established above) means precisely one thing: a
        // Comfy Registry ZIP install with no `.git`. The fast-forward above cannot
        // fire (nothing to fast-forward) and the Manager cannot resolve it, so
        // without this branch the user is left with a hard version gate and no
        // working remedy at all — which is the bug. Reinstall from source instead,
        // under the same verification.
        const zipSwapOps = verdict.outcome !== "updated" &&
            !previousRev &&
            detection.installed &&
            detection.dir &&
            post.dir &&
            samePathCI(detection.dir, post.dir, deps)
            ? resolveSwapOps(deps)
            : undefined;
        if (zipSwapOps && detection.dir) {
            return updateViaRegistryZipReinstall({
                deps,
                ops: zipSwapOps,
                dir: detection.dir,
                comfyuiPath: comfyPath,
                previousVersion,
                result,
                assertSameTarget,
                managerReason: `ComfyUI-Manager reported the queue drained without moving the pack on disk`,
            });
        }
        assertSameTarget("before reporting the update verdict");
        return finalizeUpdate(verdict, post, result, managerTaskVerdict);
    }
    // install / reinstall. Same final pin check as the update path above.
    assertNotPinned(action, deps);
    assertSameTarget("before the ComfyUI-Manager install");
    const result = action === "install"
        ? await deps.install({ id: PANEL_REGISTRY_ID, version: targetVersion })
        : await deps.reinstall({ id: PANEL_REGISTRY_ID, version: targetVersion });
    // #639 — VERIFY the pack afterward. installCustomNode verifies presence
    // downstream (#232), but reinstallCustomNode does NOT — it returns as soon as
    // the Manager queue drains, which the stale 3.x no-op passes trivially. Re-read
    // fresh from disk here so BOTH paths fail closed rather than fabricate success.
    const post = await detectPanelInstall(deps);
    // #641 — fail closed on a shadow FIRST: a served backup copy is the more
    // actionable diagnostic (it explains a wrong panel in the browser) and is named
    // with its specific remedy, even when the canonical install itself did not land.
    assertNoPanelShadow(action, post.dir, deps);
    if (!post.installed || !post.version) {
        // #1999 — the queue drained; that alone never said WHY the pack is not
        // there, and asserting the stale-3.x no-op unconditionally named a cause the
        // counters cannot establish (on ComfyUI-Manager v4 a drained queue always
        // reports total_count=0, whatever the task did). Report the counters as the
        // drain counters they are, and only offer the 3.x cause where the whole
        // counter set is zero.
        throw new PanelInstallError(`Panel ${action} did NOT land: the pack is ${post.installed ? "present but its version is unreadable" : "not present"} in custom_nodes. ` +
            `${describeManagerTaskVerdict({ kind: "unknown" }, readQueueCounts(result.details), isProvenLegacyEmptyQueue(result.details))}. NOT reporting success. Check the ComfyUI server log for what ` +
            `ComfyUI-Manager did with the task, then either update ComfyUI-Manager on ` +
            `the host (pip install -U comfyui_manager, or git pull in ` +
            `custom_nodes/ComfyUI-Manager) and retry, or install the panel from ` +
            `source. Then RESTART ComfyUI.`);
    }
    // SUCCESS REQUIRES PROVEN CHANGE (mirrors the update path): the pack went from
    // ABSENT→present (fresh install landed), or its version/git-HEAD moved. Proven
    // disk movement is checked FIRST — the ComfyUI-Manager queue counts are
    // queue-WIDE (not task-correlated), so they must NEVER veto a change the disk
    // already proves.
    //
    // AND MOVED IS NOT FORWARD. Same rule as the update path, which decides this
    // in classifyPanelUpdate: a Manager that resolves an older build and lands it
    // would otherwise be reported as the pack having "advanced to" a version
    // BELOW the one it replaced. The one legitimate backwards move is a caller who
    // asked for that exact version — `install_custom_node(version='0.11.20')` is
    // redirected here precisely so it gets what it asked for — so an explicit
    // request is honoured, and labelled as the downgrade it is.
    const regressed = !!previousVersion &&
        !!post.version &&
        SEMVER_RE.test(previousVersion.trim()) &&
        SEMVER_RE.test(post.version.trim()) &&
        compareSemver(post.version, previousVersion) < 0;
    if (regressed) {
        const requested = opts.version?.trim();
        if (!requested || requested !== post.version) {
            throw new PanelInstallError(`Panel ${action} did NOT go forward: the pack at ${post.dir ?? "custom_nodes"} ` +
                `is now ${post.version}, which is OLDER than the ${previousVersion} that was ` +
                `installed before${requested ? ` (you asked for "${requested}", which is not what landed)` : ""}. ComfyUI-Manager resolved and applied an earlier build. NOT reporting this ` +
                `as a completed ${action} — you are now on an older panel than you started ` +
                `with. Update ComfyUI-Manager on the host and retry, or reinstall the panel ` +
                `from source, then RESTART ComfyUI.`);
        }
    }
    const versionMoved = !!previousVersion && !!post.version && post.version !== previousVersion;
    const revMoved = !!previousRev && !!post.gitRev && post.gitRev !== previousRev;
    // Only a RELIABLE pre-op scan may establish absent→present. If the pre-op
    // enumeration failed (indeterminate), a "was absent" baseline is untrustworthy
    // — a pre-existing panel in a non-fast-path dir could have been missed — so we
    // do NOT infer a fresh install from it (that would fabricate success).
    const reliablyAbsent = !wasPresent && detection.scanReliable !== false;
    const changed = reliablyAbsent || versionMoved || revMoved;
    if (!changed) {
        // Nothing provably changed. Use the stale-3.x no-op count signature ONLY here
        // (not as a veto above) to sharpen the failure diagnostic.
        if (looksLikeManagerNoOp(result.details)) {
            // #1999 — same correction as the branch above. `total_count === 0` is the
            // stale-3.x signature ONLY when the rest of the counter set is zero too;
            // on ComfyUI-Manager v4 it is simply what an idle queue reports, so
            // "without actually enqueuing the task" was asserting an outcome that had
            // not been observed. The counters still fail this closed — they just no
            // longer testify to a cause they cannot see.
            throw new PanelInstallError(`Panel ${action} did NOT change the pack on disk (${PANEL_REGISTRY_ID} ` +
                `${post.version}) — likely a stale pre-existing copy. ` +
                `${describeManagerTaskVerdict({ kind: "unknown" }, readQueueCounts(result.details), isProvenLegacyEmptyQueue(result.details))}. NOT reporting success. Check the ComfyUI server log for what ` +
                `ComfyUI-Manager did with the task, then either update ComfyUI-Manager ` +
                `on the host (pip install -U comfyui_manager, or git pull in ` +
                `custom_nodes/ComfyUI-Manager) and retry, or install the panel from ` +
                `source. Then RESTART ComfyUI.`);
        }
        throw new PanelInstallError(`Panel ${action} did NOT change anything on disk: the pack is still ` +
            `${PANEL_REGISTRY_ID} ${post.version} (git-HEAD unchanged) after ` +
            `ComfyUI-Manager reported the queue drained. NOT reporting success — an ` +
            `unchanged checkout can't prove the ${action} actually executed versus a ` +
            `silent no-op (stale ComfyUI-Manager 3.x, #424). If you meant to refresh or ` +
            `upgrade, update ComfyUI-Manager on the host and retry, use ` +
            `install_comfyui(action:'panel', panel_action:'update'), or install the panel from source, then ` +
            `RESTART ComfyUI.`);
    }
    assertSameTarget("before reporting the install");
    return {
        action,
        result,
        restartRequired: true,
        message: `Panel ${action} applied via ComfyUI-Manager: pack ${wasPresent
            ? regressed
                ? "was DOWNGRADED, as explicitly requested, to"
                : "advanced to"
            : "installed on disk at"} ${PANEL_REGISTRY_ID} ${post.version}. RESTART ComfyUI to load the panel node.`,
        previousVersion,
        installedVersion: post.version,
    };
}
//# sourceMappingURL=panel-installer.js.map