comfyui-mcp 0.52.4 → 0.52.7

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 (134) hide show
  1. package/README.md +2 -2
  2. package/dist/orchestrator/agent-backend.js.map +1 -1
  3. package/dist/orchestrator/claude-backend.js +10 -2
  4. package/dist/orchestrator/claude-backend.js.map +1 -1
  5. package/dist/orchestrator/index.js +125 -3
  6. package/dist/orchestrator/index.js.map +1 -1
  7. package/dist/orchestrator/ollama-backend.js +53 -3
  8. package/dist/orchestrator/ollama-backend.js.map +1 -1
  9. package/dist/orchestrator/open-identity-normalization.js +267 -0
  10. package/dist/orchestrator/open-identity-normalization.js.map +1 -0
  11. package/dist/orchestrator/panel-agent.js +52 -5
  12. package/dist/orchestrator/panel-agent.js.map +1 -1
  13. package/dist/orchestrator/panel-tools.js +409 -31
  14. package/dist/orchestrator/panel-tools.js.map +1 -1
  15. package/dist/orchestrator/pi-backend.js +45 -16
  16. package/dist/orchestrator/pi-backend.js.map +1 -1
  17. package/dist/orchestrator/session-store.js +17 -1
  18. package/dist/orchestrator/session-store.js.map +1 -1
  19. package/dist/services/agent-identity.js +175 -0
  20. package/dist/services/agent-identity.js.map +1 -0
  21. package/dist/services/civitai-resolver.js +10 -0
  22. package/dist/services/civitai-resolver.js.map +1 -1
  23. package/dist/services/code-provider-auth.js +13 -0
  24. package/dist/services/code-provider-auth.js.map +1 -1
  25. package/dist/services/download-cache.js +48 -4
  26. package/dist/services/download-cache.js.map +1 -1
  27. package/dist/services/env-capabilities.js +1 -0
  28. package/dist/services/env-capabilities.js.map +1 -1
  29. package/dist/services/launch-argv.js +82 -0
  30. package/dist/services/launch-argv.js.map +1 -0
  31. package/dist/services/launcher-env.js +94 -0
  32. package/dist/services/launcher-env.js.map +1 -1
  33. package/dist/services/manifest-partial.js +85 -0
  34. package/dist/services/manifest-partial.js.map +1 -0
  35. package/dist/services/manifest.js +28 -16
  36. package/dist/services/manifest.js.map +1 -1
  37. package/dist/services/node-authoring.js +9 -10
  38. package/dist/services/node-authoring.js.map +1 -1
  39. package/dist/services/node-dev.js +32 -25
  40. package/dist/services/node-dev.js.map +1 -1
  41. package/dist/services/node-verify.js +8 -7
  42. package/dist/services/node-verify.js.map +1 -1
  43. package/dist/services/openai-provider-registry.js +12 -0
  44. package/dist/services/openai-provider-registry.js.map +1 -1
  45. package/dist/services/output-dir.js +6 -68
  46. package/dist/services/output-dir.js.map +1 -1
  47. package/dist/services/process-control.js +39 -7
  48. package/dist/services/process-control.js.map +1 -1
  49. package/dist/services/skill-generator.js +27 -0
  50. package/dist/services/skill-generator.js.map +1 -1
  51. package/dist/services/ui-bridge.js +84 -0
  52. package/dist/services/ui-bridge.js.map +1 -1
  53. package/dist/services/workspace-env.js +53 -25
  54. package/dist/services/workspace-env.js.map +1 -1
  55. package/dist/tools/manifest.js +1 -1
  56. package/dist/tools/manifest.js.map +1 -1
  57. package/dist/tools/node-pack.js +27 -16
  58. package/dist/tools/node-pack.js.map +1 -1
  59. package/dist/tools/report-issue.js +74 -4
  60. package/dist/tools/report-issue.js.map +1 -1
  61. package/dist/tools/report-issue.test.js +154 -2
  62. package/dist/tools/report-issue.test.js.map +1 -1
  63. package/locales/ar/main.json +2 -0
  64. package/locales/en/main.json +2 -0
  65. package/locales/es/main.json +2 -0
  66. package/locales/fa/main.json +2 -0
  67. package/locales/fr/main.json +2 -0
  68. package/locales/ja/main.json +2 -0
  69. package/locales/ko/main.json +2 -0
  70. package/locales/pt-BR/main.json +2 -0
  71. package/locales/ru/main.json +2 -0
  72. package/locales/tr/main.json +2 -0
  73. package/locales/zh/main.json +2 -0
  74. package/locales/zh-TW/main.json +2 -0
  75. package/package.json +1 -1
  76. package/packs/README.md +2 -1
  77. package/packs/ltx-2.3-xy-plot/pack.yaml +14 -3
  78. package/packs/ltx-2.3-xy-plot/workflow.json +6 -6
  79. package/packs/z-image-turbo-controlnet/install-runpod.sh +0 -2
  80. package/packs/z-image-turbo-controlnet/install-windows.bat +0 -2
  81. package/packs/z-image-turbo-controlnet/manifest.yaml +7 -5
  82. package/packs/z-image-turbo-controlnet/pack.yaml +3 -2
  83. package/packs/z-image-turbo-controlnet/workflow.json +3 -139
  84. package/packs/z-image-xy-plot/install-runpod.sh +1 -1
  85. package/packs/z-image-xy-plot/install-windows.bat +1 -1
  86. package/packs/z-image-xy-plot/manifest.yaml +18 -14
  87. package/packs/z-image-xy-plot/pack.yaml +17 -5
  88. package/packs/z-image-xy-plot/workflow.json +12 -12
  89. package/plugin/agents/explorer.md +2 -0
  90. package/plugin/commands/node-skill.md +1 -1
  91. package/plugin/skills/ai-toolkit-trainer/SKILL.md +5 -0
  92. package/plugin/skills/anima-base/SKILL.md +5 -0
  93. package/plugin/skills/anima-lora-trainer/SKILL.md +5 -0
  94. package/plugin/skills/civitai/SKILL.md +5 -0
  95. package/plugin/skills/color-correction/SKILL.md +5 -0
  96. package/plugin/skills/comfyui-core/SKILL.md +5 -0
  97. package/plugin/skills/comfyui-frontend-extensions/SKILL.md +8 -7
  98. package/plugin/skills/comfyui-launch-flags/SKILL.md +2 -5
  99. package/plugin/skills/comfyui-node-registry/SKILL.md +5 -0
  100. package/plugin/skills/debug-render/SKILL.md +5 -0
  101. package/plugin/skills/director/SKILL.md +5 -0
  102. package/plugin/skills/ernie-image/SKILL.md +5 -0
  103. package/plugin/skills/flux-txt2img/SKILL.md +5 -0
  104. package/plugin/skills/ideogram-ultra/SKILL.md +5 -0
  105. package/plugin/skills/installer-packs/SKILL.md +5 -0
  106. package/plugin/skills/krea2-identity-edit/SKILL.md +5 -0
  107. package/plugin/skills/krea2-txt2img/SKILL.md +5 -0
  108. package/plugin/skills/local-llm-free/SKILL.md +5 -0
  109. package/plugin/skills/lora-manager/SKILL.md +64 -0
  110. package/plugin/skills/ltx-director/SKILL.md +5 -0
  111. package/plugin/skills/ltxv2-video/SKILL.md +5 -0
  112. package/plugin/skills/model-compatibility/SKILL.md +5 -0
  113. package/plugin/skills/model-registry/SKILL.md +5 -0
  114. package/plugin/skills/panel-node-pack-sync/SKILL.md +5 -0
  115. package/plugin/skills/prompt-engineering/SKILL.md +5 -0
  116. package/plugin/skills/qwen-image-edit/SKILL.md +5 -0
  117. package/plugin/skills/qwen-txt2img/SKILL.md +5 -0
  118. package/plugin/skills/report-bug/SKILL.md +5 -0
  119. package/plugin/skills/rgthree/SKILL.md +5 -0
  120. package/plugin/skills/train-character-lora/SKILL.md +5 -0
  121. package/plugin/skills/triton-sageattention/SKILL.md +2 -21
  122. package/plugin/skills/troubleshooting/SKILL.md +5 -0
  123. package/plugin/skills/video-extend/SKILL.md +5 -0
  124. package/plugin/skills/video-upscale/SKILL.md +5 -0
  125. package/plugin/skills/wan-flf-video/SKILL.md +5 -0
  126. package/plugin/skills/wan-multitalk/SKILL.md +5 -0
  127. package/plugin/skills/wan-scail-replacement/SKILL.md +5 -0
  128. package/plugin/skills/wan-t2v-video/SKILL.md +5 -0
  129. package/plugin/skills/workflow-layout/SKILL.md +5 -0
  130. package/plugin/skills/z-image-txt2img/SKILL.md +5 -0
  131. package/scripts/arena-baseline.mjs +20 -0
  132. package/scripts/arena-graphic.mjs +4 -1
  133. package/scripts/arena-report.mjs +105 -0
  134. package/scripts/check-pack-models.mjs +94 -3
@@ -23,7 +23,7 @@
23
23
  // gating for panel_clear/panel_restart_comfyui) is IDENTICAL across providers,
24
24
  // so parity is automatic — neither path reimplements a tool.
25
25
  import { z } from "zod";
26
- import { randomUUID } from "node:crypto";
26
+ import { createHash, randomUUID } from "node:crypto";
27
27
  // #873 — the operator's tool-surface policy also governs the panel surface.
28
28
  import { resolveToolSurfacePolicy, toolActionPolicyError, toolAllowed, } from "../tools/tool-surface-filter.js";
29
29
  import { logger as toolPolicyLogger } from "../utils/logger.js";
@@ -34,6 +34,7 @@ import { fileURLToPath } from "node:url";
34
34
  import { comfyuiFetch } from "../comfyui/fetch.js";
35
35
  import { assertPanelNotTargetedUnverifiable } from "../services/panel-pin-guard.js";
36
36
  import { nodesInstallCommandArgs } from "../services/node-management.js";
37
+ import { formatQueueStatusPartialNote, getManifestPartialLeftover, } from "../services/manifest-partial.js";
37
38
  import { searchPanelNodes } from "../services/manager-node-search.js";
38
39
  import { isPanelAnsweredError } from "../services/panel-answered.js";
39
40
  import { isPreExecutorRefusal } from "../services/panel-refusal.js";
@@ -45,6 +46,7 @@ import { primePanelBase, verifiedPanelDiskVersion, } from "../services/panel-wor
45
46
  import { conversationOfScopeAddress, isScopeAddress, shortTabId } from "../services/session-scope.js";
46
47
  import { NODE_ID_MESSAGE, NODE_ID_PATTERN, normalizeNodeId } from "./node-id.js";
47
48
  import { parseContradictoryPromotedWidgetRefusal, resolveInnerPromotedTarget, } from "./promoted-widget.js";
49
+ import { isPlainObject, isStampMismatchSaveRefusal, patchOpenIdentity, shouldRebindOpenIdentity, workflowFromSerializeReply, } from "./open-identity-normalization.js";
48
50
  import { clearSwitchHold, describeSwitchHold, recordSwitchHold, successProvesSwitchCleared, } from "./switch-hold.js";
49
51
  import { NO_ORIGIN_REMEDY } from "./fence-refusal.js";
50
52
  /** #884 — journal TICKETS (run completions #468, ask answers #486) must be
@@ -72,7 +74,7 @@ function journalTabFor(ctx) {
72
74
  function journalConversationFor(ctx) {
73
75
  return conversationOfScopeAddress(ctx.tabId);
74
76
  }
75
- import { BRIDGE_DEFAULT_TIMEOUT_MS, BRIDGE_READ_DEFAULT_TIMEOUT_MS, dispatchOutcomeOf, isCapabilityRefusal, isPanelCmdUnsupportedError, isReplyTimeoutTagged, isRoutingAmbiguity, } from "../services/ui-bridge.js";
77
+ import { BRIDGE_DEFAULT_TIMEOUT_MS, BRIDGE_READ_DEFAULT_TIMEOUT_MS, dispatchOutcomeOf, isCapabilityRefusal, isPanelCmdUnsupportedError, isReplyTimeoutTagged, isRoutingAmbiguity, requiresWorkflowStampEnforcement, } from "../services/ui-bridge.js";
76
78
  import { withWorkflowTarget, } from "../services/workflow-target-store.js";
77
79
  import { addUserMcpServer, readUserMcpServers, removeUserMcpServer, setUserMcpServerSecret, } from "../services/user-mcp-config.js";
78
80
  import { setComfyuiSecret, setAgentSecret, isAllowedAgentSecretKey, receiptDisclosures, shadowedNote, storeDamageNote, } from "../services/panel-secrets.js";
@@ -94,6 +96,13 @@ import { config, isRemoteMode, isCloudMode, getBootLocalComfyUIBaseUrl, getComfy
94
96
  import { normalizeInstallPathEnv } from "../utils/install-path-env.js";
95
97
  import { sliceWorkflow } from "../services/workflow-slicer.js";
96
98
  import { validateA2UISpecServer } from "../services/a2ui-spec.js";
99
+ /** #1700 — orchestrator wires this so panel_reload can fork-respawn the agent
100
+ * with a freshly-read MCP set. Unset in tests that only exercise the tool. */
101
+ let applyMcpReload;
102
+ /** Bind (or clear) the in-process MCP-config respawn used by panel_reload. */
103
+ export function setApplyMcpReload(fn) {
104
+ applyMcpReload = fn;
105
+ }
97
106
  /** Treat these as an affirmative answer to a yes/no confirm card (destructive-op
98
107
  * gate). Deliberately BROAD/lenient — a false "no" only SKIPS a destructive op, so
99
108
  * erring toward "not yes" is safe. NEVER use this for the adult-consent gate: a
@@ -782,6 +791,26 @@ function isFencedGraphRead(cmd) {
782
791
  const name = typeof cmd.cmd === "string" ? cmd.cmd : "";
783
792
  return name.startsWith("graph_") && !MUTATING_GRAPH_EDIT_CMDS.has(name);
784
793
  }
794
+ /**
795
+ * #1778 — a fenced command that is neither a graph edit nor a graph read.
796
+ *
797
+ * `workflow_save` / `workflow_save_as` / `workflow_rename` / `workflow_close`
798
+ * require a workflow stamp (`requiresWorkflowStampEnforcement`) and the
799
+ * dispatch gate refuses them the same way it refuses a graph_* command. They
800
+ * are not in MUTATING_GRAPH_EDIT_CMDS and they do not start with `graph_`, so
801
+ * neither existing probe branch fires. The save handler then returns at
802
+ * `if (res.isError) return res` with the generic "tab may be disconnected"
803
+ * wrapper and an empty corroboration, so a save-only retry never self-heals.
804
+ *
805
+ * Derived from the stamp-enforcement classifier minus the `graph_` prefix, so
806
+ * a newly added active-workflow mutator cannot drift out. The prefix is still
807
+ * load-bearing for the same reason as `isFencedGraphRead`: the diagnosis runs
808
+ * `workflow_list`, which must not re-enter this branch.
809
+ */
810
+ function isFencedActiveWorkflowMutator(cmd) {
811
+ const name = typeof cmd.cmd === "string" ? cmd.cmd : "";
812
+ return !name.startsWith("graph_") && requiresWorkflowStampEnforcement(cmd);
813
+ }
785
814
  /** True when an error is a TRANSIENT transport/reconnect drop (the tab went away
786
815
  * or was replaced), NOT a genuine command error or a live-but-frozen reply
787
816
  * timeout. Deliberately EXCLUDES "did not reply within N ms" (a backgrounded/
@@ -844,6 +873,17 @@ function isWorkflowInstanceMismatch(err) {
844
873
  const msg = err instanceof Error ? err.message : String(err ?? "");
845
874
  return /workflow instance mismatch/i.test(msg);
846
875
  }
876
+ /** Would the next graph command still resolve `ctx.tabId` to a live tab? */
877
+ function sessionRouteIsLive(ctx) {
878
+ if (typeof ctx.bridge.canReach !== "function")
879
+ return true;
880
+ try {
881
+ return ctx.bridge.canReach(ctx.tabId) === true;
882
+ }
883
+ catch {
884
+ return false;
885
+ }
886
+ }
847
887
  /**
848
888
  * #1331 — the bridge refused a mutation because the active workflow has no identity to
849
889
  * fence against (ui-bridge's `no trusted identity` branch).
@@ -2342,6 +2382,35 @@ async function settleDroppedEnqueue(ctx, res, dispatch) {
2342
2382
  `when the Manager will not take it. This tool cannot clone for you — it drives whatever ` +
2343
2383
  `ComfyUI the panel is bound to, which need not be this machine.`);
2344
2384
  }
2385
+ /**
2386
+ * #1699 — a drained Manager queue is not apply_manifest completion when
2387
+ * custom_nodes were never submitted. Keep the panel payload parseable (do not
2388
+ * append prose after the JSON) and name the leftover entries on the object.
2389
+ */
2390
+ function annotateQueueStatusWithManifestPartial(res) {
2391
+ const leftover = getManifestPartialLeftover();
2392
+ if (!leftover || leftover.not_started.length === 0)
2393
+ return res;
2394
+ if (res.isError)
2395
+ return res;
2396
+ const parsed = parseToolResultJson(res);
2397
+ if (!parsed)
2398
+ return appendNote(res, formatQueueStatusPartialNote(leftover));
2399
+ return {
2400
+ ...res,
2401
+ content: [
2402
+ {
2403
+ type: "text",
2404
+ text: JSON.stringify({
2405
+ ...parsed,
2406
+ apply_manifest_partial: leftover,
2407
+ queue_complete_for_apply_manifest: false,
2408
+ }, null, 2),
2409
+ },
2410
+ ...res.content.slice(1),
2411
+ ],
2412
+ };
2413
+ }
2345
2414
  /** Parse a ctx.call ToolResult's text payload as JSON, or null if not parseable. */
2346
2415
  function parseToolResultJson(res) {
2347
2416
  if (!res || res.isError)
@@ -2386,6 +2455,31 @@ function isStaleNodeSchemaRefusal(res) {
2386
2455
  return false;
2387
2456
  return /added or retyped since this page loaded its node schema/i.test(textOfToolResult(res));
2388
2457
  }
2458
+ /**
2459
+ * #1708 — the panel's add-node guard polls `app.widgets` for a constructor. ComfyUI
2460
+ * 1.49+ puts `getCustomWidgets()` results in the Pinia widget store, so
2461
+ * AUTOCOMPLETE_TEXT_* (LoRA Manager Vue widgets) never appears there. The 5s wait
2462
+ * always expires and the refusal names a reload that cannot help.
2463
+ */
2464
+ function isLoraManagerAutocompleteRefusal(res) {
2465
+ if (!res.isError)
2466
+ return false;
2467
+ const text = textOfToolResult(res);
2468
+ return (/had no widget after .+ waiting for node extensions to register/i.test(text) &&
2469
+ /AUTOCOMPLETE_TEXT_/i.test(text));
2470
+ }
2471
+ const LORA_MANAGER_AUTOCOMPLETE_NOTE = `\n\nThis is NOT a missing pack and NOT a widget that is still loading. ` +
2472
+ `ComfyUI-LoRA-Manager registers AUTOCOMPLETE_TEXT_* via getCustomWidgets() into ` +
2473
+ `the frontend widget store; the add-node guard only looks at app.widgets (core types), ` +
2474
+ `so reload / panel_refresh_nodes / retry will keep failing. ` +
2475
+ `Add "LoRA Text Loader (LoraManager)" instead — same MODEL/CLIP/trigger_words outputs, ` +
2476
+ `but its lora_syntax input is a standard STRING socket you can drive with panel_set_widget ` +
2477
+ `or a wired string. Core LoraLoader also works. Do not retry this class_type.`;
2478
+ function withLoraManagerAutocompleteNote(res) {
2479
+ if (!isLoraManagerAutocompleteRefusal(res))
2480
+ return res;
2481
+ return appendToolResultText(res, LORA_MANAGER_AUTOCOMPLETE_NOTE);
2482
+ }
2389
2483
  function appendToolResultText(res, extra) {
2390
2484
  const idx = res?.content?.findIndex((c) => c.type === "text") ?? -1;
2391
2485
  const block = idx >= 0 ? res.content[idx] : undefined;
@@ -2395,6 +2489,62 @@ function appendToolResultText(res, extra) {
2395
2489
  content[idx] = { type: "text", text: `${block.text}${extra}` };
2396
2490
  return { ...res, content };
2397
2491
  }
2492
+ // ---- #1695: bound the panel_set_widget previous/new echo --------------------
2493
+ //
2494
+ // Code-mode clients (Codex `functions.exec`) batch several panel_set_widget
2495
+ // calls into one script, and the OUTER tool result accumulates every nested
2496
+ // reply. Each reply echoes the previous and new widget values verbatim; two
2497
+ // long CLIPTextEncode prompts per call push the aggregate past the client's
2498
+ // tool-result budget ("Output exceeded the available model context and was
2499
+ // truncated") even though every mutation succeeded — and the truncation can
2500
+ // hide exactly the per-call status the caller needs. The echo is confirmation,
2501
+ // not transport (a read-back belongs to panel_query_graph), so a long string
2502
+ // value is summarized server-side as { chars, sha256, preview }: the hash keeps
2503
+ // the confirmation verifiable, the preview keeps it legible, and `echo: "full"`
2504
+ // opts back into the verbatim reply.
2505
+ //
2506
+ // Server-side on purpose — the echo is produced by the panel frontend, and an
2507
+ // old panel keeps echoing verbatim no matter what a new one would do (the same
2508
+ // reason fitQueryGraphReply re-fits here, #807).
2509
+ const SET_WIDGET_ECHO_SUMMARY_THRESHOLD = 1000;
2510
+ const SET_WIDGET_ECHO_PREVIEW_CHARS = 160;
2511
+ function summarizeSetWidgetEcho(res, full) {
2512
+ if (full || res.isError)
2513
+ return res;
2514
+ const payload = parseToolResultJson(res);
2515
+ const set = payload?.set;
2516
+ if (!payload || !set || typeof set !== "object" || Array.isArray(set))
2517
+ return res;
2518
+ const record = set;
2519
+ const summarize = (v) => {
2520
+ if (typeof v !== "string" || v.length <= SET_WIDGET_ECHO_SUMMARY_THRESHOLD)
2521
+ return v;
2522
+ return {
2523
+ chars: v.length,
2524
+ sha256: createHash("sha256").update(v, "utf8").digest("hex"),
2525
+ preview: `${v.slice(0, SET_WIDGET_ECHO_PREVIEW_CHARS)}…`,
2526
+ };
2527
+ };
2528
+ const previous = summarize(record.previous);
2529
+ const value = summarize(record.value);
2530
+ // Nothing crossed the threshold — return the reply untouched rather than
2531
+ // dressing up an unfitted payload as a fitted one.
2532
+ if (previous === record.previous && value === record.value)
2533
+ return res;
2534
+ const idx = res.content.findIndex((c) => c.type === "text");
2535
+ if (idx < 0)
2536
+ return res;
2537
+ const fitted = {
2538
+ ...payload,
2539
+ set: { ...record, previous, value },
2540
+ echo: "summary",
2541
+ echo_full: 'Long string values were summarized as {chars, sha256, preview} so batched calls do not overflow the outer tool result; the mutation was applied. Re-call with echo: "full" for the verbatim strings, or read the live value back with panel_query_graph.',
2542
+ };
2543
+ return {
2544
+ ...res,
2545
+ content: res.content.map((c, i) => i === idx && c.type === "text" ? { ...c, text: JSON.stringify(fitted, null, 2) } : c),
2546
+ };
2547
+ }
2398
2548
  // ---- #809: turn the panel's silent `truncated: true` booleans into a remedy --------
2399
2549
  //
2400
2550
  // A bare boolean is the WORST truncation signal there is: it is a field, not prose, so
@@ -3586,6 +3736,79 @@ function identityClaimedContentUnverifiedNote(detail) {
3586
3736
  `expecting the opened file. Retry the graph read (panel_graph_outline) once the tab answers.` +
3587
3737
  busy);
3588
3738
  }
3739
+ /**
3740
+ * #1710 — restamp extra.comfyui_mcp onto the dest the tab is already bound to,
3741
+ * and copy dest-file promoted widgets onto any live empty slots.
3742
+ *
3743
+ * Fail-closed: dest file unreadable, node-set mismatch, or a non-empty live
3744
+ * widget that disagrees with dest (#1639 previous graph) leaves extra alone.
3745
+ */
3746
+ async function tryRebindConfirmedOpenIdentity(ctx, destPath) {
3747
+ let live = null;
3748
+ try {
3749
+ const serialized = await ctx.call({ cmd: "graph_serialize" }, 8000);
3750
+ live = workflowFromSerializeReply(parseToolResultJson(serialized));
3751
+ }
3752
+ catch {
3753
+ return { status: "skipped" };
3754
+ }
3755
+ if (!live)
3756
+ return { status: "skipped" };
3757
+ let dest;
3758
+ try {
3759
+ dest = await readWorkflowFromPath(destPath);
3760
+ }
3761
+ catch {
3762
+ return { status: "skipped" };
3763
+ }
3764
+ if (!shouldRebindOpenIdentity({ live, dest, destPath }))
3765
+ return { status: "skipped" };
3766
+ const destUuid = await activeWorkflowUuidForPath(ctx, destPath);
3767
+ const { graph, restoredWidgets } = patchOpenIdentity(live, dest, destPath, destUuid);
3768
+ const loaded = await ctx.call({ cmd: "graph_load", graph }, 30000);
3769
+ if (loaded.isError)
3770
+ return { status: "skipped" };
3771
+ if (destUuid)
3772
+ refreshWorkflowUuid(ctx, { workflow_uuid: destUuid });
3773
+ return { status: "rebound", destPath, destUuid, restoredWidgets };
3774
+ }
3775
+ async function activeWorkflowUuidForPath(ctx, destPath) {
3776
+ try {
3777
+ const list = parseToolResultJson(await ctx.call({ cmd: "workflow_list" }, 6000));
3778
+ if (!list)
3779
+ return undefined;
3780
+ if (readOpenActiveAgainstTarget(list.active, destPath, list.active_confirmed) !== "same") {
3781
+ return undefined;
3782
+ }
3783
+ return responseWorkflowUuid(list.active);
3784
+ }
3785
+ catch {
3786
+ return undefined;
3787
+ }
3788
+ }
3789
+ async function activeSavedPath(ctx) {
3790
+ try {
3791
+ const list = parseToolResultJson(await ctx.call({ cmd: "workflow_list" }, 6000));
3792
+ const path = isPlainObject(list?.active) ? list.active.path : undefined;
3793
+ return typeof path === "string" && path ? path : undefined;
3794
+ }
3795
+ catch {
3796
+ return undefined;
3797
+ }
3798
+ }
3799
+ function openedIdentityReboundResult(destPath, rebound) {
3800
+ return ok({
3801
+ opened: { path: destPath },
3802
+ identity_rebound: true,
3803
+ restored_promoted_widgets: rebound.restoredWidgets,
3804
+ note: `Opened ${destPath}. Frontend-normalized presentation/widget fields differed after load; ` +
3805
+ `extra.comfyui_mcp.workflow_path/workflow_uuid were rebound to this workflow so a later ` +
3806
+ `save will not refuse a source-stamp mismatch.` +
3807
+ (rebound.restoredWidgets > 0
3808
+ ? ` Restored ${rebound.restoredWidgets} empty promoted widget value(s) from the saved graph.`
3809
+ : ""),
3810
+ });
3811
+ }
3589
3812
  function identityClaimedButLiveGraphUnchangedNote(ctx) {
3590
3813
  const fence = currentWorkflowFence(ctx);
3591
3814
  const fenceTxt = fence.known && fence.uuid
@@ -3600,7 +3823,7 @@ function identityClaimedButLiveGraphUnchangedNote(ctx) {
3600
3823
  `Read the graph (panel_graph_outline) to see what is actually open, then retry ` +
3601
3824
  `panel_open_workflow or panel_load_workflow if the canvas did not switch.`);
3602
3825
  }
3603
- async function clearFenceOnIdentityProvenOpen(ctx, res) {
3826
+ async function clearFenceOnIdentityProvenOpen(ctx, res, requestedPath) {
3604
3827
  const text = toolResultText(res);
3605
3828
  // ONLY the class that states identity was proven. The UNPROVEN verdict ("could not
3606
3829
  // prove that the active canvas was rebound") must keep failing closed: re-deriving a
@@ -3609,6 +3832,15 @@ async function clearFenceOnIdentityProvenOpen(ctx, res) {
3609
3832
  return { res, repaired: false };
3610
3833
  const canvas = await probeLiveGraphUnderCurrentFence(ctx);
3611
3834
  if (canvas.status === "answered") {
3835
+ // #1710 — the old stamp still answering is ALSO what a save-as copy looks
3836
+ // like: dest tab, dest nodes, extra.comfyui_mcp still naming the source.
3837
+ // Confirm dest file content before touching extra (#1639 stays fail-closed).
3838
+ if (requestedPath) {
3839
+ const rebound = await tryRebindConfirmedOpenIdentity(ctx, requestedPath);
3840
+ if (rebound.status === "rebound") {
3841
+ return { res: openedIdentityReboundResult(requestedPath, rebound), repaired: true };
3842
+ }
3843
+ }
3612
3844
  return { res: appendToolResultText(res, identityClaimedButLiveGraphUnchangedNote(ctx)), repaired: false };
3613
3845
  }
3614
3846
  if (canvas.status === "unanswered") {
@@ -3793,6 +4025,20 @@ function responseWorkflowUuid(value) {
3793
4025
  return WORKFLOW_UUID_RE.test(raw) ? raw : undefined;
3794
4026
  }
3795
4027
  /** Refresh only the bridge-owned command stamp, never caller data. */
4028
+ /** #1656 — tell the orchestrator that a live read confirmed the routed tab's CURRENT
4029
+ * stamp, so a value inherited on a same-socket re-hello stops being treated as
4030
+ * inherited. Never throws, and can never move a fence (see the bridge method). */
4031
+ function corroborateTabStamp(ctx, workflowUuid) {
4032
+ const fn = ctx.bridge.corroborateTabStamp;
4033
+ if (typeof fn !== "function")
4034
+ return false;
4035
+ try {
4036
+ return fn.call(ctx.bridge, ctx.tabId, workflowUuid) === true;
4037
+ }
4038
+ catch {
4039
+ return false;
4040
+ }
4041
+ }
3796
4042
  function refreshWorkflowUuid(ctx, value) {
3797
4043
  const uuid = responseWorkflowUuid(value);
3798
4044
  const refresh = ctx.bridge.refreshWorkflowUuid;
@@ -4295,8 +4541,29 @@ async function rebindWorkflowFence(ctx, opts) {
4295
4541
  // "already current" requires a KNOWN prior fence equal to the live uuid. An
4296
4542
  // UNKNOWN prior fence must not short-circuit the adoption: we would be claiming
4297
4543
  // the stamp already matched without ever having read it.
4298
- if (before.known && before.uuid === uuid)
4544
+ if (before.known && before.uuid === uuid) {
4545
+ // #1656 — THE FENCE DOES NOT MOVE HERE, BUT THE EVIDENCE DOES.
4546
+ //
4547
+ // Reaching this line means the panel's own corroborated workflow_list says the
4548
+ // LIVE canvas is this very instance. If the routed tab's stamp was one CARRIED
4549
+ // onto a new tab id by a same-socket re-hello that could not resolve an identity,
4550
+ // that carried value has now been confirmed by an observation of the tab under its
4551
+ // CURRENT id — so the dispatch-time agreement gate must stop treating it as
4552
+ // inherited. Without this, a rename/save whose hello merely RACED the canvas
4553
+ // identity would leave the session refusing edits to a canvas the panel agrees is
4554
+ // the right one: the fix would be worse than the bug.
4555
+ //
4556
+ // This is NOT the retarget #1646 removed, and deliberately not `refreshWorkflowUuid`
4557
+ // either — that one WRITES a stamp (and a conversation's issue-time stamp with it).
4558
+ // `corroborateTabStamp` can only promote the provenance of a uuid the orchestrator
4559
+ // has itself confirmed already equals the tab's current stamp; it writes no stamp and
4560
+ // refuses outright when the values differ, so no target changes and no later edit is
4561
+ // re-aimed. The uuid still goes through the orchestrator's identity validator, never
4562
+ // off parsed prose, and the result is ignored: this is corroboration, and a diagnostic
4563
+ // must never replace the outcome it is describing.
4564
+ corroborateTabStamp(ctx, uuid);
4299
4565
  return { status: "already_current", uuid, before };
4566
+ }
4300
4567
  // #1646 — a READ-ONLY probe never moves the fence: the live canvas naming a
4301
4568
  // DIFFERENT workflow is reported, not adopted. Only a deliberate rebind
4302
4569
  // (panel_set_workflow_target, open/new) may replace the fence — a mismatch
@@ -5051,7 +5318,7 @@ async function openWorkflowWithVerify(path, ctx) {
5051
5318
  return res;
5052
5319
  const probe = await observeActiveAfterOpen(ctx, path);
5053
5320
  if (probe.status !== "different") {
5054
- const cleared = await clearFenceOnIdentityProvenOpen(ctx, res);
5321
+ const cleared = await clearFenceOnIdentityProvenOpen(ctx, res, path);
5055
5322
  // #1560 — the probe's SILENCE, reported. Suppressed when the fence was in fact
5056
5323
  // repaired: that re-derivation is itself a `workflow_list` round trip, so a
5057
5324
  // success there proves the channel answered and the note would contradict the
@@ -6769,7 +7036,47 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets) {
6769
7036
  //
6770
7037
  // Safe to recommend a retry because a fence refusal is checked BEFORE the handler
6771
7038
  // runs — "Nothing was applied" is structural here, not an echoed claim.
6772
- if (isWorkflowInstanceMismatch(err) && isMutatingGraphCmd(cmd)) {
7039
+ //
7040
+ // #1778 — the same refusal on workflow_save / save_as / rename / close. Those
7041
+ // carry a workflow target but are not graph_*, so neither isMutatingGraphCmd
7042
+ // nor isFencedGraphRead was true and the call fell through to the generic
7043
+ // dispatched:false wrapper: no probe, no corroborateTabStamp, and a
7044
+ // "tab may be disconnected" line that is false here (the tab is connected;
7045
+ // what is stale is the stamp). Widening this branch is the self-heal.
7046
+ //
7047
+ // THE PHRASE IS NOT THE STATE, AND TWO OTHER REFUSALS QUOTE IT.
7048
+ //
7049
+ // `isWorkflowInstanceMismatch` is an unanchored `/workflow instance mismatch/i`,
7050
+ // and the fence is not the only thing that says those words. The bridge's
7051
+ // `"<cmd>" cannot be safely targeted to the active workflow` reject quotes them
7052
+ // inside its `readsNote` — to describe what a DIFFERENT command would get — and
7053
+ // carries the second look-alike, `no trusted identity`, in its `why` clause.
7054
+ // Both are minted pre-dispatch and flagged `dispatched:false`, so both satisfy
7055
+ // the phrase match on their way through here.
7056
+ //
7057
+ // For a mutating graph edit that never surfaced, by ORDERING rather than by
7058
+ // design: the #1401 branch catches `no trusted identity` for `isMutatingGraphCmd`
7059
+ // above. The four active-workflow mutators have no branch above them, so the
7060
+ // widening alone handed each of these two states the OTHER's remedy — measured,
7061
+ // not reasoned. A capability refusal ending "Update the panel and hard-refresh
7062
+ // the browser tab" came back with "Retry once — … panel_open_workflow is the way
7063
+ // to settle which one you mean" appended to it: the futile retry #709 exists to
7064
+ // suppress, contradicting the sentence directly above it, plus a wasted
7065
+ // workflow_list round trip. And a fence-less session got #1330's "the fence
7066
+ // already names the live canvas" for the state whose own docstring says "a
7067
+ // mismatch may clear by itself, this never does".
7068
+ //
7069
+ // So the arm carries the discriminators the graph arm gets from ordering. Both
7070
+ // are TYPED-or-explicit, never a second reading of the same prose:
7071
+ // `isCapabilityRefusal` is the bridge's own symbol marker, and
7072
+ // `isNoTrustedIdentityRefusal` matches a distinct phrase a mismatch refusal never
7073
+ // contains. Scoped to THIS arm on purpose — `isMutatingGraphCmd` keeps its exact
7074
+ // prior behaviour, so nothing about the graph path moves here.
7075
+ if (isWorkflowInstanceMismatch(err) &&
7076
+ (isMutatingGraphCmd(cmd) ||
7077
+ (isFencedActiveWorkflowMutator(cmd) &&
7078
+ !isCapabilityRefusal(err) &&
7079
+ !isNoTrustedIdentityRefusal(err)))) {
6773
7080
  const name = typeof cmd.cmd === "string" ? cmd.cmd : "panel command";
6774
7081
  const raw = err instanceof Error ? err.message : String(err);
6775
7082
  // The uuid the command CARRIED, read from the panel's own refusal rather than
@@ -8788,7 +9095,7 @@ export function buildPanelToolDefs() {
8788
9095
  },
8789
9096
  },
8790
9097
  ])),
8791
- 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. ADD NODES ONE AT A TIME, not as a parallel batch: each add carries a fresh /object_info payload and those register SERIALLY, so N concurrent adds become N sequential refresh cycles. On a large install that outruns the 30s per-command deadline and the later adds time out WHILE STILL QUEUED — they then apply when their turn arrives, leaving nodes you were told had failed (panel#767). Sequential adds each get the refresh to themselves and stay well inside the deadline.", {
9098
+ 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. ComfyUI-LoRA-Manager's 'Lora Loader (LoraManager)' (and other AUTOCOMPLETE_TEXT_* nodes) cannot be added: the pack is healthy, but the add-node guard cannot see its Vue autocomplete widget. Use 'LoRA Text Loader (LoraManager)' — same outputs, lora_syntax is a STRING — or core LoraLoader; reload/retry will not clear it. ADD NODES ONE AT A TIME, not as a parallel batch: each add carries a fresh /object_info payload and those register SERIALLY, so N concurrent adds become N sequential refresh cycles. On a large install that outruns the 30s per-command deadline and the later adds time out WHILE STILL QUEUED — they then apply when their turn arrives, leaving nodes you were told had failed (panel#767). Sequential adds each get the refresh to themselves and stay well inside the deadline.", {
8792
9099
  class_type: z.string().describe("Exact ComfyUI node class_type to create."),
8793
9100
  pos: xy()
8794
9101
  .optional()
@@ -8802,7 +9109,7 @@ export function buildPanelToolDefs() {
8802
9109
  const add = () => ctx.call({ cmd: "graph_add_node", class_type: args.class_type, pos: args.pos, title: args.title }, OBJECT_INFO_REFRESH_ACK_TIMEOUT_MS);
8803
9110
  const first = await add();
8804
9111
  if (!isStaleNodeSchemaRefusal(first))
8805
- return first;
9112
+ return withLoraManagerAutocompleteNote(first);
8806
9113
  // #1329 — DO THE REFRESH THE REFUSAL ASKS FOR, instead of billing the caller.
8807
9114
  //
8808
9115
  // The reporter uploaded two images, and both LoadImage adds were refused with
@@ -8820,19 +9127,19 @@ export function buildPanelToolDefs() {
8820
9127
  // The refusal is the better error: it names what is wrong with the SCHEMA and
8821
9128
  // what clears it. Carry the refresh failure alongside so the caller knows the
8822
9129
  // automatic attempt happened and why it did not help.
8823
- return appendToolResultText(first, `\n\n(Tried to clear this automatically: panel_refresh_nodes was dispatched and FAILED, ` +
9130
+ return withLoraManagerAutocompleteNote(appendToolResultText(first, `\n\n(Tried to clear this automatically: panel_refresh_nodes was dispatched and FAILED, ` +
8824
9131
  `so the schema is unchanged and retrying the add will refuse again. ` +
8825
- `${textOfToolResult(refreshed)})`);
9132
+ `${textOfToolResult(refreshed)})`));
8826
9133
  }
8827
9134
  const second = await add();
8828
9135
  if (!isStaleNodeSchemaRefusal(second))
8829
- return second;
9136
+ return withLoraManagerAutocompleteNote(second);
8830
9137
  // Still stale after a successful refresh: report THAT, because it means the
8831
9138
  // remedy the refusal prescribes does not fix this instance and a caller
8832
9139
  // following it by hand would loop.
8833
- return appendToolResultText(second, `\n\n(This was already retried ONCE automatically: panel_refresh_nodes reported success ` +
9140
+ return withLoraManagerAutocompleteNote(appendToolResultText(second, `\n\n(This was already retried ONCE automatically: panel_refresh_nodes reported success ` +
8834
9141
  `and the add still refuses, so repeating panel_refresh_nodes will not clear it. ` +
8835
- `Reload the ComfyUI browser tab, which rebuilds the page's node registry from scratch.)`);
9142
+ `Reload the ComfyUI browser tab, which rebuilds the page's node registry from scratch.)`));
8836
9143
  }),
8837
9144
  def("panel_remove_node", "Remove a node (and its connections) from the user's open graph by id. Undoable with Ctrl+Z.", { node_id: nodeId().describe("Node id from panel_graph_outline / panel_query_graph.") }, async (args, ctx) => ctx.call({ cmd: "graph_remove_node", node_id: args.node_id })),
8838
9145
  def("panel_clear", "Remove EVERY node from the user's open graph — only for an explicit 'clear/reset the canvas'. Just CALL THIS DIRECTLY when they ask to clear: the tool itself pops a confirm card and only wipes on a yes (don't ask separately first). The wipe is a single Ctrl+Z undo. NEVER use this for a 'new workflow' — that's panel_new_workflow (a new tab, leaves this graph intact).", {}, async (_args, ctx) => {
@@ -9227,7 +9534,7 @@ export function buildPanelToolDefs() {
9227
9534
  node_id: nodeId().describe("Node id whose input to disconnect."),
9228
9535
  input: slotRef.optional().describe("Input slot name or index (default 0)."),
9229
9536
  }, async (args, ctx) => ctx.call({ cmd: "graph_disconnect", node_id: args.node_id, input: args.input })),
9230
- def("panel_set_widget", "Set a widget value on a node in the user's open graph (steps, cfg, seed, ckpt_name, text prompts, …). Returns the previous and new value. Undoable with Ctrl+Z. To CLEAR a text widget to an empty string, pass `clear: true` (some MCP clients drop an empty-string `value` from the serialized payload, so `value: \"\"` may not arrive — `clear: true` always works). For the LTXDirector timeline node (WhatDreamsCost CSGlide), set `timeline_data` with the FULL timeline JSON (segments + global_prompt) to drive its custom timeline UI — this re-syncs the editor and regenerates its derived `local_prompts`/`segment_lengths`/`guide_strength` widgets; setting those derived widgets directly is refused (they are silently reverted).", {
9537
+ def("panel_set_widget", "Set a widget value on a node in the user's open graph (steps, cfg, seed, ckpt_name, text prompts, …). Returns the previous and new value (a string longer than 1000 chars is echoed as {chars, sha256, preview} so batched calls stay inside the outer tool-result budget — pass `echo: \"full\"` for the verbatim string). Undoable with Ctrl+Z. To CLEAR a text widget to an empty string, pass `clear: true` (some MCP clients drop an empty-string `value` from the serialized payload, so `value: \"\"` may not arrive — `clear: true` always works). For the LTXDirector timeline node (WhatDreamsCost CSGlide), set `timeline_data` with the FULL timeline JSON (segments + global_prompt) to drive its custom timeline UI — this re-syncs the editor and regenerates its derived `local_prompts`/`segment_lengths`/`guide_strength` widgets; setting those derived widgets directly is refused (they are silently reverted).", {
9231
9538
  node_id: nodeId().describe("Node id from panel_graph_outline / panel_query_graph."),
9232
9539
  widget: z.string().describe("Widget name (e.g. 'steps', 'cfg', 'text')."),
9233
9540
  value: z
@@ -9238,6 +9545,10 @@ export function buildPanelToolDefs() {
9238
9545
  .boolean()
9239
9546
  .optional()
9240
9547
  .describe("Set true to clear the widget to an empty string (\"\"). Escape hatch for when a client cannot carry an empty-string `value` through tool-arg JSON. Overrides `value`."),
9548
+ echo: z
9549
+ .enum(["summary", "full"])
9550
+ .optional()
9551
+ .describe("How to echo the previous/new values back. 'summary' (default) replaces a string longer than 1000 chars with {chars, sha256, preview} — the mutation is applied either way. 'full' returns the verbatim strings."),
9241
9552
  }, async (args, ctx) => {
9242
9553
  // Distinguish "value present but empty" from "value absent" by key
9243
9554
  // presence, NOT a truthiness check — an empty string is a legitimate
@@ -9251,7 +9562,14 @@ export function buildPanelToolDefs() {
9251
9562
  // a single revalidation, #338/#458) — that authoritative fetch can outlast
9252
9563
  // the 6000 ms default ack on a large install and return a FALSE timeout.
9253
9564
  // Give the guarded write the bounded refresh ack budget.
9254
- const write = (nodeId, widget) => ctx.call({ cmd: "graph_set_widget", node_id: nodeId, widget, value }, OBJECT_INFO_REFRESH_ACK_TIMEOUT_MS);
9565
+ // #1695: summarize the previous/new echo of every successful write
9566
+ // (direct, remapped, or inner-subgraph) before anything else touches
9567
+ // it — long prompts batched in one code-mode script otherwise overflow
9568
+ // the outer tool result even though the mutations succeeded. Applied
9569
+ // to the raw success reply so a later appendToolResultText disclosure
9570
+ // (which makes the text no longer parse as JSON) cannot dodge it.
9571
+ const echoFull = args.echo === "full";
9572
+ const write = async (nodeId, widget) => summarizeSetWidgetEcho(await ctx.call({ cmd: "graph_set_widget", node_id: nodeId, widget, value }, OBJECT_INFO_REFRESH_ACK_TIMEOUT_MS), echoFull);
9255
9573
  const first = await write(args.node_id, args.widget);
9256
9574
  if (!first.isError)
9257
9575
  return first;
@@ -9871,6 +10189,13 @@ export function buildPanelToolDefs() {
9871
10189
  }
9872
10190
  }
9873
10191
  const scope = args.scope ?? "orchestrator";
10192
+ // #1700 — the frontend soft_reload only drops the websocket (this pack's
10193
+ // /reload is a 503). The live agent keeps the MCP set it was spawned
10194
+ // with, and a plain resume restores the session-recorded set anyway.
10195
+ // Schedule a forked in-process respawn so makeMcpServers() re-reads
10196
+ // ~/.claude.json and the replacement session is NOT the old MCP set.
10197
+ if (scope === "orchestrator")
10198
+ applyMcpReload?.(ctx.tabId);
9874
10199
  const res = await ctx.call({ cmd: "soft_reload", scope }, 15000);
9875
10200
  // #765 — the agent reads this result as its last context before the
9876
10201
  // reload, and again after the resume, so it is the place to state what
@@ -10599,9 +10924,23 @@ export function buildPanelToolDefs() {
10599
10924
  if (ctx.awaitReachable && !(await ctx.awaitReachable())) {
10600
10925
  return noReachableTabFail(args.name ? "workflow_save_as" : "workflow_save", ctx);
10601
10926
  }
10602
- const res = args.name
10603
- ? await ctx.call({ cmd: "workflow_save_as", name: args.name }, 15000)
10604
- : await ctx.call({ cmd: "workflow_save" }, 15000);
10927
+ const saveOnce = () => args.name
10928
+ ? ctx.call({ cmd: "workflow_save_as", name: args.name }, 15000)
10929
+ : ctx.call({ cmd: "workflow_save" }, 15000);
10930
+ let res = await saveOnce();
10931
+ // #1710 — dest tab + dest nodes, extra still stamped as the save-as SOURCE.
10932
+ // Rebind extra to the confirmed dest, then retry the same save once.
10933
+ if (res.isError && isStampMismatchSaveRefusal(toolResultText(res))) {
10934
+ const destPath = await activeSavedPath(ctx);
10935
+ if (destPath) {
10936
+ const rebound = await tryRebindConfirmedOpenIdentity(ctx, destPath);
10937
+ if (rebound.status === "rebound") {
10938
+ const retried = await saveOnce();
10939
+ if (!retried.isError)
10940
+ res = retried;
10941
+ }
10942
+ }
10943
+ }
10605
10944
  if (res.isError)
10606
10945
  return res;
10607
10946
  // #1045 — a SAVE-AS replaces the active workflow instance: the canvas is
@@ -11040,19 +11379,50 @@ export function buildPanelToolDefs() {
11040
11379
  // known-negative write case is stated rather than papered over.
11041
11380
  let probeRefused;
11042
11381
  let probeOk = false;
11043
- try {
11044
- // The cheapest fenced read there is: ids only, one row. It is refused by the
11045
- // same instance fence every graph command carries, which is exactly the
11046
- // question — a full outline would answer it no better and would cost the caller
11047
- // a page of graph on a recovery path.
11048
- const probe = await ctx.call({ cmd: "graph_query", fields: "ids", limit: 1 }, 8000);
11049
- if (!probe.isError)
11050
- probeOk = true;
11051
- else
11052
- probeRefused = isWorkflowInstanceMismatch(toolResultText(probe));
11382
+ const probeOnce = async () => {
11383
+ probeRefused = undefined;
11384
+ probeOk = false;
11385
+ try {
11386
+ // The cheapest fenced read there is: ids only, one row. It is refused by the
11387
+ // same instance fence every graph command carries, which is exactly the
11388
+ // question — a full outline would answer it no better and would cost the caller
11389
+ // a page of graph on a recovery path.
11390
+ const probe = await ctx.call({ cmd: "graph_query", fields: "ids", limit: 1 }, 8000);
11391
+ if (!probe.isError)
11392
+ probeOk = true;
11393
+ else
11394
+ probeRefused = isWorkflowInstanceMismatch(toolResultText(probe));
11395
+ }
11396
+ catch (err) {
11397
+ probeRefused = isWorkflowInstanceMismatch(err);
11398
+ }
11399
+ };
11400
+ await probeOnce();
11401
+ // #1703 — a passing graph_query is not evidence the NEXT graph command
11402
+ // will route. After a restart the scope pin can still name the OLD
11403
+ // wf:<route>:<path> while the reconnect has already hellod under a new
11404
+ // one; ensureReachable will not move a scope-bound ctx, so claiming
11405
+ // "no recovery needed" stranded the following panel_graph_outline with
11406
+ // "no connected tab". Confirm the live route; if it is dead, take the
11407
+ // same explicit rebind the caller would get from repeating this tool,
11408
+ // and only then re-probe on the tab the next command will use.
11409
+ if (probeOk && !sessionRouteIsLive(ctx)) {
11410
+ try {
11411
+ ctx.rebindToActiveTab?.({ scopeRecoveryConsent: true });
11412
+ }
11413
+ catch {
11414
+ // a failed rebind is not a fence verdict
11415
+ }
11416
+ if (sessionRouteIsLive(ctx))
11417
+ await probeOnce();
11418
+ else {
11419
+ probeOk = false;
11420
+ probeRefused = undefined;
11421
+ }
11053
11422
  }
11054
- catch (err) {
11055
- probeRefused = isWorkflowInstanceMismatch(err);
11423
+ if (probeOk && !sessionRouteIsLive(ctx)) {
11424
+ probeOk = false;
11425
+ probeRefused = undefined;
11056
11426
  }
11057
11427
  if (probeOk) {
11058
11428
  probePassed = true;
@@ -11476,7 +11846,15 @@ CHECKED FOR YOU: the graph read this message prescribes was just run, and it ` +
11476
11846
  assertPanelNotTargetedUnverifiable("panel_update_node", args.id);
11477
11847
  return ctx.call({ cmd: "graph_update_node", id: args.id, version: args.version, channel: args.channel, mode: args.mode }, 30000);
11478
11848
  }),
11479
- 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)),
11849
+ def("panel_node_queue_status", "Check the built-in Manager's install/update queue status (to see if a queued install finished). Read-only. " +
11850
+ "A drained queue (total_count: 0, is_processing: false) only means THIS queue is idle — " +
11851
+ "it does not include apply_manifest custom_nodes that were never submitted (#1699). " +
11852
+ "When apply_manifest returned a PARTIAL INSTALL, this tool names the leftover entries and " +
11853
+ "sets queue_complete_for_apply_manifest:false; do not restart ComfyUI until those are " +
11854
+ "submitted (re-run apply_manifest).", {}, async (_args, ctx) => {
11855
+ const res = await ctx.call({ cmd: "nodes_queue_status" }, 20000);
11856
+ return annotateQueueStatusWithManifestPartial(res);
11857
+ }),
11480
11858
  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. If a crash takes the panel bridge offline so the confirmation card cannot be shown, this tool falls back to a headless restart of the configured local process (or COMFYUI_RESTART_COMMAND) instead of depending on the dead bridge — it still refuses a readable busy queue without force:true, and still refuses when a relaunch cannot be proven. 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, or set COMFYUI_RESTART_COMMAND to the exact command that restarts the instance (e.g. `docker restart <container>`): the restart then runs through that command (the busy guard above still applies) instead of needing the launch path.", { force: z.boolean().optional() }, async ({ force }, ctx) => {
11481
11859
  // Whole-handler budget (#536): confirm + dispatch + readiness — INCLUDING
11482
11860
  // the legacy path's UNPREEMPTIBLE synchronous execSync blocks — must ALL finish