comfyui-mcp 0.52.2 → 0.52.3

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.
@@ -543,6 +543,11 @@ export const __panelToolsTestHooks = {
543
543
  setDeclineProbeTiming(timing) {
544
544
  declineProbeTimingOverride = timing;
545
545
  },
546
+ /** Inject a fake #1249 server-side /free so the frozen-tab settle can be
547
+ * driven without real HTTP. null restores the live freeVramDirect. */
548
+ setFreeVramDirect(fn) {
549
+ freeVramDirectOverride = fn;
550
+ },
546
551
  /** Direct access to the #742 decline recheck loop so its hard-deadline
547
552
  * guarantee (codex gate r2) can be unit-tested with a custom deadline. */
548
553
  probeDeclineRecovery,
@@ -1904,6 +1909,167 @@ async function settleExitSubgraphAfterAckTimeout(ctx, timedOut) {
1904
1909
  }
1905
1910
  return timedOut;
1906
1911
  }
1912
+ // ---- panel_free_vram: verifiable when the canvas tab is frozen (#1249) -----
1913
+ // `free_vram` is a purely SERVER-SIDE operation — the panel's handler is a plain
1914
+ // `POST /free` against the ComfyUI the tab fronts — yet the tool treated the
1915
+ // frozen tab's ACK as the only source of truth: a reply timeout left the caller
1916
+ // with "MUTATES … may have been applied" and no way to verify recovery. The
1917
+ // settle below takes the path the tab was only proxying: when the tab PROVABLY
1918
+ // fronts the orchestrator's local boot instance (the captureRebootHealthBase
1919
+ // gate — loopback, server-trusted, handshake-origin-matched), issue /free
1920
+ // DIRECTLY and read /system_stats around it.
1921
+ //
1922
+ // Why re-issuing is safe HERE and nowhere else on the timeout path: /free is
1923
+ // IDEMPOTENT. Unloading already-unloaded models and freeing an already-empty
1924
+ // cache is a no-op, so the copy the frozen tab may still execute when it wakes
1925
+ // cannot double-apply — the exact hazard the bridge's "do not blind-retry"
1926
+ // disclosure guards against for every other mutation does not exist for this
1927
+ // one command. This is a per-command exception, argued per command; it is NOT a
1928
+ // precedent for settling other mutations this way.
1929
+ /** Per-request bound for the direct /free + /system_stats round-trips, so a
1930
+ * wedged server degrades to the honest outcome-unknown instead of hanging the
1931
+ * tool call. ComfyUI applies /free synchronously before answering, so a large
1932
+ * unload can take seconds — 10s matches the bound probeComfyEndpoint callers
1933
+ * already pay on this same server. */
1934
+ const FREE_VRAM_DIRECT_TIMEOUT_MS = 10_000;
1935
+ /** GET `${base}/system_stats` and return its device VRAM counters, or null when
1936
+ * the read cannot answer (unreachable, non-2xx, non-ComfyUI body). Never
1937
+ * throws: an unreadable stat is "no numbers", never evidence in either
1938
+ * direction — the POST's own status is what certifies the free. */
1939
+ async function readVramDevices(base, timeoutMs) {
1940
+ const controller = new AbortController();
1941
+ const timer = setTimeout(() => controller.abort(), Math.max(1, timeoutMs));
1942
+ timer.unref?.();
1943
+ try {
1944
+ const res = await comfyuiFetch(`${base}/system_stats`, {
1945
+ signal: controller.signal,
1946
+ redirect: "manual",
1947
+ });
1948
+ if (res.status < 200 || res.status >= 300)
1949
+ return null;
1950
+ let body;
1951
+ try {
1952
+ body = await res.json();
1953
+ }
1954
+ catch {
1955
+ return null; // 2xx but not JSON — up, but not a /system_stats we trust
1956
+ }
1957
+ if (!looksLikeSystemStats(body))
1958
+ return null;
1959
+ const devices = body.devices;
1960
+ if (!Array.isArray(devices))
1961
+ return null;
1962
+ return devices.map((d) => {
1963
+ const dev = (d ?? {});
1964
+ const sample = {};
1965
+ if (typeof dev.name === "string")
1966
+ sample.name = dev.name;
1967
+ if (typeof dev.vram_total === "number")
1968
+ sample.vram_total = dev.vram_total;
1969
+ if (typeof dev.vram_free === "number")
1970
+ sample.vram_free = dev.vram_free;
1971
+ return sample;
1972
+ });
1973
+ }
1974
+ catch {
1975
+ return null; // unreachable/timed out — no numbers to report
1976
+ }
1977
+ finally {
1978
+ clearTimeout(timer);
1979
+ }
1980
+ }
1981
+ /** Issue ComfyUI's /free DIRECTLY against a proven-local base and read the
1982
+ * VRAM counters around it. Never throws — every failure is a value, so the
1983
+ * settle can degrade to the honest outcome-unknown instead of masking the
1984
+ * original timeout behind a new error. */
1985
+ async function freeVramDirect(base) {
1986
+ const before = await readVramDevices(base, FREE_VRAM_DIRECT_TIMEOUT_MS);
1987
+ const controller = new AbortController();
1988
+ const timer = setTimeout(() => controller.abort(), FREE_VRAM_DIRECT_TIMEOUT_MS);
1989
+ timer.unref?.();
1990
+ try {
1991
+ const res = await comfyuiFetch(`${base}/free`, {
1992
+ method: "POST",
1993
+ headers: { "Content-Type": "application/json" },
1994
+ body: JSON.stringify({ unload_models: true, free_memory: true }),
1995
+ signal: controller.signal,
1996
+ redirect: "manual",
1997
+ });
1998
+ if (res.status < 200 || res.status >= 300) {
1999
+ return { ok: false, reason: `POST ${base}/free answered HTTP ${res.status}` };
2000
+ }
2001
+ }
2002
+ catch (err) {
2003
+ const msg = controller.signal.aborted
2004
+ ? `POST ${base}/free did not answer within ${FREE_VRAM_DIRECT_TIMEOUT_MS} ms`
2005
+ : `POST ${base}/free failed: ${err instanceof Error ? err.message : String(err)}`;
2006
+ return { ok: false, reason: msg };
2007
+ }
2008
+ finally {
2009
+ clearTimeout(timer);
2010
+ }
2011
+ const after = await readVramDevices(base, FREE_VRAM_DIRECT_TIMEOUT_MS);
2012
+ return { ok: true, before, after };
2013
+ }
2014
+ /** Test injection for the direct server-side /free, so the settle can be
2015
+ * driven without real HTTP. null restores the live path. */
2016
+ let freeVramDirectOverride = null;
2017
+ /**
2018
+ * After an ack timeout on `free_vram`, settle the outcome against the one
2019
+ * channel a frozen tab cannot block: the ComfyUI server itself.
2020
+ *
2021
+ * Returns the timeout UNTOUCHED in every case where nothing was verified —
2022
+ * no provable local server (remote/cloud tab, ambiguous origin, untrusted
2023
+ * socket), or a direct /free that itself failed. #1473's rule: an unknown
2024
+ * answer claims nothing in either direction, and the bridge's original
2025
+ * outcome-unknown disclosure is already the honest verdict there.
2026
+ */
2027
+ async function settleFreeVramAfterAckTimeout(ctx, timedOut) {
2028
+ // The same gate the restart certification uses: null unless the tab PROVABLY
2029
+ // fronts THIS orchestrator's local boot instance (loopback + server-trusted +
2030
+ // handshake-origin match). Without that proof a direct /free could aim at a
2031
+ // DIFFERENT server than the one the tab was asked to free — reporting that as
2032
+ // this command's success would be the wrong-target success the gate exists to
2033
+ // prevent, which is worse than the honest unknown being fixed.
2034
+ const base = captureRebootHealthBase(ctx);
2035
+ if (!base)
2036
+ return timedOut;
2037
+ const direct = await (freeVramDirectOverride ?? freeVramDirect)(base);
2038
+ if (!direct.ok) {
2039
+ // The tab never answered AND the server-side path failed. The outcome stays
2040
+ // unknown — say so, and name what was tried, without claiming either way.
2041
+ const text = timedOut.content?.find((c) => c.type === "text")?.text ?? "";
2042
+ return {
2043
+ ...timedOut,
2044
+ content: [
2045
+ {
2046
+ type: "text",
2047
+ text: `${text}\n\nThe server-side fallback also could not reach ComfyUI's /free ` +
2048
+ `(${direct.reason ?? "no detail"}), so whether VRAM was freed remains UNVERIFIED — ` +
2049
+ `check with get_system_stats (action:"health") once the server answers.`,
2050
+ },
2051
+ ],
2052
+ };
2053
+ }
2054
+ const statsNote = direct.before != null && direct.after != null
2055
+ ? "vram_before/vram_after are the server's own /system_stats counters around the free."
2056
+ : "The /system_stats read around it did not answer, so no VRAM counters are reported — the 2xx from /free is the verification, not a measured delta.";
2057
+ return ok({
2058
+ freed: true,
2059
+ unload_models: true,
2060
+ free_memory: true,
2061
+ acknowledged: false,
2062
+ verified: "server-side",
2063
+ via: `POST ${base}/free`,
2064
+ ...(direct.before != null ? { vram_before: direct.before } : {}),
2065
+ ...(direct.after != null ? { vram_after: direct.after } : {}),
2066
+ note: `The panel tab never acknowledged (frozen or backgrounded), so the free was issued ` +
2067
+ `DIRECTLY to the ComfyUI server this tab provably fronts — the same /free endpoint the ` +
2068
+ `panel would have called — and the server confirmed it. /free is idempotent: if the tab's ` +
2069
+ `queued copy still executes when the tab wakes, it is a no-op, so nothing was applied ` +
2070
+ `twice. ${statsNote}`,
2071
+ });
2072
+ }
1907
2073
  // ---- panel_install_node: accepted-but-never-enqueued (#1129) ---------------
1908
2074
  // #1143 fixed the pre-queue REFUSAL (403/404 → direct clone). This is the other
1909
2075
  // half of the same family: legacy Manager 3.x answers the install POST with
@@ -3860,7 +4026,7 @@ NOTE: an API-format load CAN re-mint the canvas workflow instance. If your next
3860
4026
  `Clear it with panel_set_workflow_target({mode:"current"}), which re-derives the fence ` +
3861
4027
  `from the live canvas, then retry. If the next command is not refused, nothing needs doing.`);
3862
4028
  }
3863
- async function rebindWorkflowFence(ctx) {
4029
+ async function rebindWorkflowFence(ctx, opts) {
3864
4030
  const tabAtStart = ctx.tabId;
3865
4031
  let before = currentWorkflowFence(ctx);
3866
4032
  // `before` describes the tab we are ABOUT to compare against — but ctx.call can
@@ -3952,6 +4118,13 @@ async function rebindWorkflowFence(ctx) {
3952
4118
  // the stamp already matched without ever having read it.
3953
4119
  if (before.known && before.uuid === uuid)
3954
4120
  return { status: "already_current", uuid, before };
4121
+ // #1646 — a READ-ONLY probe never moves the fence: the live canvas naming a
4122
+ // DIFFERENT workflow is reported, not adopted. Only a deliberate rebind
4123
+ // (panel_set_workflow_target, open/new) may replace the fence — a mismatch
4124
+ // diagnosis that re-pointed the session on its own authority routed the
4125
+ // caller's NEXT edits onto the very canvas the refusal named as the wrong one.
4126
+ if (opts?.adopt === false)
4127
+ return { status: "diverged", uuid, before };
3955
4128
  // refreshWorkflowUuid routes through the orchestrator's validator, which
3956
4129
  // re-checks reachability and the uuid's shape/origin binding. A `false` here is
3957
4130
  // a REFUSAL, not a no-op, so it gets its own status rather than being reported
@@ -4095,6 +4268,21 @@ panelGapNote = "") {
4095
4268
  `(#803).`
4096
4269
  : "";
4097
4270
  switch (r.status) {
4271
+ case "diverged":
4272
+ // #1646 — produced ONLY by a read-only probe (`adopt:false`), which the
4273
+ // mismatch diagnosis uses; the deliberate rebinds this renderer serves
4274
+ // never pass it. Handled anyway, because an unhandled union member would
4275
+ // silently render as `undefined` — say exactly what happened if a future
4276
+ // caller ever routes one here.
4277
+ return {
4278
+ binding: "not_recovered",
4279
+ note: ` The live canvas is a DIFFERENT workflow instance (${r.uuid}) than this session's ` +
4280
+ `fence, and it was deliberately NOT adopted — a diagnosis must never re-point ` +
4281
+ `mutation routing on its own.` +
4282
+ (r.before.known && r.before.uuid ? ` The fence still names ${r.before.uuid}.` : "") +
4283
+ `\n\nWHAT TO DO: re-open the workflow you mean with panel_open_workflow, or re-target ` +
4284
+ `the live canvas deliberately by calling this tool with mode:"current".`,
4285
+ };
4098
4286
  case "refreshed":
4099
4287
  return {
4100
4288
  binding: okBinding,
@@ -6152,9 +6340,16 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets) {
6152
6340
  // because nothing in the refusal distinguishes "the canvas really is a different
6153
6341
  // workflow" from "the identity flipped for a moment while you were building".
6154
6342
  //
6155
- // The fence is NOT weakened and nothing is auto-applied. This performs the same
6156
- // read-only re-derivation the documented recovery performs, then says which of the
6157
- // two states was found. One informed retry replaces fourteen blind ones.
6343
+ // The fence is NOT weakened, nothing is auto-applied, and #1646 — the
6344
+ // probe is READ-ONLY. The first version of this check re-derived the fence
6345
+ // onto the live canvas when the two genuinely differed ("AUTO-REBIND"), so
6346
+ // every later mutation in the caller's sequence was silently re-pointed at
6347
+ // the very canvas the refusal had just named as the wrong one — the exact
6348
+ // corruption the fence exists to prevent, delivered as recovery. Now the
6349
+ // check says which of the two states it found and the fence moves ONLY on
6350
+ // an explicit rebind: panel_set_workflow_target({mode:"current"}) or a
6351
+ // successful open. Until then every write stays refused against the target
6352
+ // the caller actually named. One informed retry replaces fourteen blind ones.
6158
6353
  //
6159
6354
  // Safe to recommend a retry because a fence refusal is checked BEFORE the handler
6160
6355
  // runs — "Nothing was applied" is structural here, not an echoed claim.
@@ -6170,33 +6365,47 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets) {
6170
6365
  const stamped = /issued for workflow instance ([0-9a-f-]{36})/i.exec(err instanceof Error ? err.message : String(err))?.[1] ?? null;
6171
6366
  let verdict;
6172
6367
  try {
6173
- const rebind = await rebindWorkflowFence(ctx);
6368
+ const probe = await rebindWorkflowFence(ctx, { adopt: false });
6174
6369
  verdict =
6175
- rebind.status === "already_current" && stamped && rebind.uuid === stamped
6176
- ? `\n\nAUTO-REBIND: ATTEMPTED, and the live canvas now reports the SAME workflow ` +
6177
- `instance this command carried (${stamped}) nothing needed repairing. The ` +
6178
- `mismatch was TRANSIENT: the identity flipped and settled back, which happens ` +
6179
- `while a new unsaved workflow is still materialising. RETRY THIS EXACT CALL ONCE. ` +
6180
- `Nothing was applied, so a retry cannot double-apply, and re-issuing the whole ` +
6181
- `build would duplicate the work that already succeeded.`
6182
- : rebind.status === "already_current"
6183
- ? `\n\nAUTO-REBIND: ATTEMPTED; the fence already named the live canvas ` +
6184
- `(${rebind.uuid}), so it was not the stale side. Retry once — if it refuses ` +
6370
+ probe.status === "already_current" && stamped && probe.uuid === stamped
6371
+ ? `\n\nCHECKED: the live canvas now reports the SAME workflow instance this ` +
6372
+ `command carried (${stamped}), so the mismatch was TRANSIENT: the identity ` +
6373
+ `flipped and settled back, which happens while a new unsaved workflow is still ` +
6374
+ `materialising. RETRY THIS EXACT CALL ONCE. Nothing was applied, so a retry ` +
6375
+ `cannot double-apply, and re-issuing the whole build would duplicate the work ` +
6376
+ `that already succeeded.`
6377
+ : probe.status === "already_current"
6378
+ ? `\n\nCHECKED: the session's fence already names the live canvas ` +
6379
+ `(${probe.uuid}), so it was not the stale side. Retry once — if it refuses ` +
6185
6380
  `again with the same pair, the two identities are genuinely disagreeing and ` +
6186
6381
  `panel_open_workflow is the way to settle which one you mean.`
6187
- : rebind.status === "refreshed"
6188
- ? `\n\nAUTO-REBIND: ATTEMPTED and the fence was RE-DERIVED onto the live canvas ` +
6189
- `(now ${rebind.uuid}). Retry once. If you meant the EARLIER workflow, re-select ` +
6190
- `it with panel_open_workflow first this session now points at the live one.`
6191
- : `\n\nAUTO-REBIND: ATTEMPTED and did NOT succeed (${rebind.status}), so the fence ` +
6192
- `is unchanged and a bare retry will fail the same way. Re-select the workflow ` +
6193
- `you mean with panel_open_workflow, then retry.`;
6382
+ : probe.status === "diverged"
6383
+ ? `\n\nCHECKED, and this session was NOT re-pointed: the live canvas is a ` +
6384
+ `DIFFERENT workflow (${probe.uuid}) than the one this command was issued ` +
6385
+ `for${stamped ? ` (${stamped})` : ""}. The fence is unchanged, so later ` +
6386
+ `edits in this sequence keep being refused rather than landing on the ` +
6387
+ `wrong canvas. WHAT TO DO: to edit the workflow you issued for, bring it ` +
6388
+ `back with panel_open_workflow; to follow the live canvas instead, ` +
6389
+ `re-target deliberately with panel_set_workflow_target({mode:"current"}). ` +
6390
+ `Either way the move is explicit — it is never made for you off a refused ` +
6391
+ `mutation.`
6392
+ : probe.status === "healed_by_panel"
6393
+ ? `\n\nCHECKED, and the answer CHANGED while it was being read: the panel ` +
6394
+ `re-advertised its identity and this session's fence moved to the live ` +
6395
+ `canvas (${probe.uuid}) — through the panel's own repair, not this ` +
6396
+ `check. If you meant the EARLIER workflow, re-select it with ` +
6397
+ `panel_open_workflow before any further edits; they now target the live one.`
6398
+ : `\n\nCHECKED, but the live canvas could not be established ` +
6399
+ `(${probe.status}), so the fence is unchanged and a bare retry will fail ` +
6400
+ `the same way. Re-select the workflow you mean with panel_open_workflow, ` +
6401
+ `then retry.`;
6194
6402
  }
6195
- catch (rebindErr) {
6403
+ catch (probeErr) {
6196
6404
  // Never let the diagnosis fail the call differently than it already failed.
6197
6405
  verdict =
6198
- `\n\nAUTO-REBIND: ATTEMPTED and threw, so the fence state is UNKNOWN — this refusal ` +
6199
- `stands on its own terms. (${rebindErr instanceof Error ? rebindErr.message : String(rebindErr)})`;
6406
+ `\n\nCHECKED, and the check itself threw, so the live canvas is UNKNOWN — this ` +
6407
+ `refusal stands on its own terms and the fence is unchanged. ` +
6408
+ `(${probeErr instanceof Error ? probeErr.message : String(probeErr)})`;
6200
6409
  }
6201
6410
  return fail(`${name} was NOT applied — nothing changed. ${raw}${verdict}`);
6202
6411
  }
@@ -8596,7 +8805,7 @@ export function buildPanelToolDefs() {
8596
8805
  : "") +
8597
8806
  `Any values in this result are the canvas's actual state.`);
8598
8807
  }),
8599
- 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. DUPLICATE FENCE (#862): if a render this session cannot account for is already in flight (after a reconnect this is usually YOUR earlier render still running — the queue record does not survive a restart), the run is REFUSED before anything is queued and the in-flight prompt is named; inspect queue (action:'list') first, or pass allow_duplicate:true only to deliberately stack behind it. Use this so the render runs on THEIR canvas and they see the result.", {
8808
+ 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. DUPLICATE FENCE (#862): if a render this session cannot account for is already in flight (after a reconnect this is usually YOUR earlier render still running — the queue record does not survive a restart), the run is REFUSED before anything is queued and the in-flight prompt is named; inspect queue (action:'list') first, then pass allow_duplicate:true once you have decided it is fine to run behind what is there — a scoped to_node_id preview after a reconnect is the ordinary case for it, a deliberate sweep/batch the other. Use this so the render runs on THEIR canvas and they see the result.", {
8600
8809
  batch_count: z
8601
8810
  .number()
8602
8811
  .int()
@@ -8612,7 +8821,7 @@ export function buildPanelToolDefs() {
8612
8821
  allow_duplicate: z
8613
8822
  .boolean()
8614
8823
  .optional()
8615
- .describe("Queue even when a render this session cannot account for is already in flight (default false). When work is in flight that this session has no record of queueing — e.g. YOUR OWN earlier render still running after a reconnect, whose record does not survive the restart — panel_run REFUSES to stack a duplicate and names the in-flight prompt instead. Pass true only to deliberately queue behind it (a sweep/batch)."),
8824
+ .describe("Queue even when a render this session cannot account for is already in flight (default false). When work is in flight that this session has no record of queueing — e.g. YOUR OWN earlier render still running after a reconnect, whose record does not survive the restart — panel_run REFUSES to stack a duplicate and names the in-flight prompt instead. Pass true once you have LOOKED at what is in flight (queue action:'list') and decided it is fine to run behind it. After a reconnect that is the ordinary case, not an exotic one: you confirmed the in-flight job is your own earlier render or the user's, and you still want the next run — a scoped to_node_id preview, the next step of the task. Deliberately stacking a sweep/batch uses the same override."),
8616
8825
  }, async (args, ctx) => {
8617
8826
  // BACKPRESSURE: the agent can't see ComfyUI's queue, so re-queuing while a
8618
8827
  // render is already running silently stacks behind it (this is how a stuck
@@ -8678,9 +8887,12 @@ export function buildPanelToolDefs() {
8678
8887
  `no prompt id) even YOUR OWN earlier render reads as unconfirmable, and queueing now ` +
8679
8888
  `would stack a DUPLICATE behind it (#862). Nothing was queued. Inspect with queue ` +
8680
8889
  `(action:"list"): if the in-flight job is the render you already started, wait for it ` +
8681
- `and confirm the outcome with get_history instead of re-running it. If you genuinely ` +
8682
- `intend to stack another render behind it (a deliberate sweep/batch), re-call panel_run ` +
8683
- `with allow_duplicate:true. If the in-flight job is actually wedged, queue ` +
8890
+ `and confirm the outcome with get_history instead of re-running it. Once you HAVE ` +
8891
+ `looked and decided it is fine to run behind what is there, re-call panel_run with ` +
8892
+ `allow_duplicate:true after a reconnect that is the ORDINARY case, not an exotic ` +
8893
+ `one: the in-flight job is your own earlier render or the user's, and you still want ` +
8894
+ `the next run (a scoped to_node_id preview, the next step of the task). Deliberately ` +
8895
+ `stacking a sweep/batch uses the same override. If the in-flight job is actually wedged, queue ` +
8684
8896
  `(action:"cancel") with clear_pending:true interrupts it AND drops everything pending.`);
8685
8897
  }
8686
8898
  const runCmd = { cmd: "graph_run", batch_count: args.batch_count, to_node_id: args.to_node_id };
@@ -11582,7 +11794,17 @@ CHECKED FOR YOU: the graph read this message prescribes was just run, and it ` +
11582
11794
  ".") + argvNote + (preflightNote ? ` ${preflightNote}` : ""),
11583
11795
  });
11584
11796
  }),
11585
- def("panel_free_vram", "Unload all loaded models and free VRAM (ComfyUI /free). Use to unwedge a stuck/OOM ComfyUI when a cancel didn't free memory — before retrying or, last resort, restarting (panel_restart_comfyui). Does NOT restart ComfyUI; it just drops resident models and frees cached memory.", {}, async (_args, ctx) => ctx.call({ cmd: "free_vram" }, 15000)),
11797
+ def("panel_free_vram", "Unload all loaded models and free VRAM (ComfyUI /free). Use to unwedge a stuck/OOM ComfyUI when a cancel didn't free memory — before retrying or, last resort, restarting (panel_restart_comfyui). Does NOT restart ComfyUI; it just drops resident models and frees cached memory. If the panel tab is frozen and cannot acknowledge, the free is instead issued DIRECTLY to the ComfyUI server and verified there (same /free, idempotent) whenever the tab provably fronts the local server — otherwise the outcome is reported unknown rather than claimed.", {}, async (_args, ctx) => {
11798
+ const res = await ctx.call({ cmd: "free_vram" }, 15000);
11799
+ // #1249 — ONLY a no-reply is settled server-side. An acked executor error
11800
+ // (the panel's own "Failed to free VRAM: …") is a reply the bridge
11801
+ // received and relayed; it already says what failed, and re-issuing from
11802
+ // out here would fire a second mutation behind a verdict the caller was
11803
+ // given. A tagged reply-timeout is the one case where nothing answered.
11804
+ if (!isReplyTimeoutResult(res))
11805
+ return res;
11806
+ return settleFreeVramAfterAckTimeout(ctx, res);
11807
+ }),
11586
11808
  def("panel_show_media", "Display one or more images or videos directly in the panel chat. Use this whenever the user asks to SEE or SHOW a file — a disk path you composited/downloaded/generated (absolute path on the orchestrator host) OR a ComfyUI output ref ({ filename, subfolder?, type? }). Items are rendered as media cards in the agent chat area; supply optional captions. Max 8 items per call. NEVER describe an image with emoji or text placeholders — call this tool instead.", {
11587
11809
  items: z
11588
11810
  .array(z.object({