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.
- package/README.md +5 -10
- package/dist/comfyui/cloud-client.js +2 -2
- package/dist/comfyui/cloud-client.js.map +1 -1
- package/dist/config.js +22 -0
- package/dist/config.js.map +1 -1
- package/dist/orchestrator/agent-backend.js +9 -0
- package/dist/orchestrator/agent-backend.js.map +1 -1
- package/dist/orchestrator/antigravity-backend.js +3 -2
- package/dist/orchestrator/antigravity-backend.js.map +1 -1
- package/dist/orchestrator/call-tool-admission.js +160 -0
- package/dist/orchestrator/call-tool-admission.js.map +1 -0
- package/dist/orchestrator/chatgpt-oauth-backend.js +3 -2
- package/dist/orchestrator/chatgpt-oauth-backend.js.map +1 -1
- package/dist/orchestrator/claude-backend.js +300 -3
- package/dist/orchestrator/claude-backend.js.map +1 -1
- package/dist/orchestrator/codex-backend.js +3 -2
- package/dist/orchestrator/codex-backend.js.map +1 -1
- package/dist/orchestrator/gemini-backend.js +3 -2
- package/dist/orchestrator/gemini-backend.js.map +1 -1
- package/dist/orchestrator/grok-backend.js +5 -3
- package/dist/orchestrator/grok-backend.js.map +1 -1
- package/dist/orchestrator/index.js +169 -137
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/orchestrator/ollama-backend.js +3 -2
- package/dist/orchestrator/ollama-backend.js.map +1 -1
- package/dist/orchestrator/panel-agent.js +89 -19
- package/dist/orchestrator/panel-agent.js.map +1 -1
- package/dist/orchestrator/panel-tools.js +955 -81
- package/dist/orchestrator/panel-tools.js.map +1 -1
- package/dist/orchestrator/pi-backend.js +3 -2
- package/dist/orchestrator/pi-backend.js.map +1 -1
- package/dist/orchestrator/session-store.js +22 -0
- package/dist/orchestrator/session-store.js.map +1 -1
- package/dist/services/civitai-resolver.js +22 -7
- package/dist/services/civitai-resolver.js.map +1 -1
- package/dist/services/comfy-cli.js +1 -1
- package/dist/services/download-jobs.js +1 -1
- package/dist/services/extra-paths.js +7 -7
- package/dist/services/extra-paths.js.map +1 -1
- package/dist/services/graph-query.js +10 -4
- package/dist/services/graph-query.js.map +1 -1
- package/dist/services/manager-config.js +11 -2
- package/dist/services/manager-config.js.map +1 -1
- package/dist/services/model-resolver.js +4 -4
- package/dist/services/model-resolver.js.map +1 -1
- package/dist/services/node-authoring.js +3 -3
- package/dist/services/node-authoring.js.map +1 -1
- package/dist/services/node-dev.js +3 -3
- package/dist/services/node-dev.js.map +1 -1
- package/dist/services/node-management.js +185 -9
- package/dist/services/node-management.js.map +1 -1
- package/dist/services/node-snapshots.js +117 -12
- package/dist/services/node-snapshots.js.map +1 -1
- package/dist/services/node-verify.js +1 -1
- package/dist/services/node-verify.js.map +1 -1
- package/dist/services/output-dir.js +1 -1
- package/dist/services/output-dir.js.map +1 -1
- package/dist/services/panel-installer.js +466 -6
- package/dist/services/panel-installer.js.map +1 -1
- package/dist/services/panel-pending-cancel.js +446 -0
- package/dist/services/panel-pending-cancel.js.map +1 -0
- package/dist/services/panel-pin-guard.js +50 -3
- package/dist/services/panel-pin-guard.js.map +1 -1
- package/dist/services/panel-sync.js +3 -20
- package/dist/services/panel-sync.js.map +1 -1
- package/dist/services/process-control.js +132 -8
- package/dist/services/process-control.js.map +1 -1
- package/dist/services/queue-manager.js +1 -1
- package/dist/services/queue-manager.js.map +1 -1
- package/dist/services/queue-monitor.js +1 -1
- package/dist/services/queue-monitor.js.map +1 -1
- package/dist/services/queue-monitor.self-attribution.test.js +1 -1
- package/dist/services/queue-monitor.self-attribution.test.js.map +1 -1
- package/dist/services/ui-bridge.js +180 -17
- package/dist/services/ui-bridge.js.map +1 -1
- package/dist/services/update-comfyui.js +29 -2
- package/dist/services/update-comfyui.js.map +1 -1
- package/dist/services/workspace-env.js +3 -3
- package/dist/services/workspace-env.js.map +1 -1
- package/dist/tools/api-nodes.js +1 -1
- package/dist/tools/api-nodes.js.map +1 -1
- package/dist/tools/batches.js +1 -1
- package/dist/tools/batches.js.map +1 -1
- package/dist/tools/compact.js +32 -6
- package/dist/tools/compact.js.map +1 -1
- package/dist/tools/extra-paths.js +1 -1
- package/dist/tools/extra-paths.js.map +1 -1
- package/dist/tools/generate-3d.js +2 -2
- package/dist/tools/generate-3d.js.map +1 -1
- package/dist/tools/install-panel.js +62 -22
- package/dist/tools/install-panel.js.map +1 -1
- package/dist/tools/model-explorer.js +175 -117
- package/dist/tools/model-explorer.js.map +1 -1
- package/dist/tools/queue-management.js +117 -135
- package/dist/tools/queue-management.js.map +1 -1
- package/dist/tools/run-template.js +4 -4
- package/dist/tools/run-template.js.map +1 -1
- package/dist/tools/vocabulary.js +123 -19
- package/dist/tools/vocabulary.js.map +1 -1
- package/dist/tools/workflow-autoload.js +5 -5
- package/dist/tools/workflow-autoload.js.map +1 -1
- package/dist/tools/workflow-execute.js +1 -1
- package/dist/tools/workflow-execute.js.map +1 -1
- package/dist/tools/workspace-env.js +53 -28
- package/dist/tools/workspace-env.js.map +1 -1
- package/docs/design/panel-surface.txt +1 -0
- package/docs/design/tool-surface.txt +3 -0
- package/package.json +2 -2
- package/plugin/commands/batch.md +1 -1
- package/plugin/commands/gen.md +2 -2
- package/plugin/scripts/monitor-progress.mjs +1 -1
- package/plugin/skills/comfyui-core/SKILL.md +15 -13
- package/plugin/skills/director/SKILL.md +1 -1
- package/plugin/skills/ltxv2-video/SKILL.md +1 -1
- package/plugin/skills/troubleshooting/SKILL.md +1 -1
- package/plugin/skills/video-extend/SKILL.md +1 -1
- package/plugin/skills/workflow-layout/SKILL.md +4 -5
- package/scripts/arena-scenarios.mjs +5 -5
- package/scripts/check-tool-vocabulary.mts +9 -0
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
241
|
-
`
|
|
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) =>
|
|
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
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
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
|
-
|
|
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 —
|