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.
- package/README.md +1 -1
- package/dist/boot.js +5 -4
- package/dist/boot.js.map +1 -1
- package/dist/orchestrator/get-errors-audit.js +14 -7
- package/dist/orchestrator/get-errors-audit.js.map +1 -1
- package/dist/orchestrator/index.js +94 -298
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/orchestrator/panel-mcp-http.js +2 -2
- package/dist/orchestrator/panel-mcp-http.js.map +1 -1
- package/dist/orchestrator/panel-tools.js +114 -7
- package/dist/orchestrator/panel-tools.js.map +1 -1
- package/dist/orchestrator/promoted-widget.js +49 -0
- package/dist/orchestrator/promoted-widget.js.map +1 -1
- package/dist/orchestrator/run-completion-journal.js +5 -0
- package/dist/orchestrator/run-completion-journal.js.map +1 -1
- package/dist/orchestrator/run-completion-watchdog.js +63 -14
- package/dist/orchestrator/run-completion-watchdog.js.map +1 -1
- package/dist/services/bridge-port-reclaim.js +340 -0
- package/dist/services/bridge-port-reclaim.js.map +1 -0
- package/dist/services/bridge-ports.js +78 -0
- package/dist/services/bridge-ports.js.map +1 -0
- package/dist/services/listener-ownership.js +30 -0
- package/dist/services/listener-ownership.js.map +1 -1
- package/dist/services/panel-launcher.js +20 -4
- package/dist/services/panel-launcher.js.map +1 -1
- package/dist/services/secure-bridge.js +8 -5
- package/dist/services/secure-bridge.js.map +1 -1
- package/dist/services/self-restart.js +24 -5
- package/dist/services/self-restart.js.map +1 -1
- package/dist/services/tunnel.js +11 -2
- package/dist/services/tunnel.js.map +1 -1
- package/docker/runpod/README.md +1 -1
- 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 (
|
|
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
|
|
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
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
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
|
-
`
|
|
917
|
-
|
|
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.
|
|
935
|
-
//
|
|
936
|
-
|
|
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
|
|
1034
|
-
|
|
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 =
|
|
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) —
|
|
1125
|
-
//
|
|
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
|
-
|
|
1128
|
-
bound = await tryReclaimBridgePort(bridge, lockPort, lockPath);
|
|
899
|
+
let reclaim;
|
|
1129
900
|
if (!bound) {
|
|
1130
|
-
|
|
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
|
|
1297
|
-
//
|
|
1298
|
-
//
|
|
1299
|
-
//
|
|
1300
|
-
const bridgePort =
|
|
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({
|
|
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
|
|
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
|
-
//
|
|
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) ||
|
|
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
|
|
1993
|
-
//
|
|
1994
|
-
//
|
|
1995
|
-
//
|
|
1996
|
-
//
|
|
1997
|
-
//
|
|
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) ||
|
|
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
|
|
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
|
-
//
|
|
6213
|
-
//
|
|
6214
|
-
const tunnelPort =
|
|
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).
|