comfyui-mcp 0.48.18 → 0.48.20

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.
@@ -37,6 +37,8 @@ import { getNsfwConsent, setNsfwConsent } from "../services/panel-settings.js";
37
37
  import { QueueMonitor } from "../services/queue-monitor.js";
38
38
  import { getObjectInfo, backfillObjectInfo, resetClient, resetObjectInfoCache, } from "../comfyui/client.js";
39
39
  import { convertUiToApi, collectNodeTypes } from "../services/workflow-converter.js";
40
+ import { restartComfyUI } from "../services/process-control.js";
41
+ import { isRemoteMode } from "../config.js";
40
42
  import { sliceWorkflow } from "../services/workflow-slicer.js";
41
43
  import { validateA2UISpecServer } from "../services/a2ui-spec.js";
42
44
  /** Treat these as an affirmative answer to the adult-content consent card. */
@@ -103,6 +105,30 @@ export function rebootDropped(res) {
103
105
  // unrelated tab reconnection makes readiness pass. Those return verbatim.
104
106
  return /disconnected mid-command|OUTCOME UNKNOWN|ECONNRESET|socket hang up|premature close|other side closed|ECONNABORTED|EPIPE/i.test(text);
105
107
  }
108
+ /**
109
+ * True when a comfy_reboot ToolResult is a NON-error, NON-fired refusal whose
110
+ * cause is that the panel could reach NO ComfyUI-Manager reboot endpoint — i.e.
111
+ * every Manager reboot route answered 404/405 (the classic legacy Manager 3.x
112
+ * symptom: `POST /v2/manager/reboot → 405; GET /manager/reboot → 404`,
113
+ * panel #253/#266 and this repo #425). This is distinct from:
114
+ * - a busy-guard refusal (a generation is running) — its text speaks to the
115
+ * queue/generation, never to a "reboot endpoint", so it does NOT match; and
116
+ * - a Manager-security 403 refusal — that speaks to "security"/"forbidden".
117
+ * Only a no-endpoint refusal is safe to retry through the headless managed
118
+ * restart (kill + relaunch), and only for a LOCAL, process-controllable target.
119
+ */
120
+ export function rebootNoEndpoint(res) {
121
+ if (res?.isError)
122
+ return false;
123
+ const text = res?.content?.find((c) => c.type === "text")?.text ?? "";
124
+ // A busy-guard / security refusal must never be treated as "no endpoint" — a
125
+ // kill+relaunch fallback would abort a running render or defeat the security
126
+ // gate. Require the reboot-endpoint signature AND the absence of those.
127
+ if (/busy|in progress|generation|queue is|running|security|forbidden|403/i.test(text)) {
128
+ return false;
129
+ }
130
+ return /reboot endpoint|reboot route|was NOT restarted|no reachable .*reboot/i.test(text);
131
+ }
106
132
  let panelRebootTimingOverride = null;
107
133
  function parsePositiveNumberEnv(name, fallback) {
108
134
  const raw = process.env[name];
@@ -130,10 +156,71 @@ export const __panelToolsTestHooks = {
130
156
  setPanelRebootTiming(timing) {
131
157
  panelRebootTimingOverride = timing;
132
158
  },
159
+ /** Zero out the post-drop retry settle so retry-once tests don't sleep. */
160
+ setRetrySettleMs(ms) {
161
+ retrySettleMsOverride = ms;
162
+ },
163
+ isRetrySafeCmd,
164
+ isTransientReconnectError,
133
165
  };
134
166
  function sleep(ms) {
135
167
  return new Promise((r) => setTimeout(r, ms));
136
168
  }
169
+ // ── Post-reconnect retry-once for idempotent panel commands ──────────────────
170
+ // A tool-triggered ComfyUI reboot (#278/#481), a panel_free_vram (#310), or a
171
+ // Manager-backed call racing a post-restart reconnect (#332) can drop the panel
172
+ // tab's transport the instant AFTER a command was dispatched — or replace the
173
+ // tab under a BRAND-NEW socket/tab id with no migration alias, orphaning this
174
+ // session. The bridge's own mid-command resume only helps when the SAME tab id
175
+ // re-hellos; when the id changed, the in-flight command surfaces a bare
176
+ // "no connected tab" / "disconnected mid-command … genuinely gone" / "Failed to
177
+ // fetch" and the agent is told to hand-call panel_set_workflow_target(current).
178
+ //
179
+ // For commands that are SAFE to re-issue (idempotent reads, plus idempotent
180
+ // UI-state writes like set_todo that fully REPLACE state), we transparently
181
+ // rebind onto the now-live tab (ensureReachable) and retry ONCE after a short
182
+ // settle. Mutating graph edits (add_node/connect/set_widget/…) are deliberately
183
+ // EXCLUDED — re-issuing them could double-apply — so they keep surfacing the
184
+ // bridge's honest OUTCOME-UNKNOWN error.
185
+ const RETRY_SAFE_CMDS = new Set([
186
+ // Idempotent reads (mirror UiBridge.READONLY_CMDS + list/status probes).
187
+ "graph_serialize",
188
+ "graph_outline",
189
+ "graph_get_errors",
190
+ "graph_get_subgraph",
191
+ "graph_prompt_director_audit",
192
+ "graph_query",
193
+ "get_todo",
194
+ "workflow_list",
195
+ "nodes_list",
196
+ "nodes_queue_status",
197
+ "node_queue_status",
198
+ // Idempotent full-replace UI state — re-sending the same list is a no-op (#481).
199
+ "set_todo",
200
+ ]);
201
+ /** A command whose result is unchanged by being re-issued after a reconnect —
202
+ * so it is safe to transparently retry once when the transport dropped. */
203
+ function isRetrySafeCmd(cmd) {
204
+ const name = typeof cmd.cmd === "string" ? cmd.cmd : "";
205
+ return RETRY_SAFE_CMDS.has(name);
206
+ }
207
+ /** True when an error is a TRANSIENT transport/reconnect drop (the tab went away
208
+ * or was replaced), NOT a genuine command error or a live-but-frozen reply
209
+ * timeout. Deliberately EXCLUDES "did not reply within N ms" (a backgrounded/
210
+ * frozen tab — retrying just double-waits, #334) and "OUTCOME UNKNOWN" (a
211
+ * mutating command that may already have applied). */
212
+ function isTransientReconnectError(err) {
213
+ const msg = err instanceof Error ? err.message : String(err ?? "");
214
+ return /no connected tab|genuinely gone|is not open|Failed to fetch|Panel not reachable|ECONNRESET|socket hang up|premature close|other side closed|ECONNABORTED|EPIPE/i.test(msg);
215
+ }
216
+ let retrySettleMsOverride = null;
217
+ /** Short pause before the single post-drop retry, letting the replacement tab
218
+ * finish its reconnect hello so ensureReachable can resolve it. Test-overridable. */
219
+ function retrySettleMs() {
220
+ if (retrySettleMsOverride != null)
221
+ return retrySettleMsOverride;
222
+ return Math.round(parsePositiveNumberEnv("COMFYUI_PANEL_RETRY_SETTLE_S", 0.4) * 1000);
223
+ }
137
224
  /**
138
225
  * Poll the panel bridge until ComfyUI is reachable again after a reboot. Each probe
139
226
  * is a lightweight `nodes_queue_status` round-trip (via ctx.call, which never
@@ -309,6 +396,44 @@ async function openWorkflowWithVerify(path, ctx) {
309
396
  // is a REAL failure. Return the original bridge timeout error unchanged.
310
397
  return res;
311
398
  }
399
+ /**
400
+ * Resolve a caller-supplied pin `path` (path / filename / key, any form) to the
401
+ * AUTHORITATIVE open-workflow record from a fresh `workflow_list` — the single
402
+ * source of truth for which tabs exist and their canonical `key` (#259). Returns:
403
+ * - the matched record when the workflow IS open (so the pin can be canonicalized
404
+ * to its stable key and bound to the exact frontend tab identity);
405
+ * - `null` when workflow_list is unreachable/empty or carries no `workflows`
406
+ * array (indeterminate — caller should fall back to the raw path, NOT fail);
407
+ * - the sentinel `NOT_OPEN` when the list IS known but the target is absent, so
408
+ * the caller can FAIL CLOSED instead of letting the panel silently route the
409
+ * pin to some other open tab.
410
+ */
411
+ const NOT_OPEN = Symbol("workflow-not-open");
412
+ async function resolveOpenWorkflow(ctx, path) {
413
+ let parsed = null;
414
+ try {
415
+ parsed = parseToolResultJson(await ctx.call({ cmd: "workflow_list" }, 6000));
416
+ }
417
+ catch {
418
+ return null; // transport error — indeterminate, don't fail the pin
419
+ }
420
+ if (!parsed)
421
+ return null;
422
+ const rawList = parsed.workflows;
423
+ if (!Array.isArray(rawList) || rawList.length === 0) {
424
+ // No enumerable tab list (older panel / stub) — can't verify, don't fail closed.
425
+ return null;
426
+ }
427
+ for (const wf of rawList) {
428
+ if (activeMatchesTarget(wf, path))
429
+ return wf;
430
+ }
431
+ // The active object is authoritative too, in case it isn't mirrored in the array.
432
+ if (activeMatchesTarget(parsed.active, path)) {
433
+ return parsed.active;
434
+ }
435
+ return NOT_OPEN;
436
+ }
312
437
  export const __openWorkflowTestHooks = {
313
438
  /** Inject fast open-verify timing so tests don't wait the real ~6s budget. */
314
439
  setOpenVerifyTiming(timing) {
@@ -316,6 +441,7 @@ export const __openWorkflowTestHooks = {
316
441
  },
317
442
  isAckTimeout,
318
443
  activeMatchesTarget,
444
+ resolveOpenWorkflow,
319
445
  };
320
446
  const slotRef = z.union([z.string(), z.number().int().min(0)]);
321
447
  // CivitAI browsing-level bitmask values: PG=1, PG-13=2, R=4, X=8, XXX=16.
@@ -517,11 +643,14 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets) {
517
643
  // CONSERVATIVE by construction (must not weaken multi-tab routing):
518
644
  // - fires ONLY when the current tab is genuinely unreachable (canReach false);
519
645
  // a healthy session — including a healthy MULTI-tab one — is never touched;
520
- // - uses the SAME resolveActiveTabId the explicit rebind uses, which THROWS on
521
- // ambiguity (2+ live tabs, no last-active) and when nothing is connected, so
522
- // an orphaned session is never silently hijacked onto an arbitrary tab
523
- // the throw is swallowed and the command falls through to the bridge's clear
524
- // `no connected tab` / `Multiple panel tabs` error;
646
+ // - STRICT-SINGLE: only silently rebinds when there is EXACTLY ONE connected
647
+ // tab. With 2+ live tabs the bridge's no-tabId resolution would fall back to
648
+ // `lastActiveTabId` which can be an UNRELATED workflow (codex) so the
649
+ // silent path refuses to guess and instead lets the command surface the
650
+ // bridge's clear `no connected tab` error. The user then re-binds with the
651
+ // EXPLICIT panel_set_workflow_target({mode:"current"}) signal, which DOES
652
+ // accept the last-active tab because it is a deliberate "use what's live now"
653
+ // consent — silent auto-heal must be stricter than an explicit rebind;
525
654
  // - PINNED sessions are left strict: a session pinned to a specific workflow
526
655
  // keeps requiring the explicit rebind consent signal. Only "current"-mode
527
656
  // (follow-the-active-tab) sessions self-heal, which is faithful to what that
@@ -535,6 +664,14 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets) {
535
664
  return;
536
665
  if (workflowTargets?.get(ctx.tabId)?.mode === "pinned")
537
666
  return; // stay strict
667
+ // Strict-single: never silently pick among multiple live tabs (would risk the
668
+ // real bridge's last-active fallback routing to an unrelated workflow). When
669
+ // the bridge can enumerate its tabs and there is more than one, do NOT rebind.
670
+ if (typeof bridge.tabs === "function") {
671
+ const live = bridge.tabs();
672
+ if (Array.isArray(live) && live.length > 1)
673
+ return;
674
+ }
538
675
  try {
539
676
  rebindToActiveTab();
540
677
  }
@@ -543,14 +680,40 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets) {
543
680
  // command surface the bridge's own clear, tab-listing error.
544
681
  }
545
682
  };
683
+ const sendRouted = async (cmd, timeoutMs) => {
684
+ const target = workflowTargets?.get(ctx.tabId);
685
+ const routed = target ? withWorkflowTarget(cmd, target) : cmd;
686
+ return bridge.send(routed, { tabId: ctx.tabId, timeoutMs });
687
+ };
546
688
  const call = async (cmd, timeoutMs) => {
547
689
  try {
548
690
  ensureReachable();
549
- const target = workflowTargets?.get(ctx.tabId);
550
- const routed = target ? withWorkflowTarget(cmd, target) : cmd;
551
- return ok(await bridge.send(routed, { tabId: ctx.tabId, timeoutMs }));
691
+ return ok(await sendRouted(cmd, timeoutMs));
552
692
  }
553
693
  catch (err) {
694
+ // Post-reconnect retry-once: a reboot/free_vram/reconnect can drop the tab's
695
+ // transport (or replace it under a new tab id) the instant after we dispatch.
696
+ // For idempotent commands, settle briefly, rebind onto the now-live tab, and
697
+ // retry ONE time before surfacing an error (#278/#310/#332/#481). Mutating
698
+ // edits are excluded from RETRY_SAFE_CMDS, so they never double-apply.
699
+ if (isRetrySafeCmd(cmd) && isTransientReconnectError(err)) {
700
+ try {
701
+ await sleep(retrySettleMs());
702
+ ensureReachable(); // rebinds a current-mode session onto the reconnected tab
703
+ return ok(await sendRouted(cmd, timeoutMs));
704
+ }
705
+ catch (err2) {
706
+ // The retry also failed — surface an actionable reconnecting status rather
707
+ // than a bare transport error (#332), while still failing honestly.
708
+ if (isTransientReconnectError(err2)) {
709
+ const name = typeof cmd.cmd === "string" ? cmd.cmd : "panel command";
710
+ return fail(`${name} could not reach the ComfyUI panel — it is still reconnecting after a ` +
711
+ `restart/reload. Wait a moment and retry; if it persists, rebind with ` +
712
+ `panel_set_workflow_target({mode:"current"}). (${err2 instanceof Error ? err2.message : String(err2)})`);
713
+ }
714
+ return fail(err2);
715
+ }
716
+ }
554
717
  return fail(err);
555
718
  }
556
719
  };
@@ -624,7 +787,15 @@ async function resolveWorkflowInput(args, ctx) {
624
787
  let reply;
625
788
  try {
626
789
  ctx.ensureReachable?.();
627
- reply = await ctx.bridge.send({ cmd: "graph_serialize" }, {
790
+ // Route to the SAME authoritative target as ctx.call: when the session is
791
+ // pinned, inject the pinned workflow_path so the live-canvas capture serializes
792
+ // the PINNED workflow, not whatever tab is visible (codex — this direct send
793
+ // otherwise bypasses withWorkflowTarget and reads the wrong graph).
794
+ const target = ctx.workflowTarget?.get(ctx.tabId);
795
+ const cmd = target
796
+ ? withWorkflowTarget({ cmd: "graph_serialize" }, target)
797
+ : { cmd: "graph_serialize" };
798
+ reply = await ctx.bridge.send(cmd, {
628
799
  tabId: ctx.tabId,
629
800
  timeoutMs: 30000,
630
801
  });
@@ -1075,6 +1246,17 @@ export function buildPanelToolDefs() {
1075
1246
  // session is left untouched; an ambiguous multi-tab case surfaces a clear
1076
1247
  // error rather than guessing.
1077
1248
  if (ctx.rebindToActiveTab) {
1249
+ // Strict-single: if this session's tab is orphaned AND 2+ tabs are live,
1250
+ // do NOT guess (the bridge would fall back to last-active, possibly an
1251
+ // unrelated tab) — surface a clear error so the user picks, honoring the
1252
+ // documented "ambiguous multi-tab surfaces a clear error" promise (codex).
1253
+ const orphaned = typeof ctx.bridge.canReach === "function" && !ctx.bridge.canReach(ctx.tabId);
1254
+ const live = typeof ctx.bridge.tabs === "function" ? ctx.bridge.tabs() : undefined;
1255
+ if (orphaned && Array.isArray(live) && live.length > 1) {
1256
+ return fail("This session's ComfyUI tab was replaced and multiple tabs are now open — " +
1257
+ "can't safely pick one. Switch to the tab you want, then call " +
1258
+ 'panel_set_workflow_target({mode:"current"}) before panel_reload.');
1259
+ }
1078
1260
  try {
1079
1261
  ctx.rebindToActiveTab();
1080
1262
  }
@@ -1492,7 +1674,32 @@ export function buildPanelToolDefs() {
1492
1674
  return fail(err);
1493
1675
  }
1494
1676
  }
1495
- const target = ctx.workflowTarget.set(ctx.tabId, { mode, path, filename });
1677
+ // PIN: bind to the EXACT open-workflow identity from the authoritative
1678
+ // workflow_list, canonicalizing to its stable `key` and FAILING CLOSED when
1679
+ // the requested workflow isn't actually open — instead of letting the panel
1680
+ // silently route the pin to another tab (#259). Indeterminate lists (older
1681
+ // panel / no `workflows` array) fall back to the raw path (unchanged).
1682
+ let pinPath = path;
1683
+ let pinFilename = filename;
1684
+ if (mode === "pinned" && path) {
1685
+ const resolved = await resolveOpenWorkflow(ctx, path);
1686
+ if (resolved === NOT_OPEN) {
1687
+ return fail(`Cannot pin to "${path}" — it is not open in ComfyUI. Open it first ` +
1688
+ `(panel_open_workflow) or pick an open workflow from panel_list_workflows, ` +
1689
+ `then pin. (Refusing to pin to a workflow that isn't open so graph edits ` +
1690
+ `never land on the wrong tab.)`);
1691
+ }
1692
+ if (resolved) {
1693
+ // Canonicalize to the stable key so routing survives rename/reconnect.
1694
+ pinPath = resolved.key ?? resolved.path ?? path;
1695
+ pinFilename = filename ?? resolved.filename ?? resolved.path;
1696
+ }
1697
+ }
1698
+ const target = ctx.workflowTarget.set(ctx.tabId, {
1699
+ mode,
1700
+ path: pinPath,
1701
+ filename: pinFilename,
1702
+ });
1496
1703
  ctx.bridge.push({ type: "workflow_target", target }, ctx.tabId);
1497
1704
  const hint = target.mode === "pinned"
1498
1705
  ? `Pinned to "${target.filename ?? target.path}". Graph tools will target that workflow without switching the user's view.`
@@ -1619,7 +1826,15 @@ export function buildPanelToolDefs() {
1619
1826
  })),
1620
1827
  def("panel_screenshot", "Render the workflow the user is currently viewing (root graph, or the open subgraph) to a PNG and return it as an IMAGE so you can SEE the layout. It frames the whole graph (nodes + groups), captures, then restores the user's view. Use this to visually verify a layout you just built — overlaps, alignment, rails, colors, group bands — instead of reasoning from coordinates alone.", { padding: z.number().optional().describe("Margin around the graph in px (default 60).") }, async (args, ctx) => {
1621
1828
  try {
1622
- const res = (await ctx.bridge.send({ cmd: "graph_screenshot", padding: args.padding }, { tabId: ctx.tabId }));
1829
+ ctx.ensureReachable?.();
1830
+ // Route to the same authoritative target as ctx.call: a pinned session
1831
+ // screenshots the PINNED workflow (via injected workflow_path), not just
1832
+ // whatever tab is visible (codex — graph_* must carry the pin).
1833
+ const target = ctx.workflowTarget?.get(ctx.tabId);
1834
+ const cmd = withWorkflowTarget({ cmd: "graph_screenshot", padding: args.padding }, target ?? { mode: "current" });
1835
+ const res = (await ctx.bridge.send(cmd, {
1836
+ tabId: ctx.tabId,
1837
+ }));
1623
1838
  if (!res?.image)
1624
1839
  return fail("screenshot returned no image");
1625
1840
  return { content: [{ type: "image", data: res.image, mimeType: res.mimeType ?? "image/png" }] };
@@ -1696,6 +1911,50 @@ export function buildPanelToolDefs() {
1696
1911
  const fired = rebootConfirmed(res);
1697
1912
  const dropped = !fired && rebootDropped(res);
1698
1913
  if (!fired && !dropped) {
1914
+ // The panel could not fire a Manager reboot. If the SOLE reason is that
1915
+ // NO Manager reboot endpoint answered (legacy Manager 3.x: v2 route 405s,
1916
+ // legacy route 404s — #425, panel #253/#266) AND the target is a LOCAL,
1917
+ // process-controllable ComfyUI, fall back to the headless managed restart
1918
+ // (kill + relaunch) — the same mechanism as the `restart_comfyui` tool.
1919
+ // A busy-guard or security refusal is deliberately NOT eligible
1920
+ // (rebootNoEndpoint excludes those), so this never aborts a running
1921
+ // render or bypasses Manager's security gate.
1922
+ if (!isRemoteMode() && rebootNoEndpoint(res)) {
1923
+ let restart;
1924
+ try {
1925
+ restart = await restartComfyUI();
1926
+ }
1927
+ catch (err) {
1928
+ return fail("The built-in Manager exposed no reboot endpoint (legacy Manager 3.x), " +
1929
+ "and the headless managed restart also failed: " +
1930
+ (err instanceof Error ? err.message : String(err)) +
1931
+ " — restart ComfyUI on the host, then reconnect.");
1932
+ }
1933
+ if (!restart.started) {
1934
+ return fail("The built-in Manager exposed no reboot endpoint (legacy Manager 3.x). " +
1935
+ "Tried the headless managed restart (kill + relaunch) as a fallback, but it " +
1936
+ `could not restart ComfyUI: ${restart.message} ` +
1937
+ "Restart ComfyUI on the host, then reconnect.");
1938
+ }
1939
+ // A managed kill+relaunch restarts ComfyUI out-of-band from the WS
1940
+ // client, so drop the memoized caches exactly as the Manager-reboot
1941
+ // path does before waiting for the panel to reconnect.
1942
+ resetClient();
1943
+ resetObjectInfoCache();
1944
+ const timing = getPanelRebootTiming();
1945
+ const recovery = await waitForPanelReady(ctx, timing);
1946
+ return ok({
1947
+ rebooting: true,
1948
+ ready: recovery.ready,
1949
+ recovered_ms: recovery.waited_ms,
1950
+ probes: recovery.attempts,
1951
+ via: "headless-managed-restart",
1952
+ note: "ComfyUI-Manager (legacy 3.x) had no reboot endpoint; restarted ComfyUI " +
1953
+ `via the headless managed restart (kill + relaunch)${recovery.ready
1954
+ ? ` and it came back ready in ${(recovery.waited_ms / 1000).toFixed(1)}s.`
1955
+ : " but the panel did not reconnect within the readiness budget — verify with panel_node_queue_status."}`,
1956
+ });
1957
+ }
1699
1958
  // Genuine refusal (or an unrelated error) — do NOT poll or reset caches.
1700
1959
  // Resetting on a refusal would close the shared client mid-generation
1701
1960
  // (codex WS-3 finding #2).