UNPKG

comfyui-mcp

Version:

Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),

1,793 lines • 91.5 kB
import { AsyncLocalStorage } from "node:async_hooks";
import { Client } from "@stable-canvas/comfyui-client";
import { LoopbackWebSocket } from "../transport/loopback-websocket.js";
import { config, getComfyUIApiHost, getComfyUIBasePath, getComfyUIBaseUrl, isCloudMode, isRemoteMode, } from "../config.js";
import { logger } from "../utils/logger.js";
import { ComfyUIError, ConnectionError, WorkflowExecutionError, describeFetchFailure, } from "../utils/errors.js";
import { comfyuiFetch, connectedPanelFallbackOriginsNow, connectedPanelOriginsNow, comfyHttpTimeoutSeconds, deliveryDoubt, describeMissingInputMediaDrift, isComfyTransportFailure, isTimeoutAbort, originOf, raceAbort, } from "./fetch.js";
import { isKnownLoaderInput } from "./loader-asset-inputs.js";
import { sameOrigin } from "../utils/origin.js";
import { choosePanelFallbackOrigin, describeDeclinedPanelFallback, httpOriginOf, isUndefinedApiBaseFailure, resolvePanelReadOrigin, } from "../services/panel-fallback-target.js";
import { PanelComfyUIReadRelayError, PanelImageRelayError, PANEL_COMFYUI_READ_MAX_BYTES, PANEL_COMFYUI_READ_OBJECT_INFO_MAX_BYTES, requestPanelComfyUIRead, requestPanelImage, } from "../services/panel-image-relay.js";
import { BoundedResponseError, clampViewResponseBytes, MAX_HISTORY_RESPONSE_BYTES, MAX_PREVIEW_SOURCE_BYTES, MAX_VIEW_RESPONSE_BYTES as SHARED_MAX_VIEW_RESPONSE_BYTES, readResponseBodyBounded, } from "./bounded-response.js";
import { bodyPrefixOf, classifyNonJson, describeStatus, fetchComfyJson, guardClientFetch, isNonJsonResponseError, NonJsonResponseError, noteComfyApiRootValidated, provesNonJsonAnswer, readComfyJson, redactErrorMessage, rethrowWithJsonDiagnosis, scrubLogLines, scrubSecretShapedText, uploadTooLargeError, } from "./json-guard.js";
import * as cloudClient from "./cloud-client.js";
// Functions that fundamentally require a local ComfyUI process (WebSocket-bound
// session, local `client.fetchApi` paths, etc.) throw via this guard when the
// server is configured for Comfy Cloud — there is no WebSocket to attach to
// and no local socket to call. Dispatcher pattern from @picoSols
// (picoSols/comfyui-cloud-mcp@7a812069).
function requireLocalMode(op) {
    if (isCloudMode()) {
        throw new ComfyUIError(`This tool needs a direct ComfyUI session (${op}) and is not available in Comfy Cloud mode. ` +
            `Unset COMFYUI_API_KEY to target a local or remote ComfyUI instance.`, "CLOUD_UNSUPPORTED");
    }
}
/**
 * Assert that we are in pure local mode (not cloud, not remote) and that
 * `config.comfyuiPath` is available. Unlike `requireLocalMode` which only
 * blocks cloud mode, this also throws when `--comfyui-url` points at a
 * non-loopback host (remote mode). Tools that spawn OS processes or read/write
 * the local ComfyUI filesystem MUST call this guard.
 */
function requireLocalComfyUI(op) {
    requireLocalMode(op);
    if (isRemoteMode()) {
        throw new ComfyUIError(`This operation (${op}) requires a local ComfyUI installation and is not available ` +
            `when targeting a remote instance via --comfyui-url. Unset --comfyui-url or ` +
            `point it at a local address to use this tool.`, "REMOTE_UNSUPPORTED");
    }
    if (!config.comfyuiPath) {
        throw new ComfyUIError(`This operation (${op}) requires a local ComfyUI installation but COMFYUI_PATH ` +
            `is not set. Set the COMFYUI_PATH environment variable to the ComfyUI root directory.`, "NO_LOCAL_PATH");
    }
}
let clientInstance = null;
/** One in-flight /upload/image POST per ComfyUI origin (#2801). Concurrent
 *  stage/upload writes share a socket and can EPIPE; overlapping POSTs are also
 *  non-idempotent, so they wait rather than racing. */
const uploadChains = new Map();
/** Keep the SDK's configured URL literal until a refused loopback dial proves
 * that Node should retry through its IPv6-capable localhost resolver. */
function connectionWebSocket() {
    // oxlint-disable-next-line anti-slop/no-chained-type-assertions -- the SDK's DOM WebSocket type is wider than this Node adapter's runtime-compatible surface
    return LoopbackWebSocket;
}
export function getClient() {
    requireLocalMode("getClient");
    if (!clientInstance) {
        const ws = connectionWebSocket();
        clientInstance = new Client({
            api_host: getComfyUIApiHost(),
            // Path prefix for reverse-proxied / gateway'd ComfyUI (e.g. "/comfyapi").
            api_base: getComfyUIBasePath(),
            ssl: config.comfyuiSsl,
            clientId: "comfyui-mcp",
            ...(ws ? { WebSocket: ws } : {}),
            // Inject generic auth headers (COMFYUI_AUTH_*) on the library's own HTTP
            // calls; a no-op when unset. Node 22+ provides global WebSocket.
            //
            // Wrapped so the library's non-2xx path — which calls `res.json()` on the
            // ERROR body and lets a bare SyntaxError replace its own error, losing the
            // status and the URL with it — reports what actually answered instead
            // (#828, #1160). See guardClientFetch.
            fetch: guardClientFetch(comfyuiFetch),
        });
        logger.info("ComfyUI client created", {
            host: getComfyUIApiHost(),
        });
    }
    return clientInstance;
}
/**
 * `client.fetchApi`, minus the part that makes a status branch unreachable (#385).
 *
 * `Client.fetchApi` THROWS for every status outside [200, 400) — it never returns
 * a 4xx response. So every `if (!res.ok)` / `if (res.status === 404)` written
 * after a `fetchApi` call is dead code, and the endpoint-specific answers behind
 * those checks have never once run. That is not a theoretical concern:
 *
 *   - `fetchImage`'s IMAGE_NOT_FOUND — added by #435 for issue #385 itself, naming
 *     the filename and pointing at the listing tool — never fired for a missing
 *     output file.
 *   - `getSetting` treats a 404 as "unset (frontend default applies)", because
 *     some ComfyUI builds 404 the per-id settings route. Those builds threw.
 *   - `loadLockFromLibrary` treats a 404 as "no lock present", which is the
 *     ORDINARY case for an unlocked workflow. It threw.
 *
 * This keeps the library's URL and header construction verbatim — `apiURL` adds
 * the api_base prefix and the clientId, `apiHeaders` adds comfy-user and merges
 * the caller's — so a proxied, prefixed or multi-user target resolves exactly as
 * before. The ONLY difference is that the Response comes back instead of being
 * turned into an exception, which is what lets the caller classify it.
 *
 * `userdataFetch` in services/userdata-library.ts arrived at this same shape
 * independently for one route (panel #202); this is that fix generalised.
 *
 * Not a replacement for `comfyuiFetch`: use that when you are composing the URL
 * yourself (as `getSystemStats` and `enqueuePrompt` do). Use this when you want
 * the library's routing and a Response you can read.
 */
export async function comfyApiFetch(route, init = {}) {
    const client = getClient();
    return await client.fetch(client.apiURL(route), {
        ...init,
        headers: client.apiHeaders(init),
    });
}
export async function connectClient() {
    requireLocalMode("connectClient");
    const client = getClient();
    try {
        await client.connect();
        logger.info("Connected to ComfyUI via WebSocket");
        return client;
    }
    catch (err) {
        throw new ConnectionError(`Failed to connect to ComfyUI at ${getComfyUIApiHost()}: ${err instanceof Error ? err.message : err}`);
    }
}
/**
 * Ensures WebSocket is connected, auto-reconnecting if stale.
 * Only needed before WebSocket-dependent operations (enqueue with progress tracking).
 */
export async function ensureConnected() {
    requireLocalMode("ensureConnected");
    const client = getClient();
    // If the socket looks healthy, return immediately
    if (!client.closed) {
        return client;
    }
    // Socket is stale — reset and reconnect
    logger.info("WebSocket stale (closed=true), reconnecting...");
    resetClient();
    try {
        return await connectClient();
    }
    catch {
        // First attempt failed — reset singleton completely and retry once
        logger.warn("Reconnect failed, resetting client and retrying...");
        resetClient();
        try {
            return await connectClient();
        }
        catch (err) {
            throw new ConnectionError(`Failed to reconnect to ComfyUI after retry: ${err instanceof Error ? err.message : err}`);
        }
    }
}
/** True when a decoded /system_stats body has the recognizable ComfyUI shape (a
 *  `system` object and/or a `devices` array). A bare 2xx carrying an API
 *  gateway's own JSON envelope is NOT ComfyUI and must not be handed on as
 *  stats.
 *
 *  This comment used to say the panel-side reboot certification uses the same
 *  predicate. It does not — panel#2221. Every panel /system_stats consumer takes
 *  the body as-is; the two that exist read defensively (`data?.devices?.[0]`, and
 *  an unknown platform falling back to POSIX), so they fail safe rather than
 *  certifying the document. The claim is recorded here because a false provenance
 *  is worse than none: someone syncing the two sides looks for the counterpart,
 *  finds nothing, and either duplicates it or assumes the panel already guards. */
function looksLikeSystemStats(body) {
    if (!body || typeof body !== "object")
        return false;
    const b = body;
    return (b.system != null && typeof b.system === "object") || Array.isArray(b.devices);
}
/** A relayed /object_info document must be a non-empty object registry, not an
 * HTML or gateway JSON envelope that happens to parse successfully. Every
 * entry must retain the required ComfyUI node-definition fields. */
function looksLikeObjectInfo(body) {
    if (!body || typeof body !== "object" || Array.isArray(body))
        return false;
    const entries = Object.entries(body);
    if (entries.length === 0)
        return false;
    return entries.every(([nodeType, definition]) => {
        if (!nodeType.trim() || !definition || typeof definition !== "object" || Array.isArray(definition))
            return false;
        const def = definition;
        const input = def.input;
        return Boolean(Object.prototype.hasOwnProperty.call(def, "input") &&
            input &&
            typeof input === "object" &&
            !Array.isArray(input) &&
            Array.isArray(def.output) &&
            Array.isArray(def.output_is_list) &&
            Array.isArray(def.output_name) &&
            typeof def.name === "string" &&
            typeof def.display_name === "string" &&
            typeof def.description === "string" &&
            typeof def.category === "string" &&
            typeof def.output_node === "boolean");
    });
}
function panelReadResponse(read) {
    const headers = new Headers();
    if (read.contentType)
        headers.set("content-type", read.contentType);
    return new Response(read.body, { status: 200, headers });
}
const PANEL_READ_PATHS = {
    history: "/history",
    system_stats: "/system_stats",
    logs: "/internal/logs",
    object_info: "/object_info",
};
/**
 * One HTTP GET against a unique proven loopback panel origin (#2836 recurrence).
 * Configured auth stays on the headless target. A guessed origin is never
 * contacted — the caller already required choosePanelFallbackOrigin kind "use".
 */
async function fetchProvenPanelRead(origin, apiBase, operation) {
    const url = `${origin}${apiBase.replace(/\/+$/, "")}${PANEL_READ_PATHS[operation]}`;
    try {
        const res = await fetch(url, {
            method: "GET",
            redirect: "error",
            signal: AbortSignal.timeout(8_000),
        });
        if (!res.ok)
            return undefined;
        const body = await res.text();
        const bytes = Buffer.byteLength(body, "utf8");
        const max = operation === "object_info"
            ? PANEL_COMFYUI_READ_OBJECT_INFO_MAX_BYTES
            : PANEL_COMFYUI_READ_MAX_BYTES;
        if (bytes > max)
            return undefined;
        return {
            operation,
            body,
            contentType: res.headers.get("content-type"),
            bytes,
        };
    }
    catch {
        return undefined;
    }
}
/** Ask the connected panel after the configured headless route failed at the
 * transport layer. Resolve origin+api_base first (#2836): an unproven origin
 * is never contacted. A unique proven loopback origin that differs from the
 * dead target is fetched here (the panel's own api_base object can be
 * undefined, so fetch_comfyui_read is not the first hop). Same-origin or
 * unknown still uses one panel relay; an undefined panel api_base is a named
 * transport diagnostic. */
async function panelReadFallback(operation, primaryError) {
    const primary = primaryError instanceof Error ? primaryError.message : String(primaryError);
    const origins = connectedPanelOriginsNow();
    const resolved = resolvePanelReadOrigin(origins, getComfyUIBasePath());
    if (resolved.kind === "unproven") {
        throw new ComfyUIError(`${primary} The connected panel ComfyUI read fallback was not attempted (PANEL_ORIGIN_UNPROVEN). ` +
            `The panel origin is unproven, so fetch_comfyui_read was not dispatched. A guessed origin is never contacted.`, "PANEL_ORIGIN_UNPROVEN");
    }
    if (resolved.kind === "proven") {
        const choice = choosePanelFallbackOrigin(getComfyUIBaseUrl(), origins);
        if (choice.kind === "use") {
            const fromOrigin = await fetchProvenPanelRead(choice.origin, resolved.apiBase, operation);
            if (fromOrigin)
                return fromOrigin;
        }
    }
    try {
        return await requestPanelComfyUIRead(operation);
    }
    catch (error) {
        if (error instanceof PanelComfyUIReadRelayError && error.unavailable)
            return undefined;
        if (isUndefinedApiBaseFailure(error)) {
            throw new ComfyUIError(`${primary} The connected panel ComfyUI read fallback failed safely (PANEL_API_BASE_UNAVAILABLE). ` +
                `fetch_comfyui_read could not read api_base because the panel's ComfyUI API object was undefined. ` +
                `This is a transport diagnostic: the headless target was unreachable, and the panel read was not given a usable API base.`, "PANEL_API_BASE_UNAVAILABLE", resolved.kind === "proven"
                ? { origin: resolved.origin, api_base: resolved.apiBase }
                : undefined);
        }
        const code = error instanceof PanelComfyUIReadRelayError ? error.code : "RELAY_ERROR";
        // #2703 - the code alone was the whole answer, and PANEL_FETCH_FAILED does
        // not distinguish "the read exceeded the relay's byte ceiling" from "the
        // panel's fetch timed out" from "ComfyUI answered 403" from "that panel
        // predates the read relay". The reporter got `fetch failed: connect
        // ECONNREFUSED 127.0.0.1:8188 ... (PANEL_FETCH_FAILED)` and had nothing to
        // act on: the headless target was dead AND the one path that could have
        // answered declined without saying why. The relay now carries the cause
        // (services/panel-image-relay.ts, panelFailureReason); say it here, because
        // this message - not the relay error - is what reaches the caller.
        const reason = error instanceof PanelComfyUIReadRelayError ? error.reason : undefined;
        throw new Error(`${primary} The connected panel ComfyUI read fallback failed safely (${code}).` +
            (reason ? ` The panel reported: ${reason}` : ""), { cause: error });
    }
}
/** Nested fetchImage from a looping image relay must not wrap/retry again (#2864). */
const panelImageRelayHop = new AsyncLocalStorage();
/**
 * One authenticated `fetch_image` relay after the configured /view route failed
 * at the transport layer (#2864). Diagnostic/published origins are not dialed
 * here (#2149): they are not authorization to contact a /view target. Nested
 * re-entry is refused by `panelImageRelayHop` in `fetchImage`.
 */
async function panelImageFallback(filename, type, subfolder, primaryError, declined) {
    const primary = primaryError instanceof Error ? primaryError.message : String(primaryError);
    try {
        const relayed = await requestPanelImage(filename, type, subfolder);
        if (relayed)
            return { base64: relayed.base64, mimeType: relayed.mimeType };
    }
    catch (error) {
        if (error instanceof PanelImageRelayError && error.unavailable) {
            // The relay itself was unreachable; keep the original transport failure.
        }
        else if (isUndefinedApiBaseFailure(error)) {
            throw new ComfyUIError(`${primary} The connected panel image relay failed safely (PANEL_API_BASE_UNAVAILABLE). ` +
                `fetch_image could not read api_base because the panel's ComfyUI API object was undefined. ` +
                `This is a transport diagnostic: the headless target was unreachable, and the panel image relay was not given a usable API base.`, "PANEL_API_BASE_UNAVAILABLE");
        }
        else if (error instanceof PanelImageRelayError) {
            throw new Error(`${primary} The connected panel image relay failed safely (${error.code}).` +
                (error.reason ? ` The panel reported: ${error.reason}` : ""), { cause: error });
        }
    }
    if (declined) {
        throw new Error(`${primary}${declined}`, { cause: primaryError });
    }
    return undefined;
}
/** Budget for the /system_stats probe. Short on purpose: this is a liveness
 *  read, and a ComfyUI mid-decode will not answer in time. Exported so the
 *  call_tool timeout test can shrink only THIS deadline. */
export const SYSTEM_STATS_TIMEOUT_MS = 15_000;
export async function getSystemStats(options = {}) {
    if (isCloudMode())
        return cloudClient.getSystemStats();
    requireLocalMode("getSystemStats");
    // Fetched directly (not via the client library) so a non-JSON answer can be
    // DIAGNOSED rather than surfacing as "Unexpected token '<', "<!DOCTYPE "..."
    // (#828). comfyuiFetch carries the same auth headers the library would, and
    // getComfyUIBaseUrl() carries the same path prefix, so a proxied/prefixed
    // remote resolves identically.
    const url = `${getComfyUIBaseUrl()}/system_stats`;
    // The 15s signal used to sit only on `fetch`. Headers arriving before the
    // deadline left `readComfyJson` (`res.text()` + `JSON.parse`) unbounded, so
    // the abort fired, the nested call reported "The operation was aborted due
    // to timeout", and the enclosing call_tool stayed pending until something
    // killed it (#1672). Race the WHOLE fetch+decode against the same signal.
    const signal = AbortSignal.timeout(SYSTEM_STATS_TIMEOUT_MS);
    try {
        const stats = await raceAbort(signal, () => fetchComfyJson(url, {
            init: { signal },
            expectShape: looksLikeSystemStats,
            shapeHint: "a ComfyUI /system_stats document (it has no `system` object and no `devices` array)",
            diagnosticContext: options.diagnosticContext === "health"
                ? "get_system_stats_health"
                : options.diagnosticContext === "environment"
                    ? "install_comfyui_environment"
                    : undefined,
        }));
        // Shape-valid /system_stats is the in-session proof that this base URL is a
        // ComfyUI API root. A later empty 502 must not be reported as a misconfigured
        // URL (#1670). A timeout is not that proof — do not stamp on the abort path.
        noteComfyApiRootValidated(getComfyUIBaseUrl());
        return stats;
    }
    catch (err) {
        if (isComfyTransportFailure(err)) {
            const relayed = await panelReadFallback("system_stats", err);
            if (relayed) {
                return await readComfyJson(panelReadResponse(relayed), {
                    url: "/system_stats",
                    expectShape: looksLikeSystemStats,
                    shapeHint: "a ComfyUI /system_stats document (it has no `system` object and no `devices` array)",
                });
            }
        }
        if (!isTimeoutAbort(err))
            throw err;
        // Structured, not the raw AbortSignal.timeout string: call_tool must
        // settle with a result the caller can act on, not hang and not dump
        // "The operation was aborted due to timeout" with no endpoint.
        throw new ComfyUIError(`No reply from ComfyUI within ${SYSTEM_STATS_TIMEOUT_MS / 1000}s — while requesting ${url} (GET). ` +
            `Nothing was learned about the server from this — a timeout is not a refusal and not a "not found". ` +
            // #1896 — this asserted "The connection was accepted but the body never
            // finished". The budget covers connecting too, so an unroutable or
            // filtered target expires here having established nothing, and the
            // sentence immediately before already said nothing was learned.
            //
            // It matters more than the sibling in comfyui/fetch.ts, because #1896
            // now sends callers HERE on purpose: a ComfyUI failure whose connected
            // panel is on the same origin tells the reader to run this probe to
            // find out whether the route from this process is dead or only this one
            // request is stalling. If the route IS dead, the old text answered with
            // the opposite conclusion — "a long decode occupies the server, retry
            // after the current job" — which is the reading that discriminator
            // exists to rule out. The decode case is still the common one, so it is
            // kept as the likely reading rather than dropped.
            `Whether a connection was ever established is NOT known from this: the budget covers ` +
            `connecting, the request and the reply alike. The usual cause is a connection that WAS ` +
            `accepted while a long decode occupied the server, in which case retrying after the ` +
            `current job finishes is enough — but a target this process cannot route to expires here ` +
            `identically, so a repeat failure while something else reaches that ComfyUI points at the ` +
            `route rather than at a busy server.`, "COMFYUI_HTTP_TIMEOUT", { endpoint: "/system_stats", timeout_ms: SYSTEM_STATS_TIMEOUT_MS });
    }
}
// /object_info is large (~MBs) and slow (300-800 ms) but only changes when
// ComfyUI restarts or a custom node is (un)installed. Memoize it, but only for a
// bounded FRESHNESS WINDOW rather than the whole process lifetime: an out-of-band
// ComfyUI restart/install (Desktop Manager reboot, a manual restart, a node pack
// installed outside an mcp tool) never calls resetObjectInfoCache(), so a
// lifetime cache serves the PRE-restart schema forever — create_workflow's
// node_info and validate actions, and list_packs (action:"check_runtime"), all report the new nodes as unknown
// (#528). A TTL bounds that staleness: within the window we serve the cached
// snapshot (so a burst of validations stays ~0.5 s, the perf win flagged by
// josephoibrahim/comfy-cozy), and the FIRST call after the window does a single
// coalesced refetch that picks up whatever the live server now exposes. No cheap
// per-call probe is added — ComfyUI's /object_info has no ETag/Last-Modified and
// no node-set-identity endpoint, so a TTL is the lightest mechanism that both
// self-heals after an out-of-band change and never fetches the heavy payload on
// every call. Managed restart/install paths still call resetObjectInfoCache() for
// an immediate refresh; the TTL is the backstop for the out-of-band case.
//
// Override with COMFYUI_MCP_OBJECT_INFO_TTL_MS (0 disables caching entirely; a
// large value restores the old lifetime behavior for latency-sensitive setups).
const OBJECT_INFO_TTL_MS = (() => {
    const raw = process.env.COMFYUI_MCP_OBJECT_INFO_TTL_MS;
    if (raw === undefined || raw.trim() === "")
        return 30_000;
    const n = Number(raw);
    return Number.isFinite(n) && n >= 0 ? n : 30_000;
})();
let objectInfoCache = null;
let objectInfoCachedAt = 0;
let objectInfoInflight = null;
// Invalidation epoch. resetObjectInfoCache() bumps it; a fetch that STARTED under
// an older epoch must not commit its result to the cache — otherwise a request
// in flight when a restart invalidates the cache can resolve afterward and
// repopulate it with the PRE-restart schema, and future callers would await that
// stale value (codex WS-3 finding #1).
let objectInfoEpoch = 0;
/** Fresh `/object_info` snapshot, or null when the cache is empty or expired. */
export function peekObjectInfoCache() {
    return objectInfoCacheFresh() ? objectInfoCache : null;
}
function objectInfoCacheFresh() {
    if (objectInfoCache === null)
        return false;
    const age = Date.now() - objectInfoCachedAt;
    // A NEGATIVE age means the system clock moved backward since we cached (manual
    // correction, VM snapshot restore, NTP step). Without this guard the snapshot
    // would read "fresh" until wall time catches back up — a 1 h rollback would
    // extend a 30 s TTL by ~an hour, so an out-of-band restart stays stale far past
    // the window (#528 review P2). Treat a backward jump as EXPIRED: the next call
    // refetches and re-stamps objectInfoCachedAt against the corrected clock, so
    // normal within-window caching resumes immediately after the single refetch.
    return age >= 0 && age < OBJECT_INFO_TTL_MS;
}
/**
 * True when /object_info was rejected by an authentication layer (401/403),
 * including an empty body. That is not a JSON document of installed nodes and
 * must not be read as "the class_type is not installed" (#2451).
 */
export function isObjectInfoAuthFailure(err) {
    if (!isNonJsonResponseError(err))
        return false;
    const { kind, status } = err.diagnosis;
    return kind === "login" && (status === 401 || status === 403);
}
/**
 * An /object_info auth rejection, worded so callers cannot conclude the pack
 * is missing. The live panel may already have the type (#2451, same class of
 * defect as #2085's "401 is not a missing Manager").
 */
export function objectInfoAuthError(err) {
    return new ComfyUIError(`${err.message} This is an authentication failure, not evidence that the node pack is missing ` +
        `or that the class_type is uninstalled. A connected panel that can already add the type ` +
        `(panel_add_node after panel_refresh_nodes) is reading the live registry through an ` +
        `authenticated browser session this process does not have. Configure COMFYUI_AUTH_TOKEN / ` +
        `COMFYUI_AUTH_HEADER, or the CF_ACCESS_CLIENT_ID + CF_ACCESS_CLIENT_SECRET pair, for this ` +
        `MCP process so /object_info can be read.`, "OBJECT_INFO_AUTH", { status: err.diagnosis.status, url: err.diagnosis.url });
}
let liveObjectInfoFallback;
/**
 * Test seam for #2451: the live panel registry (the same source panel_add_node
 * used) when the configured /object_info answers 401. Production has no
 * LiteGraph in this process; leave unset to get OBJECT_INFO_AUTH.
 */
export function setLiveObjectInfoFallbackForTests(fn) {
    liveObjectInfoFallback = fn;
}
async function readLiveObjectInfoOnAuthFailure() {
    if (!liveObjectInfoFallback)
        return undefined;
    try {
        const live = await liveObjectInfoFallback();
        if (!live || typeof live !== "object" || Array.isArray(live))
            return undefined;
        if (Object.keys(live).length === 0)
            return undefined;
        logger.info("getObjectInfo serving live registry after /object_info auth rejection", {
            types: Object.keys(live).length,
        });
        return live;
    }
    catch {
        return undefined;
    }
}
export async function getObjectInfo() {
    if (isCloudMode())
        return cloudClient.getObjectInfo();
    // Serve from cache only while still inside the freshness window; once it lapses
    // we fall through to a refetch so an out-of-band restart/install is picked up.
    if (objectInfoCacheFresh())
        return objectInfoCache;
    if (objectInfoInflight)
        return objectInfoInflight;
    const startEpoch = objectInfoEpoch;
    // Commit to the shared cache ONLY if no invalidation happened while we were
    // fetching. Awaiters of this promise still get the value (they asked before
    // the reset), but the cache is not poisoned for callers that arrive after.
    const commit = (info) => {
        if (objectInfoEpoch === startEpoch) {
            objectInfoCache = info;
            objectInfoCachedAt = Date.now();
        }
        return info;
    };
    const inflight = (async () => {
        // Capture the client this request runs on so the catch only resets THIS one.
        const startClient = getClient();
        try {
            return commit((await startClient.getNodeDefs()));
        }
        catch (err) {
            // A managed restart/reboot leaves the cached client bound to a socket that
            // was torn down, so the first call after it surfaces a bare "fetch failed"
            // (issue #376). Drop the stale client and retry once against a fresh one
            // before giving up — this is exactly the reconnect the caller expects.
            // Only reset if OUR client is still current: a concurrent reset may have
            // already installed a newer client we must not close.
            // The library's parse errors QUOTE the body they choked on, and a gateway
            // that reflects the request can have put our ComfyUI credential in that
            // body — so this message goes through the same redaction as any body text
            // before it reaches the log (codex gate, round 5, finding 5).
            logger.warn("getObjectInfo failed; resetting client and retrying once", {
                error: redactErrorMessage(err),
            });
            resetClientIfCurrent(startClient);
            try {
                return commit((await getClient().getNodeDefs()));
            }
            catch (retryErr) {
                if (isComfyTransportFailure(err) && isComfyTransportFailure(retryErr)) {
                    const relayed = await panelReadFallback("object_info", retryErr);
                    if (relayed) {
                        const info = await readComfyJson(panelReadResponse(relayed), {
                            url: "/object_info",
                            maxBytes: PANEL_COMFYUI_READ_OBJECT_INFO_MAX_BYTES,
                            expectShape: looksLikeObjectInfo,
                            shapeHint: "a ComfyUI /object_info node registry object",
                        });
                        return commit(info);
                    }
                }
                // The client library parses JSON itself, so an HTML body reaches us as a
                // bare "Unexpected token '<'" naming neither the URL nor the responder
                // (#828). Re-probe the endpoint ONCE to say what actually answered; if
                // the probe is inconclusive the original error is rethrown untouched —
                // an unproven cause must never be presented as the cause.
                //
                // Report whichever attempt PROVED a non-JSON answer. When the first
                // request got HTML and the retry merely hit a transport blip, surfacing
                // only the retry would downgrade a known #828 diagnosis to "server
                // unavailable" and send the user to check whether ComfyUI is running
                // (codex gate, round 8, finding 2).
                //
                // `provesNonJsonAnswer`, not `looksLikeHtmlParsedAsJson`: since
                // guardClientFetch the library's parse failure arrives as a
                // NonJsonResponseError carrying no parser text, so the old predicate
                // answered "no" for the strongest evidence available and this picker
                // fell through to `retryErr` every time — reintroducing the very
                // downgrade round 8 fixed.
                const toReport = provesNonJsonAnswer(retryErr) ? retryErr : provesNonJsonAnswer(err) ? err : retryErr;
                // #2451 — a remote /object_info 401 empty body is an AUTH gate, not an
                // empty node registry. panel_add_node can still create the type because
                // the browser is authenticated; treating the 401 as "class missing"
                // contradicts that live registry. Prefer the live snapshot when one
                // exists; otherwise say it is auth, not a missing pack.
                if (isObjectInfoAuthFailure(toReport)) {
                    const live = await readLiveObjectInfoOnAuthFailure();
                    if (live)
                        return commit(live);
                    throw objectInfoAuthError(toReport);
                }
                return await rethrowWithJsonDiagnosis(toReport, `${getComfyUIBaseUrl()}/object_info`);
            }
        }
    })();
    objectInfoInflight = inflight;
    try {
        return await inflight;
    }
    finally {
        // Only clear the shared slot if it's still ours — a concurrent reset may have
        // already abandoned it and a newer fetch may have taken the slot.
        if (objectInfoInflight === inflight)
            objectInfoInflight = null;
    }
}
/**
 * Drop the memoized /object_info so the next call refetches. Called after
 * ComfyUI restarts (node packs may have changed) and available for tools
 * that mutate the node set mid-session. Bumps the invalidation epoch and
 * abandons any in-flight fetch so a fetch started before the reset can never
 * commit a pre-restart result to the cache.
 */
export function resetObjectInfoCache() {
    objectInfoCache = null;
    objectInfoCachedAt = 0;
    objectInfoInflight = null;
    objectInfoEpoch++;
    logger.debug("object_info cache reset", { epoch: objectInfoEpoch });
}
/**
 * Some custom nodes register individually (`/object_info/<Type>` returns a schema)
 * but are absent from the bulk `/object_info` response — seen with controlnet_aux's
 * `DWPreprocessor`. The converter would then skip the node and silently drop its
 * connections. Backfill the schemas for the given node types missing from
 * `objectInfo` by fetching each one individually, and return a merged copy.
 */
export async function backfillObjectInfo(objectInfo, nodeTypes) {
    if (isCloudMode())
        return objectInfo;
    const missing = [...new Set(nodeTypes)].filter((t) => t && !(t in objectInfo));
    if (missing.length === 0)
        return objectInfo;
    const base = `${getComfyUIBaseUrl()}/object_info`;
    const merged = { ...objectInfo };
    await Promise.all(missing.map(async (t) => {
        try {
            const res = await comfyuiFetch(`${base}/${encodeURIComponent(t)}`);
            if (!res.ok)
                return;
            // `/object_info/<Type>` answers with the same shape as the bulk endpoint,
            // keyed by the node's live registration name.
            const def = (await res.json());
            if (!def || typeof def !== "object")
                return;
            // `/object_info/<Type>` returns the schema keyed by the node's LIVE
            // registration name — which can differ from the string we asked for in
            // case, namespace prefix, or display-vs-class name (#404,
            // `DetectorForNSFW`). Honor whatever key(s) ComfyUI actually returns
            // rather than narrowing to an exact `def[t]` match (which silently
            // dropped the node and made the node_info lookup report "no match" for a node
            // the server clearly registers). Prefer the exact key when present,
            // otherwise merge every returned definition.
            if (def[t]) {
                merged[t] = def[t];
                logger.info(`Backfilled object_info for '${t}' (missing from bulk /object_info)`);
            }
            else {
                const keys = Object.keys(def);
                if (keys.length === 0)
                    return;
                for (const k of keys)
                    merged[k] = def[k];
                logger.info(`Backfilled object_info for '${t}' under live registration key(s) [${keys.join(", ")}] (missing from bulk /object_info)`);
            }
        }
        catch {
            // Leave it missing — the converter skips + warns as before.
        }
    }));
    return merged;
}
export async function getQueue() {
    if (isCloudMode())
        return cloudClient.getQueue();
    const client = getClient();
    const queue = await client.getQueue();
    return {
        queue_running: (queue.Running ?? queue.queue_running ?? []),
        queue_pending: (queue.Pending ?? queue.queue_pending ?? []),
    };
}
/**
 * /queue, but a read that is allowed to FAIL.
 *
 * `getQueue()` above cannot say "I could not look". It goes through the
 * vendored client, whose failure path resolves a document with no Running or
 * Pending key — and the `?? []` normalizer then turns that into an EMPTY
 * QUEUE. Measured against a real HTTP server: a 500, a 502 HTML proxy page, a
 * 200 `{}`, and a dead port (ECONNREFUSED) ALL resolve
 * `{queue_running:[],queue_pending:[]}`. Nothing throws.
 *
 * That collapse is harmless for a summary — an unreachable ComfyUI has no jobs
 * to list — and fatal for any caller that must tell "this job is not queued"
 * from "I could not check", because those are the same bytes. #1632 is exactly
 * that mistake one level up: reporting a job as removed without looking.
 *
 * So this takes the guarded JSON path every other verifying read here uses.
 * A network error, a non-2xx, an HTML body, and a JSON document that is not a
 * queue all THROW; only a real /queue document resolves. Callers that must not
 * confuse absence with ignorance use this one.
 */
export async function getQueueVerified() {
    requireLocalMode("getQueueVerified");
    const res = await comfyApiFetch("/queue");
    const queue = await readComfyJson(res, {
        url: "/queue",
        // Both halves must be present. Accepting a bare `{}` would reinstate the
        // very collapse this function exists to avoid — silently, and now wearing
        // the word "verified".
        expectShape: (v) => typeof v === "object" && v !== null && !Array.isArray(v) &&
            ("queue_running" in v || "Running" in v) &&
            ("queue_pending" in v || "Pending" in v),
        shapeHint: "a ComfyUI /queue document (queue_running + queue_pending)",
    });
    return {
        queue_running: (queue.Running ?? queue.queue_running ?? []),
        queue_pending: (queue.Pending ?? queue.queue_pending ?? []),
    };
}
export async function interrupt(promptId) {
    if (isCloudMode())
        return cloudClient.interrupt(promptId);
    const client = getClient();
    await client.interrupt(promptId ?? null);
}
/**
 * POST /free — unload resident models and/or free cached memory. Used by
 * clear_vram and by the cancel escalation (a wedged sampler step can ignore an
 * interrupt; freeing VRAM under it sometimes shakes it loose). No-op on cloud.
 */
export async function freeMemory(opts) {
    if (isCloudMode())
        return;
    const client = getClient();
    await client.fetchApi("/free", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
            unload_models: !!opts.unload_models,
            free_memory: !!opts.free_memory,
        }),
    });
}
/**
 * Fire-and-forget: enqueue a prompt via HTTP POST (no WebSocket needed).
 * Returns prompt_id and queue position immediately.
 */
/**
 * Human-readable account of output branches ComfyUI REFUSED while accepting the
 * prompt, or undefined when it accepted everything.
 *
 * Shape from ComfyUI: { "<nodeId>": { class_type, errors: [{ type, message,
 * details, extra_info: { input_name } }] } }. Only what is actually present is
 * stated — a node that carries no readable error still gets named, because "this
 * branch will not run" is the load-bearing fact and it does not depend on our
 * being able to parse the reason.
 */
function describeRejectedOutputs(nodeErrors) {
    if (!nodeErrors || typeof nodeErrors !== "object")
        return undefined;
    const entries = Object.entries(nodeErrors);
    if (entries.length === 0)
        return undefined;
    const parts = entries.map(([nodeId, raw]) => {
        const rec = (raw ?? {});
        const cls = typeof rec.class_type === "string" ? ` (${rec.class_type})` : "";
        const errs = Array.isArray(rec.errors) ? rec.errors : [];
        const reasons = errs
            .map((e) => {
            const er = (e ?? {});
            const msg = typeof er.message === "string" ? er.message : null;
            const input = er.extra_info && typeof er.extra_info.input_name === "string"
                ? er.extra_info.input_name
                : null;
            if (msg && input)
                return `${msg} (${input})`;
            return msg ?? (input ? `problem with input "${input}"` : null);
        })
            .filter((r) => !!r);
        return `node ${nodeId}${cls}${reasons.length ? `: ${reasons.join("; ")}` : ""}`;
    });
    return (`The prompt was QUEUED, but ComfyUI REJECTED ${parts.length} output branch` +
        `${parts.length === 1 ? "" : "es"} at validation and will not run ${parts.length === 1 ? "it" : "them"} — ${parts.join(" | ")}. Everything upstream of the ACCEPTED outputs still ` +
        `runs, so this prompt can complete and report success while producing nothing ` +
        `from the rejected branch${parts.length === 1 ? "" : "es"}. Fix the named ` +
        `input(s) and re-queue if you expected output from ${parts.length === 1 ? "it" : "them"}.`);
}
export async function enqueuePrompt(workflow, extraData, opts) {
    if (isCloudMode())
        return cloudClient.enqueuePrompt(workflow, extraData, opts);
    // POST /prompt directly (rather than the SDK's _enqueue_prompt) for two
    // reasons: (1) the SDK does not forward `extra_data` — how comfy.org API-node
    // credentials must travel — nor can it enqueue at the front; and (2) on an
    // HTTP 400 the SDK throws a generic "Endpoint Bad Request" that discards
    // ComfyUI's authoritative prompt-validation body (`node_errors`), so callers
    // never learn which node/input was invalid (#485). Going direct lets us read
    // and surface that body. `comfyuiFetch` applies the same auth headers the SDK
    // would (CF Access / COMFYUI_AUTH_*).
    const url = `${getComfyUIBaseUrl()}/prompt`;
    const res = await comfyuiFetch(url, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
            prompt: workflow,
            client_id: "comfyui-mcp",
            ...(extraData ? { extra_data: extraData } : {}),
            ...(opts?.front ? { front: true } : {}),
            ...(opts?.partialExecutionTargets?.length
                ? { partial_execution_targets: [...opts.partialExecutionTargets] }
                : {}),
        }),
    });
    if (!res.ok) {
        throw await buildEnqueueError(res, url);
    }
    // A bare res.json() here is the worst place in the codebase for one, and the
    // same family as #1149/#1160/#828. This is a MUTATING POST that already
    // succeeded at the HTTP layer: if the body will not parse, the prompt may well
    // be queued and running, and `Unexpected end of JSON input` says nothing about
    // that — a caller reads it as "the run failed" and re-submits, queueing the
    // render twice.
    //
    // So: classify what actually answered (readComfyJson names the URL, status,
    // content type and body prefix), and state the delivery doubt explicitly. The
    // expectShape check is what catches the shape that matters — a gateway's own
    // JSON error envelope is valid JSON with no prompt_id, and reading
    // `data.prompt_id` off it would hand back `undefined` as a prompt id and let
    // every downstream poll chase a job that was never queued.
    let data;
    try {
        data = await readComfyJson(res, {
            url: "/prompt",
            expectShape: (v) => !!v && typeof v === "object" && typeof v.prompt_id === "string",
            shapeHint: "the enqueue result ({ prompt_id, … })",
        });
    }
    catch (err) {
        throw new ComfyUIError(`${err instanceof Error ? err.message : String(err)} ` +
            `OUTCOME UNDETERMINED: the POST to /prompt was accepted (HTTP ${res.status}) and the ` +
            `workflow MAY ALREADY BE QUEUED — this is not proof it failed. Check queue ` +
            `(action:"status") or get_history BEFORE re-submitting; a blind retry can run the ` +
            `same workflow twice.`, "ENQUEUE_UNVERIFIED");
    }
    // NB: `data.number` is ComfyUI's monotonic priority counter (and is NEGATIVE
    // for front-inserted jobs) — NOT the remaining queue depth. The old SDK path
    // returned exec_info.queue_remaining; to preserve an accurate count now that
    // we POST directly, read /queue for the authoritative running+pending total.
    // Fall back to undefined (never the misleading counter) if that read fails.
    // A 200 does NOT mean every output was accepted. ComfyUI validates each output
    // branch independently: if SOME validate it queues those and returns 200 with a
    // prompt_id, carrying `node_errors` for the ones it REJECTED (execution.py
    // returns (True, None, good_outputs, node_errors); it only 400s when NO output
    // is good). Those branches then never run — ComfyUI logs "Output will be
    // ignored" — and the prompt still completes, so the run reports success while
    // producing nothing from them.
    //
    // That is exactly how #1037 reached a user: a required nested input was missing
    // on one node, its output branch was dropped, and queue(action:"status") said
    // success with no video. This function already goes direct to /prompt SO THAT
    // node_errors can be read (#485) — but only the 400 path read them.
    //
    // Reported, never swallowed and never fatal: the run WAS queued and the
    // accepted branches will produce output, so this is a disclosure attached to a
    // success, not a failure.
    const rejectedOutputs = describeRejectedOutputs(data.node_errors);
    return {
        prompt_id: data.prompt_id,
        queue_remaining: await queueRemainingCount(),
        ...(rejectedOutputs ? { rejectedOutputs } : {}),
    };
}
/**
 * Authoritative "jobs still in the queue" count (running + pending) via a direct
 * /queue read. Returns undefined if the queue can't be read, so callers never
 * surface ComfyUI's monotonic `number` counter as a remaining-count. Only
 * reachable on the local/remote path — enqueuePrompt returns early in cloud mode.
 */
async function queueRemainingCount() {
    try {
        const res = await comfyuiFetch(`${getComfyUIBaseUrl()}/queue`);
        if (!res.ok)
            return undefined;
        const q = (await res.json());
        const running = (q.queue_running ?? q.Running ?? []).length;
        const pending = (q.queue_pending ?? q.Pending ?? []).length;
        return running + pending;
    }
    catch {
        return undefined;
    }
}
/**
 * Turn a non-OK /prompt response into a rich error. On the HTTP 400 that
 * ComfyUI returns when prompt validation fails, the JSON body carries the
 * authoritative diagnosis:
 *   {
 *     error: { type, message, details, extra_info },
 *     node_errors: { "<id>": { class_type, errors: [{ message, details, ... }] } }
 *   }
 * We format the top-level message plus one line per offending node
 * (`ClassType (node <id>): <message>`), and stash the raw payload in `details`.
 * Falls back to the generic HTTP status only when the body is empty or is not
 * the expected validation JSON (#485).
 */
/**
 * One field of a STRUCTURED /prompt error, made safe to interpolate (#1191,
 * codex review).
 *
 * The fix for #1191 redacted the raw-body FALLBACK and stopped there — but the
 * structured branch runs FIRST and copied `error.message`, each node's
 * `class_type`, and every `errors[].message` / `.details` verbatim into the
 * result. A hostile or reflecting gateway can answer with JSON shaped exactly
 * like a ComfyUI validation failure, so a credential echoed in any of those
 * fields reached the agent while the "redacted" path never ran.
 *
 * scrubSecretShapedText returns null when it cannot substitute safely; that is a
 * FAIL-CLOSED signal, so the field is withheld rather than passed through. The
 * #485 diagnosis survives because a genuine ComfyUI validation message contains
 * no credential and is returned unchanged.
 */
function safeField(v) {
    if (typeof v !== "string" || v === "")
        return "";
    const scrubbed = scrubSecretShapedText(v);
    return scrubbed === null ? "(withheld: contains a configured credential)" : scrubbed;
}
/**
 * ComfyUI error `type`s that mean "the value you gave this input is not
 * something this server has".
 *
 * Which one fires is decided by the node, not by us: `validate_inputs` skips the
 * combo-membership check for any input the node declares in its own
 * `VALIDATE_INPUTS`, so `LoadImage.image` — which does — can only ever fail as
 * `custom_validation_failed`, while a loader that leaves the check in place
 * fails as `value_not_in_list`. Both are the SAME situation for a media
 * selector, so both are matched — and both are stable machine tokens, unlike the
 * English `message`/`details` beside them.
 */
const MISSING_VALUE_ERROR_TYPES = new Set(["custom_validation_failed", "value_not_in_list"]);
/**
 * Loader inputs in a rejection that name a media FILE the server does not have
 * (#2673).
 *
 * Restricted to the (class_type, input) allowlist, so a `custom_validation_failed`
 * raised by some unrelated custom check — a bad width, an out-of-range strength —
 * never collects an "upload the file" note. An unlisted loader yields nothing,
 * which is the pre-#2673 silence rather than a wrong claim.
 */
function collectMissingInputMedia(nodeErrors) {
    const found = [];
    for (const [nodeId, info] of Object.entries(nodeErrors)) {
        const classType = info?.class_type;
        if (typeof classType !== "string")
            continue;
        const errs = Array.isArray(info?.errors) ? info.errors : [];
        for (const e of errs) {
            if (typeof e?.type !== "string" || !MISSING_VALUE_ERROR_TYPES.has(e.type))
                continue;
            const inputName = e.extra_info?.input_name;
            if (typeof inputName !== "string")
                continue;
            // A `value_not_in_list` reports `received_value`; when it is present and
            // is NOT a string, the widget never held a filename at all (a malformed
            // prompt, an object, a link tuple) and "the server does not have this
            // file" would be a wrong reading of a real rejection (gate, round 4).
            // ABSENCE must not disqualify: `custom_validation_failed` — the shape
            // LoadImage always fails with — carries no `received_value` at all.
            const received = e.extra_info?.received_value;
            if (received !== undefined && typeof received !== "string")
                continue;
            if (!isKnownLoaderInput(classType, inputName))
                continue;
            found.push({ nodeId, classType, inputName });
        }
    }
    return found;
}
/**
 * The recovery note for a rejection of that shape (#2673).
 *
 * WHAT IT DOES NOT SAY: why the file is absent. It states the one thing the
 * input's TYPE guarantees — ComfyUI resolves this value inside its OWN input
 * directory, so the rejection is about a file on a server and not about the
 * value's spelling — then hands over the machine-checked panel-vs-target
 * comparison and the two calls that put a file on THIS target. Naming a cause we
 * have not observed is what sent #2673's reporter to "attachment registration
 * race, or filename handling", when nothing between the panel and `/prompt`
 * rewrites the value at all.
 *
 * The last sentence is the one that saves the next render: `enqueue_workflow`
 * passes loader values through verbatim, so re-submitting reproduces this exactly.
 */
function describeMissingInputMedia(missing, target) {
    const where = missing
        .map((m) => `${safeField(m.classType)}.${safeField(m.inputName)} (node ${safeField(m.nodeId)})`)
        .join(", ");
    // #1191 — `originOf` drops userinfo/path/query, so a COMFYUI_URL carrying a
    // token cannot leak. It returns undefined only for a target `new URL()` cannot
    // parse, and interpolating THAT raw would reintroduce the leak by the back
    // door (gate finding), so the fallback takes the same fail-closed scrub as
    // every other interpolated field here.
    const origin = originOf(target) ?? safeField(target);
    return (`\n\nThat input names a FILE on the server, not a free value: on stock ComfyUI ${where} is a ` +
        `server-side FILE selector, resolved against the media directories of the ComfyUI at ` +
        `${origin} — so this is a question about WHICH server holds the file, not about the value's ` +
        `spelling. A file ATTACHED in the panel's chat is uploaded by ` +
        `the BROWSER to whichever ComfyUI that tab is on, a separate connection from this headless ` +
        `target (COMFYUI_URL), so a file can exist on one and not the other.` +
        `${describeMissingInputMediaDrift(target)}` +
        ` To put it on THIS target: upload_image (action:"image") for a file on disk, or upload_image ` +
        `(action:"stage") for an existing ComfyUI output; then re-enqueue with the filename it ` +
        `returns. Re-submitting this workflow unchanged will fail identically — enqueue_workflow ` +
        `sends loader values through verbatim and never rewrites them.`);
}
async function buildEnqueueError(res, requestedUrl) {
    // WHICH server actually answered (#2673, gate finding).
    //
    // `comfyuiFetch` goes through `fetch`, which FOLLOWS redirects — so a 307/308
    // in front of ComfyUI can move the POST to a different origin, and the input
    // directory that was searched belongs to whoever answered, not to whoever was
    // addressed. Naming the requested URL would then point the reader at the proxy
    // and compare the panel's origin against the wrong server, producing confident
    // and wrong guidance on exactly the message written to end that guessing.
    //
    // `res.url` is "" on a Response that was constructed rather than fetched, so
    // the requested URL stays as the fallback: an unknown final URL must degrade to
    // the address we know we asked, never to "".
    const target = res.url || requestedUrl;
    // unknown-ok: "" only routes to the GENERIC status message, which reports the
    // HTTP status and claims nothing about node errors. An unread body and an empty
    // body get the same honest fallback rather than a fabricated validation result.
    const bodyText = await res.text().catch(() => "");
    // #1191 — statusText is attacker-influenceable on a hostile proxy and lands in
    // a message the agent reads, so it gets the same scrub the body does.
    // describeStatus drops a standard reason phrase entirely (it adds nothing),
    // clips a long one, and falls back to the bare status if it cannot scrub.
    const generic = `ComfyUI /prompt returned ${describeStatus(res.status, res.statusText)}`;
    let parsed;
    try {
        parsed = bodyText ? JSON.parse(bodyText) : undefined;
    }
    catch {
        parsed = undefined;
    }
    if (parsed && typeof parsed === "object") {
        const payload = parsed;
        const nodeErrors = payload.node_errors ?? {};
        const lines = [];
        for (const [nodeId, info] of Object.entries(nodeErrors)) {
            const cls = info?.class_type ?? "node";
            const errs = Array.isArray(info?.errors) ? info.errors : [];
            if (errs.length === 0) {
                lines.push(`- ${safeField(cls)} (node ${safeField(nodeId)}): validation failed`);
                continue;
            }
            for (const e of errs) {
                const detail = e?.details ? ` (${safeField(e.details)})` : "";
                lines.push(`- ${safeField(cls)} (node ${safeField(nodeId)}): ` +
                    `${safeField(e?.message) || "validation failed"}${detail}`);
            }
        }
        const headline = payload.error?.message
            ? `ComfyUI rejected the workflow (${res.status}): ${safeField(payload.error.message)}`
            : `ComfyUI rejected the workflow (${res.status})`;
        if (lines.length > 0 || payload.error?.message) {
            const base = lines.length > 0 ? `${headline}\n${lines.join("\n")}` : headline;
            // #2673 — the reporter's agent got exactly `base` and stopped. It named the
            // node and quoted ComfyUI's "Invalid image file", and said nothing about
            // WHICH server was asked, whether a connected panel is on a different one,
            // or how to put the file where the render will look. Appended, never
            // substituted: ComfyUI's own diagnosis stays first and whole.
            const missing = collectMissingInputMedia(nodeErrors);
            const message = missing.length > 0 ? base + describeMissingInputMedia(missing, target) : base;
            return new WorkflowExecutionError(message, {
                status: res.status,
                error: payload.error,
                node_errors: nodeErrors,
            });
        }
    }
    // Empty or unexpected body: fall back to the generic HTTP status, but keep a
    // REDACTED prefix of the text so nothing is silently dropped.
    //
    // #1191 — this used to interpolate 500 RAW bytes. A gateway that reflects the
    // request can echo our own credential in the body it answers with, and this
    // string goes straight into a tool result the agent reads and users paste into
    // bug reports. /prompt is the worst endpoint to have that on: it is the call
    // every render makes, so this is the error path most likely to be hit and
    // shared. bodyPrefixOf redacts by SHAPE as well as by known value, and
    // withholds the prefix entirely when it cannot substitute safely.
    return new ConnectionError(bodyText ? `${generic}: ${bodyPrefixOf(bodyText)}` : generic);
}
/**
 * Remove a specific pending job from the queue by prompt_id.
 */
export async function deleteQueueItem(id) {
    if (isCloudMode())
        return cloudClient.deleteQueueItem(id);
    const client = getClient();
    await client.deleteItem("queue", id);
}
/**
 * Clear all pending jobs from the queue (doesn't affect running job).
 */
export async function clearQueue() {
    if (isCloudMode())
        return cloudClient.clearQueue();
    const client = getClient();
    await client.clearItems("queue");
}
export async function getSamplers() {
    if (isCloudMode())
        return cloudClient.getSamplers();
    const client = getClient();
    return client.getSamplers();
}
export async function getSchedulers() {
    if (isCloudMode())
        return cloudClient.getSchedulers();
    const client = getClient();
    return client.getSchedulers();
}
export async function getCheckpoints() {
    if (isCloudMode())
        return cloudClient.getCheckpoints();
    const client = getClient();
    return client.getSDModels();
}
export async function getLoRAs() {
    if (isCloudMode())
        return cloudClient.getLoRAs();
    const client = getClient();
    return client.getLoRAs();
}
export async function getVAEs() {
    if (isCloudMode())
        return cloudClient.getVAEs();
    const client = getClient();
    return client.getVAEs();
}
export async function getUpscaleModels() {
    if (isCloudMode())
        return cloudClient.getUpscaleModels();
    const client = getClient();
    return client.getUpscaleModels();
}
export function resetClient() {
    uploadChains.clear();
    if (clientInstance) {
        try {
            clientInstance.close();
        }
        catch {
            // Ignore close errors — process may already be dead
        }
        clientInstance = null;
        logger.info("ComfyUI client reset");
    }
}
/**
 * Reset the singleton ONLY if it is still the client the caller was using. A
 * failing request captures its client at start and passes it here in its catch;
 * if a concurrent reset (resetObjectInfoCache abandons the in-flight slot) already
 * spun up a NEW client in the meantime, we must not close that newer client + its
 * fresh WebSocket out from under whoever created it (codex WS-3 round-2 finding).
 * Returns true if it actually reset. A null argument never resets.
 */
export function resetClientIfCurrent(client) {
    if (!client || clientInstance !== client)
        return false;
    resetClient();
    return true;
}
export function getComfyUIPath() {
    if (isCloudMode())
        return cloudClient.getComfyUIPath();
    return config.comfyuiPath;
}
export async function getLogs(opts) {
    // Cloud mode has no /internal/logs equivalent; this always throws
    // CLOUD_UNSUPPORTED, so there is nothing to select or scrub.
    if (isCloudMode())
        return cloudClient.getLogs();
    // A managed/panel restart or Manager reboot leaves the cached client bound to
    // a socket that was torn down, so the first /internal/logs call after it
    // surfaces a bare "fetch failed" (issue #399) — regardless of any keyword the
    // caller passed, since filtering happens after this fetch. Mirror the
    // getObjectInfo reset-and-retry: drop the stale client and retry once against
    // a fresh one before giving up, and surface the underlying error if it still
    // fails so callers see more than "fetch failed".
    let text;
    // Capture the client this request runs on so the catch only resets THIS one
    // (not a newer client a concurrent reset may have installed).
    const startClient = getClient();
    try {
        text = await startClient.fetchApi("/internal/logs").then((r) => r.text());
    }
    catch (err) {
        logger.warn("getLogs failed; resetting client and retrying once", {
            error: err instanceof Error ? err.message : String(err),
        });
        resetClientIfCurrent(startClient);
        try {
            text = await getClient().fetchApi("/internal/logs").then((r) => r.text());
        }
        catch (err2) {
            // A DIAGNOSED non-JSON answer is not a connection failure — the server (or
            // whatever is in front of it) replied, and the diagnosis already names the
            // endpoint, the status and what answered. Wrapping it in a ConnectionError
            // titled "Failed to fetch" contradicted its own contents and sent readers
            // to check whether ComfyUI was up when it demonstrably was (#828).
            if (isNonJsonResponseError(err2))
                throw err2;
            if (isComfyTransportFailure(err) && isComfyTransportFailure(err2)) {
                const relayed = await panelReadFallback("logs", err2);
                if (relayed) {
                    text = relayed.body;
                }
                else {
                    const detail = err2 instanceof Error ? err2.message : String(err2);
                    throw new ConnectionError(`Failed to fetch ComfyUI logs after reconnect retry: ${detail}`);
                }
            }
            else {
                const detail = err2 instanceof Error ? err2.message : String(err2);
                throw new ConnectionError(`Failed to fetch ComfyUI logs after reconnect retry: ${detail}`);
            }
        }
    }
    // ComfyUI returns logs as a JSON-encoded string with \n separators,
    // or as raw text depending on version. Handle both.
    let lines;
    try {
        const parsed = JSON.parse(text);
        lines = (typeof parsed === "string" ? parsed : text).split("\n").filter(Boolean);
    }
    catch {
        // Not JSON — treat as raw text
        lines = text.split("\n").filter(Boolean);
    }
    return selectAndScrubLogLines(lines, opts);
}
/**
 * Select the requested lines, THEN scrub them (#1223).
 *
 * The order is the whole point, and getting it backwards is a false ABSENCE:
 * `get_system_stats action:"logs"` filtered by keyword AFTER this function had
 * already redacted, so a keyword matching redacted text — `keyword:"ltxvideo"`,
 * `keyword:"LTXLoopingSampler"` — reported "No log lines found" while the
 * matching lines sat in the log. Reporting nothing found is much worse than
 * reporting a redacted match: the caller stops looking.
 *
 * Selection lives HERE rather than in the caller so the raw text never leaves
 * this function. Callers get scrubbed lines and cannot filter on anything else,
 * which is what makes the ordering a property of the code rather than a rule
 * every future call site has to remember.
 */
function selectAndScrubLogLines(lines, opts) {
    let selected = lines;
    if (opts?.keyword) {
        const kw = opts.keyword.toLowerCase();
        selected = selected.filter((line) => line.toLowerCase().includes(kw));
    }
    // Tail before scrubbing: strictly less work, and identical output either way.
    if (typeof opts?.maxLines === "number" && selected.length > opts.maxLines) {
        selected = selected.slice(-opts.maxLines);
    }
    return scrubLogLines(selected);
}
/**
 * ComfyUI frontend per-user settings, served by the frontend user manager:
 *   GET  /settings         → every stored setting as one JSON object
 *   GET  /settings/{id}    → one setting's raw stored value
 *   POST /settings/{id}    → persist one setting (JSON body is the raw value)
 *
 * These are the ComfyUI *frontend* UI settings (`Comfy.*` ids) and are entirely
 * unrelated to our own generation-defaults store, which `get_defaults` reads and
 * writes with action:"get"/"set". Local and remote
 * (`--comfyui-url`) both work over plain REST and inherit `comfyuiFetch` auth
 * headers. Comfy Cloud exposes no per-user settings store, so these throw
 * CLOUD_UNSUPPORTED via `requireLocalMode`.
 *
 * Multi-user note: a `--multi-user` ComfyUI keys settings by a `comfy-user`
 * header; set `COMFYUI_AUTH_COMFY_USER` (any COMFYUI_AUTH_* header is injected
 * by `comfyuiFetch`) or requests may 404 or read/write another user's store.
 */
function settingsVersionDriftError() {
    return new ComfyUIError("This ComfyUI version/config does not expose the user settings API " +
        "(requires the standard frontend user manager). If this is a --multi-user " +
        "server, set COMFYUI_AUTH_COMFY_USER so requests carry a comfy-user header.", "SETTINGS_UNSUPPORTED");
}
/**
 * GET /settings — every stored frontend setting as a raw `id: value` object.
 *
 * An UNREADABLE answer is not an empty one (#796). This used to fall through to
 * `{}` on any non-JSON body, and `get_defaults action:"get_ui"` renders that as
 * `count: 0` beside a note saying only explicitly-stored settings appear — i.e.
 * "you have no settings", asserted from a body nobody could parse. The realistic
 * trigger is the one this repo keeps meeting: an auth proxy answering with a
 * sign-in page, or a different service on the host.
 *
 * readComfyJson is the vetted path for exactly this — it names the URL and what
 * actually answered, and scrubs secret-shaped text — and was already imported
 * here; these two functions simply hand-rolled `res.text()` + `JSON.parse`
 * instead. `expectShape` also rejects a 200 that parses as JSON but is not a map
 * (an API gateway's own error envelope), which the old truthiness check accepted
 * as long as it was any object — including an array.
 */
export async function getSettings() {
    requireLocalMode("settings");
    const client = getClient();
    const res = await comfyApiFetch("/settings");
    if (res.status === 404)
        throw settingsVersionDriftError();
    return await readComfyJson(res, {
        url: "/settings",
        expectShape: (v) => typeof v === "object" && v !== null && !Array.isArray(v),
        shapeHint: "the settings map",
    });
}
/**
 * GET /settings/{id} — one setting's raw stored value. Returns `undefined` for
 * an unset key: ComfyUI returns an empty body or `null` for unset ids on
 * different versions, and some builds 404 the per-id route, so empty / `null` /
 * 404 are treated uniformly as "unset (frontend default applies)". Stored values
 * are passed through verbatim — never coerced, so an older frontend's `"true"`
 * string surfaces as a string.
 *
 * PARSE FAILURE IS NOT IN THAT LIST ANY MORE (#796). Empty, `null` and 404 are
 * things a ComfyUI build actually SAYS to mean "unset"; a non-empty body that is
 * not JSON is something else answering — a proxy sign-in page, a gateway error
 * envelope — and reporting it as unset is a claim nobody observed. It reached the
 * caller as `value: null, note: "unset (frontend default applies)"`, and worse,
 * `set_ui` reports `previous: null` from this same call, so a user who then set a
 * value was told the old one was unset and could not restore it.
 */
export async function getSetting(id) {
    requireLocalMode("settings");
    const client = getClient();
    const url = `/settings/${encodeURIComponent(id)}`;
    const res = await comfyApiFetch(url);
    if (res.status === 404)
        return undefined;
    // ONLY 404 means "unset". Every other error status must throw (review finding 1).
    //
    // This branch could not run before #385 made it reachable, and without it the
    // function reopens the exact hole its own docstring says was closed: a 403 or
    // 500 carrying a gateway's JSON error envelope parses fine and is RETURNED AS
    // THE STORED VALUE, and a 502 with an empty body reports "unset (frontend
    // default applies)" for a setting nobody read. `set_ui` echoes this call as
    // `previous:`, so a user who then writes a value is told the old one was unset
    // and cannot restore it.
    if (!res.ok) {
        throw new ComfyUIError(`ComfyUI ${url} answered ${describeStatus(res.status, res.statusText)}, so this setting could ` +
            `NOT be read. That is not a report that it is unset — nothing was read. Only a 404 means ` +
            `"unset (frontend default applies)" on builds that 404 the per-id route.`, "HTTP_ERROR");
    }
    const text = await res.text();
    if (text.trim() === "")
        return undefined;
    try {
        const parsed = JSON.parse(text);
        return parsed === null ? undefined : parsed;
    }
    catch {
        // Classified rather than swallowed. Same machinery readComfyJson uses, so the
        // message names the URL and the responder and is secret-scrubbed; it cannot
        // use readComfyJson directly because an EMPTY body must stay "unset" here and
        // that helper (correctly) treats an unparseable body as a failure.
        throw new NonJsonResponseError(classifyNonJson({
            url,
            status: res.status,
            statusText: res.statusText,
            contentType: res.headers.get("content-type") ?? "",
            body: text,
        }));
    }
}
/**
 * POST /settings/{id} — persist one setting. The value is sent as-is (raw JSON
 * body); it is stored verbatim and never coerced. A 404 here means the settings
 * route is absent (version drift), not an unknown id — unknown ids are stored
 * verbatim and ignored by the UI.
 */
export async function setSetting(id, value) {
    requireLocalMode("settings");
    const client = getClient();
    const res = await comfyApiFetch(`/settings/${encodeURIComponent(id)}`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(value),
    });
    if (res.status === 404)
        throw settingsVersionDriftError();
    if (!res.ok) {
        // unknown-ok: "" is interpolated into an ERROR MESSAGE and nothing else — the
        // HTTP status is reported either way, so an unreadable body costs detail in the
        // text, never a wrong conclusion. Verified there is no branch on this value.
        //
        // #385 made this branch REACHABLE, and it was printing 500 raw bytes. A
        // reflecting gateway answers `invalid token: Bearer <ours>` and that went
        // straight into a tool result (review finding 2). Body and reason phrase both
        // go through the scrubbers now, as everywhere else.
        const body = await res.text().catch(() => "");
        throw new ConnectionError(`ComfyUI /settings/${id} returned ${describeStatus(res.status, res.statusText)}: ${bodyPrefixOf(body)}`);
    }
}
export { MAX_HISTORY_RESPONSE_BYTES } from "./bounded-response.js";
/** The panel `fetch_comfyui_read` command has one fixed global `/history`
 * route. A prompt-scoped caller still uses that body, then we keep only the
 * requested id — never the rest of the map. */
function historyForPrompt(history, promptId) {
    if (!promptId)
        return history;
    if (!history || typeof history !== "object" || Array.isArray(history))
        return {};
    if (!Object.prototype.hasOwnProperty.call(history, promptId))
        return {};
    const entry = history[promptId];
    if (!entry || typeof entry !== "object" || Array.isArray(entry))
        return {};
    return { [promptId]: entry };
}
export async function getHistory(promptId, options = {}) {
    if (isCloudMode())
        return cloudClient.getHistory(promptId, options);
    const path = promptId ? `/history/${promptId}` : "/history";
    let res;
    try {
        res = await comfyApiFetch(path, options.signal ? { signal: options.signal } : {});
    }
    catch (err) {
        // #2532 — prompt-scoped `/history/<id>` used to skip this fallback because
        // the panel command is global-only. That left get_history(action:"list")
        // and the run-completion journal unable to name a finished panel_run's
        // outputs while queue.status still reported done:true from local cache.
        if (isComfyTransportFailure(err)) {
            const relayed = await panelReadFallback("history", err);
            if (relayed) {
                const all = await readComfyJson(panelReadResponse(relayed), {
                    url: "/history",
                    maxBytes: MAX_HISTORY_RESPONSE_BYTES,
                    bodyTimeoutMs: Math.round(comfyHttpTimeoutSeconds() * 1000),
                    signal: options.signal,
                });
                return historyForPrompt(all, promptId);
            }
        }
        throw err;
    }
    // #1149 — this was a bare res.json(), the last unguarded parse on the media
    // read paths. A reporter on a remote H100 got `Unexpected end of JSON input`
    // from get_history and get_image after a completed video render, which
    // get_image then wrapped as HISTORY_UNREADABLE: a raw parser message standing
    // in for a diagnosis, naming no endpoint and no status, for a render that had
    // demonstrably succeeded.
    //
    // readComfyJson is what every sibling read already uses (#918/#946/#952): it
    // names the URL, the status, the content type and a body prefix, so an empty
    // body, an auth gate's login page and a proxy's HTML are each said out loud
    // rather than collapsing into one parser error.
    //
    // No expectShape: /history's value is an object keyed by prompt id, and `{}`
    // is a perfectly valid EMPTY history. Asserting a shape here would turn a
    // legitimately empty answer into a fabricated failure — the opposite defect.
    return readComfyJson(res, {
        url: path,
        maxBytes: MAX_HISTORY_RESPONSE_BYTES,
        bodyTimeoutMs: Math.round(comfyHttpTimeoutSeconds() * 1000),
        signal: options.signal,
    });
}
/** A /view response is saved and may later be previewed, so bound the first read too. */
export const MAX_VIEW_RESPONSE_BYTES = SHARED_MAX_VIEW_RESPONSE_BYTES;
export { MAX_PREVIEW_SOURCE_BYTES };
function validateViewResponseOrigin(res, expectedOrigin, label) {
    if (res.url) {
        const actualOrigin = httpOriginOf(res.url);
        // The transport may retry exact 127.0.0.1 at localhost for an IPv6-only
        // loopback listener. The comparator folds only those known loopback aliases;
        // remote origins remain an exact scheme/host/port match.
        if (!sameOrigin(actualOrigin, expectedOrigin)) {
            throw new ComfyUIError(`ComfyUI /view response from ${label} changed origin unexpectedly; the response was refused.`, "VIEW_RESPONSE_ORIGIN", { expectedOrigin, actualOrigin: actualOrigin ?? "invalid" });
        }
    }
    if (res.status >= 300 && res.status < 400) {
        // The caller must not follow a redirect from a panel-origin fallback. A
        // same-origin redirect is refused too: without a validated final URL it
        // would be easy to reintroduce cross-origin leakage in a later edit.
        throw new ComfyUIError(`ComfyUI /view returned an unsafe redirect from ${label}; the response was refused.`, "VIEW_REDIRECT_UNSAFE", { status: res.status });
    }
}
function viewTooLarge(filename, maxBytes) {
    return new ComfyUIError(`ComfyUI /view response for "${filename}" exceeds the ${maxBytes / 1024 ** 2} MB safety limit.`, "VIEW_TOO_LARGE", { filename, maxBytes });
}
async function readViewResponseBounded(res, filename, timeoutMs, signal, maxBytes = MAX_VIEW_RESPONSE_BYTES) {
    const limit = clampViewResponseBytes(maxBytes);
    try {
        return await readResponseBodyBounded(res, timeoutMs, limit, signal);
    }
    catch (error) {
        if (error instanceof BoundedResponseError) {
            if (error.kind === "too-large")
                throw viewTooLarge(filename, limit);
            throw new ComfyUIError(`ComfyUI /view did not finish sending "${filename}" within ${timeoutMs / 1000}s; the response was aborted.`, "VIEW_READ_TIMEOUT", { filename, timeout_ms: timeoutMs });
        }
        throw error;
    }
}
/**
 * Fetch an image from ComfyUI's /view endpoint as a base64 string.
 * Works over HTTP — no local filesystem access needed.
 */
export async function fetchImage(filename, type = "output", subfolder = "", options = {}) {
    if (isCloudMode())
        return cloudClient.fetchImage(filename, type, subfolder, options);
    const client = getClient();
    const params = new URLSearchParams({ filename, type, subfolder });
    const viewRoute = `/view?${params.toString()}`;
    const configuredTarget = `${getComfyUIBaseUrl().replace(/\/+$/, "")}${viewRoute}`;
    const configuredOrigin = httpOriginOf(configuredTarget);
    const fallbackPath = `${getComfyUIBasePath().replace(/\/+$/, "")}${viewRoute}`;
    let res;
    let answeredByPanelOrigin;
    let responseReadTimeoutMs = Math.round(comfyHttpTimeoutSeconds() * 1000);
    try {
        res = await comfyApiFetch(viewRoute, {
            redirect: "manual",
            ...(options.signal ? { signal: options.signal } : {}),
        });
        if (configuredOrigin === undefined) {
            throw new ComfyUIError("The configured ComfyUI target is not a valid HTTP(S) origin.", "VIEW_ERROR");
        }
        validateViewResponseOrigin(res, configuredOrigin, "the configured ComfyUI target");
    }
    catch (primaryError) {
        if (!isComfyTransportFailure(primaryError))
            throw primaryError;
        // A looping fetch_image / get_image fallback must not wrap this failure
        // again. One hop: one panel relay, then stop (#2864).
        if (panelImageRelayHop.getStore())
            throw primaryError;
        const choice = choosePanelFallbackOrigin(configuredTarget, connectedPanelFallbackOriginsNow());
        const declined = describeDeclinedPanelFallback(choice);
        if (choice.kind !== "use") {
            // Production children have no bridge object. They use the bounded
            // reference-only relay instead: the orchestrator resolves the child
            // capability and asks the authenticated panel to fetch same-origin /view. The old
            // direct-origin seam remains injectable for focused fallback mechanics
            // tests, but it is never installed by production.
            const relayed = await panelImageRelayHop.run(true, () => panelImageFallback(filename, type, subfolder, primaryError, declined));
            if (relayed)
                return relayed;
            throw primaryError;
        }
        else {
            const fallbackUrl = `${choice.origin}${fallbackPath}`;
            try {
                // The configured auth headers are for the headless target, not an origin
                // selected from a browser handshake. Never send them to the fallback.
                // A browser-only/tunnel origin can be unreachable from this process; do
                // not add the full headless timeout on top of the failed primary request.
                res = await fetch(fallbackUrl, {
                    redirect: "manual",
                    signal: options.signal
                        ? AbortSignal.any([options.signal, AbortSignal.timeout(8_000)])
                        : AbortSignal.timeout(8_000),
                });
                responseReadTimeoutMs = 8_000;
            }
            catch (fallbackError) {
                const primary = primaryError instanceof Error ? primaryError.message : String(primaryError);
                const secondary = describeFetchFailure(fallbackError).message;
                throw new Error(`${primary} I then tried the ComfyUI a connected sidebar panel is on ` +
                    `(${fallbackUrl}), and that failed too: ${secondary}. The browser can reach ` +
                    `that origin — this process cannot — so something between them (a tunnel, a ` +
                    `container boundary, or a server bound to one interface) is in the way.`, { cause: fallbackError });
            }
            validateViewResponseOrigin(res, choice.origin, "the connected panel's ComfyUI");
            answeredByPanelOrigin = choice.origin;
        }
    }
    if (!res.ok) {
        const where = subfolder ? `${type}/${subfolder}` : type;
        const responder = answeredByPanelOrigin
            ? `The connected panel's ComfyUI at ${answeredByPanelOrigin}`
            : "ComfyUI";
        let rejectionReason = "";
        if (res.status === 400) {
            try {
                const body = await readViewResponseBounded(res, filename, responseReadTimeoutMs, options.signal);
                rejectionReason = bodyPrefixOf(body.toString("utf8"));
            }
            catch {
                // The status is still actionable when a diagnostic body cannot be read.
            }
        }
        throw new ComfyUIError(`${responder} /view returned ${res.status} for "${filename}" (${where}). ` +
            (res.status === 404
                ? `No such file in the ComfyUI ${type} directory` +
                    (subfolder ? ` under subfolder "${subfolder}"` : "") +
                    `. Check the filename/subfolder (e.g. via get_image (action:"list_outputs") or get_history).`
                : `The ComfyUI server rejected the request${rejectionReason ? `: ${rejectionReason}` : "."}`), res.status === 404 ? "IMAGE_NOT_FOUND" : "VIEW_ERROR", {
            status: res.status,
            filename,
            type,
            subfolder,
            ...(rejectionReason ? { reason: rejectionReason } : {}),
        });
    }
    const contentType = res.headers.get("content-type") ?? "image/png";
    const mimeType = contentType.split(";")[0].trim();
    const bytes = await readViewResponseBounded(res, filename, responseReadTimeoutMs, options.signal, options.maxBytes);
    const base64 = bytes.toString("base64");
    return { base64, mimeType };
}
function serializePerTargetUpload(key, work) {
    const prior = uploadChains.get(key);
    let release = () => { };
    const gate = new Promise((resolve) => {
        release = resolve;
    });
    uploadChains.set(key, gate);
    const run = async () => {
        if (prior) {
            // unknown-ok: a prior upload's failure must not block this one
            await prior.catch(() => undefined);
        }
        try {
            return await work();
        }
        finally {
            release();
            if (uploadChains.get(key) === gate)
                uploadChains.delete(key);
        }
    };
    return run();
}
function uploadDeliveryUnknown(err) {
    if (!isComfyTransportFailure(err))
        return false;
    return deliveryDoubt(describeFetchFailure(err).code, "POST") !== "";
}
function buildUploadForm(name, data, mimeType, overwrite, subfolder) {
    const formData = new FormData();
    formData.append("image", new Blob([data], { type: mimeType }), name);
    formData.append("type", "input");
    formData.append("overwrite", String(overwrite));
    if (subfolder)
        formData.append("subfolder", subfolder);
    return formData;
}
async function readUploadImageResponse(res) {
    if (!res.ok) {
        // This branch only became REACHABLE with #385, and reaching it must not cost
        // the #1160 diagnosis. An auth gate answering 401 with a sign-in page is the
        // reported case, and "returned 401: <!DOCTYPE html>…" is strictly worse than
        // naming the gate and saying which layer wants the credential.
        //
        // It must also not dump a RAW body: a gateway that reflects the request can
        // put our own credential in it, which is exactly what bodyPrefixOf exists to
        // prevent. Both problems are solved by classifying rather than interpolating.
        // unknown-ok: "" only means an unreadable body, which classifyNonJson reports
        // as an empty one — a loss of detail, never a wrong conclusion.
        const text = await res.text().catch(() => "");
        // #1905 — HTTP 413 is a size limit. Classifying it as NON_JSON_RESPONSE
        // (plain-text aiohttp body) then recommended checking the ComfyUI base URL,
        // even when /system_stats was healthy JSON. Name the limit before any parse.
        const tooLarge = uploadTooLargeError({
            url: "/upload/image",
            status: res.status,
            statusText: res.statusText,
            body: text,
        });
        if (tooLarge)
            throw tooLarge;
        throw new NonJsonResponseError(classifyNonJson({
            url: "/upload/image",
            status: res.status,
            statusText: res.statusText,
            contentType: res.headers.get("content-type") ?? "",
            body: text,
        }));
    }
    // Never let a parser message stand in for a diagnosis: a proxy or a
    // still-starting server answering 200 with HTML used to surface as a raw
    // SyntaxError with no mention of what was requested (#946, and the same class
    // as #918/#952).
    return readComfyJson(res, {
        url: "/upload/image",
        expectShape: (v) => !!v && typeof v === "object" && typeof v.name === "string",
        shapeHint: "the upload result ({ name, subfolder, type })",
    });
}
async function proveUploadedInputLanded(name, subfolder, data) {
    try {
        const params = new URLSearchParams({ filename: name, type: "input", subfolder });
        const res = await comfyApiFetch(`/view?${params.toString()}`, { method: "GET" });
        if (res.status === 404)
            return "absent";
        if (!res.ok)
            return "unknown";
        const timeoutMs = Math.round(comfyHttpTimeoutSeconds() * 1000);
        const bytes = await readViewResponseBounded(res, name, timeoutMs, undefined, Math.max(data.length, 1));
        return bytes.equals(data) ? "committed" : "absent";
    }
    catch {
        // unknown-ok: a failed /view cannot prove the POST committed or missed
        return "unknown";
    }
}
async function postUploadImage(name, data, mimeType, overwrite, subfolder) {
    const res = await comfyApiFetch("/upload/image", {
        method: "POST",
        body: buildUploadForm(name, data, mimeType, overwrite, subfolder),
    });
    return readUploadImageResponse(res);
}
async function uploadImageHttpOnce(name, data, mimeType, overwrite, subfolder) {
    try {
        return await postUploadImage(name, data, mimeType, overwrite, subfolder);
    }
    catch (err) {
        if (!uploadDeliveryUnknown(err))
            throw err;
        const landing = await proveUploadedInputLanded(name, subfolder, data);
        if (landing === "committed") {
            logger.info("Upload POST transport failed; /view proved the input landed", {
                name,
                subfolder,
            });
            return { name, subfolder, type: "input" };
        }
        if (landing === "absent" && overwrite) {
            try {
                return await postUploadImage(name, data, mimeType, overwrite, subfolder);
            }
            catch (retryErr) {
                if (!uploadDeliveryUnknown(retryErr))
                    throw retryErr;
                const again = await proveUploadedInputLanded(name, subfolder, data);
                if (again === "committed") {
                    logger.info("Upload retry transport failed; /view proved the input landed", {
                        name,
                        subfolder,
                    });
                    return { name, subfolder, type: "input" };
                }
                throw retryErr;
            }
        }
        throw err;
    }
}
/**
 * Upload an image to ComfyUI's input/ directory via HTTP multipart POST.
 * Works over HTTP — no local filesystem access needed.
 */
export async function uploadImageHttp(filename, data, mimeType = "image/png", overwrite = true) {
    if (isCloudMode())
        return cloudClient.uploadImageHttp(filename, data, mimeType, overwrite);
    // #946 — a `filename` carrying a path ("assets/clip.mp4") is a SUBFOLDER
    // request, and ComfyUI's /upload/image takes that as its own form field, not
    // as a slash inside the multipart filename. Sending the whole path as the
    // filename put it somewhere between the transport and ComfyUI's handler and
    // came back as a bare `Unexpected non-whitespace character after JSON at
    // position 4` — no upload, no usable error. Split it and use the field the
    // API actually has, which is also what the caller was asking for.
    const { subfolder, name } = splitUploadTarget(filename);
    return serializePerTargetUpload(getComfyUIBaseUrl(), () => uploadImageHttpOnce(name, data, mimeType, overwrite, subfolder));
}
/**
 * Split an upload target into ComfyUI's two fields (#946).
 *
 * The implementation lives in ./upload-target.js so BOTH upload clients can
 * share it (cloud-client.ts cannot import from here without a circular
 * import); re-exported so existing imports keep working.
 */
import { splitUploadTarget } from "./upload-target.js";
export { splitUploadTarget };
//# sourceMappingURL=client.js.map