comfyui-mcp 0.45.0 → 0.47.0

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 (66) hide show
  1. package/README.md +3 -3
  2. package/dist/config.js +186 -19
  3. package/dist/config.js.map +1 -1
  4. package/dist/orchestrator/codex-backend.js +195 -53
  5. package/dist/orchestrator/codex-backend.js.map +1 -1
  6. package/dist/orchestrator/grok-backend.js +5 -4
  7. package/dist/orchestrator/grok-backend.js.map +1 -1
  8. package/dist/orchestrator/index.js +585 -45
  9. package/dist/orchestrator/index.js.map +1 -1
  10. package/dist/orchestrator/panel-tools.js +2 -1
  11. package/dist/orchestrator/panel-tools.js.map +1 -1
  12. package/dist/services/ai-toolkit.js +11 -1
  13. package/dist/services/ai-toolkit.js.map +1 -1
  14. package/dist/services/download-jobs.js +92 -0
  15. package/dist/services/download-jobs.js.map +1 -0
  16. package/dist/services/download-progress.js +213 -7
  17. package/dist/services/download-progress.js.map +1 -1
  18. package/dist/services/env-capabilities.js +3 -2
  19. package/dist/services/env-capabilities.js.map +1 -1
  20. package/dist/services/generate-conditioned.js +1 -0
  21. package/dist/services/generate-conditioned.js.map +1 -1
  22. package/dist/services/process-control.js +154 -4
  23. package/dist/services/process-control.js.map +1 -1
  24. package/dist/services/queue-monitor.js +8 -2
  25. package/dist/services/queue-monitor.js.map +1 -1
  26. package/dist/services/runpod-client.js +146 -6
  27. package/dist/services/runpod-client.js.map +1 -1
  28. package/dist/services/runpod-ssh.js +3 -1
  29. package/dist/services/runpod-ssh.js.map +1 -1
  30. package/dist/services/runpod-watch.js +199 -2
  31. package/dist/services/runpod-watch.js.map +1 -1
  32. package/dist/services/secure-bridge.js +24 -17
  33. package/dist/services/secure-bridge.js.map +1 -1
  34. package/dist/services/training-datasets.js +168 -0
  35. package/dist/services/training-datasets.js.map +1 -0
  36. package/dist/services/workflow-composer.js +3 -0
  37. package/dist/services/workflow-composer.js.map +1 -1
  38. package/dist/tools/apps.js +222 -0
  39. package/dist/tools/apps.js.map +1 -0
  40. package/dist/tools/generate-conditioned.js +4 -0
  41. package/dist/tools/generate-conditioned.js.map +1 -1
  42. package/dist/tools/image-management.js +24 -0
  43. package/dist/tools/image-management.js.map +1 -1
  44. package/dist/tools/index.js +2 -0
  45. package/dist/tools/index.js.map +1 -1
  46. package/dist/tools/model-extras.js +73 -42
  47. package/dist/tools/model-extras.js.map +1 -1
  48. package/dist/tools/model-management.js +90 -4
  49. package/dist/tools/model-management.js.map +1 -1
  50. package/dist/tools/process-control.js +1 -1
  51. package/dist/tools/process-control.js.map +1 -1
  52. package/dist/tools/prompt-director.js +2 -1
  53. package/dist/tools/prompt-director.js.map +1 -1
  54. package/dist/tools/runpod.js +210 -20
  55. package/dist/tools/runpod.js.map +1 -1
  56. package/dist/tools/train.js +104 -10
  57. package/dist/tools/train.js.map +1 -1
  58. package/docker/runpod/Dockerfile +15 -2
  59. package/docker/runpod/README.md +23 -0
  60. package/docker/runpod/deadman_server.py +71 -0
  61. package/docker/runpod/deadman_watch.sh +106 -0
  62. package/docker/runpod/post_start.sh +17 -0
  63. package/docker/runpod/test_deadman_harness.py +206 -0
  64. package/package.json +1 -1
  65. package/scripts/asset-counts.mjs +129 -0
  66. package/scripts/gen-tool-docs.ts +7 -0
@@ -8,10 +8,10 @@
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
10
10
  // key. See docs/design/panel-orchestrator.md.
11
- import { existsSync, writeFileSync, unlinkSync, readFileSync, readdirSync } from "node:fs";
11
+ import { existsSync, mkdirSync, writeFileSync, unlinkSync, readFileSync, readdirSync, rmSync } from "node:fs";
12
12
  import { execFileSync } from "node:child_process";
13
- import { tmpdir, networkInterfaces } from "node:os";
14
- import { join } from "node:path";
13
+ import { tmpdir, homedir, networkInterfaces } from "node:os";
14
+ import { dirname, join } from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { randomBytes } from "node:crypto";
17
17
  import readline from "node:readline";
@@ -32,7 +32,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
32
32
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
33
33
  import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
34
34
  import { registerAllTools } from "../tools/index.js";
35
- import { isForceRemoteFlagSet, isLoopbackHost, detectLocalComfyUIPath, setComfyuiTarget, onComfyuiTargetChanged, isTargetingLocal, getComfyUIBaseUrl } from "../config.js";
35
+ import { isForceRemoteFlagSet, isLoopbackHost, detectLocalComfyUIPath, setComfyuiTarget, onComfyuiTargetChanged, isTargetingLocal, isTargetingLocalOrLan, isTargetingPod, getComfyUIBaseUrl, getLocalComfyuiUrl, rescopeLocalTargetFile, getComfyUIAuthHeaders } from "../config.js";
36
36
  import { buildComfyuiMcpEnv, comfyuiSecretKeys, onComfyuiSecretsChanged, hydrateAgentSecretsIntoEnv, onAgentSecretsChanged, setAgentSecret, setComfyuiSecret, isAllowedComfyuiSecretKey, } from "../services/panel-secrets.js";
37
37
  import { CodexBackend } from "./codex-backend.js";
38
38
  import { GeminiBackend, GEMINI_DEFAULT_MODEL } from "./gemini-backend.js";
@@ -55,6 +55,8 @@ import { startPanelConsoleHttpServer } from "./panel-console-http.js";
55
55
  import { readComfyuiCrashLog, formatCrashNote } from "../services/crash-log.js";
56
56
  import { QueueMonitor } from "../services/queue-monitor.js";
57
57
  import { initRunpodWatcher, getRunpodWatcher } from "../services/runpod-watch.js";
58
+ import { getPod } from "../services/runpod-client.js";
59
+ import { listTargetChangeRequests, consumeTargetChange, ackTargetChange, setProgressDir, CONTROL_PREFIX } from "../services/download-progress.js";
58
60
  import { hasActiveTrainingJob, reconcileStaleTrainingJobs } from "../services/training-jobs.js";
59
61
  import { buildQueueStatusFrame, createQueueStatusBroadcaster, } from "../services/queue-status-broadcast.js";
60
62
  import { unloadAllOllama, warmOllama, resolveOllamaHost } from "../services/ollama-vram.js";
@@ -593,10 +595,15 @@ const CALL_TOOL_WHITELIST = new Set([
593
595
  // Read-only.
594
596
  "diagnose_run",
595
597
  // Read-only training surface: flow/model discovery + progress polling +
596
- // docker/GPU/image preflight for the panel/mobile Training tab.
598
+ // docker/GPU/image preflight for the panel/mobile Training tab, and the
599
+ // dataset/job-config/file readers behind its Jobs/Datasets views.
597
600
  "train_list_flows",
598
601
  "train_status",
599
602
  "train_doctor",
603
+ "train_list_datasets",
604
+ "train_dataset_detail",
605
+ "train_job_config",
606
+ "train_file",
600
607
  // User-initiated training ops (panel/mobile Training wizard): stage a dataset,
601
608
  // launch a GPU-container training run, cancel one. All validation lives in the
602
609
  // tools themselves (dataset checks, docker/image preflight, liveness-verified
@@ -624,6 +631,18 @@ const CALL_TOOL_WHITELIST = new Set([
624
631
  "runpod_watch",
625
632
  "runpod_unwatch",
626
633
  "runpod_deploy_link",
634
+ // Micro-Apps (panel "Apps" feature): the canvas-less client's list/run/poll
635
+ // surface. Same risk posture as enqueue_workflow (already whitelisted): run
636
+ // queues a job the user explicitly tapped; list/get/status are read-only.
637
+ "apps_list",
638
+ "apps_get",
639
+ "apps_run",
640
+ "apps_run_status",
641
+ // Registry install (mobile Explore): the rig fetches the bundle from the
642
+ // public registry and imports it locally. Same risk as save_workflow +
643
+ // download_model (already whitelisted): writes a local app bundle, no
644
+ // model/system mutation. Deps install stays a separate consented action.
645
+ "apps_import",
627
646
  ]);
628
647
  /** Lazily build ONE in-process MCP client wired to the full comfyui tool surface,
629
648
  * reused across call_tool requests. Reuses the exact tool implementations (same
@@ -737,11 +756,19 @@ export async function runPanelOrchestrator() {
737
756
  const bridgeHost = (process.env.COMFYUI_MCP_BRIDGE_HOST ?? "127.0.0.1").trim() || "127.0.0.1";
738
757
  const lanBridge = !isLoopbackBindHost(bridgeHost);
739
758
  const envBridgeToken = process.env.COMFYUI_MCP_BRIDGE_TOKEN?.trim() || null;
740
- const bridgeToken = envBridgeToken ?? (wantSecureBridge || lanBridge ? randomBytes(24).toString("hex") : null);
759
+ // Provisioned eagerly for secure/LAN boots, LAZILY on the first remote
760
+ // retarget (a loopback boot must not permanently rule out the secure bridge
761
+ // the pod's HTTPS panel needs — codex finding).
762
+ let bridgeToken = envBridgeToken ?? (wantSecureBridge || lanBridge ? randomBytes(24).toString("hex") : null);
741
763
  // Dedicated PANEL bridge port (default 9180). Token-gated in secure/LAN mode.
742
764
  const lockPort = Number(process.env.COMFYUI_MCP_BRIDGE_PORT) || 9180;
743
765
  const lockPath = orchLockPath(lockPort);
744
766
  const bridge = startUiBridge(lockPort, bridgeToken, bridgeHost);
767
+ // The LISTENER'S auth was fixed at construction: a null boot token means a
768
+ // tokenless listener FOREVER — lazily provisioning a token later would
769
+ // advertise a tunnel whose token is not enforced (codex finding). The lazy
770
+ // secure-bridge path must refuse in that case, not expose it publicly.
771
+ const bridgeListenerTokenless = bridgeToken === null;
745
772
  // On-demand phone pairing (the panel "Remote control" button). Off by default:
746
773
  // the FIRST pair request lazily binds a SECOND, token-gated listener on all
747
774
  // interfaces (so a phone can reach it), while the primary loopback bridge stays
@@ -947,10 +974,32 @@ export async function runPanelOrchestrator() {
947
974
  `SSH tunnel (ssh -L 3000:localhost:3000 …) at http://localhost:3000.`);
948
975
  }
949
976
  }
950
- // Cross-process download-progress channel: each tab's comfyui MCP subprocess
951
- // writes per-download JSON here; the watcher below broadcasts it to the panel
952
- // tray. Port-scoped so parallel orchestrators don't cross streams.
953
- const progressDir = join(tmpdir(), `comfyui-mcp-progress-${bridgePort}`);
977
+ // Cross-process download-progress + control channel: each tab's comfyui MCP
978
+ // subprocess writes per-download JSON (and runpod_* target requests) here;
979
+ // the watcher below broadcasts downloads to the panel tray and applies
980
+ // control requests. Port-scoped so parallel orchestrators don't cross
981
+ // streams, NONCED + mode-0700 so another local user can't pre-create the
982
+ // predictable path and inject a hostile retarget (codex finding — the
983
+ // applied URL receives configured auth headers on later requests).
984
+ const progressNonce = randomBytes(12).toString("hex");
985
+ const progressDir = join(tmpdir(), `comfyui-mcp-progress-${bridgePort}-${progressNonce}`);
986
+ // Reap dirs from dead processes on this port (auto-restarts would otherwise
987
+ // accumulate them forever — codex finding). Same port ⇒ same orchestrator,
988
+ // so any other dir with the prefix belongs to a previous life.
989
+ try {
990
+ for (const d of readdirSync(tmpdir())) {
991
+ if (d.startsWith(`comfyui-mcp-progress-${bridgePort}-`) && join(tmpdir(), d) !== progressDir) {
992
+ rmSync(join(tmpdir(), d), { recursive: true, force: true });
993
+ }
994
+ }
995
+ }
996
+ catch {
997
+ /* best-effort */
998
+ }
999
+ mkdirSync(progressDir, { recursive: true, mode: 0o700 });
1000
+ // Late-bind the channel dir so the control channel works IN-PROCESS too
1001
+ // (direct/mobile tool calls — codex finding: it was dead without the env var).
1002
+ setProgressDir(progressDir);
954
1003
  // The bundled plugin (skills) ships alongside dist/ in the package root. Load
955
1004
  // it so the background agents are ComfyUI experts out of the box.
956
1005
  const pluginPath = fileURLToPath(new URL("../../plugin", import.meta.url));
@@ -1717,6 +1766,22 @@ export async function runPanelOrchestrator() {
1717
1766
  // ComfyUI the user actually has open — local OR a RunPod proxy — with no
1718
1767
  // `connect <url>`. Loopback → LOCAL mode (keep COMFYUI_PATH); non-loopback →
1719
1768
  // REMOTE mode (drop the path). No-op if unchanged. Returns true if it retargeted.
1769
+ // Same-URL canonicalization, DEFAULT-PORT-INSENSITIVE but SCHEME-AWARE:
1770
+ // strip only the scheme's actual default (:443 for https, :80 for http) —
1771
+ // http://h:443 and http://h are NOT the same endpoint (codex finding).
1772
+ // Shared by applyComfyuiUrl's dedupe and the control-channel ack check
1773
+ // (runpodProxyUrl omits :443 while getComfyUIBaseUrl includes it — codex).
1774
+ const canonTargetUrl = (u) => {
1775
+ try {
1776
+ const p = new URL(u);
1777
+ if ((p.protocol === "https:" && p.port === "443") || (p.protocol === "http:" && p.port === "80"))
1778
+ p.port = "";
1779
+ return p.toString().replace(/\/+$/, "");
1780
+ }
1781
+ catch {
1782
+ return u.replace(/\/+$/, "");
1783
+ }
1784
+ };
1720
1785
  const applyComfyuiUrl = (rawUrl) => {
1721
1786
  if (typeof rawUrl !== "string")
1722
1787
  return false;
@@ -1733,7 +1798,7 @@ export async function runPanelOrchestrator() {
1733
1798
  catch {
1734
1799
  return false; // not a valid URL — ignore (keep current target)
1735
1800
  }
1736
- if (!host || next === comfyuiUrl)
1801
+ if (!host || canonTargetUrl(next) === canonTargetUrl(comfyuiUrl))
1737
1802
  return false;
1738
1803
  const prev = comfyuiUrl;
1739
1804
  comfyuiUrl = next;
@@ -1746,22 +1811,11 @@ export async function runPanelOrchestrator() {
1746
1811
  // forces getClient() to rebuild against the new host on its next use.
1747
1812
  setComfyuiTarget(next);
1748
1813
  resetClient();
1749
- // Point every provider at the new target: Claude via its rebuilt MCP env, the
1750
- // manager's image-fetch URL, then respawn active agents so the live comfyui MCP
1751
- // subprocess is recreated with the new COMFYUI_URL (no-op if none are running —
1752
- // the next spawn picks it up from the now-updated closures).
1753
- manager.setMcpServers(buildMcpServers());
1754
- manager.setComfyuiUrl(comfyuiUrl);
1755
- manager.restartAllForMcpEnv();
1756
- // Re-point the render watchdog and re-probe the env (remote vs local differs).
1757
- try {
1758
- QueueMonitor.stop();
1759
- }
1760
- catch {
1761
- /* best-effort */
1762
- }
1763
- QueueMonitor.start(comfyuiUrl);
1764
- void refreshEnvCapabilities();
1814
+ // The heavy retarget work (QueueMonitor restart, agent MCP-env respawn,
1815
+ // env-capability re-probe) runs in the onComfyuiTargetChanged listener
1816
+ // fired by setComfyuiTarget above — ONE fan-out for every retarget path
1817
+ // (hello, runpod tools, watcher auto-stop/vanish), so they can't drift
1818
+ // split-brained again (#269).
1765
1819
  logger.info(`[panel-orchestrator] retargeted ComfyUI ${prev} → ${comfyuiUrl} (${isLoopbackUrl(next) ? "local" : "remote"} mode) from panel hello`);
1766
1820
  // Advertising itself happens unconditionally on every hello (see below) —
1767
1821
  // a retarget doesn't need its own advertise call here.
@@ -2028,8 +2082,23 @@ export async function runPanelOrchestrator() {
2028
2082
  if (event.type === "hello" && event.tab_id) {
2029
2083
  const panelTab = event.tab_id;
2030
2084
  // Retarget ComfyUI to the URL the browser was served from (window.location),
2031
- // BEFORE the readiness probe so the "ready" ack reflects the right instance.
2032
- applyComfyuiUrl(event.comfyui_url);
2085
+ // BEFORE the readiness probe so the "ready" ack reflects the right instance —
2086
+ // but a hello can arrive from a STALE browser tab on a DEAD instance (E2E
2087
+ // finding: a zombie :8189 tab kept retargeting the orchestrator to a corpse
2088
+ // and silently breaking every tool that probes the target). Probe
2089
+ // loopback/LAN hellos first; RunPod proxies skip the probe (booting pods
2090
+ // answer late — readiness is the connector's job).
2091
+ const helloUrl = event.comfyui_url;
2092
+ void (async () => {
2093
+ if (typeof helloUrl === "string" && !/\.proxy\.runpod\.net/i.test(helloUrl)) {
2094
+ const base = helloUrl.trim().replace(/\/+$/, "");
2095
+ if (base && !(await probeOk(`${base}/system_stats`, 3_000))) {
2096
+ logger.warn(`[panel-orchestrator] ignoring hello retarget to unreachable ${base} (stale tab on a dead instance?) — keeping ${comfyuiUrl}`);
2097
+ return;
2098
+ }
2099
+ }
2100
+ applyComfyuiUrl(helloUrl);
2101
+ })();
2033
2102
  // Re-advertise the secure bridge on EVERY hello, not just when the URL
2034
2103
  // changes: advertiseBridge's own retries are short (~3s) and can race a
2035
2104
  // pod-side ComfyUI restart, permanently leaving the pod's stored bridge
@@ -2151,8 +2220,12 @@ export async function runPanelOrchestrator() {
2151
2220
  const rpFrame = getRunpodWatcher()?.current();
2152
2221
  if (rpFrame)
2153
2222
  bridge.push(rpFrame, panelTab);
2223
+ // Seed any failed auto-connect warnings too — they live SEPARATELY from
2224
+ // the watched frame so this tab sees every still-billing failure (codex).
2225
+ for (const f of getRunpodWatcher()?.failedFrames() ?? [])
2226
+ bridge.push(f, panelTab);
2154
2227
  // Seed the honest host indicator: tell this tab where renders run now.
2155
- bridge.push({ type: "comfyui_target", url: getComfyUIBaseUrl(), is_local: isTargetingLocal() }, panelTab);
2228
+ bridge.push({ type: "comfyui_target", url: getComfyUIBaseUrl(), is_local: isTargetingLocalOrLan() }, panelTab);
2156
2229
  // Re-push the last usage so the context meter isn't blank after a reload.
2157
2230
  const lastStatus = manager.lastStatusFor(key);
2158
2231
  if (lastStatus)
@@ -3047,6 +3120,84 @@ export async function runPanelOrchestrator() {
3047
3120
  // a downloading row that stops updating for 60s is treated as a dead writer.
3048
3121
  const DOWNLOAD_LINGER_MS = 8000;
3049
3122
  const downloadRemoveAt = new Map();
3123
+ /** Boolean URL probe with a timeout — readiness checks for pending pod connects. */
3124
+ const probeOk = async (url, timeoutMs = 8_000) => {
3125
+ const ctl = new AbortController();
3126
+ const t = setTimeout(() => ctl.abort(), timeoutMs);
3127
+ try {
3128
+ // Carry configured auth (COMFYUI_AUTH_TOKEN / custom header) — a
3129
+ // protected pod's ComfyUI 401s otherwise and connect:true always times
3130
+ // out (codex finding).
3131
+ const res = await fetch(url, { signal: ctl.signal, headers: getComfyUIAuthHeaders() });
3132
+ return res.ok;
3133
+ }
3134
+ catch {
3135
+ return false;
3136
+ }
3137
+ finally {
3138
+ clearTimeout(t);
3139
+ }
3140
+ };
3141
+ // Genuinely-in-flight download rows (set by pollDownloads) — read by the pod
3142
+ // idle-stop veto, SCOPED per pod via each row's stamped target (#269).
3143
+ let downloadingRows = [];
3144
+ // create-with-connect pending boot: per-pod pending connects (a second
3145
+ // create(connect:true) must not silently displace the first's promise —
3146
+ // codex finding: the displaced pod kept billing with no deadline, no
3147
+ // notification, and no auto-stop). Any deliberate target event clears ALL
3148
+ // of them (the user chose something else) — EXCEPT a readiness-driven
3149
+ // completion, which would otherwise wipe its siblings' promises (codex):
3150
+ // machineRetargetInFlight distinguishes machine- from user-driven retargets.
3151
+ // PERSISTED (user-private config dir): an orchestrator self-restart mid-boot
3152
+ // must not strand a promised auto-connect (codex finding — the replacement
3153
+ // process gets a fresh progress dir, so in-memory state alone is lost).
3154
+ const pendingPodConnects = new Map();
3155
+ let machineRetargetInFlight = false;
3156
+ const pendingConnectsFile = join(homedir(), ".comfyui-mcp", `runpod-pending-connects-${bridgePort}.json`);
3157
+ // Port-scope the saved-target file too, and re-restore for a pod boot (the
3158
+ // module-init read ran unscoped at import — codex finding).
3159
+ rescopeLocalTargetFile(join(homedir(), ".comfyui-mcp", `local-target-${bridgePort}.json`));
3160
+ const persistPendingConnects = () => {
3161
+ try {
3162
+ if (pendingPodConnects.size === 0) {
3163
+ unlinkSync(pendingConnectsFile);
3164
+ return;
3165
+ }
3166
+ mkdirSync(dirname(pendingConnectsFile), { recursive: true });
3167
+ writeFileSync(pendingConnectsFile, JSON.stringify(Object.fromEntries([...pendingPodConnects].map(([k, v]) => [k, { url: v.url, deadline: v.deadline }]))));
3168
+ }
3169
+ catch {
3170
+ /* best-effort */
3171
+ }
3172
+ };
3173
+ // Restore promises made before a restart; an already-past deadline fires the
3174
+ // honest timeout on the first poll tick. RESTORE on a pod boot OR any
3175
+ // self-restart generation (the common create(connect:true) case restarts
3176
+ // with the target still LOCAL mid-wait — codex finding: requiring a pod
3177
+ // target deleted the promise on the normal self-restart). A deliberate
3178
+ // fresh non-pod boot (gen 0) invalidates old pendings instead.
3179
+ try {
3180
+ const restartGen = Number(process.env.COMFYUI_MCP_RESTART_GEN ?? "0");
3181
+ if (!isTargetingPod() && !(restartGen > 0)) {
3182
+ if (existsSync(pendingConnectsFile)) {
3183
+ logger.info("[panel-orchestrator] discarding saved pending pod auto-connect(s) — this boot selected a non-pod target explicitly");
3184
+ unlinkSync(pendingConnectsFile);
3185
+ }
3186
+ }
3187
+ else {
3188
+ const saved = JSON.parse(readFileSync(pendingConnectsFile, "utf-8"));
3189
+ for (const [podId, v] of Object.entries(saved)) {
3190
+ if (typeof v?.url === "string" && typeof v?.deadline === "number") {
3191
+ pendingPodConnects.set(podId, { url: v.url, deadline: v.deadline, lastProbe: 0 });
3192
+ }
3193
+ }
3194
+ if (pendingPodConnects.size > 0)
3195
+ logger.info(`[panel-orchestrator] restored ${pendingPodConnects.size} pending pod auto-connect(s) from before the restart`);
3196
+ }
3197
+ }
3198
+ catch {
3199
+ // no saved state
3200
+ }
3050
3201
  let lastDownloadSnapshot = "[]";
3051
3202
  const pollDownloads = () => {
3052
3203
  let files = [];
@@ -3059,6 +3210,8 @@ export async function runPanelOrchestrator() {
3059
3210
  const now = Date.now();
3060
3211
  const downloads = [];
3061
3212
  for (const f of files) {
3213
+ if (f.startsWith(CONTROL_PREFIX))
3214
+ continue; // control channel, not a download row
3062
3215
  const full = join(progressDir, f);
3063
3216
  let row;
3064
3217
  try {
@@ -3097,12 +3250,222 @@ export async function runPanelOrchestrator() {
3097
3250
  }
3098
3251
  downloads.push(row);
3099
3252
  }
3253
+ // Live-download rows for the idle-stop veto (#269): rows surviving the loop
3254
+ // above are fresh (dead writers >60s are already unlinked), so a
3255
+ // "downloading" row here is genuinely in flight — and a pod mid-download
3256
+ // must not count as idle. Self-healing: a crashed writer's row ages out.
3257
+ downloadingRows = downloads.filter((d) => d.status === "downloading");
3100
3258
  downloads.sort((a, b) => String(a.name ?? "").localeCompare(String(b.name ?? "")));
3101
3259
  const snapshot = JSON.stringify(downloads);
3102
3260
  if (snapshot !== lastDownloadSnapshot) {
3103
3261
  lastDownloadSnapshot = snapshot;
3104
3262
  bridge.push({ type: "download_progress", downloads }); // broadcast to all tabs
3105
3263
  }
3264
+ // MCP-child control channel (#269): runpod_* tools that ran in spawned
3265
+ // agent children ask the orchestrator to retarget / watch / unwatch /
3266
+ // auto-connect here — through the SAME applyComfyuiUrl fan-out as a panel
3267
+ // hello. Each request is its own file: consumption deletes exactly the
3268
+ // file read, so concurrent children can't clobber each other (codex).
3269
+ try {
3270
+ for (const { req, file } of listTargetChangeRequests(progressDir)) {
3271
+ // Apply EVERY request — per-request files make the old timestamp
3272
+ // watermark both unnecessary and wrong (two same-millisecond requests
3273
+ // — e.g. create's watch + auto-connect — would drop the second, codex
3274
+ // finding). A crash between apply and delete replays an idempotent op
3275
+ // once on restart; a dropped auto-connect bills an unwatched pod.
3276
+ // A `local` request resolves the fallback HERE — the orchestrator owns
3277
+ // the learned-LAN memory; the requesting child (spawned post-connect)
3278
+ // may know nothing but loopback (codex finding). `onlyIfTarget` guards
3279
+ // it: a stale child's stop-fallback applies only when the CURRENT
3280
+ // target really is that pod (codex finding — it dragged the target
3281
+ // off a newer pod). The ack below reports the resulting URL either
3282
+ // way, so the stale child ALIGNS to it.
3283
+ const localGuardOk = !req.onlyIfTarget || getComfyUIBaseUrl().includes(req.onlyIfTarget);
3284
+ // Generation guard for URL retargets: drop the retarget when a NEWER
3285
+ // direct choice moved the target after the child wrote this (codex
3286
+ // finding: a queued pod-A request applied after the user picked pod B).
3287
+ const urlGenOk = !req.expectedCurrentUrl || canonTargetUrl(getComfyUIBaseUrl()) === canonTargetUrl(req.expectedCurrentUrl);
3288
+ // Only an APPLIED target/unwatch choice supersedes pending auto-connects
3289
+ // (watch-ONLY isn't a choice; a guarded-OUT or generation-STALE request
3290
+ // applied nothing and must not drop a booting pod's promise — codex
3291
+ // findings on both axes).
3292
+ const appliedChoice = urlGenOk && (!!req.url || ((!!req.local || !!req.unwatch) && localGuardOk));
3293
+ if (!req.connectWhenReady && appliedChoice) {
3294
+ pendingPodConnects.clear();
3295
+ persistPendingConnects();
3296
+ }
3297
+ if (req.local && localGuardOk && urlGenOk)
3298
+ applyComfyuiUrl(getLocalComfyuiUrl());
3299
+ else if (req.url && urlGenOk)
3300
+ applyComfyuiUrl(req.url); // dropped when a newer choice superseded it
3301
+ if (req.unwatch && localGuardOk && urlGenOk) {
3302
+ // Scoped for stop-fallbacks: only the stopped pod's own watch dies
3303
+ // (codex finding: stopping A killed unrelated watched B).
3304
+ if (req.unwatchPodId) {
3305
+ const w = getRunpodWatcher();
3306
+ if (w?.watchedPodId() === req.unwatchPodId)
3307
+ w.unwatch();
3308
+ }
3309
+ else {
3310
+ getRunpodWatcher()?.unwatch();
3311
+ }
3312
+ }
3313
+ // A confirmed stop clears the pod's recorded auto-connect failure —
3314
+ // including the spawned-child case, where the caller had no watcher
3315
+ // of its own (codex finding: stopped pods kept "billing" forever).
3316
+ // And it CANCELS any pending auto-connect for it — otherwise the
3317
+ // readiness loop could still retarget to a just-stopped pod, or warn
3318
+ // about a timeout for a pod the user deliberately stopped (codex).
3319
+ if (req.stoppedPodId) {
3320
+ getRunpodWatcher()?.clearConnectFailed(req.stoppedPodId);
3321
+ if (pendingPodConnects.delete(req.stoppedPodId))
3322
+ persistPendingConnects();
3323
+ }
3324
+ if (req.watchPodId) {
3325
+ // Boot-status arm for a create: never displace the ACTIVE render
3326
+ // target's watch — its idle auto-stop / dead-target cleanup is the
3327
+ // billing guard (codex finding: creating pod B while rendering on
3328
+ // pod A left A unguarded and billing indefinitely). The new pod gets
3329
+ // watched when its pending connect completes (it becomes the target).
3330
+ const w = getRunpodWatcher();
3331
+ const cur = w?.watchedPodId();
3332
+ const curIsActiveTarget = !!cur && !isTargetingLocal() && getComfyUIBaseUrl().includes(cur);
3333
+ if (!(curIsActiveTarget && cur !== req.watchPodId))
3334
+ w?.watch(req.watchPodId);
3335
+ }
3336
+ // Whether the watch request actually LANDED (the guard above may have
3337
+ // refused it) — computed AFTER the attempt; the ack carries it so the
3338
+ // child's create result can't claim a watch we rejected (codex finding).
3339
+ const watchApplied = !!req.watchPodId && getRunpodWatcher()?.watchedPodId() === req.watchPodId;
3340
+ // Whether a URL retarget landed (generation-guarded): the ack must
3341
+ // confirm the AUTHORITATIVE retarget before the child reports success
3342
+ // (codex finding: rejected writes still read "connected").
3343
+ const connectApplied = !!req.url && urlGenOk && canonTargetUrl(getComfyUIBaseUrl()) === canonTargetUrl(req.url);
3344
+ if (req.connectWhenReady && urlGenOk) {
3345
+ // The ORCHESTRATOR waits for boot (the tool call returned inside the
3346
+ // MCP 60s lifetime): probe every ~10s, retarget+watch on ready, and
3347
+ // report honestly on deadline — never block the MCP child (codex).
3348
+ // Per-pod slot: concurrent creates each keep their own deadline.
3349
+ // Generation-guarded: a stale registration must not re-arm after the
3350
+ // user already chose a newer target (codex finding).
3351
+ pendingPodConnects.set(req.connectWhenReady.podId, {
3352
+ url: req.connectWhenReady.url,
3353
+ deadline: Date.now() + 8 * 60_000,
3354
+ lastProbe: 0,
3355
+ });
3356
+ persistPendingConnects();
3357
+ }
3358
+ consumeTargetChange(file);
3359
+ // Ack with the RESULTING target ONLY when the requester is waiting —
3360
+ // fire-and-forget requests would leak ack files (codex finding). The
3361
+ // `applied` flag distinguishes an applied local switch from a guarded
3362
+ // skip (onlyIfTarget didn't match — codex finding).
3363
+ if (req.wantAck)
3364
+ ackTargetChange(file, getComfyUIBaseUrl(), req.onlyIfTarget ? localGuardOk && urlGenOk : req.url ? connectApplied : req.connectWhenReady ? urlGenOk : req.watchPodId ? watchApplied : true);
3365
+ }
3366
+ }
3367
+ catch {
3368
+ /* best-effort — the next tick retries a partially-written file */
3369
+ }
3370
+ // Pending create-and-connects: probe each until its pod's ComfyUI answers
3371
+ // BOTH readiness endpoints, then retarget through the shared fan-out.
3372
+ const reportConnectFailed = async (podId, supersededBy) => {
3373
+ // HONEST failure frame (codex finding: fabricated watching:true/RUNNING
3374
+ // read as a healthy pod while it bills unguarded). Fetch the real state;
3375
+ // `watching` reflects whether the single watcher actually follows it.
3376
+ let status = "UNKNOWN"; // unconfirmed — never present a terminal state we didn't verify (codex)
3377
+ let name = null;
3378
+ let gpu = null;
3379
+ let costPerHr = null;
3380
+ let uptime = null;
3381
+ try {
3382
+ const pod = await getPod(podId);
3383
+ status = pod?.desiredStatus ?? "TERMINATED";
3384
+ name = pod?.name ?? null;
3385
+ gpu = pod?.machine?.gpuDisplayName ?? null;
3386
+ costPerHr = pod?.costPerHr ?? null;
3387
+ uptime = pod?.runtime?.uptimeInSeconds ?? null;
3388
+ }
3389
+ catch { /* keep fallbacks */ }
3390
+ // RECHECK before installing: a manual connect/stop during the getPod
3391
+ // await already resolved this — installing now would resurrect a stale
3392
+ // alert on a healthy target (codex finding).
3393
+ if (!isTargetingLocal() && getComfyUIBaseUrl().includes(podId))
3394
+ return;
3395
+ // Suppress the alert ONLY for proven terminal/gone states — a booting
3396
+ // (CREATED/RESTARTING) or unverifiable (UNKNOWN) pod may still be billing
3397
+ // and keeps its warning (codex finding).
3398
+ if (status === "EXITED" || status === "TERMINATED" || status === "DEAD" || status === "PAUSED")
3399
+ return;
3400
+ const frame = {
3401
+ type: "runpod_alert",
3402
+ pod_id: podId,
3403
+ reason: supersededBy ? "superseded" : "timeout",
3404
+ status,
3405
+ name,
3406
+ gpu,
3407
+ cost_per_hr: costPerHr,
3408
+ uptime_seconds: uptime,
3409
+ ...(supersededBy ? { superseded_by: supersededBy } : {}),
3410
+ };
3411
+ // Route through the watcher so the failure STICKS (seeds to new tabs and
3412
+ // rides later frames until the pod exits — codex finding: a one-shot
3413
+ // push lets the only warning evaporate on the next routine poll). The
3414
+ // alert channel can't clobber the watched pod's status slot (codex).
3415
+ const w = getRunpodWatcher();
3416
+ if (w)
3417
+ w.markConnectFailed(podId, frame);
3418
+ else
3419
+ void bridge.push(frame);
3420
+ };
3421
+ for (const [podId, p] of pendingPodConnects) {
3422
+ if (Date.now() > p.deadline) {
3423
+ pendingPodConnects.delete(podId);
3424
+ persistPendingConnects();
3425
+ logger.warn(`[panel-orchestrator] pod ${podId} was not ready within 8 minutes — NOT auto-connecting (connect manually with runpod_pod_connect)`);
3426
+ // The tool call is long gone and idle auto-stop can't fire on a pod we
3427
+ // never connected to (renderingOnPod is false) — the failed pod keeps
3428
+ // billing with no visible failure unless we say so (codex finding).
3429
+ void reportConnectFailed(podId);
3430
+ continue;
3431
+ }
3432
+ if (Date.now() - p.lastProbe >= 10_000) {
3433
+ p.lastProbe = Date.now();
3434
+ void (async () => {
3435
+ const stats = await probeOk(`${p.url}/system_stats`);
3436
+ const queue = stats ? await probeOk(`${p.url}/queue`) : false;
3437
+ if (!pendingPodConnects.has(podId) || pendingPodConnects.get(podId) !== p)
3438
+ return; // superseded/cleared
3439
+ if (stats && queue) {
3440
+ pendingPodConnects.delete(podId);
3441
+ persistPendingConnects();
3442
+ // A readiness-driven retarget — NOT a user override: siblings'
3443
+ // pending connects stay alive through the listener (codex).
3444
+ machineRetargetInFlight = true;
3445
+ try {
3446
+ applyComfyuiUrl(p.url);
3447
+ }
3448
+ finally {
3449
+ machineRetargetInFlight = false;
3450
+ }
3451
+ // ONE winner: a second readiness would displace this pod's watch
3452
+ // (its idle-stop guard), so competing pending connects are resolved
3453
+ // as SUPERSEDED — with an HONEST frame: the loser is UNWATCHED
3454
+ // (nothing guards its cost) and connect_failed says why (codex).
3455
+ for (const [otherId, other] of pendingPodConnects) {
3456
+ pendingPodConnects.delete(otherId);
3457
+ persistPendingConnects();
3458
+ logger.warn(`[panel-orchestrator] pod ${otherId} auto-connect superseded by pod ${podId} (one active target) — it is UNWATCHED and still billing; stop it to end billing if unused`);
3459
+ void reportConnectFailed(otherId, podId);
3460
+ void other;
3461
+ }
3462
+ getRunpodWatcher()?.watch(podId);
3463
+ logger.info(`[panel-orchestrator] pod ${podId} ready — auto-connected (${p.url})`);
3464
+ void bridge.push({ type: "runpod_connected", pod_id: podId, url: p.url });
3465
+ }
3466
+ })();
3467
+ }
3468
+ }
3106
3469
  };
3107
3470
  const downloadTimer = setInterval(pollDownloads, 700);
3108
3471
  downloadTimer.unref?.();
@@ -3135,6 +3498,9 @@ export async function runPanelOrchestrator() {
3135
3498
  })();
3136
3499
  initRunpodWatcher({
3137
3500
  push: (frame) => void bridge.push(frame),
3501
+ // Persist unresolved connect-failure alerts per port (restart-proof — a
3502
+ // self-restart must not lose a still-billing warning, codex finding).
3503
+ persistPath: join(homedir(), ".comfyui-mcp", `runpod-connect-failures-${bridgePort}.json`),
3138
3504
  comfyuiIdle: (podId) => {
3139
3505
  const s = QueueMonitor.snapshot();
3140
3506
  // NOT idle while a training job is alive on THIS pod: training isn't a
@@ -3142,16 +3508,53 @@ export async function runPanelOrchestrator() {
3142
3508
  // run "idle" and auto-stop the pod mid-flight (P4 guard; review finding
3143
3509
  // on the connector). hasActiveTrainingJob is a probe-free file scan,
3144
3510
  // scoped to the watched pod so a run on another pod doesn't suppress
3145
- // this pod's idle-stop (codex #274).
3146
- return s.connected && !s.running && s.queueDepth === 0 && !hasActiveTrainingJob("pod", podId);
3511
+ // this pod's idle-stop (codex #274). Also NOT idle while THIS POD is
3512
+ // downloading — rows are target-stamped by their writer (#269; an
3513
+ // unstamped pre-fix row errs toward "busy", the cost-safe direction).
3514
+ const podDownloading = downloadingRows.some((d) => {
3515
+ const t = typeof d.target === "string" ? d.target : "";
3516
+ return t === "" || t.includes(podId);
3517
+ });
3518
+ return s.connected && !s.running && s.queueDepth === 0 && !podDownloading && !hasActiveTrainingJob("pod", podId);
3147
3519
  },
3148
3520
  // Idle auto-stop only applies to a pod we're actually rendering on: the active
3149
3521
  // ComfyUI target is that pod's proxy (its id appears in the URL). A pod we
3150
3522
  // merely watch while it boots stays local-targeted, so this is false and it is
3151
3523
  // never auto-stopped on the local rig's idleness.
3152
3524
  renderingOnPod: (podId) => !isTargetingLocal() && getComfyUIBaseUrl().includes(podId),
3525
+ // The watched pod vanished or was auto-stopped (#269 dead-target cleanup):
3526
+ // when renders were pointing AT it, fall back to the local target — via
3527
+ // setComfyuiTarget, so the shared retarget fan-out (QueueMonitor, agents,
3528
+ // frame) runs too. Not every watched pod is the render target, so guard.
3529
+ onPodUnavailable: (goneId) => {
3530
+ if (isTargetingLocal() || !getComfyUIBaseUrl().includes(goneId))
3531
+ return;
3532
+ const local = getLocalComfyuiUrl();
3533
+ logger.warn(`[panel-orchestrator] pod ${goneId} unavailable while targeted — retargeting local ComfyUI (${local})`);
3534
+ // A MACHINE-driven fallback (auto-stop/vanish), not a user override:
3535
+ // pending create-and-connects on OTHER pods must survive it — otherwise
3536
+ // a booting pod silently loses its promised auto-connect (codex finding).
3537
+ machineRetargetInFlight = true;
3538
+ try {
3539
+ setComfyuiTarget(local);
3540
+ }
3541
+ finally {
3542
+ machineRetargetInFlight = false;
3543
+ }
3544
+ resetClient();
3545
+ },
3153
3546
  idleStopMinutes: runpodIdleStopMinutes,
3154
3547
  });
3548
+ // Boot re-watch (#269 r2): a restart rebuilds the watcher EMPTY — a pod that
3549
+ // stayed the ACTIVE target across the restart (its proxy URL is still the
3550
+ // configured target) would otherwise get no more heartbeats and self-stop
3551
+ // ~20min later even though renders keep flowing. Re-watch it immediately:
3552
+ // beats + live status resume, and its dead-man watchdog stays fed.
3553
+ const bootPodMatch = getComfyUIBaseUrl().match(/^https:\/\/([a-z0-9]+)-\d+\.proxy\.runpod\.net/i);
3554
+ if (bootPodMatch) {
3555
+ logger.info(`[panel-orchestrator] re-watching active RunPod target ${bootPodMatch[1]} after restart`);
3556
+ getRunpodWatcher()?.watch(bootPodMatch[1]);
3557
+ }
3155
3558
  // Money guard (codex #263): the idle predicate above trusts persisted
3156
3559
  // training records blindly (hasActiveTrainingJob is a probe-free file scan),
3157
3560
  // and owner-death reconciliation otherwise only runs via getJob/listJobs —
@@ -3170,12 +3573,52 @@ export async function runPanelOrchestrator() {
3170
3573
  });
3171
3574
  }, 5 * 60_000);
3172
3575
  trainingReconcileTimer.unref?.();
3173
- // Honest host indicator: whenever the ComfyUI target moves (RunPod connect,
3174
- // pod stop → local fallback, "Local" switch), broadcast a `comfyui_target`
3175
- // frame so every control panel truthfully shows where renders run. Seeded per
3176
- // tab on connect (below) so a fresh tab knows the host without waiting for a
3177
- // switch.
3576
+ // Honest host indicator + RETARGET FAN-OUT: whenever the ComfyUI target moves
3577
+ // (RunPod connect, pod stop → local fallback, "Local" switch, panel hello),
3578
+ // setComfyuiTarget fires this ONE listener — it repoints everything that was
3579
+ // previously left split-brained (#269): QueueMonitor (its WS/polls keep
3580
+ // talking to the OLD host), the agent subprocesses' MCP env (respawned via
3581
+ // restartAllForMcpEnv), and the env-capability probe, then broadcasts the
3582
+ // `comfyui_target` frame so every panel truthfully shows where renders run.
3583
+ // Same-URL retargets (a repeated connect to the current target) skip the
3584
+ // restart storm and only re-broadcast. Seeded per tab on connect (below) so
3585
+ // a fresh tab knows the host without waiting for a switch.
3586
+ let lastRetargetUrl = comfyuiUrl; // seeded: a first same-URL event must not restart everything
3178
3587
  onComfyuiTargetChanged((url, isLocal) => {
3588
+ // ANY target event — a change OR a reaffirmation of the current target —
3589
+ // supersedes ALL pending auto-connects (codex findings: direct
3590
+ // setComfyuiTarget callers bypass applyComfyuiUrl, and an explicit
3591
+ // Local/Stop at the SAME target must not let a booting pod steal it back
3592
+ // later) — EXCEPT a readiness-driven completion, which must leave its
3593
+ // siblings' pending promises intact (codex finding).
3594
+ if (!machineRetargetInFlight) {
3595
+ pendingPodConnects.clear();
3596
+ persistPendingConnects();
3597
+ }
3598
+ if (url !== lastRetargetUrl) {
3599
+ lastRetargetUrl = url;
3600
+ // Sync the shared target closures FIRST: retargets that did NOT come
3601
+ // through applyComfyuiUrl (runpod tools, watcher callbacks) leave them
3602
+ // holding the OLD host — and buildMcpServers()/refreshEnvCapabilities()
3603
+ // below would rebuild/respawn agents against it (codex finding).
3604
+ comfyuiUrl = url;
3605
+ comfyuiPath = localPathForTarget(url);
3606
+ try {
3607
+ QueueMonitor.stop();
3608
+ }
3609
+ catch {
3610
+ /* best-effort */
3611
+ }
3612
+ QueueMonitor.start(url);
3613
+ manager.setMcpServers(buildMcpServers());
3614
+ manager.setComfyuiUrl(url);
3615
+ manager.restartAllForMcpEnv();
3616
+ void refreshEnvCapabilities();
3617
+ }
3618
+ // The readvertise timer follows the target too (created only at startup
3619
+ // before — a local→pod retarget left the pod unable to learn the bridge
3620
+ // URL, codex finding). Cheap no-op for same-url events.
3621
+ syncReadvertise(url);
3179
3622
  void bridge.push({ type: "comfyui_target", url, is_local: isLocal });
3180
3623
  });
3181
3624
  // Keep the pod's stored bridge URL fresh so a ComfyUI RESTART self-heals fast.
@@ -3190,15 +3633,104 @@ export async function runPanelOrchestrator() {
3190
3633
  // the advertise on a cheap idempotent timer repopulates the pod's store within
3191
3634
  // one interval of any reboot (from any cause: the agent's restart, a Manager
3192
3635
  // UI restart, a crash), so the browser's reclaim poll reconnects promptly.
3193
- // Only meaningful for a remote https target with a secure bridge.
3636
+ // Only meaningful for a remote https target with a secure bridge. Driven by
3637
+ // syncReadvertise at STARTUP and on every retarget (codex finding: a
3638
+ // local→pod retarget later left the timer nonexistent, so the pod could
3639
+ // never learn the WSS URL/token — the very deadlock it prevents). The
3640
+ // bridge itself is created LAZILY on the first remote target too (a local
3641
+ // boot has wantSecureBridge=false — codex finding).
3194
3642
  let readvertiseTimer = null;
3195
- if (secureBridge && isRemoteHttpsUrl(comfyuiUrl)) {
3196
- readvertiseTimer = setInterval(() => {
3197
- if (secureBridge && isRemoteHttpsUrl(comfyuiUrl))
3198
- void secureBridge.advertise(comfyuiUrl);
3199
- }, 5000);
3200
- readvertiseTimer.unref?.();
3201
- }
3643
+ // One in-flight setup MAX — concurrent ticks must not each start a tunnel
3644
+ // (codex finding: a slow first attempt spawned multiple cloudflared clients).
3645
+ let secureBridgeSetup = null;
3646
+ // A tokenless primary listener does NOT rule out pod panels: the tunnel gets
3647
+ // its OWN token-gated listener (same addListener mechanism the phone-pair
3648
+ // flow uses) on a dedicated port — never retrofit auth onto the public path
3649
+ // and never open a tunnel in front of an unauthenticated one (codex P1).
3650
+ // Port map: +0 bridge, +1 panel_* HTTP-MCP, +2 phone-pair, +3 console (line
3651
+ // ~1427), +4 tunnel listener (codex finding: +3 was already taken).
3652
+ const tunnelPort = lockPort + 4;
3653
+ let tunnelToken = null;
3654
+ let tunnelListenerStarted = false;
3655
+ const ensureSecureBridge = async (url) => {
3656
+ if (secureBridge)
3657
+ return;
3658
+ if (!secureBridgeSetup) {
3659
+ secureBridgeSetup = (async () => {
3660
+ try {
3661
+ let port = lockPort;
3662
+ let token;
3663
+ if (bridgeListenerTokenless) {
3664
+ // Primary listener stays tokenless for local use; the tunnel binds
3665
+ // its own token-gated listener (pair-flow precedent) and advertises
3666
+ // THAT token — enforced, because this listener was built with it.
3667
+ // Bound ONCE: a retried setup must reuse it, not EADDRINUSE (codex).
3668
+ tunnelToken ??= randomBytes(24).toString("hex");
3669
+ if (!tunnelListenerStarted) {
3670
+ await bridge.addListener("0.0.0.0", tunnelPort, tunnelToken);
3671
+ tunnelListenerStarted = true;
3672
+ }
3673
+ port = tunnelPort;
3674
+ token = tunnelToken;
3675
+ }
3676
+ else if (bridgeToken) {
3677
+ token = bridgeToken;
3678
+ }
3679
+ else {
3680
+ return; // unreachable — the tokenless branch above covers this
3681
+ }
3682
+ secureBridge = await setupSecureBridge({
3683
+ bridgePort: port,
3684
+ comfyuiUrl: url,
3685
+ token,
3686
+ bridge,
3687
+ // The setup itself advertises on completion — guard it too: a slow
3688
+ // tunnel must not hand the bridge URL to a pod the user already
3689
+ // left (codex finding: the post-await guard alone couldn't stop it).
3690
+ shouldAdvertise: (t) => comfyuiUrl === t && isRemoteHttpsUrl(t),
3691
+ });
3692
+ }
3693
+ catch (err) {
3694
+ logger.error(`[panel-orchestrator] secure bridge (cloudflared) failed: ${err instanceof Error ? err.message : String(err)}. ` +
3695
+ `Install cloudflared (npm i -g cloudflared), or re-run with --insecure-bridge and open the pod through an ` +
3696
+ `SSH tunnel (ssh -L 3000:localhost:3000 …) at http://localhost:3000.`);
3697
+ }
3698
+ finally {
3699
+ secureBridgeSetup = null;
3700
+ }
3701
+ })();
3702
+ }
3703
+ return secureBridgeSetup;
3704
+ };
3705
+ const syncReadvertise = (url) => {
3706
+ const wanted = !insecureBridge && isRemoteHttpsUrl(url);
3707
+ if (wanted && !readvertiseTimer) {
3708
+ // Immediate first advertise (creating the tunnel on a local→pod
3709
+ // transition), then the interval.
3710
+ void (async () => {
3711
+ await ensureSecureBridge(url);
3712
+ // The user may have moved on while the tunnel came up — advertising an
3713
+ // OLD pod now would let its panel hello steal the newer target (codex).
3714
+ if (secureBridge && comfyuiUrl === url && isRemoteHttpsUrl(url))
3715
+ void secureBridge.advertise(url);
3716
+ })();
3717
+ readvertiseTimer = setInterval(() => {
3718
+ void (async () => {
3719
+ if (isRemoteHttpsUrl(comfyuiUrl)) {
3720
+ await ensureSecureBridge(comfyuiUrl);
3721
+ if (secureBridge)
3722
+ void secureBridge.advertise(comfyuiUrl);
3723
+ }
3724
+ })();
3725
+ }, 5000);
3726
+ readvertiseTimer.unref?.();
3727
+ }
3728
+ else if (!wanted && readvertiseTimer) {
3729
+ clearInterval(readvertiseTimer);
3730
+ readvertiseTimer = null;
3731
+ }
3732
+ };
3733
+ syncReadvertise(comfyuiUrl);
3202
3734
  // The no-path suffix must not read as an error when it is BY DESIGN: for a
3203
3735
  // remote target a local path is the wrong filesystem and is deliberately
3204
3736
  // dropped — installs/downloads run host-side via ComfyUI-Manager (remote
@@ -3227,6 +3759,14 @@ export async function runPanelOrchestrator() {
3227
3759
  if (readvertiseTimer)
3228
3760
  clearInterval(readvertiseTimer);
3229
3761
  QueueMonitor.stop();
3762
+ // Remove OUR nonced progress dir (startup reaps previous lives') so the
3763
+ // auto-restart cycle doesn't accumulate temp dirs (codex finding).
3764
+ try {
3765
+ rmSync(progressDir, { recursive: true, force: true });
3766
+ }
3767
+ catch {
3768
+ /* best-effort */
3769
+ }
3230
3770
  unsubscribeSecrets();
3231
3771
  unsubscribeAgentSecrets();
3232
3772
  await manager.stopAll();