comfyui-mcp 0.44.0 → 0.46.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 (64) hide show
  1. package/README.md +2 -2
  2. package/dist/config.js +159 -11
  3. package/dist/config.js.map +1 -1
  4. package/dist/orchestrator/ai-proposer.js +5 -0
  5. package/dist/orchestrator/ai-proposer.js.map +1 -1
  6. package/dist/orchestrator/antigravity-backend.js +394 -52
  7. package/dist/orchestrator/antigravity-backend.js.map +1 -1
  8. package/dist/orchestrator/claude-backend.js +13 -0
  9. package/dist/orchestrator/claude-backend.js.map +1 -1
  10. package/dist/orchestrator/codex-backend.js +12 -1
  11. package/dist/orchestrator/codex-backend.js.map +1 -1
  12. package/dist/orchestrator/gemini-backend.js +8 -1
  13. package/dist/orchestrator/gemini-backend.js.map +1 -1
  14. package/dist/orchestrator/grok-backend.js +9 -5
  15. package/dist/orchestrator/grok-backend.js.map +1 -1
  16. package/dist/orchestrator/index.js +603 -50
  17. package/dist/orchestrator/index.js.map +1 -1
  18. package/dist/orchestrator/panel-agent.js +21 -5
  19. package/dist/orchestrator/panel-agent.js.map +1 -1
  20. package/dist/orchestrator/panel-console-http.js +1 -0
  21. package/dist/orchestrator/panel-console-http.js.map +1 -1
  22. package/dist/orchestrator/panel-tools.js +140 -9
  23. package/dist/orchestrator/panel-tools.js.map +1 -1
  24. package/dist/services/ai-toolkit.js +260 -3
  25. package/dist/services/ai-toolkit.js.map +1 -1
  26. package/dist/services/download-progress.js +177 -7
  27. package/dist/services/download-progress.js.map +1 -1
  28. package/dist/services/missing-models.js +295 -0
  29. package/dist/services/missing-models.js.map +1 -0
  30. package/dist/services/openai-provider-registry.js +20 -1
  31. package/dist/services/openai-provider-registry.js.map +1 -1
  32. package/dist/services/panel-secrets.js +58 -7
  33. package/dist/services/panel-secrets.js.map +1 -1
  34. package/dist/services/process-control.js +154 -4
  35. package/dist/services/process-control.js.map +1 -1
  36. package/dist/services/runpod-client.js +157 -9
  37. package/dist/services/runpod-client.js.map +1 -1
  38. package/dist/services/runpod-ssh.js +594 -0
  39. package/dist/services/runpod-ssh.js.map +1 -0
  40. package/dist/services/runpod-watch.js +192 -13
  41. package/dist/services/runpod-watch.js.map +1 -1
  42. package/dist/services/secure-bridge.js +22 -6
  43. package/dist/services/secure-bridge.js.map +1 -1
  44. package/dist/services/trainer-bootstrap.js +142 -0
  45. package/dist/services/trainer-bootstrap.js.map +1 -0
  46. package/dist/services/training-config.js +4 -1
  47. package/dist/services/training-config.js.map +1 -1
  48. package/dist/services/training-jobs.js +1332 -253
  49. package/dist/services/training-jobs.js.map +1 -1
  50. package/dist/tools/apps.js +222 -0
  51. package/dist/tools/apps.js.map +1 -0
  52. package/dist/tools/index.js +4 -0
  53. package/dist/tools/index.js.map +1 -1
  54. package/dist/tools/missing-models.js +151 -0
  55. package/dist/tools/missing-models.js.map +1 -0
  56. package/dist/tools/process-control.js +1 -1
  57. package/dist/tools/process-control.js.map +1 -1
  58. package/dist/tools/runpod.js +205 -17
  59. package/dist/tools/runpod.js.map +1 -1
  60. package/dist/tools/train.js +138 -5
  61. package/dist/tools/train.js.map +1 -1
  62. package/docker/trainer/Dockerfile +4 -2
  63. package/package.json +1 -1
  64. package/scripts/gen-tool-docs.ts +43 -4
@@ -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,9 @@ 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";
60
+ import { hasActiveTrainingJob, reconcileStaleTrainingJobs } from "../services/training-jobs.js";
58
61
  import { buildQueueStatusFrame, createQueueStatusBroadcaster, } from "../services/queue-status-broadcast.js";
59
62
  import { unloadAllOllama, warmOllama, resolveOllamaHost } from "../services/ollama-vram.js";
60
63
  import { isLocalLmstudio, startLmstudioServer, unloadAllLmstudio, warmLmstudio, } from "../services/lmstudio-lifecycle.js";
@@ -94,6 +97,8 @@ OPENING A STAGED / DOWNLOADED WORKFLOW. When you've saved or downloaded a workfl
94
97
 
95
98
  RESOLVING A TANGLED / TOGGLE-HEAVY WORKFLOW (Get/Set buses + rgthree-bypassed pipelines). Expert and community graphs are often thick with VIRTUAL WIRING — GetNode/SetNode buses and Reroutes that hide the real connections — and rgthree "Fast Groups Bypasser/Muter" TOGGLED PIPELINES (one graph holding several pipelines, only one active at a time). Do NOT hand-trace GetNode→SetNode links or guess which branches are live. To get the REAL wiring: call panel_strip_workflow(path:<file> | pack:<name> | graph:<json>) — it resolves Get/Set buses, Reroutes, subgraph definitions, and bypassed/muted nodes into REAL connections and returns the flat, runnable graph (read server-side, never shuttled through chat). If the file is a MULTI-PIPELINE monolith and you only want ONE pipeline, FIRST panel_slice_workflow(path:<file>, groups:[<group-title substrings>]) to carve that pipeline into a standalone activated graph (it seeds from the output nodes in those groups, takes their backward closure through links + Set/Get buses, and un-bypasses the kept nodes), THEN panel_strip_workflow to flatten the buses. Reach for panel_strip_workflow whenever a graph is too tangled to read directly or you need to UNDERSTAND or REBUILD its actual wiring (e.g. a staged expert example full of GetNode/SetNode/Reroute); reach for panel_slice_workflow when an ULTRA-style monolith bundles several toggled pipelines and you want just one. (The same two tools exist as the MCP strip_workflow / slice_workflow for non-panel sessions.)
96
99
 
100
+ RECOMMENDING CIVITAI MODELS — SHOW, DON'T JUST TELL. When the user asks about or you're recommending specific CivitAI resources (a "good relight LoRA?", "which Flux checkpoint?", "find me an anime style"), LEAN TOWARD opening the docked CivitAI browser and highlighting your picks rather than answering with only a text table. Flow: panel_open_civitai (docked, matched query/tab/filters) → panel_civitai_search to refine → panel_civitai_results to read the metadata + URLs → panel_civitai_highlight the one(s) you recommend, with a BRIEF text summary of why each fits. This docks beside the chat so both stay visible, and lets the user SEE the actual cards. (Note: you read metadata + URLs only, not the images.) It's a nudge, not a mandate — a quick factual answer or a resource the user already named is fine as text; reach for the browser when they're choosing between options or exploring.
101
+
97
102
  DOWNLOADING MODELS — use the download_model tool, NOT a raw shell download. When a workflow needs model weights you don't have (checkpoints, LoRAs, VAEs, text encoders, etc.), download them with the comfyui MCP download_model tool (or download_civitai_model for CivitAI): it streams the file into the correct ComfyUI models/ subfolder AND surfaces live progress in the panel's download tray so the user can watch it. Pass target_subfolder to land the file exactly where it belongs (e.g. 'loras', 'checkpoints', 'vae', 'text_encoders', or a nested path like 'loras/<subdir>'). Do NOT shell out to curl/wget/aria2 for model files — a raw shell download has no progress in the panel and can drop the file in the wrong place. Reserve the shell for things download_model can't do.
98
103
 
99
104
  HARDWARE & RUNTIME STATS — use the MCP tools, NOT the shell. For GPU / VRAM / CPU / RAM, CUDA/torch/python versions, and ComfyUI runtime stats, call the comfyui MCP get_system_stats (raw /system_stats) or get_environment (a summarized snapshot) — they read the CONNECTED ComfyUI's /system_stats and work for LOCAL and REMOTE targets alike. Do NOT shell out (nvidia-smi, PowerShell, wmic, python) for hardware info: the managed shell is sandboxed/read-only and rejects multi-line scripts, so those probes fail and only reach the orchestrator host anyway, not a remote ComfyUI. The startup ENVIRONMENT line already summarizes the machine; when you need current or more detail, get_system_stats / get_environment are the source of truth.
@@ -602,21 +607,37 @@ const CALL_TOOL_WHITELIST = new Set([
602
607
  "train_start",
603
608
  "train_cancel",
604
609
  // RunPod control panel (desktop + mobile): the one-tap pod lifecycle + the
605
- // local⇄pod host switch. Read-only status/list/troubleshoot plus the
606
- // user-initiated lifecycle actions (create/start/stop/connect/use_local/
607
- // watch/unwatch) and the referral deploy link. Each tool validates its own
608
- // pod state; the whitelist only gates reachability from a canvas-less client.
610
+ // local⇄pod host switch. Read-only status/list/troubleshoot, the COST-SAVING
611
+ // actions (stop/use_local), connect (retarget only — a pod must already be
612
+ // RUNNING, so it neither spins nor keeps one billing), watch/unwatch, and the
613
+ // referral deploy link. Each tool validates its own pod state; the whitelist
614
+ // only gates reachability from a canvas-less client.
615
+ // NOTE: runpod_pod_create AND runpod_pod_start are deliberately EXCLUDED
616
+ // (#269/#278) — both put a pod into a BILLING state (create deploys; start
617
+ // RESUMES billing on a stopped pod). A confirmation-less mirrored/foreign tab
618
+ // must not be able to spend money, so both go through an agent turn / explicit
619
+ // UI action. stop is kept (it SAVES money).
609
620
  "runpod_pod_status",
610
621
  "runpod_list_pods",
611
- "runpod_pod_start",
612
622
  "runpod_pod_stop",
613
- "runpod_pod_create",
614
623
  "runpod_pod_connect",
615
624
  "runpod_pod_troubleshoot",
616
625
  "runpod_use_local",
617
626
  "runpod_watch",
618
627
  "runpod_unwatch",
619
628
  "runpod_deploy_link",
629
+ // Micro-Apps (panel "Apps" feature): the canvas-less client's list/run/poll
630
+ // surface. Same risk posture as enqueue_workflow (already whitelisted): run
631
+ // queues a job the user explicitly tapped; list/get/status are read-only.
632
+ "apps_list",
633
+ "apps_get",
634
+ "apps_run",
635
+ "apps_run_status",
636
+ // Registry install (mobile Explore): the rig fetches the bundle from the
637
+ // public registry and imports it locally. Same risk as save_workflow +
638
+ // download_model (already whitelisted): writes a local app bundle, no
639
+ // model/system mutation. Deps install stays a separate consented action.
640
+ "apps_import",
620
641
  ]);
621
642
  /** Lazily build ONE in-process MCP client wired to the full comfyui tool surface,
622
643
  * reused across call_tool requests. Reuses the exact tool implementations (same
@@ -730,11 +751,19 @@ export async function runPanelOrchestrator() {
730
751
  const bridgeHost = (process.env.COMFYUI_MCP_BRIDGE_HOST ?? "127.0.0.1").trim() || "127.0.0.1";
731
752
  const lanBridge = !isLoopbackBindHost(bridgeHost);
732
753
  const envBridgeToken = process.env.COMFYUI_MCP_BRIDGE_TOKEN?.trim() || null;
733
- const bridgeToken = envBridgeToken ?? (wantSecureBridge || lanBridge ? randomBytes(24).toString("hex") : null);
754
+ // Provisioned eagerly for secure/LAN boots, LAZILY on the first remote
755
+ // retarget (a loopback boot must not permanently rule out the secure bridge
756
+ // the pod's HTTPS panel needs — codex finding).
757
+ let bridgeToken = envBridgeToken ?? (wantSecureBridge || lanBridge ? randomBytes(24).toString("hex") : null);
734
758
  // Dedicated PANEL bridge port (default 9180). Token-gated in secure/LAN mode.
735
759
  const lockPort = Number(process.env.COMFYUI_MCP_BRIDGE_PORT) || 9180;
736
760
  const lockPath = orchLockPath(lockPort);
737
761
  const bridge = startUiBridge(lockPort, bridgeToken, bridgeHost);
762
+ // The LISTENER'S auth was fixed at construction: a null boot token means a
763
+ // tokenless listener FOREVER — lazily provisioning a token later would
764
+ // advertise a tunnel whose token is not enforced (codex finding). The lazy
765
+ // secure-bridge path must refuse in that case, not expose it publicly.
766
+ const bridgeListenerTokenless = bridgeToken === null;
738
767
  // On-demand phone pairing (the panel "Remote control" button). Off by default:
739
768
  // the FIRST pair request lazily binds a SECOND, token-gated listener on all
740
769
  // interfaces (so a phone can reach it), while the primary loopback bridge stays
@@ -940,10 +969,32 @@ export async function runPanelOrchestrator() {
940
969
  `SSH tunnel (ssh -L 3000:localhost:3000 …) at http://localhost:3000.`);
941
970
  }
942
971
  }
943
- // Cross-process download-progress channel: each tab's comfyui MCP subprocess
944
- // writes per-download JSON here; the watcher below broadcasts it to the panel
945
- // tray. Port-scoped so parallel orchestrators don't cross streams.
946
- const progressDir = join(tmpdir(), `comfyui-mcp-progress-${bridgePort}`);
972
+ // Cross-process download-progress + control channel: each tab's comfyui MCP
973
+ // subprocess writes per-download JSON (and runpod_* target requests) here;
974
+ // the watcher below broadcasts downloads to the panel tray and applies
975
+ // control requests. Port-scoped so parallel orchestrators don't cross
976
+ // streams, NONCED + mode-0700 so another local user can't pre-create the
977
+ // predictable path and inject a hostile retarget (codex finding — the
978
+ // applied URL receives configured auth headers on later requests).
979
+ const progressNonce = randomBytes(12).toString("hex");
980
+ const progressDir = join(tmpdir(), `comfyui-mcp-progress-${bridgePort}-${progressNonce}`);
981
+ // Reap dirs from dead processes on this port (auto-restarts would otherwise
982
+ // accumulate them forever — codex finding). Same port ⇒ same orchestrator,
983
+ // so any other dir with the prefix belongs to a previous life.
984
+ try {
985
+ for (const d of readdirSync(tmpdir())) {
986
+ if (d.startsWith(`comfyui-mcp-progress-${bridgePort}-`) && join(tmpdir(), d) !== progressDir) {
987
+ rmSync(join(tmpdir(), d), { recursive: true, force: true });
988
+ }
989
+ }
990
+ }
991
+ catch {
992
+ /* best-effort */
993
+ }
994
+ mkdirSync(progressDir, { recursive: true, mode: 0o700 });
995
+ // Late-bind the channel dir so the control channel works IN-PROCESS too
996
+ // (direct/mobile tool calls — codex finding: it was dead without the env var).
997
+ setProgressDir(progressDir);
947
998
  // The bundled plugin (skills) ships alongside dist/ in the package root. Load
948
999
  // it so the background agents are ComfyUI experts out of the box.
949
1000
  const pluginPath = fileURLToPath(new URL("../../plugin", import.meta.url));
@@ -1710,6 +1761,22 @@ export async function runPanelOrchestrator() {
1710
1761
  // ComfyUI the user actually has open — local OR a RunPod proxy — with no
1711
1762
  // `connect <url>`. Loopback → LOCAL mode (keep COMFYUI_PATH); non-loopback →
1712
1763
  // REMOTE mode (drop the path). No-op if unchanged. Returns true if it retargeted.
1764
+ // Same-URL canonicalization, DEFAULT-PORT-INSENSITIVE but SCHEME-AWARE:
1765
+ // strip only the scheme's actual default (:443 for https, :80 for http) —
1766
+ // http://h:443 and http://h are NOT the same endpoint (codex finding).
1767
+ // Shared by applyComfyuiUrl's dedupe and the control-channel ack check
1768
+ // (runpodProxyUrl omits :443 while getComfyUIBaseUrl includes it — codex).
1769
+ const canonTargetUrl = (u) => {
1770
+ try {
1771
+ const p = new URL(u);
1772
+ if ((p.protocol === "https:" && p.port === "443") || (p.protocol === "http:" && p.port === "80"))
1773
+ p.port = "";
1774
+ return p.toString().replace(/\/+$/, "");
1775
+ }
1776
+ catch {
1777
+ return u.replace(/\/+$/, "");
1778
+ }
1779
+ };
1713
1780
  const applyComfyuiUrl = (rawUrl) => {
1714
1781
  if (typeof rawUrl !== "string")
1715
1782
  return false;
@@ -1726,7 +1793,7 @@ export async function runPanelOrchestrator() {
1726
1793
  catch {
1727
1794
  return false; // not a valid URL — ignore (keep current target)
1728
1795
  }
1729
- if (!host || next === comfyuiUrl)
1796
+ if (!host || canonTargetUrl(next) === canonTargetUrl(comfyuiUrl))
1730
1797
  return false;
1731
1798
  const prev = comfyuiUrl;
1732
1799
  comfyuiUrl = next;
@@ -1739,22 +1806,11 @@ export async function runPanelOrchestrator() {
1739
1806
  // forces getClient() to rebuild against the new host on its next use.
1740
1807
  setComfyuiTarget(next);
1741
1808
  resetClient();
1742
- // Point every provider at the new target: Claude via its rebuilt MCP env, the
1743
- // manager's image-fetch URL, then respawn active agents so the live comfyui MCP
1744
- // subprocess is recreated with the new COMFYUI_URL (no-op if none are running —
1745
- // the next spawn picks it up from the now-updated closures).
1746
- manager.setMcpServers(buildMcpServers());
1747
- manager.setComfyuiUrl(comfyuiUrl);
1748
- manager.restartAllForMcpEnv();
1749
- // Re-point the render watchdog and re-probe the env (remote vs local differs).
1750
- try {
1751
- QueueMonitor.stop();
1752
- }
1753
- catch {
1754
- /* best-effort */
1755
- }
1756
- QueueMonitor.start(comfyuiUrl);
1757
- void refreshEnvCapabilities();
1809
+ // The heavy retarget work (QueueMonitor restart, agent MCP-env respawn,
1810
+ // env-capability re-probe) runs in the onComfyuiTargetChanged listener
1811
+ // fired by setComfyuiTarget above — ONE fan-out for every retarget path
1812
+ // (hello, runpod tools, watcher auto-stop/vanish), so they can't drift
1813
+ // split-brained again (#269).
1758
1814
  logger.info(`[panel-orchestrator] retargeted ComfyUI ${prev} → ${comfyuiUrl} (${isLoopbackUrl(next) ? "local" : "remote"} mode) from panel hello`);
1759
1815
  // Advertising itself happens unconditionally on every hello (see below) —
1760
1816
  // a retarget doesn't need its own advertise call here.
@@ -1778,24 +1834,36 @@ export async function runPanelOrchestrator() {
1778
1834
  // the next probe uses the new key, and re-push readiness + models to every
1779
1835
  // live tab so the OpenRouter provider flips to "ready" and lists its models
1780
1836
  // without a reconnect.
1837
+ const KEYED_PROVIDERS = ["openrouter", "custom", "glm", "kimi", "moonshot"];
1781
1838
  const unsubscribeAgentSecrets = onAgentSecretsChanged(() => {
1782
1839
  hydrateAgentSecretsIntoEnv();
1783
1840
  // A key change can affect ANY keyed provider (OpenRouter/Custom endpoints and
1784
1841
  // the hosted API-key backends GLM / Kimi / Moonshot) — drop each one's cached
1785
1842
  // probe backend + model list so the next probe carries the fresh credentials
1786
1843
  // (and a revoked key immediately stops reading as "ready" from a stale cache).
1787
- for (const b of ["openrouter", "custom", "glm", "kimi", "moonshot"]) {
1844
+ for (const b of KEYED_PROVIDERS) {
1788
1845
  modelsByBackend.delete(b);
1789
1846
  const pb = probeBackends.get(b);
1790
1847
  if (pb?.close)
1791
1848
  void pb.close().catch(() => { });
1792
1849
  probeBackends.delete(b);
1793
1850
  }
1851
+ // Cache-drop alone does NOT rotate the key on a LIVE agent: keyed backends
1852
+ // capture the credential at construction and keep using the OLD key until
1853
+ // rebuilt (#278). So a live tab running a keyed provider must be restarted
1854
+ // for the new/revoked key to take effect — a SILENT rebuild at idle (no
1855
+ // download-retry nudge; the guard's job was only to stop the spurious nudge
1856
+ // + unrelated Claude/Codex restarts, not to stop key rotation).
1857
+ for (const [tabId, backend] of tabBackends.entries()) {
1858
+ if (KEYED_PROVIDERS.includes(backend)) {
1859
+ manager.restartForProviderKey(agentKeyFor(tabId));
1860
+ }
1861
+ }
1794
1862
  for (const tabId of tabBackends.keys()) {
1795
1863
  pushReadiness(tabId);
1796
1864
  pushModels(tabId);
1797
1865
  }
1798
- logger.info("[panel-orchestrator] provider key saved → readiness + models refreshed");
1866
+ logger.info("[panel-orchestrator] provider key saved → readiness + models refreshed + keyed agents rebuilt");
1799
1867
  });
1800
1868
  // Debounce the connect ack: the panel re-sends `hello` on reconnect and on
1801
1869
  // workflow-title changes, which would otherwise stack duplicate greetings.
@@ -2132,8 +2200,12 @@ export async function runPanelOrchestrator() {
2132
2200
  const rpFrame = getRunpodWatcher()?.current();
2133
2201
  if (rpFrame)
2134
2202
  bridge.push(rpFrame, panelTab);
2203
+ // Seed any failed auto-connect warnings too — they live SEPARATELY from
2204
+ // the watched frame so this tab sees every still-billing failure (codex).
2205
+ for (const f of getRunpodWatcher()?.failedFrames() ?? [])
2206
+ bridge.push(f, panelTab);
2135
2207
  // Seed the honest host indicator: tell this tab where renders run now.
2136
- bridge.push({ type: "comfyui_target", url: getComfyUIBaseUrl(), is_local: isTargetingLocal() }, panelTab);
2208
+ bridge.push({ type: "comfyui_target", url: getComfyUIBaseUrl(), is_local: isTargetingLocalOrLan() }, panelTab);
2137
2209
  // Re-push the last usage so the context meter isn't blank after a reload.
2138
2210
  const lastStatus = manager.lastStatusFor(key);
2139
2211
  if (lastStatus)
@@ -3028,6 +3100,84 @@ export async function runPanelOrchestrator() {
3028
3100
  // a downloading row that stops updating for 60s is treated as a dead writer.
3029
3101
  const DOWNLOAD_LINGER_MS = 8000;
3030
3102
  const downloadRemoveAt = new Map();
3103
+ /** Boolean URL probe with a timeout — readiness checks for pending pod connects. */
3104
+ const probeOk = async (url, timeoutMs = 8_000) => {
3105
+ const ctl = new AbortController();
3106
+ const t = setTimeout(() => ctl.abort(), timeoutMs);
3107
+ try {
3108
+ // Carry configured auth (COMFYUI_AUTH_TOKEN / custom header) — a
3109
+ // protected pod's ComfyUI 401s otherwise and connect:true always times
3110
+ // out (codex finding).
3111
+ const res = await fetch(url, { signal: ctl.signal, headers: getComfyUIAuthHeaders() });
3112
+ return res.ok;
3113
+ }
3114
+ catch {
3115
+ return false;
3116
+ }
3117
+ finally {
3118
+ clearTimeout(t);
3119
+ }
3120
+ };
3121
+ // Genuinely-in-flight download rows (set by pollDownloads) — read by the pod
3122
+ // idle-stop veto, SCOPED per pod via each row's stamped target (#269).
3123
+ let downloadingRows = [];
3124
+ // create-with-connect pending boot: per-pod pending connects (a second
3125
+ // create(connect:true) must not silently displace the first's promise —
3126
+ // codex finding: the displaced pod kept billing with no deadline, no
3127
+ // notification, and no auto-stop). Any deliberate target event clears ALL
3128
+ // of them (the user chose something else) — EXCEPT a readiness-driven
3129
+ // completion, which would otherwise wipe its siblings' promises (codex):
3130
+ // machineRetargetInFlight distinguishes machine- from user-driven retargets.
3131
+ // PERSISTED (user-private config dir): an orchestrator self-restart mid-boot
3132
+ // must not strand a promised auto-connect (codex finding — the replacement
3133
+ // process gets a fresh progress dir, so in-memory state alone is lost).
3134
+ const pendingPodConnects = new Map();
3135
+ let machineRetargetInFlight = false;
3136
+ const pendingConnectsFile = join(homedir(), ".comfyui-mcp", `runpod-pending-connects-${bridgePort}.json`);
3137
+ // Port-scope the saved-target file too, and re-restore for a pod boot (the
3138
+ // module-init read ran unscoped at import — codex finding).
3139
+ rescopeLocalTargetFile(join(homedir(), ".comfyui-mcp", `local-target-${bridgePort}.json`));
3140
+ const persistPendingConnects = () => {
3141
+ try {
3142
+ if (pendingPodConnects.size === 0) {
3143
+ unlinkSync(pendingConnectsFile);
3144
+ return;
3145
+ }
3146
+ mkdirSync(dirname(pendingConnectsFile), { recursive: true });
3147
+ writeFileSync(pendingConnectsFile, JSON.stringify(Object.fromEntries([...pendingPodConnects].map(([k, v]) => [k, { url: v.url, deadline: v.deadline }]))));
3148
+ }
3149
+ catch {
3150
+ /* best-effort */
3151
+ }
3152
+ };
3153
+ // Restore promises made before a restart; an already-past deadline fires the
3154
+ // honest timeout on the first poll tick. RESTORE on a pod boot OR any
3155
+ // self-restart generation (the common create(connect:true) case restarts
3156
+ // with the target still LOCAL mid-wait — codex finding: requiring a pod
3157
+ // target deleted the promise on the normal self-restart). A deliberate
3158
+ // fresh non-pod boot (gen 0) invalidates old pendings instead.
3159
+ try {
3160
+ const restartGen = Number(process.env.COMFYUI_MCP_RESTART_GEN ?? "0");
3161
+ if (!isTargetingPod() && !(restartGen > 0)) {
3162
+ if (existsSync(pendingConnectsFile)) {
3163
+ logger.info("[panel-orchestrator] discarding saved pending pod auto-connect(s) — this boot selected a non-pod target explicitly");
3164
+ unlinkSync(pendingConnectsFile);
3165
+ }
3166
+ }
3167
+ else {
3168
+ const saved = JSON.parse(readFileSync(pendingConnectsFile, "utf-8"));
3169
+ for (const [podId, v] of Object.entries(saved)) {
3170
+ if (typeof v?.url === "string" && typeof v?.deadline === "number") {
3171
+ pendingPodConnects.set(podId, { url: v.url, deadline: v.deadline, lastProbe: 0 });
3172
+ }
3173
+ }
3174
+ if (pendingPodConnects.size > 0)
3175
+ logger.info(`[panel-orchestrator] restored ${pendingPodConnects.size} pending pod auto-connect(s) from before the restart`);
3176
+ }
3177
+ }
3178
+ catch {
3179
+ // no saved state
3180
+ }
3031
3181
  let lastDownloadSnapshot = "[]";
3032
3182
  const pollDownloads = () => {
3033
3183
  let files = [];
@@ -3040,6 +3190,8 @@ export async function runPanelOrchestrator() {
3040
3190
  const now = Date.now();
3041
3191
  const downloads = [];
3042
3192
  for (const f of files) {
3193
+ if (f.startsWith(CONTROL_PREFIX))
3194
+ continue; // control channel, not a download row
3043
3195
  const full = join(progressDir, f);
3044
3196
  let row;
3045
3197
  try {
@@ -3078,12 +3230,222 @@ export async function runPanelOrchestrator() {
3078
3230
  }
3079
3231
  downloads.push(row);
3080
3232
  }
3233
+ // Live-download rows for the idle-stop veto (#269): rows surviving the loop
3234
+ // above are fresh (dead writers >60s are already unlinked), so a
3235
+ // "downloading" row here is genuinely in flight — and a pod mid-download
3236
+ // must not count as idle. Self-healing: a crashed writer's row ages out.
3237
+ downloadingRows = downloads.filter((d) => d.status === "downloading");
3081
3238
  downloads.sort((a, b) => String(a.name ?? "").localeCompare(String(b.name ?? "")));
3082
3239
  const snapshot = JSON.stringify(downloads);
3083
3240
  if (snapshot !== lastDownloadSnapshot) {
3084
3241
  lastDownloadSnapshot = snapshot;
3085
3242
  bridge.push({ type: "download_progress", downloads }); // broadcast to all tabs
3086
3243
  }
3244
+ // MCP-child control channel (#269): runpod_* tools that ran in spawned
3245
+ // agent children ask the orchestrator to retarget / watch / unwatch /
3246
+ // auto-connect here — through the SAME applyComfyuiUrl fan-out as a panel
3247
+ // hello. Each request is its own file: consumption deletes exactly the
3248
+ // file read, so concurrent children can't clobber each other (codex).
3249
+ try {
3250
+ for (const { req, file } of listTargetChangeRequests(progressDir)) {
3251
+ // Apply EVERY request — per-request files make the old timestamp
3252
+ // watermark both unnecessary and wrong (two same-millisecond requests
3253
+ // — e.g. create's watch + auto-connect — would drop the second, codex
3254
+ // finding). A crash between apply and delete replays an idempotent op
3255
+ // once on restart; a dropped auto-connect bills an unwatched pod.
3256
+ // A `local` request resolves the fallback HERE — the orchestrator owns
3257
+ // the learned-LAN memory; the requesting child (spawned post-connect)
3258
+ // may know nothing but loopback (codex finding). `onlyIfTarget` guards
3259
+ // it: a stale child's stop-fallback applies only when the CURRENT
3260
+ // target really is that pod (codex finding — it dragged the target
3261
+ // off a newer pod). The ack below reports the resulting URL either
3262
+ // way, so the stale child ALIGNS to it.
3263
+ const localGuardOk = !req.onlyIfTarget || getComfyUIBaseUrl().includes(req.onlyIfTarget);
3264
+ // Generation guard for URL retargets: drop the retarget when a NEWER
3265
+ // direct choice moved the target after the child wrote this (codex
3266
+ // finding: a queued pod-A request applied after the user picked pod B).
3267
+ const urlGenOk = !req.expectedCurrentUrl || canonTargetUrl(getComfyUIBaseUrl()) === canonTargetUrl(req.expectedCurrentUrl);
3268
+ // Only an APPLIED target/unwatch choice supersedes pending auto-connects
3269
+ // (watch-ONLY isn't a choice; a guarded-OUT or generation-STALE request
3270
+ // applied nothing and must not drop a booting pod's promise — codex
3271
+ // findings on both axes).
3272
+ const appliedChoice = urlGenOk && (!!req.url || ((!!req.local || !!req.unwatch) && localGuardOk));
3273
+ if (!req.connectWhenReady && appliedChoice) {
3274
+ pendingPodConnects.clear();
3275
+ persistPendingConnects();
3276
+ }
3277
+ if (req.local && localGuardOk && urlGenOk)
3278
+ applyComfyuiUrl(getLocalComfyuiUrl());
3279
+ else if (req.url && urlGenOk)
3280
+ applyComfyuiUrl(req.url); // dropped when a newer choice superseded it
3281
+ if (req.unwatch && localGuardOk && urlGenOk) {
3282
+ // Scoped for stop-fallbacks: only the stopped pod's own watch dies
3283
+ // (codex finding: stopping A killed unrelated watched B).
3284
+ if (req.unwatchPodId) {
3285
+ const w = getRunpodWatcher();
3286
+ if (w?.watchedPodId() === req.unwatchPodId)
3287
+ w.unwatch();
3288
+ }
3289
+ else {
3290
+ getRunpodWatcher()?.unwatch();
3291
+ }
3292
+ }
3293
+ // A confirmed stop clears the pod's recorded auto-connect failure —
3294
+ // including the spawned-child case, where the caller had no watcher
3295
+ // of its own (codex finding: stopped pods kept "billing" forever).
3296
+ // And it CANCELS any pending auto-connect for it — otherwise the
3297
+ // readiness loop could still retarget to a just-stopped pod, or warn
3298
+ // about a timeout for a pod the user deliberately stopped (codex).
3299
+ if (req.stoppedPodId) {
3300
+ getRunpodWatcher()?.clearConnectFailed(req.stoppedPodId);
3301
+ if (pendingPodConnects.delete(req.stoppedPodId))
3302
+ persistPendingConnects();
3303
+ }
3304
+ if (req.watchPodId) {
3305
+ // Boot-status arm for a create: never displace the ACTIVE render
3306
+ // target's watch — its idle auto-stop / dead-target cleanup is the
3307
+ // billing guard (codex finding: creating pod B while rendering on
3308
+ // pod A left A unguarded and billing indefinitely). The new pod gets
3309
+ // watched when its pending connect completes (it becomes the target).
3310
+ const w = getRunpodWatcher();
3311
+ const cur = w?.watchedPodId();
3312
+ const curIsActiveTarget = !!cur && !isTargetingLocal() && getComfyUIBaseUrl().includes(cur);
3313
+ if (!(curIsActiveTarget && cur !== req.watchPodId))
3314
+ w?.watch(req.watchPodId);
3315
+ }
3316
+ // Whether the watch request actually LANDED (the guard above may have
3317
+ // refused it) — computed AFTER the attempt; the ack carries it so the
3318
+ // child's create result can't claim a watch we rejected (codex finding).
3319
+ const watchApplied = !!req.watchPodId && getRunpodWatcher()?.watchedPodId() === req.watchPodId;
3320
+ // Whether a URL retarget landed (generation-guarded): the ack must
3321
+ // confirm the AUTHORITATIVE retarget before the child reports success
3322
+ // (codex finding: rejected writes still read "connected").
3323
+ const connectApplied = !!req.url && urlGenOk && canonTargetUrl(getComfyUIBaseUrl()) === canonTargetUrl(req.url);
3324
+ if (req.connectWhenReady && urlGenOk) {
3325
+ // The ORCHESTRATOR waits for boot (the tool call returned inside the
3326
+ // MCP 60s lifetime): probe every ~10s, retarget+watch on ready, and
3327
+ // report honestly on deadline — never block the MCP child (codex).
3328
+ // Per-pod slot: concurrent creates each keep their own deadline.
3329
+ // Generation-guarded: a stale registration must not re-arm after the
3330
+ // user already chose a newer target (codex finding).
3331
+ pendingPodConnects.set(req.connectWhenReady.podId, {
3332
+ url: req.connectWhenReady.url,
3333
+ deadline: Date.now() + 8 * 60_000,
3334
+ lastProbe: 0,
3335
+ });
3336
+ persistPendingConnects();
3337
+ }
3338
+ consumeTargetChange(file);
3339
+ // Ack with the RESULTING target ONLY when the requester is waiting —
3340
+ // fire-and-forget requests would leak ack files (codex finding). The
3341
+ // `applied` flag distinguishes an applied local switch from a guarded
3342
+ // skip (onlyIfTarget didn't match — codex finding).
3343
+ if (req.wantAck)
3344
+ ackTargetChange(file, getComfyUIBaseUrl(), req.onlyIfTarget ? localGuardOk && urlGenOk : req.url ? connectApplied : req.connectWhenReady ? urlGenOk : req.watchPodId ? watchApplied : true);
3345
+ }
3346
+ }
3347
+ catch {
3348
+ /* best-effort — the next tick retries a partially-written file */
3349
+ }
3350
+ // Pending create-and-connects: probe each until its pod's ComfyUI answers
3351
+ // BOTH readiness endpoints, then retarget through the shared fan-out.
3352
+ const reportConnectFailed = async (podId, supersededBy) => {
3353
+ // HONEST failure frame (codex finding: fabricated watching:true/RUNNING
3354
+ // read as a healthy pod while it bills unguarded). Fetch the real state;
3355
+ // `watching` reflects whether the single watcher actually follows it.
3356
+ let status = "UNKNOWN"; // unconfirmed — never present a terminal state we didn't verify (codex)
3357
+ let name = null;
3358
+ let gpu = null;
3359
+ let costPerHr = null;
3360
+ let uptime = null;
3361
+ try {
3362
+ const pod = await getPod(podId);
3363
+ status = pod?.desiredStatus ?? "TERMINATED";
3364
+ name = pod?.name ?? null;
3365
+ gpu = pod?.machine?.gpuDisplayName ?? null;
3366
+ costPerHr = pod?.costPerHr ?? null;
3367
+ uptime = pod?.runtime?.uptimeInSeconds ?? null;
3368
+ }
3369
+ catch { /* keep fallbacks */ }
3370
+ // RECHECK before installing: a manual connect/stop during the getPod
3371
+ // await already resolved this — installing now would resurrect a stale
3372
+ // alert on a healthy target (codex finding).
3373
+ if (!isTargetingLocal() && getComfyUIBaseUrl().includes(podId))
3374
+ return;
3375
+ // Suppress the alert ONLY for proven terminal/gone states — a booting
3376
+ // (CREATED/RESTARTING) or unverifiable (UNKNOWN) pod may still be billing
3377
+ // and keeps its warning (codex finding).
3378
+ if (status === "EXITED" || status === "TERMINATED" || status === "DEAD" || status === "PAUSED")
3379
+ return;
3380
+ const frame = {
3381
+ type: "runpod_alert",
3382
+ pod_id: podId,
3383
+ reason: supersededBy ? "superseded" : "timeout",
3384
+ status,
3385
+ name,
3386
+ gpu,
3387
+ cost_per_hr: costPerHr,
3388
+ uptime_seconds: uptime,
3389
+ ...(supersededBy ? { superseded_by: supersededBy } : {}),
3390
+ };
3391
+ // Route through the watcher so the failure STICKS (seeds to new tabs and
3392
+ // rides later frames until the pod exits — codex finding: a one-shot
3393
+ // push lets the only warning evaporate on the next routine poll). The
3394
+ // alert channel can't clobber the watched pod's status slot (codex).
3395
+ const w = getRunpodWatcher();
3396
+ if (w)
3397
+ w.markConnectFailed(podId, frame);
3398
+ else
3399
+ void bridge.push(frame);
3400
+ };
3401
+ for (const [podId, p] of pendingPodConnects) {
3402
+ if (Date.now() > p.deadline) {
3403
+ pendingPodConnects.delete(podId);
3404
+ persistPendingConnects();
3405
+ logger.warn(`[panel-orchestrator] pod ${podId} was not ready within 8 minutes — NOT auto-connecting (connect manually with runpod_pod_connect)`);
3406
+ // The tool call is long gone and idle auto-stop can't fire on a pod we
3407
+ // never connected to (renderingOnPod is false) — the failed pod keeps
3408
+ // billing with no visible failure unless we say so (codex finding).
3409
+ void reportConnectFailed(podId);
3410
+ continue;
3411
+ }
3412
+ if (Date.now() - p.lastProbe >= 10_000) {
3413
+ p.lastProbe = Date.now();
3414
+ void (async () => {
3415
+ const stats = await probeOk(`${p.url}/system_stats`);
3416
+ const queue = stats ? await probeOk(`${p.url}/queue`) : false;
3417
+ if (!pendingPodConnects.has(podId) || pendingPodConnects.get(podId) !== p)
3418
+ return; // superseded/cleared
3419
+ if (stats && queue) {
3420
+ pendingPodConnects.delete(podId);
3421
+ persistPendingConnects();
3422
+ // A readiness-driven retarget — NOT a user override: siblings'
3423
+ // pending connects stay alive through the listener (codex).
3424
+ machineRetargetInFlight = true;
3425
+ try {
3426
+ applyComfyuiUrl(p.url);
3427
+ }
3428
+ finally {
3429
+ machineRetargetInFlight = false;
3430
+ }
3431
+ // ONE winner: a second readiness would displace this pod's watch
3432
+ // (its idle-stop guard), so competing pending connects are resolved
3433
+ // as SUPERSEDED — with an HONEST frame: the loser is UNWATCHED
3434
+ // (nothing guards its cost) and connect_failed says why (codex).
3435
+ for (const [otherId, other] of pendingPodConnects) {
3436
+ pendingPodConnects.delete(otherId);
3437
+ persistPendingConnects();
3438
+ 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`);
3439
+ void reportConnectFailed(otherId, podId);
3440
+ void other;
3441
+ }
3442
+ getRunpodWatcher()?.watch(podId);
3443
+ logger.info(`[panel-orchestrator] pod ${podId} ready — auto-connected (${p.url})`);
3444
+ void bridge.push({ type: "runpod_connected", pod_id: podId, url: p.url });
3445
+ }
3446
+ })();
3447
+ }
3448
+ }
3087
3449
  };
3088
3450
  const downloadTimer = setInterval(pollDownloads, 700);
3089
3451
  downloadTimer.unref?.();
@@ -3116,23 +3478,117 @@ export async function runPanelOrchestrator() {
3116
3478
  })();
3117
3479
  initRunpodWatcher({
3118
3480
  push: (frame) => void bridge.push(frame),
3119
- comfyuiIdle: () => {
3481
+ // Persist unresolved connect-failure alerts per port (restart-proof — a
3482
+ // self-restart must not lose a still-billing warning, codex finding).
3483
+ persistPath: join(homedir(), ".comfyui-mcp", `runpod-connect-failures-${bridgePort}.json`),
3484
+ comfyuiIdle: (podId) => {
3120
3485
  const s = QueueMonitor.snapshot();
3121
- return s.connected && !s.running && s.queueDepth === 0;
3486
+ // NOT idle while a training job is alive on THIS pod: training isn't a
3487
+ // ComfyUI queue job, so the queue alone would call an hours-long LoRA
3488
+ // run "idle" and auto-stop the pod mid-flight (P4 guard; review finding
3489
+ // on the connector). hasActiveTrainingJob is a probe-free file scan,
3490
+ // scoped to the watched pod so a run on another pod doesn't suppress
3491
+ // this pod's idle-stop (codex #274). Also NOT idle while THIS POD is
3492
+ // downloading — rows are target-stamped by their writer (#269; an
3493
+ // unstamped pre-fix row errs toward "busy", the cost-safe direction).
3494
+ const podDownloading = downloadingRows.some((d) => {
3495
+ const t = typeof d.target === "string" ? d.target : "";
3496
+ return t === "" || t.includes(podId);
3497
+ });
3498
+ return s.connected && !s.running && s.queueDepth === 0 && !podDownloading && !hasActiveTrainingJob("pod", podId);
3122
3499
  },
3123
3500
  // Idle auto-stop only applies to a pod we're actually rendering on: the active
3124
3501
  // ComfyUI target is that pod's proxy (its id appears in the URL). A pod we
3125
3502
  // merely watch while it boots stays local-targeted, so this is false and it is
3126
3503
  // never auto-stopped on the local rig's idleness.
3127
3504
  renderingOnPod: (podId) => !isTargetingLocal() && getComfyUIBaseUrl().includes(podId),
3505
+ // The watched pod vanished or was auto-stopped (#269 dead-target cleanup):
3506
+ // when renders were pointing AT it, fall back to the local target — via
3507
+ // setComfyuiTarget, so the shared retarget fan-out (QueueMonitor, agents,
3508
+ // frame) runs too. Not every watched pod is the render target, so guard.
3509
+ onPodUnavailable: (goneId) => {
3510
+ if (isTargetingLocal() || !getComfyUIBaseUrl().includes(goneId))
3511
+ return;
3512
+ const local = getLocalComfyuiUrl();
3513
+ logger.warn(`[panel-orchestrator] pod ${goneId} unavailable while targeted — retargeting local ComfyUI (${local})`);
3514
+ // A MACHINE-driven fallback (auto-stop/vanish), not a user override:
3515
+ // pending create-and-connects on OTHER pods must survive it — otherwise
3516
+ // a booting pod silently loses its promised auto-connect (codex finding).
3517
+ machineRetargetInFlight = true;
3518
+ try {
3519
+ setComfyuiTarget(local);
3520
+ }
3521
+ finally {
3522
+ machineRetargetInFlight = false;
3523
+ }
3524
+ resetClient();
3525
+ },
3128
3526
  idleStopMinutes: runpodIdleStopMinutes,
3129
3527
  });
3130
- // Honest host indicator: whenever the ComfyUI target moves (RunPod connect,
3131
- // pod stop → local fallback, "Local" switch), broadcast a `comfyui_target`
3132
- // frame so every control panel truthfully shows where renders run. Seeded per
3133
- // tab on connect (below) so a fresh tab knows the host without waiting for a
3134
- // switch.
3528
+ // Money guard (codex #263): the idle predicate above trusts persisted
3529
+ // training records blindly (hasActiveTrainingJob is a probe-free file scan),
3530
+ // and owner-death reconciliation otherwise only runs via getJob/listJobs —
3531
+ // so if the harness that launched a pod training run dies and nobody ever
3532
+ // calls train_status again, the stale "running" record would suppress the
3533
+ // pod auto-stop FOREVER. Periodically reconcile dead-owner records (probes
3534
+ // fire only for dead/stale owners, so a healthy run costs nothing here).
3535
+ const trainingReconcileTimer = setInterval(() => {
3536
+ void reconcileStaleTrainingJobs()
3537
+ .then((n) => {
3538
+ if (n > 0)
3539
+ logger.info(`[panel-orchestrator] reconciled ${n} dead-owner training job(s) — pod idle auto-stop unblocked`);
3540
+ })
3541
+ .catch((err) => {
3542
+ logger.debug(`[panel-orchestrator] training reconcile: ${err instanceof Error ? err.message : String(err)}`);
3543
+ });
3544
+ }, 5 * 60_000);
3545
+ trainingReconcileTimer.unref?.();
3546
+ // Honest host indicator + RETARGET FAN-OUT: whenever the ComfyUI target moves
3547
+ // (RunPod connect, pod stop → local fallback, "Local" switch, panel hello),
3548
+ // setComfyuiTarget fires this ONE listener — it repoints everything that was
3549
+ // previously left split-brained (#269): QueueMonitor (its WS/polls keep
3550
+ // talking to the OLD host), the agent subprocesses' MCP env (respawned via
3551
+ // restartAllForMcpEnv), and the env-capability probe, then broadcasts the
3552
+ // `comfyui_target` frame so every panel truthfully shows where renders run.
3553
+ // Same-URL retargets (a repeated connect to the current target) skip the
3554
+ // restart storm and only re-broadcast. Seeded per tab on connect (below) so
3555
+ // a fresh tab knows the host without waiting for a switch.
3556
+ let lastRetargetUrl = comfyuiUrl; // seeded: a first same-URL event must not restart everything
3135
3557
  onComfyuiTargetChanged((url, isLocal) => {
3558
+ // ANY target event — a change OR a reaffirmation of the current target —
3559
+ // supersedes ALL pending auto-connects (codex findings: direct
3560
+ // setComfyuiTarget callers bypass applyComfyuiUrl, and an explicit
3561
+ // Local/Stop at the SAME target must not let a booting pod steal it back
3562
+ // later) — EXCEPT a readiness-driven completion, which must leave its
3563
+ // siblings' pending promises intact (codex finding).
3564
+ if (!machineRetargetInFlight) {
3565
+ pendingPodConnects.clear();
3566
+ persistPendingConnects();
3567
+ }
3568
+ if (url !== lastRetargetUrl) {
3569
+ lastRetargetUrl = url;
3570
+ // Sync the shared target closures FIRST: retargets that did NOT come
3571
+ // through applyComfyuiUrl (runpod tools, watcher callbacks) leave them
3572
+ // holding the OLD host — and buildMcpServers()/refreshEnvCapabilities()
3573
+ // below would rebuild/respawn agents against it (codex finding).
3574
+ comfyuiUrl = url;
3575
+ comfyuiPath = localPathForTarget(url);
3576
+ try {
3577
+ QueueMonitor.stop();
3578
+ }
3579
+ catch {
3580
+ /* best-effort */
3581
+ }
3582
+ QueueMonitor.start(url);
3583
+ manager.setMcpServers(buildMcpServers());
3584
+ manager.setComfyuiUrl(url);
3585
+ manager.restartAllForMcpEnv();
3586
+ void refreshEnvCapabilities();
3587
+ }
3588
+ // The readvertise timer follows the target too (created only at startup
3589
+ // before — a local→pod retarget left the pod unable to learn the bridge
3590
+ // URL, codex finding). Cheap no-op for same-url events.
3591
+ syncReadvertise(url);
3136
3592
  void bridge.push({ type: "comfyui_target", url, is_local: isLocal });
3137
3593
  });
3138
3594
  // Keep the pod's stored bridge URL fresh so a ComfyUI RESTART self-heals fast.
@@ -3147,15 +3603,104 @@ export async function runPanelOrchestrator() {
3147
3603
  // the advertise on a cheap idempotent timer repopulates the pod's store within
3148
3604
  // one interval of any reboot (from any cause: the agent's restart, a Manager
3149
3605
  // UI restart, a crash), so the browser's reclaim poll reconnects promptly.
3150
- // Only meaningful for a remote https target with a secure bridge.
3606
+ // Only meaningful for a remote https target with a secure bridge. Driven by
3607
+ // syncReadvertise at STARTUP and on every retarget (codex finding: a
3608
+ // local→pod retarget later left the timer nonexistent, so the pod could
3609
+ // never learn the WSS URL/token — the very deadlock it prevents). The
3610
+ // bridge itself is created LAZILY on the first remote target too (a local
3611
+ // boot has wantSecureBridge=false — codex finding).
3151
3612
  let readvertiseTimer = null;
3152
- if (secureBridge && isRemoteHttpsUrl(comfyuiUrl)) {
3153
- readvertiseTimer = setInterval(() => {
3154
- if (secureBridge && isRemoteHttpsUrl(comfyuiUrl))
3155
- void secureBridge.advertise(comfyuiUrl);
3156
- }, 5000);
3157
- readvertiseTimer.unref?.();
3158
- }
3613
+ // One in-flight setup MAX — concurrent ticks must not each start a tunnel
3614
+ // (codex finding: a slow first attempt spawned multiple cloudflared clients).
3615
+ let secureBridgeSetup = null;
3616
+ // A tokenless primary listener does NOT rule out pod panels: the tunnel gets
3617
+ // its OWN token-gated listener (same addListener mechanism the phone-pair
3618
+ // flow uses) on a dedicated port — never retrofit auth onto the public path
3619
+ // and never open a tunnel in front of an unauthenticated one (codex P1).
3620
+ // Port map: +0 bridge, +1 panel_* HTTP-MCP, +2 phone-pair, +3 console (line
3621
+ // ~1427), +4 tunnel listener (codex finding: +3 was already taken).
3622
+ const tunnelPort = lockPort + 4;
3623
+ let tunnelToken = null;
3624
+ let tunnelListenerStarted = false;
3625
+ const ensureSecureBridge = async (url) => {
3626
+ if (secureBridge)
3627
+ return;
3628
+ if (!secureBridgeSetup) {
3629
+ secureBridgeSetup = (async () => {
3630
+ try {
3631
+ let port = lockPort;
3632
+ let token;
3633
+ if (bridgeListenerTokenless) {
3634
+ // Primary listener stays tokenless for local use; the tunnel binds
3635
+ // its own token-gated listener (pair-flow precedent) and advertises
3636
+ // THAT token — enforced, because this listener was built with it.
3637
+ // Bound ONCE: a retried setup must reuse it, not EADDRINUSE (codex).
3638
+ tunnelToken ??= randomBytes(24).toString("hex");
3639
+ if (!tunnelListenerStarted) {
3640
+ await bridge.addListener("0.0.0.0", tunnelPort, tunnelToken);
3641
+ tunnelListenerStarted = true;
3642
+ }
3643
+ port = tunnelPort;
3644
+ token = tunnelToken;
3645
+ }
3646
+ else if (bridgeToken) {
3647
+ token = bridgeToken;
3648
+ }
3649
+ else {
3650
+ return; // unreachable — the tokenless branch above covers this
3651
+ }
3652
+ secureBridge = await setupSecureBridge({
3653
+ bridgePort: port,
3654
+ comfyuiUrl: url,
3655
+ token,
3656
+ bridge,
3657
+ // The setup itself advertises on completion — guard it too: a slow
3658
+ // tunnel must not hand the bridge URL to a pod the user already
3659
+ // left (codex finding: the post-await guard alone couldn't stop it).
3660
+ shouldAdvertise: (t) => comfyuiUrl === t && isRemoteHttpsUrl(t),
3661
+ });
3662
+ }
3663
+ catch (err) {
3664
+ logger.error(`[panel-orchestrator] secure bridge (cloudflared) failed: ${err instanceof Error ? err.message : String(err)}. ` +
3665
+ `Install cloudflared (npm i -g cloudflared), or re-run with --insecure-bridge and open the pod through an ` +
3666
+ `SSH tunnel (ssh -L 3000:localhost:3000 …) at http://localhost:3000.`);
3667
+ }
3668
+ finally {
3669
+ secureBridgeSetup = null;
3670
+ }
3671
+ })();
3672
+ }
3673
+ return secureBridgeSetup;
3674
+ };
3675
+ const syncReadvertise = (url) => {
3676
+ const wanted = !insecureBridge && isRemoteHttpsUrl(url);
3677
+ if (wanted && !readvertiseTimer) {
3678
+ // Immediate first advertise (creating the tunnel on a local→pod
3679
+ // transition), then the interval.
3680
+ void (async () => {
3681
+ await ensureSecureBridge(url);
3682
+ // The user may have moved on while the tunnel came up — advertising an
3683
+ // OLD pod now would let its panel hello steal the newer target (codex).
3684
+ if (secureBridge && comfyuiUrl === url && isRemoteHttpsUrl(url))
3685
+ void secureBridge.advertise(url);
3686
+ })();
3687
+ readvertiseTimer = setInterval(() => {
3688
+ void (async () => {
3689
+ if (isRemoteHttpsUrl(comfyuiUrl)) {
3690
+ await ensureSecureBridge(comfyuiUrl);
3691
+ if (secureBridge)
3692
+ void secureBridge.advertise(comfyuiUrl);
3693
+ }
3694
+ })();
3695
+ }, 5000);
3696
+ readvertiseTimer.unref?.();
3697
+ }
3698
+ else if (!wanted && readvertiseTimer) {
3699
+ clearInterval(readvertiseTimer);
3700
+ readvertiseTimer = null;
3701
+ }
3702
+ };
3703
+ syncReadvertise(comfyuiUrl);
3159
3704
  // The no-path suffix must not read as an error when it is BY DESIGN: for a
3160
3705
  // remote target a local path is the wrong filesystem and is deliberately
3161
3706
  // dropped — installs/downloads run host-side via ComfyUI-Manager (remote
@@ -3184,6 +3729,14 @@ export async function runPanelOrchestrator() {
3184
3729
  if (readvertiseTimer)
3185
3730
  clearInterval(readvertiseTimer);
3186
3731
  QueueMonitor.stop();
3732
+ // Remove OUR nonced progress dir (startup reaps previous lives') so the
3733
+ // auto-restart cycle doesn't accumulate temp dirs (codex finding).
3734
+ try {
3735
+ rmSync(progressDir, { recursive: true, force: true });
3736
+ }
3737
+ catch {
3738
+ /* best-effort */
3739
+ }
3187
3740
  unsubscribeSecrets();
3188
3741
  unsubscribeAgentSecrets();
3189
3742
  await manager.stopAll();