comfyui-mcp 0.22.0 → 0.23.1

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 (42) 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/backend-readiness.js +80 -0
  5. package/dist/orchestrator/backend-readiness.js.map +1 -0
  6. package/dist/orchestrator/codex-backend.js +32 -4
  7. package/dist/orchestrator/codex-backend.js.map +1 -1
  8. package/dist/orchestrator/index.js +333 -154
  9. package/dist/orchestrator/index.js.map +1 -1
  10. package/dist/orchestrator/panel-agent.js +49 -14
  11. package/dist/orchestrator/panel-agent.js.map +1 -1
  12. package/dist/services/env-capabilities.js +95 -1
  13. package/dist/services/env-capabilities.js.map +1 -1
  14. package/dist/services/image-management.js +100 -1
  15. package/dist/services/image-management.js.map +1 -1
  16. package/dist/services/manifest.js +71 -14
  17. package/dist/services/manifest.js.map +1 -1
  18. package/dist/services/model-resolver.js +143 -1
  19. package/dist/services/model-resolver.js.map +1 -1
  20. package/dist/services/node-management.js +46 -0
  21. package/dist/services/node-management.js.map +1 -1
  22. package/dist/tools/image-management.js +8 -4
  23. package/dist/tools/image-management.js.map +1 -1
  24. package/dist/tools/manifest.js +1 -1
  25. package/dist/tools/manifest.js.map +1 -1
  26. package/dist/tools/model-extras.js +27 -8
  27. package/dist/tools/model-extras.js.map +1 -1
  28. package/dist/tools/model-management.js +1 -1
  29. package/dist/tools/model-management.js.map +1 -1
  30. package/dist/tools/node-management.js +21 -0
  31. package/dist/tools/node-management.js.map +1 -1
  32. package/dist/transport/cli.js +39 -2
  33. package/dist/transport/cli.js.map +1 -1
  34. package/package.json +2 -2
  35. package/packs/wan-multitalk/install-runpod.sh +44 -0
  36. package/packs/wan-multitalk/install-windows.bat +49 -0
  37. package/packs/wan-multitalk/manifest.yaml +50 -0
  38. package/packs/wan-multitalk/pack.yaml +28 -0
  39. package/packs/wan-multitalk/workflow.json +1432 -0
  40. package/plugin/skills/triton-sageattention/SKILL.md +15 -0
  41. package/plugin/skills/troubleshooting/SKILL.md +56 -0
  42. package/plugin/skills/wan-multitalk/SKILL.md +97 -0
@@ -22,6 +22,7 @@ import { readUserMcpServers } from "../services/user-mcp-config.js";
22
22
  import { buildComfyuiMcpEnv, comfyuiSecretKeys, onComfyuiSecretsChanged, } from "../services/panel-secrets.js";
23
23
  import { CodexBackend } from "./codex-backend.js";
24
24
  import { GeminiBackend, GEMINI_DEFAULT_MODEL } from "./gemini-backend.js";
25
+ import { allBackendReadiness } from "./backend-readiness.js";
25
26
  import { startPanelMcpHttpServer } from "./panel-mcp-http.js";
26
27
  import { readComfyuiCrashLog, formatCrashNote } from "../services/crash-log.js";
27
28
  import { QueueMonitor } from "../services/queue-monitor.js";
@@ -56,6 +57,8 @@ RESOLVING A TANGLED / TOGGLE-HEAVY WORKFLOW (Get/Set buses + rgthree-bypassed pi
56
57
 
57
58
  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
59
 
60
+ 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.
61
+
59
62
  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
63
 
61
64
  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 +345,28 @@ export async function runPanelOrchestrator() {
342
345
  // mode — so it generates against the live ComfyUI over COMFYUI_URL and never
343
346
  // tries to bind the bridge port we own here.
344
347
  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;
348
+ // Mutable: the panel sends the ComfyUI URL it was SERVED FROM (window.location)
349
+ // in `hello`, and the orchestrator retargets to it (applyComfyuiUrl) so
350
+ // `--panel-orchestrator` boots on the localhost default and auto-points at
351
+ // whatever ComfyUI (local or a RunPod proxy) the browser is actually on. No
352
+ // `connect <url>` needed.
353
+ let comfyuiUrl = process.env.COMFYUI_URL ?? "http://127.0.0.1:8188";
354
+ // ComfyUI install path — when set AND the target is loopback, the spawned agent's
355
+ // MCP runs in LOCAL mode (download_model / apply_manifest / installer-pack /
356
+ // model-scan tools). A REMOTE target (non-loopback) forces remote-only, so we
357
+ // drop the path. `envComfyuiPath` is the orchestrator's own env value; the live
358
+ // `comfyuiPath` is derived from it + the current target.
359
+ const envComfyuiPath = process.env.COMFYUI_PATH;
360
+ const isLoopbackUrl = (u) => {
361
+ try {
362
+ const h = new URL(u).hostname.toLowerCase();
363
+ return h === "127.0.0.1" || h === "localhost" || h === "::1" || h === "0.0.0.0" || h === "";
364
+ }
365
+ catch {
366
+ return true;
367
+ }
368
+ };
369
+ let comfyuiPath = isLoopbackUrl(comfyuiUrl) ? envComfyuiPath : undefined;
350
370
  const model = process.env.COMFYUI_MCP_PANEL_MODEL ?? "claude-opus-4-8";
351
371
  const envEffort = process.env.COMFYUI_MCP_PANEL_EFFORT;
352
372
  const effort = isEffort(envEffort) ? envEffort : undefined;
@@ -409,12 +429,31 @@ export async function runPanelOrchestrator() {
409
429
  // COMFYUI_MCP_GEMINI_MODEL (default gemini-2.5-pro). The model is applied at spawn
410
430
  // via the CLI `--model` flag (ACP exposes no per-session model setter).
411
431
  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;
432
+ // ── Per-tab backend (single-port multi-provider) ──────────────────────────
433
+ // ONE orchestrator on ONE bridge port serves ALL providers; the panel picks a
434
+ // provider per tab via the `hello`/`set_backend` handshake, instead of the node
435
+ // spawning one process per provider on its own port (9180/9181/9182). Internally
436
+ // each (panel tab, backend) pair is one agent addressed by a composite key
437
+ // `tabId::backend`, so switching provider starts a FRESH session for that
438
+ // provider (the panel replays the transcript to seed it) while a same-provider
439
+ // reconnect RESUMES. `backendId`/`codexModel`/`geminiModel` above are the
440
+ // DEFAULT + per-provider model config; the process is no longer pinned to one.
441
+ const KNOWN_BACKENDS = new Set(["claude", "codex", "gemini"]);
442
+ const defaultBackend = KNOWN_BACKENDS.has(backendId) ? backendId : "claude";
443
+ const AGENT_KEY_SEP = "::";
444
+ const tabBackends = new Map(); // panel tabId -> selected backend
445
+ const backendForTab = (panelTabId) => tabBackends.get(panelTabId) ?? defaultBackend;
446
+ const agentKeyFor = (panelTabId) => panelTabId + AGENT_KEY_SEP + backendForTab(panelTabId);
447
+ // A panel tab id never contains "::"; backend names never do — so split on the
448
+ // LAST separator to recover each half from a composite key.
449
+ const panelTabOf = (key) => {
450
+ const i = key.lastIndexOf(AGENT_KEY_SEP);
451
+ return i >= 0 ? key.slice(0, i) : key;
452
+ };
453
+ const backendOf = (key) => {
454
+ const i = key.lastIndexOf(AGENT_KEY_SEP);
455
+ return i >= 0 ? key.slice(i + AGENT_KEY_SEP.length) : defaultBackend;
456
+ };
418
457
  // ---- live ENVIRONMENT-CAPABILITIES block ----
419
458
  // Gather the machine's facts ONCE at startup (CACHED) — OS/CPU/RAM from node,
420
459
  // GPU/VRAM/CUDA/torch/python/ComfyUI from /system_stats, Triton/SageAttention by
@@ -462,7 +501,9 @@ export async function runPanelOrchestrator() {
462
501
  // A panel-saved tool secret (CIVITAI_API_TOKEN, HF_TOKEN, …) is layered on top
463
502
  // by buildComfyuiMcpEnv() at SPAWN time, so the same headless tool surface — and
464
503
  // the same secrets — reach either provider.
465
- const comfyuiBaseEnv = {
504
+ // A FUNCTION (not a frozen object) so it always reflects the CURRENT retargeted
505
+ // comfyuiUrl/comfyuiPath — makeHttpBackendMcpServers calls it per (re)spawn.
506
+ const comfyuiBaseEnv = () => ({
466
507
  COMFYUI_URL: comfyuiUrl,
467
508
  COMFYUI_MCP_PROGRESS_DIR: progressDir,
468
509
  ...(comfyuiPath ? { COMFYUI_PATH: comfyuiPath } : {}),
@@ -472,19 +513,23 @@ export async function runPanelOrchestrator() {
472
513
  ...(process.env.HF_TOKEN ? { HF_TOKEN: process.env.HF_TOKEN } : {}),
473
514
  // Test-only tool-call trace (knowledge-parity smoke). No-op unless set.
474
515
  ...(process.env.COMFYUI_MCP_TOOL_TRACE ? { COMFYUI_MCP_TOOL_TRACE: process.env.COMFYUI_MCP_TOOL_TRACE } : {}),
475
- };
516
+ });
476
517
  // The orchestrator-hosted loopback HTTP MCP for panel_* tools. Started for the
477
518
  // non-Claude backends (Codex + Gemini), which can't host an in-process SDK MCP
478
519
  // server the way Claude does. Port: COMFYUI_MCP_PANEL_MCP_PORT, default
479
520
  // bridgePort+1 (loopback only).
521
+ // Start the loopback HTTP panel-MCP ALWAYS: with single-port multi-provider any
522
+ // tab may pick codex/gemini at runtime, and those backends drive the canvas
523
+ // through this server (Claude tabs use the in-process SDK server instead). The
524
+ // per-tab session routing (`urlFor(panelTabId)`) already isolates tabs.
480
525
  let panelMcpHttp = null;
481
- if (isHttpPanelBackend) {
526
+ {
482
527
  const panelMcpPort = Number(process.env.COMFYUI_MCP_PANEL_MCP_PORT) || bridgePort + 1;
483
528
  try {
484
529
  panelMcpHttp = await startPanelMcpHttpServer(bridge, panelMcpPort);
485
530
  }
486
531
  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)}`);
532
+ 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
533
  }
489
534
  }
490
535
  // Shared MCP server config for BOTH the Codex and Gemini backends — they take an
@@ -498,52 +543,61 @@ export async function runPanelOrchestrator() {
498
543
  args: [mcpEntry], // dist/index.js
499
544
  // Merge persisted tool secrets at SPAWN time so a respawn picks up a
500
545
  // just-saved CIVITAI_API_TOKEN / HF_TOKEN without a process restart.
501
- env: buildComfyuiMcpEnv(comfyuiBaseEnv),
546
+ env: buildComfyuiMcpEnv(comfyuiBaseEnv()),
502
547
  },
503
548
  // Live-graph panel_* tools for THIS tab over the loopback HTTP MCP.
504
549
  ...(panelMcpHttp
505
550
  ? { panel: { transport: "http", url: panelMcpHttp.urlFor(tabId) } }
506
551
  : {}),
507
552
  });
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({
553
+ // Build the provider backend for a composite agent key `panelTabId::backend`.
554
+ // Claude → undefined (PanelAgent uses its built-in in-process SDK backend);
555
+ // codex/gemini their CLI-driven backend, wired to the panel_* tools over the
556
+ // loopback HTTP MCP for THIS panel tab's canvas (comfyuiUrl gives vision parity).
557
+ const makeBackend = (key) => {
558
+ const backend = backendOf(key);
559
+ const panelTabId = panelTabOf(key);
560
+ if (backend === "codex") {
561
+ return new CodexBackend({
562
+ cwd: comfyuiPath ?? process.cwd(),
563
+ model: codexModel,
564
+ systemAppend: panelSystemAppend,
565
+ comfyuiUrl,
566
+ mcpServers: makeHttpBackendMcpServers(panelTabId),
567
+ });
568
+ }
569
+ if (backend === "gemini") {
570
+ return new GeminiBackend({
520
571
  cwd: comfyuiPath ?? process.cwd(),
521
572
  model: geminiModel,
522
573
  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
574
  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;
575
+ mcpServers: makeHttpBackendMcpServers(panelTabId),
576
+ });
577
+ }
578
+ return undefined; // claude → built-in ClaudeBackend
579
+ };
580
+ logger.info(`[panel-orchestrator] single-port multi-provider: default backend=${defaultBackend}; ` +
581
+ `codex/gemini panel_* live-graph tools via loopback HTTP MCP${panelMcpHttp ? ` on :${panelMcpHttp.port}` : " UNAVAILABLE"} + headless comfyui MCP`);
582
+ // Readiness/model probing routes through the SELECTED backend PER TAB a
583
+ // codex/gemini tab's "ready" must NOT depend on Claude SDK/login health. Claude
584
+ // uses fetchSupportedModels(); codex/gemini spin up a throwaway probe backend
585
+ // (which also proves the CLI can launch). Cached per backend so repeated hellos
586
+ // don't re-probe.
587
+ const probeBackends = new Map();
588
+ const getProbeBackend = (backend) => {
589
+ if (backend === "claude")
590
+ return null; // claude uses the SDK probe below
591
+ let pb = probeBackends.get(backend);
592
+ if (!pb) {
593
+ pb =
594
+ backend === "codex"
595
+ ? new CodexBackend({ cwd: comfyuiPath ?? process.cwd(), model: codexModel })
596
+ : new GeminiBackend({ cwd: comfyuiPath ?? process.cwd(), model: geminiModel });
597
+ probeBackends.set(backend, pb);
598
+ }
599
+ return pb;
600
+ };
547
601
  // Durable per-tab session ids (keyed by our bridge port), so a tab's agent
548
602
  // resumes its conversation even after the orchestrator PROCESS is killed and
549
603
  // respawned (a wedge auto-restart) — not just a soft reload.
@@ -577,42 +631,46 @@ export async function runPanelOrchestrator() {
577
631
  comfyuiUrl, // for fetching image bytes to inline into agent turns
578
632
  systemAppend: panelSystemAppend,
579
633
  pluginPath: pluginAvailable ? pluginPath : undefined,
580
- // Live-graph control of the user's open workflow, per tab (in-process).
581
- makePanelServer: (tabId) => createPanelMcpServer(bridge, tabId),
634
+ // In-process live-graph MCP for CLAUDE keys only (codex/gemini drive the
635
+ // canvas through the loopback HTTP MCP instead). Bound to the PANEL tab so
636
+ // panel_* tools reach the user's canvas regardless of the composite key.
637
+ makePanelServer: (key) => backendOf(key) === "claude" ? createPanelMcpServer(bridge, panelTabOf(key)) : undefined,
582
638
  mcpServers: buildMcpServers(),
583
- onSay: (tabId, text, meta) => {
639
+ // NOTE: manager callbacks fire with the composite agent key `tabId::backend`;
640
+ // panelTabOf() recovers the PANEL tab so every push reaches the right socket.
641
+ onSay: (key, text, meta) => {
584
642
  // `id` lets the panel reconcile this committed message with its live
585
643
  // streaming preview (same id) instead of rendering a duplicate bubble.
586
- bridge.push({ type: "say", text, id: meta?.id, streamed: meta?.streamed }, tabId);
644
+ bridge.push({ type: "say", text, id: meta?.id, streamed: meta?.streamed }, panelTabOf(key));
587
645
  },
588
646
  // 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);
647
+ onStream: (key, ev) => {
648
+ bridge.push({ type: "stream", phase: ev.phase, id: ev.id, delta: ev.delta }, panelTabOf(key));
591
649
  },
592
650
  // Per-response usage → the panel's context/usage meter (updates live).
593
- onStatus: pushStatus,
651
+ onStatus: (key, status) => pushStatus(panelTabOf(key), status),
594
652
  // 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);
653
+ onSession: (key, sessionId) => {
654
+ bridge.push({ type: "session", session_id: sessionId }, panelTabOf(key));
597
655
  },
598
656
  // Per-turn rewind anchor (assistant UUID) → the panel stores it so a later
599
657
  // "rewind conversation to here" can fork the session at that point.
600
- onTurnAnchor: (tabId, uuid) => {
601
- bridge.push({ type: "turn_anchor", uuid }, tabId);
658
+ onTurnAnchor: (key, uuid) => {
659
+ bridge.push({ type: "turn_anchor", uuid }, panelTabOf(key));
602
660
  },
603
661
  // Turn lifecycle → the panel's "working" indicator (stays up through silent
604
662
  // tool work; clears on done).
605
- onTurn: (tabId, state) => {
606
- bridge.push({ type: "turn", state }, tabId);
663
+ onTurn: (key, state) => {
664
+ bridge.push({ type: "turn", state }, panelTabOf(key));
607
665
  },
608
666
  // Live extended-thinking token count → "thinking… (N)" indicator.
609
- onThinking: (tabId, tokens) => {
610
- bridge.push({ type: "thinking", tokens }, tabId);
667
+ onThinking: (key, tokens) => {
668
+ bridge.push({ type: "thinking", tokens }, panelTabOf(key));
611
669
  },
612
670
  // The agent dequeued a message (the true "read" moment) → flip that bubble
613
671
  // from queued/muted to read.
614
- onSeen: (tabId, mid) => {
615
- bridge.push({ type: "ack", ok: true, kind: "seen", mid }, tabId);
672
+ onSeen: (key, mid) => {
673
+ bridge.push({ type: "ack", ok: true, kind: "seen", mid }, panelTabOf(key));
616
674
  },
617
675
  // ROOT-CAUSE self-exit (the "bridge open but no panel agent responded" wedge):
618
676
  // a tab's agent died fatally (couldn't start, or its bounded self-restart gave
@@ -629,6 +687,52 @@ export async function runPanelOrchestrator() {
629
687
  // Let refreshEnvCapabilities() feed a freshly-gathered env block into agents
630
688
  // spawned after a ComfyUI restart/reconnect.
631
689
  liveManager = manager;
690
+ // Retarget the live ComfyUI from the panel's `hello.comfyui_url` (the URL the
691
+ // browser was SERVED FROM — window.location). This is what lets a bare
692
+ // `--panel-orchestrator` (booted on the localhost default) auto-point at whatever
693
+ // ComfyUI the user actually has open — local OR a RunPod proxy — with no
694
+ // `connect <url>`. Loopback → LOCAL mode (keep COMFYUI_PATH); non-loopback →
695
+ // REMOTE mode (drop the path). No-op if unchanged. Returns true if it retargeted.
696
+ const applyComfyuiUrl = (rawUrl) => {
697
+ if (typeof rawUrl !== "string")
698
+ return false;
699
+ const next = rawUrl.trim().replace(/\/+$/, "");
700
+ if (!next)
701
+ return false;
702
+ let host;
703
+ try {
704
+ const parsed = new URL(next);
705
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
706
+ return false;
707
+ host = parsed.hostname;
708
+ }
709
+ catch {
710
+ return false; // not a valid URL — ignore (keep current target)
711
+ }
712
+ if (!host || next === comfyuiUrl)
713
+ return false;
714
+ const prev = comfyuiUrl;
715
+ comfyuiUrl = next;
716
+ comfyuiPath = isLoopbackUrl(next) ? envComfyuiPath : undefined;
717
+ // Point every provider at the new target: Claude via its rebuilt MCP env, the
718
+ // manager's image-fetch URL, then respawn active agents so the live comfyui MCP
719
+ // subprocess is recreated with the new COMFYUI_URL (no-op if none are running —
720
+ // the next spawn picks it up from the now-updated closures).
721
+ manager.setMcpServers(buildMcpServers());
722
+ manager.setComfyuiUrl(comfyuiUrl);
723
+ manager.restartAllForMcpEnv();
724
+ // Re-point the render watchdog and re-probe the env (remote vs local differs).
725
+ try {
726
+ QueueMonitor.stop();
727
+ }
728
+ catch {
729
+ /* best-effort */
730
+ }
731
+ QueueMonitor.start(comfyuiUrl);
732
+ void refreshEnvCapabilities();
733
+ logger.info(`[panel-orchestrator] retargeted ComfyUI ${prev} → ${comfyuiUrl} (${isLoopbackUrl(next) ? "local" : "remote"} mode) from panel hello`);
734
+ return true;
735
+ };
632
736
  // Tool secrets → comfyui MCP env: when the user saves a token via
633
737
  // panel_request_secret (e.g. CIVITAI_API_TOKEN for download_civitai_model), the
634
738
  // secret store persists it and fires this. We rebuild the comfyui server's spawn
@@ -650,52 +754,59 @@ export async function runPanelOrchestrator() {
650
754
  // works on the subscription lane) and cached. Pushed to each tab so the
651
755
  // panel's model/effort picker reflects what's actually available, with each
652
756
  // 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) => ({
757
+ // Model list PER BACKEND — probed lazily and cached; an empty/failed probe is
758
+ // NOT cached so the next hello retries. Claude uses fetchSupportedModels() (the
759
+ // only path that works on the subscription lane); codex/gemini enumerate via a
760
+ // throwaway probe backend, which also proves the CLI can launch (= readiness).
761
+ // (Gemini's probe proves the CLI + ACP handshake but not Google sign-in, which
762
+ // ACP only reports at session/new so its "ready" is provisional; a signed-out
763
+ // CLI surfaces a clear one-shot error on the first turn.)
764
+ const modelsByBackend = new Map();
765
+ function ensureModels(backend) {
766
+ let p = modelsByBackend.get(backend);
767
+ if (!p) {
768
+ const pb = getProbeBackend(backend);
769
+ const probe = pb
770
+ ? Promise.resolve(pb.prepare?.())
771
+ .then(() => pb.listModels())
772
+ .then((list) => list.map((m) => ({
673
773
  value: m.id,
674
774
  displayName: m.label ?? m.id,
675
775
  ...(m.supportsEffort != null ? { supportsEffort: m.supportsEffort } : {}),
676
776
  ...(m.supportedEffortLevels ? { supportedEffortLevels: m.supportedEffortLevels } : {}),
677
777
  })))
678
778
  .catch((err) => {
679
- logger.warn(`[panel-orchestrator] codex model probe failed: ${err instanceof Error ? err.message : String(err)}`);
779
+ logger.warn(`[panel-orchestrator] ${backend} model probe failed: ${err instanceof Error ? err.message : String(err)}`);
680
780
  return [];
681
781
  })
682
782
  : fetchSupportedModels(model);
683
- modelsPromise = probe.then((list) => {
684
- // Don't cache an empty/failed probe forever — let the next hello retry.
783
+ p = probe.then((list) => {
685
784
  if (!list.length)
686
- modelsPromise = null;
785
+ modelsByBackend.delete(backend); // don't cache a failure
687
786
  return list;
688
787
  });
788
+ modelsByBackend.set(backend, p);
689
789
  }
690
- return modelsPromise;
790
+ return p;
791
+ }
792
+ // The model to highlight as "current" for a backend: the panel's configured
793
+ // model for claude; the env override (or account default = the list's own
794
+ // current) for codex/gemini.
795
+ function currentModelFor(backend) {
796
+ if (backend === "codex")
797
+ return codexModel;
798
+ if (backend === "gemini")
799
+ return geminiModel;
800
+ return model;
691
801
  }
692
- function pushModels(tabId) {
693
- void ensureModels()
802
+ function pushModels(panelTabId) {
803
+ const backend = backendForTab(panelTabId);
804
+ void ensureModels(backend)
694
805
  .then((models) => {
695
806
  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);
807
+ // `backend` rides on the models frame so the panel's picker reflects the
808
+ // provider THIS tab selected (single-port multi-provider).
809
+ bridge.push({ type: "models", models, current: currentModelFor(backend), backend }, panelTabId);
699
810
  }
700
811
  })
701
812
  .catch(() => {
@@ -720,10 +831,8 @@ export async function runPanelOrchestrator() {
720
831
  // the built-ins that make sense inside the ComfyUI panel chat.
721
832
  const PANEL_SLASH_ALLOWLIST = new Set(["compact", "context", "usage", "loop", "goal", "clear"]);
722
833
  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;
834
+ // Claude-only: SDK slash-commands don't exist for codex/gemini. Callers already
835
+ // gate this to claude tabs (single-port multi-provider), so no backend check here.
727
836
  void ensureCommands()
728
837
  .then((commands) => {
729
838
  const useful = commands.filter((c) => PANEL_SLASH_ALLOWLIST.has(c.name));
@@ -734,75 +843,132 @@ export async function runPanelOrchestrator() {
734
843
  /* probe already logged; panel just won't show SDK commands */
735
844
  });
736
845
  }
846
+ // Real per-provider readiness, computed HERE (the machine running the agents)
847
+ // and pushed over the bridge so the panel's provider switcher reflects the
848
+ // truth — not the ComfyUI host's probe, which is blind to the laptop in the
849
+ // "remote ComfyUI, local agent" model (and never sees Claude's SDK, which has
850
+ // no CLI). The panel prefers this frame over its GET /backends probe.
851
+ function pushReadiness(tabId) {
852
+ try {
853
+ const { backends, any_ready } = allBackendReadiness(KNOWN_BACKENDS);
854
+ bridge.push({ type: "backends", backends, any_ready }, tabId);
855
+ }
856
+ catch (err) {
857
+ logger.warn(`[panel-orchestrator] readiness probe failed: ${err instanceof Error ? err.message : String(err)}`);
858
+ }
859
+ }
737
860
  bridge.onPanelMessage = (event) => {
738
861
  // Connect ack: the instant a panel tab connects, the orchestrator announces
739
862
  // itself so "connected" means "a real agent is attending" — not merely "a
740
863
  // socket is open." A bare/undriven bridge stays silent, so the panel can
741
864
  // tell the difference (and warn if no ack arrives).
742
865
  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.
866
+ const panelTab = event.tab_id;
867
+ // Retarget ComfyUI to the URL the browser was served from (window.location),
868
+ // BEFORE the readiness probe so the "ready" ack reflects the right instance.
869
+ applyComfyuiUrl(event.comfyui_url);
870
+ // Per-tab backend selection (single-port multi-provider). The panel names
871
+ // its chosen provider on connect (and on a switch it re-sends hello / a
872
+ // set_backend); absent or unknown → the default.
873
+ const reqBackend = typeof event.backend === "string"
874
+ ? event.backend.toLowerCase()
875
+ : undefined;
876
+ const backend = reqBackend && KNOWN_BACKENDS.has(reqBackend) ? reqBackend : defaultBackend;
877
+ const prev = tabBackends.get(panelTab);
878
+ if (prev && prev !== backend) {
879
+ // Provider switch: retire the previous provider's agent for this tab so it
880
+ // doesn't linger. The new provider starts a FRESH session (the panel
881
+ // replays the transcript as context on its first message to seed it).
882
+ manager.reset(panelTab + AGENT_KEY_SEP + prev);
883
+ }
884
+ tabBackends.set(panelTab, backend);
885
+ const key = panelTab + AGENT_KEY_SEP + backend;
886
+ // Reload restore: the panel re-sends the last session id it saw. Honored
887
+ // only for a SAME-provider (re)connect — a switch always starts fresh. The
888
+ // orchestrator's own store stays authoritative on the actual spawn.
745
889
  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);
890
+ if (resume && (!prev || prev === backend))
891
+ manager.setResume(key, resume);
892
+ // Live model list for the picker; SDK slash commands are Claude-only.
893
+ pushModels(panelTab);
894
+ // Truthful provider readiness (this machine runs the agents), so the
895
+ // switcher stops falsely showing "CLI not installed" behind a remote pod.
896
+ pushReadiness(panelTab);
897
+ if (backend === "claude")
898
+ pushCommands(panelTab);
752
899
  // Re-push the last usage so the context meter isn't blank after a reload.
753
- const lastStatus = manager.lastStatusFor(event.tab_id);
900
+ const lastStatus = manager.lastStatusFor(key);
754
901
  if (lastStatus)
755
- pushStatus(event.tab_id, lastStatus);
756
- const tabId = event.tab_id;
902
+ pushStatus(panelTab, lastStatus);
757
903
  const now = Date.now();
758
- if (now - (lastAckAt.get(tabId) ?? 0) < ACK_DEBOUNCE_MS)
904
+ if (now - (lastAckAt.get(panelTab) ?? 0) < ACK_DEBOUNCE_MS)
759
905
  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()
906
+ lastAckAt.set(panelTab, now);
907
+ const isCx = backend === "codex";
908
+ const isGm = backend === "gemini";
909
+ // TRUTHFUL "connected": only claim ready after PROVING the SELECTED backend
910
+ // can run, by probing its model list. If the probe fails the "connected
911
+ // but dead" wedge — send a degraded ack so the panel shows the real state.
912
+ void ensureModels(backend)
766
913
  .then((models) => {
767
914
  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
915
+ const agentLabel = isCx
775
916
  ? (codexModel ?? models[0].value ?? "Codex")
776
- : isGemini
917
+ : isGm
777
918
  ? (geminiModel ?? models[0].value ?? "Gemini")
778
919
  : model;
920
+ // Greet only on a FRESH session (a resume/reconnect already has the thread).
779
921
  if (!resume) {
780
- const readyText = isCodex
922
+ const readyText = isCx
781
923
  ? `🟢 comfyui-mcp agent ready — ${agentLabel} on your Codex (ChatGPT) account. Ask away.`
782
- : isGemini
924
+ : isGm
783
925
  ? `🟢 comfyui-mcp agent ready — ${agentLabel} on your Google account (Gemini Code Assist). Ask away.`
784
926
  : `🟢 comfyui-mcp agent ready — ${agentLabel} on your Claude subscription. Ask away.`;
785
- bridge.push({ type: "say", text: readyText }, tabId);
927
+ bridge.push({ type: "say", text: readyText }, panelTab);
786
928
  }
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`);
929
+ bridge.push({ type: "ack", ok: true, kind: "ready", agent: agentLabel, backend }, panelTab);
930
+ logger.info(`[panel-orchestrator] tab ${panelTab.slice(0, 8)} connected (${backend}) — agent healthy, ready ack`);
789
931
  }
790
932
  else {
791
- const degradedText = isCodex
933
+ const degradedText = isCx
792
934
  ? "⚠️ 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
935
+ : isGm
794
936
  ? "⚠️ 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
937
  : "⚠️ 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`);
938
+ bridge.push({ type: "say", text: degradedText }, panelTab);
939
+ bridge.push({ type: "ack", ok: false, kind: "degraded" }, panelTab);
940
+ logger.warn(`[panel-orchestrator] tab ${panelTab.slice(0, 8)} connected (${backend}) but model probe empty — degraded ack`);
799
941
  }
800
942
  })
801
943
  .catch(() => {
802
- bridge.push({ type: "ack", ok: false, kind: "degraded" }, tabId);
944
+ bridge.push({ type: "ack", ok: false, kind: "degraded" }, panelTab);
803
945
  });
804
946
  return;
805
947
  }
948
+ // Provider switch WITHOUT a reconnect (single-port multi-provider): the panel
949
+ // picked a different backend chip. Retire the old provider's agent, remember
950
+ // the new one, and re-advertise its models. The panel replays the transcript
951
+ // as context on its next message so the fresh provider has the conversation.
952
+ if (event.type === "set_backend" && event.tab_id) {
953
+ const panelTab = event.tab_id;
954
+ const reqBackend = typeof event.backend === "string"
955
+ ? event.backend.toLowerCase()
956
+ : "";
957
+ if (!KNOWN_BACKENDS.has(reqBackend)) {
958
+ bridge.push({ type: "ack", ok: false, kind: "set_backend", message: `unknown backend '${reqBackend}'` }, panelTab);
959
+ return;
960
+ }
961
+ const prev = tabBackends.get(panelTab) ?? defaultBackend;
962
+ if (prev !== reqBackend)
963
+ manager.reset(panelTab + AGENT_KEY_SEP + prev);
964
+ tabBackends.set(panelTab, reqBackend);
965
+ pushModels(panelTab);
966
+ if (reqBackend === "claude")
967
+ pushCommands(panelTab);
968
+ bridge.push({ type: "ack", ok: true, kind: "set_backend", backend: reqBackend }, panelTab);
969
+ logger.info(`[panel-orchestrator] tab ${panelTab.slice(0, 8)} switched backend ${prev} → ${reqBackend}`);
970
+ return;
971
+ }
806
972
  // Live panel config (currently just the render-stall threshold). Applied
807
973
  // immediately, no reconnect — the next turn's watchdog check uses the new
808
974
  // value. Sent by the panel on connect and whenever the setting changes.
@@ -830,13 +996,13 @@ export async function runPanelOrchestrator() {
830
996
  // makes the SDK session hang on init. (Defense in depth; the panel only
831
997
  // sends ids from the live catalog.)
832
998
  if (nextModel) {
833
- const known = await ensureModels().catch(() => []);
999
+ const known = await ensureModels(backendForTab(tabId)).catch(() => []);
834
1000
  if (known.length && !known.some((m) => m.value === nextModel)) {
835
1001
  logger.warn(`[panel-orchestrator] ignoring unknown model "${nextModel}" — keeping current`);
836
1002
  nextModel = undefined;
837
1003
  }
838
1004
  }
839
- const applied = await manager.setOptions(tabId, { model: nextModel, effort: nextEffort });
1005
+ const applied = await manager.setOptions(agentKeyFor(tabId), { model: nextModel, effort: nextEffort });
840
1006
  bridge.push({
841
1007
  type: "ack",
842
1008
  ok: true,
@@ -862,11 +1028,11 @@ export async function runPanelOrchestrator() {
862
1028
  // look at me") so the agent stops and fixes it instead of running blind.
863
1029
  // Everything else (e.g. a finished render's images) is enqueued normally.
864
1030
  if (ev.kind === "run_error") {
865
- void manager.injectRunError(event.tab_id, ev.error ?? "unknown error");
1031
+ void manager.injectRunError(agentKeyFor(event.tab_id), ev.error ?? "unknown error");
866
1032
  logger.info(`[panel-orchestrator] tab ${event.tab_id.slice(0, 8)} run_error → agent (interrupt)`);
867
1033
  return;
868
1034
  }
869
- const delivered = manager.injectEvent(event.tab_id, ev);
1035
+ const delivered = manager.injectEvent(agentKeyFor(event.tab_id), ev);
870
1036
  if (delivered) {
871
1037
  logger.info(`[panel-orchestrator] tab ${event.tab_id.slice(0, 8)} event → agent: ${event.kind}`);
872
1038
  }
@@ -880,7 +1046,7 @@ export async function runPanelOrchestrator() {
880
1046
  // interrupted turn so BOTH messages get answered; a plain Stop/Ctrl+C/Esc
881
1047
  // sends a bare interrupt and must NOT re-run the stopped turn.
882
1048
  const requeueInFlight = event.requeue === true;
883
- void manager.interrupt(tabId, { requeueInFlight });
1049
+ void manager.interrupt(agentKeyFor(tabId), { requeueInFlight });
884
1050
  bridge.push({ type: "ack", ok: true, kind: "interrupt" }, tabId);
885
1051
  logger.info(`[panel-orchestrator] tab ${tabId.slice(0, 8)} interrupted${requeueInFlight ? " (send-now: re-queue)" : ""}`);
886
1052
  return;
@@ -890,7 +1056,7 @@ export async function runPanelOrchestrator() {
890
1056
  if (event.type === "cancel_message" && event.tab_id) {
891
1057
  const tabId = event.tab_id;
892
1058
  const mid = typeof event.mid === "string" ? event.mid : undefined;
893
- const removed = mid ? manager.cancelQueued(tabId, mid) : false;
1059
+ const removed = mid ? manager.cancelQueued(agentKeyFor(tabId), mid) : false;
894
1060
  bridge.push({ type: "ack", ok: true, kind: "cancel_message", mid, removed }, tabId);
895
1061
  return;
896
1062
  }
@@ -900,7 +1066,7 @@ export async function runPanelOrchestrator() {
900
1066
  const tabId = event.tab_id;
901
1067
  // reset() is synchronous (map cleared now), so no concurrent send() can
902
1068
  // spawn an agent before we report the cleared session.
903
- manager.reset(tabId);
1069
+ manager.reset(agentKeyFor(tabId));
904
1070
  bridge.push({ type: "session", session_id: null }, tabId);
905
1071
  bridge.push({ type: "ack", ok: true, kind: "new_session" }, tabId);
906
1072
  return;
@@ -912,7 +1078,7 @@ export async function runPanelOrchestrator() {
912
1078
  if (event.type === "rewind" && event.tab_id) {
913
1079
  const tabId = event.tab_id;
914
1080
  const anchor = typeof event.anchor === "string" ? event.anchor : null;
915
- const ok = manager.rewind(tabId, anchor);
1081
+ const ok = manager.rewind(agentKeyFor(tabId), anchor);
916
1082
  bridge.push({ type: "ack", ok, kind: "rewind" }, tabId);
917
1083
  logger.info(`[panel-orchestrator] tab ${tabId.slice(0, 8)} rewind (anchor=${anchor ? anchor.slice(0, 8) : "fresh"}, ok=${ok})`);
918
1084
  return;
@@ -923,7 +1089,7 @@ export async function runPanelOrchestrator() {
923
1089
  const order = Array.isArray(event.order)
924
1090
  ? event.order.filter((m) => typeof m === "string")
925
1091
  : [];
926
- const ok = manager.reorderQueue(tabId, order);
1092
+ const ok = manager.reorderQueue(agentKeyFor(tabId), order);
927
1093
  bridge.push({ type: "ack", ok, kind: "reorder" }, tabId);
928
1094
  logger.info(`[panel-orchestrator] tab ${tabId.slice(0, 8)} reorder queue (${order.length} mids, ok=${ok})`);
929
1095
  return;
@@ -934,9 +1100,10 @@ export async function runPanelOrchestrator() {
934
1100
  if (event.type === "resume_session" && event.tab_id) {
935
1101
  const tabId = event.tab_id;
936
1102
  const sid = typeof event.session_id === "string" ? event.session_id : undefined;
937
- manager.reset(tabId);
1103
+ const key = agentKeyFor(tabId);
1104
+ manager.reset(key);
938
1105
  if (sid)
939
- manager.setResume(tabId, sid);
1106
+ manager.setResume(key, sid);
940
1107
  bridge.push({ type: "ack", ok: true, kind: "resume_session" }, tabId);
941
1108
  return;
942
1109
  }
@@ -1000,7 +1167,17 @@ export async function runPanelOrchestrator() {
1000
1167
  catch (err) {
1001
1168
  logger.debug(`[panel-orchestrator] queue-note check failed (ignored): ${err instanceof Error ? err.message : String(err)}`);
1002
1169
  }
1003
- manager.send(event.tab_id, outText, {
1170
+ // Transcript replay (single-port provider switch): the panel sends the prior
1171
+ // conversation as `context` on the FIRST message to a freshly-switched
1172
+ // provider, so the new backend has the thread (minus internal session data —
1173
+ // thinking/tool traces/cache aren't portable across providers). Prepend it the
1174
+ // same way crash/queue notes are, so it seeds the fresh session's first turn.
1175
+ const replay = typeof event.context === "string"
1176
+ ? event.context.trim()
1177
+ : "";
1178
+ if (replay)
1179
+ outText = `${replay}\n\n${outText}`;
1180
+ manager.send(agentKeyFor(event.tab_id), outText, {
1004
1181
  title: event.title,
1005
1182
  images: event.images,
1006
1183
  mid: userMid,
@@ -1083,9 +1260,11 @@ export async function runPanelOrchestrator() {
1083
1260
  QueueMonitor.stop();
1084
1261
  unsubscribeSecrets();
1085
1262
  await manager.stopAll();
1086
- // Dispose the readiness-probe backend (kills its Codex/Gemini CLI child).
1087
- if (probeBackend?.close)
1088
- await probeBackend.close().catch(() => { });
1263
+ // Dispose the readiness-probe backends (kills each Codex/Gemini CLI child).
1264
+ for (const pb of probeBackends.values()) {
1265
+ if (pb.close)
1266
+ await pb.close().catch(() => { });
1267
+ }
1089
1268
  // Tear down the loopback panel HTTP MCP (codex/gemini mode only).
1090
1269
  if (panelMcpHttp)
1091
1270
  await panelMcpHttp.stop().catch(() => { });