comfyui-mcp 0.48.24 → 0.48.25
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/dist/orchestrator/panel-tools.js +264 -28
- package/dist/orchestrator/panel-tools.js.map +1 -1
- package/dist/services/download-cache.js +223 -0
- package/dist/services/download-cache.js.map +1 -1
- package/dist/services/model-resolver.js +123 -8
- package/dist/services/model-resolver.js.map +1 -1
- package/package.json +1 -1
|
@@ -193,6 +193,10 @@ export const __panelToolsTestHooks = {
|
|
|
193
193
|
setRetrySettleMs(ms) {
|
|
194
194
|
retrySettleMsOverride = ms;
|
|
195
195
|
},
|
|
196
|
+
/** Inject fast reconnect-wait timing so #400/#402 tests don't wait the real ~20s. */
|
|
197
|
+
setReconnectWaitTiming(timing) {
|
|
198
|
+
reconnectWaitTimingOverride = timing;
|
|
199
|
+
},
|
|
196
200
|
isRetrySafeCmd,
|
|
197
201
|
isTransientReconnectError,
|
|
198
202
|
// #384 live-canvas capture fallback (defined later in the module).
|
|
@@ -257,6 +261,23 @@ function retrySettleMs() {
|
|
|
257
261
|
return retrySettleMsOverride;
|
|
258
262
|
return Math.round(parsePositiveNumberEnv("COMFYUI_PANEL_RETRY_SETTLE_S", 0.4) * 1000);
|
|
259
263
|
}
|
|
264
|
+
let reconnectWaitTimingOverride = null;
|
|
265
|
+
/** Bounded wait for a browser tab to (re)connect after a full ComfyUI restart or a
|
|
266
|
+
* soft-reload — the "Connected: none" window in which every panel_* call fires into
|
|
267
|
+
* a dead binding (#400) or a mutating open/save returns OUTCOME UNKNOWN (#402). The
|
|
268
|
+
* browser reconnects its own socket seconds-to-tens-of-seconds after ComfyUI comes
|
|
269
|
+
* back, so the existing single ~400ms retry always loses the race. Test-overridable. */
|
|
270
|
+
/** Hard ceiling on the reconnect wait so an oversized env value can never make a
|
|
271
|
+
* tool block near/over the outer MCP tools/call deadline (~300s). */
|
|
272
|
+
const RECONNECT_WAIT_MAX_MS = 60_000;
|
|
273
|
+
function reconnectWaitTiming() {
|
|
274
|
+
if (reconnectWaitTimingOverride)
|
|
275
|
+
return reconnectWaitTimingOverride;
|
|
276
|
+
return {
|
|
277
|
+
budgetMs: Math.min(RECONNECT_WAIT_MAX_MS, Math.round(parsePositiveNumberEnv("COMFYUI_PANEL_RECONNECT_WAIT_S", 20) * 1000)),
|
|
278
|
+
intervalMs: Math.round(parsePositiveNumberEnv("COMFYUI_PANEL_RECONNECT_POLL_S", 0.5) * 1000),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
260
281
|
/** True when a decoded /system_stats body has the recognizable ComfyUI shape (a
|
|
261
282
|
* `system` object and/or a `devices` array) — the same fields health_check /
|
|
262
283
|
* get_environment read. A bare 2xx from a reverse-proxy login page, an SPA
|
|
@@ -852,11 +873,31 @@ async function waitForWorkflowActive(ctx, path, timing) {
|
|
|
852
873
|
* the target became active despite the slow ack, otherwise the original timeout
|
|
853
874
|
* failure. Never masks a genuine open-failure as success.
|
|
854
875
|
*/
|
|
876
|
+
/** Terminal error for a MUTATING panel command when the pre-send reachability wait
|
|
877
|
+
* gave up (no tab reconnected within budget / an ambiguous multi-tab session). We
|
|
878
|
+
* must NOT dispatch — firing into a dead binding is exactly the OUTCOME-UNKNOWN /
|
|
879
|
+
* double-apply risk the pre-send wait exists to prevent (codex). */
|
|
880
|
+
function noReachableTabFail(cmd) {
|
|
881
|
+
return fail(`${cmd} — this session has no reachable panel tab yet (still reconnecting after a ` +
|
|
882
|
+
`restart/reload, or multiple tabs are open and none is this session's). Nothing was ` +
|
|
883
|
+
`sent. Retry in a moment, or rebind with panel_set_workflow_target({mode:"current"}).`);
|
|
884
|
+
}
|
|
855
885
|
async function openWorkflowWithVerify(path, ctx) {
|
|
886
|
+
// #402: after a full ComfyUI restart the browser tab re-registers a few seconds
|
|
887
|
+
// later. Awaiting a stable binding BEFORE dispatching a mutating workflow_open
|
|
888
|
+
// (nothing is sent yet — no double-apply risk) means the command reaches a live
|
|
889
|
+
// tab instead of firing into the "Connected: none" window and coming back
|
|
890
|
+
// OUTCOME UNKNOWN. A healthy session returns from this instantly; if no tab
|
|
891
|
+
// reconnects within budget we REFUSE rather than dispatch into a dead binding.
|
|
892
|
+
if (ctx.awaitReachable && !(await ctx.awaitReachable())) {
|
|
893
|
+
return noReachableTabFail("workflow_open");
|
|
894
|
+
}
|
|
856
895
|
const res = await ctx.call({ cmd: "workflow_open", path }, 15000);
|
|
857
896
|
// Success, or a genuine acked error (missing file / real executor error) — the
|
|
858
|
-
// caller must see it as-is.
|
|
859
|
-
|
|
897
|
+
// caller must see it as-is. A slow-ack TIMEOUT or a mid-command reconnect DROP
|
|
898
|
+
// ("OUTCOME UNKNOWN", #402) both warrant verification: re-reading the active
|
|
899
|
+
// workflow is idempotent, so we can turn an UNKNOWN into a definite outcome.
|
|
900
|
+
if (!isAckTimeout(res) && !isReconnectDrop(res))
|
|
860
901
|
return res;
|
|
861
902
|
const timing = getOpenVerifyTiming();
|
|
862
903
|
const verify = await waitForWorkflowActive(ctx, path, timing);
|
|
@@ -865,15 +906,30 @@ async function openWorkflowWithVerify(path, ctx) {
|
|
|
865
906
|
opened: { path },
|
|
866
907
|
recovered: true,
|
|
867
908
|
note: `"${path}" is now the active workflow — the switch succeeded, but the tab was slow ` +
|
|
868
|
-
`to acknowledge (backgrounded/frozen or already open)
|
|
909
|
+
`to acknowledge (backgrounded/frozen or already open) or briefly disconnected while ` +
|
|
910
|
+
`reconnecting after a restart, so the initial ack was inconclusive. ` +
|
|
869
911
|
`Confirmed active via workflow_list after ${(verify.waited_ms / 1000).toFixed(1)}s ` +
|
|
870
912
|
`(${verify.attempts} probe${verify.attempts === 1 ? "" : "s"}). Do NOT retry.`,
|
|
871
913
|
});
|
|
872
914
|
}
|
|
873
|
-
// The ack
|
|
874
|
-
// is a REAL failure. Return the original bridge
|
|
915
|
+
// The ack was inconclusive AND the target never became active within the budget —
|
|
916
|
+
// this is a REAL failure. Return the original bridge error unchanged.
|
|
875
917
|
return res;
|
|
876
918
|
}
|
|
919
|
+
/** True when a ToolResult is a MID-COMMAND reconnect drop ("disconnected
|
|
920
|
+
* mid-command … OUTCOME UNKNOWN") — the command was written but the tab dropped
|
|
921
|
+
* before a reply while reconnecting after a restart/reload (#402). Re-verifying an
|
|
922
|
+
* idempotent workflow-state change is safe on this signal. */
|
|
923
|
+
function isReconnectDrop(res) {
|
|
924
|
+
if (!res?.isError)
|
|
925
|
+
return false;
|
|
926
|
+
const text = res?.content?.find((c) => c.type === "text")?.text ?? "";
|
|
927
|
+
// A pre-write "NOT dispatched" send failure must NOT verify (nothing happened) —
|
|
928
|
+
// let it surface as-is; only a POST-write mid-command drop is re-verifiable.
|
|
929
|
+
if (/NOT dispatched/i.test(text))
|
|
930
|
+
return false;
|
|
931
|
+
return /disconnected mid-command|OUTCOME UNKNOWN/i.test(text);
|
|
932
|
+
}
|
|
877
933
|
/**
|
|
878
934
|
* Resolve a caller-supplied pin `path` (path / filename / key, any form) to the
|
|
879
935
|
* AUTHORITATIVE open-workflow record from a fresh `workflow_list` — the single
|
|
@@ -1327,18 +1383,38 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets) {
|
|
|
1327
1383
|
// mode already means.
|
|
1328
1384
|
// It routes through makePanelToolCtx only — bridge.resolveTarget itself is
|
|
1329
1385
|
// untouched, so the dead-alias security invariant (ui-bridge.test.ts:459) holds.
|
|
1386
|
+
// The tab ids ELIGIBLE to host a graph/workflow session: connected AND canvas-owning.
|
|
1387
|
+
// A headless client (mobile mirror / remote / exec viewer — ui-bridge Conn.headless)
|
|
1388
|
+
// is canvas-less and can never run graph tools, so it must never be a rebind target
|
|
1389
|
+
// (codex). Returns null when the bridge can't enumerate tabs/headlessness (older or
|
|
1390
|
+
// lightweight ctx) — callers then fall back to bridge.resolveActiveTabId (legacy).
|
|
1391
|
+
const isHeadlessTab = (id) => typeof bridge.isHeadless === "function" && bridge.isHeadless(id);
|
|
1392
|
+
const interactiveTabIds = () => {
|
|
1393
|
+
if (typeof bridge.tabs !== "function")
|
|
1394
|
+
return null;
|
|
1395
|
+
const live = bridge.tabs();
|
|
1396
|
+
if (!Array.isArray(live))
|
|
1397
|
+
return null;
|
|
1398
|
+
return live.filter((t) => !isHeadlessTab(t.tab_id)).map((t) => t.tab_id);
|
|
1399
|
+
};
|
|
1330
1400
|
const ensureReachable = () => {
|
|
1331
1401
|
if (typeof bridge.canReach !== "function")
|
|
1332
1402
|
return; // lightweight test ctx
|
|
1333
1403
|
if (bridge.canReach(ctx.tabId))
|
|
1334
|
-
return;
|
|
1404
|
+
return; // healthy binding — leave untouched
|
|
1335
1405
|
if (workflowTargets?.get(ctx.tabId)?.mode === "pinned")
|
|
1336
1406
|
return; // stay strict
|
|
1337
1407
|
// Strict-single: never silently pick among multiple live tabs (would risk the
|
|
1338
|
-
// real bridge's last-active fallback routing to an unrelated workflow).
|
|
1339
|
-
//
|
|
1340
|
-
|
|
1341
|
-
|
|
1408
|
+
// real bridge's last-active fallback routing to an unrelated workflow). Count only
|
|
1409
|
+
// INTERACTIVE tabs — a lone canvas tab alongside headless viewers still binds, and
|
|
1410
|
+
// a headless-only state is treated as "nothing bindable" (rebindToActiveTab throws).
|
|
1411
|
+
const eligible = interactiveTabIds();
|
|
1412
|
+
if (eligible) {
|
|
1413
|
+
if (eligible.length > 1)
|
|
1414
|
+
return; // 2+ INTERACTIVE tabs → strict, don't guess
|
|
1415
|
+
}
|
|
1416
|
+
else if (typeof bridge.tabs === "function") {
|
|
1417
|
+
const live = bridge.tabs(); // legacy path (no headless info)
|
|
1342
1418
|
if (Array.isArray(live) && live.length > 1)
|
|
1343
1419
|
return;
|
|
1344
1420
|
}
|
|
@@ -1346,10 +1422,58 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets) {
|
|
|
1346
1422
|
rebindToActiveTab();
|
|
1347
1423
|
}
|
|
1348
1424
|
catch {
|
|
1349
|
-
// Ambiguous (2+ tabs) or nothing
|
|
1425
|
+
// Ambiguous (2+ tabs) or nothing bindable — leave tabId as-is and let the
|
|
1350
1426
|
// command surface the bridge's own clear, tab-listing error.
|
|
1351
1427
|
}
|
|
1352
1428
|
};
|
|
1429
|
+
// Bounded pre-send wait for a tab to (re)connect after a restart/reload — see
|
|
1430
|
+
// PanelToolCtx.awaitReachable. Complements ensureReachable (which acts INSTANTLY
|
|
1431
|
+
// on an already-present sole tab): this waits out the "Connected: none" window a
|
|
1432
|
+
// fresh ComfyUI restart opens, in which the browser's own socket hasn't re-hello'd
|
|
1433
|
+
// yet. Conservative by construction: returns at once for a healthy session, and
|
|
1434
|
+
// only waits while ZERO tabs are connected (a 1+/multi-tab case is left untouched
|
|
1435
|
+
// for the existing strict-single ensureReachable to resolve or refuse).
|
|
1436
|
+
const awaitReachable = async (budgetMs) => {
|
|
1437
|
+
if (typeof bridge.canReach !== "function")
|
|
1438
|
+
return true; // lightweight test ctx
|
|
1439
|
+
if (bridge.canReach(ctx.tabId))
|
|
1440
|
+
return true; // healthy binding
|
|
1441
|
+
// We can only meaningfully WAIT for a reconnect when the bridge can enumerate its
|
|
1442
|
+
// live tabs. Without that (older/lightweight bridge), never loop — do a single
|
|
1443
|
+
// synchronous heal and report reachability, exactly as before this primitive.
|
|
1444
|
+
if (typeof bridge.tabs !== "function") {
|
|
1445
|
+
ensureReachable();
|
|
1446
|
+
return bridge.canReach(ctx.tabId);
|
|
1447
|
+
}
|
|
1448
|
+
const timing = reconnectWaitTiming();
|
|
1449
|
+
// The configured reconnect budget is the intended MAX; an explicit budgetMs (e.g.
|
|
1450
|
+
// a caller's remaining deadline) only ever TIGHTENS it — never extends the wait.
|
|
1451
|
+
const budget = Math.max(0, budgetMs != null ? Math.min(budgetMs, timing.budgetMs) : timing.budgetMs);
|
|
1452
|
+
const intervalMs = Math.max(1, timing.intervalMs);
|
|
1453
|
+
const deadline = Date.now() + budget;
|
|
1454
|
+
for (;;) {
|
|
1455
|
+
// Only an INTERACTIVE (canvas-owning) tab is a valid graph/workflow binding — a
|
|
1456
|
+
// headless viewer is canvas-less, so awaiting/rebinding onto one and reporting
|
|
1457
|
+
// "ready" would route open/save at a client with no canvas (codex).
|
|
1458
|
+
const interactive = interactiveTabIds() ?? [];
|
|
1459
|
+
if (interactive.length > 0) {
|
|
1460
|
+
// A canvas tab is present → the reconnect window is OVER. Try the strict-single
|
|
1461
|
+
// synchronous heal (binds a sole reconnected tab). Then return IMMEDIATELY,
|
|
1462
|
+
// bound or not: if we still can't reach ctx.tabId (2+ interactive tabs, or a
|
|
1463
|
+
// pinned stale target ensureReachable leaves strict), waiting longer can't help —
|
|
1464
|
+
// report now so open/save refuse promptly and panel_set_workflow_target proceeds
|
|
1465
|
+
// to its explicit last-active rebind instead of stalling the whole budget (codex).
|
|
1466
|
+
ensureReachable();
|
|
1467
|
+
return bridge.canReach(ctx.tabId);
|
|
1468
|
+
}
|
|
1469
|
+
// ZERO interactive tabs — the genuine "Connected: none" post-restart window (a lone
|
|
1470
|
+
// headless viewer counts as none). Keep waiting for a canvas tab to re-register.
|
|
1471
|
+
const left = deadline - Date.now();
|
|
1472
|
+
if (left <= 0)
|
|
1473
|
+
return bridge.canReach(ctx.tabId);
|
|
1474
|
+
await sleep(Math.min(intervalMs, left));
|
|
1475
|
+
}
|
|
1476
|
+
};
|
|
1353
1477
|
const sendRouted = async (cmd, timeoutMs) => {
|
|
1354
1478
|
const target = workflowTargets?.get(ctx.tabId);
|
|
1355
1479
|
const routed = target ? withWorkflowTarget(cmd, target) : cmd;
|
|
@@ -1449,9 +1573,31 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets) {
|
|
|
1449
1573
|
// active tab can't be picked.
|
|
1450
1574
|
const rebindToActiveTab = () => {
|
|
1451
1575
|
const previous = ctx.tabId;
|
|
1576
|
+
// A healthy binding is left untouched (never disturb a live session). Recovery only
|
|
1577
|
+
// fires for an orphaned/stale tab id.
|
|
1452
1578
|
if (bridge.canReach(previous))
|
|
1453
1579
|
return { previous, current: previous, rebound: false };
|
|
1454
|
-
|
|
1580
|
+
// Pick the target tab EXCLUDING headless (canvas-less) viewers, which can't host a
|
|
1581
|
+
// graph session (codex). Prefer the sole interactive tab; with 2+ interactive tabs
|
|
1582
|
+
// fall back to the bridge's last-active resolution (the explicit-rebind "use what's
|
|
1583
|
+
// live now" consent); with none, throw. A resolution that still lands on a headless
|
|
1584
|
+
// tab is rejected as "nothing bindable" so no graph session is ever bound canvas-less.
|
|
1585
|
+
const eligible = interactiveTabIds();
|
|
1586
|
+
let current;
|
|
1587
|
+
if (eligible) {
|
|
1588
|
+
if (eligible.length === 1)
|
|
1589
|
+
current = eligible[0];
|
|
1590
|
+
else if (eligible.length === 0)
|
|
1591
|
+
throw new Error("Panel not reachable: no panel connected");
|
|
1592
|
+
else
|
|
1593
|
+
current = bridge.resolveActiveTabId(); // 2+ interactive → last-active (or throws)
|
|
1594
|
+
}
|
|
1595
|
+
else {
|
|
1596
|
+
current = bridge.resolveActiveTabId(); // legacy bridge (no headless info)
|
|
1597
|
+
}
|
|
1598
|
+
if (typeof bridge.isHeadless === "function" && bridge.isHeadless(current)) {
|
|
1599
|
+
throw new Error("Panel not reachable: no panel connected");
|
|
1600
|
+
}
|
|
1455
1601
|
// Carry a pinned workflow target across to the new tab id so a pinned
|
|
1456
1602
|
// session keeps its pin after self-healing.
|
|
1457
1603
|
const pinned = workflowTargets?.get(previous);
|
|
@@ -1466,6 +1612,7 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets) {
|
|
|
1466
1612
|
ctx.confirm = confirm;
|
|
1467
1613
|
ctx.rebindToActiveTab = rebindToActiveTab;
|
|
1468
1614
|
ctx.ensureReachable = ensureReachable;
|
|
1615
|
+
ctx.awaitReachable = awaitReachable;
|
|
1469
1616
|
return ctx;
|
|
1470
1617
|
}
|
|
1471
1618
|
/**
|
|
@@ -2144,7 +2291,7 @@ export function buildPanelToolDefs() {
|
|
|
2144
2291
|
node_id: z.number().int().describe("Node id whose input to disconnect."),
|
|
2145
2292
|
input: slotRef.optional().describe("Input slot name or index (default 0)."),
|
|
2146
2293
|
}, async (args, ctx) => ctx.call({ cmd: "graph_disconnect", node_id: args.node_id, input: args.input })),
|
|
2147
|
-
def("panel_set_widget", "Set a widget value on a node in the user's open graph (steps, cfg, seed, ckpt_name, text prompts, …). Returns the previous and new value. Undoable with Ctrl+Z. To CLEAR a text widget to an empty string, pass `clear: true` (some MCP clients drop an empty-string `value` from the serialized payload, so `value: \"\"` may not arrive — `clear: true` always works).", {
|
|
2294
|
+
def("panel_set_widget", "Set a widget value on a node in the user's open graph (steps, cfg, seed, ckpt_name, text prompts, …). Returns the previous and new value. Undoable with Ctrl+Z. To CLEAR a text widget to an empty string, pass `clear: true` (some MCP clients drop an empty-string `value` from the serialized payload, so `value: \"\"` may not arrive — `clear: true` always works). For the LTXDirector timeline node (WhatDreamsCost CSGlide), set `timeline_data` with the FULL timeline JSON (segments + global_prompt) to drive its custom timeline UI — this re-syncs the editor and regenerates its derived `local_prompts`/`segment_lengths`/`guide_strength` widgets; setting those derived widgets directly is refused (they are silently reverted).", {
|
|
2148
2295
|
node_id: z.number().int().describe("Node id from panel_graph_outline / panel_query_graph."),
|
|
2149
2296
|
widget: z.string().describe("Widget name (e.g. 'steps', 'cfg', 'text')."),
|
|
2150
2297
|
value: z
|
|
@@ -2169,6 +2316,14 @@ export function buildPanelToolDefs() {
|
|
|
2169
2316
|
node_id: z.number().int().describe("Node id from panel_graph_outline / panel_query_graph."),
|
|
2170
2317
|
pos: xy().describe("New canvas [x, y] (two numbers)."),
|
|
2171
2318
|
}, async (args, ctx) => ctx.call({ cmd: "graph_move_node", node_id: args.node_id, pos: args.pos })),
|
|
2319
|
+
def("panel_resize_node", "Resize a node to [width, height] (canvas px) on the user's open graph. Essential for Note / MarkdownNote nodes, which are created tiny (140×60) and are unreadable until enlarged — panel_move_node only repositions, it cannot resize. Uses the node's own setSize so DOM-widget nodes (MarkdownNote) and nodes that clamp to a computed minimum reflow correctly. Undoable with Ctrl+Z.", {
|
|
2320
|
+
node_id: z.number().int().describe("Node id from panel_graph_outline / panel_query_graph."),
|
|
2321
|
+
size: z
|
|
2322
|
+
.array(z.number())
|
|
2323
|
+
.min(2)
|
|
2324
|
+
.max(2)
|
|
2325
|
+
.describe("New [width, height] in canvas px (both > 0)."),
|
|
2326
|
+
}, async (args, ctx) => ctx.call({ cmd: "graph_resize_node", node_id: args.node_id, size: args.size })),
|
|
2172
2327
|
def("panel_auto_layout", "Automatically arrange the user's open graph (or a subset of nodes) into a clean left-to-right / top-to-bottom / grid layout based on the real link topology. Group boxes move with their members and are re-fit. Use dry_run:true to preview proposed positions without touching the canvas. Undoable (one Ctrl+Z).", {
|
|
2173
2328
|
node_ids: z
|
|
2174
2329
|
.array(z.number().int())
|
|
@@ -2211,7 +2366,7 @@ export function buildPanelToolDefs() {
|
|
|
2211
2366
|
dy: args.dy,
|
|
2212
2367
|
scale: args.scale,
|
|
2213
2368
|
})),
|
|
2214
|
-
def("panel_run", "Queue the workflow the user has OPEN — exactly like them pressing Queue Prompt (current widget values, the live graph they can see). On success it confirms the run was queued; if ComfyUI REFUSES the prompt (validation failure on either channel — per-node node_errors OR a top-level error like a missing node type) it returns a FAILURE with that rejection detail, never a false 'queued'. Pass to_node_id to RUN ONLY ONE BRANCH ('run to node'): ComfyUI renders just that output node plus everything upstream of it and SKIPS every other output branch — handy for previewing or debugging part of a big graph without rendering the whole thing. to_node_id MUST be an OUTPUT node (SaveImage, PreviewImage, SaveVideo, …) — pick the one at the END of the branch you want; nodes are tagged is_output:true in panel_query_graph's detail rows. Omit it to run the whole graph. Use this so the render runs on THEIR canvas and they see the result.", {
|
|
2369
|
+
def("panel_run", "Queue the workflow the user has OPEN — exactly like them pressing Queue Prompt (current widget values, the live graph they can see). On success it confirms the run was queued; if ComfyUI REFUSES the prompt (validation failure on either channel — per-node node_errors OR a top-level error like a missing node type) it returns a FAILURE with that rejection detail, never a false 'queued'. Pass to_node_id to RUN ONLY ONE BRANCH ('run to node'): ComfyUI renders just that output node plus everything upstream of it and SKIPS every other output branch — handy for previewing or debugging part of a big graph without rendering the whole thing. to_node_id MUST be an OUTPUT node (SaveImage, PreviewImage, SaveVideo, …) — pick the one at the END of the branch you want; nodes are tagged is_output:true in panel_query_graph's detail rows. The output node may be NESTED inside a subgraph — just pass its id (resolved in the scope you're currently viewing, then anywhere in the workflow); the tool builds the nested execution path for you. Omit it to run the whole graph. Use this so the render runs on THEIR canvas and they see the result.", {
|
|
2215
2370
|
batch_count: z
|
|
2216
2371
|
.number()
|
|
2217
2372
|
.int()
|
|
@@ -2223,7 +2378,7 @@ export function buildPanelToolDefs() {
|
|
|
2223
2378
|
.number()
|
|
2224
2379
|
.int()
|
|
2225
2380
|
.optional()
|
|
2226
|
-
.describe("Output node id to render UP TO (partial execution). Omit to run the whole graph. Must be an OUTPUT node — one with is_output:true in panel_query_graph's detail rows."),
|
|
2381
|
+
.describe("Output node id to render UP TO (partial execution). Omit to run the whole graph. Must be an OUTPUT node — one with is_output:true in panel_query_graph's detail rows. May be nested inside a subgraph (pass the node's own id)."),
|
|
2227
2382
|
}, async (args, ctx) => {
|
|
2228
2383
|
// BACKPRESSURE: the agent can't see ComfyUI's queue, so re-queuing while a
|
|
2229
2384
|
// render is already running silently stacks behind it (this is how a stuck
|
|
@@ -2306,7 +2461,14 @@ export function buildPanelToolDefs() {
|
|
|
2306
2461
|
// documented "ambiguous multi-tab surfaces a clear error" promise (codex).
|
|
2307
2462
|
const orphaned = typeof ctx.bridge.canReach === "function" && !ctx.bridge.canReach(ctx.tabId);
|
|
2308
2463
|
const live = typeof ctx.bridge.tabs === "function" ? ctx.bridge.tabs() : undefined;
|
|
2309
|
-
|
|
2464
|
+
// Count only INTERACTIVE (canvas-owning) tabs for the ambiguity guard: one
|
|
2465
|
+
// desktop canvas alongside headless viewers is NOT ambiguous — rebindToActiveTab
|
|
2466
|
+
// binds the sole desktop tab. Only 2+ real canvas tabs are unpickable here.
|
|
2467
|
+
const headless = ctx.bridge.isHeadless;
|
|
2468
|
+
const interactive = Array.isArray(live) && typeof headless === "function"
|
|
2469
|
+
? live.filter((t) => !headless(t.tab_id))
|
|
2470
|
+
: live;
|
|
2471
|
+
if (orphaned && Array.isArray(interactive) && interactive.length > 1) {
|
|
2310
2472
|
return fail("This session's ComfyUI tab was replaced and multiple tabs are now open — " +
|
|
2311
2473
|
"can't safely pick one. Switch to the tab you want, then call " +
|
|
2312
2474
|
'panel_set_workflow_target({mode:"current"}) before panel_reload.');
|
|
@@ -2704,9 +2866,20 @@ export function buildPanelToolDefs() {
|
|
|
2704
2866
|
multi_select: args.multi_select,
|
|
2705
2867
|
});
|
|
2706
2868
|
}),
|
|
2707
|
-
def("panel_save_workflow", "Save the user's open workflow PROGRAMMATICALLY — no Save/Rename dialog ever pops. With no `name`: saves in place (or auto-names + persists a never-saved workflow). With `name`: if the workflow is ALREADY saved under a different name this is a SAVE-AS — it writes a NEW file and leaves the original untouched on disk (it NEVER renames/moves/destroys the original); for a never-saved workflow it is simply the first save. The result reports what happened: `saved_as`+`copied_from`+`original_on_disk` (a disk-verified check that the original file still exists) for a Save-As copy, or `first_save` for a brand-new workflow. Use this freely (e.g. after building a graph) — it won't interrupt the user.", { name: z.string().optional().describe("Name for the workflow (no .json needed). If the workflow is already saved under a different name, this writes a NEW file (Save-As COPY) and leaves the original in place — it never renames/moves/destroys it. Omit to save in place / auto-name an unsaved workflow.") }, async (args, ctx) =>
|
|
2708
|
-
|
|
2709
|
-
|
|
2869
|
+
def("panel_save_workflow", "Save the user's open workflow PROGRAMMATICALLY — no Save/Rename dialog ever pops. With no `name`: saves in place (or auto-names + persists a never-saved workflow). With `name`: if the workflow is ALREADY saved under a different name this is a SAVE-AS — it writes a NEW file and leaves the original untouched on disk (it NEVER renames/moves/destroys the original); for a never-saved workflow it is simply the first save. The result reports what happened: `saved_as`+`copied_from`+`original_on_disk` (a disk-verified check that the original file still exists) for a Save-As copy, or `first_save` for a brand-new workflow. Use this freely (e.g. after building a graph) — it won't interrupt the user.", { name: z.string().optional().describe("Name for the workflow (no .json needed). If the workflow is already saved under a different name, this writes a NEW file (Save-As COPY) and leaves the original in place — it never renames/moves/destroys it. Omit to save in place / auto-name an unsaved workflow.") }, async (args, ctx) => {
|
|
2870
|
+
// #402: await a stable tab binding before dispatching the (mutating) save, so
|
|
2871
|
+
// a save issued in the post-restart "Connected: none" window reaches a live
|
|
2872
|
+
// tab instead of failing with a bare "Failed to fetch"/OUTCOME UNKNOWN. Pre-
|
|
2873
|
+
// send only (nothing dispatched yet) → no risk of writing the file twice; and
|
|
2874
|
+
// if no tab reconnects within budget we REFUSE rather than fire into a dead
|
|
2875
|
+
// binding.
|
|
2876
|
+
if (ctx.awaitReachable && !(await ctx.awaitReachable())) {
|
|
2877
|
+
return noReachableTabFail(args.name ? "workflow_save_as" : "workflow_save");
|
|
2878
|
+
}
|
|
2879
|
+
return args.name
|
|
2880
|
+
? ctx.call({ cmd: "workflow_save_as", name: args.name }, 15000)
|
|
2881
|
+
: ctx.call({ cmd: "workflow_save" }, 15000);
|
|
2882
|
+
}),
|
|
2710
2883
|
def("panel_list_workflows", "List the user's OPEN workflow tabs and which one is active (path, filename, modified, persisted). Use this to know what's open before switching/renaming/closing. Read-only.", {}, async (_args, ctx) => ctx.call({ cmd: "workflow_list" })),
|
|
2711
2884
|
def("panel_get_workflow_target", "Read which workflow this agent is bound to edit. mode 'current' means graph tools follow whatever tab the user is viewing; mode 'pinned' means edits are bound to the pinned workflow (which was the active canvas at pin time) — if the user later switches to another tab, your next graph call FAILS LOUDLY rather than silently editing the wrong graph. Call this when unsure which workflow your panel_* edits will affect.", {}, async (_args, ctx) => {
|
|
2712
2885
|
const target = ctx.workflowTarget?.get(ctx.tabId) ?? { mode: "current" };
|
|
@@ -2737,15 +2910,55 @@ export function buildPanelToolDefs() {
|
|
|
2737
2910
|
// pin store, so subsequent panel_* calls route to the live tab. Surfaces a
|
|
2738
2911
|
// clear error if a single active tab can't be determined.
|
|
2739
2912
|
let rebindNote = "";
|
|
2913
|
+
let deferredBind = false;
|
|
2740
2914
|
if (mode === "current" && ctx.rebindToActiveTab) {
|
|
2915
|
+
const before = ctx.tabId;
|
|
2916
|
+
// Give an in-flight reconnect (a ComfyUI restart / panel reload still
|
|
2917
|
+
// settling) a brief chance to bind immediately, since this IS the recovery
|
|
2918
|
+
// signal the agent reaches for in exactly that window (#474). awaitReachable
|
|
2919
|
+
// rebinds via ensureReachable when a tab is (re)connected.
|
|
2920
|
+
if (ctx.awaitReachable)
|
|
2921
|
+
await ctx.awaitReachable();
|
|
2741
2922
|
try {
|
|
2742
|
-
|
|
2743
|
-
if (rebound) {
|
|
2744
|
-
rebindNote = ` Rebound this session from tab ${previous.slice(0, 8)} onto the active tab ${current.slice(0, 8)}.`;
|
|
2745
|
-
}
|
|
2923
|
+
ctx.rebindToActiveTab(); // completes the rebind if awaitReachable didn't
|
|
2746
2924
|
}
|
|
2747
2925
|
catch (err) {
|
|
2748
|
-
|
|
2926
|
+
// #474: with 2+ live tabs the rebind is AMBIGUOUS — fail so the user picks.
|
|
2927
|
+
// But with ZERO tabs connected (the "Connected: none" window right after a
|
|
2928
|
+
// restart/reload where the old tmp: tab is gone) the recovery call must NOT
|
|
2929
|
+
// hard-fail: clear the stale binding and record the current-mode intent so
|
|
2930
|
+
// the session binds onto the tab the moment one reconnects, instead of
|
|
2931
|
+
// stranding the agent with no way to recover.
|
|
2932
|
+
const live = typeof ctx.bridge.tabs === "function" ? ctx.bridge.tabs() : undefined;
|
|
2933
|
+
let noTabsConnected;
|
|
2934
|
+
if (Array.isArray(live)) {
|
|
2935
|
+
// Count only INTERACTIVE (canvas-owning) tabs: a headless-only reconnect is
|
|
2936
|
+
// NOT a usable graph binding, so it defers (binds once a real canvas tab
|
|
2937
|
+
// connects) rather than failing as if a tab were pickable.
|
|
2938
|
+
const headless = ctx.bridge.isHeadless;
|
|
2939
|
+
const interactive = typeof headless === "function" ? live.filter((t) => !headless(t.tab_id)) : live;
|
|
2940
|
+
noTabsConnected = interactive.length === 0;
|
|
2941
|
+
}
|
|
2942
|
+
else {
|
|
2943
|
+
// No tab enumeration — classify by the resolve error: only "nothing
|
|
2944
|
+
// connected" defers; an AMBIGUOUS multi-tab error must still fail so the
|
|
2945
|
+
// user picks (never silently defer a routable-but-ambiguous session).
|
|
2946
|
+
const msg = err instanceof Error ? err.message : String(err ?? "");
|
|
2947
|
+
noTabsConnected =
|
|
2948
|
+
/no panel connected|not reachable|connected:\s*none|no connected tab/i.test(msg) &&
|
|
2949
|
+
!/multiple|last active|pass tab_id/i.test(msg);
|
|
2950
|
+
}
|
|
2951
|
+
if (!noTabsConnected)
|
|
2952
|
+
return fail(err);
|
|
2953
|
+
deferredBind = true;
|
|
2954
|
+
rebindNote =
|
|
2955
|
+
" No panel tab is connected yet — cleared the stale binding; this session will " +
|
|
2956
|
+
"follow (bind onto) the tab as soon as one reconnects. Retry your graph tool in a moment.";
|
|
2957
|
+
}
|
|
2958
|
+
// Detect the rebind regardless of whether awaitReachable or rebindToActiveTab
|
|
2959
|
+
// performed it (either mutates ctx.tabId), so the note is never swallowed.
|
|
2960
|
+
if (!deferredBind && ctx.tabId !== before) {
|
|
2961
|
+
rebindNote = ` Rebound this session from tab ${before.slice(0, 8)} onto the active tab ${ctx.tabId.slice(0, 8)}.`;
|
|
2749
2962
|
}
|
|
2750
2963
|
}
|
|
2751
2964
|
// PIN: bind to the EXACT open-workflow identity from the authoritative
|
|
@@ -2771,7 +2984,7 @@ export function buildPanelToolDefs() {
|
|
|
2771
2984
|
const hint = target.mode === "pinned"
|
|
2772
2985
|
? `Pinned to "${target.filename ?? target.path}". Graph tools will target that workflow without switching the user's view.`
|
|
2773
2986
|
: "Following the user's current workflow tab.";
|
|
2774
|
-
return ok({ ...target, note: hint + rebindNote });
|
|
2987
|
+
return ok({ ...target, ...(deferredBind ? { deferred: true } : {}), note: hint + rebindNote });
|
|
2775
2988
|
}),
|
|
2776
2989
|
def("panel_new_workflow", "Open a brand-new BLANK workflow in a NEW TAB. Use this whenever the user wants a 'new workflow' / 'fresh canvas' / 'start over for a new project'. This does NOT touch their current workflow — it opens a separate tab. NEVER use panel_clear for a new workflow (panel_clear wipes the CURRENT graph and is only for 'clear/reset this canvas').", {}, async (_args, ctx) => ctx.call({ cmd: "workflow_new" }, 15000)),
|
|
2777
2990
|
def("panel_open_workflow", "Open / switch to a workflow by path or filename (from panel_list_workflows). Switches the active tab to it.", { path: z.string().describe("Workflow path, filename, or key from panel_list_workflows.") },
|
|
@@ -2870,12 +3083,16 @@ export function buildPanelToolDefs() {
|
|
|
2870
3083
|
"• 'active' — normal: the node executes.\n" +
|
|
2871
3084
|
"• 'bypass' — the node is SKIPPED and PASSES ITS INPUT THROUGH to its output (downstream still runs, just as if this node weren't there). Use to disable a single processing node (an upscaler, a LoRA, a detailer) while keeping the pipeline connected.\n" +
|
|
2872
3085
|
"• 'mute' — the node AND everything DOWNSTREAM of it do NOT execute (no pass-through). Use to fully switch off a branch/output.\n" +
|
|
2873
|
-
"CRITICAL — modes silently change what a render produces, so they are a top cause of 'wrong output'. A BYPASSED node contributes nothing of its own and a MUTED node kills its branch. Use this tool to ENABLE the path you actually want and DISABLE the one you don't — e.g. to drive a workflow from its Ideogram/JSON prompt builder you must set the manual-prompt node to 'bypass' and the JSON-builder path to 'active' (or vice-versa); likewise to pick one branch of an rgthree 'Fast Groups Bypasser'/Muter or a prompt-source switch. ALWAYS read modes first (panel_graph_outline marks [bypass]/[mute]; panel_query_graph detail rows carry mode): if the intended path is bypassed/muted, fix it HERE before running, and never assume a switch/route is already active. Undoable with Ctrl+Z.", {
|
|
3086
|
+
"CRITICAL — modes silently change what a render produces, so they are a top cause of 'wrong output'. A BYPASSED node contributes nothing of its own and a MUTED node kills its branch. Use this tool to ENABLE the path you actually want and DISABLE the one you don't — e.g. to drive a workflow from its Ideogram/JSON prompt builder you must set the manual-prompt node to 'bypass' and the JSON-builder path to 'active' (or vice-versa); likewise to pick one branch of an rgthree 'Fast Groups Bypasser'/Muter or a prompt-source switch. ALWAYS read modes first (panel_graph_outline marks [bypass]/[mute]; panel_query_graph detail rows carry mode): if the intended path is bypassed/muted, fix it HERE before running, and never assume a switch/route is already active. UNSAFE-BYPASS GUARD: bypassing a SUBGRAPH node whose boundary inputs are ordered differently from its outputs is REJECTED — ComfyUI forwards each output from the input at the SAME index, so e.g. an IMAGE output backed by a BBOX_DETECTOR input would silently feed the wrong type downstream. Re-order the boundary inputs or add an explicit ImpactSwitch to choose the passthrough; pass force:true only if you truly intend the positional forward. Undoable with Ctrl+Z.", {
|
|
2874
3087
|
node_id: z.number().int().describe("Node id from panel_graph_outline / panel_query_graph."),
|
|
2875
3088
|
mode: z
|
|
2876
3089
|
.enum(["active", "bypass", "mute"])
|
|
2877
3090
|
.describe("'active' = runs normally; 'bypass' = skipped, passes input through (downstream still runs); 'mute' = node and everything downstream do not execute."),
|
|
2878
|
-
|
|
3091
|
+
force: z
|
|
3092
|
+
.boolean()
|
|
3093
|
+
.optional()
|
|
3094
|
+
.describe("Override the unsafe-bypass guard on a subgraph node (proceed with a positional boundary forward even when input/output types don't line up by index). Omit for normal safe behaviour."),
|
|
3095
|
+
}, async (args, ctx) => ctx.call({ cmd: "graph_set_node_mode", node_id: args.node_id, mode: args.mode, force: args.force })),
|
|
2879
3096
|
def("panel_set_node_color", "Set a node's title-bar and/or body color on the user's open graph. Easiest: pass a `preset` from ComfyUI's palette (red, brown, green, blue, pale_blue, cyan, purple, yellow, black) for matched colors. Or set explicit `color` (title bar) and/or `bgcolor` (body) as hex like '#3f789e'. Pass null for a field to reset it to the theme default. Great for colour-coding stages. Undoable.", {
|
|
2880
3097
|
node_id: z.number().int().describe("Node id from panel_graph_outline / panel_query_graph."),
|
|
2881
3098
|
preset: z
|
|
@@ -3242,17 +3459,36 @@ export function buildPanelToolDefs() {
|
|
|
3242
3459
|
: `The reboot command was sent but I could NOT confirm ComfyUI actually cycled within ${waited}s (it never went down — the panel may have merely disconnected/inferred a reboot without one). Verify with health_check / panel_node_queue_status; do NOT assume it restarted.`,
|
|
3243
3460
|
});
|
|
3244
3461
|
}
|
|
3462
|
+
// #400: ComfyUI is healthy, but the panel's browser tab re-registers its own
|
|
3463
|
+
// socket a moment later. If we return NOW, the very next graph tool in this turn
|
|
3464
|
+
// hits "no connected tab … Connected: none" and the agent is told to hand-rebind.
|
|
3465
|
+
// Wait (bounded, clamped to THIS handler's deadline) for the tab to reconnect and
|
|
3466
|
+
// rebind this session onto it. `ready` reflects GRAPH-TOOL readiness (a bound tab),
|
|
3467
|
+
// NOT just server health — a caller keying off `ready` must not be led into the
|
|
3468
|
+
// Connected:none window (codex). `server_ready` carries the certified cycle either
|
|
3469
|
+
// way. When ctx has no awaitReachable (older/lightweight ctx) tabBack is true, so
|
|
3470
|
+
// the historical ready:true-on-healthy-restart contract is preserved.
|
|
3471
|
+
const tabBack = ctx.awaitReachable
|
|
3472
|
+
? await ctx.awaitReachable(Math.max(0, overallDeadline - Date.now()))
|
|
3473
|
+
: true;
|
|
3245
3474
|
return ok({
|
|
3246
3475
|
rebooting: true,
|
|
3247
|
-
ready:
|
|
3476
|
+
ready: tabBack, // graph tools are usable only once a panel tab is bound
|
|
3477
|
+
server_ready: true, // ComfyUI itself cycled and is healthy
|
|
3248
3478
|
confirmed_cycle: true, // we directly observed the down→up cycle on the boot endpoint
|
|
3249
3479
|
recovered_ms: recovery.waited_ms,
|
|
3250
3480
|
probes: recovery.attempts,
|
|
3251
3481
|
saw_down: recovery.sawDown,
|
|
3252
3482
|
via: recovery.via,
|
|
3483
|
+
panel_tab_reconnected: tabBack,
|
|
3253
3484
|
note: `ComfyUI restart accepted and it is healthy again in ${(recovery.waited_ms / 1000).toFixed(1)}s` +
|
|
3254
3485
|
" (observed it go down then come back)" +
|
|
3255
3486
|
(dropped ? "; connection dropped as expected while it went down" : "") +
|
|
3487
|
+
(tabBack
|
|
3488
|
+
? "; the panel tab reconnected — graph tools are ready."
|
|
3489
|
+
: "; ComfyUI is back but the panel tab has NOT reconnected yet (ready:false) — " +
|
|
3490
|
+
'wait a moment then retry, or rebind with panel_set_workflow_target({mode:"current"}) ' +
|
|
3491
|
+
"before issuing graph tools.") +
|
|
3256
3492
|
".",
|
|
3257
3493
|
});
|
|
3258
3494
|
}),
|