comfyui-mcp 0.52.47 → 0.52.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +1 -1
  2. package/dist/boot.js +5 -4
  3. package/dist/boot.js.map +1 -1
  4. package/dist/orchestrator/get-errors-audit.js +14 -7
  5. package/dist/orchestrator/get-errors-audit.js.map +1 -1
  6. package/dist/orchestrator/index.js +94 -298
  7. package/dist/orchestrator/index.js.map +1 -1
  8. package/dist/orchestrator/panel-mcp-http.js +2 -2
  9. package/dist/orchestrator/panel-mcp-http.js.map +1 -1
  10. package/dist/orchestrator/panel-tools.js +114 -7
  11. package/dist/orchestrator/panel-tools.js.map +1 -1
  12. package/dist/orchestrator/promoted-widget.js +49 -0
  13. package/dist/orchestrator/promoted-widget.js.map +1 -1
  14. package/dist/orchestrator/run-completion-journal.js +5 -0
  15. package/dist/orchestrator/run-completion-journal.js.map +1 -1
  16. package/dist/orchestrator/run-completion-watchdog.js +63 -14
  17. package/dist/orchestrator/run-completion-watchdog.js.map +1 -1
  18. package/dist/services/bridge-port-reclaim.js +340 -0
  19. package/dist/services/bridge-port-reclaim.js.map +1 -0
  20. package/dist/services/bridge-ports.js +78 -0
  21. package/dist/services/bridge-ports.js.map +1 -0
  22. package/dist/services/listener-ownership.js +30 -0
  23. package/dist/services/listener-ownership.js.map +1 -1
  24. package/dist/services/panel-launcher.js +20 -4
  25. package/dist/services/panel-launcher.js.map +1 -1
  26. package/dist/services/secure-bridge.js +8 -5
  27. package/dist/services/secure-bridge.js.map +1 -1
  28. package/dist/services/self-restart.js +24 -5
  29. package/dist/services/self-restart.js.map +1 -1
  30. package/dist/services/tunnel.js +11 -2
  31. package/dist/services/tunnel.js.map +1 -1
  32. package/docker/runpod/README.md +1 -1
  33. package/package.json +1 -1
@@ -3,7 +3,7 @@
3
3
  // Claude session stays free. Launch with `comfyui-mcp --panel-orchestrator`
4
4
  // (or COMFYUI_MCP_PANEL_ORCHESTRATOR=1).
5
5
  //
6
- // It owns the UI bridge (port 9180) directly — so it SEES panel messages instead
6
+ // It owns the UI bridge (DEFAULT_PANEL_BRIDGE_PORT, currently 9199) directly — so it SEES panel messages instead
7
7
  // of relying on an idle interactive session to notice a channel push — and spawns
8
8
  // one Claude Agent SDK streaming session per panel tab (src/orchestrator/
9
9
  // panel-agent.ts). Each agent runs on the user's Claude SUBSCRIPTION with no API
@@ -15,11 +15,13 @@ import { tmpdir, homedir, networkInterfaces } from "node:os";
15
15
  import { dirname, join } from "node:path";
16
16
  import { fileURLToPath } from "node:url";
17
17
  import { randomBytes } from "node:crypto";
18
- import readline from "node:readline";
19
18
  import { startUiBridge, isLoopbackBindHost, isMirrorSafeFrameType, isUnknownCommandReply, isPanelCmdUnsupportedError, SESSION_EPOCH, } from "../services/ui-bridge.js";
20
19
  import { canonicalOrigin } from "../utils/origin.js";
21
20
  import { publishFrontendVirtualTypes, } from "../services/frontend-virtual-types.js";
22
- import { setupSecureBridge, resolveComfyuiPathForTarget } from "../services/secure-bridge.js";
21
+ import { setupSecureBridge, resolveComfyuiPathForTarget, advertiseBridge } from "../services/secure-bridge.js";
22
+ import { localBridgeUrl, panelPortBlock, pinBoundBridgePort, resolveBridgePort, } from "../services/bridge-ports.js";
23
+ import { assessBridgeHolder, bindFailureAdvice, orchLockPath, pidExists, pidListeningOnPort, probeComfyUi, startupDeadlineHolderAdvice, tryReclaimBridgePort, } from "../services/bridge-port-reclaim.js";
24
+ import { unclassifiedOwnership } from "../services/listener-ownership.js";
23
25
  import { judgeHelloRetarget, canonComfyuiTargetUrl } from "../services/hello-retarget.js";
24
26
  import { startQuickTunnel } from "../services/tunnel.js";
25
27
  import { detectInstallMode } from "../services/self-update.js";
@@ -425,9 +427,6 @@ function formatQueueNote(rep) {
425
427
  * it, so the panel pack can reliably identify and replace a stale orchestrator
426
428
  * left over from a previous ComfyUI session (the "orphan on the port" trap).
427
429
  */
428
- function orchLockPath(port) {
429
- return join(tmpdir(), `comfyui-mcp-panel-orch-${port}.json`);
430
- }
431
430
  function readWindowsProcessStartedAtMs(pid) {
432
431
  // Get-CimInstance already returns CreationDate as a .NET DateTime (CIM converts
433
432
  // the raw WMI DMTF string for us), so use it directly — feeding it back through
@@ -467,16 +466,6 @@ function readProcessStartedAtMs(pid) {
467
466
  return readWindowsProcessStartedAtMs(pid);
468
467
  return null;
469
468
  }
470
- function pidExists(pid) {
471
- try {
472
- process.kill(pid, 0); // signal 0 = existence probe, doesn't actually signal
473
- return true;
474
- }
475
- catch (err) {
476
- // EPERM = exists but not ours to signal.
477
- return err.code === "EPERM";
478
- }
479
- }
480
469
  function parentIdentityMatches(pid, expectedStartedAtMs) {
481
470
  if (!pidExists(pid))
482
471
  return false;
@@ -490,244 +479,6 @@ function parentIdentityMatches(pid, expectedStartedAtMs) {
490
479
  return true;
491
480
  return Math.abs(actualStartedAtMs - expectedStartedAtMs) <= 2000;
492
481
  }
493
- /**
494
- * Kill a process AND ITS TREE. A bare signal to the holder's pid leaves its
495
- * children (cloudflared tunnel, spawned agent MCP subprocesses) alive — the
496
- * field report "it doesn't fully terminate" (2026-07-08): Ctrl+C/kill of the
497
- * node process orphaned cloudflared, and stale children kept state around.
498
- * Windows: taskkill /T /F (tree + force). POSIX: best-effort children sweep
499
- * (pkill -P) around the usual TERM → KILL escalation.
500
- */
501
- function killProcessTreeCrossPlatform(pid, mode) {
502
- if (process.platform === "win32") {
503
- // No graceful tree-signal exists on Windows; /T /F is the reliable path.
504
- execFileSync("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore" });
505
- return;
506
- }
507
- const sig = mode === "term" ? "SIGTERM" : "SIGKILL";
508
- try {
509
- execFileSync("pkill", [`-${sig.replace("SIG", "")}`, "-P", String(pid)], { stdio: "ignore" });
510
- }
511
- catch {
512
- // no children / pkill absent — the direct signal below still applies
513
- }
514
- process.kill(pid, sig);
515
- }
516
- /** Copy-paste "free this port" commands — LAST RESORT only (non-interactive
517
- * shells where we cannot prompt, or a kill that failed): the interactive path
518
- * resolves the owner from the port and kills it itself after consent. */
519
- function portKillHint(port) {
520
- const ps = `Get-NetTCPConnection -LocalPort ${port} -State Listen | % { taskkill /PID $_.OwningProcess /T /F }`;
521
- const sh = `lsof -ti tcp:${port} -s tcp:listen | xargs -r kill -9`;
522
- return process.platform === "win32"
523
- ? `To free the port manually:\n PowerShell: ${ps}\n bash/zsh: ${sh}`
524
- : `To free the port manually:\n bash/zsh: ${sh}\n PowerShell: ${ps}`;
525
- }
526
- /**
527
- * PROBE TIMEOUT. These four inspections run SYNCHRONOUSLY, so a command that
528
- * hangs blocks the whole event loop — no bridge traffic, no agent turns,
529
- * nothing — and they run on the startup/takeover path where being stuck is
530
- * indistinguishable from being broken. `lsof` is the known offender (it stalls
531
- * on unreachable network mounts), and `netstat` is slow on a busy host.
532
- *
533
- * 5s matches what the rest of the codebase already uses for these EXACT
534
- * commands — port-owner.ts times out its `lsof`/`netstat` and process-control.ts
535
- * its `tasklist`. These copies simply never got it.
536
- *
537
- * Timing out costs nothing in correctness: both callers already fail closed on
538
- * a throw (null / "unknown"), and both READ that as "could not determine" —
539
- * pidListeningOnPort's null makes the caller decline to claim a takeover rather
540
- * than assert the port is free.
541
- */
542
- const PORT_PROBE_TIMEOUT_MS = 5000;
543
- /** Resolve which pid is LISTENING on a local TCP port (null if none/unknown). */
544
- function pidListeningOnPort(port) {
545
- try {
546
- if (process.platform === "win32") {
547
- const out = execFileSync("netstat", ["-ano", "-p", "tcp"], {
548
- encoding: "utf8",
549
- timeout: PORT_PROBE_TIMEOUT_MS,
550
- });
551
- for (const line of out.split(/\r?\n/)) {
552
- const m = line.match(/TCP\s+\S+:(\d+)\s+\S+\s+LISTENING\s+(\d+)\s*$/i);
553
- if (m && Number(m[1]) === port)
554
- return Number(m[2]);
555
- }
556
- return null;
557
- }
558
- const out = execFileSync("lsof", ["-ti", `tcp:${port}`, "-s", "tcp:LISTEN"], {
559
- encoding: "utf8",
560
- timeout: PORT_PROBE_TIMEOUT_MS,
561
- });
562
- const pid = Number(out.trim().split(/\s+/)[0]);
563
- return Number.isInteger(pid) && pid > 0 ? pid : null;
564
- }
565
- catch {
566
- return null;
567
- }
568
- }
569
- /** Best-effort executable name for a pid ("unknown" when unreadable). */
570
- function processNameOf(pid) {
571
- try {
572
- if (process.platform === "win32") {
573
- const out = execFileSync("tasklist", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], {
574
- encoding: "utf8",
575
- timeout: PORT_PROBE_TIMEOUT_MS,
576
- });
577
- return /^"([^"]+)"/m.exec(out)?.[1] ?? "unknown";
578
- }
579
- return (execFileSync("ps", ["-p", String(pid), "-o", "comm="], {
580
- encoding: "utf8",
581
- timeout: PORT_PROBE_TIMEOUT_MS,
582
- }).trim() ||
583
- "unknown");
584
- }
585
- catch {
586
- return "unknown";
587
- }
588
- }
589
- /** Can the target ComfyUI answer /system_stats within timeoutMs? */
590
- async function probeComfyUi(url, timeoutMs = 3000) {
591
- try {
592
- const ctl = new AbortController();
593
- const timer = setTimeout(() => ctl.abort(), timeoutMs);
594
- const res = await fetch(new URL("system_stats", url.endsWith("/") ? url : `${url}/`), {
595
- signal: ctl.signal,
596
- });
597
- clearTimeout(timer);
598
- return res.ok;
599
- }
600
- catch {
601
- return false;
602
- }
603
- }
604
- function readOrchestratorLock(lockPath) {
605
- try {
606
- const raw = JSON.parse(readFileSync(lockPath, "utf8"));
607
- return raw && typeof raw === "object" ? raw : null;
608
- }
609
- catch {
610
- return null; // missing / unreadable / not JSON — nothing to reclaim from.
611
- }
612
- }
613
- /** A single y/n question on the real terminal. Resolves false on anything but
614
- * an explicit y/yes (a closed stdin, EOF, or a stray keypress all count as no). */
615
- function promptYesNo(question) {
616
- return new Promise((resolve) => {
617
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
618
- rl.question(question, (answer) => {
619
- rl.close();
620
- resolve(/^y(es)?$/i.test(answer.trim()));
621
- });
622
- });
623
- }
624
- /**
625
- * The bridge port is almost always held by a PREVIOUS comfyui-mcp orchestrator
626
- * that never exited — a stale panel session, one orphaned by a ComfyUI crash,
627
- * or simply an older version still running while the user upgraded — not some
628
- * unrelated process. Rather than leave the user to go hunt down and kill a PID
629
- * themselves (the old behavior: log a warning and stay degraded), read the
630
- * lockfile the holder wrote at its own startup (see below) and, ONLY when
631
- * stdin is a real terminal — `--panel-orchestrator` / `connect` run standalone
632
- * in the user's own shell and are mutually exclusive with the stdio MCP server,
633
- * so stdin is never claimed by the MCP JSON-RPC protocol here — interactively
634
- * offer to stop it and retry the bind.
635
- *
636
- * Returns false (falls back to the existing hard-fail message) whenever it
637
- * can't confidently identify a reclaimable holder, isn't running
638
- * interactively, or the user declines.
639
- */
640
- async function tryReclaimBridgePort(bridge, port, lockPath) {
641
- if (!process.stdin.isTTY || !process.stdout.isTTY)
642
- return false;
643
- const lock = readOrchestratorLock(lockPath);
644
- const lockPid = typeof lock?.pid === "number" && Number.isInteger(lock.pid) && lock.pid > 0 && pidExists(lock.pid)
645
- ? lock.pid
646
- : null;
647
- // The lockfile can be stale/missing while SOMETHING still owns the port (a
648
- // crashed session's orphaned child, an unrelated app). Resolve the actual
649
- // listener from the port so a single consent can clear EVERYTHING.
650
- const portPid = pidListeningOnPort(port);
651
- const pid = lockPid ?? portPid;
652
- if (!pid)
653
- return false;
654
- const myUrl = process.env.COMFYUI_URL || "http://127.0.0.1:8188";
655
- let holderNote;
656
- let detailNote = "";
657
- if (lockPid) {
658
- const myVersion = detectInstallMode().currentVersion ?? "unknown";
659
- const heldVersion = typeof lock?.version === "string" ? lock.version : "unknown";
660
- const startedAt = typeof lock?.startedAt === "string" ? lock.startedAt : null;
661
- holderNote =
662
- heldVersion !== "unknown" && myVersion !== "unknown" && heldVersion !== myVersion
663
- ? `an older comfyui-mcp v${heldVersion} (this is v${myVersion})`
664
- : `another comfyui-mcp v${heldVersion} session`;
665
- // Show WHICH ComfyUI each side is driving — the classic tangle is a stale
666
- // session recalled from shell history still "driving" a terminated pod
667
- // while the user tries to connect to the live one; without the URLs both
668
- // sessions look identical and the takeover choice is a coin flip.
669
- const heldUrl = typeof lock?.comfyuiUrl === "string" ? lock.comfyuiUrl : null;
670
- if (startedAt)
671
- detailNote += `, started ${startedAt}`;
672
- if (heldUrl) {
673
- const alive = await probeComfyUi(heldUrl);
674
- detailNote += `, driving ${heldUrl}${alive ? "" : " (NOT RESPONDING — likely a terminated pod / stale session)"}`;
675
- }
676
- }
677
- else {
678
- holderNote = `an unidentified process ("${processNameOf(pid)}" — no comfyui-mcp lockfile; likely an orphaned session or another app)`;
679
- }
680
- logger.warn(`[panel-orchestrator] port ${port} is already held by ${holderNote} — pid ${pid}${detailNote}.`);
681
- const ok = await promptYesNo(`Stop it (and its whole process tree) and take over port ${port} for ${myUrl}? [y/N] `);
682
- if (!ok) {
683
- logger.info(`[panel-orchestrator] leaving pid ${pid} alone.\n${portKillHint(port)}`);
684
- return false;
685
- }
686
- // One Y = full authority to clear the port: kill the lockfile's holder AND
687
- // whatever is actually listening (they can differ when the lockfile is
688
- // stale), whole trees, escalating to a hard kill for anything that survives.
689
- const targets = [...new Set([lockPid, portPid].filter((p) => p != null))];
690
- logger.info(`[panel-orchestrator] stopping pid${targets.length > 1 ? "s" : ""} ${targets.join(", ")} (and their process trees) to reclaim port ${port}…`);
691
- for (const t of targets) {
692
- try {
693
- killProcessTreeCrossPlatform(t, "term");
694
- }
695
- catch (err) {
696
- logger.warn(`[panel-orchestrator] couldn't stop pid ${t}: ${err instanceof Error ? err.message : String(err)}`);
697
- }
698
- }
699
- // Give them a moment to release the port; escalate to a hard tree-kill for
700
- // survivors, sweep any NEW listener that appeared (a respawned child), then
701
- // retry the bind once (bridge.start() runs its own EADDRINUSE backoff,
702
- // covering the OS's brief port-release lag).
703
- const deadline = Date.now() + 5000;
704
- while (Date.now() < deadline && targets.some((t) => pidExists(t))) {
705
- await new Promise((r) => setTimeout(r, 200));
706
- }
707
- for (const t of targets) {
708
- if (!pidExists(t))
709
- continue;
710
- try {
711
- killProcessTreeCrossPlatform(t, "kill");
712
- }
713
- catch {
714
- // already gone
715
- }
716
- }
717
- const straggler = pidListeningOnPort(port);
718
- if (straggler && !targets.includes(straggler)) {
719
- logger.warn(`[panel-orchestrator] a new pid ${straggler} grabbed port ${port} — stopping it too.`);
720
- try {
721
- killProcessTreeCrossPlatform(straggler, "kill");
722
- }
723
- catch {
724
- // best-effort
725
- }
726
- }
727
- await new Promise((r) => setTimeout(r, 300));
728
- bridge.start();
729
- return bridge.whenReady();
730
- }
731
482
  /**
732
483
  * Tie the orchestrator's lifetime to ComfyUI's. The launcher (the panel pack)
733
484
  * passes its own PID as COMFYUI_MCP_PARENT_PID; we poll whether that process is
@@ -861,7 +612,7 @@ export class DownloadProgressSnapshots {
861
612
  * #1524 — a startup that never finishes must not become a silent resident.
862
613
  *
863
614
  * A respawn was observed alive for hours holding NO listening ports, while an
864
- * older instance owned 9180/9181/9183. That is not the bind-failure path: that
615
+ * older instance owned the bridge port block. That is not the bind-failure path: that
865
616
  * one is bounded (five attempts, then `whenReady()` resolves false), tries to
866
617
  * reclaim the port, and exits non-zero with a clear message. A process with no
867
618
  * ports at all never got that far — it hung EARLIER, so any guard wrapped around
@@ -893,6 +644,21 @@ export class DownloadProgressSnapshots {
893
644
  export function armStartupDeadline(port, deps = {}) {
894
645
  const exit = deps.exit ?? ((code) => process.exit(code));
895
646
  const findIncumbent = deps.incumbent ?? pidListeningOnPort;
647
+ const assessHolder = deps.assessHolder ??
648
+ (async (p) => {
649
+ const a = await assessBridgeHolder(p, orchLockPath(p));
650
+ return { ownership: a.ownership, processName: a.processName };
651
+ });
652
+ const holderAdvice = deps.holderAdvice ??
653
+ (async (pid) => {
654
+ const a = await assessHolder(port);
655
+ return startupDeadlineHolderAdvice({
656
+ port,
657
+ pid,
658
+ ownership: a.ownership,
659
+ processName: a.processName,
660
+ });
661
+ });
896
662
  // CLAMPED, not merely "positive and finite" (codex). Node coerces a
897
663
  // sub-millisecond delay AND anything past the 32-bit signed limit to 1ms — so
898
664
  // `0.5`, or a large number typed by someone trying to RAISE the deadline, would
@@ -904,17 +670,19 @@ export function armStartupDeadline(port, deps = {}) {
904
670
  const MAX_MS = 2_147_483_647; // Node's setTimeout ceiling
905
671
  const ms = Number.isFinite(raw) && raw >= MIN_MS && raw <= MAX_MS ? Math.floor(raw) : 90_000;
906
672
  const timer = setTimeout(() => {
907
- const incumbent = findIncumbent(port);
908
- logger.error(`[panel-orchestrator] startup did not complete within ${Math.round(ms / 1000)}s and this ` +
909
- `process holds no bridge port — exiting rather than lingering with no way to serve panel_* ` +
910
- `tools.` +
911
- (incumbent
912
- ? ` Port ${port} is held by pid ${incumbent}; if that is an older comfyui-mcp, stop it ` +
913
- `and start this one again.`
673
+ void (async () => {
674
+ const incumbent = findIncumbent(port);
675
+ const extra = incumbent
676
+ ? await holderAdvice(incumbent)
914
677
  : ` Nothing is listening on ${port} either, so the hang is before the bind — please ` +
915
- `report this with the last lines above (#1524).`) +
916
- ` Raise COMFYUI_MCP_STARTUP_DEADLINE_MS if this machine is legitimately slower than that.`);
917
- exit(1);
678
+ `report this with the last lines above (#1524).`;
679
+ logger.error(`[panel-orchestrator] startup did not complete within ${Math.round(ms / 1000)}s and this ` +
680
+ `process holds no bridge port — exiting rather than lingering with no way to serve panel_* ` +
681
+ `tools.` +
682
+ extra +
683
+ ` Raise COMFYUI_MCP_STARTUP_DEADLINE_MS if this machine is legitimately slower than that.`);
684
+ exit(1);
685
+ })();
918
686
  }, ms);
919
687
  // Never hold the event loop open on this timer's account.
920
688
  timer.unref?.();
@@ -931,9 +699,13 @@ export async function runPanelOrchestrator() {
931
699
  logger.error(`[panel-orchestrator] unhandled rejection (ignored): ${reason instanceof Error ? reason.stack ?? reason.message : String(reason)}`);
932
700
  });
933
701
  // #1524 — armed HERE, before anything that can block, and disarmed only once
934
- // the bridge port is actually held. The port is re-derived rather than passed
935
- // in because the deadline has to exist before the block that computes it.
936
- const disarmStartupDeadline = armStartupDeadline(Number(process.env.COMFYUI_MCP_BRIDGE_PORT) || 9180);
702
+ // the bridge port is actually held. Resolved once and pinned into this
703
+ // process env so a self-restart child inherits the *effective* port, never a
704
+ // new compiled default (#2030).
705
+ const lockPort = resolveBridgePort();
706
+ pinBoundBridgePort(lockPort);
707
+ const ports = panelPortBlock(lockPort);
708
+ const disarmStartupDeadline = armStartupDeadline(lockPort);
937
709
  process.on("uncaughtException", (err) => {
938
710
  // A synchronous uncaught throw leaves the process in an UNDEFINED state. The
939
711
  // old "log + continue" here was a zombie root cause — the orchestrator stayed
@@ -1030,8 +802,8 @@ export async function runPanelOrchestrator() {
1030
802
  // retarget (a loopback boot must not permanently rule out the secure bridge
1031
803
  // the pod's HTTPS panel needs — codex finding).
1032
804
  let bridgeToken = envBridgeToken ?? (wantSecureBridge || lanBridge ? randomBytes(24).toString("hex") : null);
1033
- // Dedicated PANEL bridge port (default 9180). Token-gated in secure/LAN mode.
1034
- const lockPort = Number(process.env.COMFYUI_MCP_BRIDGE_PORT) || 9180;
805
+ // Dedicated PANEL bridge port. Token-gated in secure/LAN mode.
806
+ // lockPort / ports were resolved and pinned at the top of this function.
1035
807
  const lockPath = orchLockPath(lockPort);
1036
808
  const bridge = startUiBridge(lockPort, bridgeToken, bridgeHost);
1037
809
  // Starts empty intentionally: a newly connected panel must be able to clear
@@ -1047,7 +819,7 @@ export async function runPanelOrchestrator() {
1047
819
  // interfaces (so a phone can reach it), while the primary loopback bridge stays
1048
820
  // token-less. LAN mode returns a same-wifi ws:// URL; tunnel mode fronts the same
1049
821
  // token-gated listener with a cloudflared wss:// for anywhere access.
1050
- const pairPort = lockPort + 2; // avoid the panel_* HTTP-MCP port (lockPort + 1)
822
+ const pairPort = ports.pairing;
1051
823
  // A stable pairing token can be pinned via COMFYUI_MCP_PAIR_TOKEN. Without it,
1052
824
  // the token is generated per session, so a phone's saved bridge URL dies on the
1053
825
  // next orchestrator restart and the user must re-pair. With it pinned, the token
@@ -1121,13 +893,16 @@ export async function runPanelOrchestrator() {
1121
893
  // holds it, fail loudly instead of running uselessly. (This also avoids the
1122
894
  // case where a failed bind leaves the process with no live handles and it
1123
895
  // exits silently.) First try to reclaim it interactively (see
1124
- // tryReclaimBridgePort) — almost always a stale/older comfyui-mcp session,
1125
- // not some unrelated process — before giving up.
896
+ // tryReclaimBridgePort) — only when the holder speaks the panel protocol.
897
+ // A foreign holder (Logitech G HUB on 9180) is named and left running.
1126
898
  let bound = await bridge.whenReady();
1127
- if (!bound)
1128
- bound = await tryReclaimBridgePort(bridge, lockPort, lockPath);
899
+ let reclaim;
1129
900
  if (!bound) {
1130
- logger.error(`[panel-orchestrator] could not bind the panel bridge port — another process owns it. Free that port and restart the orchestrator. Override the port with COMFYUI_MCP_BRIDGE_PORT.\n${portKillHint(lockPort)}`);
901
+ reclaim = await tryReclaimBridgePort(bridge, lockPort, lockPath);
902
+ bound = reclaim.bound;
903
+ }
904
+ if (!bound) {
905
+ logger.error(bindFailureAdvice(lockPort, reclaim ?? { ownership: unclassifiedOwnership(), processName: "unknown" }));
1131
906
  process.exit(1);
1132
907
  }
1133
908
  // The port is ours — startup got where it needed to. Everything after this is
@@ -1293,11 +1068,11 @@ export async function runPanelOrchestrator() {
1293
1068
  const model = process.env.COMFYUI_MCP_PANEL_MODEL ?? "claude-opus-5";
1294
1069
  const envEffort = process.env.COMFYUI_MCP_PANEL_EFFORT;
1295
1070
  const effort = isEffort(envEffort) ? envEffort : undefined;
1296
- // Single-port multi-provider: ONE orchestrator on ONE bridge port (default
1297
- // 9180) serves ALL providers — the panel picks a provider per tab via the
1298
- // hello/set_backend handshake (see "Per-tab backend" below). +1 is the panel_*
1299
- // HTTP-MCP port, +2 the phone-pairing listener. COMFYUI_MCP_BRIDGE_PORT overrides.
1300
- const bridgePort = Number(process.env.COMFYUI_MCP_BRIDGE_PORT) || 9180;
1071
+ // Single-port multi-provider: ONE orchestrator on ONE bridge port serves ALL
1072
+ // providers — the panel picks a provider per tab via the hello/set_backend
1073
+ // handshake. Sibling ports come from panelPortBlock (count down from 9199;
1074
+ // 9180-era sessions keep +1..+4 so pairing on 9182 still answers).
1075
+ const bridgePort = lockPort;
1301
1076
  // Open the cloudflared tunnel and advertise the wss URL to the pod so its
1302
1077
  // browser panel connects automatically — the user never copies a URL. Best
1303
1078
  // effort: on failure the (token-gated) bridge stays up and we log an actionable
@@ -1305,7 +1080,13 @@ export async function runPanelOrchestrator() {
1305
1080
  let secureBridge = null;
1306
1081
  if (wantSecureBridge && bridgeToken) {
1307
1082
  try {
1308
- secureBridge = await setupSecureBridge({ bridgePort, comfyuiUrl, token: bridgeToken, bridge });
1083
+ secureBridge = await setupSecureBridge({
1084
+ bridgePort,
1085
+ comfyuiUrl,
1086
+ token: bridgeToken,
1087
+ bridge,
1088
+ localUrl: localBridgeUrl(lockPort),
1089
+ });
1309
1090
  }
1310
1091
  catch (err) {
1311
1092
  logger.error(`[panel-orchestrator] secure bridge (cloudflared) failed: ${err instanceof Error ? err.message : String(err)}. ` +
@@ -1313,6 +1094,13 @@ export async function runPanelOrchestrator() {
1313
1094
  `SSH tunnel (ssh -L 3000:localhost:3000 …) at http://localhost:3000.`);
1314
1095
  }
1315
1096
  }
1097
+ // Local sessions: tell the pack the loopback URL we actually bound, so a
1098
+ // panel that still guesses 9180 can follow 9199 (and a migrated 9180 session
1099
+ // stays discoverable). Same advertise_bridge POST the tunnel path uses.
1100
+ if (isLoopbackUrl(comfyuiUrl)) {
1101
+ const local = localBridgeUrl(lockPort);
1102
+ void advertiseBridge(comfyuiUrl, local, undefined, local);
1103
+ }
1316
1104
  // Cross-process download-progress + control channel: each tab's comfyui MCP
1317
1105
  // subprocess writes per-download JSON (and runpod_* target requests) here;
1318
1106
  // the watcher below broadcasts downloads to the panel tray and applies
@@ -1541,7 +1329,7 @@ export async function runPanelOrchestrator() {
1541
1329
  // ── Per-tab backend (single-port multi-provider) ──────────────────────────
1542
1330
  // ONE orchestrator on ONE bridge port serves ALL providers; the panel picks a
1543
1331
  // provider per tab via the `hello`/`set_backend` handshake, instead of the node
1544
- // spawning one process per provider on its own port (9180/9181/9182).
1332
+ // spawning one process per provider on its own port.
1545
1333
  //
1546
1334
  // SESSIONS ARE ORCHESTRATOR-SCOPED (#884, owner-stated invariant): one agent
1547
1335
  // session spans every panel, browser tab and open workflow — a workflow-scoped
@@ -1973,30 +1761,30 @@ export async function runPanelOrchestrator() {
1973
1761
  // The orchestrator-hosted loopback HTTP MCP for panel_* tools. Started for the
1974
1762
  // non-Claude backends (Codex + Gemini), which can't host an in-process SDK MCP
1975
1763
  // server the way Claude does. Port: COMFYUI_MCP_PANEL_MCP_PORT, default
1976
- // bridgePort+1 (loopback only).
1764
+ // panelPortBlock(bridge).panelMcp (loopback only).
1977
1765
  // Start the loopback HTTP panel-MCP ALWAYS: with single-port multi-provider any
1978
1766
  // tab may pick codex/gemini at runtime, and those backends drive the canvas
1979
1767
  // through this server (Claude tabs use the in-process SDK server instead). The
1980
1768
  // per-tab session routing (`urlFor(panelTabId)`) already isolates tabs.
1981
1769
  let panelMcpHttp = null;
1982
1770
  {
1983
- const panelMcpPort = Number(process.env.COMFYUI_MCP_PANEL_MCP_PORT) || bridgePort + 1;
1771
+ const panelMcpPort = Number(process.env.COMFYUI_MCP_PANEL_MCP_PORT) || ports.panelMcp;
1984
1772
  try {
1985
- panelMcpHttp = await startPanelMcpHttpServer(bridge, panelMcpPort, "127.0.0.1", workflowTargets);
1773
+ panelMcpHttp = await startPanelMcpHttpServer(bridge, panelMcpPort, "127.0.0.1", workflowTargets, (promptIds) => runCompletionWatchdog?.markTicketed(promptIds));
1986
1774
  }
1987
1775
  catch (err) {
1988
1776
  logger.error(`[panel-orchestrator] could not start the panel HTTP MCP on :${panelMcpPort} — codex/gemini tabs will lack live-graph tools: ${err instanceof Error ? err.message : String(err)}`);
1989
1777
  }
1990
1778
  }
1991
1779
  // Loopback MCP console (control plane): OAuth, MCP mappings, service lifecycle.
1992
- // Default port bridge+3 (9180→9183). NOT +2: bridge+2 is the phone-pairing
1993
- // listener's port (see pairPort above), and the fork this console was ported
1994
- // from predates pairing — on Windows both binds accidentally coexist
1995
- // (specific 127.0.0.1 vs wildcard 0.0.0.0), on Linux whichever comes second
1996
- // dies with EADDRINUSE. The panel never hardcodes this port — it uses the
1997
- // console_url advertised on the `backends` frame.
1780
+ // Default from panelPortBlock (9199→9196; 9180-era → 9183). NOT the pairing
1781
+ // port: the fork this console was ported from predates pairing — on Windows
1782
+ // both binds accidentally coexist (specific 127.0.0.1 vs wildcard 0.0.0.0),
1783
+ // on Linux whichever comes second dies with EADDRINUSE. The panel never
1784
+ // hardcodes this port — it uses the console_url advertised on the `backends`
1785
+ // frame.
1998
1786
  let panelConsoleHttp = null;
1999
- const consolePort = Number(process.env.COMFYUI_MCP_CONSOLE_PORT) || bridgePort + 3;
1787
+ const consolePort = Number(process.env.COMFYUI_MCP_CONSOLE_PORT) || ports.console;
2000
1788
  const consoleToken = randomBytes(24).toString("hex");
2001
1789
  try {
2002
1790
  panelConsoleHttp = await startPanelConsoleHttpServer({
@@ -2390,7 +2178,7 @@ export async function runPanelOrchestrator() {
2390
2178
  // CONVERSATION (concurrently in-flight turns on two backends never share
2391
2179
  // one issue-time stamp).
2392
2180
  makePanelServer: (key) => backendOf(key) === "claude"
2393
- ? createPanelMcpServer(bridge, key, workflowTargets)
2181
+ ? createPanelMcpServer(bridge, key, workflowTargets, (promptIds) => runCompletionWatchdog?.markTicketed(promptIds))
2394
2182
  : undefined,
2395
2183
  mcpServers: buildMcpServers(),
2396
2184
  // Per-KEY factory — spawns must reflect live state (the Blind gate) and
@@ -3732,6 +3520,10 @@ export async function runPanelOrchestrator() {
3732
3520
  // POST is cheap and idempotent (see advertiseBridge's own docstring).
3733
3521
  if (secureBridge && isRemoteHttpsUrl(comfyuiUrl))
3734
3522
  void secureBridge.advertise(comfyuiUrl);
3523
+ else if (isLoopbackUrl(comfyuiUrl)) {
3524
+ const local = localBridgeUrl(lockPort);
3525
+ void advertiseBridge(comfyuiUrl, local, undefined, local);
3526
+ }
3735
3527
  // Per-tab backend selection (single-port multi-provider). The panel names
3736
3528
  // its chosen provider on connect (and on a switch it re-sends hello / a
3737
3529
  // set_backend); absent or unknown → the default. The SAME shared
@@ -5995,10 +5787,13 @@ export async function runPanelOrchestrator() {
5995
5787
  // observation went only to the `queue_status` UI broadcast. This watchdog is
5996
5788
  // the join: an observed completion whose panel_run ticket is still unanswered
5997
5789
  // after the grace is synthesised into the SAME journal the real frame uses.
5790
+ // A fast completion may precede the ticket itself, so the watchdog also holds
5791
+ // unknown ids briefly and re-checks them after panel_run's reply can arrive.
5998
5792
  // See run-completion-watchdog.ts for why it waits, and why the panel's frame
5999
5793
  // still wins whenever it is coming.
6000
5794
  const wd = createRunCompletionWatchdog({
6001
5795
  awaiting: (promptId) => RunCompletions.awaitingCompletion(promptId),
5796
+ knownTicket: (promptId) => RunCompletions.ticketFor(promptId),
6002
5797
  resolveOutputs: (promptId) => resolveHistoryCompletionImages(promptId),
6003
5798
  lookupStatus: (promptId) => resolveHistoryCompletionStatus(promptId),
6004
5799
  deliver: (payload, ticket) => {
@@ -6188,7 +5983,7 @@ export async function runPanelOrchestrator() {
6188
5983
  // MEMORY (the panel __init__'s advertise store). A restart — which the agent
6189
5984
  // does after every custom-node install — WIPES it: the browser reloads,
6190
5985
  // fetches an empty /bridge_url, falls back to the token-less
6191
- // ws://127.0.0.1:9180, and is rejected ("missing/invalid token"). It can't
5986
+ // ws://127.0.0.1:<bridge>, and is rejected ("missing/invalid token"). It can't
6192
5987
  // send a hello to trigger the on-hello re-advertise (line ~1452) BECAUSE it
6193
5988
  // never gets a valid connection — a deadlock that only broke when something
6194
5989
  // eventually nudged it, stranding the agent mid-task for minutes. Re-POSTing
@@ -6209,9 +6004,9 @@ export async function runPanelOrchestrator() {
6209
6004
  // its OWN token-gated listener (same addListener mechanism the phone-pair
6210
6005
  // flow uses) on a dedicated port — never retrofit auth onto the public path
6211
6006
  // and never open a tunnel in front of an unauthenticated one (codex P1).
6212
- // Port map: +0 bridge, +1 panel_* HTTP-MCP, +2 phone-pair, +3 console (line
6213
- // ~1427), +4 tunnel listener (codex finding: +3 was already taken).
6214
- const tunnelPort = lockPort + 4;
6007
+ // Sibling ports: panelPortBlock (count down from 9199; 9180-era counts up so
6008
+ // pairing on 9182 still answers). Tunnel is the last slot.
6009
+ const tunnelPort = ports.tunnel;
6215
6010
  let tunnelToken = null;
6216
6011
  let tunnelListenerStarted = false;
6217
6012
  const ensureSecureBridge = async (url) => {
@@ -6246,6 +6041,7 @@ export async function runPanelOrchestrator() {
6246
6041
  comfyuiUrl: url,
6247
6042
  token,
6248
6043
  bridge,
6044
+ localUrl: localBridgeUrl(lockPort),
6249
6045
  // The setup itself advertises on completion — guard it too: a slow
6250
6046
  // tunnel must not hand the bridge URL to a pod the user already
6251
6047
  // left (codex finding: the post-await guard alone couldn't stop it).