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.
@@ -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. Only a slow-ack TIMEOUT warrants verification.
859
- if (!isAckTimeout(res))
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), so the initial ack timed out. ` +
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 timed out AND the target never became active within the budget — this
874
- // is a REAL failure. Return the original bridge timeout error unchanged.
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). When
1339
- // the bridge can enumerate its tabs and there is more than one, do NOT rebind.
1340
- if (typeof bridge.tabs === "function") {
1341
- const live = bridge.tabs();
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 connected — leave tabId as-is and let the
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
- const current = bridge.resolveActiveTabId(); // throws if no single active tab
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
- if (orphaned && Array.isArray(live) && live.length > 1) {
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) => args.name
2708
- ? ctx.call({ cmd: "workflow_save_as", name: args.name }, 15000)
2709
- : ctx.call({ cmd: "workflow_save" }, 15000)),
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
- const { previous, current, rebound } = ctx.rebindToActiveTab();
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
- return fail(err);
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
- }, async (args, ctx) => ctx.call({ cmd: "graph_set_node_mode", node_id: args.node_id, mode: args.mode })),
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: true,
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
  }),