comfyui-mcp 0.48.19 → 0.48.21

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
@@ -219,6 +306,112 @@ function parseToolResultJson(res) {
219
306
  return null;
220
307
  }
221
308
  }
309
+ // ---- panel_run reply interpretation (#213/#331/#248/#194) -------------------
310
+ // `panel_run` forwards `graph_run` to the panel, which drives `app.queuePrompt`
311
+ // and forwards ComfyUI's /prompt outcome back. ComfyUI splits a rejection into
312
+ // TWO channels:
313
+ // • per-node problems -> `node_errors` (a map keyed by node id)
314
+ // • TOP-LEVEL problems -> `error` (e.g. prompt_outputs_failed_validation,
315
+ // missing_node_type) — leaves node_errors EMPTY
316
+ // #213: the panel's success guard looked ONLY at node_errors, so a top-level
317
+ // rejection (empty node_errors) slipped through as `queued:true` — a FALSE
318
+ // success the agent then waited a whole turn on. We therefore DERIVE the verdict
319
+ // from the authoritative fields (mirroring the #485 enqueue-validation parsing):
320
+ // a reply is a rejection when it carries a non-empty `error`, a non-empty
321
+ // `node_errors`, or an explicit `queued:false` — regardless of any `queued:true`
322
+ // flag that may accompany it. Only a reply with NONE of those is a real queue.
323
+ /** True when a graph_run reply's top-level `error` channel is populated (an
324
+ * object with any keys, or a non-blank string). Empty object / "" / absent = no. */
325
+ function hasTopLevelError(error) {
326
+ if (typeof error === "string")
327
+ return error.trim().length > 0;
328
+ if (error != null && typeof error === "object")
329
+ return Object.keys(error).length > 0;
330
+ return false;
331
+ }
332
+ /** True when a graph_run reply's `node_errors` channel names at least one node. */
333
+ function hasNodeErrors(nodeErrors) {
334
+ return (nodeErrors != null &&
335
+ typeof nodeErrors === "object" &&
336
+ Object.keys(nodeErrors).length > 0);
337
+ }
338
+ /**
339
+ * Format a ComfyUI /prompt rejection payload (the top-level `error` object plus
340
+ * per-node `node_errors`) into a human-readable failure — the same shape the
341
+ * #485 HTTP enqueue path surfaces, so panel_run and enqueue_workflow read alike.
342
+ */
343
+ function formatRunRejection(payload) {
344
+ let headline = "ComfyUI refused to queue the workflow";
345
+ const topError = payload.error;
346
+ const extraLines = [];
347
+ if (topError && typeof topError === "object") {
348
+ const te = topError;
349
+ const msg = typeof te.message === "string" ? te.message.trim() : "";
350
+ const type = typeof te.type === "string" ? te.type.trim() : "";
351
+ if (msg)
352
+ headline = `ComfyUI refused to queue the workflow: ${msg}${type ? ` (${type})` : ""}`;
353
+ else if (type)
354
+ headline = `ComfyUI refused to queue the workflow (${type})`;
355
+ const details = typeof te.details === "string" ? te.details.trim() : "";
356
+ if (details)
357
+ extraLines.push(details);
358
+ }
359
+ else if (typeof topError === "string" && topError.trim()) {
360
+ headline = `ComfyUI refused to queue the workflow: ${topError.trim()}`;
361
+ }
362
+ const lines = [...extraLines];
363
+ const ne = payload.node_errors;
364
+ if (ne && typeof ne === "object") {
365
+ for (const [nodeId, info] of Object.entries(ne)) {
366
+ const i = (info ?? {});
367
+ const cls = typeof i.class_type === "string" ? i.class_type : "node";
368
+ const errs = Array.isArray(i.errors) ? i.errors : [];
369
+ if (errs.length === 0) {
370
+ lines.push(`- ${cls} (node ${nodeId}): validation failed`);
371
+ continue;
372
+ }
373
+ for (const e of errs) {
374
+ const detail = typeof e?.details === "string" && e.details ? ` (${e.details})` : "";
375
+ const m = typeof e?.message === "string" ? e.message : "validation failed";
376
+ lines.push(`- ${cls} (node ${nodeId}): ${m}${detail}`);
377
+ }
378
+ }
379
+ }
380
+ return lines.length ? `${headline}\n${lines.join("\n")}` : headline;
381
+ }
382
+ /**
383
+ * Inspect a graph_run ToolResult and return a FAILURE ToolResult when the run
384
+ * did NOT genuinely enter ComfyUI's queue, or `null` when it is a real queue
385
+ * (so the caller may append the success/anti-poll guidance).
386
+ *
387
+ * - An isError reply (no connected tab #331, a thrown app.queuePrompt #248, a
388
+ * transport drop) is passed through VERBATIM — its full detail/browser stack
389
+ * is preserved and the success-only "you'll be notified" note is NOT added.
390
+ * - A NON-error reply is parsed: a top-level `error`, a non-empty `node_errors`,
391
+ * or an explicit `queued:false` is surfaced as a formatted failure (#213) —
392
+ * even when a stale `queued:true` accompanies it.
393
+ * - Anything else (a plain `queued:true`, or an unparseable reply we must not
394
+ * regress) returns null and is treated as a genuine queue.
395
+ */
396
+ function detectRunRejection(res) {
397
+ // Bridge/transport/executor error: never a queue. Preserve it verbatim (#248),
398
+ // no success note (#331). fail() already carries err.message (incl. any stack).
399
+ if (res?.isError)
400
+ return res;
401
+ const parsed = parseToolResultJson(res);
402
+ if (!parsed)
403
+ return null; // unparseable non-error reply — don't regress a success
404
+ const topError = parsed.error;
405
+ const nodeErrors = parsed.node_errors;
406
+ const rejected = hasTopLevelError(topError) || hasNodeErrors(nodeErrors) || parsed.queued === false;
407
+ if (!rejected)
408
+ return null; // genuine queue (queued:true / no rejection signal)
409
+ return fail(formatRunRejection({ error: topError, node_errors: nodeErrors }));
410
+ }
411
+ export const __panelRunTestHooks = {
412
+ detectRunRejection,
413
+ formatRunRejection,
414
+ };
222
415
  /** Drop a trailing .json (case-insensitive) so filename/path forms compare equal. */
223
416
  function stripJsonExt(s) {
224
417
  return typeof s === "string" ? s.replace(/\.json$/i, "") : null;
@@ -309,6 +502,44 @@ async function openWorkflowWithVerify(path, ctx) {
309
502
  // is a REAL failure. Return the original bridge timeout error unchanged.
310
503
  return res;
311
504
  }
505
+ /**
506
+ * Resolve a caller-supplied pin `path` (path / filename / key, any form) to the
507
+ * AUTHORITATIVE open-workflow record from a fresh `workflow_list` — the single
508
+ * source of truth for which tabs exist and their canonical `key` (#259). Returns:
509
+ * - the matched record when the workflow IS open (so the pin can be canonicalized
510
+ * to its stable key and bound to the exact frontend tab identity);
511
+ * - `null` when workflow_list is unreachable/empty or carries no `workflows`
512
+ * array (indeterminate — caller should fall back to the raw path, NOT fail);
513
+ * - the sentinel `NOT_OPEN` when the list IS known but the target is absent, so
514
+ * the caller can FAIL CLOSED instead of letting the panel silently route the
515
+ * pin to some other open tab.
516
+ */
517
+ const NOT_OPEN = Symbol("workflow-not-open");
518
+ async function resolveOpenWorkflow(ctx, path) {
519
+ let parsed = null;
520
+ try {
521
+ parsed = parseToolResultJson(await ctx.call({ cmd: "workflow_list" }, 6000));
522
+ }
523
+ catch {
524
+ return null; // transport error — indeterminate, don't fail the pin
525
+ }
526
+ if (!parsed)
527
+ return null;
528
+ const rawList = parsed.workflows;
529
+ if (!Array.isArray(rawList) || rawList.length === 0) {
530
+ // No enumerable tab list (older panel / stub) — can't verify, don't fail closed.
531
+ return null;
532
+ }
533
+ for (const wf of rawList) {
534
+ if (activeMatchesTarget(wf, path))
535
+ return wf;
536
+ }
537
+ // The active object is authoritative too, in case it isn't mirrored in the array.
538
+ if (activeMatchesTarget(parsed.active, path)) {
539
+ return parsed.active;
540
+ }
541
+ return NOT_OPEN;
542
+ }
312
543
  export const __openWorkflowTestHooks = {
313
544
  /** Inject fast open-verify timing so tests don't wait the real ~6s budget. */
314
545
  setOpenVerifyTiming(timing) {
@@ -316,6 +547,7 @@ export const __openWorkflowTestHooks = {
316
547
  },
317
548
  isAckTimeout,
318
549
  activeMatchesTarget,
550
+ resolveOpenWorkflow,
319
551
  };
320
552
  const slotRef = z.union([z.string(), z.number().int().min(0)]);
321
553
  // CivitAI browsing-level bitmask values: PG=1, PG-13=2, R=4, X=8, XXX=16.
@@ -517,11 +749,14 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets) {
517
749
  // CONSERVATIVE by construction (must not weaken multi-tab routing):
518
750
  // - fires ONLY when the current tab is genuinely unreachable (canReach false);
519
751
  // 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;
752
+ // - STRICT-SINGLE: only silently rebinds when there is EXACTLY ONE connected
753
+ // tab. With 2+ live tabs the bridge's no-tabId resolution would fall back to
754
+ // `lastActiveTabId` which can be an UNRELATED workflow (codex) so the
755
+ // silent path refuses to guess and instead lets the command surface the
756
+ // bridge's clear `no connected tab` error. The user then re-binds with the
757
+ // EXPLICIT panel_set_workflow_target({mode:"current"}) signal, which DOES
758
+ // accept the last-active tab because it is a deliberate "use what's live now"
759
+ // consent — silent auto-heal must be stricter than an explicit rebind;
525
760
  // - PINNED sessions are left strict: a session pinned to a specific workflow
526
761
  // keeps requiring the explicit rebind consent signal. Only "current"-mode
527
762
  // (follow-the-active-tab) sessions self-heal, which is faithful to what that
@@ -535,6 +770,14 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets) {
535
770
  return;
536
771
  if (workflowTargets?.get(ctx.tabId)?.mode === "pinned")
537
772
  return; // stay strict
773
+ // Strict-single: never silently pick among multiple live tabs (would risk the
774
+ // real bridge's last-active fallback routing to an unrelated workflow). When
775
+ // the bridge can enumerate its tabs and there is more than one, do NOT rebind.
776
+ if (typeof bridge.tabs === "function") {
777
+ const live = bridge.tabs();
778
+ if (Array.isArray(live) && live.length > 1)
779
+ return;
780
+ }
538
781
  try {
539
782
  rebindToActiveTab();
540
783
  }
@@ -543,14 +786,40 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets) {
543
786
  // command surface the bridge's own clear, tab-listing error.
544
787
  }
545
788
  };
789
+ const sendRouted = async (cmd, timeoutMs) => {
790
+ const target = workflowTargets?.get(ctx.tabId);
791
+ const routed = target ? withWorkflowTarget(cmd, target) : cmd;
792
+ return bridge.send(routed, { tabId: ctx.tabId, timeoutMs });
793
+ };
546
794
  const call = async (cmd, timeoutMs) => {
547
795
  try {
548
796
  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 }));
797
+ return ok(await sendRouted(cmd, timeoutMs));
552
798
  }
553
799
  catch (err) {
800
+ // Post-reconnect retry-once: a reboot/free_vram/reconnect can drop the tab's
801
+ // transport (or replace it under a new tab id) the instant after we dispatch.
802
+ // For idempotent commands, settle briefly, rebind onto the now-live tab, and
803
+ // retry ONE time before surfacing an error (#278/#310/#332/#481). Mutating
804
+ // edits are excluded from RETRY_SAFE_CMDS, so they never double-apply.
805
+ if (isRetrySafeCmd(cmd) && isTransientReconnectError(err)) {
806
+ try {
807
+ await sleep(retrySettleMs());
808
+ ensureReachable(); // rebinds a current-mode session onto the reconnected tab
809
+ return ok(await sendRouted(cmd, timeoutMs));
810
+ }
811
+ catch (err2) {
812
+ // The retry also failed — surface an actionable reconnecting status rather
813
+ // than a bare transport error (#332), while still failing honestly.
814
+ if (isTransientReconnectError(err2)) {
815
+ const name = typeof cmd.cmd === "string" ? cmd.cmd : "panel command";
816
+ return fail(`${name} could not reach the ComfyUI panel — it is still reconnecting after a ` +
817
+ `restart/reload. Wait a moment and retry; if it persists, rebind with ` +
818
+ `panel_set_workflow_target({mode:"current"}). (${err2 instanceof Error ? err2.message : String(err2)})`);
819
+ }
820
+ return fail(err2);
821
+ }
822
+ }
554
823
  return fail(err);
555
824
  }
556
825
  };
@@ -624,7 +893,15 @@ async function resolveWorkflowInput(args, ctx) {
624
893
  let reply;
625
894
  try {
626
895
  ctx.ensureReachable?.();
627
- reply = await ctx.bridge.send({ cmd: "graph_serialize" }, {
896
+ // Route to the SAME authoritative target as ctx.call: when the session is
897
+ // pinned, inject the pinned workflow_path so the live-canvas capture serializes
898
+ // the PINNED workflow, not whatever tab is visible (codex — this direct send
899
+ // otherwise bypasses withWorkflowTarget and reads the wrong graph).
900
+ const target = ctx.workflowTarget?.get(ctx.tabId);
901
+ const cmd = target
902
+ ? withWorkflowTarget({ cmd: "graph_serialize" }, target)
903
+ : { cmd: "graph_serialize" };
904
+ reply = await ctx.bridge.send(cmd, {
628
905
  tabId: ctx.tabId,
629
906
  timeoutMs: 30000,
630
907
  });
@@ -1023,7 +1300,7 @@ export function buildPanelToolDefs() {
1023
1300
  dy: args.dy,
1024
1301
  scale: args.scale,
1025
1302
  })),
1026
- 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). Returns queued:true, or queued:false with node_errors when frontend validation fails. 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.", {
1303
+ 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.", {
1027
1304
  batch_count: z
1028
1305
  .number()
1029
1306
  .int()
@@ -1042,6 +1319,16 @@ export function buildPanelToolDefs() {
1042
1319
  // job once let three more pile up). Snapshot the watchdog BEFORE we queue.
1043
1320
  const pre = QueueMonitor.snapshot();
1044
1321
  const res = await ctx.call({ cmd: "graph_run", batch_count: args.batch_count, to_node_id: args.to_node_id }, 20000);
1322
+ // Derive the verdict from the AUTHORITATIVE reply, not a bare `queued`
1323
+ // flag. A rejection — a no-connected-tab / thrown-queuePrompt error
1324
+ // (#331/#248), or a ComfyUI /prompt refusal on EITHER channel
1325
+ // (top-level `error` with empty node_errors #213, or per-node
1326
+ // node_errors) — is surfaced as a failure WITHOUT the success-only
1327
+ // "you'll be notified automatically" guidance. Only a genuine queue
1328
+ // gets the anti-poll note below.
1329
+ const rejection = detectRunRejection(res);
1330
+ if (rejection)
1331
+ return rejection;
1045
1332
  // Append anti-poll guidance: the agent should go idle after queuing so the
1046
1333
  // executed event auto-injects the output image, rather than busy-polling.
1047
1334
  const note = "\n\n[IMPORTANT] You will be notified automatically with the output image(s)/video when the render finishes — do NOT poll get_queue, get_history, or list_output_images. Just end your turn now and wait for the result to be delivered to you.";
@@ -1075,6 +1362,17 @@ export function buildPanelToolDefs() {
1075
1362
  // session is left untouched; an ambiguous multi-tab case surfaces a clear
1076
1363
  // error rather than guessing.
1077
1364
  if (ctx.rebindToActiveTab) {
1365
+ // Strict-single: if this session's tab is orphaned AND 2+ tabs are live,
1366
+ // do NOT guess (the bridge would fall back to last-active, possibly an
1367
+ // unrelated tab) — surface a clear error so the user picks, honoring the
1368
+ // documented "ambiguous multi-tab surfaces a clear error" promise (codex).
1369
+ const orphaned = typeof ctx.bridge.canReach === "function" && !ctx.bridge.canReach(ctx.tabId);
1370
+ const live = typeof ctx.bridge.tabs === "function" ? ctx.bridge.tabs() : undefined;
1371
+ if (orphaned && Array.isArray(live) && live.length > 1) {
1372
+ return fail("This session's ComfyUI tab was replaced and multiple tabs are now open — " +
1373
+ "can't safely pick one. Switch to the tab you want, then call " +
1374
+ 'panel_set_workflow_target({mode:"current"}) before panel_reload.');
1375
+ }
1078
1376
  try {
1079
1377
  ctx.rebindToActiveTab();
1080
1378
  }
@@ -1492,7 +1790,32 @@ export function buildPanelToolDefs() {
1492
1790
  return fail(err);
1493
1791
  }
1494
1792
  }
1495
- const target = ctx.workflowTarget.set(ctx.tabId, { mode, path, filename });
1793
+ // PIN: bind to the EXACT open-workflow identity from the authoritative
1794
+ // workflow_list, canonicalizing to its stable `key` and FAILING CLOSED when
1795
+ // the requested workflow isn't actually open — instead of letting the panel
1796
+ // silently route the pin to another tab (#259). Indeterminate lists (older
1797
+ // panel / no `workflows` array) fall back to the raw path (unchanged).
1798
+ let pinPath = path;
1799
+ let pinFilename = filename;
1800
+ if (mode === "pinned" && path) {
1801
+ const resolved = await resolveOpenWorkflow(ctx, path);
1802
+ if (resolved === NOT_OPEN) {
1803
+ return fail(`Cannot pin to "${path}" — it is not open in ComfyUI. Open it first ` +
1804
+ `(panel_open_workflow) or pick an open workflow from panel_list_workflows, ` +
1805
+ `then pin. (Refusing to pin to a workflow that isn't open so graph edits ` +
1806
+ `never land on the wrong tab.)`);
1807
+ }
1808
+ if (resolved) {
1809
+ // Canonicalize to the stable key so routing survives rename/reconnect.
1810
+ pinPath = resolved.key ?? resolved.path ?? path;
1811
+ pinFilename = filename ?? resolved.filename ?? resolved.path;
1812
+ }
1813
+ }
1814
+ const target = ctx.workflowTarget.set(ctx.tabId, {
1815
+ mode,
1816
+ path: pinPath,
1817
+ filename: pinFilename,
1818
+ });
1496
1819
  ctx.bridge.push({ type: "workflow_target", target }, ctx.tabId);
1497
1820
  const hint = target.mode === "pinned"
1498
1821
  ? `Pinned to "${target.filename ?? target.path}". Graph tools will target that workflow without switching the user's view.`
@@ -1619,7 +1942,15 @@ export function buildPanelToolDefs() {
1619
1942
  })),
1620
1943
  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
1944
  try {
1622
- const res = (await ctx.bridge.send({ cmd: "graph_screenshot", padding: args.padding }, { tabId: ctx.tabId }));
1945
+ ctx.ensureReachable?.();
1946
+ // Route to the same authoritative target as ctx.call: a pinned session
1947
+ // screenshots the PINNED workflow (via injected workflow_path), not just
1948
+ // whatever tab is visible (codex — graph_* must carry the pin).
1949
+ const target = ctx.workflowTarget?.get(ctx.tabId);
1950
+ const cmd = withWorkflowTarget({ cmd: "graph_screenshot", padding: args.padding }, target ?? { mode: "current" });
1951
+ const res = (await ctx.bridge.send(cmd, {
1952
+ tabId: ctx.tabId,
1953
+ }));
1623
1954
  if (!res?.image)
1624
1955
  return fail("screenshot returned no image");
1625
1956
  return { content: [{ type: "image", data: res.image, mimeType: res.mimeType ?? "image/png" }] };
@@ -1696,6 +2027,50 @@ export function buildPanelToolDefs() {
1696
2027
  const fired = rebootConfirmed(res);
1697
2028
  const dropped = !fired && rebootDropped(res);
1698
2029
  if (!fired && !dropped) {
2030
+ // The panel could not fire a Manager reboot. If the SOLE reason is that
2031
+ // NO Manager reboot endpoint answered (legacy Manager 3.x: v2 route 405s,
2032
+ // legacy route 404s — #425, panel #253/#266) AND the target is a LOCAL,
2033
+ // process-controllable ComfyUI, fall back to the headless managed restart
2034
+ // (kill + relaunch) — the same mechanism as the `restart_comfyui` tool.
2035
+ // A busy-guard or security refusal is deliberately NOT eligible
2036
+ // (rebootNoEndpoint excludes those), so this never aborts a running
2037
+ // render or bypasses Manager's security gate.
2038
+ if (!isRemoteMode() && rebootNoEndpoint(res)) {
2039
+ let restart;
2040
+ try {
2041
+ restart = await restartComfyUI();
2042
+ }
2043
+ catch (err) {
2044
+ return fail("The built-in Manager exposed no reboot endpoint (legacy Manager 3.x), " +
2045
+ "and the headless managed restart also failed: " +
2046
+ (err instanceof Error ? err.message : String(err)) +
2047
+ " — restart ComfyUI on the host, then reconnect.");
2048
+ }
2049
+ if (!restart.started) {
2050
+ return fail("The built-in Manager exposed no reboot endpoint (legacy Manager 3.x). " +
2051
+ "Tried the headless managed restart (kill + relaunch) as a fallback, but it " +
2052
+ `could not restart ComfyUI: ${restart.message} ` +
2053
+ "Restart ComfyUI on the host, then reconnect.");
2054
+ }
2055
+ // A managed kill+relaunch restarts ComfyUI out-of-band from the WS
2056
+ // client, so drop the memoized caches exactly as the Manager-reboot
2057
+ // path does before waiting for the panel to reconnect.
2058
+ resetClient();
2059
+ resetObjectInfoCache();
2060
+ const timing = getPanelRebootTiming();
2061
+ const recovery = await waitForPanelReady(ctx, timing);
2062
+ return ok({
2063
+ rebooting: true,
2064
+ ready: recovery.ready,
2065
+ recovered_ms: recovery.waited_ms,
2066
+ probes: recovery.attempts,
2067
+ via: "headless-managed-restart",
2068
+ note: "ComfyUI-Manager (legacy 3.x) had no reboot endpoint; restarted ComfyUI " +
2069
+ `via the headless managed restart (kill + relaunch)${recovery.ready
2070
+ ? ` and it came back ready in ${(recovery.waited_ms / 1000).toFixed(1)}s.`
2071
+ : " but the panel did not reconnect within the readiness budget — verify with panel_node_queue_status."}`,
2072
+ });
2073
+ }
1699
2074
  // Genuine refusal (or an unrelated error) — do NOT poll or reset caches.
1700
2075
  // Resetting on a refusal would close the shared client mid-generation
1701
2076
  // (codex WS-3 finding #2).