comfyui-mcp 0.49.0 → 0.49.2

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 (120) hide show
  1. package/README.md +5 -10
  2. package/dist/comfyui/cloud-client.js +2 -2
  3. package/dist/comfyui/cloud-client.js.map +1 -1
  4. package/dist/config.js +22 -0
  5. package/dist/config.js.map +1 -1
  6. package/dist/orchestrator/agent-backend.js +9 -0
  7. package/dist/orchestrator/agent-backend.js.map +1 -1
  8. package/dist/orchestrator/antigravity-backend.js +3 -2
  9. package/dist/orchestrator/antigravity-backend.js.map +1 -1
  10. package/dist/orchestrator/call-tool-admission.js +160 -0
  11. package/dist/orchestrator/call-tool-admission.js.map +1 -0
  12. package/dist/orchestrator/chatgpt-oauth-backend.js +3 -2
  13. package/dist/orchestrator/chatgpt-oauth-backend.js.map +1 -1
  14. package/dist/orchestrator/claude-backend.js +300 -3
  15. package/dist/orchestrator/claude-backend.js.map +1 -1
  16. package/dist/orchestrator/codex-backend.js +3 -2
  17. package/dist/orchestrator/codex-backend.js.map +1 -1
  18. package/dist/orchestrator/gemini-backend.js +3 -2
  19. package/dist/orchestrator/gemini-backend.js.map +1 -1
  20. package/dist/orchestrator/grok-backend.js +5 -3
  21. package/dist/orchestrator/grok-backend.js.map +1 -1
  22. package/dist/orchestrator/index.js +169 -137
  23. package/dist/orchestrator/index.js.map +1 -1
  24. package/dist/orchestrator/ollama-backend.js +3 -2
  25. package/dist/orchestrator/ollama-backend.js.map +1 -1
  26. package/dist/orchestrator/panel-agent.js +89 -19
  27. package/dist/orchestrator/panel-agent.js.map +1 -1
  28. package/dist/orchestrator/panel-tools.js +955 -81
  29. package/dist/orchestrator/panel-tools.js.map +1 -1
  30. package/dist/orchestrator/pi-backend.js +3 -2
  31. package/dist/orchestrator/pi-backend.js.map +1 -1
  32. package/dist/orchestrator/session-store.js +22 -0
  33. package/dist/orchestrator/session-store.js.map +1 -1
  34. package/dist/services/civitai-resolver.js +22 -7
  35. package/dist/services/civitai-resolver.js.map +1 -1
  36. package/dist/services/comfy-cli.js +1 -1
  37. package/dist/services/download-jobs.js +1 -1
  38. package/dist/services/extra-paths.js +7 -7
  39. package/dist/services/extra-paths.js.map +1 -1
  40. package/dist/services/graph-query.js +10 -4
  41. package/dist/services/graph-query.js.map +1 -1
  42. package/dist/services/manager-config.js +11 -2
  43. package/dist/services/manager-config.js.map +1 -1
  44. package/dist/services/model-resolver.js +4 -4
  45. package/dist/services/model-resolver.js.map +1 -1
  46. package/dist/services/node-authoring.js +3 -3
  47. package/dist/services/node-authoring.js.map +1 -1
  48. package/dist/services/node-dev.js +3 -3
  49. package/dist/services/node-dev.js.map +1 -1
  50. package/dist/services/node-management.js +185 -9
  51. package/dist/services/node-management.js.map +1 -1
  52. package/dist/services/node-snapshots.js +117 -12
  53. package/dist/services/node-snapshots.js.map +1 -1
  54. package/dist/services/node-verify.js +1 -1
  55. package/dist/services/node-verify.js.map +1 -1
  56. package/dist/services/output-dir.js +1 -1
  57. package/dist/services/output-dir.js.map +1 -1
  58. package/dist/services/panel-installer.js +466 -6
  59. package/dist/services/panel-installer.js.map +1 -1
  60. package/dist/services/panel-pending-cancel.js +446 -0
  61. package/dist/services/panel-pending-cancel.js.map +1 -0
  62. package/dist/services/panel-pin-guard.js +50 -3
  63. package/dist/services/panel-pin-guard.js.map +1 -1
  64. package/dist/services/panel-sync.js +3 -20
  65. package/dist/services/panel-sync.js.map +1 -1
  66. package/dist/services/process-control.js +132 -8
  67. package/dist/services/process-control.js.map +1 -1
  68. package/dist/services/queue-manager.js +1 -1
  69. package/dist/services/queue-manager.js.map +1 -1
  70. package/dist/services/queue-monitor.js +1 -1
  71. package/dist/services/queue-monitor.js.map +1 -1
  72. package/dist/services/queue-monitor.self-attribution.test.js +1 -1
  73. package/dist/services/queue-monitor.self-attribution.test.js.map +1 -1
  74. package/dist/services/ui-bridge.js +180 -17
  75. package/dist/services/ui-bridge.js.map +1 -1
  76. package/dist/services/update-comfyui.js +29 -2
  77. package/dist/services/update-comfyui.js.map +1 -1
  78. package/dist/services/workspace-env.js +3 -3
  79. package/dist/services/workspace-env.js.map +1 -1
  80. package/dist/tools/api-nodes.js +1 -1
  81. package/dist/tools/api-nodes.js.map +1 -1
  82. package/dist/tools/batches.js +1 -1
  83. package/dist/tools/batches.js.map +1 -1
  84. package/dist/tools/compact.js +32 -6
  85. package/dist/tools/compact.js.map +1 -1
  86. package/dist/tools/extra-paths.js +1 -1
  87. package/dist/tools/extra-paths.js.map +1 -1
  88. package/dist/tools/generate-3d.js +2 -2
  89. package/dist/tools/generate-3d.js.map +1 -1
  90. package/dist/tools/install-panel.js +62 -22
  91. package/dist/tools/install-panel.js.map +1 -1
  92. package/dist/tools/model-explorer.js +175 -117
  93. package/dist/tools/model-explorer.js.map +1 -1
  94. package/dist/tools/queue-management.js +117 -135
  95. package/dist/tools/queue-management.js.map +1 -1
  96. package/dist/tools/run-template.js +4 -4
  97. package/dist/tools/run-template.js.map +1 -1
  98. package/dist/tools/vocabulary.js +123 -19
  99. package/dist/tools/vocabulary.js.map +1 -1
  100. package/dist/tools/workflow-autoload.js +5 -5
  101. package/dist/tools/workflow-autoload.js.map +1 -1
  102. package/dist/tools/workflow-execute.js +1 -1
  103. package/dist/tools/workflow-execute.js.map +1 -1
  104. package/dist/tools/workspace-env.js +53 -28
  105. package/dist/tools/workspace-env.js.map +1 -1
  106. package/docs/design/panel-surface.txt +1 -0
  107. package/docs/design/tool-surface.txt +3 -0
  108. package/package.json +2 -2
  109. package/plugin/commands/batch.md +1 -1
  110. package/plugin/commands/gen.md +2 -2
  111. package/plugin/scripts/monitor-progress.mjs +1 -1
  112. package/plugin/skills/comfyui-core/SKILL.md +15 -13
  113. package/plugin/skills/director/SKILL.md +1 -1
  114. package/plugin/skills/ltxv2-video/SKILL.md +1 -1
  115. package/plugin/skills/troubleshooting/SKILL.md +1 -1
  116. package/plugin/skills/video-extend/SKILL.md +1 -1
  117. package/plugin/skills/workflow-layout/SKILL.md +4 -5
  118. package/scripts/arena-scenarios.mjs +5 -5
  119. package/scripts/check-tool-vocabulary.mts +9 -0
  120. package/scripts/gen-tool-docs.ts +31 -10
@@ -15,17 +15,20 @@ 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";
18
- import { startUiBridge, isLoopbackBindHost } from "../services/ui-bridge.js";
18
+ import { startUiBridge, isLoopbackBindHost, SESSION_EPOCH } from "../services/ui-bridge.js";
19
19
  import { setupSecureBridge, resolveComfyuiPathForTarget } from "../services/secure-bridge.js";
20
20
  import { startQuickTunnel } from "../services/tunnel.js";
21
21
  import { detectInstallMode } from "../services/self-update.js";
22
+ import { performPanelSync } from "../services/panel-sync.js";
23
+ import { isPanelAutoInstallDisabled } from "../services/panel-installer.js";
22
24
  import { SelfRestarter } from "../services/self-restart.js";
23
- import { SessionStore, armableResume, deriveStableKey, destinationHasCollisionState, keepsBackendState, siblingOwnsStableKey, workflowIdentityParts, } from "./session-store.js";
25
+ import { SessionStore, armableResume, carryWorkflowCommandStamp, deriveStableKey, destinationHasCollisionState, keepsBackendState, siblingOwnsStableKey, workflowIdentityParts, } from "./session-store.js";
24
26
  import { listSessions, loadTranscript } from "./history.js";
25
27
  import { uploadImageHttp, resetClient } from "../comfyui/client.js";
26
28
  import { logger } from "../utils/logger.js";
27
29
  import { PanelAgentManager, fetchSupportedModels, fetchSupportedCommands, isEffort, } from "./panel-agent.js";
28
30
  import { promptText } from "./error-text.js";
31
+ import { callToolAdmission } from "./call-tool-admission.js";
29
32
  import { createPanelMcpServer, makePanelToolCtx, resolvePinTarget } from "./panel-tools.js";
30
33
  import { optionsAckFrame, optionsErrorAckFrame, optionsRequestMeta, } from "./options-ack.js";
31
34
  import { readUserMcpServers } from "../services/user-mcp-config.js";
@@ -89,7 +92,7 @@ WEDGED RENDER / OOM / VRAM PINNED — when a generation is stuck or hits CUDA ou
89
92
 
90
93
  WORKFLOW TARGETING — by default your panel_* graph edits follow whichever workflow tab the user is currently viewing. The panel can only read or edit the workflow currently IN VIEW, so to work on a specific open workflow, make it the active canvas first with panel_open_workflow, then call panel_set_workflow_target(mode:"pinned", path:<from panel_list_workflows>) to bind your edits to it; panel_get_workflow_target shows the current binding. Pinning to a background (open but not active) workflow is REJECTED at pin time — it cannot route edits to a tab that isn't in view. A pin does NOT switch what the user sees; it binds your edits to that workflow so that if the user later switches away, your next graph call fails loudly instead of silently editing the wrong graph. Set mode:"current" to follow the user's active tab again.
91
94
 
92
- CRITICAL — never destroy the user's work. When they ask for a "new workflow", a "fresh canvas", or to "start over for a new project", call panel_new_workflow (it opens a NEW TAB and leaves their current workflow intact). NEVER use panel_clear for that — panel_clear wipes the CURRENTLY OPEN graph and is ONLY for an explicit "clear/reset this canvas". You can manage tabs with panel_list_workflows / panel_open_workflow / panel_rename_workflow / panel_close_workflow, and group nodes with panel_select_nodes / panel_create_subgraph. To label a node by its purpose, use panel_set_node_title. To read or edit nodes INSIDE a subgraph, call panel_enter_subgraph(node_id) first — then panel_query_graph / panel_graph_outline and the panel_* edit tools operate on the subgraph's inner nodes — and panel_exit_subgraph when you're done.
95
+ CRITICAL — never destroy the user's work. When they ask for a "new workflow", a "fresh canvas", or to "start over for a new project", call panel_new_workflow (it opens a NEW TAB and leaves their current workflow intact). NEVER use panel_clear for that — panel_clear wipes the CURRENTLY OPEN graph and is ONLY for an explicit "clear/reset this canvas". You can manage tabs with panel_list_workflows / panel_open_workflow / panel_rename_workflow / panel_close_workflow, and group nodes with panel_select_nodes / panel_create_subgraph. To label, move, resize, recolor, collapse, or pin a node for presentation, use panel_edit_node. To read or edit nodes INSIDE a subgraph, call panel_enter_subgraph(node_id) first — then panel_query_graph / panel_graph_outline and the panel_* edit tools operate on the subgraph's inner nodes — and panel_exit_subgraph when you're done.
93
96
 
94
97
  SUBGRAPH I/O — exposing interior nodes to the boundary. To wire an interior node to the subgraph's boundary from INSIDE a subgraph, do NOT panel_connect to a guessed rail node id — that's the rail and you'll get it wrong. Use panel_expose_subgraph_output(from_node_id, from_output) to expose an interior OUTPUT on the output rail (so the parent graph can wire the subgraph node's new output), and panel_expose_subgraph_input(to_node_id, to_input) to expose an interior INPUT on the input rail. Read panel_query_graph's \`rails\` field (present when viewing a subgraph) to see the current boundary slots — what's already exposed and what still needs it. To EXPAND/DISSOLVE a subgraph back into the parent graph (inline its inner nodes and rewire external links, removing the wrapper — the inverse of panel_create_subgraph), use panel_unpack_subgraph(node_id). All three are undoable with Ctrl+Z.
95
98
 
@@ -129,11 +132,11 @@ INSPECT NODE MODES BEFORE YOU RUN. After loading a pack/template/workflow — an
129
132
 
130
133
  VERIFY THE OUTPUT MATCHES THE REQUEST. After a render completes, actually LOOK at the image/video the panel delivers and confirm it matches what was asked BEFORE you declare success or move to the next step. If it doesn't match, do NOT report progress — diagnose (wrong prompt path? a bypassed/muted builder or switch? wrong widget value?), fix it (often panel_set_node_mode or panel_set_widget), and rerun. Only claim something works once you've SEEN that it does — never report progress you haven't verified.
131
134
 
132
- AFTER PANEL_RUN — once you call panel_run to queue a render, you will be notified automatically with the output image(s)/video when it finishes. Do not poll get_queue, get_history, or list_output_images waiting for the result — just end your turn and the finished render will be delivered to you.
135
+ AFTER PANEL_RUN — once you call panel_run to queue a render, you will be notified automatically with the output image(s)/video when it finishes. Do not poll queue (action:"list"), get_history, or list_output_images waiting for the result — just end your turn and the finished render will be delivered to you.
133
136
 
134
137
  DEBUG WRONG RENDERS BY INSPECTING INTERMEDIATE STEPS (run-to-node). When a final asset comes out WRONG — artifacts, wrong subject/pose/composition/color, blur, a ControlNet/IPAdapter/mask/LoRA not taking, a refiner or upscale stage degrading it — do NOT just re-roll the whole graph. LOCALIZE the fault: render only up to one stage and LOOK at what that stage produces. panel_run takes to_node_id to run ONE output branch (ComfyUI partial execution) — only that output node plus everything upstream of it renders, the rest is skipped, so it's fast and cheap, and the result is delivered to you automatically like any run. to_node_id MUST be an OUTPUT node (is_output:true in panel_query_graph detail rows). To inspect a point that ISN'T an output — a latent, a preprocessor/depth/pose map, a mask, an intermediate image — TAP it: add a PreviewImage on an IMAGE wire (or VAEDecode→PreviewImage on a LATENT, MaskToImage→PreviewImage on a MASK), panel_run(to_node_id=that preview), read the delivered image, then panel_remove_node the tap when done. Bisect upstream→downstream until you find the FIRST stage whose output is bad — that node (or its inputs/widgets) is what to fix, then run-to-node there again to confirm before a full run. For the full method (probe recipes, symptom→probe map) read the debug-render skill via read_skill. This is for renders that COMPLETE but look wrong; for runs that fail with an error/OOM/missing node, use the troubleshooting skill instead.
135
138
 
136
- CHAIN A STAGE'S OUTPUT INTO THE NEXT STAGE'S LOADER — when a multi-stage pipeline (e.g. Krea2 image → LTX video → WAN extend) needs one stage's OUTPUT fed into the next stage's loader (LoadImage / VHS_LoadVideo / LoadAudio), call stage_output_as_input with the output's { filename, subfolder?, type? } and drop the returned input filename into the loader's image/video/audio widget. (Or, for a file already on disk, upload_image / upload_video / upload_audio.) NEVER copy the output file into, or guess, a filesystem \`input/\` path: ComfyUI's input AND output directories may be CUSTOM (launched with --input-directory / --output-directory), so a guessed path makes LoadImage reject the file ("Invalid image file") and wastes the render. stage_output_as_input goes through the server API (/view → /upload/image), which resolves the real dirs correctly every time. VERIFY A VIDEO RENDER VIA THE FILESYSTEM, NOT /history — VHS_VideoCombine and similar video nodes write the .mp4 but frequently do NOT register an output in ComfyUI's /history (the prompt shows done with no output and no error), so do NOT conclude a clip "silently dropped" from get_history/get_job_status; confirm it with list_output_images (which now lists videos, each tagged kind:"video") by filename/prefix + fresh mtime, then chain it forward with stage_output_as_input.
139
+ CHAIN A STAGE'S OUTPUT INTO THE NEXT STAGE'S LOADER — when a multi-stage pipeline (e.g. Krea2 image → LTX video → WAN extend) needs one stage's OUTPUT fed into the next stage's loader (LoadImage / VHS_LoadVideo / LoadAudio), call stage_output_as_input with the output's { filename, subfolder?, type? } and drop the returned input filename into the loader's image/video/audio widget. (Or, for a file already on disk, upload_image / upload_video / upload_audio.) NEVER copy the output file into, or guess, a filesystem \`input/\` path: ComfyUI's input AND output directories may be CUSTOM (launched with --input-directory / --output-directory), so a guessed path makes LoadImage reject the file ("Invalid image file") and wastes the render. stage_output_as_input goes through the server API (/view → /upload/image), which resolves the real dirs correctly every time. VERIFY A VIDEO RENDER VIA THE FILESYSTEM, NOT /history — VHS_VideoCombine and similar video nodes write the .mp4 but frequently do NOT register an output in ComfyUI's /history (the prompt shows done with no output and no error), so do NOT conclude a clip "silently dropped" from get_history or queue (action:"status"); confirm it with list_output_images (which now lists videos, each tagged kind:"video") by filename/prefix + fresh mtime, then chain it forward with stage_output_as_input.
137
140
 
138
141
  BYPASS COMPLETED STAGES BEFORE QUEUING THE NEXT ONE. When you build a multi-stage pipeline on one canvas (e.g. Krea2 → LTX → WAN), once a stage has RUN and you've captured/staged its output, BYPASS that stage's nodes with panel_set_node_mode(mode:"bypass") BEFORE you queue the next stage — so panel_run doesn't re-execute (and make the user pay for / wait on) work that's already done. Re-running the whole graph because an earlier stage was left active is a real, costly failure mode: explicitly bypass each finished stage and keep only the ACTIVE stage live. (This complements stage_output_as_input, which feeds the prior stage's output forward into the next stage's loader — bypass the producer, feed its captured output to the consumer.)
139
142
 
@@ -192,7 +195,7 @@ const HEADLESS_DIRECTIVE = "[HEADLESS SESSION — no ComfyUI panel/canvas is con
192
195
  "(panel_run, panel_query_graph, panel_set_widget, panel_add_node, …) are UNAVAILABLE here and will fail — " +
193
196
  "do everything through the comfyui MCP tools (generate_image, or create_workflow + enqueue_workflow). " +
194
197
  "There is NO panel to auto-deliver a finished render, so you MUST deliver the result YOURSELF IN THIS SAME TURN: " +
195
- "enqueuing returns a prompt_id immediately, so wait for it with get_job_status(prompt_id) — poll it briefly until " +
198
+ "enqueuing returns a prompt_id immediately, so wait for it with queue (action:\"status\", prompt_id) — poll it briefly until " +
196
199
  "it reports completion (this is the ONE case where polling IS correct) — then fetch the output with get_history and " +
197
200
  "show it with panel_show_media. Do NOT end your turn expecting an automatic notification; none will arrive. " +
198
201
  "If the run FAILED — or the user asks why a render failed / what's missing — call diagnose_run FIRST, and do NOT use " +
@@ -225,7 +228,7 @@ function formatQueueNote(rep) {
225
228
  return (`⚠️ The current ComfyUI render appears STALLED: ` +
226
229
  `${rep.currentNode ? `node ${rep.currentNode} ` : ""}${rep.progress ? `(progress ${rep.progress}) ` : ""}` +
227
230
  `on prompt ${rep.runningPromptId ?? "?"} has not advanced for ~${secs}s. ComfyUI only checks interrupts ` +
228
- `BETWEEN steps, so a stuck step can ignore cancel_job. If it's wedged: call cancel_job with ` +
231
+ `BETWEEN steps, so a stuck step can ignore a cancel. If it's wedged: call queue (action:"cancel") with ` +
229
232
  `clear_pending:true; if it reports the job still wedged, restart_comfyui / panel_restart_comfyui. ` +
230
233
  `Do NOT queue another run on top.`);
231
234
  }
@@ -237,8 +240,8 @@ function formatQueueNote(rep) {
237
240
  if (rep.backlog && !rep.selfAttributed) {
238
241
  const pending = Math.max(0, rep.queueDepth - 1);
239
242
  return (`ℹ️ ComfyUI queue: ${rep.queueDepth} tasks in flight (1 running + ${pending} pending) that this session ` +
240
- `didn't queue. This is only a problem if the running one is stuck — inspect with get_queue. ` +
241
- `cancel_queued_job drops a single pending item; cancel_job with clear_pending:true resets everything.`);
243
+ `didn't queue. This is only a problem if the running one is stuck — inspect with queue (action:"list"). ` +
244
+ `queue (action:"cancel_queued") drops a single pending item; queue (action:"cancel") with clear_pending:true resets everything.`);
242
245
  }
243
246
  return null;
244
247
  }
@@ -579,109 +582,6 @@ function isRemoteHttpsUrl(u) {
579
582
  return false;
580
583
  }
581
584
  }
582
- /** The direct tool channel: a mobile client can invoke these READ/DOWNLOAD backend
583
- * tools without an agent turn (structured nav data + rig-side downloads). The
584
- * bridge is already token-gated; this whitelist keeps call_tool to non-destructive
585
- * tools (no restart/remove/clear/install). */
586
- const CALL_TOOL_WHITELIST = new Set([
587
- "list_workflows",
588
- "get_workflow",
589
- "analyze_workflow",
590
- "query_workflow",
591
- "workflow_from_image",
592
- "list_output_images",
593
- "get_image",
594
- "list_local_models",
595
- // Read-only CivitAI lookups (creator-search feature): let a client browse
596
- // models/creators through the rig without an agent turn.
597
- "search_civitai_models",
598
- "search_civitai_creators",
599
- "download_civitai_model",
600
- "download_model",
601
- "enqueue_workflow",
602
- // Persist a workflow to the ComfyUI library (mobile "pull workflow from a
603
- // CivitAI example" → save_workflow). Writes a workflow file (auto-converts
604
- // API-format graphs to canvas-openable UI format); overwrites same-filename,
605
- // so the client generates a unique name. No model/system mutation.
606
- "save_workflow",
607
- // One-tap cancel of the RUNNING render (the mobile queue monitor's stop
608
- // button). User-initiated and narrowly scoped: the client passes the
609
- // prompt_id it saw in `queue_status`, and cancel_job only interrupts when the
610
- // running job still matches — it can never kill a job that started after the
611
- // tap, and (without clear_pending, which the mobile client never sends) it
612
- // never touches other pending jobs in a shared queue.
613
- "cancel_job",
614
- // "Why did my render fail?" for canvas-less clients. The panel answers this from
615
- // live canvas state (panel_get_errors); a phone has no canvas, so it reads
616
- // the same story server-side from history + re-validating the graph that ran.
617
- // Read-only.
618
- "diagnose_run",
619
- // Read-only training surface: flow/model discovery + progress polling +
620
- // docker/GPU/image preflight for the panel/mobile Training tab, and the
621
- // dataset/job-config/file readers behind its Jobs/Datasets views.
622
- "train_list_flows",
623
- "train_status",
624
- "train_doctor",
625
- "train_list_datasets",
626
- "train_dataset_detail",
627
- "train_job_config",
628
- "train_file",
629
- "train_preview_config",
630
- "train_dataset_update",
631
- "train_dataset_delete",
632
- "train_caption_image",
633
- "train_caption_dataset",
634
- "train_delete_job",
635
- // User-initiated training ops (panel/mobile Training wizard): stage a dataset,
636
- // launch a GPU-container training run, cancel one. All validation lives in the
637
- // tools themselves (dataset checks, docker/image preflight, liveness-verified
638
- // cancel); the whitelist only gates reachability.
639
- "train_prepare_dataset",
640
- "train_start",
641
- "train_cancel",
642
- // RunPod control panel (desktop + mobile): the one-tap pod lifecycle + the
643
- // local⇄pod host switch. Read-only status/list/troubleshoot, the COST-SAVING
644
- // actions (stop/use_local), connect (retarget only — a pod must already be
645
- // RUNNING, so it neither spins nor keeps one billing), watch/unwatch, and the
646
- // referral deploy link. Each tool validates its own pod state; the whitelist
647
- // only gates reachability from a canvas-less client.
648
- // NOTE: runpod_pod_create AND runpod_pod_start are deliberately EXCLUDED
649
- // (#269/#278) — both put a pod into a BILLING state (create deploys; start
650
- // RESUMES billing on a stopped pod). A confirmation-less mirrored/foreign tab
651
- // must not be able to spend money, so both go through an agent turn / explicit
652
- // UI action. stop is kept (it SAVES money).
653
- "runpod_pod_status",
654
- "runpod_list_pods",
655
- "runpod_pod_stop",
656
- "runpod_pod_connect",
657
- "runpod_pod_troubleshoot",
658
- "runpod_use_local",
659
- "runpod_watch",
660
- "runpod_unwatch",
661
- "runpod_deploy_link",
662
- // Micro-Apps (panel "Apps" feature): the canvas-less client's list/run/poll
663
- // surface, plus registry install. One tool since 0.49.0 slice 2, so the
664
- // whitelist can no longer distinguish the actions — the risk posture is judged
665
- // over the whole tool. Run queues a job the user explicitly tapped (same as
666
- // enqueue_workflow, already whitelisted); list/get/run_status are read-only;
667
- // import (mobile Explore) has the rig fetch a bundle from the public registry
668
- // and write it locally, the same risk as save_workflow + download_model
669
- // (already whitelisted) — no model/system mutation, and deps install stays a
670
- // separate consented action.
671
- "apps",
672
- // App dependency side-panel (Explore/detail): the ✓/download panel reads what
673
- // an app needs vs what's installed and offers per-item fetches. Reads are safe
674
- // (missing-model detection + candidate resolution, node-pack presence); model
675
- // downloads reuse the already-whitelisted download_civitai_model/download_model.
676
- // install_custom_node is a MUTATION that runs the pack's code on install —
677
- // reachable for the panel's "install missing node" button, gated behind an
678
- // explicit themed confirm client-side. (Revisit if a canvas-less/foreign tab
679
- // must not be able to trigger a node install.)
680
- "resolve_missing_models",
681
- "extract_workflow_dependencies",
682
- "list_installed_nodes",
683
- "install_custom_node",
684
- ]);
685
585
  /** Lazily build ONE in-process MCP client wired to the full comfyui tool surface,
686
586
  * reused across call_tool requests. Reuses the exact tool implementations (same
687
587
  * getClient()/COMFYUI_URL as the agents) — no logic duplication. */
@@ -703,6 +603,55 @@ function getCallToolClient() {
703
603
  }
704
604
  return callToolClientPromise;
705
605
  }
606
+ /**
607
+ * #694 — the `models` push frame is the ONE frame stamped with the bridge's
608
+ * per-process SESSION_EPOCH (no per-command stamping): the panel scopes its
609
+ * retry_of dedupe cache to the process that minted the rids, so a restarted
610
+ * orchestrator's tokens never collide with a prior process's. Exported (pure)
611
+ * so the epoch-stability test can build two frames without booting the
612
+ * orchestrator.
613
+ */
614
+ export function buildModelsPushFrame(models, current, backend) {
615
+ return { type: "models", epoch: SESSION_EPOCH, models, current, backend };
616
+ }
617
+ /** #694 (epoch-first) — the tiny immediate frame a hello gets before any async
618
+ * work: advances the session epoch without waiting for model discovery, so a
619
+ * command arriving in the gap can never resolve retry_of against the prior
620
+ * process's epoch. Exported (pure) for the shape test. Type is "session_epoch"
621
+ * — NEVER "session": that frame name is taken (session_id lifecycle). */
622
+ export function buildSessionEpochFrame() {
623
+ return { type: "session_epoch", epoch: SESSION_EPOCH };
624
+ }
625
+ /**
626
+ * Send the model handshake even when discovery returned no choices. The models
627
+ * frame is also the process-epoch handshake that scopes panel retry tokens, so
628
+ * suppressing an empty catalog would leave a reconnecting panel on the prior
629
+ * process's epoch and allow stale tokens to resolve there.
630
+ */
631
+ export function pushModelsFrame(bridge, panelTabId, models, current, backend) {
632
+ return bridge.push(buildModelsPushFrame(models, current, backend), panelTabId);
633
+ }
634
+ /**
635
+ * Keeps download-tray reconciliation state separate from the progress-file
636
+ * lifecycle. `record` only decides whether a changed snapshot needs a global
637
+ * broadcast; `forPanel` is the current (possibly empty) state sent to every
638
+ * hello/re-hello so a reconnect cannot retain rows from an old process.
639
+ */
640
+ export class DownloadProgressSnapshots {
641
+ lastSnapshot = null;
642
+ rows = [];
643
+ record(rows) {
644
+ const snapshot = JSON.stringify(rows);
645
+ if (snapshot === this.lastSnapshot)
646
+ return false;
647
+ this.lastSnapshot = snapshot;
648
+ this.rows = rows;
649
+ return true;
650
+ }
651
+ forPanel() {
652
+ return this.rows;
653
+ }
654
+ }
706
655
  export async function runPanelOrchestrator() {
707
656
  // Crash guard: the orchestrator is a long-lived background process the user
708
657
  // can't see. A stray rejection (e.g. a fire-and-forget push to a tab that
@@ -802,6 +751,9 @@ export async function runPanelOrchestrator() {
802
751
  const lockPort = Number(process.env.COMFYUI_MCP_BRIDGE_PORT) || 9180;
803
752
  const lockPath = orchLockPath(lockPort);
804
753
  const bridge = startUiBridge(lockPort, bridgeToken, bridgeHost);
754
+ // Starts empty intentionally: a newly connected panel must be able to clear
755
+ // rows left by an older process before this process sees any progress files.
756
+ const downloadSnapshots = new DownloadProgressSnapshots();
805
757
  // The LISTENER'S auth was fixed at construction: a null boot token means a
806
758
  // tokenless listener FOREVER — lazily provisioning a token later would
807
759
  // advertise a tunnel whose token is not enforced (codex finding). The lazy
@@ -1282,6 +1234,12 @@ export async function runPanelOrchestrator() {
1282
1234
  // new provider's onSession would persist under the old backend's key. Tracked for every
1283
1235
  // tab with a valid identity; cleared when the identity is absent/untrusted.
1284
1236
  const tabStableIdentity = new Map();
1237
+ // The UUID stamped on the NEXT panel command. Normally this exactly mirrors
1238
+ // tabStableIdentity from hello. #716 intentionally lets a successful explicit
1239
+ // open/re-pin refresh only this command fence before the next hello arrives;
1240
+ // session ownership remains hello-bound so a command reply can never silently
1241
+ // retarget durable conversation state.
1242
+ const tabCommandWorkflowUuid = new Map();
1285
1243
  const workflowTargets = new WorkflowTargetStore();
1286
1244
  // Monotonic per-tab sequence for set_workflow_target events. A pinned target is
1287
1245
  // validated asynchronously (resolvePinTarget queries workflow_list), so a later event
@@ -1919,7 +1877,28 @@ export async function runPanelOrchestrator() {
1919
1877
  // (the generation-bound-command leak). Resolved from the CALLER's tab id: during the switch
1920
1878
  // race the retiring tab still maps to its own uuid, so a late command stamps the ORIGIN
1921
1879
  // workflow's uuid and the panel (now showing the new one) fails it closed.
1922
- bridge.setTabWorkflowUuidResolver((tabId) => tabStableIdentity.get(panelTabOf(tabId))?.uuid);
1880
+ bridge.setTabWorkflowUuidResolver((tabId) => tabCommandWorkflowUuid.get(panelTabOf(tabId)), (tabId, workflowUuid) => {
1881
+ // A command reply is useful only while its routed tab still exists, and
1882
+ // only when its UUID has the same strict shape/origin binding as hello.
1883
+ // Invalid/missing panel data leaves the old stamp in place, causing the
1884
+ // existing fail-closed fence to reject a subsequent mutation.
1885
+ try {
1886
+ if (!bridge.canReach(tabId))
1887
+ return false;
1888
+ }
1889
+ catch {
1890
+ return false;
1891
+ }
1892
+ const panelTab = panelTabOf(tabId);
1893
+ const identity = workflowIdentityParts({
1894
+ workflowUuid,
1895
+ origin: bridge.tabServerOrigin(tabId),
1896
+ });
1897
+ if (!identity)
1898
+ return false;
1899
+ tabCommandWorkflowUuid.set(panelTab, identity.uuid);
1900
+ return true;
1901
+ });
1923
1902
  // ── Local-agent VRAM pause during generation ────────────────────────────
1924
1903
  // On a single-GPU box the local Ollama chat model and ComfyUI fight for VRAM:
1925
1904
  // a resident model can OOM a render, and a chat sent mid-render reloads the
@@ -2254,21 +2233,15 @@ export async function runPanelOrchestrator() {
2254
2233
  const backend = backendForTab(panelTabId);
2255
2234
  void ensureModels(backend)
2256
2235
  .then((models) => {
2257
- if (models.length) {
2258
- // `backend` rides on the models frame so the panel's picker reflects the
2259
- // provider THIS tab selected (single-port multi-provider). `current`
2260
- // reports the model this tab will ACTUALLY spawn with: the picker's
2261
- // per-tab override when one is set (set_options survives reconnects
2262
- // of the same tab id), else the backend's configured default —
2263
- // previously the default was always reported, so a reconnecting
2264
- // client's picker showed the wrong current model after a switch.
2265
- bridge.push({
2266
- type: "models",
2267
- models,
2268
- current: manager.modelOverrideFor(agentKeyFor(panelTabId)) ?? currentModelFor(backend),
2269
- backend,
2270
- }, panelTabId);
2271
- }
2236
+ // `backend` rides on the models frame so the panel's picker reflects the
2237
+ // provider THIS tab selected (single-port multi-provider). `current`
2238
+ // reports the model this tab will ACTUALLY spawn with: the picker's
2239
+ // per-tab override when one is set (set_options survives reconnects
2240
+ // of the same tab id), else the backend's configured default —
2241
+ // previously the default was always reported, so a reconnecting
2242
+ // client's picker showed the wrong current model after a switch. Send
2243
+ // even an empty list: the frame advances the #694 session epoch.
2244
+ pushModelsFrame(bridge, panelTabId, models, manager.modelOverrideFor(agentKeyFor(panelTabId)) ?? currentModelFor(backend), backend);
2272
2245
  })
2273
2246
  .catch(() => {
2274
2247
  /* probe already logged; panel keeps its fallback list */
@@ -2346,6 +2319,14 @@ export async function runPanelOrchestrator() {
2346
2319
  // tell the difference (and warn if no ack arrives).
2347
2320
  if (event.type === "hello" && event.tab_id) {
2348
2321
  const panelTab = event.tab_id;
2322
+ // #694 (epoch-first) — restamp the session epoch IMMEDIATELY on hello,
2323
+ // before any async work (model discovery, panel sync, retarget probe).
2324
+ // The models frame carries the epoch too, but it awaits async discovery,
2325
+ // so a command arriving in the gap could resolve retry_of against the
2326
+ // PRIOR process's epoch/journal. This tiny "session_epoch" frame (NEVER
2327
+ // "session" — that name is the session_id lifecycle frame) advances the
2328
+ // epoch first thing; the panel stamps on any epoch-carrying frame.
2329
+ bridge.push(buildSessionEpochFrame(), panelTab);
2349
2330
  // Learn the sidebar panel's version from its hello and, the first time we
2350
2331
  // see it (or when it changes on a panel update), refresh the env block so
2351
2332
  // the agent's ENVIRONMENT line carries the panel version — bug reports get
@@ -2355,6 +2336,40 @@ export async function runPanelOrchestrator() {
2355
2336
  latestPanelVersion = helloPanelVer;
2356
2337
  void refreshEnvCapabilities();
2357
2338
  }
2339
+ // #706 — an npm-orchestrator update can require a newer Registry panel.
2340
+ // The panel-sync service owns the ENTIRE safety decision: it re-reads the
2341
+ // local install under the panel-op lock, refuses pins/dev installs/shadows
2342
+ // and unverifiable scans, and only reports a version it verified on disk.
2343
+ // A desktop hello is the first point at which we can both repair an
2344
+ // unpinned skew and tell the affected user that ComfyUI must restart. The
2345
+ // bridge pins each CURRENT socket's kind on its FIRST hello, so query that
2346
+ // trusted session state rather than this raw (and replayable) hello's
2347
+ // `headless`. A headless mirror cannot load the desktop extension.
2348
+ if (!bridge.isCurrentHeadless(panelTab) &&
2349
+ !isPanelAutoInstallDisabled()) {
2350
+ void performPanelSync()
2351
+ .then((sync) => {
2352
+ // An already-current local panel needs no chat noise. Every other
2353
+ // outcome is actionable: synced => restart, pinned => unpin first,
2354
+ // blocked/unknown/dev => its truthful recovery guidance.
2355
+ if (sync.decision === "up-to-date" || sync.decision === "not-applicable")
2356
+ return;
2357
+ bridge.push({ type: "say", text: `⚠️ ${sync.message}` }, panelTab);
2358
+ logger.info(`[panel-orchestrator] panel sync on hello for ${panelTab.slice(0, 8)}: ` +
2359
+ `${sync.decision}${sync.synced ? ` (verified ${sync.verifiedVersion ?? "unknown"})` : ""}`);
2360
+ })
2361
+ .catch((err) => {
2362
+ // Never translate a Manager queue/verification failure into success.
2363
+ // The explicit tool remains available for diagnosis and retry.
2364
+ const detail = err instanceof Error ? err.message : String(err);
2365
+ logger.warn(`[panel-orchestrator] panel auto-sync on hello failed: ${detail}`);
2366
+ bridge.push({
2367
+ type: "say",
2368
+ text: `⚠️ Could not automatically sync the ComfyUI-MCP panel; no update was claimed. ` +
2369
+ `Run install_panel(action:'status') to inspect it, then retry install_panel(action:'sync') if appropriate. (${detail})`,
2370
+ }, panelTab);
2371
+ });
2372
+ }
2358
2373
  // Retarget ComfyUI to the URL the browser was served from (window.location),
2359
2374
  // BEFORE the readiness probe so the "ready" ack reflects the right instance —
2360
2375
  // but a hello can arrive from a STALE browser tab on a DEAD instance (E2E
@@ -2492,6 +2507,7 @@ export async function runPanelOrchestrator() {
2492
2507
  workflowTargetSeq.delete(migratedFrom);
2493
2508
  tabStableKey.delete(migratedFrom);
2494
2509
  tabStableIdentity.delete(migratedFrom);
2510
+ tabCommandWorkflowUuid.delete(migratedFrom);
2495
2511
  }
2496
2512
  else {
2497
2513
  // #570 — migrate per-backend state for EVERY provider, not only the currently-selected
@@ -2591,6 +2607,11 @@ export async function runPanelOrchestrator() {
2591
2607
  const carriedStable = tabStableKey.get(migratedFrom);
2592
2608
  tabStableKey.delete(migratedFrom);
2593
2609
  tabStableIdentity.delete(migratedFrom);
2610
+ // #436 — the command stamp is the ONE per-tab map that must NOT be retired
2611
+ // here: carry it onto the old id (same uuid — sameWorkflow proved it). See
2612
+ // carryWorkflowCommandStamp for why deleting it flapped sessions still
2613
+ // bound to the pre-migration id into read-ok / write-refused.
2614
+ carryWorkflowCommandStamp(tabCommandWorkflowUuid, migratedFrom, newIdentity);
2594
2615
  if (carriedStable !== undefined) {
2595
2616
  if (panelTab.startsWith("tmp:"))
2596
2617
  tabStableKey.set(panelTab, carriedStable);
@@ -2602,10 +2623,14 @@ export async function runPanelOrchestrator() {
2602
2623
  // #570: record this tab's TRUSTED identity (every tab) for the migration
2603
2624
  // discriminator above and the set_backend recompute below. Absent/untrusted →
2604
2625
  // cleared (fail closed: no proof of continuity → a future migration won't rebind).
2605
- if (newIdentity)
2626
+ if (newIdentity) {
2606
2627
  tabStableIdentity.set(panelTab, newIdentity);
2607
- else
2628
+ tabCommandWorkflowUuid.set(panelTab, newIdentity.uuid);
2629
+ }
2630
+ else {
2608
2631
  tabStableIdentity.delete(panelTab);
2632
+ tabCommandWorkflowUuid.delete(panelTab);
2633
+ }
2609
2634
  // Blind content mode rides the hello (issue #90) so the FIRST agent spawn
2610
2635
  // already carries the right tool-server env. A CHANGE against a live
2611
2636
  // agent also respawns it (codex-review F2: the set_content_mode frame is
@@ -2855,6 +2880,7 @@ export async function runPanelOrchestrator() {
2855
2880
  sessionStore.clearStable(prior);
2856
2881
  tabStableKey.delete(panelTab);
2857
2882
  tabStableIdentity.delete(panelTab);
2883
+ tabCommandWorkflowUuid.delete(panelTab);
2858
2884
  }
2859
2885
  }
2860
2886
  // Live model list for the picker; SDK slash commands are Claude-only.
@@ -2865,6 +2891,12 @@ export async function runPanelOrchestrator() {
2865
2891
  if (backend === "claude")
2866
2892
  pushCommands(panelTab);
2867
2893
  bridge.push({ type: "workflow_target", target: workflowTargets.get(panelTab) }, panelTab);
2894
+ // #717: panel tray rows belong to the bridge session, while progress files
2895
+ // are process-private. Reconcile THIS hello/re-hello directly, including
2896
+ // an empty snapshot, rather than waiting for an unrelated future change.
2897
+ // This frame is state only: it does not create terminal rows or signal any
2898
+ // download outcome.
2899
+ bridge.push({ type: "download_progress", downloads: downloadSnapshots.forPanel() }, panelTab);
2868
2900
  // Seed this tab's live queue monitor right away: queue_status broadcasts
2869
2901
  // are change-only, so a tab connecting MID-render would otherwise wait for
2870
2902
  // the next state transition to learn a job is already running.
@@ -3423,9 +3455,12 @@ export async function runPanelOrchestrator() {
3423
3455
  const tool = typeof ev.tool === "string" ? ev.tool : "";
3424
3456
  const toolArgs = ev.args && typeof ev.args === "object" ? ev.args : {};
3425
3457
  void (async () => {
3426
- if (!CALL_TOOL_WHITELIST.has(tool)) {
3427
- bridge.push({ type: "tool_result", cid, tool, ok: false, error: `tool "${tool}" is not permitted` }, tabId);
3428
- logger.warn(`[panel-orchestrator] call_tool rejected non-whitelisted "${tool}" (tab ${tabId.slice(0, 8)})`);
3458
+ // Name-level whitelist plus, for consolidated tools, action-level scope
3459
+ // (see call-tool-admission.ts). The name-refusal string is unchanged.
3460
+ const denied = callToolAdmission(tool, toolArgs);
3461
+ if (denied !== null) {
3462
+ bridge.push({ type: "tool_result", cid, tool, ok: false, error: denied }, tabId);
3463
+ logger.warn(`[panel-orchestrator] call_tool rejected: ${denied} (tab ${tabId.slice(0, 8)})`);
3429
3464
  return;
3430
3465
  }
3431
3466
  const client = await getCallToolClient();
@@ -4003,7 +4038,6 @@ export async function runPanelOrchestrator() {
4003
4038
  catch {
4004
4039
  // no saved state
4005
4040
  }
4006
- let lastDownloadSnapshot = "[]";
4007
4041
  const pollDownloads = () => {
4008
4042
  let files = [];
4009
4043
  try {
@@ -4135,9 +4169,7 @@ export async function runPanelOrchestrator() {
4135
4169
  // must not count as idle. Self-healing: a crashed writer's row ages out.
4136
4170
  downloadingRows = downloads.filter((d) => d.status === "downloading");
4137
4171
  downloads.sort((a, b) => String(a.name ?? "").localeCompare(String(b.name ?? "")));
4138
- const snapshot = JSON.stringify(downloads);
4139
- if (snapshot !== lastDownloadSnapshot) {
4140
- lastDownloadSnapshot = snapshot;
4172
+ if (downloadSnapshots.record(downloads)) {
4141
4173
  bridge.push({ type: "download_progress", downloads }); // broadcast to all tabs
4142
4174
  }
4143
4175
  // Flush any download-completion buckets whose debounce window has elapsed —