comfyui-mcp 0.49.0 → 0.49.2

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.
Files changed (120) hide show
  1. package/README.md +5 -10
  2. package/dist/comfyui/cloud-client.js +2 -2
  3. package/dist/comfyui/cloud-client.js.map +1 -1
  4. package/dist/config.js +22 -0
  5. package/dist/config.js.map +1 -1
  6. package/dist/orchestrator/agent-backend.js +9 -0
  7. package/dist/orchestrator/agent-backend.js.map +1 -1
  8. package/dist/orchestrator/antigravity-backend.js +3 -2
  9. package/dist/orchestrator/antigravity-backend.js.map +1 -1
  10. package/dist/orchestrator/call-tool-admission.js +160 -0
  11. package/dist/orchestrator/call-tool-admission.js.map +1 -0
  12. package/dist/orchestrator/chatgpt-oauth-backend.js +3 -2
  13. package/dist/orchestrator/chatgpt-oauth-backend.js.map +1 -1
  14. package/dist/orchestrator/claude-backend.js +300 -3
  15. package/dist/orchestrator/claude-backend.js.map +1 -1
  16. package/dist/orchestrator/codex-backend.js +3 -2
  17. package/dist/orchestrator/codex-backend.js.map +1 -1
  18. package/dist/orchestrator/gemini-backend.js +3 -2
  19. package/dist/orchestrator/gemini-backend.js.map +1 -1
  20. package/dist/orchestrator/grok-backend.js +5 -3
  21. package/dist/orchestrator/grok-backend.js.map +1 -1
  22. package/dist/orchestrator/index.js +169 -137
  23. package/dist/orchestrator/index.js.map +1 -1
  24. package/dist/orchestrator/ollama-backend.js +3 -2
  25. package/dist/orchestrator/ollama-backend.js.map +1 -1
  26. package/dist/orchestrator/panel-agent.js +89 -19
  27. package/dist/orchestrator/panel-agent.js.map +1 -1
  28. package/dist/orchestrator/panel-tools.js +955 -81
  29. package/dist/orchestrator/panel-tools.js.map +1 -1
  30. package/dist/orchestrator/pi-backend.js +3 -2
  31. package/dist/orchestrator/pi-backend.js.map +1 -1
  32. package/dist/orchestrator/session-store.js +22 -0
  33. package/dist/orchestrator/session-store.js.map +1 -1
  34. package/dist/services/civitai-resolver.js +22 -7
  35. package/dist/services/civitai-resolver.js.map +1 -1
  36. package/dist/services/comfy-cli.js +1 -1
  37. package/dist/services/download-jobs.js +1 -1
  38. package/dist/services/extra-paths.js +7 -7
  39. package/dist/services/extra-paths.js.map +1 -1
  40. package/dist/services/graph-query.js +10 -4
  41. package/dist/services/graph-query.js.map +1 -1
  42. package/dist/services/manager-config.js +11 -2
  43. package/dist/services/manager-config.js.map +1 -1
  44. package/dist/services/model-resolver.js +4 -4
  45. package/dist/services/model-resolver.js.map +1 -1
  46. package/dist/services/node-authoring.js +3 -3
  47. package/dist/services/node-authoring.js.map +1 -1
  48. package/dist/services/node-dev.js +3 -3
  49. package/dist/services/node-dev.js.map +1 -1
  50. package/dist/services/node-management.js +185 -9
  51. package/dist/services/node-management.js.map +1 -1
  52. package/dist/services/node-snapshots.js +117 -12
  53. package/dist/services/node-snapshots.js.map +1 -1
  54. package/dist/services/node-verify.js +1 -1
  55. package/dist/services/node-verify.js.map +1 -1
  56. package/dist/services/output-dir.js +1 -1
  57. package/dist/services/output-dir.js.map +1 -1
  58. package/dist/services/panel-installer.js +466 -6
  59. package/dist/services/panel-installer.js.map +1 -1
  60. package/dist/services/panel-pending-cancel.js +446 -0
  61. package/dist/services/panel-pending-cancel.js.map +1 -0
  62. package/dist/services/panel-pin-guard.js +50 -3
  63. package/dist/services/panel-pin-guard.js.map +1 -1
  64. package/dist/services/panel-sync.js +3 -20
  65. package/dist/services/panel-sync.js.map +1 -1
  66. package/dist/services/process-control.js +132 -8
  67. package/dist/services/process-control.js.map +1 -1
  68. package/dist/services/queue-manager.js +1 -1
  69. package/dist/services/queue-manager.js.map +1 -1
  70. package/dist/services/queue-monitor.js +1 -1
  71. package/dist/services/queue-monitor.js.map +1 -1
  72. package/dist/services/queue-monitor.self-attribution.test.js +1 -1
  73. package/dist/services/queue-monitor.self-attribution.test.js.map +1 -1
  74. package/dist/services/ui-bridge.js +180 -17
  75. package/dist/services/ui-bridge.js.map +1 -1
  76. package/dist/services/update-comfyui.js +29 -2
  77. package/dist/services/update-comfyui.js.map +1 -1
  78. package/dist/services/workspace-env.js +3 -3
  79. package/dist/services/workspace-env.js.map +1 -1
  80. package/dist/tools/api-nodes.js +1 -1
  81. package/dist/tools/api-nodes.js.map +1 -1
  82. package/dist/tools/batches.js +1 -1
  83. package/dist/tools/batches.js.map +1 -1
  84. package/dist/tools/compact.js +32 -6
  85. package/dist/tools/compact.js.map +1 -1
  86. package/dist/tools/extra-paths.js +1 -1
  87. package/dist/tools/extra-paths.js.map +1 -1
  88. package/dist/tools/generate-3d.js +2 -2
  89. package/dist/tools/generate-3d.js.map +1 -1
  90. package/dist/tools/install-panel.js +62 -22
  91. package/dist/tools/install-panel.js.map +1 -1
  92. package/dist/tools/model-explorer.js +175 -117
  93. package/dist/tools/model-explorer.js.map +1 -1
  94. package/dist/tools/queue-management.js +117 -135
  95. package/dist/tools/queue-management.js.map +1 -1
  96. package/dist/tools/run-template.js +4 -4
  97. package/dist/tools/run-template.js.map +1 -1
  98. package/dist/tools/vocabulary.js +123 -19
  99. package/dist/tools/vocabulary.js.map +1 -1
  100. package/dist/tools/workflow-autoload.js +5 -5
  101. package/dist/tools/workflow-autoload.js.map +1 -1
  102. package/dist/tools/workflow-execute.js +1 -1
  103. package/dist/tools/workflow-execute.js.map +1 -1
  104. package/dist/tools/workspace-env.js +53 -28
  105. package/dist/tools/workspace-env.js.map +1 -1
  106. package/docs/design/panel-surface.txt +1 -0
  107. package/docs/design/tool-surface.txt +3 -0
  108. package/package.json +2 -2
  109. package/plugin/commands/batch.md +1 -1
  110. package/plugin/commands/gen.md +2 -2
  111. package/plugin/scripts/monitor-progress.mjs +1 -1
  112. package/plugin/skills/comfyui-core/SKILL.md +15 -13
  113. package/plugin/skills/director/SKILL.md +1 -1
  114. package/plugin/skills/ltxv2-video/SKILL.md +1 -1
  115. package/plugin/skills/troubleshooting/SKILL.md +1 -1
  116. package/plugin/skills/video-extend/SKILL.md +1 -1
  117. package/plugin/skills/workflow-layout/SKILL.md +4 -5
  118. package/scripts/arena-scenarios.mjs +5 -5
  119. package/scripts/check-tool-vocabulary.mts +9 -0
  120. package/scripts/gen-tool-docs.ts +31 -10
@@ -31,7 +31,7 @@ import { comfyuiFetch } from "../comfyui/fetch.js";
31
31
  import { assertPanelNotTargetedUnverifiable } from "../services/panel-pin-guard.js";
32
32
  import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
33
33
  import { parse as parseYaml } from "yaml";
34
- import { dispatchOutcomeOf, isPanelCmdUnsupportedError, isReplyTimeoutTagged, } from "../services/ui-bridge.js";
34
+ import { dispatchOutcomeOf, isPanelCmdUnsupportedError, isReplyTimeoutTagged, requiresWorkflowStampEnforcement, } from "../services/ui-bridge.js";
35
35
  import { withWorkflowTarget, } from "../services/workflow-target-store.js";
36
36
  import { addUserMcpServer, readUserMcpServers, removeUserMcpServer, setUserMcpServerSecret, } from "../services/user-mcp-config.js";
37
37
  import { setComfyuiSecret, setAgentSecret, isAllowedAgentSecretKey } from "../services/panel-secrets.js";
@@ -40,9 +40,9 @@ import { getNsfwConsent, setNsfwConsent } from "../services/panel-settings.js";
40
40
  import { QueueMonitor } from "../services/queue-monitor.js";
41
41
  import { getClient, getObjectInfo, backfillObjectInfo, resetClient, resetObjectInfoCache, } from "../comfyui/client.js";
42
42
  import { convertUiToApi, collectNodeTypes } from "../services/workflow-converter.js";
43
- import { restartComfyUI } from "../services/process-control.js";
43
+ import { restartComfyUI, preflightLocalRestart, recordRestartDispatch, clearRestartDispatch, getRestartDispatchRecord, RESTART_DISPATCH_CAUSATION_WINDOW_MS, PROCESS_WIDE_RESTART_DISPATCH_TOKEN, __processControlTestHooks, } from "../services/process-control.js";
44
44
  import { resetManagerApiCache } from "../services/manager-api-cache.js";
45
- import { isRemoteMode, isCloudMode, getBootLocalComfyUIBaseUrl, getComfyUIBaseUrl, } from "../config.js";
45
+ import { isRemoteMode, isCloudMode, getBootLocalComfyUIBaseUrl, getComfyUIBaseUrl, getComfyuiTargetGeneration, } from "../config.js";
46
46
  import { sliceWorkflow } from "../services/workflow-slicer.js";
47
47
  import { validateA2UISpecServer } from "../services/a2ui-spec.js";
48
48
  /** Treat these as an affirmative answer to a yes/no confirm card (destructive-op
@@ -206,6 +206,19 @@ const MAX_REBOOT_BUDGET_MS = 240_000; // 240s → settle+budget ≤ 250s < 300s
206
206
  // fast with an actionable retry / restart_comfyui hint. It NEVER shortcuts the
207
207
  // confirmation itself (no auto-confirm): it bounds only how long we wait for the answer.
208
208
  const RESTART_CONFIRM_TIMEOUT_MS = 90_000; // 90s
209
+ // #742 decline-probe recheck window: a single ECONNREFUSED is NOT proof of a
210
+ // lost server — a genuinely restarting instance is refused during its normal
211
+ // down window (codex gate). When the decline path probes before reporting, it
212
+ // rechecks over this SHORT, bounded window and only declares DOWN when the
213
+ // endpoint is STILL refused at the end of it; a recovery inside the window is
214
+ // reported as such. Deliberately small: the turn already ended on the user's
215
+ // decline, so this is a report-time check and must not stall.
216
+ const DECLINE_PROBE_WINDOW_MS = 6_000; // 6s total
217
+ const DECLINE_PROBE_INTERVAL_MS = 2_000; // 2s between samples
218
+ const DECLINE_PROBE_TIMEOUT_MS = 2_000; // per-sample probe ceiling
219
+ // #742 r4/r5: the decline path may name restart causation only against a
220
+ // dispatch RECORDED within RESTART_DISPATCH_CAUSATION_WINDOW_MS (imported from
221
+ // process-control) whose token THIS session holds — see sessionRestartDispatch.
209
222
  function parsePositiveNumberEnv(name, fallback) {
210
223
  const raw = process.env[name];
211
224
  if (raw == null || raw === "")
@@ -245,6 +258,30 @@ export const __panelToolsTestHooks = {
245
258
  setHealthProbe(fn) {
246
259
  healthProbeOverride = fn;
247
260
  },
261
+ /** Inject a fake #742 restart preflight so reboot tests don't probe real
262
+ * processes/ports. null restores the live preflightLocalRestart. */
263
+ setLocalRestartPreflight(fn) {
264
+ localRestartPreflightOverride = fn;
265
+ },
266
+ /** Inject a fast #742 decline-probe recheck window so decline-path tests
267
+ * don't wait the real ~6s. null restores the DECLINE_PROBE_* constants. */
268
+ setDeclineProbeTiming(timing) {
269
+ declineProbeTimingOverride = timing;
270
+ },
271
+ /** Direct access to the #742 decline recheck loop so its hard-deadline
272
+ * guarantee (codex gate r2) can be unit-tested with a custom deadline. */
273
+ probeDeclineRecovery,
274
+ /** r5: the restart-dispatch record THIS session (ctx) holds, or null. */
275
+ getSessionRestartDispatch(ctx) {
276
+ return sessionRestartDispatch(ctx);
277
+ },
278
+ /** r5: seed a restart-dispatch record HELD BY this session (ctx) — with an
279
+ * explicit `at` so fresh/stale shapes don't need a real restart. */
280
+ seedSessionRestartDispatch(ctx, record) {
281
+ const token = randomUUID();
282
+ __processControlTestHooks.setRestartDispatchRecord(token, record);
283
+ sessionRestartDispatchTokens.set(ctx, token);
284
+ },
248
285
  looksLikeSystemStats,
249
286
  probeComfyHealth,
250
287
  probeComfyEndpoint,
@@ -357,12 +394,15 @@ const MUTATING_GRAPH_EDIT_CMDS = new Set([
357
394
  "graph_disconnect",
358
395
  "graph_set_widget",
359
396
  "graph_set_node_property",
397
+ // Legacy bridge commands remain behind compatibility tool names so panels that
398
+ // predate graph_edit_node continue to receive commands they actually implement.
360
399
  "graph_move_node",
361
400
  "graph_resize_node",
362
401
  "graph_set_title",
363
- "graph_set_node_mode",
364
- "graph_set_node_color",
365
402
  "graph_set_node_collapsed",
403
+ "graph_set_node_color",
404
+ "graph_edit_node",
405
+ "graph_set_node_mode",
366
406
  "graph_update_node",
367
407
  "graph_create_group",
368
408
  "graph_edit_group",
@@ -645,6 +685,66 @@ function normalizeProbe(v) {
645
685
  return "down";
646
686
  return v;
647
687
  }
688
+ /**
689
+ * The #742 decline-path recheck: poll `base` over a short, bounded window
690
+ * (clamped to `deadline`) so a server that is merely mid-restart is not
691
+ * falsely declared lost on a single ECONNREFUSED. Samples immediately, then
692
+ * sleeps intervalMs between samples — "still refused at the END of the
693
+ * window" is the only down verdict, and ONLY when the full window ran: a
694
+ * deadline that truncates the window downgrades the verdict to "ambiguous"
695
+ * regardless of the samples taken (r3). The deadline is HARD (codex gate
696
+ * r2): no awaited probe may START once the remaining budget is exhausted —
697
+ * the loop stops and the verdict falls to the last known state (or
698
+ * "ambiguous" when nothing was sampled). Never throws.
699
+ */
700
+ async function probeDeclineRecovery(base, windowMs, intervalMs, probeTimeoutMs, deadline) {
701
+ const start = Date.now();
702
+ const windowEnd = start + Math.max(1, windowMs);
703
+ const end = Math.min(windowEnd, deadline);
704
+ // r3: a window the deadline cuts short can never prove permanence — DOWN
705
+ // requires the FULL recheck window to have run its course.
706
+ const truncated = end < windowEnd;
707
+ const probe = healthProbeOverride ?? probeComfyEndpoint;
708
+ let attempts = 0;
709
+ let sawDown = false;
710
+ let last = "unknown";
711
+ for (;;) {
712
+ // HARD deadline: NEVER begin an awaited probe with an exhausted remainder
713
+ // (clamping an expired remainder to a 1ms timeout would still start — and
714
+ // await — a probe PAST the deadline, defeating the guarantee).
715
+ const remaining = end - Date.now();
716
+ if (remaining <= 0)
717
+ break;
718
+ attempts++;
719
+ const t = Math.min(probeTimeoutMs, remaining);
720
+ let status = "unknown";
721
+ try {
722
+ status = normalizeProbe(await probe(base, t));
723
+ }
724
+ catch {
725
+ status = "unknown";
726
+ }
727
+ if (status === "healthy") {
728
+ return {
729
+ status: sawDown ? "recovered" : "healthy",
730
+ attempts,
731
+ waited_ms: Date.now() - start,
732
+ };
733
+ }
734
+ if (status === "down")
735
+ sawDown = true;
736
+ last = status;
737
+ const left = end - Date.now();
738
+ if (left <= 0)
739
+ break;
740
+ await new Promise((r) => setTimeout(r, Math.max(1, Math.min(intervalMs, left))));
741
+ }
742
+ return {
743
+ status: last === "down" && !truncated ? "down" : "ambiguous",
744
+ attempts,
745
+ waited_ms: Date.now() - start,
746
+ };
747
+ }
648
748
  /**
649
749
  * The FIXED ComfyUI base URL to health-probe during a reboot readiness wait, or
650
750
  * null when we must fall back to the panel round-trip (as before #509). Captured by
@@ -708,6 +808,49 @@ function captureRebootHealthBase(ctx) {
708
808
  return loopbackProbeUrl(base);
709
809
  }
710
810
  let healthProbeOverride = null;
811
+ /** Test injection for the #742 refuse-safe restart preflight (the real one is
812
+ * preflightLocalRestart in process-control). null → the live preflight. */
813
+ let localRestartPreflightOverride = null;
814
+ /** Test injection for the #742 decline-probe recheck window, so tests don't
815
+ * wait the real ~6s. null → the DECLINE_PROBE_* constants. */
816
+ let declineProbeTimingOverride = null;
817
+ // #742 r5: restart-dispatch tokens held PER SESSION. Each MCP session gets its
818
+ // own PanelToolCtx (one per connection — see the session factory in
819
+ // panel-mcp-http), so keying by the ctx object scopes a dispatch record to the
820
+ // session that dispatched it: session A's failed restart can never ground
821
+ // causation for session B's decline, and A's recovery clears only A's record.
822
+ // WeakMap → entries die with their session's ctx.
823
+ const sessionRestartDispatchTokens = new WeakMap();
824
+ /** Stamp a restart dispatch and hold its token on THIS session (replacing any
825
+ * record the session previously held — a session has at most one live one).
826
+ * Returns the held token so clears can be CLEAR-IF-SAME (r15). */
827
+ function stampSessionRestartDispatch(ctx, base) {
828
+ const prev = sessionRestartDispatchTokens.get(ctx);
829
+ if (prev)
830
+ clearRestartDispatch(prev);
831
+ const token = recordRestartDispatch(base);
832
+ sessionRestartDispatchTokens.set(ctx, token);
833
+ return token;
834
+ }
835
+ /** The dispatch token THIS session currently holds, or undefined. */
836
+ function sessionRestartDispatchToken(ctx) {
837
+ return sessionRestartDispatchTokens.get(ctx);
838
+ }
839
+ /** The restart-dispatch record THIS session holds, or null. */
840
+ function sessionRestartDispatch(ctx) {
841
+ const held = sessionRestartDispatchToken(ctx);
842
+ return held ? getRestartDispatchRecord(held) : null;
843
+ }
844
+ /** Clear ONLY when the session STILL holds `token` (r15): a newer dispatch
845
+ * that landed since `token` was validated keeps its record — a
846
+ * read-current-then-clear would evict the newer dispatch's record and make a
847
+ * later persistent DOWN falsely report "no restart was dispatched". */
848
+ function clearSessionRestartDispatchIfSame(ctx, token) {
849
+ if (sessionRestartDispatchTokens.get(ctx) !== token)
850
+ return;
851
+ sessionRestartDispatchTokens.delete(ctx);
852
+ clearRestartDispatch(token);
853
+ }
711
854
  /**
712
855
  * Observe the boot endpoint's recovery AFTER a reboot was dispatched, and certify ONLY on
713
856
  * an OBSERVED DOWN→UP cycle. Acceptance (dispatch confirmed/dropped) is the guard against
@@ -1116,6 +1259,30 @@ function activeMatchesTarget(active, path) {
1116
1259
  return false;
1117
1260
  return stripJsonExt(a.filename) === want || stripJsonExt(a.path) === want;
1118
1261
  }
1262
+ /**
1263
+ * Exact saved-workflow identity for command-fence refreshes. Unlike
1264
+ * activeMatchesTarget(), this deliberately does NOT accept a filename/basename:
1265
+ * a successful open of `workflows/a/foo.json` cannot safely adopt the UUID of
1266
+ * an active `workflows/b/foo.json`. The panel's saved workflow routing key is
1267
+ * `wf:<canonical path>`, which is the only pathless fallback that is still an
1268
+ * exact identity.
1269
+ */
1270
+ function activeMatchesOpenRefreshTarget(active, path) {
1271
+ const targetIdentity = canonicalRequestedSavedIdentity(path);
1272
+ return !!targetIdentity && canonicalSavedRecordIdentity(active) === targetIdentity;
1273
+ }
1274
+ /** Normalizes only syntax the panel's saved-path/routing identity normalizes. */
1275
+ function canonicalSavedWorkflowPath(value) {
1276
+ if (typeof value !== "string" || !value)
1277
+ return null;
1278
+ const normalized = value.replace(/\\/g, "/").replace(/^\.\/+/, "").replace(/\/+/g, "/");
1279
+ // `tmp:<uuid>` is a per-tab ephemeral routing handle, never a saved workflow
1280
+ // path. `wf:<path>` is likewise a routing token, not a path; accepting either
1281
+ // here could manufacture `wf:tmp:…` and refresh a durable command fence.
1282
+ if (!normalized || /^(?:tmp:|wf:)/i.test(normalized))
1283
+ return null;
1284
+ return normalized;
1285
+ }
1119
1286
  let openVerifyTimingOverride = null;
1120
1287
  function getOpenVerifyTiming() {
1121
1288
  if (openVerifyTimingOverride)
@@ -1126,6 +1293,62 @@ function getOpenVerifyTiming() {
1126
1293
  probeTimeoutMs: Math.round(parsePositiveNumberEnv("COMFYUI_PANEL_OPEN_VERIFY_PROBE_S", 4) * 1000),
1127
1294
  };
1128
1295
  }
1296
+ // Strict canonical RFC UUID for a command-fence refresh: lowercase only, an
1297
+ // assigned RFC version, and the RFC variant. Do not normalize this transport
1298
+ // value — an uppercase or malformed producer value must leave the prior fence.
1299
+ // Keep this check at the command-response boundary: a missing, malformed, or
1300
+ // old-panel response must leave the existing command fence intact, never turn
1301
+ // a graph mutation into an unstamped send.
1302
+ const WORKFLOW_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
1303
+ function responseWorkflowUuid(value) {
1304
+ if (!value || typeof value !== "object")
1305
+ return undefined;
1306
+ const raw = value.workflow_uuid;
1307
+ if (typeof raw !== "string")
1308
+ return undefined;
1309
+ return WORKFLOW_UUID_RE.test(raw) ? raw : undefined;
1310
+ }
1311
+ /** Refresh only the bridge-owned command stamp, never caller data. */
1312
+ function refreshWorkflowUuid(ctx, value) {
1313
+ const uuid = responseWorkflowUuid(value);
1314
+ const refresh = ctx.bridge.refreshWorkflowUuid;
1315
+ return uuid && typeof refresh === "function" ? refresh.call(ctx.bridge, ctx.tabId, uuid) : false;
1316
+ }
1317
+ /**
1318
+ * A successful open reply belongs to that exact bridge request, but another
1319
+ * navigation may have completed before the caller receives it. Re-read the
1320
+ * active object and accept the returned UUID only while it still names this
1321
+ * open's canonical target; otherwise leave the old stamp to fail closed.
1322
+ */
1323
+ async function refreshOpenWorkflowUuid(ctx, requestedPath, openResult) {
1324
+ const parsedOpen = parseToolResultJson(openResult);
1325
+ const opened = parsedOpen?.opened;
1326
+ const openedPath = opened && typeof opened === "object" && typeof opened.path === "string"
1327
+ ? opened.path
1328
+ : undefined;
1329
+ // The caller's original token is the fence target. A panel can resolve an
1330
+ // alias/basename to a path, but that reply must never retroactively turn the
1331
+ // alias into a UUID-refresh authorization. Require the reply to corroborate
1332
+ // the original exact saved identity before consulting the live active record.
1333
+ const requestedIdentity = canonicalRequestedSavedIdentity(requestedPath);
1334
+ const openedIdentity = openedPath
1335
+ ? canonicalSavedRecordIdentity({ path: openedPath, routing_key: parsedOpen?.routing_key })
1336
+ : null;
1337
+ if (!requestedIdentity || requestedIdentity !== openedIdentity)
1338
+ return;
1339
+ try {
1340
+ const list = parseToolResultJson(await ctx.call({ cmd: "workflow_list" }, 6000));
1341
+ if (!list || !activeMatchesOpenRefreshTarget(list.active, requestedPath))
1342
+ return;
1343
+ // Prefer the just-read active UUID; use the command's correlated response as
1344
+ // a compatibility fallback only after that same active-target confirmation.
1345
+ refreshWorkflowUuid(ctx, list.active) || refreshWorkflowUuid(ctx, parseToolResultJson(openResult));
1346
+ }
1347
+ catch {
1348
+ // A failed read must not convert a confirmed open into a failure, nor should
1349
+ // it clear the old stamp. The next mutation remains safely fenced.
1350
+ }
1351
+ }
1129
1352
  /** Exact resolved-path check for an open receipt. A filename/basename is not a
1130
1353
  * workflow identity: `other/foo.json` must never confirm `wanted/foo.json`. */
1131
1354
  function resolvedOpenPathMatches(receipt, path) {
@@ -1172,7 +1395,26 @@ async function waitForOpenReceipt(ctx, path, rid, timing) {
1172
1395
  return { receipt: "unknown", waited_ms: Date.now() - start, attempts };
1173
1396
  }
1174
1397
  if (receipt.applied === true) {
1175
- return { receipt: "applied", waited_ms: Date.now() - start, attempts };
1398
+ // A receipt proves this open applied, but a later user switch could
1399
+ // have made another canvas active before this probe. Refresh only
1400
+ // when the current active object still names this exact target.
1401
+ const active = parsed.active;
1402
+ const resolved = receipt.resolved;
1403
+ const resolvedPath = resolved.path;
1404
+ // The receipt has already proved the command's exact resolved path.
1405
+ // Still require its routing claim and the live active record to
1406
+ // corroborate the ORIGINAL request identity before refreshing.
1407
+ const requestedIdentity = canonicalRequestedSavedIdentity(path);
1408
+ const resolvedIdentity = canonicalSavedRecordIdentity({
1409
+ path: resolvedPath,
1410
+ routing_key: resolved.routing_key,
1411
+ });
1412
+ const workflowUuid = requestedIdentity &&
1413
+ requestedIdentity === resolvedIdentity &&
1414
+ activeMatchesOpenRefreshTarget(active, path)
1415
+ ? responseWorkflowUuid(active)
1416
+ : undefined;
1417
+ return { receipt: "applied", workflowUuid, waited_ms: Date.now() - start, attempts };
1176
1418
  }
1177
1419
  if (receipt.applied === false) {
1178
1420
  return {
@@ -1228,8 +1470,14 @@ async function openWorkflowWithVerify(path, ctx) {
1228
1470
  // caller must see it as-is. A slow-ack TIMEOUT or a mid-command reconnect DROP
1229
1471
  // ("OUTCOME UNKNOWN", #402) both warrant a receipt lookup, which can turn an
1230
1472
  // unknown outcome into a definite one without inferring from active state.
1231
- if (!isAckTimeout(res) && !isReconnectDrop(res))
1473
+ if (!isAckTimeout(res) && !isReconnectDrop(res)) {
1474
+ // #716 — re-read the active record after this exact successful open before
1475
+ // refreshing the next command's stamp. This prevents a late reply from an
1476
+ // earlier open from overwriting the fence after another tab became active.
1477
+ if (!res.isError)
1478
+ await refreshOpenWorkflowUuid(ctx, path, res);
1232
1479
  return res;
1480
+ }
1233
1481
  if (!dispatchedRid) {
1234
1482
  return fail(`${toolResultText(res)}\n\nworkflow_open outcome is undetermined: the command may have been sent, but this ` +
1235
1483
  `bridge/panel combination did not expose a request id for receipt correlation. Do not assume ` +
@@ -1238,6 +1486,8 @@ async function openWorkflowWithVerify(path, ctx) {
1238
1486
  const timing = getOpenVerifyTiming();
1239
1487
  const verify = await waitForOpenReceipt(ctx, path, dispatchedRid, timing);
1240
1488
  if (verify.receipt === "applied") {
1489
+ if (verify.workflowUuid)
1490
+ refreshWorkflowUuid(ctx, { workflow_uuid: verify.workflowUuid });
1241
1491
  return ok({
1242
1492
  opened: { path },
1243
1493
  recovered: true,
@@ -1324,11 +1574,32 @@ async function resolveOpenWorkflow(ctx, path) {
1324
1574
  rec = exact.length === 1 ? exact[0] : activePreferred.length === 1 ? activePreferred[0] : undefined;
1325
1575
  }
1326
1576
  if (rec) {
1327
- return { record: rec, isActive: computeIsActive(rec, activeObj), activeLabel };
1577
+ const isActive = computeIsActive(rec, activeObj);
1578
+ // Only the top-level active object is the panel's direct current-canvas
1579
+ // report. An affirmatively-active per-record flag alone remains sufficient
1580
+ // to route a legacy pin, but is NOT enough to replace a command identity:
1581
+ // a stale/mixed list can say `rec.active:true` for A while top-level active
1582
+ // (and its UUID) is B. Refresh only when both expose the same exact saved
1583
+ // path/routing identity; aliases and routing tokens are deliberately
1584
+ // irrelevant. The caller-path check happens in resolvePinTarget(), where
1585
+ // the refreshed value is handed to the bridge-owned command fence.
1586
+ const workflowUuid = isActive === true &&
1587
+ activeRecordMatchesExactSavedIdentity(rec, activeObj)
1588
+ ? responseWorkflowUuid(activeObj)
1589
+ : undefined;
1590
+ return { record: rec, isActive, activeLabel, workflowUuid };
1328
1591
  }
1329
1592
  // The active object is authoritative too, in case it isn't mirrored in the array.
1330
1593
  if (activeMatchesTarget(activeObj, path)) {
1331
- return { record: activeObj, isActive: true, activeLabel };
1594
+ return {
1595
+ record: activeObj,
1596
+ isActive: true,
1597
+ activeLabel,
1598
+ // The top-level active object was not corroborated by a selected entry in
1599
+ // workflow_list. It may remain a compatibility-valid pin selector, but it
1600
+ // is never a safe source for replacing a command fence.
1601
+ workflowUuid: undefined,
1602
+ };
1332
1603
  }
1333
1604
  return NOT_OPEN;
1334
1605
  }
@@ -1376,6 +1647,53 @@ function identityVerdict(rec, activeObj) {
1376
1647
  }
1377
1648
  return comparable ? false : undefined;
1378
1649
  }
1650
+ /**
1651
+ * Exact saved-workflow identity required before an active-list UUID can refresh
1652
+ * a command fence. `activeMatchesTarget()` and an item's `active:true` are
1653
+ * intentionally alias/compatibility-friendly for pin resolution; neither can
1654
+ * prove that the record carrying the pin and top-level `active` name the same
1655
+ * saved canvas. Reject contradictory path/routing pairs and never fall back to
1656
+ * filename, basename, or key aliases here.
1657
+ */
1658
+ function activeRecordMatchesExactSavedIdentity(rec, activeObj) {
1659
+ if (!activeObj || typeof activeObj !== "object")
1660
+ return false;
1661
+ const recordIdentity = canonicalSavedRecordIdentity(rec);
1662
+ const activeIdentity = canonicalSavedRecordIdentity(activeObj);
1663
+ return !!recordIdentity && recordIdentity === activeIdentity;
1664
+ }
1665
+ /** Canonical `wf:<path>` identity for a caller's literal saved-workflow path. */
1666
+ function canonicalRequestedSavedIdentity(path) {
1667
+ const canonicalPath = canonicalSavedWorkflowPath(path);
1668
+ // A bare basename is an alias selector, not the canonical saved path the
1669
+ // command fence must bind. It may still resolve a legacy pin, but must never
1670
+ // authorize replacing its existing UUID stamp.
1671
+ return canonicalPath && canonicalPath.includes("/") ? `wf:${canonicalPath}` : null;
1672
+ }
1673
+ /**
1674
+ * Canonical `wf:<path>` identity, but only for a complete corroborating record.
1675
+ * Command-fence replacement is stricter than pin routing: a same-path record
1676
+ * with a missing/malformed route may be partial or replayed, and must not let a
1677
+ * new UUID replace the existing fail-closed stamp.
1678
+ */
1679
+ function canonicalSavedRecordIdentity(value) {
1680
+ if (!value || typeof value !== "object")
1681
+ return null;
1682
+ const record = value;
1683
+ const path = canonicalSavedWorkflowPath(record.path);
1684
+ const routing = canonicalSavedWorkflowRoutingIdentity(record.routing_key);
1685
+ // Both fields must be present and corroborate. A mixed, partial, or replayed
1686
+ // snapshot is not a safe source of a command-fence replacement.
1687
+ if (!path || !routing || routing !== `wf:${path}`)
1688
+ return null;
1689
+ return routing;
1690
+ }
1691
+ function canonicalSavedWorkflowRoutingIdentity(value) {
1692
+ if (typeof value !== "string" || !value.startsWith("wf:"))
1693
+ return null;
1694
+ const path = canonicalSavedWorkflowPath(value.slice(3));
1695
+ return path ? `wf:${path}` : null;
1696
+ }
1379
1697
  /** Stable-identity match (positive only) — used to prefer the active record among matches. */
1380
1698
  function recMatchesActive(rec, activeObj) {
1381
1699
  return identityVerdict(rec, activeObj) === true;
@@ -1429,10 +1747,20 @@ export async function resolvePinTarget(ctx, path, filename) {
1429
1747
  if (resolved) {
1430
1748
  // Canonicalize to the stable key so routing survives rename/reconnect.
1431
1749
  const rec = resolved.record;
1750
+ // `resolveOpenWorkflow()` establishes whether rec and top-level active are
1751
+ // exact two-field peers. That is still insufficient to replace a command
1752
+ // fence when the caller supplied a basename/key/routing alias: retain the
1753
+ // legacy pin resolution, but adopt its UUID only for the caller's own exact
1754
+ // canonical saved path.
1755
+ const callerIdentity = canonicalRequestedSavedIdentity(path);
1756
+ const workflowUuid = callerIdentity && callerIdentity === canonicalSavedRecordIdentity(rec)
1757
+ ? resolved.workflowUuid
1758
+ : undefined;
1432
1759
  return {
1433
1760
  ok: true,
1434
1761
  pinPath: rec.key ?? rec.path ?? path,
1435
1762
  pinFilename: filename ?? rec.filename ?? rec.path,
1763
+ workflowUuid,
1436
1764
  };
1437
1765
  }
1438
1766
  // Indeterminate list — stay lenient (older/partial panel).
@@ -1445,6 +1773,8 @@ export const __openWorkflowTestHooks = {
1445
1773
  },
1446
1774
  isAckTimeout,
1447
1775
  activeMatchesTarget,
1776
+ activeMatchesOpenRefreshTarget,
1777
+ activeRecordMatchesExactSavedIdentity,
1448
1778
  resolveOpenWorkflow,
1449
1779
  };
1450
1780
  const slotRef = z.union([z.string(), z.number().int().min(0)]);
@@ -1734,6 +2064,7 @@ async function readWorkflowFromPath(rawPath) {
1734
2064
  // (the panel executors already read pos/bounds as [x, y] / [x, y, w, h] arrays).
1735
2065
  const xy = () => z.array(z.number()).min(2).max(2).describe("[x, y] (two numbers).");
1736
2066
  const rect = () => z.array(z.number()).min(4).max(4).describe("[x, y, width, height] (four numbers).");
2067
+ const nodeSize = () => z.array(z.number().positive()).min(2).max(2).describe("[width, height] (two positive numbers).");
1737
2068
  /** Build a tab-bound execution context shared by both transports. */
1738
2069
  export function makePanelToolCtx(bridge, tabId, workflowTargets) {
1739
2070
  // The routing tab id is held on the returned ctx object (NOT captured by
@@ -1861,12 +2192,65 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets) {
1861
2192
  await sleep(Math.min(intervalMs, left));
1862
2193
  }
1863
2194
  };
2195
+ const panelConnectionIdentity = () => typeof bridge.tabConnectionIdentity === "function"
2196
+ ? bridge.tabConnectionIdentity(ctx.tabId)
2197
+ : undefined;
2198
+ // A restart report must NEVER count the panel socket that existed before the
2199
+ // reboot command. Unlike awaitReachable(), which intentionally returns at once
2200
+ // for a healthy binding, this waits for a strictly newer hello generation. The
2201
+ // fresh hello can keep the same tab/socket id or arrive under a new one; in the
2202
+ // latter case only rebind after the old target is gone, preserving strict
2203
+ // multi-tab routing and never guessing away from a still-live pre-restart tab.
2204
+ const awaitPostRestartReachable = async (before, budgetMs) => {
2205
+ // The actual UiBridge exposes a tab-session binding. Preserve historical
2206
+ // lightweight/mock-context behavior only when that capability does not exist
2207
+ // at all; a real bridge missing the pre-dispatch identity fails closed.
2208
+ if (typeof bridge.tabConnectionIdentity !== "function")
2209
+ return awaitReachable(budgetMs);
2210
+ if (before == null)
2211
+ return false;
2212
+ const timing = reconnectWaitTiming();
2213
+ const budget = Math.max(0, budgetMs != null ? Math.min(budgetMs, timing.budgetMs) : timing.budgetMs);
2214
+ const deadline = Date.now() + budget;
2215
+ const isOriginalTabReconnected = () => {
2216
+ const current = panelConnectionIdentity();
2217
+ return (current != null &&
2218
+ current.generation > before.generation &&
2219
+ current.tabSessionId === before.tabSessionId);
2220
+ };
2221
+ for (;;) {
2222
+ if (isOriginalTabReconnected())
2223
+ return true;
2224
+ // A new tab id cannot resolve through the retired binding. Once that binding is
2225
+ // actually gone, the existing conservative rebind can follow the sole new tab.
2226
+ if (!bridge.canReach(ctx.tabId)) {
2227
+ const interactive = interactiveTabIds() ?? [];
2228
+ if (interactive.length > 0)
2229
+ ensureReachable();
2230
+ if (isOriginalTabReconnected())
2231
+ return true;
2232
+ }
2233
+ const left = deadline - Date.now();
2234
+ if (left <= 0)
2235
+ return false;
2236
+ await sleep(Math.min(Math.max(1, timing.intervalMs), left));
2237
+ }
2238
+ };
1864
2239
  const sendRouted = async (cmd, timeoutMs, onDispatchedRid) => {
1865
2240
  const target = workflowTargets?.get(ctx.tabId);
1866
2241
  const routed = target ? withWorkflowTarget(cmd, target) : cmd;
1867
2242
  return bridge.send(routed, { tabId: ctx.tabId, timeoutMs, onDispatchedRid });
1868
2243
  };
1869
2244
  const call = async (cmd, timeoutMs, onDispatchedRid) => {
2245
+ // #694: capture the rid of THIS call's dispatched attempt so an OUTCOME-UNKNOWN
2246
+ // mutating failure can name it as the caller's explicit retry token (see the
2247
+ // catch below). The bridge fires the observer post-write (per attempt); chain
2248
+ // to any caller-supplied observer (workflow_open's receipt correlation).
2249
+ let dispatchedRid;
2250
+ const observeRid = (rid) => {
2251
+ dispatchedRid = rid;
2252
+ onDispatchedRid?.(rid);
2253
+ };
1870
2254
  try {
1871
2255
  // #436: a MUTATING graph edit must not fire into the "Connected: none"
1872
2256
  // window a ComfyUI restart/reload opens. A read survives that window (it is
@@ -1884,7 +2268,7 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets) {
1884
2268
  await awaitReachable();
1885
2269
  }
1886
2270
  ensureReachable();
1887
- return ok(await sendRouted(cmd, timeoutMs, onDispatchedRid));
2271
+ return ok(await sendRouted(cmd, timeoutMs, observeRid));
1888
2272
  }
1889
2273
  catch (err) {
1890
2274
  // Post-reconnect retry-once: a reboot/free_vram/reconnect can drop the tab's
@@ -1896,7 +2280,7 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets) {
1896
2280
  try {
1897
2281
  await sleep(retrySettleMs());
1898
2282
  ensureReachable(); // rebinds a current-mode session onto the reconnected tab
1899
- return ok(await sendRouted(cmd, timeoutMs, onDispatchedRid));
2283
+ return ok(await sendRouted(cmd, timeoutMs, observeRid));
1900
2284
  }
1901
2285
  catch (err2) {
1902
2286
  // The retry also failed — surface an actionable reconnecting status rather
@@ -1937,6 +2321,26 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets) {
1937
2321
  `moment, or rebind with panel_set_workflow_target({mode:"current"}) to follow the ` +
1938
2322
  `tab that's live now. (${err instanceof Error ? err.message : String(err)})`);
1939
2323
  }
2324
+ // #694 — EXPLICIT caller retry identity. A MUTATING command whose outcome is
2325
+ // UNKNOWN — a post-write reply timeout or a mid-command disconnect (the only
2326
+ // two dispatched:true rejections the bridge mints) — may already have been
2327
+ // applied by the panel, so a blind retry can double-apply. Name the dispatched
2328
+ // attempt's rid as the caller's retry token: re-issuing identical args plus
2329
+ // retry_of:"<rid>" lets the panel recognize and dedupe that exact mutation.
2330
+ // Pre-write refusals (dispatched:false, handled above) mint NO token — nothing
2331
+ // was sent, so there is nothing to dedupe. Minting is gated BOTH ways: the
2332
+ // bridge classifies the command as mutating (requiresWorkflowStampEnforcement)
2333
+ // AND the command is one the retry map admits (RETRY_TOKEN_CMDS) — a
2334
+ // read/view-only command outside BRIDGE_READONLY_CMDS (find_nodes, canvas,
2335
+ // screenshot, list_subgraphs) can satisfy the first without the second, and
2336
+ // it must never mint a token (a ledger answer for a read is a STALE outcome).
2337
+ if (dispatchedRid &&
2338
+ requiresWorkflowStampEnforcement(cmd) &&
2339
+ RETRY_TOKEN_CMDS.has(typeof cmd.cmd === "string" ? cmd.cmd : "") &&
2340
+ (dispatchOutcomeOf(err) === true || isReplyTimeoutTagged(err))) {
2341
+ const cause = err instanceof Error ? err.message : String(err);
2342
+ return fail(`${cause}\n\nTo retry this exact mutation, re-issue identical args plus retry_of:"${dispatchedRid}"; otherwise call normally.`);
2343
+ }
1940
2344
  return fail(err);
1941
2345
  }
1942
2346
  };
@@ -2042,6 +2446,9 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets) {
2042
2446
  ctx.rebindToActiveTab = rebindToActiveTab;
2043
2447
  ctx.ensureReachable = ensureReachable;
2044
2448
  ctx.awaitReachable = awaitReachable;
2449
+ ctx.panelConnectionIdentity = panelConnectionIdentity;
2450
+ ctx.awaitPostRestartReachable = awaitPostRestartReachable;
2451
+ ctx.tabCanMutateGraph = () => bridge.tabCanMutateGraph(ctx.tabId);
2045
2452
  return ctx;
2046
2453
  }
2047
2454
  /**
@@ -2251,8 +2658,16 @@ allowStateFallback = false) {
2251
2658
  /* fall through to the actionable error below */
2252
2659
  }
2253
2660
  }
2254
- throw new Error(`Couldn't capture the live canvas (${msg}). ` +
2255
- `An older panel version may not support graph_serialize — pass pack, path, or graph instead.`);
2661
+ // #721: only blame an old panel when the error actually IS an
2662
+ // unsupported-command rejection. Anything else (e.g. the panel's graph
2663
+ // desync guard, whose remedy is rebinding/opening the workflow) already
2664
+ // carries its own remedy in the message — appending the version hint
2665
+ // there misdirects the agent to pack/path/graph instead.
2666
+ if (isPanelCmdUnsupportedError(err, "graph_serialize")) {
2667
+ throw new Error(`Couldn't capture the live canvas (${msg}). ` +
2668
+ `An older panel version may not support graph_serialize — pass pack, path, or graph instead.`);
2669
+ }
2670
+ throw new Error(`Couldn't capture the live canvas (${msg}).`);
2256
2671
  }
2257
2672
  const wf = reply?.workflow;
2258
2673
  if (!wf || typeof wf !== "object") {
@@ -2517,6 +2932,127 @@ export const __panelAskTestHooks = {
2517
2932
  askSurfaceError,
2518
2933
  isReplyTimeoutError,
2519
2934
  };
2935
+ const PANEL_EDIT_NODE_FIELDS = ["pos", "size", "title", "preset", "color", "bgcolor", "shape", "collapsed", "pinned", "mode"];
2936
+ const NODE_COLOR_HEX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
2937
+ /** Cross-field rules that a flat ZodRawShape cannot express. Keep these at the
2938
+ * MCP boundary as well as in the panel executor: malformed direct tool calls
2939
+ * must never become a no-op or an ambiguous graph edit. */
2940
+ function validatePanelEditNodeArgs(args) {
2941
+ const hasNodeId = args.node_id !== undefined;
2942
+ const hasNodeIds = args.node_ids !== undefined;
2943
+ if (hasNodeId === hasNodeIds)
2944
+ return "panel_edit_node requires exactly one of node_id or node_ids.";
2945
+ if (hasNodeIds && (!Array.isArray(args.node_ids) || args.node_ids.length === 0))
2946
+ return "panel_edit_node node_ids must be a non-empty array.";
2947
+ if (!PANEL_EDIT_NODE_FIELDS.some((field) => args[field] !== undefined))
2948
+ return "panel_edit_node requires at least one editable field.";
2949
+ if (args.preset !== undefined && (args.color !== undefined || args.bgcolor !== undefined)) {
2950
+ return "panel_edit_node preset cannot be combined with color or bgcolor.";
2951
+ }
2952
+ return null;
2953
+ }
2954
+ /**
2955
+ * #694 — `retry_of`: an EXPLICIT caller retry identity for MUTATING panel
2956
+ * commands. When a mutating call fails OUTCOME-UNKNOWN (a post-write reply
2957
+ * timeout or a mid-command disconnect), the error text names the dispatched
2958
+ * attempt's rid and tells the caller to re-issue identical args plus
2959
+ * retry_of:"<rid>"; the panel dedupes the retried mutation on that token so the
2960
+ * retry can never double-apply. The token is OPAQUE caller data to the bridge —
2961
+ * forwarded to the wire UNTOUCHED (contrast workflow_uuid, which is bridge-owned
2962
+ * and always overwritten). Optional everywhere; never required.
2963
+ */
2964
+ const RETRY_OF_ARG = {
2965
+ retry_of: z
2966
+ .string()
2967
+ .optional()
2968
+ .describe("Retry token (#694): pass the retry_of rid from a previous outcome-unknown failure of this identical call to retry that exact mutation; omit otherwise."),
2969
+ };
2970
+ /**
2971
+ * #694 — the MUTATING panel tools that accept retry_of, keyed to the bridge
2972
+ * command each dispatches. Mirrors the #694 mutation surface EXACTLY: every
2973
+ * graph_* command NOT in BRIDGE_READONLY_CMDS (isMutatingGraphCommand — the
2974
+ * bridge's own fail-closed classification, which also arms the tight default
2975
+ * timeout and the dispatched:true mid-command outcome) plus the four workflow
2976
+ * mutators (workflow_save / workflow_save_as / workflow_rename / workflow_close
2977
+ * — the requiresWorkflowStampEnforcement set). Navigation/creation
2978
+ * (workflow_open / workflow_new), BRIDGE_READONLY_CMDS reads, and the tools whose
2979
+ * descriptions declare them view/read-only (panel_find_nodes, panel_canvas,
2980
+ * panel_screenshot, panel_list_subgraphs) are excluded: a read must never mint
2981
+ * or carry a retry token — its retry could be answered from the ledger with a
2982
+ * STALE outcome (codex gate). A few state-changing commands that sit OUTSIDE
2983
+ * BRIDGE_READONLY_CMDS but are reads in spirit (graph_select_nodes,
2984
+ * graph_enter/exit_subgraph, graph_copy_nodes) accept the token: they change UI
2985
+ * state idempotently, so a deduped retry is a no-op. EXPLICIT MAP, mirroring the
2986
+ * RETRY_SAFE_CMDS / MUTATING_GRAPH_EDIT_CMDS maintenance model — keep in sync
2987
+ * when mutating tools are added. Exported for the #694 surface-integrity test.
2988
+ */
2989
+ export const RETRY_TOKEN_CMD_BY_TOOL = {
2990
+ panel_add_node: "graph_add_node",
2991
+ panel_edit_node: "graph_edit_node",
2992
+ panel_remove_node: "graph_remove_node",
2993
+ panel_clear: "graph_clear",
2994
+ panel_flatten_workflow: "graph_load",
2995
+ panel_load_workflow: "graph_load",
2996
+ panel_connect: "graph_connect",
2997
+ panel_disconnect: "graph_disconnect",
2998
+ panel_set_widget: "graph_set_widget",
2999
+ panel_set_property: "graph_set_node_property",
3000
+ panel_move_node: "graph_move_node",
3001
+ panel_resize_node: "graph_resize_node",
3002
+ panel_auto_layout: "graph_auto_layout",
3003
+ panel_run: "graph_run",
3004
+ panel_save_workflow: "workflow_save", // workflow_save_as when `name` is given
3005
+ panel_rename_workflow: "workflow_rename",
3006
+ panel_close_workflow: "workflow_close",
3007
+ panel_select_nodes: "graph_select_nodes",
3008
+ panel_create_subgraph: "graph_create_subgraph",
3009
+ panel_subgraph_group: "graph_subgraph_group",
3010
+ panel_copy_nodes: "graph_copy_nodes",
3011
+ panel_paste_nodes: "graph_paste_nodes",
3012
+ panel_save_subgraph: "graph_save_subgraph",
3013
+ panel_add_subgraph: "graph_add_subgraph",
3014
+ panel_create_group: "graph_create_group",
3015
+ panel_move_group: "graph_move_group",
3016
+ panel_edit_group: "graph_edit_group",
3017
+ panel_remove_group: "graph_remove_group",
3018
+ panel_set_node_title: "graph_set_title",
3019
+ panel_set_node_collapsed: "graph_set_node_collapsed",
3020
+ panel_set_node_mode: "graph_set_node_mode",
3021
+ panel_set_node_color: "graph_set_node_color",
3022
+ panel_enter_subgraph: "graph_enter_subgraph",
3023
+ panel_exit_subgraph: "graph_exit_subgraph",
3024
+ panel_move_rail: "graph_move_rail",
3025
+ panel_promote_widget: "graph_promote_widget",
3026
+ panel_expose_subgraph_output: "graph_expose_subgraph_output",
3027
+ panel_expose_subgraph_input: "graph_expose_subgraph_input",
3028
+ panel_unpack_subgraph: "graph_unpack_subgraph",
3029
+ panel_update_node: "graph_update_node",
3030
+ };
3031
+ /** #694 — the bridge commands the retry map admits, for the mint gate: a
3032
+ * dispatched timeout/drop only mints a retry token when the command is in
3033
+ * this set (bridge classification alone would include read/view-only
3034
+ * commands that sit outside BRIDGE_READONLY_CMDS). */
3035
+ export const RETRY_TOKEN_CMDS = new Set(Object.values(RETRY_TOKEN_CMD_BY_TOOL).concat(["workflow_save_as"]));
3036
+ /** #694 — augment one MUTATING tool def: accept retry_of and attach it, UNTOUCHED,
3037
+ * to every mutating command the handler dispatches (per-command gated so a read
3038
+ * probe inside the same handler — e.g. panel_flatten_workflow's live-canvas
3039
+ * graph_serialize — stays clean). `call` is overridden on a prototype-delegating
3040
+ * wrapper (Object.create(ctx)) so LIVE properties — e.g. ctx.tabId rebinding —
3041
+ * keep reading through to the real ctx; only `call` shadows. */
3042
+ function withRetryToken(d) {
3043
+ return {
3044
+ ...d,
3045
+ schema: { ...d.schema, ...RETRY_OF_ARG },
3046
+ handler: (args, ctx) => {
3047
+ const retryOf = typeof args.retry_of === "string" && args.retry_of !== "" ? args.retry_of : undefined;
3048
+ if (!retryOf)
3049
+ return d.handler(args, ctx);
3050
+ const wrapped = Object.create(ctx);
3051
+ wrapped.call = (cmd, timeoutMs, onDispatchedRid) => ctx.call(requiresWorkflowStampEnforcement(cmd) ? { ...cmd, retry_of: retryOf } : cmd, timeoutMs, onDispatchedRid);
3052
+ return d.handler(args, wrapped);
3053
+ },
3054
+ };
3055
+ }
2520
3056
  /**
2521
3057
  * The SINGLE source of truth for the panel_* tool surface. Both transports
2522
3058
  * register these exact definitions, so the Claude (in-process) and Codex (HTTP)
@@ -2525,7 +3061,7 @@ export const __panelAskTestHooks = {
2525
3061
  export function buildPanelToolDefs() {
2526
3062
  // Local helper so each def reads like the original `tool(...)` call.
2527
3063
  const def = (name, description, schema, handler) => ({ name, description, schema, handler });
2528
- return [
3064
+ const defs = [
2529
3065
  def("panel_query_graph", "FILTER or TRAVERSE a SUBSET of the live canvas, for when you ALREADY KNOW what you're looking for. NOT for 'show me the canvas' or any whole-graph overview — call panel_graph_outline FIRST for that. NOT query_workflow (that queries a saved file or JSON you provide, not the live canvas). Filters, traverses, projects and aggregates over the workflow the user is CURRENTLY VIEWING without dumping the whole graph (replaces the old panel_get_graph full-JSON dump; output is TOKEN-BOUNDED with an explicit truncation marker, so a big graph can never flood your context). Combine: `types` (node type contains any), `title` (contains), `where` widget predicates ANDed ('cfg>7', 'steps<=20', 'sampler_name=euler', 'text~sunset' — ops = != >= <= > < ~contains), `ids` (exact nodes — THE way to read ONE node's exact slot/widget detail: {ids:[42], fields:'detail'}), `upstream_of`/`downstream_of` + `depth` (dependency traversal: upstream = what FEEDS that node, downstream = what CONSUMES it; seed at depth 0), `fields` ('compact' one line per node [default], 'ids', 'detail' = the full node summary with slots + connections + mode), `group_by:'type'` (counts only), `limit` (default 40). detail rows include each node's MODE — a 'bypass' node is skipped and a 'mute' node kills everything downstream, so check modes on the path you care about before running (fix with panel_set_node_mode). Every result also carries `groups` (id, title, member node_ids — groups are geometric, trust this list) and, when viewing a SUBGRAPH (after panel_enter_subgraph), `rails` (boundary rail ids/slots). Typical flow: panel_graph_outline to orient → panel_query_graph to pinpoint/inspect → edit. Read-only.", {
2530
3066
  types: z.array(z.string()).optional().describe("Node type contains ANY of these (case-insensitive)."),
2531
3067
  title: z.string().optional().describe("Node title contains this."),
@@ -2639,7 +3175,7 @@ export function buildPanelToolDefs() {
2639
3175
  mode: args.mode,
2640
3176
  limit: args.limit,
2641
3177
  })),
2642
- def("panel_add_node", "Add a node to the user's OPEN ComfyUI graph by class_type (e.g. 'KSampler', 'CheckpointLoaderSimple'). The user sees it appear live; Ctrl+Z undoes it. Returns the created node's id, slots, and default widget values.", {
3178
+ def("panel_add_node", "Add a node to the user's OPEN ComfyUI graph by class_type (e.g. 'KSampler', 'CheckpointLoaderSimple'). The user sees it appear live; Ctrl+Z undoes it. Returns the created node's id, slots, and default widget values. Frontend-only virtual types are addable too: 'Note' and 'MarkdownNote' — the supported way to ANNOTATE a workflow with on-canvas instructions (add the node, then put the text in its 'text' widget via panel_set_widget) — plus 'Reroute' and 'PrimitiveNode'. These are LiteGraph-native and never appear in the backend node registry, so they legitimately bypass the backend class_type check.", {
2643
3179
  class_type: z.string().describe("Exact ComfyUI node class_type to create."),
2644
3180
  pos: xy()
2645
3181
  .optional()
@@ -2749,6 +3285,11 @@ export function buildPanelToolDefs() {
2749
3285
  return ok(`${summary}\n\n${JSON.stringify(graph)}`);
2750
3286
  }
2751
3287
  const loaded = await ctx.call({ cmd: "graph_load", graph: graph }, 30000);
3288
+ // ctx.call returns an error ToolResult for an outcome-unknown graph_load
3289
+ // (rather than throwing). Preserve it verbatim: wrapping it as a successful
3290
+ // "Loaded" result would fabricate success and hide its retry_of token.
3291
+ if (loaded.isError)
3292
+ return loaded;
2752
3293
  const loadText = loaded.content?.[0]?.text ?? "";
2753
3294
  return ok(`${summary}\nLoaded onto the canvas (one undo restores the original). ${loadText.slice(0, 120)}`);
2754
3295
  }),
@@ -2896,18 +3437,46 @@ export function buildPanelToolDefs() {
2896
3437
  .union([z.string(), z.number(), z.boolean(), z.null()])
2897
3438
  .describe("New property value (string/number/boolean/null). For the rgthree Fast Groups Bypasser, matchTitle is a title substring/regex filter."),
2898
3439
  }, async (args, ctx) => ctx.call({ cmd: "graph_set_node_property", node_id: args.node_id, name: args.name, value: args.value })),
2899
- def("panel_move_node", "Move a node to a new canvas position [x, y] in the user's open graph. Undoable.", {
2900
- node_id: z.number().int().describe("Node id from panel_graph_outline / panel_query_graph."),
2901
- pos: xy().describe("New canvas [x, y] (two numbers)."),
2902
- }, async (args, ctx) => ctx.call({ cmd: "graph_move_node", node_id: args.node_id, pos: args.pos })),
2903
- 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.", {
2904
- node_id: z.number().int().describe("Node id from panel_graph_outline / panel_query_graph."),
2905
- size: z
2906
- .array(z.number())
2907
- .min(2)
2908
- .max(2)
2909
- .describe("New [width, height] in canvas px (both > 0)."),
2910
- }, async (args, ctx) => ctx.call({ cmd: "graph_resize_node", node_id: args.node_id, size: args.size })),
3440
+ def("panel_edit_node", "Atomically edit one node, or apply the same edit to several nodes. Pass exactly one of node_id or node_ids, plus at least one field. In one Ctrl+Z step you can move (pos), resize (size — including Note/MarkdownNote), retitle, recolor, change shape, collapse, pin, or set execution mode. Widget values, LiteGraph properties, links, and slot order stay on their dedicated tools. For a multi-node call, position/size/title/mode apply the same value to every target. Color fields accept #RGB, #RGBA, #RRGGBB, or #RRGGBBAA; null clears a color. Bypassing a subgraph retains panel_set_node_mode's unsafe-boundary guard; force:true is required to override it. Undoable with Ctrl+Z.", {
3441
+ node_id: z.number().int().optional().describe("One node id from panel_graph_outline / panel_query_graph. Provide this OR node_ids, not both."),
3442
+ node_ids: z.array(z.number().int()).min(1).optional().describe("Several node ids that receive the same presentation edit. Provide this OR node_id, not both."),
3443
+ pos: xy().optional().describe("New canvas [x, y]."),
3444
+ size: nodeSize().optional().describe("New [width, height] in canvas px. Uses the node's setSize so DOM-widget nodes reflow and minimum sizes are honored."),
3445
+ title: z.string().optional().describe("New header title."),
3446
+ preset: z.enum(["red", "brown", "green", "blue", "pale_blue", "cyan", "purple", "yellow", "black"]).optional().describe("Named LiteGraph palette color (sets both title and body). Cannot be combined with color/bgcolor."),
3447
+ color: z.string().regex(NODE_COLOR_HEX).nullable().optional().describe("Title-bar color hex, or null to clear."),
3448
+ bgcolor: z.string().regex(NODE_COLOR_HEX).nullable().optional().describe("Body color hex, or null to clear."),
3449
+ shape: z.enum(["default", "box", "round", "card"]).optional().describe("Node outline shape; default restores the theme default."),
3450
+ collapsed: z.boolean().optional().describe("true collapses to a title chip; false expands."),
3451
+ pinned: z.boolean().optional().describe("Whether LiteGraph marks this node pinned for presentation/layout."),
3452
+ mode: z.enum(["active", "bypass", "mute"]).optional().describe("Execution mode. Bypass/mute change what renders; inspect the graph first."),
3453
+ force: z.boolean().optional().describe("Required only to bypass a subgraph whose positional I/O boundary mapping is unsafe."),
3454
+ }, async (args, ctx) => {
3455
+ const error = validatePanelEditNodeArgs(args);
3456
+ if (error)
3457
+ return fail(error);
3458
+ return ctx.call({
3459
+ cmd: "graph_edit_node",
3460
+ node_id: args.node_id,
3461
+ node_ids: args.node_ids,
3462
+ pos: args.pos,
3463
+ size: args.size,
3464
+ title: args.title,
3465
+ preset: args.preset,
3466
+ color: args.color,
3467
+ bgcolor: args.bgcolor,
3468
+ shape: args.shape,
3469
+ collapsed: args.collapsed,
3470
+ pinned: args.pinned,
3471
+ mode: args.mode,
3472
+ force: args.force,
3473
+ });
3474
+ }),
3475
+ // Keep legacy bridge commands behind compatibility tool names. graph_edit_node
3476
+ // is newer than several installed panels, while current panels adapt these
3477
+ // commands into the same atomic implementation.
3478
+ def("panel_move_node", "Compatibility wrapper for panel_edit_node(pos).", { node_id: z.number().int(), pos: xy() }, async (args, ctx) => ctx.call({ cmd: "graph_move_node", node_id: args.node_id, pos: args.pos })),
3479
+ def("panel_resize_node", "Compatibility wrapper for panel_edit_node(size).", { node_id: z.number().int(), size: nodeSize() }, async (args, ctx) => ctx.call({ cmd: "graph_resize_node", node_id: args.node_id, size: args.size })),
2911
3480
  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).", {
2912
3481
  node_ids: z
2913
3482
  .array(z.number().int())
@@ -2991,7 +3560,7 @@ export function buildPanelToolDefs() {
2991
3560
  QueueMonitor.markSelfQueued(queuedId);
2992
3561
  // Append anti-poll guidance: the agent should go idle after queuing so the
2993
3562
  // executed event auto-injects the output image, rather than busy-polling.
2994
- 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.";
3563
+ const note = "\n\n[IMPORTANT] You will be notified automatically with the output image(s)/video when the render finishes — do NOT poll queue (action:\"list\"), get_history, or list_output_images. Just end your turn now and wait for the result to be delivered to you.";
2995
3564
  // Backpressure note. A backlog is only alarming when it's a job we did NOT
2996
3565
  // queue (possibly foreign/stuck). Deliberately batching renders — a sweep,
2997
3566
  // a multi-variant comparison — is a NORMAL workflow, so a queue made of our
@@ -3007,14 +3576,14 @@ export function buildPanelToolDefs() {
3007
3576
  warn =
3008
3577
  `\n\n[QUEUE] Queued behind your own in-flight render(s) (1 running${pendingTxt}). ` +
3009
3578
  `This is normal when batching a sweep or comparison — they drain in order and nothing is stuck; ` +
3010
- `each result is delivered to you as it finishes. To drop a single pending item, use cancel_queued_job. ` +
3011
- `Only use cancel_job with clear_pending:true if a render is ACTUALLY wedged — it kills the running job AND your entire queue.`;
3579
+ `each result is delivered to you as it finishes. To drop a single pending item, use queue (action:"cancel_queued"). ` +
3580
+ `Only use queue (action:"cancel") with clear_pending:true if a render is ACTUALLY wedged — it kills the running job AND your entire queue.`;
3012
3581
  }
3013
3582
  else {
3014
3583
  warn =
3015
3584
  `\n\n[QUEUE] A render is already running${pre.runningPromptId ? ` (prompt ${pre.runningPromptId})` : ""}${pendingTxt}, ` +
3016
- `and the queue includes work this session didn't queue — your run is queued behind it. Inspect with get_queue before acting. ` +
3017
- `If the running job is genuinely stuck, cancel_job with clear_pending:true interrupts it AND drops pending, then escalate to restart_comfyui if it reports the job wedged.`;
3585
+ `and the queue includes work this session didn't queue — your run is queued behind it. Inspect with queue (action:"list") before acting. ` +
3586
+ `If the running job is genuinely stuck, queue (action:"cancel") with clear_pending:true interrupts it AND drops pending, then escalate to restart_comfyui if it reports the job wedged.`;
3018
3587
  }
3019
3588
  }
3020
3589
  if (res.content?.[0]?.type === "text") {
@@ -3680,18 +4249,27 @@ export function buildPanelToolDefs() {
3680
4249
  // so both entry points validate identically.
3681
4250
  let pinPath = path;
3682
4251
  let pinFilename = filename;
4252
+ let pinnedWorkflowUuid;
3683
4253
  if (mode === "pinned" && path) {
3684
4254
  const res = await resolvePinTarget(ctx, path, filename);
3685
4255
  if (!res.ok)
3686
4256
  return fail(res.error);
3687
4257
  pinPath = res.pinPath;
3688
4258
  pinFilename = res.pinFilename;
4259
+ pinnedWorkflowUuid = res.workflowUuid;
3689
4260
  }
3690
4261
  const target = ctx.workflowTarget.set(ctx.tabId, {
3691
4262
  mode,
3692
4263
  path: pinPath,
3693
4264
  filename: pinFilename,
3694
4265
  });
4266
+ // #716 — a successful, positively-active re-pin is an explicit recovery
4267
+ // boundary after reconnect/open. Its UUID came from the same fresh
4268
+ // workflow_list result that validated the pin. Missing, malformed, or
4269
+ // indeterminate values leave the old command stamp intact (fail closed).
4270
+ if (mode === "pinned" && pinnedWorkflowUuid) {
4271
+ refreshWorkflowUuid(ctx, { workflow_uuid: pinnedWorkflowUuid });
4272
+ }
3695
4273
  ctx.bridge.push({ type: "workflow_target", target }, ctx.tabId);
3696
4274
  const hint = target.mode === "pinned"
3697
4275
  ? `Pinned to "${target.filename ?? target.path}". Graph tools will target that workflow without switching the user's view.`
@@ -3783,14 +4361,8 @@ export function buildPanelToolDefs() {
3783
4361
  bounds: args.bounds,
3784
4362
  }, 15000)),
3785
4363
  def("panel_remove_group", "Remove a group box from the user's open graph. The nodes inside the group are NOT deleted — only the box. Undoable.", { group_id: z.number().int().describe("Group id from panel_query_graph's groups[] / panel_create_group.") }, async (args, ctx) => ctx.call({ cmd: "graph_remove_group", group_id: args.group_id }, 15000)),
3786
- def("panel_set_node_title", "Rename a node's TITLE (the label on its header) — e.g. to label a node by its purpose. Different from panel_set_widget (which changes a value). Undoable with Ctrl+Z.", {
3787
- node_id: z.number().int().describe("Node id from panel_graph_outline / panel_query_graph."),
3788
- title: z.string().describe("New title text."),
3789
- }, async (args, ctx) => ctx.call({ cmd: "graph_set_title", node_id: args.node_id, title: args.title }, 15000)),
3790
- def("panel_set_node_collapsed", "Collapse (minimize) or expand a node on the user's open graph. Collapsed nodes shrink to just their title bar — handy for tidying loaders or rarely-touched nodes. Undoable.", {
3791
- node_id: z.number().int().describe("Node id from panel_graph_outline / panel_query_graph."),
3792
- collapsed: z.boolean().optional().describe("true = collapse/minimize (default), false = expand."),
3793
- }, async (args, ctx) => ctx.call({ cmd: "graph_set_node_collapsed", node_id: args.node_id, collapsed: args.collapsed })),
4364
+ def("panel_set_node_title", "Compatibility wrapper for panel_edit_node(title).", { node_id: z.number().int(), title: z.string() }, async (args, ctx) => ctx.call({ cmd: "graph_set_title", node_id: args.node_id, title: args.title }, 15000)),
4365
+ def("panel_set_node_collapsed", "Compatibility wrapper for panel_edit_node(collapsed).", { node_id: z.number().int(), collapsed: z.boolean().optional() }, async (args, ctx) => ctx.call({ cmd: "graph_set_node_collapsed", node_id: args.node_id, collapsed: args.collapsed ?? true })),
3794
4366
  def("panel_set_node_mode", "Set a node's EXECUTION MODE on the user's open graph — active, bypass, or mute — and return { node_id, mode, previous_mode }. This is how you turn a node ON or OFF without deleting it. Modes:\n" +
3795
4367
  "• 'active' — normal: the node executes.\n" +
3796
4368
  "• '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" +
@@ -3805,21 +4377,12 @@ export function buildPanelToolDefs() {
3805
4377
  .optional()
3806
4378
  .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."),
3807
4379
  }, async (args, ctx) => ctx.call({ cmd: "graph_set_node_mode", node_id: args.node_id, mode: args.mode, force: args.force })),
3808
- 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.", {
3809
- node_id: z.number().int().describe("Node id from panel_graph_outline / panel_query_graph."),
3810
- preset: z
3811
- .enum(["red", "brown", "green", "blue", "pale_blue", "cyan", "purple", "yellow", "black"])
3812
- .optional()
3813
- .describe("Named LiteGraph color preset (sets both title + body)."),
3814
- color: z.string().nullable().optional().describe("Title-bar color hex, or null to clear. Ignored if preset given."),
3815
- bgcolor: z.string().nullable().optional().describe("Body color hex, or null to clear. Ignored if preset given."),
3816
- }, async (args, ctx) => ctx.call({
3817
- cmd: "graph_set_node_color",
3818
- node_id: args.node_id,
3819
- preset: args.preset,
3820
- color: args.color,
3821
- bgcolor: args.bgcolor,
3822
- })),
4380
+ def("panel_set_node_color", "Legacy color compatibility wrapper. Unlike panel_edit_node, color and bgcolor accept any CSS color string; when preset is supplied it wins over explicit colors, preserving the historical bridge behavior.", {
4381
+ node_id: z.number().int(),
4382
+ preset: z.enum(["red", "brown", "green", "blue", "pale_blue", "cyan", "purple", "yellow", "black"]).nullable().optional(),
4383
+ color: z.string().nullable().optional(),
4384
+ bgcolor: z.string().nullable().optional(),
4385
+ }, async (args, ctx) => ctx.call({ cmd: "graph_set_node_color", node_id: args.node_id, preset: args.preset, color: args.color, bgcolor: args.bgcolor })),
3823
4386
  def("panel_screenshot", "SCREENSHOT the canvas to a PNG IMAGE — pixels, for when the question is VISUAL: overlaps, alignment, rails, colors, group bands. To READ what is on the canvas as text (ids, types, widget values, wiring) use panel_graph_outline instead; an image cannot be searched or quoted. Renders the workflow the user is currently viewing (root graph, or the open subgraph): frames the whole graph (nodes + groups), captures, then restores the user's view. Use it to verify a layout you just built instead of reasoning from coordinates alone.", { padding: z.number().optional().describe("Margin around the graph in px (default 60).") }, async (args, ctx) => {
3824
4387
  try {
3825
4388
  ctx.ensureReachable?.();
@@ -3827,7 +4390,11 @@ export function buildPanelToolDefs() {
3827
4390
  // screenshots the PINNED workflow (via injected workflow_path), not just
3828
4391
  // whatever tab is visible (codex — graph_* must carry the pin).
3829
4392
  const target = ctx.workflowTarget?.get(ctx.tabId);
3830
- const cmd = withWorkflowTarget({ cmd: "graph_screenshot", padding: args.padding }, target ?? { mode: "current" });
4393
+ const cmd = withWorkflowTarget(
4394
+ // #694: panel_screenshot is view-only — OUTSIDE the retry map — so it
4395
+ // never carries the token (a ledger answer for a read is a STALE
4396
+ // outcome), and the withRetryToken wrapper can't reach this direct send.
4397
+ { cmd: "graph_screenshot", padding: args.padding }, target ?? { mode: "current" });
3831
4398
  const res = (await ctx.bridge.send(cmd, {
3832
4399
  tabId: ctx.tabId,
3833
4400
  }));
@@ -3908,7 +4475,7 @@ export function buildPanelToolDefs() {
3908
4475
  return ctx.call({ cmd: "graph_update_node", id: args.id, version: args.version, channel: args.channel, mode: args.mode }, 30000);
3909
4476
  }),
3910
4477
  def("panel_node_queue_status", "Check the built-in Manager's install/update queue status (to see if a queued install finished). Read-only.", {}, async (_args, ctx) => ctx.call({ cmd: "nodes_queue_status" }, 20000)),
3911
- def("panel_restart_comfyui", "Restart the user's ComfyUI server via the built-in Manager — needed to load newly installed/updated custom nodes. CALL THIS DIRECTLY when a restart is needed: it pops a confirm card and only restarts on a yes (don't ask separately first). ComfyUI and this agent go down briefly, then the panel auto-reconnects and you resume. ⚠️ BUSY GUARD: a restart ABORTS any in-progress or queued generation — if ComfyUI is generating, this tool REFUSES and tells you (it does NOT restart). When that happens, tell the user a render is running and WAIT for it (poll panel_node_queue_status), or pass force:true ONLY if the user explicitly confirms they want to kill the running generation. Best practice: before restarting after an install, check the queue is idle first. Only call when a restart is actually needed.", { force: z.boolean().optional() }, async ({ force }, ctx) => {
4478
+ def("panel_restart_comfyui", "Restart the user's ComfyUI server via the built-in Manager — needed to load newly installed/updated custom nodes. CALL THIS DIRECTLY when a restart is needed: it pops a confirm card and only restarts on a yes (don't ask separately first). ComfyUI and this agent go down briefly, then the panel auto-reconnects and you resume. ⚠️ BUSY GUARD: a restart ABORTS any in-progress or queued generation — if ComfyUI is generating, this tool REFUSES and tells you (it does NOT restart). When that happens, tell the user a render is running and WAIT for it (poll panel_node_queue_status), or pass force:true ONLY if the user explicitly confirms they want to kill the running generation. Best practice: before restarting after an install, check the queue is idle first. Only call when a restart is actually needed. On an externally-managed install whose relaunch can't be proven from here (e.g. Pinokio), the restart is REFUSED before anything is stopped — restart from the launcher that owns the server instead.", { force: z.boolean().optional() }, async ({ force }, ctx) => {
3912
4479
  // Whole-handler budget (#536): confirm + dispatch + readiness — INCLUDING
3913
4480
  // the legacy path's UNPREEMPTIBLE synchronous execSync blocks — must ALL finish
3914
4481
  // under the outer ~300s tools/call limit. 255s + the legacy admission rule below
@@ -3944,6 +4511,124 @@ export function buildPanelToolDefs() {
3944
4511
  "panel card.");
3945
4512
  }
3946
4513
  if (decision !== "yes") {
4514
+ // #742: NEVER claim "not restarted" while the server is actually DOWN —
4515
+ // and NEVER declare a loss from ONE probe (codex gate): a genuinely
4516
+ // restarting server is refused during its normal down window, and a
4517
+ // transport/card error mapping to "no" is exactly how a tab dying
4518
+ // during a healthy prior reboot lands here. So recheck over a short,
4519
+ // bounded window and report DOWN only when the endpoint is STILL
4520
+ // refused at the end of it; a recovery inside the window is reported
4521
+ // as such (bound instance) or keeps the plain cancel line (unbound).
4522
+ // CAUSATION ("a restart took it down") is named ONLY when the probed
4523
+ // target is PROVABLY the same boot instance the restart would have
4524
+ // stopped — the same binding the refuse-safe preflight uses; an
4525
+ // unbound configured endpoint gets a generic unreachable report that
4526
+ // never claims "we stopped it".
4527
+ const declineBootBase = captureRebootHealthBase(ctx);
4528
+ const boundToRestartTarget = declineBootBase != null && sameHttpBase(getComfyUIBaseUrl(), declineBootBase);
4529
+ const declineProbeBase = boundToRestartTarget
4530
+ ? declineBootBase
4531
+ : getComfyUIBaseUrl();
4532
+ // r15: capture the session's held dispatch TOKEN before the probe
4533
+ // awaits — the exoneration clear (r13) and the recovery claim (r14)
4534
+ // must key on the token VALIDATED HERE, never whichever token is
4535
+ // current after the awaits: a concurrent accepted restart stamping a
4536
+ // fresh token mid-probe keeps its record (clear-if-same below), so a
4537
+ // later persistent DOWN can still attribute to it.
4538
+ const declineHeldToken = sessionRestartDispatchToken(ctx);
4539
+ const declineTiming = declineProbeTimingOverride ?? {
4540
+ windowMs: DECLINE_PROBE_WINDOW_MS,
4541
+ intervalMs: DECLINE_PROBE_INTERVAL_MS,
4542
+ probeTimeoutMs: DECLINE_PROBE_TIMEOUT_MS,
4543
+ };
4544
+ const outcome = await probeDeclineRecovery(declineProbeBase, declineTiming.windowMs, declineTiming.intervalMs, declineTiming.probeTimeoutMs, overallDeadline);
4545
+ // The VALIDATED token's record (null when it was superseded by a
4546
+ // newer stamp during the probe — the conservative outcome: neither
4547
+ // claim nor clear keys on a record this probe didn't validate).
4548
+ const declineHeldRecord = declineHeldToken
4549
+ ? getRestartDispatchRecord(declineHeldToken)
4550
+ : null;
4551
+ // r13: a HEALTHY/RECOVERED observation exonerates any dispatch THIS
4552
+ // session has on record — the restart explained itself, so its token
4553
+ // must never ground causation for a LATER, independent failure.
4554
+ // Base-matched: a healthy observation only exonerates the instance
4555
+ // it was taken on (an unbound healthy endpoint says nothing about a
4556
+ // boot-instance record). The 10-minute causation window stays as the
4557
+ // backstop for records never observed back.
4558
+ // r14: the record is captured BEFORE the clear — the recovery CLAIM
4559
+ // below ("a restart initiated earlier appears to have completed")
4560
+ // requires the token to have been present at this moment.
4561
+ // r15: CLEAR-IF-SAME — the clear fires only when the session still
4562
+ // holds the SAME token validated before the probe; a newer dispatch
4563
+ // stamped mid-probe keeps its record.
4564
+ if (outcome.status === "healthy" || outcome.status === "recovered") {
4565
+ if (declineHeldToken != null &&
4566
+ declineHeldRecord != null &&
4567
+ (declineHeldRecord.base == null ||
4568
+ sameHttpBase(declineHeldRecord.base, declineProbeBase))) {
4569
+ clearSessionRestartDispatchIfSame(ctx, declineHeldToken);
4570
+ }
4571
+ }
4572
+ if (outcome.status === "down") {
4573
+ const secs = Math.max(1, Math.round(outcome.waited_ms / 1000));
4574
+ if (boundToRestartTarget) {
4575
+ // r4: causation may be named ONLY against a RECORDED restart
4576
+ // dispatch — recent enough to plausibly be the cause, and
4577
+ // targeting THIS instance. r5: the record must be one THIS
4578
+ // SESSION dispatched (holds the token of) — another session's
4579
+ // failed restart never grounds causation here. An unrelated
4580
+ // crash / manual stop (no record, a stale record, another
4581
+ // instance's record, or another session's record) gets the
4582
+ // causation-free report.
4583
+ const dispatch = sessionRestartDispatch(ctx);
4584
+ const causative = dispatch != null &&
4585
+ Date.now() - dispatch.at <= RESTART_DISPATCH_CAUSATION_WINDOW_MS &&
4586
+ (dispatch.base == null || sameHttpBase(dispatch.base, declineBootBase));
4587
+ if (causative) {
4588
+ return ok("⚠️ ComfyUI is DOWN — it was STOPPED and did not come back (still " +
4589
+ `unreachable after a ${secs}s recheck window), so do NOT treat this as ` +
4590
+ "\"nothing happened\". The restart just declined was NOT what stopped " +
4591
+ "it (nothing was dispatched after the decline), but a restart initiated " +
4592
+ "earlier already took the server down and it never returned. Start " +
4593
+ "ComfyUI manually from whatever launches it (e.g. Pinokio, the Desktop " +
4594
+ "app, or your terminal), then reload the panel tab so it reconnects. " +
4595
+ "restart_comfyui can attempt the relaunch for you when the install's " +
4596
+ "launch path is resolvable.");
4597
+ }
4598
+ return ok("⚠️ ComfyUI is DOWN — still unreachable after " +
4599
+ `a ${secs}s recheck window — and no restart was dispatched through me ` +
4600
+ "that would explain it (the restart just declined was NOT dispatched, " +
4601
+ "and none is on record recently for this instance). Something else " +
4602
+ "stopped it (a crash, a manual stop, or its launcher). Start ComfyUI " +
4603
+ "manually from whatever launches it (e.g. Pinokio, the Desktop app, or " +
4604
+ "your terminal), then reload the panel tab so it reconnects.");
4605
+ }
4606
+ return ok("⚠️ ComfyUI appears to be DOWN — the configured endpoint was still " +
4607
+ `unreachable after a ${secs}s recheck window. The restart just declined ` +
4608
+ "was NOT dispatched, and I can't confirm from here whether the server " +
4609
+ "was actually stopped — this endpoint isn't provably the instance the " +
4610
+ "restart would have cycled. Check ComfyUI on its host and start it " +
4611
+ "manually if it is down, then reload the panel tab so it reconnects.");
4612
+ }
4613
+ if (outcome.status === "recovered" && boundToRestartTarget) {
4614
+ // r14: the recovery CLAIM ("a restart initiated earlier appears to
4615
+ // have completed") passes the SAME causation gate as the DOWN
4616
+ // report — a session-held, bound-confirmed record, recent, and
4617
+ // base-matched, captured BEFORE the r13 exoneration clear (which
4618
+ // fires either way). Without it, the down→healthy cycle alone
4619
+ // proves nothing about what caused the down: report the recovery
4620
+ // causation-free.
4621
+ const recoveryCausative = declineHeldRecord != null &&
4622
+ Date.now() - declineHeldRecord.at <= RESTART_DISPATCH_CAUSATION_WINDOW_MS &&
4623
+ (declineHeldRecord.base == null ||
4624
+ sameHttpBase(declineHeldRecord.base, declineBootBase));
4625
+ return ok(recoveryCausative
4626
+ ? "Cancelled — no new restart was dispatched. Note: ComfyUI was briefly " +
4627
+ "unreachable but is healthy again — a restart initiated earlier " +
4628
+ "appears to have completed."
4629
+ : "Cancelled — no new restart was dispatched. ComfyUI was briefly " +
4630
+ "unreachable but is healthy again.");
4631
+ }
3947
4632
  return ok("Cancelled — ComfyUI was not restarted.");
3948
4633
  }
3949
4634
  // Heal an orphaned session onto the live tab FIRST, then bind the reboot dispatch
@@ -3952,7 +4637,105 @@ export function buildPanelToolDefs() {
3952
4637
  // server-authorized + immutable, bound to the exact host FAMILY the reboot goes
3953
4638
  // to (null unless the bound tab provably fronts our boot instance).
3954
4639
  ctx.ensureReachable?.();
4640
+ // #742 REFUSE-SAFE PREFLIGHT: a Manager reboot stops ComfyUI OUT-OF-BAND —
4641
+ // it never goes through our validated kill+relaunch — so before dispatching
4642
+ // anything, the stop must be provable survivable (#368/#370: losing a restart
4643
+ // is cheap, losing the server is not). On a Pinokio-style install (externally
4644
+ // supervised; no main.py/interpreter resolvable from here) a plain Manager
4645
+ // restart kills the process and the supervisor does NOT re-launch it — the
4646
+ // exact #742 lost-server. When the reboot would target OUR local boot
4647
+ // instance (the same instance binding the legacy fallback uses), prove a
4648
+ // relaunch is possible FIRST and refuse BEFORE any stop when it isn't. Only
4649
+ // the PROVEN-dangerous shape refuses (a reachable local non-Desktop process
4650
+ // with an unbuildable/unvalidatable relaunch); every other shape — Desktop
4651
+ // (Electron-supervised, #400), unverifiable, or remote — proceeds exactly as
4652
+ // before. The binding for this DECISION is captured pre-await; nothing
4653
+ // downstream may reuse it (r7).
4654
+ const preflightHealthBase = captureRebootHealthBase(ctx);
4655
+ if (preflightHealthBase != null && sameHttpBase(getComfyUIBaseUrl(), preflightHealthBase)) {
4656
+ // Snapshot the target GENERATION at the decision (r11): a final-state
4657
+ // base comparison (A vs A) cannot detect an intervening A→B→A
4658
+ // retarget, so stability is judged by the monotonic epoch bumped on
4659
+ // EVERY retarget — any mutation, including a round trip back to the
4660
+ // same base, is caught.
4661
+ const preflightTargetGeneration = getComfyuiTargetGeneration();
4662
+ const preflight = await (localRestartPreflightOverride ?? preflightLocalRestart)();
4663
+ // r8/r9/r10: the preflight AWAIT makes the pre-decision captures
4664
+ // STALE — and the preflight itself reads MUTABLE config (target URL,
4665
+ // port, COMFYUI_PATH) throughout, so a config retarget during the
4666
+ // await can re-point the whole assessment at a DIFFERENT install
4667
+ // while the tab still fronts the original one. Re-heal and
4668
+ // re-capture BEFORE trusting the result either way. The guarantee
4669
+ // that must hold: a stop/reboot is only ever sent when the preflight
4670
+ // validated THE instance the dispatch will cycle.
4671
+ ctx.ensureReachable?.();
4672
+ const postPreflightHealthBase = captureRebootHealthBase(ctx);
4673
+ const tabFrontsSameInstance = postPreflightHealthBase != null &&
4674
+ sameHttpBase(preflightHealthBase, postPreflightHealthBase);
4675
+ const configStable = getComfyuiTargetGeneration() === preflightTargetGeneration;
4676
+ if (!configStable && tabFrontsSameInstance) {
4677
+ // r10: the target config moved MID-CHECK, so the preflight result
4678
+ // — pass OR fail — cannot vouch for the tab-fronted instance (a
4679
+ // PASS may have validated a different, safe install; it must never
4680
+ // bless a stop of this one). Its relaunch is UNPROVEN → refuse; an
4681
+ // instance with an unproven relaunch is never sent a stop.
4682
+ return ok({
4683
+ rebooting: false,
4684
+ ready: false,
4685
+ confirmed_cycle: false,
4686
+ refused: true,
4687
+ note: "Refusing to restart ComfyUI: the ComfyUI target configuration changed " +
4688
+ "while the restart safety check was running, so the check cannot vouch " +
4689
+ "for a safe relaunch of the instance this panel fronts. A stop is never " +
4690
+ "sent to an instance whose relaunch is unproven — ComfyUI was NOT " +
4691
+ "stopped (it is still running). Let the target settle, then retry " +
4692
+ "panel_restart_comfyui.",
4693
+ });
4694
+ }
4695
+ if (!preflight.ok && tabFrontsSameInstance) {
4696
+ // r9: the danger proof follows the INSTANCE the tab fronts, NOT the
4697
+ // mutable runtime config — a config-only retarget mid-await must
4698
+ // not wash out the proof that the tab-fronted boot instance is
4699
+ // unrelaunchable. The tab STILL fronts that same instance, so the
4700
+ // proof is still valid and the refusal stands: an instance proven
4701
+ // unrelaunchable is NEVER sent a stop/reboot, regardless of
4702
+ // config/tab shuffling mid-flight. Only a genuinely different,
4703
+ // unconfirmable target falls through to the honest-unconfirmed
4704
+ // dispatch (nothing was ever proved dangerous for it — and nothing
4705
+ // was ever stopped, the preflight never stops anything).
4706
+ return ok({
4707
+ rebooting: false,
4708
+ ready: false,
4709
+ confirmed_cycle: false,
4710
+ refused: true,
4711
+ note: `Refusing to restart ComfyUI: ${preflight.reason} This looks like an ` +
4712
+ "externally-managed install (e.g. Pinokio): a restart from here would STOP " +
4713
+ "ComfyUI and nothing would bring it back automatically, so it was refused " +
4714
+ "BEFORE anything was stopped — ComfyUI is still running. Restart it from the " +
4715
+ "launcher that owns it (e.g. Pinokio's own controls), or point COMFYUI_PATH " +
4716
+ "at the live install so a relaunch can be proven and use restart_comfyui.",
4717
+ });
4718
+ }
4719
+ // Otherwise: a PASS with a stable config (proven safe for THE
4720
+ // tab-fronted instance — proceed), or the tab now fronts a genuinely
4721
+ // different, unconfirmable target (nothing provable about it — the
4722
+ // dispatch path below treats that honestly, r6/r7). Nothing was
4723
+ // stopped in any case — the preflight never stops anything.
4724
+ }
4725
+ // r7: the preflight AWAIT sits between the binding capture and the dispatch,
4726
+ // breaking the no-await invariant above — a tab/connection rebind during
4727
+ // that await would make every pre-await capture stale (the dispatch would
4728
+ // go to an unconfirmable/different target while the causation stamp, the
4729
+ // recovery observer, and the legacy-fallback gate all read the STALE bound
4730
+ // base). Re-heal and re-capture the tab id, panel identity, and health base
4731
+ // AT THE DISPATCH POINT; everything below uses ONLY these fresh captures.
4732
+ ctx.ensureReachable?.();
3955
4733
  const boundTabId = ctx.tabId;
4734
+ // Snapshot the exact browser-tab registration that is about to receive the
4735
+ // reboot. A post-restart success must observe a strictly newer hello from
4736
+ // this SAME browser tab; a different tab can reuse the same saved-workflow
4737
+ // routing id while the original is still reconnecting.
4738
+ const preRestartPanelIdentity = ctx.panelConnectionIdentity?.();
3956
4739
  const healthBase = captureRebootHealthBase(ctx);
3957
4740
  const timing = getPanelRebootTiming();
3958
4741
  const dispatchTimeout = Math.max(1, Math.min(15000, overallDeadline - Date.now()));
@@ -4115,6 +4898,18 @@ export function buildPanelToolDefs() {
4115
4898
  " — restart ComfyUI on the host, then reconnect.");
4116
4899
  }
4117
4900
  clearTimeout(restartTimer);
4901
+ // #742 r5/r6: the managed restart stopped the process — record the
4902
+ // dispatch with THIS session holding the token, stamped with the
4903
+ // BOUND-CONFIRMED base (this fallback only runs when the instance
4904
+ // binding held, so healthBase is non-null here). restartComfyUI
4905
+ // also stamped its own process-wide record, which never grounds
4906
+ // causation. Only a PROVEN stop is recorded; a refusal/timeout
4907
+ // (restart undefined, or stopped!==true) records nothing. The
4908
+ // token is kept so the recovery clear below is CLEAR-IF-SAME (r15).
4909
+ let legacyDispatchToken;
4910
+ if (restart?.stopped === true) {
4911
+ legacyDispatchToken = stampSessionRestartDispatch(ctx, healthBase);
4912
+ }
4118
4913
  // DEFINITIVE no-restart: a spawn failure, OR restartComfyUI refused before
4119
4914
  // stopping anything (no process found / unsafe relaunch → stopped:false &&
4120
4915
  // started:false). The process was NOT cycled, so the still-healthy endpoint is
@@ -4130,21 +4925,55 @@ export function buildPanelToolDefs() {
4130
4925
  // Otherwise (the process WAS stopped/started, or restartComfyUI's own readiness
4131
4926
  // poll merely expired — neither terminal) DEFER to OUR OWN observed DOWN→UP.
4132
4927
  const recovery = await proofPromise;
4928
+ // #742 r4/r5/r15: the managed restart was observed back — clear THIS
4929
+ // session's record, CLEAR-IF-SAME: only when the session still holds
4930
+ // the token THIS restart stamped (a concurrent dispatch's newer
4931
+ // record survives). restartComfyUI also clears its own process-wide
4932
+ // record on success; this covers only-observer-saw-it recoveries.
4933
+ if (recovery.ready && legacyDispatchToken != null) {
4934
+ clearSessionRestartDispatchIfSame(ctx, legacyDispatchToken);
4935
+ }
4133
4936
  const observed = recovery.via === "observed-cycle";
4937
+ // The legacy Manager path restarts ComfyUI out-of-band too. Server recovery alone
4938
+ // is not graph-tool readiness: wait for the browser tab to reconnect, then verify
4939
+ // the same workflow-stamp capability the bridge requires before it dispatches a
4940
+ // mutation. Without this, updating the panel pack followed by a legacy restart can
4941
+ // falsely report ready while the browser is still running stale panel JS (#709).
4942
+ const tabBack = recovery.ready
4943
+ ? ctx.awaitPostRestartReachable
4944
+ ? await ctx.awaitPostRestartReachable(preRestartPanelIdentity, Math.max(0, overallDeadline - Date.now()))
4945
+ : ctx.awaitReachable
4946
+ ? await ctx.awaitReachable(Math.max(0, overallDeadline - Date.now()))
4947
+ : true
4948
+ : false;
4949
+ const graphToolsReady = tabBack && (ctx.tabCanMutateGraph ? ctx.tabCanMutateGraph() : true);
4134
4950
  return ok({
4135
4951
  rebooting: true,
4136
- ready: recovery.ready,
4952
+ ready: graphToolsReady,
4953
+ graph_tools_ready: graphToolsReady,
4954
+ server_ready: recovery.ready,
4955
+ panel_tab_reconnected: tabBack,
4137
4956
  confirmed_cycle: observed, // true = we directly observed the down→up cycle
4138
4957
  recovered_ms: recovery.waited_ms,
4139
4958
  probes: recovery.attempts,
4140
4959
  saw_down: recovery.sawDown,
4141
4960
  via: recovery.ready ? recovery.via : undefined,
4142
- note: "ComfyUI-Manager (legacy 3.x) had no reboot endpoint; ran the headless managed " +
4143
- "restart (kill + relaunch) " +
4144
- (recovery.ready
4145
- ? `and it came back healthy in ${(recovery.waited_ms / 1000).toFixed(1)}s` +
4146
- (observed ? " (observed it go down then come back)." : " (cycle not directly observed).")
4147
- : `but it did NOT become healthy within ${Math.round(recovery.waited_ms / 1000)}s — verify with health_check / panel_node_queue_status before assuming it restarted.`),
4961
+ note: recovery.ready && !graphToolsReady
4962
+ ? "ComfyUI-Manager (legacy 3.x) had no reboot endpoint; the headless managed restart " +
4963
+ `came back healthy in ${(recovery.waited_ms / 1000).toFixed(1)}s, but ` +
4964
+ (!tabBack
4965
+ ? "the panel tab has NOT reconnected yet (ready:false). Wait a moment then retry, or " +
4966
+ 'rebind with panel_set_workflow_target({mode:"current"}) before issuing graph tools.'
4967
+ : "the panel tab reconnected but cannot safely run graph mutations (ready:false), usually " +
4968
+ "because it is still running a stale panel bundle. Hard-refresh the ComfyUI browser tab " +
4969
+ "(Ctrl+Shift+R) before issuing graph tools; if that does not restore it, update the panel " +
4970
+ "and open/reload a saved workflow with a stable identity.")
4971
+ : "ComfyUI-Manager (legacy 3.x) had no reboot endpoint; ran the headless managed " +
4972
+ "restart (kill + relaunch) " +
4973
+ (recovery.ready
4974
+ ? `and it came back healthy in ${(recovery.waited_ms / 1000).toFixed(1)}s` +
4975
+ (observed ? " (observed it go down then come back)." : " (cycle not directly observed).")
4976
+ : `but it did NOT become healthy within ${Math.round(recovery.waited_ms / 1000)}s — verify with health_check / panel_node_queue_status before assuming it restarted.`),
4148
4977
  });
4149
4978
  }
4150
4979
  // Genuine refusal (busy guard / security / no eligible fallback) — return
@@ -4158,6 +4987,25 @@ export function buildPanelToolDefs() {
4158
4987
  resetClient();
4159
4988
  resetObjectInfoCache();
4160
4989
  resetManagerApiCache("panel Manager reboot");
4990
+ // #742 r4/r5/r6: record the ACTUAL dispatch (acceptance proven — a refusal
4991
+ // never reaches here). ONLY a BOUND-CONFIRMED target (the same binding
4992
+ // the r1 causation scoping and the refuse-safe preflight use) may stamp a
4993
+ // causation-capable record — held on THIS session with the BOUND base at
4994
+ // stamp time, never the mutable configured one. An unbound/unconfirmable
4995
+ // target (r6) stamps only the shared PROCESS-WIDE slot, which never
4996
+ // grounds causation: the dispatch can't be proven to have hit the
4997
+ // instance a later decline would probe, and a session that rebinds to
4998
+ // the boot tab afterward can't claim it either (a re-targeted session
4999
+ // can't prove the earlier dispatch hit its current instance — no claim
5000
+ // is the truthful answer). It is cleared below if observed back —
5001
+ // CLEAR-IF-SAME on the token stamped here (r15).
5002
+ let acceptedDispatchToken;
5003
+ if (healthBase != null && sameHttpBase(getComfyUIBaseUrl(), healthBase)) {
5004
+ acceptedDispatchToken = stampSessionRestartDispatch(ctx, healthBase);
5005
+ }
5006
+ else {
5007
+ recordRestartDispatch(getComfyUIBaseUrl(), PROCESS_WIDE_RESTART_DISPATCH_TOKEN);
5008
+ }
4161
5009
  // Observe recovery. There is exactly ONE sound proof that THIS ComfyUI instance
4162
5010
  // actually cycled: a directly OBSERVED down→up on the server-authorized, immutable,
4163
5011
  // family-bound boot endpoint (observeRecovery). We do NOT fabricate a second proof
@@ -4192,6 +5040,12 @@ export function buildPanelToolDefs() {
4192
5040
  // down→up, which the observer has been (and continues) watching for.
4193
5041
  gate.deadline = Math.min(Date.now() + timing.budgetMs, overallDeadline);
4194
5042
  const recovery = await recoveryPromise;
5043
+ // #742 r4/r5/r15: the dispatched restart was observed back — clear the
5044
+ // record, CLEAR-IF-SAME: only when the session still holds the token
5045
+ // THIS dispatch stamped (a concurrent dispatch's newer record survives).
5046
+ if (recovery.ready && acceptedDispatchToken != null) {
5047
+ clearSessionRestartDispatchIfSame(ctx, acceptedDispatchToken);
5048
+ }
4195
5049
  if (!recovery.ready) {
4196
5050
  const waited = Math.round(recovery.waited_ms / 1000);
4197
5051
  return ok({
@@ -4206,21 +5060,32 @@ export function buildPanelToolDefs() {
4206
5060
  : `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.`,
4207
5061
  });
4208
5062
  }
4209
- // #400: ComfyUI is healthy, but the panel's browser tab re-registers its own
4210
- // socket a moment later. If we return NOW, the very next graph tool in this turn
5063
+ // #400/#709: ComfyUI is healthy, but the panel's browser tab re-registers its own
5064
+ // socket a moment later. The old socket can still be reachable after dispatch, so
5065
+ // the generation waiter below requires a fresh hello before reporting readiness.
4211
5066
  // hits "no connected tab … Connected: none" and the agent is told to hand-rebind.
4212
5067
  // Wait (bounded, clamped to THIS handler's deadline) for the tab to reconnect and
4213
5068
  // rebind this session onto it. `ready` reflects GRAPH-TOOL readiness (a bound tab),
4214
5069
  // NOT just server health — a caller keying off `ready` must not be led into the
4215
5070
  // Connected:none window (codex). `server_ready` carries the certified cycle either
4216
- // way. When ctx has no awaitReachable (older/lightweight ctx) tabBack is true, so
4217
- // the historical ready:true-on-healthy-restart contract is preserved.
4218
- const tabBack = ctx.awaitReachable
4219
- ? await ctx.awaitReachable(Math.max(0, overallDeadline - Date.now()))
4220
- : true;
5071
+ // way. A production PanelToolCtx supplies the generation waiter; an explicitly
5072
+ // lightweight context retains the historical reachability-only test contract.
5073
+ const tabBack = ctx.awaitPostRestartReachable
5074
+ ? await ctx.awaitPostRestartReachable(preRestartPanelIdentity, Math.max(0, overallDeadline - Date.now()))
5075
+ : ctx.awaitReachable
5076
+ ? await ctx.awaitReachable(Math.max(0, overallDeadline - Date.now()))
5077
+ : true;
5078
+ // A recovered socket does NOT mean graph mutations are usable: an already-open
5079
+ // browser tab can reconnect after ComfyUI restarts while still serving stale panel
5080
+ // JS, which lacks the #570 workflow-stamp fence. Report the same capability the
5081
+ // bridge enforces before dispatch so this success path never fabricates readiness.
5082
+ // Old/lightweight contexts have no capability accessor, so preserve their historical
5083
+ // contract rather than claiming a production tab passed a check it never ran.
5084
+ const graphToolsReady = tabBack && (ctx.tabCanMutateGraph ? ctx.tabCanMutateGraph() : true);
4221
5085
  return ok({
4222
5086
  rebooting: true,
4223
- ready: tabBack, // graph tools are usable only once a panel tab is bound
5087
+ ready: graphToolsReady, // graph tools require a bound AND workflow-stamp-capable tab
5088
+ graph_tools_ready: graphToolsReady,
4224
5089
  server_ready: true, // ComfyUI itself cycled and is healthy
4225
5090
  confirmed_cycle: true, // we directly observed the down→up cycle on the boot endpoint
4226
5091
  recovered_ms: recovery.waited_ms,
@@ -4228,15 +5093,21 @@ export function buildPanelToolDefs() {
4228
5093
  saw_down: recovery.sawDown,
4229
5094
  via: recovery.via,
4230
5095
  panel_tab_reconnected: tabBack,
4231
- note: `ComfyUI restart accepted and it is healthy again in ${(recovery.waited_ms / 1000).toFixed(1)}s` +
4232
- " (observed it go down then come back)" +
4233
- (dropped ? "; connection dropped as expected while it went down" : "") +
4234
- (tabBack
4235
- ? "; the panel tab reconnected — graph tools are ready."
4236
- : "; ComfyUI is back but the panel tab has NOT reconnected yet (ready:false) — " +
4237
- 'wait a moment then retry, or rebind with panel_set_workflow_target({mode:"current"}) ' +
4238
- "before issuing graph tools.") +
4239
- ".",
5096
+ note: (tabBack && !graphToolsReady
5097
+ ? `ComfyUI restart accepted and it is healthy again in ${(recovery.waited_ms / 1000).toFixed(1)}s` +
5098
+ " (observed it go down then come back); the panel tab reconnected but cannot safely run " +
5099
+ "graph mutations (ready:false), usually because it is still running a stale panel bundle. " +
5100
+ "Hard-refresh the ComfyUI browser tab (Ctrl+Shift+R) before issuing graph tools; if that " +
5101
+ "does not restore it, update the panel and open/reload a saved workflow with a stable identity"
5102
+ : `ComfyUI restart accepted and it is healthy again in ${(recovery.waited_ms / 1000).toFixed(1)}s` +
5103
+ " (observed it go down then come back)" +
5104
+ (dropped ? "; connection dropped as expected while it went down" : "") +
5105
+ (tabBack
5106
+ ? "; the panel tab reconnected — graph tools are ready."
5107
+ : "; ComfyUI is back but the panel tab has NOT reconnected yet (ready:false) — " +
5108
+ 'wait a moment then retry, or rebind with panel_set_workflow_target({mode:"current"}) ' +
5109
+ "before issuing graph tools.") +
5110
+ "."),
4240
5111
  });
4241
5112
  }),
4242
5113
  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)),
@@ -4366,6 +5237,9 @@ export function buildPanelToolDefs() {
4366
5237
  return ctx.call({ cmd: "ui_update", card_id: args.card_id, spec: v.spec }, 15000);
4367
5238
  }),
4368
5239
  ];
5240
+ // #694: every MUTATING panel tool (RETRY_TOKEN_CMD_BY_TOOL) accepts the explicit
5241
+ // retry token in its schema and forwards it, untouched, on its command frames.
5242
+ return defs.map((d) => (d.name in RETRY_TOKEN_CMD_BY_TOOL ? withRetryToken(d) : d));
4369
5243
  }
4370
5244
  /**
4371
5245
  * Build the per-tab live-graph MCP server for the Claude (in-process Agent SDK)