comfyui-mcp 0.22.0 → 0.23.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 (40) hide show
  1. package/README.md +24 -0
  2. package/dist/index.js +39 -1
  3. package/dist/index.js.map +1 -1
  4. package/dist/orchestrator/codex-backend.js +32 -4
  5. package/dist/orchestrator/codex-backend.js.map +1 -1
  6. package/dist/orchestrator/index.js +315 -154
  7. package/dist/orchestrator/index.js.map +1 -1
  8. package/dist/orchestrator/panel-agent.js +49 -14
  9. package/dist/orchestrator/panel-agent.js.map +1 -1
  10. package/dist/services/env-capabilities.js +95 -1
  11. package/dist/services/env-capabilities.js.map +1 -1
  12. package/dist/services/image-management.js +100 -1
  13. package/dist/services/image-management.js.map +1 -1
  14. package/dist/services/manifest.js +71 -14
  15. package/dist/services/manifest.js.map +1 -1
  16. package/dist/services/model-resolver.js +143 -1
  17. package/dist/services/model-resolver.js.map +1 -1
  18. package/dist/services/node-management.js +46 -0
  19. package/dist/services/node-management.js.map +1 -1
  20. package/dist/tools/image-management.js +8 -4
  21. package/dist/tools/image-management.js.map +1 -1
  22. package/dist/tools/manifest.js +1 -1
  23. package/dist/tools/manifest.js.map +1 -1
  24. package/dist/tools/model-extras.js +27 -8
  25. package/dist/tools/model-extras.js.map +1 -1
  26. package/dist/tools/model-management.js +1 -1
  27. package/dist/tools/model-management.js.map +1 -1
  28. package/dist/tools/node-management.js +21 -0
  29. package/dist/tools/node-management.js.map +1 -1
  30. package/dist/transport/cli.js +39 -2
  31. package/dist/transport/cli.js.map +1 -1
  32. package/package.json +1 -1
  33. package/packs/wan-multitalk/install-runpod.sh +44 -0
  34. package/packs/wan-multitalk/install-windows.bat +49 -0
  35. package/packs/wan-multitalk/manifest.yaml +50 -0
  36. package/packs/wan-multitalk/pack.yaml +28 -0
  37. package/packs/wan-multitalk/workflow.json +1432 -0
  38. package/plugin/skills/triton-sageattention/SKILL.md +15 -0
  39. package/plugin/skills/troubleshooting/SKILL.md +56 -0
  40. package/plugin/skills/wan-multitalk/SKILL.md +97 -0
@@ -56,6 +56,8 @@ RESOLVING A TANGLED / TOGGLE-HEAVY WORKFLOW (Get/Set buses + rgthree-bypassed pi
56
56
 
57
57
  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.
58
58
 
59
+ 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.
60
+
59
61
  LOCAL-GPU (FREE) vs API NODES (PAID CREDITS) — and ASK before spending. ComfyUI workflows are either LOCAL — they run on the user's OWN GPU, which is free — or they use API NODES (hosted/partner services) that consume the user's PAID api credits. The bundled installer packs (list_packs) are ALL local/free; ComfyUI's official templates and any ad-hoc or generated workflow MAY use API nodes. BEFORE you build OR load any workflow that uses API nodes, you MUST ASK the user whether to use the free local GPU or paid api credits, and NEVER silently spend credits. To tell the difference reliably, call check_workflow_runtime(pack:<name> or graph:<json>) — it returns { runtime: 'local'|'api'|'mixed'|'unknown', usesApiNodes, apiNodes[] } by scanning the graph's nodes against the server's API-node set; treat 'api'/'mixed' (usesApiNodes:true) AND 'unknown' (unclassifiable nodes that could be paid) as POSSIBLY PAID and stop to ask — only 'local' is confirmed free. DEFAULT TO / PREFER the local pack unless the user explicitly opts into API nodes. Packs are always safe to load without asking; check ad-hoc/template workflows first.
60
62
 
61
63
  You also have the comfyui MCP tools to generate images, video, and audio and to inspect, download models for, and manage their ComfyUI instance. Use them to actually do what's asked, then tell them what you did and name or link any output. If a request is ambiguous, make a sensible choice and say what you chose rather than stalling.
@@ -342,11 +344,28 @@ export async function runPanelOrchestrator() {
342
344
  // mode — so it generates against the live ComfyUI over COMFYUI_URL and never
343
345
  // tries to bind the bridge port we own here.
344
346
  const mcpEntry = fileURLToPath(new URL("../index.js", import.meta.url));
345
- const comfyuiUrl = process.env.COMFYUI_URL ?? "http://127.0.0.1:8188";
346
- // ComfyUI install path — when set, the spawned agent's MCP runs in LOCAL mode,
347
- // so download_model / apply_manifest / installer-pack / model-scan tools work
348
- // instead of degrading to remote-only. The panel pack supplies this.
349
- const comfyuiPath = process.env.COMFYUI_PATH;
347
+ // Mutable: the panel sends the ComfyUI URL it was SERVED FROM (window.location)
348
+ // in `hello`, and the orchestrator retargets to it (applyComfyuiUrl) so
349
+ // `--panel-orchestrator` boots on the localhost default and auto-points at
350
+ // whatever ComfyUI (local or a RunPod proxy) the browser is actually on. No
351
+ // `connect <url>` needed.
352
+ let comfyuiUrl = process.env.COMFYUI_URL ?? "http://127.0.0.1:8188";
353
+ // ComfyUI install path — when set AND the target is loopback, the spawned agent's
354
+ // MCP runs in LOCAL mode (download_model / apply_manifest / installer-pack /
355
+ // model-scan tools). A REMOTE target (non-loopback) forces remote-only, so we
356
+ // drop the path. `envComfyuiPath` is the orchestrator's own env value; the live
357
+ // `comfyuiPath` is derived from it + the current target.
358
+ const envComfyuiPath = process.env.COMFYUI_PATH;
359
+ const isLoopbackUrl = (u) => {
360
+ try {
361
+ const h = new URL(u).hostname.toLowerCase();
362
+ return h === "127.0.0.1" || h === "localhost" || h === "::1" || h === "0.0.0.0" || h === "";
363
+ }
364
+ catch {
365
+ return true;
366
+ }
367
+ };
368
+ let comfyuiPath = isLoopbackUrl(comfyuiUrl) ? envComfyuiPath : undefined;
350
369
  const model = process.env.COMFYUI_MCP_PANEL_MODEL ?? "claude-opus-4-8";
351
370
  const envEffort = process.env.COMFYUI_MCP_PANEL_EFFORT;
352
371
  const effort = isEffort(envEffort) ? envEffort : undefined;
@@ -409,12 +428,31 @@ export async function runPanelOrchestrator() {
409
428
  // COMFYUI_MCP_GEMINI_MODEL (default gemini-2.5-pro). The model is applied at spawn
410
429
  // via the CLI `--model` flag (ACP exposes no per-session model setter).
411
430
  const geminiModel = process.env.COMFYUI_MCP_GEMINI_MODEL ?? GEMINI_DEFAULT_MODEL;
412
- const isCodex = backendId === "codex";
413
- const isGemini = backendId === "gemini";
414
- // Codex + Gemini both drive the live canvas through the loopback HTTP panel MCP
415
- // (neither can host an in-process SDK MCP server like Claude does), so several
416
- // branches below treat them together.
417
- const isHttpPanelBackend = isCodex || isGemini;
431
+ // ── Per-tab backend (single-port multi-provider) ──────────────────────────
432
+ // ONE orchestrator on ONE bridge port serves ALL providers; the panel picks a
433
+ // provider per tab via the `hello`/`set_backend` handshake, instead of the node
434
+ // spawning one process per provider on its own port (9180/9181/9182). Internally
435
+ // each (panel tab, backend) pair is one agent addressed by a composite key
436
+ // `tabId::backend`, so switching provider starts a FRESH session for that
437
+ // provider (the panel replays the transcript to seed it) while a same-provider
438
+ // reconnect RESUMES. `backendId`/`codexModel`/`geminiModel` above are the
439
+ // DEFAULT + per-provider model config; the process is no longer pinned to one.
440
+ const KNOWN_BACKENDS = new Set(["claude", "codex", "gemini"]);
441
+ const defaultBackend = KNOWN_BACKENDS.has(backendId) ? backendId : "claude";
442
+ const AGENT_KEY_SEP = "::";
443
+ const tabBackends = new Map(); // panel tabId -> selected backend
444
+ const backendForTab = (panelTabId) => tabBackends.get(panelTabId) ?? defaultBackend;
445
+ const agentKeyFor = (panelTabId) => panelTabId + AGENT_KEY_SEP + backendForTab(panelTabId);
446
+ // A panel tab id never contains "::"; backend names never do — so split on the
447
+ // LAST separator to recover each half from a composite key.
448
+ const panelTabOf = (key) => {
449
+ const i = key.lastIndexOf(AGENT_KEY_SEP);
450
+ return i >= 0 ? key.slice(0, i) : key;
451
+ };
452
+ const backendOf = (key) => {
453
+ const i = key.lastIndexOf(AGENT_KEY_SEP);
454
+ return i >= 0 ? key.slice(i + AGENT_KEY_SEP.length) : defaultBackend;
455
+ };
418
456
  // ---- live ENVIRONMENT-CAPABILITIES block ----
419
457
  // Gather the machine's facts ONCE at startup (CACHED) — OS/CPU/RAM from node,
420
458
  // GPU/VRAM/CUDA/torch/python/ComfyUI from /system_stats, Triton/SageAttention by
@@ -462,7 +500,9 @@ export async function runPanelOrchestrator() {
462
500
  // A panel-saved tool secret (CIVITAI_API_TOKEN, HF_TOKEN, …) is layered on top
463
501
  // by buildComfyuiMcpEnv() at SPAWN time, so the same headless tool surface — and
464
502
  // the same secrets — reach either provider.
465
- const comfyuiBaseEnv = {
503
+ // A FUNCTION (not a frozen object) so it always reflects the CURRENT retargeted
504
+ // comfyuiUrl/comfyuiPath — makeHttpBackendMcpServers calls it per (re)spawn.
505
+ const comfyuiBaseEnv = () => ({
466
506
  COMFYUI_URL: comfyuiUrl,
467
507
  COMFYUI_MCP_PROGRESS_DIR: progressDir,
468
508
  ...(comfyuiPath ? { COMFYUI_PATH: comfyuiPath } : {}),
@@ -472,19 +512,23 @@ export async function runPanelOrchestrator() {
472
512
  ...(process.env.HF_TOKEN ? { HF_TOKEN: process.env.HF_TOKEN } : {}),
473
513
  // Test-only tool-call trace (knowledge-parity smoke). No-op unless set.
474
514
  ...(process.env.COMFYUI_MCP_TOOL_TRACE ? { COMFYUI_MCP_TOOL_TRACE: process.env.COMFYUI_MCP_TOOL_TRACE } : {}),
475
- };
515
+ });
476
516
  // The orchestrator-hosted loopback HTTP MCP for panel_* tools. Started for the
477
517
  // non-Claude backends (Codex + Gemini), which can't host an in-process SDK MCP
478
518
  // server the way Claude does. Port: COMFYUI_MCP_PANEL_MCP_PORT, default
479
519
  // bridgePort+1 (loopback only).
520
+ // Start the loopback HTTP panel-MCP ALWAYS: with single-port multi-provider any
521
+ // tab may pick codex/gemini at runtime, and those backends drive the canvas
522
+ // through this server (Claude tabs use the in-process SDK server instead). The
523
+ // per-tab session routing (`urlFor(panelTabId)`) already isolates tabs.
480
524
  let panelMcpHttp = null;
481
- if (isHttpPanelBackend) {
525
+ {
482
526
  const panelMcpPort = Number(process.env.COMFYUI_MCP_PANEL_MCP_PORT) || bridgePort + 1;
483
527
  try {
484
528
  panelMcpHttp = await startPanelMcpHttpServer(bridge, panelMcpPort);
485
529
  }
486
530
  catch (err) {
487
- logger.error(`[panel-orchestrator] could not start the panel HTTP MCP on :${panelMcpPort} — ${backendId} will lack live-graph tools: ${err instanceof Error ? err.message : String(err)}`);
531
+ logger.error(`[panel-orchestrator] could not start the panel HTTP MCP on :${panelMcpPort} — codex/gemini tabs will lack live-graph tools: ${err instanceof Error ? err.message : String(err)}`);
488
532
  }
489
533
  }
490
534
  // Shared MCP server config for BOTH the Codex and Gemini backends — they take an
@@ -498,52 +542,61 @@ export async function runPanelOrchestrator() {
498
542
  args: [mcpEntry], // dist/index.js
499
543
  // Merge persisted tool secrets at SPAWN time so a respawn picks up a
500
544
  // just-saved CIVITAI_API_TOKEN / HF_TOKEN without a process restart.
501
- env: buildComfyuiMcpEnv(comfyuiBaseEnv),
545
+ env: buildComfyuiMcpEnv(comfyuiBaseEnv()),
502
546
  },
503
547
  // Live-graph panel_* tools for THIS tab over the loopback HTTP MCP.
504
548
  ...(panelMcpHttp
505
549
  ? { panel: { transport: "http", url: panelMcpHttp.urlFor(tabId) } }
506
550
  : {}),
507
551
  });
508
- const makeBackend = isCodex
509
- ? (tabId) => new CodexBackend({
510
- cwd: comfyuiPath ?? process.cwd(),
511
- model: codexModel,
512
- systemAppend: panelSystemAppend,
513
- // Base ComfyUI URL so the backend can fetch image bytes from /view and
514
- // deliver them to a turn as `localImage` input items (vision parity).
515
- comfyuiUrl,
516
- mcpServers: makeHttpBackendMcpServers(tabId),
517
- })
518
- : isGemini
519
- ? (tabId) => new GeminiBackend({
552
+ // Build the provider backend for a composite agent key `panelTabId::backend`.
553
+ // Claude → undefined (PanelAgent uses its built-in in-process SDK backend);
554
+ // codex/gemini their CLI-driven backend, wired to the panel_* tools over the
555
+ // loopback HTTP MCP for THIS panel tab's canvas (comfyuiUrl gives vision parity).
556
+ const makeBackend = (key) => {
557
+ const backend = backendOf(key);
558
+ const panelTabId = panelTabOf(key);
559
+ if (backend === "codex") {
560
+ return new CodexBackend({
561
+ cwd: comfyuiPath ?? process.cwd(),
562
+ model: codexModel,
563
+ systemAppend: panelSystemAppend,
564
+ comfyuiUrl,
565
+ mcpServers: makeHttpBackendMcpServers(panelTabId),
566
+ });
567
+ }
568
+ if (backend === "gemini") {
569
+ return new GeminiBackend({
520
570
  cwd: comfyuiPath ?? process.cwd(),
521
571
  model: geminiModel,
522
572
  systemAppend: panelSystemAppend,
523
- // Base ComfyUI URL so the backend can fetch image bytes from /view and
524
- // deliver them inline as base64 image ContentBlocks (vision parity).
525
573
  comfyuiUrl,
526
- mcpServers: makeHttpBackendMcpServers(tabId),
527
- })
528
- : undefined;
529
- if (isCodex) {
530
- logger.info(`[panel-orchestrator] agent backend = codex (codex app-server); panel_* live-graph tools via loopback HTTP MCP${panelMcpHttp ? ` on :${panelMcpHttp.port}` : " UNAVAILABLE"} + headless comfyui MCP`);
531
- }
532
- else if (isGemini) {
533
- logger.info(`[panel-orchestrator] agent backend = gemini (gemini --acp); model=${geminiModel}; panel_* live-graph tools via loopback HTTP MCP${panelMcpHttp ? ` on :${panelMcpHttp.port}` : " UNAVAILABLE"} + headless comfyui MCP`);
534
- }
535
- else if (backendId !== "claude") {
536
- logger.warn(`[panel-orchestrator] unknown PANEL_AGENT_BACKEND "${backendId}" defaulting to claude`);
537
- }
538
- // Readiness/model probing must route through the SELECTED backend (P1-2): in a
539
- // non-Claude mode the panel's "ready" must NOT depend on Claude SDK/login health.
540
- // A dedicated probe backend (not tied to a tab) supplies the model list and
541
- // proves the CLI can start; Claude mode keeps its SDK probes.
542
- const probeBackend = isCodex
543
- ? new CodexBackend({ cwd: comfyuiPath ?? process.cwd(), model: codexModel })
544
- : isGemini
545
- ? new GeminiBackend({ cwd: comfyuiPath ?? process.cwd(), model: geminiModel })
546
- : null;
574
+ mcpServers: makeHttpBackendMcpServers(panelTabId),
575
+ });
576
+ }
577
+ return undefined; // claude → built-in ClaudeBackend
578
+ };
579
+ logger.info(`[panel-orchestrator] single-port multi-provider: default backend=${defaultBackend}; ` +
580
+ `codex/gemini panel_* live-graph tools via loopback HTTP MCP${panelMcpHttp ? ` on :${panelMcpHttp.port}` : " UNAVAILABLE"} + headless comfyui MCP`);
581
+ // Readiness/model probing routes through the SELECTED backend PER TAB a
582
+ // codex/gemini tab's "ready" must NOT depend on Claude SDK/login health. Claude
583
+ // uses fetchSupportedModels(); codex/gemini spin up a throwaway probe backend
584
+ // (which also proves the CLI can launch). Cached per backend so repeated hellos
585
+ // don't re-probe.
586
+ const probeBackends = new Map();
587
+ const getProbeBackend = (backend) => {
588
+ if (backend === "claude")
589
+ return null; // claude uses the SDK probe below
590
+ let pb = probeBackends.get(backend);
591
+ if (!pb) {
592
+ pb =
593
+ backend === "codex"
594
+ ? new CodexBackend({ cwd: comfyuiPath ?? process.cwd(), model: codexModel })
595
+ : new GeminiBackend({ cwd: comfyuiPath ?? process.cwd(), model: geminiModel });
596
+ probeBackends.set(backend, pb);
597
+ }
598
+ return pb;
599
+ };
547
600
  // Durable per-tab session ids (keyed by our bridge port), so a tab's agent
548
601
  // resumes its conversation even after the orchestrator PROCESS is killed and
549
602
  // respawned (a wedge auto-restart) — not just a soft reload.
@@ -577,42 +630,46 @@ export async function runPanelOrchestrator() {
577
630
  comfyuiUrl, // for fetching image bytes to inline into agent turns
578
631
  systemAppend: panelSystemAppend,
579
632
  pluginPath: pluginAvailable ? pluginPath : undefined,
580
- // Live-graph control of the user's open workflow, per tab (in-process).
581
- makePanelServer: (tabId) => createPanelMcpServer(bridge, tabId),
633
+ // In-process live-graph MCP for CLAUDE keys only (codex/gemini drive the
634
+ // canvas through the loopback HTTP MCP instead). Bound to the PANEL tab so
635
+ // panel_* tools reach the user's canvas regardless of the composite key.
636
+ makePanelServer: (key) => backendOf(key) === "claude" ? createPanelMcpServer(bridge, panelTabOf(key)) : undefined,
582
637
  mcpServers: buildMcpServers(),
583
- onSay: (tabId, text, meta) => {
638
+ // NOTE: manager callbacks fire with the composite agent key `tabId::backend`;
639
+ // panelTabOf() recovers the PANEL tab so every push reaches the right socket.
640
+ onSay: (key, text, meta) => {
584
641
  // `id` lets the panel reconcile this committed message with its live
585
642
  // streaming preview (same id) instead of rendering a duplicate bubble.
586
- bridge.push({ type: "say", text, id: meta?.id, streamed: meta?.streamed }, tabId);
643
+ bridge.push({ type: "say", text, id: meta?.id, streamed: meta?.streamed }, panelTabOf(key));
587
644
  },
588
645
  // Live streaming deltas → the panel's think-window + streaming reply bubble.
589
- onStream: (tabId, ev) => {
590
- bridge.push({ type: "stream", phase: ev.phase, id: ev.id, delta: ev.delta }, tabId);
646
+ onStream: (key, ev) => {
647
+ bridge.push({ type: "stream", phase: ev.phase, id: ev.id, delta: ev.delta }, panelTabOf(key));
591
648
  },
592
649
  // Per-response usage → the panel's context/usage meter (updates live).
593
- onStatus: pushStatus,
650
+ onStatus: (key, status) => pushStatus(panelTabOf(key), status),
594
651
  // Report the SDK session id so the panel can persist it and resume on reload.
595
- onSession: (tabId, sessionId) => {
596
- bridge.push({ type: "session", session_id: sessionId }, tabId);
652
+ onSession: (key, sessionId) => {
653
+ bridge.push({ type: "session", session_id: sessionId }, panelTabOf(key));
597
654
  },
598
655
  // Per-turn rewind anchor (assistant UUID) → the panel stores it so a later
599
656
  // "rewind conversation to here" can fork the session at that point.
600
- onTurnAnchor: (tabId, uuid) => {
601
- bridge.push({ type: "turn_anchor", uuid }, tabId);
657
+ onTurnAnchor: (key, uuid) => {
658
+ bridge.push({ type: "turn_anchor", uuid }, panelTabOf(key));
602
659
  },
603
660
  // Turn lifecycle → the panel's "working" indicator (stays up through silent
604
661
  // tool work; clears on done).
605
- onTurn: (tabId, state) => {
606
- bridge.push({ type: "turn", state }, tabId);
662
+ onTurn: (key, state) => {
663
+ bridge.push({ type: "turn", state }, panelTabOf(key));
607
664
  },
608
665
  // Live extended-thinking token count → "thinking… (N)" indicator.
609
- onThinking: (tabId, tokens) => {
610
- bridge.push({ type: "thinking", tokens }, tabId);
666
+ onThinking: (key, tokens) => {
667
+ bridge.push({ type: "thinking", tokens }, panelTabOf(key));
611
668
  },
612
669
  // The agent dequeued a message (the true "read" moment) → flip that bubble
613
670
  // from queued/muted to read.
614
- onSeen: (tabId, mid) => {
615
- bridge.push({ type: "ack", ok: true, kind: "seen", mid }, tabId);
671
+ onSeen: (key, mid) => {
672
+ bridge.push({ type: "ack", ok: true, kind: "seen", mid }, panelTabOf(key));
616
673
  },
617
674
  // ROOT-CAUSE self-exit (the "bridge open but no panel agent responded" wedge):
618
675
  // a tab's agent died fatally (couldn't start, or its bounded self-restart gave
@@ -629,6 +686,52 @@ export async function runPanelOrchestrator() {
629
686
  // Let refreshEnvCapabilities() feed a freshly-gathered env block into agents
630
687
  // spawned after a ComfyUI restart/reconnect.
631
688
  liveManager = manager;
689
+ // Retarget the live ComfyUI from the panel's `hello.comfyui_url` (the URL the
690
+ // browser was SERVED FROM — window.location). This is what lets a bare
691
+ // `--panel-orchestrator` (booted on the localhost default) auto-point at whatever
692
+ // ComfyUI the user actually has open — local OR a RunPod proxy — with no
693
+ // `connect <url>`. Loopback → LOCAL mode (keep COMFYUI_PATH); non-loopback →
694
+ // REMOTE mode (drop the path). No-op if unchanged. Returns true if it retargeted.
695
+ const applyComfyuiUrl = (rawUrl) => {
696
+ if (typeof rawUrl !== "string")
697
+ return false;
698
+ const next = rawUrl.trim().replace(/\/+$/, "");
699
+ if (!next)
700
+ return false;
701
+ let host;
702
+ try {
703
+ const parsed = new URL(next);
704
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
705
+ return false;
706
+ host = parsed.hostname;
707
+ }
708
+ catch {
709
+ return false; // not a valid URL — ignore (keep current target)
710
+ }
711
+ if (!host || next === comfyuiUrl)
712
+ return false;
713
+ const prev = comfyuiUrl;
714
+ comfyuiUrl = next;
715
+ comfyuiPath = isLoopbackUrl(next) ? envComfyuiPath : undefined;
716
+ // Point every provider at the new target: Claude via its rebuilt MCP env, the
717
+ // manager's image-fetch URL, then respawn active agents so the live comfyui MCP
718
+ // subprocess is recreated with the new COMFYUI_URL (no-op if none are running —
719
+ // the next spawn picks it up from the now-updated closures).
720
+ manager.setMcpServers(buildMcpServers());
721
+ manager.setComfyuiUrl(comfyuiUrl);
722
+ manager.restartAllForMcpEnv();
723
+ // Re-point the render watchdog and re-probe the env (remote vs local differs).
724
+ try {
725
+ QueueMonitor.stop();
726
+ }
727
+ catch {
728
+ /* best-effort */
729
+ }
730
+ QueueMonitor.start(comfyuiUrl);
731
+ void refreshEnvCapabilities();
732
+ logger.info(`[panel-orchestrator] retargeted ComfyUI ${prev} → ${comfyuiUrl} (${isLoopbackUrl(next) ? "local" : "remote"} mode) from panel hello`);
733
+ return true;
734
+ };
632
735
  // Tool secrets → comfyui MCP env: when the user saves a token via
633
736
  // panel_request_secret (e.g. CIVITAI_API_TOKEN for download_civitai_model), the
634
737
  // secret store persists it and fires this. We rebuild the comfyui server's spawn
@@ -650,52 +753,59 @@ export async function runPanelOrchestrator() {
650
753
  // works on the subscription lane) and cached. Pushed to each tab so the
651
754
  // panel's model/effort picker reflects what's actually available, with each
652
755
  // model's supported effort levels, instead of a hardcoded list.
653
- let modelsPromise = null;
654
- function ensureModels() {
655
- if (!modelsPromise) {
656
- // Non-Claude mode (P1-2): enumerate via the selected backend (which also
657
- // proves the CLI can start = readiness) NEVER the Claude SDK probe. Shape
658
- // the backend's ModelChoice[] into the panel's ModelInfo[] form.
659
- // NOTE (gemini): the Gemini probe is prepare()+listModels, which proves the
660
- // CLI launches + the ACP handshake works but does NOT verify Google sign-in
661
- // (ACP reports auth only at session/new), so the "ready" ack is PROVISIONAL
662
- // for Gemini — a signed-out CLI still acks green here and surfaces a clear
663
- // one-shot sign-in error on the first turn (no loop). The panel separately
664
- // gates the UI via oauth_creds detection, so this is acceptable.
665
- const probe = probeBackend
666
- ? Promise.resolve(probeBackend.prepare?.())
667
- .then(() => probeBackend.listModels())
668
- .then((list) =>
669
- // Carry the effort metadata through to the panel's ModelInfo — without
670
- // this the supportsEffort/supportedEffortLevels the Codex backend
671
- // advertises get dropped here and the panel hides the effort dropdown.
672
- list.map((m) => ({
756
+ // Model list PER BACKEND — probed lazily and cached; an empty/failed probe is
757
+ // NOT cached so the next hello retries. Claude uses fetchSupportedModels() (the
758
+ // only path that works on the subscription lane); codex/gemini enumerate via a
759
+ // throwaway probe backend, which also proves the CLI can launch (= readiness).
760
+ // (Gemini's probe proves the CLI + ACP handshake but not Google sign-in, which
761
+ // ACP only reports at session/new so its "ready" is provisional; a signed-out
762
+ // CLI surfaces a clear one-shot error on the first turn.)
763
+ const modelsByBackend = new Map();
764
+ function ensureModels(backend) {
765
+ let p = modelsByBackend.get(backend);
766
+ if (!p) {
767
+ const pb = getProbeBackend(backend);
768
+ const probe = pb
769
+ ? Promise.resolve(pb.prepare?.())
770
+ .then(() => pb.listModels())
771
+ .then((list) => list.map((m) => ({
673
772
  value: m.id,
674
773
  displayName: m.label ?? m.id,
675
774
  ...(m.supportsEffort != null ? { supportsEffort: m.supportsEffort } : {}),
676
775
  ...(m.supportedEffortLevels ? { supportedEffortLevels: m.supportedEffortLevels } : {}),
677
776
  })))
678
777
  .catch((err) => {
679
- logger.warn(`[panel-orchestrator] codex model probe failed: ${err instanceof Error ? err.message : String(err)}`);
778
+ logger.warn(`[panel-orchestrator] ${backend} model probe failed: ${err instanceof Error ? err.message : String(err)}`);
680
779
  return [];
681
780
  })
682
781
  : fetchSupportedModels(model);
683
- modelsPromise = probe.then((list) => {
684
- // Don't cache an empty/failed probe forever — let the next hello retry.
782
+ p = probe.then((list) => {
685
783
  if (!list.length)
686
- modelsPromise = null;
784
+ modelsByBackend.delete(backend); // don't cache a failure
687
785
  return list;
688
786
  });
787
+ modelsByBackend.set(backend, p);
689
788
  }
690
- return modelsPromise;
789
+ return p;
790
+ }
791
+ // The model to highlight as "current" for a backend: the panel's configured
792
+ // model for claude; the env override (or account default = the list's own
793
+ // current) for codex/gemini.
794
+ function currentModelFor(backend) {
795
+ if (backend === "codex")
796
+ return codexModel;
797
+ if (backend === "gemini")
798
+ return geminiModel;
799
+ return model;
691
800
  }
692
- function pushModels(tabId) {
693
- void ensureModels()
801
+ function pushModels(panelTabId) {
802
+ const backend = backendForTab(panelTabId);
803
+ void ensureModels(backend)
694
804
  .then((models) => {
695
805
  if (models.length) {
696
- // `backend` rides on the models frame so the panel's backend picker can
697
- // reflect which provider this orchestrator is actually running as.
698
- bridge.push({ type: "models", models, current: model, backend: backendId }, tabId);
806
+ // `backend` rides on the models frame so the panel's picker reflects the
807
+ // provider THIS tab selected (single-port multi-provider).
808
+ bridge.push({ type: "models", models, current: currentModelFor(backend), backend }, panelTabId);
699
809
  }
700
810
  })
701
811
  .catch(() => {
@@ -720,10 +830,8 @@ export async function runPanelOrchestrator() {
720
830
  // the built-ins that make sense inside the ComfyUI panel chat.
721
831
  const PANEL_SLASH_ALLOWLIST = new Set(["compact", "context", "usage", "loop", "goal", "clear"]);
722
832
  function pushCommands(tabId) {
723
- // Non-Claude mode (P1-2): no Claude slash-commands skip the Claude SDK probe
724
- // entirely (CODEX/GEMINI_CAPABILITIES.slashCommands === false).
725
- if (isHttpPanelBackend)
726
- return;
833
+ // Claude-only: SDK slash-commands don't exist for codex/gemini. Callers already
834
+ // gate this to claude tabs (single-port multi-provider), so no backend check here.
727
835
  void ensureCommands()
728
836
  .then((commands) => {
729
837
  const useful = commands.filter((c) => PANEL_SLASH_ALLOWLIST.has(c.name));
@@ -740,69 +848,109 @@ export async function runPanelOrchestrator() {
740
848
  // socket is open." A bare/undriven bridge stays silent, so the panel can
741
849
  // tell the difference (and warn if no ack arrives).
742
850
  if (event.type === "hello" && event.tab_id) {
743
- // Reload restore: the panel re-sends the last session id it saw so the
744
- // agent's memory continues. Only honored before the tab's agent spawns.
851
+ const panelTab = event.tab_id;
852
+ // Retarget ComfyUI to the URL the browser was served from (window.location),
853
+ // BEFORE the readiness probe so the "ready" ack reflects the right instance.
854
+ applyComfyuiUrl(event.comfyui_url);
855
+ // Per-tab backend selection (single-port multi-provider). The panel names
856
+ // its chosen provider on connect (and on a switch it re-sends hello / a
857
+ // set_backend); absent or unknown → the default.
858
+ const reqBackend = typeof event.backend === "string"
859
+ ? event.backend.toLowerCase()
860
+ : undefined;
861
+ const backend = reqBackend && KNOWN_BACKENDS.has(reqBackend) ? reqBackend : defaultBackend;
862
+ const prev = tabBackends.get(panelTab);
863
+ if (prev && prev !== backend) {
864
+ // Provider switch: retire the previous provider's agent for this tab so it
865
+ // doesn't linger. The new provider starts a FRESH session (the panel
866
+ // replays the transcript as context on its first message to seed it).
867
+ manager.reset(panelTab + AGENT_KEY_SEP + prev);
868
+ }
869
+ tabBackends.set(panelTab, backend);
870
+ const key = panelTab + AGENT_KEY_SEP + backend;
871
+ // Reload restore: the panel re-sends the last session id it saw. Honored
872
+ // only for a SAME-provider (re)connect — a switch always starts fresh. The
873
+ // orchestrator's own store stays authoritative on the actual spawn.
745
874
  const resume = typeof event.resume === "string" ? event.resume : undefined;
746
- if (resume)
747
- manager.setResume(event.tab_id, resume);
748
- // Send the live model list so the picker reflects the real subscription,
749
- // and the SDK's slash commands so the composer can surface them.
750
- pushModels(event.tab_id);
751
- pushCommands(event.tab_id);
875
+ if (resume && (!prev || prev === backend))
876
+ manager.setResume(key, resume);
877
+ // Live model list for the picker; SDK slash commands are Claude-only.
878
+ pushModels(panelTab);
879
+ if (backend === "claude")
880
+ pushCommands(panelTab);
752
881
  // Re-push the last usage so the context meter isn't blank after a reload.
753
- const lastStatus = manager.lastStatusFor(event.tab_id);
882
+ const lastStatus = manager.lastStatusFor(key);
754
883
  if (lastStatus)
755
- pushStatus(event.tab_id, lastStatus);
756
- const tabId = event.tab_id;
884
+ pushStatus(panelTab, lastStatus);
757
885
  const now = Date.now();
758
- if (now - (lastAckAt.get(tabId) ?? 0) < ACK_DEBOUNCE_MS)
886
+ if (now - (lastAckAt.get(panelTab) ?? 0) < ACK_DEBOUNCE_MS)
759
887
  return;
760
- lastAckAt.set(tabId, now);
761
- // TRUTHFUL "connected": only claim ready after PROVING the SDK can run, by
762
- // probing the model list (same machinery the agent uses to spawn). If the
763
- // probe fails — the "connected but dead" wedge say so and send a degraded
764
- // ack instead of a green ready, so the panel can show the real state.
765
- void ensureModels()
888
+ lastAckAt.set(panelTab, now);
889
+ const isCx = backend === "codex";
890
+ const isGm = backend === "gemini";
891
+ // TRUTHFUL "connected": only claim ready after PROVING the SELECTED backend
892
+ // can run, by probing its model list. If the probe fails the "connected
893
+ // but dead" wedge — send a degraded ack so the panel shows the real state.
894
+ void ensureModels(backend)
766
895
  .then((models) => {
767
896
  if (models.length) {
768
- // Greet only on a FRESH session. On a reconnect/resume — a panel swap,
769
- // a WS blip, or a real restart (all carry `resume`) — the user already
770
- // has their thread, so re-greeting is just noise. The ack still fires.
771
- // Backend-appropriate messaging (P1-2): each provider must name its own
772
- // account/auth, and the agent label is that provider's model (Codex/
773
- // Gemini account default when the env override is unset).
774
- const agentLabel = isCodex
897
+ const agentLabel = isCx
775
898
  ? (codexModel ?? models[0].value ?? "Codex")
776
- : isGemini
899
+ : isGm
777
900
  ? (geminiModel ?? models[0].value ?? "Gemini")
778
901
  : model;
902
+ // Greet only on a FRESH session (a resume/reconnect already has the thread).
779
903
  if (!resume) {
780
- const readyText = isCodex
904
+ const readyText = isCx
781
905
  ? `🟢 comfyui-mcp agent ready — ${agentLabel} on your Codex (ChatGPT) account. Ask away.`
782
- : isGemini
906
+ : isGm
783
907
  ? `🟢 comfyui-mcp agent ready — ${agentLabel} on your Google account (Gemini Code Assist). Ask away.`
784
908
  : `🟢 comfyui-mcp agent ready — ${agentLabel} on your Claude subscription. Ask away.`;
785
- bridge.push({ type: "say", text: readyText }, tabId);
909
+ bridge.push({ type: "say", text: readyText }, panelTab);
786
910
  }
787
- bridge.push({ type: "ack", ok: true, kind: "ready", agent: agentLabel, backend: backendId }, tabId);
788
- logger.info(`[panel-orchestrator] tab ${tabId.slice(0, 8)} connected — agent healthy, sent ready ack`);
911
+ bridge.push({ type: "ack", ok: true, kind: "ready", agent: agentLabel, backend }, panelTab);
912
+ logger.info(`[panel-orchestrator] tab ${panelTab.slice(0, 8)} connected (${backend}) — agent healthy, ready ack`);
789
913
  }
790
914
  else {
791
- const degradedText = isCodex
915
+ const degradedText = isCx
792
916
  ? "⚠️ The background agent isn't responding — the Codex app-server couldn't start. Make sure Codex is installed and signed in (run `codex login`), then Disconnect → Connect to retry."
793
- : isGemini
917
+ : isGm
794
918
  ? "⚠️ The background agent isn't responding — the Gemini CLI couldn't start. Make sure the Gemini CLI is installed and signed in (run `gemini` once and complete the Google sign-in), then Disconnect → Connect to retry."
795
919
  : "⚠️ The background agent isn't responding — the Claude Agent SDK couldn't start. Make sure you're signed in (run `claude` once), then Disconnect → Connect to retry.";
796
- bridge.push({ type: "say", text: degradedText }, tabId);
797
- bridge.push({ type: "ack", ok: false, kind: "degraded" }, tabId);
798
- logger.warn(`[panel-orchestrator] tab ${tabId.slice(0, 8)} connected but model probe empty — sent degraded ack`);
920
+ bridge.push({ type: "say", text: degradedText }, panelTab);
921
+ bridge.push({ type: "ack", ok: false, kind: "degraded" }, panelTab);
922
+ logger.warn(`[panel-orchestrator] tab ${panelTab.slice(0, 8)} connected (${backend}) but model probe empty — degraded ack`);
799
923
  }
800
924
  })
801
925
  .catch(() => {
802
- bridge.push({ type: "ack", ok: false, kind: "degraded" }, tabId);
926
+ bridge.push({ type: "ack", ok: false, kind: "degraded" }, panelTab);
803
927
  });
804
928
  return;
805
929
  }
930
+ // Provider switch WITHOUT a reconnect (single-port multi-provider): the panel
931
+ // picked a different backend chip. Retire the old provider's agent, remember
932
+ // the new one, and re-advertise its models. The panel replays the transcript
933
+ // as context on its next message so the fresh provider has the conversation.
934
+ if (event.type === "set_backend" && event.tab_id) {
935
+ const panelTab = event.tab_id;
936
+ const reqBackend = typeof event.backend === "string"
937
+ ? event.backend.toLowerCase()
938
+ : "";
939
+ if (!KNOWN_BACKENDS.has(reqBackend)) {
940
+ bridge.push({ type: "ack", ok: false, kind: "set_backend", message: `unknown backend '${reqBackend}'` }, panelTab);
941
+ return;
942
+ }
943
+ const prev = tabBackends.get(panelTab) ?? defaultBackend;
944
+ if (prev !== reqBackend)
945
+ manager.reset(panelTab + AGENT_KEY_SEP + prev);
946
+ tabBackends.set(panelTab, reqBackend);
947
+ pushModels(panelTab);
948
+ if (reqBackend === "claude")
949
+ pushCommands(panelTab);
950
+ bridge.push({ type: "ack", ok: true, kind: "set_backend", backend: reqBackend }, panelTab);
951
+ logger.info(`[panel-orchestrator] tab ${panelTab.slice(0, 8)} switched backend ${prev} → ${reqBackend}`);
952
+ return;
953
+ }
806
954
  // Live panel config (currently just the render-stall threshold). Applied
807
955
  // immediately, no reconnect — the next turn's watchdog check uses the new
808
956
  // value. Sent by the panel on connect and whenever the setting changes.
@@ -830,13 +978,13 @@ export async function runPanelOrchestrator() {
830
978
  // makes the SDK session hang on init. (Defense in depth; the panel only
831
979
  // sends ids from the live catalog.)
832
980
  if (nextModel) {
833
- const known = await ensureModels().catch(() => []);
981
+ const known = await ensureModels(backendForTab(tabId)).catch(() => []);
834
982
  if (known.length && !known.some((m) => m.value === nextModel)) {
835
983
  logger.warn(`[panel-orchestrator] ignoring unknown model "${nextModel}" — keeping current`);
836
984
  nextModel = undefined;
837
985
  }
838
986
  }
839
- const applied = await manager.setOptions(tabId, { model: nextModel, effort: nextEffort });
987
+ const applied = await manager.setOptions(agentKeyFor(tabId), { model: nextModel, effort: nextEffort });
840
988
  bridge.push({
841
989
  type: "ack",
842
990
  ok: true,
@@ -862,11 +1010,11 @@ export async function runPanelOrchestrator() {
862
1010
  // look at me") so the agent stops and fixes it instead of running blind.
863
1011
  // Everything else (e.g. a finished render's images) is enqueued normally.
864
1012
  if (ev.kind === "run_error") {
865
- void manager.injectRunError(event.tab_id, ev.error ?? "unknown error");
1013
+ void manager.injectRunError(agentKeyFor(event.tab_id), ev.error ?? "unknown error");
866
1014
  logger.info(`[panel-orchestrator] tab ${event.tab_id.slice(0, 8)} run_error → agent (interrupt)`);
867
1015
  return;
868
1016
  }
869
- const delivered = manager.injectEvent(event.tab_id, ev);
1017
+ const delivered = manager.injectEvent(agentKeyFor(event.tab_id), ev);
870
1018
  if (delivered) {
871
1019
  logger.info(`[panel-orchestrator] tab ${event.tab_id.slice(0, 8)} event → agent: ${event.kind}`);
872
1020
  }
@@ -880,7 +1028,7 @@ export async function runPanelOrchestrator() {
880
1028
  // interrupted turn so BOTH messages get answered; a plain Stop/Ctrl+C/Esc
881
1029
  // sends a bare interrupt and must NOT re-run the stopped turn.
882
1030
  const requeueInFlight = event.requeue === true;
883
- void manager.interrupt(tabId, { requeueInFlight });
1031
+ void manager.interrupt(agentKeyFor(tabId), { requeueInFlight });
884
1032
  bridge.push({ type: "ack", ok: true, kind: "interrupt" }, tabId);
885
1033
  logger.info(`[panel-orchestrator] tab ${tabId.slice(0, 8)} interrupted${requeueInFlight ? " (send-now: re-queue)" : ""}`);
886
1034
  return;
@@ -890,7 +1038,7 @@ export async function runPanelOrchestrator() {
890
1038
  if (event.type === "cancel_message" && event.tab_id) {
891
1039
  const tabId = event.tab_id;
892
1040
  const mid = typeof event.mid === "string" ? event.mid : undefined;
893
- const removed = mid ? manager.cancelQueued(tabId, mid) : false;
1041
+ const removed = mid ? manager.cancelQueued(agentKeyFor(tabId), mid) : false;
894
1042
  bridge.push({ type: "ack", ok: true, kind: "cancel_message", mid, removed }, tabId);
895
1043
  return;
896
1044
  }
@@ -900,7 +1048,7 @@ export async function runPanelOrchestrator() {
900
1048
  const tabId = event.tab_id;
901
1049
  // reset() is synchronous (map cleared now), so no concurrent send() can
902
1050
  // spawn an agent before we report the cleared session.
903
- manager.reset(tabId);
1051
+ manager.reset(agentKeyFor(tabId));
904
1052
  bridge.push({ type: "session", session_id: null }, tabId);
905
1053
  bridge.push({ type: "ack", ok: true, kind: "new_session" }, tabId);
906
1054
  return;
@@ -912,7 +1060,7 @@ export async function runPanelOrchestrator() {
912
1060
  if (event.type === "rewind" && event.tab_id) {
913
1061
  const tabId = event.tab_id;
914
1062
  const anchor = typeof event.anchor === "string" ? event.anchor : null;
915
- const ok = manager.rewind(tabId, anchor);
1063
+ const ok = manager.rewind(agentKeyFor(tabId), anchor);
916
1064
  bridge.push({ type: "ack", ok, kind: "rewind" }, tabId);
917
1065
  logger.info(`[panel-orchestrator] tab ${tabId.slice(0, 8)} rewind (anchor=${anchor ? anchor.slice(0, 8) : "fresh"}, ok=${ok})`);
918
1066
  return;
@@ -923,7 +1071,7 @@ export async function runPanelOrchestrator() {
923
1071
  const order = Array.isArray(event.order)
924
1072
  ? event.order.filter((m) => typeof m === "string")
925
1073
  : [];
926
- const ok = manager.reorderQueue(tabId, order);
1074
+ const ok = manager.reorderQueue(agentKeyFor(tabId), order);
927
1075
  bridge.push({ type: "ack", ok, kind: "reorder" }, tabId);
928
1076
  logger.info(`[panel-orchestrator] tab ${tabId.slice(0, 8)} reorder queue (${order.length} mids, ok=${ok})`);
929
1077
  return;
@@ -934,9 +1082,10 @@ export async function runPanelOrchestrator() {
934
1082
  if (event.type === "resume_session" && event.tab_id) {
935
1083
  const tabId = event.tab_id;
936
1084
  const sid = typeof event.session_id === "string" ? event.session_id : undefined;
937
- manager.reset(tabId);
1085
+ const key = agentKeyFor(tabId);
1086
+ manager.reset(key);
938
1087
  if (sid)
939
- manager.setResume(tabId, sid);
1088
+ manager.setResume(key, sid);
940
1089
  bridge.push({ type: "ack", ok: true, kind: "resume_session" }, tabId);
941
1090
  return;
942
1091
  }
@@ -1000,7 +1149,17 @@ export async function runPanelOrchestrator() {
1000
1149
  catch (err) {
1001
1150
  logger.debug(`[panel-orchestrator] queue-note check failed (ignored): ${err instanceof Error ? err.message : String(err)}`);
1002
1151
  }
1003
- manager.send(event.tab_id, outText, {
1152
+ // Transcript replay (single-port provider switch): the panel sends the prior
1153
+ // conversation as `context` on the FIRST message to a freshly-switched
1154
+ // provider, so the new backend has the thread (minus internal session data —
1155
+ // thinking/tool traces/cache aren't portable across providers). Prepend it the
1156
+ // same way crash/queue notes are, so it seeds the fresh session's first turn.
1157
+ const replay = typeof event.context === "string"
1158
+ ? event.context.trim()
1159
+ : "";
1160
+ if (replay)
1161
+ outText = `${replay}\n\n${outText}`;
1162
+ manager.send(agentKeyFor(event.tab_id), outText, {
1004
1163
  title: event.title,
1005
1164
  images: event.images,
1006
1165
  mid: userMid,
@@ -1083,9 +1242,11 @@ export async function runPanelOrchestrator() {
1083
1242
  QueueMonitor.stop();
1084
1243
  unsubscribeSecrets();
1085
1244
  await manager.stopAll();
1086
- // Dispose the readiness-probe backend (kills its Codex/Gemini CLI child).
1087
- if (probeBackend?.close)
1088
- await probeBackend.close().catch(() => { });
1245
+ // Dispose the readiness-probe backends (kills each Codex/Gemini CLI child).
1246
+ for (const pb of probeBackends.values()) {
1247
+ if (pb.close)
1248
+ await pb.close().catch(() => { });
1249
+ }
1089
1250
  // Tear down the loopback panel HTTP MCP (codex/gemini mode only).
1090
1251
  if (panelMcpHttp)
1091
1252
  await panelMcpHttp.stop().catch(() => { });