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,159 lines • 65.7 kB
// Loopback HTTP console for MCP / orchestrator settings (control plane).
//
// The ComfyUI sidebar panel stays a canvas-focused client: provider, effort,
// context, storyboards. Service lifecycle, OAuth, MCP mappings, and advanced
// tool suites live here — opened from the panel's Advanced → "Open MCP Console".
//
// Bound to 127.0.0.1 only; never exposed off-host.
import { createReadStream, existsSync } from "node:fs";
import { createServer } from "node:http";
import { extname, join, resolve, sep } from "node:path";
import { allBackendReadiness } from "./backend-readiness.js";
import { getLoraCatalog, loraPreviewsDir } from "../services/lora-catalog.js";
import { setPanelSecret, clearPanelSecret, envFilePath as envStorePath, listPanelSecretsMasked, receiptDisclosures, atRiskNote, removeDisclosures, revokeIsClean, slotRevokeState, slotSaveConfirmed, slotShellProvidedKeys, unconfirmedSlotKeys, CREDENTIAL_SLOTS, } from "../services/panel-secrets.js";
import { clearPromptOverride, isKnownPrompt, listPrompts, panelPromptsPath, setPromptOverride, } from "../services/prompt-overrides.js";
import { OPENAI_KEY_PROVIDER_IDS } from "../services/openai-provider-registry.js";
import { processLocale, resolveLocale, trFor } from "../i18n/index.js";
import { logger } from "../utils/logger.js";
// The simple api-key providers (glm/kimi/moonshot) are spliced in from the
// openai-provider-registry so a new one appears here by adding a registry entry.
const KNOWN_BACKENDS = [
    "claude",
    "codex",
    "chatgpt",
    "gemini",
    "antigravity",
    "grok",
    ...OPENAI_KEY_PROVIDER_IDS,
    "ollama",
    "copilot", // EXPERIMENTAL — see orchestrator/index.ts's copilotModel comment
];
function sendJson(res, status, body) {
    const payload = JSON.stringify(body);
    res.writeHead(status, {
        "Content-Type": "application/json; charset=utf-8",
        "Content-Length": Buffer.byteLength(payload),
        "Cache-Control": "no-store",
    });
    res.end(payload);
}
function sendHtml(res, status, html) {
    res.writeHead(status, {
        "Content-Type": "text/html; charset=utf-8",
        "Content-Length": Buffer.byteLength(html),
        "Cache-Control": "no-store",
    });
    res.end(html);
}
const FRAME_ANCESTORS = "frame-ancestors http://127.0.0.1:8188 http://localhost:8188 'self'";
function sendFramedHtml(res, status, html) {
    res.writeHead(status, {
        "Content-Type": "text/html; charset=utf-8",
        "Content-Length": Buffer.byteLength(html),
        "Cache-Control": "no-store",
        "Content-Security-Policy": FRAME_ANCESTORS,
    });
    res.end(html);
}
// ─── i18n for the two served pages ────────────────────────────────────────────
//
// These documents are read in a BROWSER, and the reader's language is not this
// process's: the panel's own language setting lives in ComfyUI's client settings and
// never reaches the orchestrator. `Accept-Language` is the closest thing to that
// reader's own choice that a request carries, so it decides; a browser that asks for
// nothing we ship falls back to the process locale, which honours an explicit `--lang`.
/** The language for a page served from here. */
function pageLocale(req) {
    const header = String(req.headers["accept-language"] ?? "");
    // Ranked by q-value rather than trusting header order: descending order is a browser
    // convention, not a requirement, and `q=0` is an explicit REJECTION of a language —
    // picking it because it appeared first would be exactly backwards. Sort is stable, so
    // equal weights keep the order the browser sent.
    const ranked = header
        .split(",")
        .map((part) => {
        const [tag, ...params] = part.split(";");
        // The parameter NAME is case-insensitive per RFC 9110, so `Q=0` is the same
        // rejection as `q=0`; matching only lowercase would read it as top preference.
        const q = params.map((p) => p.trim().toLowerCase()).find((p) => p.startsWith("q="));
        const weight = q ? Number.parseFloat(q.slice(2)) : 1;
        // An UNPARSEABLE weight defaults to 1 (accept), never 0: proxies and older clients
        // do emit `q=` and `q=high`, and 0 is reserved for a language the reader explicitly
        // rejected. Treating a typo as a rejection silently deletes their language.
        return { tag: tag.trim(), weight: Number.isFinite(weight) ? weight : 1 };
    })
        .filter((r) => r.tag && r.tag !== "*" && r.weight > 0)
        .sort((a, b) => b.weight - a.weight);
    for (const { tag } of ranked) {
        const hit = resolveLocale(tag);
        if (hit)
            return hit;
    }
    return processLocale();
}
/** The right-to-left languages we ship — the same pair the panel mirrors its layout for.
 *  Keep in step with `LOCALES` in src/i18n/index.ts when a language is added. */
const RTL_LOCALES = new Set(["ar", "fa"]);
/**
 * The language the page will ACTUALLY render in, which is not always the one negotiated.
 *
 * `lang`/`dir` describe the bytes on the page, not the reader's wishes. Until a catalog
 * for the negotiated locale is installed, those bytes are English — and stamping
 * `lang="fa" dir="rtl"` onto English prose is worse than saying nothing: it mirrors the
 * entire layout and tells a screen reader to pronounce English as Persian. So probe one
 * long sentence from the page: if the catalog answers with something other than the
 * English source, the page is genuinely translated and gets its real language; if it
 * hands the fallback straight back, the page is English and says so. The day a catalog
 * lands this starts reporting the real locale with no second change.
 */
function renderedLocale(negotiated, probeKey, probeFallback) {
    if (negotiated === "en")
        return "en";
    return trFor(negotiated, probeKey, probeFallback) === probeFallback ? "en" : negotiated;
}
/** `<html>` attributes: the real language, plus `dir="rtl"` for Arabic and Persian so the
 *  browser mirrors the page instead of laying RTL text out left-to-right. */
function htmlAttrs(locale) {
    return `lang="${locale}"${RTL_LOCALES.has(locale) ? ` dir="rtl"` : ""}`;
}
/**
 * Resolve a translation, but REJECT one that dropped a `{slot}`.
 *
 * A placeholder is not decoration — it carries the ComfyUI URL, the `~/.claude.json`
 * path, the masked key. A catalog entry written without one renders a page missing the
 * very fact it exists to state, with a 200 and nothing in the log. Falling back to the
 * English source is the honest failure: a sentence the reader may not speak still beats
 * a sentence with the answer cut out of it.
 */
function trSlots(locale, key, fallback, slots) {
    const out = trFor(locale, key, fallback);
    return slots.every((s) => out.includes(`{${s}}`)) ? out : fallback;
}
/**
 * Translate one fragment of page prose into text that is SAFE to interpolate as markup.
 *
 * A catalog is data, not a template: whatever it returns is HTML-escaped before it
 * reaches the document, so a stray `<` in a translation cannot open a tag. Inline markup
 * therefore stays in the source here — the translatable text carries `{slot}`
 * placeholders and `markup` maps each one to a literal element, substituted AFTER
 * escaping so the tags stay byte-identical whatever the translation says (and so a
 * translator is free to move them, which word order in other languages requires).
 * Anything a caller interpolates INTO a `markup` value must be escaped by that caller.
 */
function trHtml(locale, key, fallback, markup = {}) {
    let out = escapeHtml(trSlots(locale, key, fallback, Object.keys(markup)));
    for (const [slot, html] of Object.entries(markup))
        out = out.split(`{${slot}}`).join(html);
    return out;
}
/** A page builder's locale-bound translator, so neither document repeats the binding. */
function pageT(locale) {
    return (key, fallback, markup) => trHtml(locale, key, fallback, markup);
}
/**
 * A `<code>` element holding a left-to-right technical string (a URL, a path).
 *
 * On an RTL page the bidi algorithm reorders these against the surrounding text — two
 * URLs in one sentence swap visually and the reader cannot tell which is which — so an
 * RTL document pins them with `dir="ltr"`. LTR pages emit the bare tag they always did.
 */
function ltrCode(locale, inner) {
    return RTL_LOCALES.has(locale) ? `<code dir="ltr">${inner}</code>` : `<code>${inner}</code>`;
}
/**
 * Serialize a value for embedding in an inline `<script>`.
 *
 * `JSON.stringify` does not escape `<`, so a value containing `</script>` would close the
 * element early and the remainder would render as page text. The `<` escape is the
 * same character to a JS parser and inert to the HTML one. Now that catalog strings flow
 * into these blocks, that is the difference between a bad translation and a broken page.
 */
function scriptJson(value) {
    return JSON.stringify(value).replace(/</g, "\\u003C");
}
function tokenOk(req, expected) {
    if (!expected)
        return true; // no token configured → open (dev)
    try {
        const url = new URL(req.url ?? "/", "http://127.0.0.1");
        const q = url.searchParams.get("token");
        const h = (req.headers["authorization"] || "").replace(/^Bearer\s+/i, "");
        return q === expected || h === expected;
    }
    catch {
        return false;
    }
}
function readJsonBody(req) {
    return new Promise((resolve, reject) => {
        let data = "";
        let settled = false;
        let oversized = false;
        const settle = (fn) => {
            if (settled)
                return;
            settled = true;
            fn();
        };
        req.on("data", (c) => {
            if (oversized)
                return; // already over limit: drain and discard the rest, don't destroy mid-stream
            data += c;
            if (data.length > 1_000_000) {
                oversized = true;
                data = "";
                settle(() => reject(new Error("body too large")));
            }
        });
        req.on("end", () => { settle(() => { try {
            resolve(data ? JSON.parse(data) : {});
        }
        catch (e) {
            reject(e);
        } }); });
        req.on("error", (e) => { settle(() => reject(e)); });
        req.on("close", () => { settle(() => reject(new Error("request closed before body was fully received"))); });
    });
}
const PREVIEW_MIME = {
    ".png": "image/png",
    ".jpg": "image/jpeg",
    ".jpeg": "image/jpeg",
    ".webp": "image/webp",
    ".gif": "image/gif",
};
function serveLoraPreview(req, res) {
    let id = "";
    try {
        const url = new URL(req.url ?? "/", "http://127.0.0.1");
        id = (url.searchParams.get("id") ?? "").trim();
    }
    catch {
        sendJson(res, 400, { ok: false, error: "bad request" });
        return;
    }
    if (!id) {
        sendJson(res, 400, { ok: false, error: "id required" });
        return;
    }
    const catalog = getLoraCatalog();
    const entry = catalog.get(id);
    if (!entry?.previewFile) {
        sendJson(res, 404, { ok: false, error: "no preview" });
        return;
    }
    const previewsRoot = resolve(loraPreviewsDir());
    const abs = resolve(join(previewsRoot, entry.previewFile));
    // Path containment: use the platform separator (sep) so the check also holds
    // on Windows, where resolve() yields backslash-separated paths.
    if (!abs.startsWith(previewsRoot + sep) && abs !== previewsRoot) {
        sendJson(res, 403, { ok: false, error: "invalid preview path" });
        return;
    }
    if (!existsSync(abs)) {
        sendJson(res, 404, { ok: false, error: "preview file missing" });
        return;
    }
    const mime = PREVIEW_MIME[extname(abs).toLowerCase()] ?? "application/octet-stream";
    res.writeHead(200, {
        "Content-Type": mime,
        "Cache-Control": "private, max-age=3600",
    });
    createReadStream(abs).pipe(res);
}
/** English source for the landing page's subtitle — also its "is this page translated?"
 *  probe, so it is named once rather than repeated at two call sites that could drift. */
const LANDING_SUBTITLE_EN = "Control plane for the panel orchestrator — MCP servers, OAuth, and service settings. The ComfyUI sidebar panel stays focused on chat, providers, and the live canvas.";
function consoleLandingHtml(opts) {
    const { bridgePort, consolePort, comfyuiUrl } = opts;
    // The subtitle is the probe: the longest sentence on the page, so a translation of it
    // is never accidentally identical to the English.
    const locale = renderedLocale(opts.locale, "console.landing.subtitle", LANDING_SUBTITLE_EN);
    const t = pageT(locale);
    // The status line is written with innerHTML (it carries its own <span>), so these
    // strings are HTML-escaped like the rest of the page BEFORE being serialized into the
    // script — `t()` does that — and the object goes through scriptJson so no translation
    // can close the <script> element either.
    const T = scriptJson({
        running: t("console.landing.status.running", "Orchestrator running"),
        none: t("console.landing.status.none", "no backends"),
        ready: t("console.landing.status.ready", "ready"),
        signIn: t("console.landing.status.sign_in", "sign in"),
        installCli: t("console.landing.status.install_cli", "install CLI"),
        failed: t("console.landing.status.failed", "Could not load status"),
    });
    return `<!DOCTYPE html>
<html ${htmlAttrs(locale)}>
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>${t("console.landing.title", "ComfyUI MCP Console")}</title>
  <style>
    :root { color-scheme: dark; font-family: system-ui, -apple-system, Segoe UI, sans-serif; }
    body { margin: 0; background: #0f1115; color: #e8eaed; line-height: 1.5; }
    main { max-width: 52rem; margin: 0 auto; padding: 2rem 1.25rem 3rem; }
    h1 { font-size: 1.35rem; font-weight: 600; margin: 0 0 0.25rem; }
    .sub { color: #9aa0a6; font-size: 0.9rem; margin-bottom: 1.5rem; }
    section { background: #181b22; border: 1px solid #2a2f3a; border-radius: 10px; padding: 1rem 1.1rem; margin-bottom: 1rem; }
    h2 { font-size: 0.95rem; margin: 0 0 0.6rem; color: #c4c7ce; }
    /* padding-INLINE-start, not padding-left: identical in LTR, but on an RTL page the
       physical property reserves the gutter on the wrong side and the bullets overhang. */
    ul { margin: 0.4rem 0 0; padding-inline-start: 1.2rem; }
    li { margin: 0.25rem 0; }
    code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.82rem; }
    pre { background: #0b0d11; border: 1px solid #2a2f3a; border-radius: 8px; padding: 0.75rem; overflow-x: auto; }
    .ok { color: #81c995; }
    .warn { color: #fdd663; }
    a { color: #8ab4f8; }
    #status { font-size: 0.85rem; }
  </style>
</head>
<body>
  <main>
    <h1>${t("console.landing.title", "ComfyUI MCP Console")}</h1>
    <p class="sub">${t("console.landing.subtitle", LANDING_SUBTITLE_EN)}</p>

    <section>
      <h2>${t("console.landing.connection.heading", "Connection")}</h2>
      <p>${t("console.landing.connection.endpoints", "Bridge {bridge} · ComfyUI {comfyui}", {
        bridge: ltrCode(locale, `ws://127.0.0.1:${bridgePort}`),
        comfyui: ltrCode(locale, escapeHtml(comfyuiUrl)),
    })}</p>
      <p id="status">${t("console.landing.status.loading", "Loading provider readiness…")}</p>
    </section>

    <section>
      <h2>${t("console.landing.here.heading", "Coming here (panel stays in ComfyUI)")}</h2>
      <ul>
        <li>${t("console.landing.here.lifecycle", "Start / stop / restart orchestrator")}</li>
        <li>${t("console.landing.here.mcp", "MCP server mappings & inherited {file} tools", {
        file: ltrCode(locale, "~/.claude.json"),
    })}</li>
        <li>${t("console.landing.here.oauth", "OAuth & API provider sign-in")}</li>
        <li>${t("console.landing.here.prompts", "Editable system prompts — {link}", {
        link: `<a href="/prompts" id="prompts-link">${t("console.landing.here.prompts_link", "open the editor")}</a>`,
    })}</li>
        <li>${t("console.landing.here.library", "LoRA library, image collections, Photomap-style tooling")}</li>
        <li>${t("console.landing.here.a2ui", "A2UI-rich tool surfaces")}</li>
      </ul>
    </section>

    <section>
      <h2>${t("console.landing.panel.heading", "Stays in the ComfyUI panel")}</h2>
      <ul>
        <li>${t("console.landing.panel.pickers", "Provider / model / effort pickers & context window meter")}</li>
        <li>${t("console.landing.panel.storyboards", "Video storyboards & live graph edits")}</li>
        <li>${t("console.landing.panel.connect", "Connect / Disconnect to this bridge")}</li>
      </ul>
    </section>

    <section>
      <h2>${t("console.landing.api.heading", "API")}</h2>
      <pre>GET  /api/status
GET  /api/prompts
POST /api/prompts</pre>
    </section>
  </main>
  <script>
    const T = ${T};
    // This page is open; the editor behind it is token-gated. So the token is NEVER
    // rendered into this HTML — an unauthenticated visitor reaching /console would read it
    // straight out of the source. It arrives in the query string instead, put there by the
    // "Advanced ↗" buttons on /credentials and /prompts, which are themselves gated and
    // already hold it. Carrying it onto the link is what makes the editor one click away
    // rather than a 401 the reader has to fix by pasting a secret they cannot see from here.
    // A hand-typed /console has no token, gets the ungated link, and lands on the 401 that
    // tells it so — which is correct: this page has no secret to give it.
    const carried = new URLSearchParams(location.search).get('token');
    if (carried) document.getElementById('prompts-link').search = '?token=' + encodeURIComponent(carried);
    fetch('/api/status').then(r => r.json()).then(d => {
      const el = document.getElementById('status');
      const rows = (d.backends || []).map(b =>
        b.backend + ': ' + (b.ready ? T.ready : (b.cli ? T.signIn : T.installCli))
      ).join(' · ');
      el.innerHTML = '<span class="ok">' + T.running + '</span> — ' + (rows || T.none);
    }).catch(() => {
      document.getElementById('status').innerHTML = '<span class="warn">' + T.failed + '</span>';
    });
  </script>
</body>
</html>`;
}
/** English source for the API Keys footer — also that page's "is this translated?" probe. */
const CREDENTIALS_PRIVACY_EN = "Stored locally, per instance. Values never leave this machine.";
/** English source for the token-rejection fragment; it is its own translation probe. */
const UNAUTHORIZED_EN = "Unauthorized — reconnect the panel.";
function credentialsHtml(slots, consoleUrl, token, negotiatedLocale) {
    // Probe on the footer sentence — the page's one piece of real prose; the rest is single
    // words a catalog might legitimately leave as-is. See renderedLocale.
    const locale = renderedLocale(negotiatedLocale, "console.credentials.privacy", CREDENTIALS_PRIVACY_EN);
    const t = pageT(locale);
    const rows = slots
        .map((s) => `      <div class="row" data-slot="${escapeHtml(s.id)}">
        <div class="meta"><span class="label">${escapeHtml(s.label)}</span>${s.help ? `<span class="help">${escapeHtml(s.help)}</span>` : ""}</div>
        <div class="state"><span class="badge" data-badge>—</span></div>
        <div class="entry"><input type="password" placeholder="${t("console.credentials.paste_key", "Paste key…")}" data-input autocomplete="off" spellcheck="false" /><button data-save>${t("console.credentials.save", "Save")}</button></div>
      </div>`)
        .join("\n");
    const cfg = scriptJson({ consoleUrl, token });
    // Unlike the landing page's status line, every string below is assigned to
    // `textContent`, never innerHTML — so these are NOT HTML-escaped (`&` would show up as
    // "&amp;" on screen); scriptJson alone keeps them from escaping the <script>.
    const T = scriptJson({
        loadFailed: trFor(locale, "console.credentials.load_failed", "Could not load status — reconnect the panel."),
        // `{masked}` is the masked key itself — a translation that drops it turns the badge
        // into a bare "set" and loses the confirmation the row exists to give.
        set: trSlots(locale, "console.credentials.badge_set", "set · {masked}", ["masked"]),
        notSet: trFor(locale, "console.credentials.badge_unset", "not set"),
        saving: trFor(locale, "console.credentials.saving", "Saving…"),
        saved: trFor(locale, "console.credentials.saved", "Saved ✓"),
        save: trFor(locale, "console.credentials.save", "Save"),
        saveFailed: trFor(locale, "console.credentials.save_failed", "save failed"),
    });
    return `<!DOCTYPE html>
<html ${htmlAttrs(locale)}>
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>${t("console.credentials.title", "API Keys")}</title>
  <style>
    :root { color-scheme: dark; font-family: system-ui, -apple-system, Segoe UI, sans-serif; }
    body { margin: 0; background: #0f1115; color: #e8eaed; }
    main { padding: 0.9rem 1rem 1.2rem; }
    header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.75rem; }
    h1 { font-size: 1.05rem; font-weight: 600; margin: 0; }
    .close { background: none; border: none; color: #9aa0a6; font-size: 1.1rem; cursor: pointer; }
    .row { display: grid; grid-template-columns: 1fr auto; gap: 0.35rem 0.6rem; padding: 0.6rem 0; border-bottom: 1px solid #23272f; }
    .meta { display: flex; flex-direction: column; }
    .label { font-size: 0.9rem; font-weight: 500; }
    .help { font-size: 0.72rem; color: #9aa0a6; }
    .state { grid-column: 2; align-self: center; }
    .badge { font-size: 0.72rem; color: #9aa0a6; }
    .badge.set { color: #81c995; }
    .entry { grid-column: 1 / -1; display: flex; gap: 0.4rem; }
    input { flex: 1; background: #0b0d11; border: 1px solid #2a2f3a; border-radius: 7px; color: #e8eaed; padding: 0.4rem 0.5rem; font-size: 0.82rem; }
    button { background: #2a3140; border: 1px solid #3a4150; color: #e8eaed; border-radius: 7px; padding: 0.4rem 0.7rem; font-size: 0.82rem; cursor: pointer; }
    button:hover { background: #333c4d; }
    button[data-save].ok { color: #81c995; border-color: #2f6b41; }
    footer { margin-top: 0.9rem; display: flex; justify-content: space-between; align-items: center; }
    .advanced { background: none; border: 1px solid #2a2f3a; color: #8ab4f8; }
    .err { color: #f28b82; font-size: 0.75rem; }
  </style>
</head>
<body>
  <main>
    <header><h1>${t("console.credentials.title", "API Keys")}</h1><button class="close" data-close title="${t("console.credentials.close", "Close")}">✕</button></header>
    <div id="rows">
${rows}
    </div>
    <p class="err" id="err"></p>
    <footer>
      <span class="help">${t("console.credentials.privacy", CREDENTIALS_PRIVACY_EN)}</span>
      <button class="advanced" data-advanced>${t("console.credentials.advanced", "Advanced ↗")}</button>
    </footer>
  </main>
  <script>
    const CFG = ${cfg};
    const T = ${T};
    const q = (t) => "?token=" + encodeURIComponent(t);
    function postHeight() {
      try { parent.postMessage({ type: "resize", height: document.body.scrollHeight }, "*"); } catch {}
    }
    async function load() {
      try {
        const r = await fetch("/api/secrets" + q(CFG.token));
        const d = await r.json();
        for (const s of (d.slots || [])) {
          const row = document.querySelector('.row[data-slot="' + s.id + '"]');
          if (!row) continue;
          const badge = row.querySelector("[data-badge]");
          badge.textContent = s.set ? T.set.replace("{masked}", () => s.masked) : T.notSet;
          badge.classList.toggle("set", !!s.set);
        }
      } catch (e) { document.getElementById("err").textContent = T.loadFailed; }
      postHeight();
    }
    document.querySelectorAll(".row").forEach((row) => {
      const btn = row.querySelector("[data-save]");
      const input = row.querySelector("[data-input]");
      btn.addEventListener("click", async () => {
        const value = input.value.trim();
        if (!value) return;
        btn.disabled = true; btn.textContent = T.saving;
        try {
          const r = await fetch("/api/secrets" + q(CFG.token), {
            method: "POST", headers: { "content-type": "application/json" },
            body: JSON.stringify({ slot: row.dataset.slot, value }),
          });
          const d = await r.json();
          if (!r.ok || !d.ok) throw new Error(d.error || T.saveFailed);
          input.value = "";
          const badge = row.querySelector("[data-badge]");
          badge.textContent = T.set.replace("{masked}", () => d.masked); badge.classList.add("set");
          btn.textContent = T.saved; btn.classList.add("ok");
          setTimeout(() => { btn.textContent = T.save; btn.classList.remove("ok"); btn.disabled = false; }, 1500);
        } catch (e) {
          document.getElementById("err").textContent = String(e.message || e);
          btn.textContent = T.save; btn.disabled = false;
        }
      });
    });
    document.querySelector("[data-close]").addEventListener("click", () => { try { parent.postMessage({ type: "close" }, "*"); } catch {} });
    document.querySelector("[data-advanced]").addEventListener("click", () => { window.open(CFG.consoleUrl + "/console" + (CFG.token ? q(CFG.token) : ""), "_blank", "noopener"); });
    window.addEventListener("load", load);
    new ResizeObserver(postHeight).observe(document.body);
  </script>
</body>
</html>`;
}
/** English source for the prompts editor's intro — also that page's translation probe.
 *  It is the one full sentence on the page; every other string is a button or a badge. */
const PROMPTS_INTRO_EN = "Every system prompt the orchestrator controls, as the agent actually receives it. Your text is stored in {file} — the built-in stays untouched, so Reset always brings it back.";
/**
 * The system-prompt editor.
 *
 * Rows are built in the BROWSER from /api/prompts rather than baked in here, for two
 * reasons: the registry is populated at orchestrator startup (a page rendered before a
 * backend registered its prompt would be permanently stale), and a prompt is thousands of
 * characters — serializing every default into the document AND into the editor's state
 * would send each one twice. The client builds nodes with textContent, never innerHTML,
 * so prompt text that contains markup stays text.
 */
function promptsHtml(consoleUrl, token, negotiatedLocale) {
    const locale = renderedLocale(negotiatedLocale, "console.prompts.intro", PROMPTS_INTRO_EN);
    const t = pageT(locale);
    const cfg = scriptJson({ consoleUrl, token });
    // Every one of these reaches the page as `textContent` or as a DOM property, never as
    // innerHTML, so they are NOT HTML-escaped (`&` would otherwise show up as "&amp;" on
    // screen) — scriptJson alone keeps a translation from escaping the <script> element.
    const T = scriptJson({
        loadFailed: trFor(locale, "console.prompts.load_failed", "Could not load the prompts — reconnect the panel."),
        empty: trFor(locale, "console.prompts.empty", "No editable prompts are registered yet. Start the orchestrator, then reload this page."),
        custom: trFor(locale, "console.prompts.badge_custom", "customised"),
        builtin: trFor(locale, "console.prompts.badge_default", "built-in default"),
        placeholder: trFor(locale, "console.prompts.placeholder", "Using the built-in default — type here to override it."),
        save: trFor(locale, "console.prompts.save", "Save"),
        saving: trFor(locale, "console.prompts.saving", "Saving…"),
        saved: trFor(locale, "console.prompts.saved", "Saved ✓"),
        saveFailed: trFor(locale, "console.prompts.save_failed", "save failed"),
        reset: trFor(locale, "console.prompts.reset", "Reset to built-in"),
        resetting: trFor(locale, "console.prompts.resetting", "Resetting…"),
        resetFailed: trFor(locale, "console.prompts.reset_failed", "reset failed"),
        viewDefault: trFor(locale, "console.prompts.view_default", "View the built-in default"),
        copyDefault: trFor(locale, "console.prompts.copy_default", "Copy it into the editor"),
    });
    return `<!DOCTYPE html>
<html ${htmlAttrs(locale)}>
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>${t("console.prompts.title", "System Prompts")}</title>
  <style>
    :root { color-scheme: dark; font-family: system-ui, -apple-system, Segoe UI, sans-serif; }
    body { margin: 0; background: #0f1115; color: #e8eaed; line-height: 1.5; }
    main { max-width: 60rem; margin: 0 auto; padding: 0.9rem 1rem 1.2rem; }
    header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.35rem; }
    h1 { font-size: 1.05rem; font-weight: 600; margin: 0; }
    .close { background: none; border: none; color: #9aa0a6; font-size: 1.1rem; cursor: pointer; }
    .intro { font-size: 0.78rem; color: #9aa0a6; margin: 0 0 0.9rem; }
    .row { padding: 0.75rem 0; border-bottom: 1px solid #23272f; }
    .head { display: flex; align-items: baseline; justify-content: space-between; gap: 0.6rem; }
    .label { font-size: 0.9rem; font-weight: 500; }
    .help { font-size: 0.72rem; color: #9aa0a6; }
    .badge { font-size: 0.72rem; color: #9aa0a6; white-space: nowrap; }
    .badge.custom { color: #fdd663; }
    textarea { display: block; width: 100%; box-sizing: border-box; margin: 0.45rem 0 0.4rem;
      min-height: 6.5rem; resize: vertical; background: #0b0d11; border: 1px solid #2a2f3a;
      border-radius: 7px; color: #e8eaed; padding: 0.5rem 0.6rem;
      font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.78rem; line-height: 1.45; }
    /* The editor is code-shaped text in every language, so it stays left-to-right even when
       the page around it is mirrored — an RTL textarea reorders a prompt's punctuation. */
    textarea { direction: ltr; text-align: start; }
    .actions { display: flex; gap: 0.4rem; align-items: center; }
    button { background: #2a3140; border: 1px solid #3a4150; color: #e8eaed; border-radius: 7px;
      padding: 0.35rem 0.7rem; font-size: 0.8rem; cursor: pointer; }
    button:hover:not(:disabled) { background: #333c4d; }
    button:disabled { opacity: 0.55; cursor: default; }
    button.ok { color: #81c995; border-color: #2f6b41; }
    button.ghost { background: none; border-color: #2a2f3a; color: #9aa0a6; }
    details { margin-top: 0.35rem; }
    summary { font-size: 0.72rem; color: #8ab4f8; cursor: pointer; }
    details button { margin-top: 0.4rem; }
    details pre { margin: 0.35rem 0 0; background: #0b0d11; border: 1px solid #2a2f3a; border-radius: 7px;
      padding: 0.5rem 0.6rem; overflow-x: auto; white-space: pre-wrap; word-break: break-word;
      direction: ltr; text-align: start;
      font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.74rem; color: #c4c7ce; }
    .err { color: #f28b82; font-size: 0.75rem; min-height: 1rem; }
    footer { margin-top: 0.9rem; display: flex; justify-content: flex-end; }
    .advanced { background: none; border: 1px solid #2a2f3a; color: #8ab4f8; }
  </style>
</head>
<body>
  <main>
    <header><h1>${t("console.prompts.title", "System Prompts")}</h1><button class="close" data-close title="${t("console.prompts.close", "Close")}">✕</button></header>
    <p class="intro">${t("console.prompts.intro", PROMPTS_INTRO_EN, {
        file: ltrCode(locale, escapeHtml(panelPromptsPath())),
    })}</p>
    <div id="rows"></div>
    <p class="err" id="err"></p>
    <footer><button class="advanced" data-advanced>${t("console.prompts.advanced", "Advanced ↗")}</button></footer>
  </main>
  <script>
    const CFG = ${cfg};
    const T = ${T};
    const q = (t) => "?token=" + encodeURIComponent(t);
    const err = document.getElementById("err");
    function postHeight() {
      try { parent.postMessage({ type: "resize", height: document.body.scrollHeight }, "*"); } catch {}
    }
    function el(tag, cls, text) {
      const n = document.createElement(tag);
      if (cls) n.className = cls;
      if (text !== undefined) n.textContent = text;
      return n;
    }
    /** Paint a row's badge and its Reset button from the server's view of that prompt. */
    function mark(row, item) {
      const badge = row.querySelector("[data-badge]");
      badge.textContent = item.overridden ? T.custom : T.builtin;
      badge.classList.toggle("custom", !!item.overridden);
      row.querySelector("[data-reset]").disabled = !item.overridden;
    }
    function build(items) {
      const host = document.getElementById("rows");
      host.textContent = "";
      if (!items.length) { host.appendChild(el("p", "help", T.empty)); postHeight(); return; }
      for (const item of items) {
        const row = el("div", "row");
        const head = el("div", "head");
        const meta = el("div", "meta");
        meta.appendChild(el("div", "label", item.label || item.id));
        if (item.help) meta.appendChild(el("div", "help", item.help));
        head.appendChild(meta);
        const badge = el("span", "badge", "");
        badge.setAttribute("data-badge", "");
        head.appendChild(badge);
        row.appendChild(head);

        const ta = el("textarea");
        ta.setAttribute("spellcheck", "false");
        ta.placeholder = T.placeholder;
        // The DEFAULT is shown greyed via placeholder-only when unset; the editor itself
        // carries the override so saving an untouched row cannot silently promote a
        // built-in into a stored override.
        ta.value = item.override || "";
        row.appendChild(ta);

        const actions = el("div", "actions");
        const save = el("button", "", T.save);
        save.setAttribute("data-save", "");
        const reset = el("button", "ghost", T.reset);
        reset.setAttribute("data-reset", "");
        actions.appendChild(save);
        actions.appendChild(reset);
        row.appendChild(actions);

        const det = el("details");
        det.appendChild(el("summary", "", T.viewDefault));
        det.appendChild(el("pre", "", item.default || ""));
        // Editing usually means "the built-in, but with one paragraph changed", and the
        // editor starts EMPTY on purpose (see above) — so the default is one click away
        // rather than a selection drag across a few thousand characters.
        const copy = el("button", "ghost", T.copyDefault);
        copy.addEventListener("click", () => { ta.value = item.default || ""; ta.focus(); postHeight(); });
        det.appendChild(copy);
        row.appendChild(det);

        save.addEventListener("click", () => send(row, { id: item.id, value: ta.value }, save, T.saving, T.saved, T.save, T.saveFailed));
        reset.addEventListener("click", () => {
          ta.value = "";
          send(row, { id: item.id, reset: true }, reset, T.resetting, T.saved, T.reset, T.resetFailed);
        });
        host.appendChild(row);
        mark(row, item);
      }
      postHeight();
    }
    async function send(row, body, btn, busyLabel, doneLabel, idleLabel, failLabel) {
      err.textContent = "";
      const wasDisabled = btn.disabled;
      btn.disabled = true; btn.textContent = busyLabel;
      try {
        const r = await fetch("/api/prompts" + q(CFG.token), {
          method: "POST", headers: { "content-type": "application/json" },
          body: JSON.stringify(body),
        });
        const d = await r.json();
        if (!r.ok || !d.ok) throw new Error(d.error || failLabel);
        mark(row, d.prompt);
        btn.textContent = doneLabel; btn.classList.add("ok");
        setTimeout(() => {
          btn.textContent = idleLabel; btn.classList.remove("ok");
          // Re-read the CURRENT disabled state rather than restoring the old one: Reset
          // disables itself the moment the override is gone, and restoring the captured
          // flag would hand it back enabled against a prompt that has no override now.
          btn.disabled = btn.hasAttribute("data-reset") ? !row.querySelector("[data-badge]").classList.contains("custom") : false;
        }, 1500);
      } catch (e) {
        err.textContent = String(e.message || e);
        btn.textContent = idleLabel; btn.disabled = wasDisabled;
      }
      postHeight();
    }
    async function load() {
      try {
        const r = await fetch("/api/prompts" + q(CFG.token));
        const d = await r.json();
        if (!r.ok || !d.ok) throw new Error(d.error || T.loadFailed);
        build(d.prompts || []);
      } catch (e) { err.textContent = T.loadFailed; }
      postHeight();
    }
    document.querySelector("[data-close]").addEventListener("click", () => { try { parent.postMessage({ type: "close" }, "*"); } catch {} });
    document.querySelector("[data-advanced]").addEventListener("click", () => { window.open(CFG.consoleUrl + "/console" + (CFG.token ? q(CFG.token) : ""), "_blank", "noopener"); });
    window.addEventListener("load", load);
    new ResizeObserver(postHeight).observe(document.body);
  </script>
</body>
</html>`;
}
function escapeHtml(s) {
    return s
        .replace(/&/g, "&amp;")
        .replace(/</g, "&lt;")
        .replace(/>/g, "&gt;")
        .replace(/"/g, "&quot;");
}
export function startPanelConsoleHttpServer(opts) {
    const host = opts.host ?? "127.0.0.1";
    // The panel calls /api/secrets from the ComfyUI page origin (e.g.
    // http://127.0.0.1:8188 → this console's port), which is CROSS-origin — without
    // CORS headers the browser hard-fails the fetch ("Couldn't load credentials")
    // even though the request itself is loopback + token-gated. Allow exactly the
    // ComfyUI origin (plus its localhost↔127.0.0.1 twin, since the page may be
    // open under either name). Never "*": the token would otherwise be callable
    // from any web page the user has open.
    const allowedOrigins = new Set();
    try {
        const u = new URL(opts.comfyuiUrl);
        const twins = new Set([u.hostname, u.hostname === "localhost" ? "127.0.0.1" : "localhost"]);
        for (const h of twins)
            allowedOrigins.add(`${u.protocol}//${h}${u.port ? `:${u.port}` : ""}`);
    }
    catch {
        allowedOrigins.add("http://127.0.0.1:8188");
        allowedOrigins.add("http://localhost:8188");
    }
    const server = createServer(async (req, res) => {
        const origin = typeof req.headers.origin === "string" ? req.headers.origin : "";
        if (origin && allowedOrigins.has(origin)) {
            res.setHeader("Access-Control-Allow-Origin", origin);
            res.setHeader("Vary", "Origin");
        }
        if (req.method === "OPTIONS") {
            // Preflight for the authorized JSON POST (Authorization + content-type).
            res.writeHead(204, {
                "Access-Control-Allow-Methods": "GET, POST",
                "Access-Control-Allow-Headers": "authorization, content-type",
                "Access-Control-Max-Age": "600",
            });
            res.end();
            return;
        }
        const path = (req.url ?? "/").split("?")[0];
        if (path === "/api/secrets") {
            if (!tokenOk(req, opts.token)) {
                sendJson(res, 401, { ok: false, error: "unauthorized" });
                return;
            }
            if (req.method === "GET") {
                sendJson(res, 200, { ok: true, slots: listPanelSecretsMasked() });
                return;
            }
            if (req.method === "POST") {
                let body;
                try {
                    body = await readJsonBody(req);
                }
                catch {
                    sendJson(res, 400, { ok: false, error: "bad json" });
                    return;
                }
                const slot = String(body?.slot ?? "");
                const value = String(body?.value ?? "");
                const clear = body?.clear === true;
                if (!slot || (!value && !clear)) {
                    sendJson(res, 400, { ok: false, error: "slot and value required (or clear: true to revoke)" });
                    return;
                }
                try {
                    if (clear) {
                        // Revoke path (issue #203): remove every alias key of the slot, then
                        // VERIFY the slot no longer resolves. Reporting `cleared:true` from
                        // "a line was removed" alone would call the credential revoked while
                        // another form of the assignment still supplies it (codex gate,
                        // round 3, finding 2).
                        // Captured BEFORE the clear: keys a real environment variable
                        // supplies come back on the next start, so the revoke is only
                        // effective for this process and must not be reported as complete.
                        const shellKeys = slotShellProvidedKeys(slot);
                        const clearOutcome = clearPanelSecret(slot);
                        const removed = clearOutcome.changed;
                        // The removal ALREADY HAPPENED. Anything it cost is disclosed here,
                        // with `ok:false` so no UI can render it as a clean revoke — it is
                        // never re-thrown, because the previous version's throw became a
                        // generic `ok:false` in the catch below and the caller retried a
                        // removal that had already run, against a store that had already
                        // lost keys (codex gate).
                        // Everything the removal owes the caller — lost keys, an incomplete
                        // loss account, dropped comments, a write that is not crash-durable,
                        // a legacy purge that failed. Built from the one shared list, not
                        // from this endpoint's own idea of which fields matter, and computed
                        // BEFORE any branch returns: the damage branch used to return in
                        // front of this and in front of the end-state check, so a revoke that
                        // lost keys reported neither its other obligations nor whether the
                        // credential still resolves (codex gate).
                        const clearWarnings = removeDisclosures(clearOutcome, envStorePath());
                        const state = slotRevokeState(slot);
                        if (clearOutcome.lostKeys.length) {
                            sendJson(res, 500, {
                                ok: false,
                                slot,
                                cleared: removed,
                                lost_keys: clearOutcome.lostKeys,
                                // The verified end state, which this branch skipped entirely.
                                still_resolves: state === "unknown" ? null : state === "still-resolves",
                                ...(clearWarnings.length ? { warnings: clearWarnings } : {}),
                                error: `"${slot}" WAS removed from the credential store, but the store no longer carries ${clearOutcome.lostKeys.join(", ")} — ` +
                                    `${clearOutcome.lostKeys.length > 1 ? "those credentials were" : "that credential was"} lost during the rewrite. ` +
                                    `Do NOT repeat the revoke: it already happened, and another write would rewrite an already-damaged store. Inspect the file and restore the missing entries first.`,
                            });
                            return;
                        }
                        if (state === "still-resolves") {
                            sendJson(res, 500, {
                                ok: false,
                                slot,
                                cleared: false,
                                still_resolves: true,
                                error: `Removed an entry for "${slot}", but the credential still resolves — something else is still providing it. It is NOT revoked.`,
                            });
                            return;
                        }
                        if (state === "unknown") {
                            // The store could not be read, so we cannot say the credential is
                            // gone: a tool process that CAN read it may still find the old
                            // value there. Report the uncertainty instead of either verdict.
                            //
                            // `ok:false`, deliberately. The warning below said the end state
                            // was UNKNOWN while the success flag next to it said the revoke
                            // worked — and a consumer reads the flag (codex gate). This is a
                            // DISCLOSURE, not a refusal: `cleared` still reports that the
                            // removal happened, so nobody is invited to retry it.
                            sendJson(res, 500, {
                                ok: false,
                                slot,
                                cleared: removed,
                                still_resolves: null,
                                warning: `"${slot}" no longer applies to this process, but the credential store could not be re-read, so whether it is gone from disk is UNKNOWN. ` +
                                    `A tool session that can read the store may still find the old value. Re-check before relying on the revoke.`,
                                ...(clearWarnings.length ? { warnings: clearWarnings } : {}),
                            });
                            return;
                        }
                        // `ok` asks the same question everywhere: is this a CLEAN revoke?
                        // The end state being verified "gone" is not enough — a revoke whose
                        // loss account was never completed, that the boot migration will
                        // undo, or that only partly ran was green-lit here while a warning
                        // beside it said exactly that, and a consumer reads the flag
                        // (codex gate). Comment loss and a durability gap stay warnings:
                        // they are what the revoke COST, not doubt about the revoke.
                        const cleanRevoke = revokeIsClean(clearOutcome);
                        sendJson(res, cleanRevoke ? 200 : 500, {
                            ok: cleanRevoke,
                            slot,
                            // `cleared` keeps its established meaning — an entry was removed
                            // from the credential STORE — and `still_resolves` reports the
                            // verified end state. Two fields, each true to its own question:
                            // conflating them is how `cleared:false` ended up sitting next to
                            // a "cleared for this session" note (codex gate, round 4,
                            // finding 5).
                            cleared: removed,
                            // Scoped claim: it no longer resolves HERE. A tool session that
                            // was already running keeps the value it was spawned with until it
                            // is rebuilt — the clear schedules that rebuild, it does not
                            // perform it, so the two are reported separately.
                            still_resolves: false,
                            live_sessions_rebuild_at_idle: true,
                            ...(shellKeys.length
                                ? {
                                    warning: `${shellKeys.join(", ")} ${shellKeys.length > 1 ? "are" : "is"} set as a real environment variable outside this app, not in the credential store. ` +
                                        `It no longer applies to the running process, but it returns on the next start — unset it where it is defined to revoke it permanently.`,
                                }
                                : {}),
                            ...(clearWarnings.length ? { warnings: clearWarnings } : {}),
                        });
                        return;
                    }
                    // Report what the read-back PROVED, not that the call returned. The
                    // same failure panel_request_secret refuses was answered ok:true here.
                    const outcome = setPanelSecret(slot, value);
                    if (!slotSaveConfirmed(outcome)) {
                        // DATA LOSS FIRST. This endpoint answered `200 {ok:true, masked}`
                        // from `slotSaveConfirmed` alone, so a save that destroyed the
                        // user's other tokens was reported to the panel as a clean success —
                        // the PR's original defect, one layer further out (codex gate). It
                        // cannot recur by omission now: a damaged receipt is `persisted:
                        // "damaged"`, which is not "yes", so `confirmed` is false and this
                        // branch is the only way out.
                        if (outcome.lostKeys.length) {
                            // Say which post-state we are ACTUALLY in. "Nothing was rolled
                            // back" was asserted unconditionally here, but this branch is also
                            // reached when a FAILED save rolled back and the rollback is what
                            // lost the keys — so it stated a post-state that had not happened
                            // (codex gate). `rolledBack` is the outcome's own answer.
                            const rollbackNote = outcome.receipts.some((r) => r.persisted === "damaged")
                                ? `Nothing was rolled back: a rollback cannot bring them back and would be one more rewrite of a damaged store.`
                                : outcome.rolledBack
                                    ? `The slot itself was rolled back, but that rollback is what the store lost these entries during — it cannot be undone by repeating it.`
                                    : `The slot was NOT left in its previous state either; see stranded_keys / unverified_restore_keys.`;
                            sendJson(res, 500, {
                                ok: false,
                                slot,
                                // Key NAMES only — never a value.
                                lost_keys: outcome.lostKeys,
                                saved_but_damaged: true,
                                rolled_back: outcome.rolledBack,
                                ...(outcome.strandedKeys.length ? { stranded_keys: outcome.strandedKeys } : {}),
                                ...(outcome.unverifiedKeys.length
                                    ? { unverified_restore_keys: outcome.unverifiedKeys }
                                    : {}),
                                ...(outcome.restoreWarnings?.length ? { warnings: outcome.restoreWarnings } : {}),
                                error: `"${slot}" was written to the credential store, but the store no longer carries ${outcome.lostKeys.join(", ")} — ` +
                                    `${outcome.lostKeys.length > 1 ? "those credentials were" : "that credential was"} lost during the write. This is NOT a successful save. ` +
                                    `${rollbackNote} ` +
                                    `Inspect the file and restore the missing ${outcome.lostKeys.length > 1 ? "entries" : "entry"} before relying on anything in it.`,
                            });
                            return;
                        }
                        const unconfirmed = unconfirmedSlotKeys(outcome.receipts);
                        const failed = unconfirmed.filter((u) => u.persisted === "no");
                        // Describe the state we LEFT, not the state we intended. A slot with
                        // alias keys is written serially, so a failure part-way can leave an
                        // earlier alias live; `strandedKeys` names exactly that case, and
                        // "nothing was half-applied" is only claimed when the restore is
                        // proven complete.
                        const stranded = outcome.strandedKeys;
                        const unverifiedRestore = outcome.unverifiedKeys;
                        const aftermath = stranded.length
                            ? `Restoring the slot did not take effect for ${stranded.join(", ")}, which still carr${stranded.length > 1 ? "y" : "ies"} the new value — the slot is in a MIXED state; set it again, or clear it, before relying on it.`
                            : unverifiedRestore.length
                                ? `Restoring the slot could NOT be verified for ${unverifiedRestore.join(", ")} (the store was unreadable), so whether anything was half-applied is unknown — re-check before relying on it.`
                                : `The change was rolled back, so nothing was half-applied.`;
                        // `unknown` is NOT a success. Answering 200 ok:true for a write
                        // whose read-back could not be performed asserts a definite
                        // configured state from a failed verification — the #826 shape
                        // reappearing inside the vocabulary introduced to prevent it
                        // (coordinator finding). `ok` is true only for a PROVEN save, which
                        // this branch is not, by construction.
                        const unverifiedSave = unconfirmed.filter((u) => u.persisted === "unknown");
                        sendJson(res, 500, {
                            ok: false,
                            slot,
                            // Key NAMES and verdicts only — never a value.
                            unconfirmed,
                            rolled_back: outcome.rolledBack,
                            ...(unverifiedSave.length ? { unverified_keys: unverifiedSave.map((u) => u.key) } : {}),
                            ...(stranded.length ? { stranded_keys: stranded } : {}),
                            ...(unverifiedRestore.length ? { unverified_restore_keys: unverifiedRestore } : {}),
                            // What the ROLLBACK's own writes cost — a rollback is a store
                            // write the user never asked for, and it was reported nowhere.
                            ...(outcome.restoreWarnings?.length ? { warnings: outcome.restoreWarnings } : {}),
                            ...(failed.length
                                ? {
                                    error: `"${slot}" was NOT saved: the credential store does not come back carrying ${failed.map((f) => f.key).join(", ")}. ${aftermath}`,
                                }
                                : stranded.length
                                    ? { error: `"${slot}" could not be saved cleanly. ${aftermath}` }
                                    : {
                                        // The REASON comes from the receipt. "unknown" no longer
                                        // means only "the file could not be re-read" — it also
                                        // covers a save whose key read back fine but whose loss
                                        // account a concurrent writer made impossible to complete,
                                        // and asserting the wrong one sends the user to check the
                                        // wrong thing (codex gate).
                                        error: `"${slot}" was written but the save is NOT confirmed (${unconfirmed.map((u) => u.key).join(", ")}), so treat it as UNKNOWN: ` +
                                            `${outcome.receipts.find((r) => r.uncertainty)?.uncertainty ?? "the store could not be re-read to confirm it"}. ` +
                                            `Do not treat it as configured. ${aftermath}`,
                                    }),
                        });
                        return;
                    }
                    const masked = listPanelSecretsMasked().find((s) => s.id === slot)?.masked ?? null;
                    // A CONFIRMED save can still owe the user a disclosure — comment lines
                    // the rewrite dropped, or a write that could not be made crash-durable.
                    // Built from the one shared list, so a new obligation on the receipt
                    // reaches this endpoint without anyone remembering to wire it up here.
                    const warnings = [
                        // The slot fan-out suspends per-alias emits and makes ONE at the end, so the
                        // respawn cost belongs to the OUTCOME, not to any single receipt — every
                        // receipt here carries an empty list by construction. Rendering only the
                        // receipts is why this endpoint warned about nothing while orphaning the same
                        // transfers the agent-facing path warns about (codex).
                        ...(atRiskNote(outcome.atRiskDownloads, undefined) ? [atRiskNote(outcome.atRiskDownloads, undefined)] : []),
                        ...outcome.receipts.flatMap((r) => receiptDisclosures(r)),
                        // A confirmed slot save performs no rollback, so this is normally
                        // empty — but it is included from the same place either way, so the
                        // rollback's own costs can never be the field nobody rendered.
                        ...(outcome.restoreWarnings ?? []),
                    ];
                    sendJson(res, 200, {
                        ok: true,
                        slot,
                        masked,
                        ...(warnings.length ? { warnings } : {}),
                    });
                }
                catch (err) {
                    const msg = err instanceof Error ? err.message : String(err);
                    sendJson(res, /unknown credential slot/i.test(msg) ? 400 : 500, { ok: false, error: msg });
                }
                return;
            }
            sendJson(res, 405, { ok: false, error: "method not allowed" });
            return;
        }
        if (path === "/api/prompts") {
            if (!tokenOk(req, opts.token)) {
                sendJson(res, 401, { ok: false, error: "unauthorized" });
                return;
            }
            if (req.method === "GET") {
                sendJson(res, 200, { ok: true, prompts: listPrompts() });
                return;
            }
            if (req.method === "POST") {
                let body;
                try {
                    body = await readJsonBody(req);
                }
                catch {
                    sendJson(res, 400, { ok: false, error: "bad json" });
                    return;
                }
                const id = String(body?.id ?? "");
                if (!id) {
                    sendJson(res, 400, { ok: false, error: "id required" });
                    return;
                }
                // Only prompts the orchestrator REGISTERED may be written. Without this an
                // arbitrary key lands in panel-prompts.json, where nothing ever reads it and
                // nothing ever offers to remove it — a typo'd id would look saved forever.
                if (!isKnownPrompt(id)) {
                    sendJson(res, 400, { ok: false, error: `unknown prompt "${id}"` });
                    return;
                }
                try {
                    // `reset` and an empty/whitespace `value` mean the same thing to the store —
                    // drop the override — but they are kept distinct on the wire because a UI that
                    // sends an accidentally-empty textarea should not be indistinguishable from one
                    // whose user pressed Reset.
                    if (body?.reset === true)
                        clearPromptOverride(id);
                    else
                        setPromptOverride(id, String(body?.value ?? ""));
                }
                catch (err) {
                    // setPromptOverride refuses rather than clobber an unreadable overrides file.
                    sendJson(res, 500, { ok: false, error: err instanceof Error ? err.message : String(err) });
                    return;
                }
                // Echo the stored state rather than the request: whitespace-only text clears the
                // override, so what the caller sent is not always what the row now is.
                const prompt = listPrompts().find((entry) => entry.id === id);
                sendJson(res, 200, { ok: true, prompt });
                return;
            }
            sendJson(res, 405, { ok: false, error: "method not allowed" });
            return;
        }
        if (req.method === "GET" && path === "/api/status") {
            const { backends, any_ready } = allBackendReadiness(KNOWN_BACKENDS);
            const bound = server.address();
            const boundPort = bound && typeof bound === "object" ? bound.port : opts.port;
            const liveUrl = `http://${host}:${boundPort}`;
            sendJson(res, 200, {
                ok: true,
                console_url: liveUrl,
                bridge_port: opts.bridgePort,
                bridge_url: `ws://${host}:${opts.bridgePort}`,
                comfyui_url: opts.comfyuiUrl,
                backends,
                any_ready,
            });
            return;
        }
        if (req.method === "GET" && path === "/api/lora-preview") {
            serveLoraPreview(req, res);
            return;
        }
        if (req.method === "GET" && path === "/credentials") {
            // The rejection is a page the same person reads, so it follows the same language as
            // the page they were trying to open — not English-only because the token was wrong.
            const locale = pageLocale(req);
            if (!tokenOk(req, opts.token)) {
                // A bare fragment has no <html> to carry the language, so the <p> carries it —
                // otherwise an Arabic or Persian rejection lays out left-to-right. The fragment
                // is a single sentence, so it is its own translated-or-not probe.
                const shown = renderedLocale(locale, "console.unauthorized", UNAUTHORIZED_EN);
                sendHtml(res, 401, `<p ${htmlAttrs(shown)}>${trHtml(shown, "console.unauthorized", UNAUTHORIZED_EN)}</p>`);
                return;
            }
            const bound = server.address();
            const boundPort = bound && typeof bound === "object" ? bound.port : opts.port;
            sendFramedHtml(res, 200, credentialsHtml(CREDENTIAL_SLOTS, `http://${host}:${boundPort}`, opts.token ?? "", locale));
            return;
        }
        if (req.method === "GET" && path === "/prompts") {
            // Same language as the page it guards — see /credentials.
            const locale = pageLocale(req);
            if (!tokenOk(req, opts.token)) {
                const shown = renderedLocale(locale, "console.unauthorized", UNAUTHORIZED_EN);
                sendHtml(res, 401, `<p ${htmlAttrs(shown)}>${trHtml(shown, "console.unauthorized", UNAUTHORIZED_EN)}</p>`);
                return;
            }
            const bound = server.address();
            const boundPort = bound && typeof bound === "object" ? bound.port : opts.port;
            sendFramedHtml(res, 200, promptsHtml(`http://${host}:${boundPort}`, opts.token ?? "", locale));
            return;
        }
        if (req.method === "GET" && (path === "/" || path === "/console")) {
            sendHtml(res, 200, consoleLandingHtml({
                bridgePort: opts.bridgePort,
                consolePort: opts.port,
                comfyuiUrl: opts.comfyuiUrl,
                locale: pageLocale(req),
            }));
            return;
        }
        sendJson(res, 404, { ok: false, error: "not_found" });
    });
    return new Promise((resolve, reject) => {
        server.once("error", reject);
        server.listen(opts.port, host, () => {
            server.removeListener("error", reject);
            const bound = server.address();
            const boundPort = bound && typeof bound === "object" ? bound.port : opts.port;
            const url = `http://${host}:${boundPort}`;
            logger.info(`[panel-console] MCP console listening on ${url} (loopback)`);
            resolve({
                port: boundPort,
                url,
                stop: () => new Promise((res, rej) => {
                    server.close((err) => (err ? rej(err) : res()));
                }),
            });
        });
    });
}
//# sourceMappingURL=panel-console-http.js.map