comfyui-mcp 0.48.22 → 0.48.24
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 +1 -1
- package/dist/orchestrator/index.js +189 -10
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/orchestrator/panel-agent.js +124 -29
- package/dist/orchestrator/panel-agent.js.map +1 -1
- package/dist/orchestrator/panel-tools.js +264 -36
- package/dist/orchestrator/panel-tools.js.map +1 -1
- package/dist/orchestrator/session-store.js +163 -28
- package/dist/orchestrator/session-store.js.map +1 -1
- package/dist/services/download-progress.js +5 -1
- package/dist/services/download-progress.js.map +1 -1
- package/dist/services/node-management.js +141 -18
- package/dist/services/node-management.js.map +1 -1
- package/dist/services/process-control.js +107 -8
- package/dist/services/process-control.js.map +1 -1
- package/dist/services/queue-monitor.js +83 -0
- package/dist/services/queue-monitor.js.map +1 -1
- package/dist/services/queue-monitor.self-attribution.test.js +120 -0
- package/dist/services/queue-monitor.self-attribution.test.js.map +1 -0
- package/dist/services/queue-status-broadcast.test.js +2 -0
- package/dist/services/queue-status-broadcast.test.js.map +1 -1
- package/dist/services/ui-bridge.js +96 -6
- package/dist/services/ui-bridge.js.map +1 -1
- package/dist/services/workflow-deps.js +20 -1
- package/dist/services/workflow-deps.js.map +1 -1
- package/dist/services/workflow-target-store.js +7 -3
- package/dist/services/workflow-target-store.js.map +1 -1
- package/dist/tools/diagnostics.js +10 -1
- package/dist/tools/diagnostics.js.map +1 -1
- package/dist/tools/model-explorer.js +85 -5
- package/dist/tools/model-explorer.js.map +1 -1
- package/dist/tools/workflow-visualize.js +1 -1
- package/dist/tools/workflow-visualize.js.map +1 -1
- package/package.json +1 -1
- package/plugin/skills/flux-txt2img/SKILL.md +9 -4
- package/plugin/skills/wan-scail-replacement/SKILL.md +95 -0
- package/scripts/sync-agents.mjs +18 -8
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ Works on **macOS**, **Linux**, and **Windows**. Auto-detects your ComfyUI instal
|
|
|
16
16
|
|
|
17
17
|
**Stuck or have a question? [Join the Discord](https://discord.gg/cW9arBhzCu)** — help, model tips, and release announcements.
|
|
18
18
|
|
|
19
|
-
**182 MCP tools** | **
|
|
19
|
+
**182 MCP tools** | **36 AI skills** (Flux · WAN · LTX 2.3 video · Qwen · Z-Image · Ideogram 4 · ERNIE · ANIMA · model registry · Civitai · node authoring · launch/perf flags) | **55 installer packs** | **11 slash commands** | **4 autonomous agents** | **3 hooks**
|
|
20
20
|
|
|
21
21
|
The plugin ships **expert skills that grow with every release** — model-specific generation guides with curated download URLs, workflow recipes, troubleshooting, and custom-node authoring — so Claude knows the right sampler, CFG, resolution, and model files for each architecture without trial and error.
|
|
22
22
|
|
|
@@ -26,7 +26,7 @@ import { uploadImageHttp, resetClient } from "../comfyui/client.js";
|
|
|
26
26
|
import { logger } from "../utils/logger.js";
|
|
27
27
|
import { PanelAgentManager, fetchSupportedModels, fetchSupportedCommands, isEffort, } from "./panel-agent.js";
|
|
28
28
|
import { promptText } from "./error-text.js";
|
|
29
|
-
import { createPanelMcpServer } from "./panel-tools.js";
|
|
29
|
+
import { createPanelMcpServer, makePanelToolCtx, resolvePinTarget } from "./panel-tools.js";
|
|
30
30
|
import { optionsAckFrame, optionsErrorAckFrame, optionsRequestMeta, } from "./options-ack.js";
|
|
31
31
|
import { readUserMcpServers } from "../services/user-mcp-config.js";
|
|
32
32
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
@@ -85,7 +85,7 @@ REPORT OUR OWN BUGS (we're in beta — bias HARD toward filing) — distinct fro
|
|
|
85
85
|
|
|
86
86
|
WEDGED RENDER / OOM / VRAM PINNED — when a generation is stuck or hits CUDA out-of-memory, or a cancel didn't actually free GPU memory (models still resident, VRAM pinned, the next run still OOMs), call panel_free_vram to UNLOAD all models and free VRAM before retrying — it does NOT restart ComfyUI, so it's the cheap first move. Escalation ladder: cancel the run → panel_free_vram (unload + free) → retry; only as a LAST RESORT panel_restart_comfyui (which refuses mid-render and guards the running generation). Reach for panel_free_vram before a restart whenever a cancel left memory pinned.
|
|
87
87
|
|
|
88
|
-
WORKFLOW TARGETING — by default your panel_* graph edits follow whichever workflow tab the user is currently viewing.
|
|
88
|
+
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.
|
|
89
89
|
|
|
90
90
|
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.
|
|
91
91
|
|
|
@@ -216,11 +216,16 @@ function formatQueueNote(rep) {
|
|
|
216
216
|
`clear_pending:true; if it reports the job still wedged, restart_comfyui / panel_restart_comfyui. ` +
|
|
217
217
|
`Do NOT queue another run on top.`);
|
|
218
218
|
}
|
|
219
|
-
|
|
219
|
+
// A plain backlog (depth > 1) is NOT evidence of a wedge — deliberately queuing
|
|
220
|
+
// a batch is a normal workflow. Suppress the note entirely when the in-flight
|
|
221
|
+
// work is this session's own recent jobs, and when it isn't, report it NEUTRALLY
|
|
222
|
+
// (no false "you likely queued behind a stuck job" diagnosis, no destructive
|
|
223
|
+
// clear_pending as the headline). A genuinely stalled job is handled above (#559).
|
|
224
|
+
if (rep.backlog && !rep.selfAttributed) {
|
|
220
225
|
const pending = Math.max(0, rep.queueDepth - 1);
|
|
221
|
-
return (
|
|
222
|
-
`
|
|
223
|
-
`
|
|
226
|
+
return (`ℹ️ ComfyUI queue: ${rep.queueDepth} tasks in flight (1 running + ${pending} pending) that this session ` +
|
|
227
|
+
`didn't queue. This is only a problem if the running one is stuck — inspect with get_queue. ` +
|
|
228
|
+
`cancel_queued_job drops a single pending item; cancel_job with clear_pending:true resets everything.`);
|
|
224
229
|
}
|
|
225
230
|
return null;
|
|
226
231
|
}
|
|
@@ -1244,7 +1249,20 @@ export async function runPanelOrchestrator() {
|
|
|
1244
1249
|
// race) can label itself correctly instead of showing the pre-init default.
|
|
1245
1250
|
const resolvedModelByTab = new Map();
|
|
1246
1251
|
const headlessTabs = new Set(); // tabs with no ComfyUI canvas (mobile/remote) — deliver renders in-turn
|
|
1252
|
+
// #570: for an UNSAVED workflow the panel tab id is an ephemeral tmp:<uuid>,
|
|
1253
|
+
// regenerated on every reload, so the tab-keyed session store can't survive an
|
|
1254
|
+
// orchestrator restart that also reloads the panel. Map each such tab to a STABLE
|
|
1255
|
+
// resume key (origin + title + backend) computed at hello, so onSession can also
|
|
1256
|
+
// persist under it and a reloaded tab can resume it. tmp: tabs only.
|
|
1257
|
+
const tabStableKey = new Map();
|
|
1247
1258
|
const workflowTargets = new WorkflowTargetStore();
|
|
1259
|
+
// Monotonic per-tab sequence for set_workflow_target events. A pinned target is
|
|
1260
|
+
// validated asynchronously (resolvePinTarget queries workflow_list), so a later event
|
|
1261
|
+
// (another pin, or a synchronous mode:"current") can arrive before the async pin
|
|
1262
|
+
// commits. Each event bumps the tab's sequence; a pinned resolution only commits/acks if
|
|
1263
|
+
// its captured sequence is still the latest — otherwise a stale pin would clobber the
|
|
1264
|
+
// user's newer selection (codex race).
|
|
1265
|
+
const workflowTargetSeq = new Map();
|
|
1248
1266
|
const backendForTab = (panelTabId) => tabBackends.get(panelTabId) ?? defaultBackend;
|
|
1249
1267
|
const agentKeyFor = (panelTabId) => panelTabId + AGENT_KEY_SEP + backendForTab(panelTabId);
|
|
1250
1268
|
// A panel tab id never contains "::"; backend names never do — so split on the
|
|
@@ -1687,6 +1705,10 @@ export async function runPanelOrchestrator() {
|
|
|
1687
1705
|
COMFYUI_URL: comfyuiUrl,
|
|
1688
1706
|
// Where download_model writes live progress for the panel tray.
|
|
1689
1707
|
COMFYUI_MCP_PROGRESS_DIR: progressDir,
|
|
1708
|
+
// Self-scope this tab's downloads so the orchestrator can wake EXACTLY
|
|
1709
|
+
// this tab's agent when a download settles (#547) — the child stamps its
|
|
1710
|
+
// own COMFYUI_MCP_TAB into each progress row, mirroring COMFYUI_MCP_BLIND.
|
|
1711
|
+
...(panelTab ? { COMFYUI_MCP_TAB: panelTab } : {}),
|
|
1690
1712
|
// Local mode → enables download_model, apply_manifest (installer packs),
|
|
1691
1713
|
// and model scans so the agent installs the right way instead of curl.
|
|
1692
1714
|
...(comfyuiPath ? { COMFYUI_PATH: comfyuiPath } : forceRemoteEnv()),
|
|
@@ -1733,6 +1755,12 @@ export async function runPanelOrchestrator() {
|
|
|
1733
1755
|
// Report the SDK session id so the panel can persist it and resume on reload.
|
|
1734
1756
|
onSession: (key, sessionId, model) => {
|
|
1735
1757
|
const panelTab = panelTabOf(key);
|
|
1758
|
+
// #570: also persist under the tab's STABLE resume key (unsaved workflows),
|
|
1759
|
+
// so a panel reload that regenerates the tmp:<uuid> can still resume this
|
|
1760
|
+
// conversation. The manager already persisted under the exact tab key.
|
|
1761
|
+
const skey = tabStableKey.get(panelTab);
|
|
1762
|
+
if (skey)
|
|
1763
|
+
sessionStore.setStable(skey, sessionId, panelTab);
|
|
1736
1764
|
bridge.push({ type: "session", session_id: sessionId }, panelTab);
|
|
1737
1765
|
bridge.broadcastTabList(); // a session started/changed → refresh mirror pickers
|
|
1738
1766
|
// #376: the ready banner was sent at hello with the PRE-init default model.
|
|
@@ -2326,6 +2354,12 @@ export async function runPanelOrchestrator() {
|
|
|
2326
2354
|
tabBackends.delete(migratedFrom);
|
|
2327
2355
|
headlessTabs.delete(migratedFrom);
|
|
2328
2356
|
workflowTargets.clear(migratedFrom);
|
|
2357
|
+
// Invalidate any in-flight set_workflow_target(pinned) resolution captured under
|
|
2358
|
+
// the retired id: dropping its sequence makes isCurrent() false, so a late async
|
|
2359
|
+
// pin can no longer write a stale target / emit a late ack that the bridge's
|
|
2360
|
+
// migration map would deliver to the new tab (codex race, tab-id migration edge).
|
|
2361
|
+
workflowTargetSeq.delete(migratedFrom);
|
|
2362
|
+
tabStableKey.delete(migratedFrom); // recomputed for the new id below (#570)
|
|
2329
2363
|
}
|
|
2330
2364
|
// Blind content mode rides the hello (issue #90) so the FIRST agent spawn
|
|
2331
2365
|
// already carries the right tool-server env. A CHANGE against a live
|
|
@@ -2371,6 +2405,53 @@ export async function runPanelOrchestrator() {
|
|
|
2371
2405
|
const resume = typeof event.resume === "string" ? event.resume : undefined;
|
|
2372
2406
|
if (resume && (!prev || prev === backend))
|
|
2373
2407
|
manager.setResume(key, resume);
|
|
2408
|
+
// #570: an UNSAVED workflow's tab id is an ephemeral tmp:<uuid>, regenerated
|
|
2409
|
+
// on every panel reload — so on an orchestrator restart that also reloads the
|
|
2410
|
+
// panel, the tab returns under a never-stored id and forgets everything
|
|
2411
|
+
// (neither our tab-keyed store nor the panel's tab-keyed hello.resume can hit).
|
|
2412
|
+
// Anchor a STABLE resume key on what DOES survive that tab's reload — the
|
|
2413
|
+
// ComfyUI origin + workflow title + backend — and seed it as a fallback.
|
|
2414
|
+
// Saved workflows (wf:<path>) already key stably, so this is tmp:-only.
|
|
2415
|
+
if (panelTab.startsWith("tmp:")) {
|
|
2416
|
+
const origin = (typeof helloUrl === "string" && helloUrl.trim() ? helloUrl.trim() : undefined) ??
|
|
2417
|
+
bridge.tabOrigin(panelTab) ??
|
|
2418
|
+
"";
|
|
2419
|
+
const title = typeof event.title === "string" ? event.title : "";
|
|
2420
|
+
const skey = `tmp::${origin}::${title}::${backend}`;
|
|
2421
|
+
tabStableKey.set(panelTab, skey);
|
|
2422
|
+
// Seed the resume fallback ONLY when the panel supplied none and no live
|
|
2423
|
+
// agent owns the key. spawn() prefers the exact-tab store hit over this
|
|
2424
|
+
// pendingResume, so the precise path is never overridden — this only
|
|
2425
|
+
// rescues the churned tmp: id. setResume() no-ops if a live agent exists.
|
|
2426
|
+
//
|
|
2427
|
+
// COLLISION GUARD: origin+title+backend is NOT unique — two unsaved tabs
|
|
2428
|
+
// with the same (default) title collide. Seed only when THIS tab is the sole
|
|
2429
|
+
// connected holder of the key, so a fresh sibling tab can never resume
|
|
2430
|
+
// another CONCURRENTLY-live unsaved tab's conversation (the cross-tab hijack
|
|
2431
|
+
// the migration path also refuses; setStable poisons a key the moment two
|
|
2432
|
+
// live tabs write distinct sessions to it). When ambiguous we surface fresh —
|
|
2433
|
+
// a lost resume is a mild miss; resuming the WRONG conversation is not.
|
|
2434
|
+
//
|
|
2435
|
+
// The SEQUENTIAL same-title case (tab A closed, a later tab B opens with the
|
|
2436
|
+
// same origin+title+backend) intentionally resumes A: by the only identity
|
|
2437
|
+
// that survives a reload — the workflow identity the issue itself names as the
|
|
2438
|
+
// key — B *is* the same workspace. This is bounded by the GC TTL (an abandoned
|
|
2439
|
+
// session ages out) and always yields to the exact-tab store and the panel's
|
|
2440
|
+
// own hello.resume. A truly-unique panel per-instance id (referenced in #568/
|
|
2441
|
+
// #570's thread) would let us tighten even this — a clean future upgrade.
|
|
2442
|
+
if (!resume && !manager.hasLiveAgent(key) && sessionStore.get(key) === undefined) {
|
|
2443
|
+
let sameKeyTabs = 0;
|
|
2444
|
+
for (const t of bridge.tabs()) {
|
|
2445
|
+
if (tabStableKey.get(t.tab_id) === skey)
|
|
2446
|
+
sameKeyTabs += 1;
|
|
2447
|
+
}
|
|
2448
|
+
if (sameKeyTabs <= 1) {
|
|
2449
|
+
const stableSid = sessionStore.getStable(skey);
|
|
2450
|
+
if (stableSid)
|
|
2451
|
+
manager.setResume(key, stableSid);
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2374
2455
|
// Live model list for the picker; SDK slash commands are Claude-only.
|
|
2375
2456
|
pushModels(panelTab);
|
|
2376
2457
|
// Truthful provider readiness (this machine runs the agents), so the
|
|
@@ -2607,10 +2688,48 @@ export async function runPanelOrchestrator() {
|
|
|
2607
2688
|
bridge.push({ type: "ack", ok: false, kind: "workflow_target", message: "path required when pinning" }, panelTab);
|
|
2608
2689
|
return;
|
|
2609
2690
|
}
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2691
|
+
// Every target event bumps the tab's sequence so a later selection always wins over
|
|
2692
|
+
// an in-flight (async) pin resolution.
|
|
2693
|
+
const seq = (workflowTargetSeq.get(panelTab) ?? 0) + 1;
|
|
2694
|
+
workflowTargetSeq.set(panelTab, seq);
|
|
2695
|
+
const isCurrent = () => workflowTargetSeq.get(panelTab) === seq;
|
|
2696
|
+
const ackTarget = (t) => {
|
|
2697
|
+
bridge.push({ type: "ack", ok: true, kind: "workflow_target", target: t }, panelTab);
|
|
2698
|
+
bridge.push({ type: "workflow_target", target: t }, panelTab);
|
|
2699
|
+
logger.info(`[panel-orchestrator] tab ${panelTab.slice(0, 8)} workflow target → ${t.mode}${t.path ? ` (${t.path})` : ""}`);
|
|
2700
|
+
};
|
|
2701
|
+
// A PINNED target must clear the SAME validation as the MCP tool
|
|
2702
|
+
// (panel_set_workflow_target) — otherwise this panel-driven event path would be a
|
|
2703
|
+
// bypass that re-admits #556/#571 (background pin) and #259 (not-open pin). Resolve
|
|
2704
|
+
// async through the shared helper, failing at pin time before the store is written.
|
|
2705
|
+
if (mode === "pinned") {
|
|
2706
|
+
void (async () => {
|
|
2707
|
+
const ctx = makePanelToolCtx(bridge, panelTab, workflowTargets);
|
|
2708
|
+
const res = await resolvePinTarget(ctx, String(path), filename);
|
|
2709
|
+
// Superseded by a newer target event while we were validating — drop silently
|
|
2710
|
+
// (no write, no late ack) so the newer selection is never clobbered.
|
|
2711
|
+
if (!isCurrent())
|
|
2712
|
+
return;
|
|
2713
|
+
if (!res.ok) {
|
|
2714
|
+
bridge.push({ type: "ack", ok: false, kind: "workflow_target", message: res.error }, panelTab);
|
|
2715
|
+
return;
|
|
2716
|
+
}
|
|
2717
|
+
ackTarget(workflowTargets.set(panelTab, { mode: "pinned", path: res.pinPath, filename: res.pinFilename }));
|
|
2718
|
+
})().catch((err) => {
|
|
2719
|
+
if (!isCurrent())
|
|
2720
|
+
return;
|
|
2721
|
+
bridge.push({
|
|
2722
|
+
type: "ack",
|
|
2723
|
+
ok: false,
|
|
2724
|
+
kind: "workflow_target",
|
|
2725
|
+
message: `Could not pin workflow: ${err instanceof Error ? err.message : String(err)}`,
|
|
2726
|
+
}, panelTab);
|
|
2727
|
+
});
|
|
2728
|
+
return;
|
|
2729
|
+
}
|
|
2730
|
+
// mode === "current" — no target to validate; follow the active tab. Writes
|
|
2731
|
+
// synchronously and is the latest sequence, so it wins over any in-flight pin.
|
|
2732
|
+
ackTarget(workflowTargets.set(panelTab, { mode, path, filename }));
|
|
2614
2733
|
return;
|
|
2615
2734
|
}
|
|
2616
2735
|
// Live panel config: render-stall threshold, plus the user's agent-model
|
|
@@ -3067,6 +3186,12 @@ export async function runPanelOrchestrator() {
|
|
|
3067
3186
|
// reset() is synchronous (map cleared now), so no concurrent send() can
|
|
3068
3187
|
// spawn an agent before we report the cleared session.
|
|
3069
3188
|
manager.reset(agentKeyFor(tabId));
|
|
3189
|
+
// reset() clears the exact-tab store; the stable resume index (#570) is the
|
|
3190
|
+
// manager's blind spot, so drop it here too — a deliberate NEW chat must not
|
|
3191
|
+
// be resurrected by the unsaved-workflow fallback on the next reload.
|
|
3192
|
+
const sk = tabStableKey.get(tabId);
|
|
3193
|
+
if (sk)
|
|
3194
|
+
sessionStore.clearStable(sk);
|
|
3070
3195
|
bridge.push({ type: "session", session_id: null }, tabId);
|
|
3071
3196
|
bridge.push({ type: "ack", ok: true, kind: "new_session" }, tabId);
|
|
3072
3197
|
bridge.broadcastTabList(); // session cleared → mirror pickers' green dot off
|
|
@@ -3309,6 +3434,37 @@ export async function runPanelOrchestrator() {
|
|
|
3309
3434
|
// a downloading row that stops updating for 60s is treated as a dead writer.
|
|
3310
3435
|
const DOWNLOAD_LINGER_MS = 8000;
|
|
3311
3436
|
const downloadRemoveAt = new Map();
|
|
3437
|
+
// Download-completion agent events (#547). A finished render already wakes the
|
|
3438
|
+
// agent (manager.injectEvent kind:"executed"); a finished DOWNLOAD had no
|
|
3439
|
+
// equivalent, so a "download then use the model" task stalled until the user
|
|
3440
|
+
// poked it. We observe the SAME first-terminal transition the tray-prune timer
|
|
3441
|
+
// uses and inject a completion event to the tab's agent — but COALESCED: an
|
|
3442
|
+
// apply_manifest that pulls many files would otherwise fire one turn per file,
|
|
3443
|
+
// so completions accumulate per agent for a short window and flush as ONE event.
|
|
3444
|
+
const DOWNLOAD_DONE_DEBOUNCE_MS = 1500;
|
|
3445
|
+
// agentKey → { download identity → {name, terminal status} } accumulated since
|
|
3446
|
+
// the last flush, plus the epoch-ms deadline (extended by each new completion)
|
|
3447
|
+
// at which we emit. Keyed by download IDENTITY (row.id), not display name, so
|
|
3448
|
+
// two distinct downloads sharing a filename in one batch don't overwrite each
|
|
3449
|
+
// other and hide a failure (codex).
|
|
3450
|
+
const downloadDonePending = new Map();
|
|
3451
|
+
// Resolve which agent to wake for a settled download row: the stamped tab's
|
|
3452
|
+
// agent when it's still live; else the SINGLE live agent (pre-fix/in-process
|
|
3453
|
+
// rows carry no tab, AND a tab-id migration/backend change can leave the
|
|
3454
|
+
// stamped tab's key no longer resolving to the live agent — codex); else none —
|
|
3455
|
+
// never fan out to unrelated tabs (#547).
|
|
3456
|
+
const resolveDownloadAgentKey = (row) => {
|
|
3457
|
+
const tab = typeof row.tab === "string" ? row.tab.trim() : "";
|
|
3458
|
+
if (tab) {
|
|
3459
|
+
const key = agentKeyFor(tab);
|
|
3460
|
+
if (manager.hasLiveAgent(key))
|
|
3461
|
+
return key;
|
|
3462
|
+
// Stamped tab's agent is gone (migration/backend switch) — fall through to
|
|
3463
|
+
// the single-live-agent fallback rather than silently dropping the event.
|
|
3464
|
+
}
|
|
3465
|
+
const live = manager.liveKeys();
|
|
3466
|
+
return live.length === 1 ? live[0] : null;
|
|
3467
|
+
};
|
|
3312
3468
|
/** Boolean URL probe with a timeout — readiness checks for pending pod connects. */
|
|
3313
3469
|
const probeOk = async (url, timeoutMs = 8_000) => {
|
|
3314
3470
|
const ctl = new AbortController();
|
|
@@ -3417,6 +3573,19 @@ export async function runPanelOrchestrator() {
|
|
|
3417
3573
|
const due = downloadRemoveAt.get(full);
|
|
3418
3574
|
if (due == null) {
|
|
3419
3575
|
downloadRemoveAt.set(full, now + DOWNLOAD_LINGER_MS); // start the linger
|
|
3576
|
+
// FIRST terminal observation of this download (due was unset) — the
|
|
3577
|
+
// exact once-per-download moment. Wake the tab's agent with the result
|
|
3578
|
+
// (#547), coalesced via downloadDonePending so a many-file manifest is
|
|
3579
|
+
// one turn, not N.
|
|
3580
|
+
const key = resolveDownloadAgentKey(row);
|
|
3581
|
+
if (key && manager.hasLiveAgent(key)) {
|
|
3582
|
+
const bucket = downloadDonePending.get(key) ??
|
|
3583
|
+
{ downloads: new Map(), flushAt: 0 };
|
|
3584
|
+
const idKey = String(row.id ?? full);
|
|
3585
|
+
bucket.downloads.set(idKey, { name: String(row.name ?? row.id ?? "model"), status: String(status) });
|
|
3586
|
+
bucket.flushAt = now + DOWNLOAD_DONE_DEBOUNCE_MS;
|
|
3587
|
+
downloadDonePending.set(key, bucket);
|
|
3588
|
+
}
|
|
3420
3589
|
}
|
|
3421
3590
|
else if (now >= due) {
|
|
3422
3591
|
try {
|
|
@@ -3450,6 +3619,16 @@ export async function runPanelOrchestrator() {
|
|
|
3450
3619
|
lastDownloadSnapshot = snapshot;
|
|
3451
3620
|
bridge.push({ type: "download_progress", downloads }); // broadcast to all tabs
|
|
3452
3621
|
}
|
|
3622
|
+
// Flush any download-completion buckets whose debounce window has elapsed —
|
|
3623
|
+
// ONE agent event per tab per batch of settled downloads (#547). Runs every
|
|
3624
|
+
// tick (700ms) so a flush always fires shortly after the last file settles.
|
|
3625
|
+
for (const [key, bucket] of [...downloadDonePending]) {
|
|
3626
|
+
if (now < bucket.flushAt)
|
|
3627
|
+
continue;
|
|
3628
|
+
downloadDonePending.delete(key);
|
|
3629
|
+
const settled = [...bucket.downloads.values()];
|
|
3630
|
+
manager.injectEvent(key, { kind: "download_done", downloads: settled });
|
|
3631
|
+
}
|
|
3453
3632
|
// MCP-child control channel (#269): runpod_* tools that ran in spawned
|
|
3454
3633
|
// agent children ask the orchestrator to retarget / watch / unwatch /
|
|
3455
3634
|
// auto-connect here — through the SAME applyComfyuiUrl fan-out as a panel
|