comfyui-mcp 0.48.21 → 0.48.23

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 (252) hide show
  1. package/README.md +2 -2
  2. package/dist/comfyui/client.js +46 -6
  3. package/dist/comfyui/client.js.map +1 -1
  4. package/dist/config.js +15 -0
  5. package/dist/config.js.map +1 -1
  6. package/dist/orchestrator/error-text.js +29 -1
  7. package/dist/orchestrator/error-text.js.map +1 -1
  8. package/dist/orchestrator/error-text.test.js +28 -0
  9. package/dist/orchestrator/error-text.test.js.map +1 -1
  10. package/dist/orchestrator/index.js +331 -56
  11. package/dist/orchestrator/index.js.map +1 -1
  12. package/dist/orchestrator/panel-agent.js +122 -31
  13. package/dist/orchestrator/panel-agent.js.map +1 -1
  14. package/dist/orchestrator/panel-tools.js +1256 -198
  15. package/dist/orchestrator/panel-tools.js.map +1 -1
  16. package/dist/orchestrator/ready-banner.js +70 -0
  17. package/dist/orchestrator/ready-banner.js.map +1 -0
  18. package/dist/orchestrator/session-store.js +163 -28
  19. package/dist/orchestrator/session-store.js.map +1 -1
  20. package/dist/services/api-nodes.js +5 -7
  21. package/dist/services/api-nodes.js.map +1 -1
  22. package/dist/services/download-cache.js +126 -18
  23. package/dist/services/download-cache.js.map +1 -1
  24. package/dist/services/download-jobs.js +463 -14
  25. package/dist/services/download-jobs.js.map +1 -1
  26. package/dist/services/download-progress.js +252 -3
  27. package/dist/services/download-progress.js.map +1 -1
  28. package/dist/services/env-capabilities.js +80 -9
  29. package/dist/services/env-capabilities.js.map +1 -1
  30. package/dist/services/generate-audio.js +16 -0
  31. package/dist/services/generate-audio.js.map +1 -1
  32. package/dist/services/local-models-fallback.js +7 -3
  33. package/dist/services/local-models-fallback.js.map +1 -1
  34. package/dist/services/manifest.js +180 -65
  35. package/dist/services/manifest.js.map +1 -1
  36. package/dist/services/model-resolver.js +267 -11
  37. package/dist/services/model-resolver.js.map +1 -1
  38. package/dist/services/node-management.js +179 -45
  39. package/dist/services/node-management.js.map +1 -1
  40. package/dist/services/output-dir.js +33 -3
  41. package/dist/services/output-dir.js.map +1 -1
  42. package/dist/services/panel-secrets.js +19 -7
  43. package/dist/services/panel-secrets.js.map +1 -1
  44. package/dist/services/process-control.js +107 -8
  45. package/dist/services/process-control.js.map +1 -1
  46. package/dist/services/queue-monitor.js +45 -2
  47. package/dist/services/queue-monitor.js.map +1 -1
  48. package/dist/services/queue-monitor.stall.test.js +78 -0
  49. package/dist/services/queue-monitor.stall.test.js.map +1 -0
  50. package/dist/services/secure-bridge.js +84 -0
  51. package/dist/services/secure-bridge.js.map +1 -1
  52. package/dist/services/storage/azure-blob.js +8 -3
  53. package/dist/services/storage/azure-blob.js.map +1 -1
  54. package/dist/services/storage/index.js +3 -3
  55. package/dist/services/storage/index.js.map +1 -1
  56. package/dist/services/storage/s3.js +9 -3
  57. package/dist/services/storage/s3.js.map +1 -1
  58. package/dist/services/ui-bridge.js +341 -24
  59. package/dist/services/ui-bridge.js.map +1 -1
  60. package/dist/services/workflow-converter.js +767 -72
  61. package/dist/services/workflow-converter.js.map +1 -1
  62. package/dist/services/workflow-deps.js +20 -1
  63. package/dist/services/workflow-deps.js.map +1 -1
  64. package/dist/services/workflow-lock.js +127 -27
  65. package/dist/services/workflow-lock.js.map +1 -1
  66. package/dist/services/workflow-target-store.js +7 -3
  67. package/dist/services/workflow-target-store.js.map +1 -1
  68. package/dist/services/workspace-env.js +48 -0
  69. package/dist/services/workspace-env.js.map +1 -1
  70. package/dist/tools/diagnostics.js +10 -1
  71. package/dist/tools/diagnostics.js.map +1 -1
  72. package/dist/tools/generate-audio.js +44 -0
  73. package/dist/tools/generate-audio.js.map +1 -1
  74. package/dist/tools/model-explorer.js +85 -5
  75. package/dist/tools/model-explorer.js.map +1 -1
  76. package/dist/tools/model-extras.js +24 -4
  77. package/dist/tools/model-extras.js.map +1 -1
  78. package/dist/tools/model-management.js +124 -14
  79. package/dist/tools/model-management.js.map +1 -1
  80. package/dist/tools/report-issue.js +220 -66
  81. package/dist/tools/report-issue.js.map +1 -1
  82. package/dist/tools/report-issue.test.js +223 -83
  83. package/dist/tools/report-issue.test.js.map +1 -1
  84. package/dist/tools/skills-access.js +7 -2
  85. package/dist/tools/skills-access.js.map +1 -1
  86. package/dist/tools/template-schema.js +5 -5
  87. package/dist/tools/template-schema.js.map +1 -1
  88. package/dist/tools/workflow-compose.js +18 -3
  89. package/dist/tools/workflow-compose.js.map +1 -1
  90. package/dist/tools/workflow-visualize.js +1 -1
  91. package/dist/tools/workflow-visualize.js.map +1 -1
  92. package/package.json +1 -1
  93. package/packs/anima/install-runpod.sh +0 -1
  94. package/packs/anima/install-windows.bat +0 -1
  95. package/packs/anima/manifest.yaml +0 -1
  96. package/packs/anima-img2img/install-runpod.sh +0 -1
  97. package/packs/anima-img2img/install-windows.bat +0 -1
  98. package/packs/anima-img2img/manifest.yaml +0 -1
  99. package/packs/anima-inpaint/install-runpod.sh +0 -1
  100. package/packs/anima-inpaint/install-windows.bat +0 -1
  101. package/packs/anima-inpaint/manifest.yaml +0 -1
  102. package/packs/anima-txt2img/install-runpod.sh +0 -1
  103. package/packs/anima-txt2img/install-windows.bat +0 -1
  104. package/packs/anima-txt2img/manifest.yaml +0 -1
  105. package/packs/artokun-flow/install-runpod.sh +0 -1
  106. package/packs/artokun-flow/install-windows.bat +0 -1
  107. package/packs/artokun-flow/manifest.yaml +0 -1
  108. package/packs/ernie/install-runpod.sh +0 -1
  109. package/packs/ernie/install-windows.bat +0 -1
  110. package/packs/ernie/manifest.yaml +0 -1
  111. package/packs/ernie-combo/install-runpod.sh +0 -1
  112. package/packs/ernie-combo/install-windows.bat +0 -1
  113. package/packs/ernie-combo/manifest.yaml +0 -1
  114. package/packs/ernie-img2img/install-runpod.sh +0 -1
  115. package/packs/ernie-img2img/install-windows.bat +0 -1
  116. package/packs/ernie-img2img/manifest.yaml +0 -1
  117. package/packs/ernie-txt2img/install-runpod.sh +0 -1
  118. package/packs/ernie-txt2img/install-windows.bat +0 -1
  119. package/packs/ernie-txt2img/manifest.yaml +0 -1
  120. package/packs/ideogram/install-runpod.sh +0 -1
  121. package/packs/ideogram/install-windows.bat +0 -1
  122. package/packs/ideogram/manifest.yaml +0 -1
  123. package/packs/ideogram-img2img/install-runpod.sh +0 -1
  124. package/packs/ideogram-img2img/install-windows.bat +0 -1
  125. package/packs/ideogram-img2img/manifest.yaml +0 -1
  126. package/packs/ideogram-txt2img/install-runpod.sh +0 -1
  127. package/packs/ideogram-txt2img/install-windows.bat +0 -1
  128. package/packs/ideogram-txt2img/manifest.yaml +0 -1
  129. package/packs/ltx-2.3/install-runpod.sh +0 -1
  130. package/packs/ltx-2.3/install-windows.bat +0 -1
  131. package/packs/ltx-2.3/manifest.yaml +0 -1
  132. package/packs/ltx-2.3-extender/install-runpod.sh +0 -1
  133. package/packs/ltx-2.3-extender/install-windows.bat +0 -1
  134. package/packs/ltx-2.3-extender/manifest.yaml +0 -1
  135. package/packs/ltx-2.3-extender-no-audio/install-runpod.sh +0 -1
  136. package/packs/ltx-2.3-extender-no-audio/install-windows.bat +0 -1
  137. package/packs/ltx-2.3-extender-no-audio/manifest.yaml +0 -1
  138. package/packs/ltx-2.3-flf/install-runpod.sh +0 -1
  139. package/packs/ltx-2.3-flf/install-windows.bat +0 -1
  140. package/packs/ltx-2.3-flf/manifest.yaml +0 -1
  141. package/packs/ltx-2.3-img2vid/install-runpod.sh +0 -1
  142. package/packs/ltx-2.3-img2vid/install-windows.bat +0 -1
  143. package/packs/ltx-2.3-img2vid/manifest.yaml +3 -4
  144. package/packs/ltx-2.3-txt2vid/install-runpod.sh +0 -1
  145. package/packs/ltx-2.3-txt2vid/install-windows.bat +0 -1
  146. package/packs/ltx-2.3-txt2vid/manifest.yaml +3 -4
  147. package/packs/ltx-2.3-xy-plot/install-runpod.sh +0 -1
  148. package/packs/ltx-2.3-xy-plot/install-windows.bat +0 -1
  149. package/packs/ltx-2.3-xy-plot/manifest.yaml +0 -1
  150. package/packs/ltx23-distill-3stage/install-runpod.sh +0 -1
  151. package/packs/ltx23-distill-3stage/install-windows.bat +0 -1
  152. package/packs/ltx23-distill-3stage/manifest.yaml +0 -1
  153. package/packs/qwen-image/install-runpod.sh +0 -1
  154. package/packs/qwen-image/install-windows.bat +0 -1
  155. package/packs/qwen-image/manifest.yaml +0 -1
  156. package/packs/qwen-image-edit/install-runpod.sh +0 -1
  157. package/packs/qwen-image-edit/install-windows.bat +0 -1
  158. package/packs/qwen-image-edit/manifest.yaml +0 -1
  159. package/packs/qwen-image-edit-edit/install-runpod.sh +0 -1
  160. package/packs/qwen-image-edit-edit/install-windows.bat +0 -1
  161. package/packs/qwen-image-edit-edit/manifest.yaml +0 -1
  162. package/packs/wan-animate/install-runpod.sh +0 -1
  163. package/packs/wan-animate/install-windows.bat +0 -1
  164. package/packs/wan-animate/manifest.yaml +0 -1
  165. package/packs/wan-animate-character/install-runpod.sh +0 -1
  166. package/packs/wan-animate-character/install-windows.bat +0 -1
  167. package/packs/wan-animate-character/manifest.yaml +0 -1
  168. package/packs/wan-animate-ofm/install-runpod.sh +0 -1
  169. package/packs/wan-animate-ofm/install-windows.bat +0 -1
  170. package/packs/wan-animate-ofm/manifest.yaml +0 -1
  171. package/packs/wan-longer-videos/install-runpod.sh +0 -1
  172. package/packs/wan-longer-videos/install-windows.bat +0 -1
  173. package/packs/wan-longer-videos/manifest.yaml +0 -1
  174. package/packs/wan-longer-videos-i2v/install-runpod.sh +0 -1
  175. package/packs/wan-longer-videos-i2v/install-windows.bat +0 -1
  176. package/packs/wan-longer-videos-i2v/manifest.yaml +0 -1
  177. package/packs/wan-longer-videos-i2v-96gb/install-runpod.sh +0 -1
  178. package/packs/wan-longer-videos-i2v-96gb/install-windows.bat +0 -1
  179. package/packs/wan-longer-videos-i2v-96gb/manifest.yaml +0 -1
  180. package/packs/wan-longer-videos-t2v/install-runpod.sh +0 -1
  181. package/packs/wan-longer-videos-t2v/install-windows.bat +0 -1
  182. package/packs/wan-longer-videos-t2v/manifest.yaml +0 -1
  183. package/packs/wan-longer-videos-t2v-96gb/install-runpod.sh +0 -1
  184. package/packs/wan-longer-videos-t2v-96gb/install-windows.bat +0 -1
  185. package/packs/wan-longer-videos-t2v-96gb/manifest.yaml +0 -1
  186. package/packs/wan-longer-videos-v2v/install-runpod.sh +0 -1
  187. package/packs/wan-longer-videos-v2v/install-windows.bat +0 -1
  188. package/packs/wan-longer-videos-v2v/manifest.yaml +0 -1
  189. package/packs/wan-longer-videos-v2v-96gb/install-runpod.sh +0 -1
  190. package/packs/wan-longer-videos-v2v-96gb/install-windows.bat +0 -1
  191. package/packs/wan-longer-videos-v2v-96gb/manifest.yaml +0 -1
  192. package/packs/wan-multitalk/install-runpod.sh +0 -1
  193. package/packs/wan-multitalk/install-windows.bat +0 -1
  194. package/packs/wan-multitalk/manifest.yaml +0 -1
  195. package/packs/wan-pusa-extend/install-runpod.sh +0 -1
  196. package/packs/wan-pusa-extend/install-windows.bat +0 -1
  197. package/packs/wan-pusa-extend/manifest.yaml +0 -1
  198. package/packs/wan-transparent/install-runpod.sh +0 -1
  199. package/packs/wan-transparent/install-windows.bat +0 -1
  200. package/packs/wan-transparent/manifest.yaml +0 -1
  201. package/packs/wan-transparent-img2vid/install-runpod.sh +0 -1
  202. package/packs/wan-transparent-img2vid/install-windows.bat +0 -1
  203. package/packs/wan-transparent-img2vid/manifest.yaml +0 -1
  204. package/packs/wan-transparent-img2vid-96gb/install-runpod.sh +0 -1
  205. package/packs/wan-transparent-img2vid-96gb/install-windows.bat +0 -1
  206. package/packs/wan-transparent-img2vid-96gb/manifest.yaml +0 -1
  207. package/packs/z-image-base/install-runpod.sh +0 -1
  208. package/packs/z-image-base/install-windows.bat +0 -1
  209. package/packs/z-image-base/manifest.yaml +0 -1
  210. package/packs/z-image-base-combo/install-runpod.sh +0 -1
  211. package/packs/z-image-base-combo/install-windows.bat +0 -1
  212. package/packs/z-image-base-combo/manifest.yaml +0 -1
  213. package/packs/z-image-base-controlnet/install-runpod.sh +0 -1
  214. package/packs/z-image-base-controlnet/install-windows.bat +0 -1
  215. package/packs/z-image-base-controlnet/manifest.yaml +0 -1
  216. package/packs/z-image-base-img2img/install-runpod.sh +0 -1
  217. package/packs/z-image-base-img2img/install-windows.bat +0 -1
  218. package/packs/z-image-base-img2img/manifest.yaml +0 -1
  219. package/packs/z-image-base-inpaint/install-runpod.sh +0 -1
  220. package/packs/z-image-base-inpaint/install-windows.bat +0 -1
  221. package/packs/z-image-base-inpaint/manifest.yaml +0 -1
  222. package/packs/z-image-base-txt2img/install-runpod.sh +0 -1
  223. package/packs/z-image-base-txt2img/install-windows.bat +0 -1
  224. package/packs/z-image-base-txt2img/manifest.yaml +0 -1
  225. package/packs/z-image-turbo/install-runpod.sh +0 -1
  226. package/packs/z-image-turbo/install-windows.bat +0 -1
  227. package/packs/z-image-turbo/manifest.yaml +0 -1
  228. package/packs/z-image-turbo-combo/install-runpod.sh +0 -1
  229. package/packs/z-image-turbo-combo/install-windows.bat +0 -1
  230. package/packs/z-image-turbo-combo/manifest.yaml +0 -1
  231. package/packs/z-image-turbo-controlnet/install-runpod.sh +0 -1
  232. package/packs/z-image-turbo-controlnet/install-windows.bat +0 -1
  233. package/packs/z-image-turbo-controlnet/manifest.yaml +0 -1
  234. package/packs/z-image-turbo-detail-daemon/install-runpod.sh +0 -1
  235. package/packs/z-image-turbo-detail-daemon/install-windows.bat +0 -1
  236. package/packs/z-image-turbo-detail-daemon/manifest.yaml +0 -1
  237. package/packs/z-image-turbo-img2img/install-runpod.sh +0 -1
  238. package/packs/z-image-turbo-img2img/install-windows.bat +0 -1
  239. package/packs/z-image-turbo-img2img/manifest.yaml +0 -1
  240. package/packs/z-image-turbo-inpainting/install-runpod.sh +0 -1
  241. package/packs/z-image-turbo-inpainting/install-windows.bat +0 -1
  242. package/packs/z-image-turbo-inpainting/manifest.yaml +0 -1
  243. package/packs/z-image-turbo-txt2img/install-runpod.sh +0 -1
  244. package/packs/z-image-turbo-txt2img/install-windows.bat +0 -1
  245. package/packs/z-image-turbo-txt2img/manifest.yaml +0 -1
  246. package/packs/z-image-xy-plot/install-runpod.sh +0 -1
  247. package/packs/z-image-xy-plot/install-windows.bat +0 -1
  248. package/packs/z-image-xy-plot/manifest.yaml +0 -1
  249. package/plugin/skills/flux-txt2img/SKILL.md +9 -4
  250. package/plugin/skills/wan-scail-replacement/SKILL.md +95 -0
  251. package/scripts/sync-agents.mjs +18 -8
  252. package/scripts/test-packs.sh +8 -3
@@ -23,22 +23,24 @@
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
27
  import { existsSync, readFileSync, statSync } from "node:fs";
27
28
  import { extname, isAbsolute, join, resolve } from "node:path";
28
29
  import { fileURLToPath } from "node:url";
29
30
  import { comfyuiFetch } from "../comfyui/fetch.js";
30
31
  import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
31
32
  import { parse as parseYaml } from "yaml";
33
+ import { dispatchOutcomeOf } from "../services/ui-bridge.js";
32
34
  import { withWorkflowTarget, } from "../services/workflow-target-store.js";
33
35
  import { addUserMcpServer, readUserMcpServers, removeUserMcpServer, setUserMcpServerSecret, } from "../services/user-mcp-config.js";
34
36
  import { setComfyuiSecret, setAgentSecret, isAllowedAgentSecretKey } from "../services/panel-secrets.js";
35
37
  import { flattenUiWorkflow } from "../services/flatten-workflow.js";
36
38
  import { getNsfwConsent, setNsfwConsent } from "../services/panel-settings.js";
37
39
  import { QueueMonitor } from "../services/queue-monitor.js";
38
- import { getObjectInfo, backfillObjectInfo, resetClient, resetObjectInfoCache, } from "../comfyui/client.js";
40
+ import { getClient, getObjectInfo, backfillObjectInfo, resetClient, resetObjectInfoCache, } from "../comfyui/client.js";
39
41
  import { convertUiToApi, collectNodeTypes } from "../services/workflow-converter.js";
40
42
  import { restartComfyUI } from "../services/process-control.js";
41
- import { isRemoteMode } from "../config.js";
43
+ import { isRemoteMode, isCloudMode, getBootLocalComfyUIBaseUrl, getComfyUIBaseUrl, } from "../config.js";
42
44
  import { sliceWorkflow } from "../services/workflow-slicer.js";
43
45
  import { validateA2UISpecServer } from "../services/a2ui-spec.js";
44
46
  /** Treat these as an affirmative answer to the adult-content consent card. */
@@ -81,29 +83,31 @@ export function rebootConfirmed(res) {
81
83
  }
82
84
  }
83
85
  /**
84
- * True when a comfy_reboot ToolResult is an ERROR whose text is the EXPECTED
85
- * connection drop of a server going down (the reboot handler exits the instant it
86
- * accepts the request, so the browser tab's socket dies before it can ack). This
87
- * is the SUCCESS signal of a reboot, NOT a failure the bridge surfaces it as a
88
- * mid-command "OUTCOME UNKNOWN" / "disconnected" / "not open" / "did not reply"
89
- * error. Distinguished from a genuine refusal, which comes back as a NON-error
90
- * ToolResult carrying `rebooting:false` (handled by rebootConfirmed instead).
86
+ * True when a comfy_reboot ToolResult is the bridge's canonical POST-WRITE mid-command
87
+ * drop the command was ACTUALLY WRITTEN to the panel socket and the connection then
88
+ * died before a reply (the reboot handler exits the instant it accepts, so the socket
89
+ * dies mid-flight). The bridge emits this for a MUTATING command as
90
+ * "disconnected mid-command OUTCOME UNKNOWN" (ui-bridge.ts handleMidCommandDisconnect).
91
+ *
92
+ * Deliberately NOT matched (coordinator P0): a raw PRE-WRITE `sock.send()` failure
93
+ * (ECONNRESET / socket hang up / EPIPE / "was NOT dispatched") — that means the command
94
+ * was NEVER written, so NOTHING was dispatched. Treating a pre-write send failure as an
95
+ * accepted/ambiguous "dropped reboot" would let readiness certify a cycle that was never
96
+ * even requested. Also NOT matched: pre-dispatch "is not open" / "did not reply within N
97
+ * ms" (a live-but-frozen tab) / idempotent-read grace expiry — those return verbatim.
98
+ * A genuine refusal comes back as a NON-error `rebooting:false` (rebootConfirmed handles).
91
99
  */
92
100
  export function rebootDropped(res) {
93
101
  if (!res?.isError)
94
102
  return false;
95
103
  const text = res?.content?.find((c) => c.type === "text")?.text ?? "";
96
- // Match ONLY signals that the command we JUST sent (comfy_reboot) died IN
97
- // FLIGHT i.e. the reboot was accepted and the origin went down before it
98
- // could ack. The bridge's mutating-command mid-flight drop is the canonical
99
- // one ("disconnected mid-command OUTCOME UNKNOWN", ui-bridge.ts), plus raw
100
- // socket resets. Deliberately NOT matched: pre-dispatch / generic errors like
101
- // "is not open" (socket already closed BEFORE we sent — no reboot happened),
102
- // "did not reply within N ms" (a live-but-frozen/backgrounded tab), and the
103
- // idempotent-read "genuinely gone" grace expiry. Treating those as a reboot
104
- // would risk a FALSE success (claiming a restart that never fired) once an
105
- // unrelated tab reconnection makes readiness pass. Those return verbatim.
106
- return /disconnected mid-command|OUTCOME UNKNOWN|ECONNRESET|socket hang up|premature close|other side closed|ECONNABORTED|EPIPE/i.test(text);
104
+ // The AUTHORITATIVE signal is the bridge's TYPED dispatch flag (dispatchOutcomeOf),
105
+ // checked by the caller BEFORE this. This text match is a defense-in-depth fallback:
106
+ // the pre-write wrapper ("the command was NOT dispatched") must WIN even if its quoted
107
+ // detail contains a post-write phrase, so a pre-write send failure is never a "drop".
108
+ if (/NOT dispatched/i.test(text))
109
+ return false;
110
+ return /disconnected mid-command|OUTCOME UNKNOWN/i.test(text);
107
111
  }
108
112
  /**
109
113
  * True when a comfy_reboot ToolResult is a NON-error, NON-fired refusal whose
@@ -130,6 +134,12 @@ export function rebootNoEndpoint(res) {
130
134
  return /reboot endpoint|reboot route|was NOT restarted|no reachable .*reboot/i.test(text);
131
135
  }
132
136
  let panelRebootTimingOverride = null;
137
+ // The whole readiness wait (settle + poll budget) MUST finish comfortably below the
138
+ // client's outer ~300s tools/call timeout, so a FAILING wait always returns a clean
139
+ // ready:false in time instead of being killed as a bare 300s timeout — even if the
140
+ // COMFYUI_PANEL_REBOOT_* env overrides are set absurdly high (coordinator codex P2).
141
+ const MAX_REBOOT_SETTLE_MS = 10_000; // 10s
142
+ const MAX_REBOOT_BUDGET_MS = 240_000; // 240s → settle+budget ≤ 250s < 300s outer
133
143
  function parsePositiveNumberEnv(name, fallback) {
134
144
  const raw = process.env[name];
135
145
  if (raw == null || raw === "")
@@ -137,16 +147,23 @@ function parsePositiveNumberEnv(name, fallback) {
137
147
  const n = Number(raw);
138
148
  return Number.isFinite(n) && n > 0 ? n : fallback;
139
149
  }
140
- function getPanelRebootTiming() {
141
- if (panelRebootTimingOverride)
142
- return panelRebootTimingOverride;
150
+ /** Reboot-readiness timing from env, with each value HARD-CAPPED so no override can
151
+ * push the total wait past the outer tools/call budget (coordinator codex P2).
152
+ * The probe interval defaults to a TIGHT 500ms: the observer runs CONCURRENTLY with
153
+ * the reboot dispatch and must catch a BRIEF down window (a fast restart can be down
154
+ * for well under 2s), needing >=2 down probes inside it (coordinator HIGH). settleMs
155
+ * is retained only for the env cap; the observer no longer settles before probing. */
156
+ function computeRebootTimingFromEnv() {
143
157
  return {
144
- settleMs: Math.round(parsePositiveNumberEnv("COMFYUI_PANEL_REBOOT_SETTLE_S", 3) * 1000),
145
- budgetMs: Math.round(parsePositiveNumberEnv("COMFYUI_PANEL_REBOOT_BUDGET_S", 120) * 1000),
146
- intervalMs: Math.round(parsePositiveNumberEnv("COMFYUI_PANEL_REBOOT_INTERVAL_S", 2) * 1000),
147
- probeTimeoutMs: Math.round(parsePositiveNumberEnv("COMFYUI_PANEL_REBOOT_PROBE_S", 5) * 1000),
158
+ settleMs: Math.min(MAX_REBOOT_SETTLE_MS, Math.round(parsePositiveNumberEnv("COMFYUI_PANEL_REBOOT_SETTLE_S", 3) * 1000)),
159
+ budgetMs: Math.min(MAX_REBOOT_BUDGET_MS, Math.round(parsePositiveNumberEnv("COMFYUI_PANEL_REBOOT_BUDGET_S", 120) * 1000)),
160
+ intervalMs: Math.round(parsePositiveNumberEnv("COMFYUI_PANEL_REBOOT_INTERVAL_S", 0.2) * 1000),
161
+ probeTimeoutMs: Math.round(parsePositiveNumberEnv("COMFYUI_PANEL_REBOOT_PROBE_S", 2) * 1000),
148
162
  };
149
163
  }
164
+ function getPanelRebootTiming() {
165
+ return panelRebootTimingOverride ?? computeRebootTimingFromEnv();
166
+ }
150
167
  /** The default generous readiness budget, in seconds — reported to callers. */
151
168
  export function panelRebootBudgetSeconds() {
152
169
  return Math.round(getPanelRebootTiming().budgetMs / 1000);
@@ -156,12 +173,31 @@ export const __panelToolsTestHooks = {
156
173
  setPanelRebootTiming(timing) {
157
174
  panelRebootTimingOverride = timing;
158
175
  },
176
+ /** Inject a fake boot-endpoint probe so readiness tests drive the real proof loop
177
+ * without real HTTP. Returns a ProbeStatus, or a boolean (true→healthy/false→down)
178
+ * so DOWN→UP can be scripted with plain booleans. null restores the live probe. */
179
+ setHealthProbe(fn) {
180
+ healthProbeOverride = fn;
181
+ },
182
+ looksLikeSystemStats,
183
+ probeComfyHealth,
184
+ probeComfyEndpoint,
185
+ captureRebootHealthBase,
186
+ sameHttpOrigin,
187
+ sameHttpBase,
188
+ isLoopbackOrigin,
189
+ loopbackProbeUrl,
190
+ /** Compute reboot timing from env WITH the P2 hard caps (bypasses any override). */
191
+ computeRebootTimingFromEnv,
159
192
  /** Zero out the post-drop retry settle so retry-once tests don't sleep. */
160
193
  setRetrySettleMs(ms) {
161
194
  retrySettleMsOverride = ms;
162
195
  },
163
196
  isRetrySafeCmd,
164
197
  isTransientReconnectError,
198
+ // #384 live-canvas capture fallback (defined later in the module).
199
+ reconstructUiFromState: (reply) => reconstructUiFromState(reply),
200
+ resolveWorkflowInput: (args, ctx, allowStateFallback = true) => resolveWorkflowInput(args, ctx, allowStateFallback),
165
201
  };
166
202
  function sleep(ms) {
167
203
  return new Promise((r) => setTimeout(r, ms));
@@ -221,43 +257,379 @@ function retrySettleMs() {
221
257
  return retrySettleMsOverride;
222
258
  return Math.round(parsePositiveNumberEnv("COMFYUI_PANEL_RETRY_SETTLE_S", 0.4) * 1000);
223
259
  }
260
+ /** True when a decoded /system_stats body has the recognizable ComfyUI shape (a
261
+ * `system` object and/or a `devices` array) — the same fields health_check /
262
+ * get_environment read. A bare 2xx from a reverse-proxy login page, an SPA
263
+ * catch-all, or a proxy error page is NOT ComfyUI and must NOT certify recovery
264
+ * (codex #509 P1). */
265
+ function looksLikeSystemStats(body) {
266
+ if (!body || typeof body !== "object")
267
+ return false;
268
+ const b = body;
269
+ const hasSystem = b.system != null && typeof b.system === "object";
270
+ const hasDevices = Array.isArray(b.devices);
271
+ return hasSystem || hasDevices;
272
+ }
273
+ /** The CONCRETE loopback FAMILY of a hostname, or null when it isn't an unambiguous
274
+ * loopback literal. IPv4 loopback (127.0.0.1 / the 0.0.0.0 wildcard) → "127.0.0.1";
275
+ * IPv6 loopback (::1 / the :: wildcard) → "::1". The families are kept DISTINCT so a
276
+ * v4 tab and a v6 instance at the same port are NOT wrongly matched (coordinator
277
+ * finding 4: v6 A on [::1]:8188 + v4 B on 127.0.0.1:8188 are DIFFERENT instances).
278
+ *
279
+ * `localhost` returns null ON PURPOSE (coordinator P0): a URL preserves the literal
280
+ * "localhost" and does NOT reveal whether the browser actually reached 127.0.0.1 or
281
+ * ::1 — so PINNING it to a family we can't verify could send the auth-bearing probe to
282
+ * a DIFFERENT-family instance than the reboot went to (v6 A rebooted, v4 B probed →
283
+ * false cert + auth leak). We therefore refuse the ambiguity: a `localhost` boot/tab
284
+ * origin is NOT directly-probeable and routes to the honest dispatched-unconfirmed
285
+ * result instead of the direct-probe certification path. */
286
+ function loopbackFamily(host) {
287
+ const h = host.toLowerCase().replace(/^\[|\]$/g, "");
288
+ if (h === "127.0.0.1" || h === "0.0.0.0")
289
+ return "127.0.0.1";
290
+ if (h === "::1" || h === "::" || h === "0000:0000:0000:0000:0000:0000:0000:0000")
291
+ return "::1";
292
+ return null;
293
+ }
294
+ /** True when a hostname is loopback-equivalent (either family, incl. the wildcard
295
+ * binds 0.0.0.0/:: which are reachable on loopback). */
296
+ function isLoopbackHostName(host) {
297
+ return loopbackFamily(host) !== null;
298
+ }
299
+ /** The scheme://host:port origin of a URL (default ports made explicit), or null if
300
+ * unparseable. Loopback hosts canonicalize to their FAMILY loopback (v4 → 127.0.0.1,
301
+ * v6 → ::1) — so localhost/127.0.0.1/0.0.0.0 compare equal, and ::1/:: compare equal,
302
+ * but a v4 host and a v6 host DIFFER (they may be different instances). Ports differ. */
303
+ function httpOriginOf(rawUrl) {
304
+ try {
305
+ const u = new URL(rawUrl);
306
+ const port = u.port || (u.protocol === "https:" ? "443" : "80");
307
+ const host = u.hostname.toLowerCase();
308
+ const canonHost = loopbackFamily(host) ?? host;
309
+ return `${u.protocol}//${canonHost}:${port}`;
310
+ }
311
+ catch {
312
+ return null;
313
+ }
314
+ }
315
+ /** Rewrite a CONCRETE loopback-literal base URL to one that is actually CONNECTABLE and
316
+ * that AGREES with loopbackFamily's identity canonicalization — so the probe (and the
317
+ * auth headers it carries) can never hit a DIFFERENT-family instance than the one
318
+ * identity matched (coordinator P1). Every IPv4-family loopback literal (127.0.0.1 /
319
+ * 0.0.0.0) → the literal 127.0.0.1; every IPv6-family loopback literal (::1 / ::) → the
320
+ * bracketed literal [::1]. A DNS-ambiguous `localhost` has no concrete family and is
321
+ * left UNCHANGED (callers gate it out via loopbackFamily before probing). Non-loopback
322
+ * hosts are returned unchanged. */
323
+ function loopbackProbeUrl(rawUrl) {
324
+ try {
325
+ const u = new URL(rawUrl);
326
+ const fam = loopbackFamily(u.hostname);
327
+ if (fam === "127.0.0.1")
328
+ u.hostname = "127.0.0.1";
329
+ else if (fam === "::1")
330
+ u.hostname = "[::1]";
331
+ return u.toString().replace(/\/+$/, "");
332
+ }
333
+ catch {
334
+ return rawUrl;
335
+ }
336
+ }
337
+ /** True when two URLs share the exact same scheme + host + port (path ignored).
338
+ * Used ONLY for the redirect host-escape check — a same-host redirect isn't a
339
+ * host escape. Instance IDENTITY uses sameHttpBase (path-aware) instead. */
340
+ function sameHttpOrigin(a, b) {
341
+ const oa = a ? httpOriginOf(a) : null;
342
+ const ob = b ? httpOriginOf(b) : null;
343
+ return oa != null && oa === ob;
344
+ }
345
+ /** The canonical scheme://host:port/path form of a URL (loopback host normalized,
346
+ * trailing slashes stripped, path case-sensitive), or null if unparseable. Two
347
+ * ComfyUI instances reverse-proxied under the SAME host:port but DIFFERENT path
348
+ * prefixes (/a vs /b) are DISTINCT — so instance identity must include the path. */
349
+ function canonicalHttpBase(rawUrl) {
350
+ const origin = httpOriginOf(rawUrl);
351
+ if (origin == null)
352
+ return null;
353
+ try {
354
+ const path = new URL(rawUrl).pathname.replace(/\/+$/, "");
355
+ return `${origin}${path}`;
356
+ }
357
+ catch {
358
+ return null;
359
+ }
360
+ }
361
+ /** True when two URLs identify the SAME instance: same scheme+host+port AND the
362
+ * same path prefix (a reverse-proxied mount point is part of its identity). */
363
+ function sameHttpBase(a, b) {
364
+ const ca = a ? canonicalHttpBase(a) : null;
365
+ const cb = b ? canonicalHttpBase(b) : null;
366
+ return ca != null && ca === cb;
367
+ }
368
+ /** True when a URL's host is loopback-EQUIVALENT (incl. the wildcard binds 0.0.0.0/::,
369
+ * which are reachable on loopback) — the only hosts the orchestrator can reach on its
370
+ * OWN machine to health-probe (the #509 local case). */
371
+ function isLoopbackOrigin(rawUrl) {
372
+ try {
373
+ return isLoopbackHostName(new URL(rawUrl).hostname);
374
+ }
375
+ catch {
376
+ return false;
377
+ }
378
+ }
379
+ /** Connection error codes that DEFINITIVELY mean the endpoint's PORT is not accepting —
380
+ * the listener is gone (a restarting process closed it). This is the ONLY connection
381
+ * failure that proves a process-down for the cycle proof, and for a LOOPBACK probe the
382
+ * ONLY sound one: ECONNREFUSED = the host actively refused the connection because nothing
383
+ * is listening on that port. Everything else is (correctly) NOT a listener-down:
384
+ * - ECONNRESET / EPIPE / EPROTO / ETIMEDOUT / "socket hang up" — a still-LISTENING server
385
+ * can reset a connection or transiently fail TLS without going down;
386
+ * - ENETUNREACH / EHOSTUNREACH / ENETDOWN / EHOSTDOWN — a local network/routing failure;
387
+ * the ComfyUI process can still be listening while the stack is momentarily unavailable
388
+ * (codex High);
389
+ * - ENOTFOUND / EAI_AGAIN — DNS (inapplicable to a loopback literal), not a listener-down.
390
+ * A genuine restart CLOSES the loopback port, so repeated polling observes ECONNREFUSED
391
+ * during the down window; the ambiguous codes above stay "unknown" so a transient glitch
392
+ * + a later 200 can never fake a restart cycle. */
393
+ const PORT_NOT_LISTENING_CODES = new Set([
394
+ "ECONNREFUSED", // host refused — nothing listening on the port (the restarting-process signal)
395
+ ]);
396
+ /** Extract a connection error's OS code (undici wraps the real error under `.cause`). */
397
+ function connErrorCode(err) {
398
+ const e = err;
399
+ if (typeof e?.code === "string")
400
+ return e.code;
401
+ if (typeof e?.cause?.code === "string")
402
+ return e.cause.code;
403
+ return undefined;
404
+ }
405
+ /**
406
+ * Probe the boot endpoint and CLASSIFY it. Because the down→up transition is the SOLE
407
+ * proof a process actually CYCLED, "down" must mean the endpoint STOPPED SERVING at the
408
+ * CONNECTION level — the port isn't accepting (a restarting process closes its listener).
409
+ * The boot endpoint in the certify path is a DIRECT loopback ComfyUI (no reverse proxy —
410
+ * captureRebootHealthBase probes 127.0.0.1/[::1] directly), so:
411
+ * - "down" = a CONNECTION failure whose code DEFINITIVELY means the port isn't accepting
412
+ * (ECONNREFUSED — the process is not listening, a genuine restart). Ambiguous
413
+ * mid-connection errors (ECONNRESET / EPIPE / EPROTO / hang up) and network/DNS
414
+ * reachability failures (ENETUNREACH / EHOSTUNREACH / ENOTFOUND …) do NOT count — the
415
+ * server can still be listening — so they are "unknown". ECONNREFUSED is the ONLY
416
+ * signal that proves a cycle.
417
+ * - "healthy" = a same-origin 2xx carrying a real /system_stats body.
418
+ * - "unknown" = the server RESPONDED (so its HTTP listener is UP — NOT a process-down),
419
+ * just not as ComfyUI-up-and-serving-stats: ANY 5xx (a transient 500 is an app error,
420
+ * NOT a restart — codex false-success fix), a 3xx (redirect:"manual", so a login/SPA
421
+ * redirect can't certify and no auth is sent onward), a 4xx (401/403/404/429), a
422
+ * wrong-origin URL, or a 2xx with a non-ComfyUI / malformed body; AND our own request
423
+ * TIMEOUT (the port accepted the connection but was slow to answer → listening, not
424
+ * down). "unknown" is NOT a down and NEVER contributes to the cycle proof — so a
425
+ * transient 5xx / slow response can never masquerade as a restart.
426
+ * Never throws.
427
+ */
428
+ async function probeComfyEndpoint(base, timeoutMs) {
429
+ if (!base)
430
+ return "unknown";
431
+ const url = `${base}/system_stats`;
432
+ const controller = new AbortController();
433
+ const timer = setTimeout(() => controller.abort(), Math.max(1, timeoutMs));
434
+ timer.unref?.();
435
+ try {
436
+ const res = await comfyuiFetch(url, { signal: controller.signal, redirect: "manual" });
437
+ const status = res.status;
438
+ // A 5xx means the HTTP server ANSWERED — its listener is UP — so it is NOT proof the
439
+ // process went down; treat it as "unknown", never "down" (a transient 500 must not
440
+ // fake a restart cycle). Same for 3xx/4xx.
441
+ if (status < 200 || status >= 300)
442
+ return "unknown"; // 3xx/4xx/5xx = responded, not stats
443
+ if (res.url && !sameHttpOrigin(res.url, url))
444
+ return "unknown"; // wrong origin
445
+ let body;
446
+ try {
447
+ body = await res.json();
448
+ }
449
+ catch {
450
+ return "unknown"; // 2xx but not JSON — up, but not a /system_stats we trust
451
+ }
452
+ return looksLikeSystemStats(body) ? "healthy" : "unknown";
453
+ }
454
+ catch (err) {
455
+ // OUR abort = a TIMEOUT: the port accepted the connection but was slow to answer, so
456
+ // its listener is UP (not a process-down) → "unknown", never part of a cycle proof (a
457
+ // transiently-slow no-op server must not fake a restart).
458
+ if (controller.signal.aborted)
459
+ return "unknown";
460
+ // A connection failure is "down" ONLY when its code DEFINITIVELY means the port isn't
461
+ // accepting (ECONNREFUSED &c). An AMBIGUOUS mid-connection error (ECONNRESET / EPIPE /
462
+ // EPROTO / hang up) can come from a STILL-listening server, so it is "unknown" — never
463
+ // a down that a later 200 could turn into a phantom cycle (codex High).
464
+ const code = connErrorCode(err);
465
+ return code != null && PORT_NOT_LISTENING_CODES.has(code) ? "down" : "unknown";
466
+ }
467
+ finally {
468
+ clearTimeout(timer);
469
+ }
470
+ }
471
+ /** Boolean healthy? wrapper over probeComfyEndpoint (redirect-safe). */
472
+ async function probeComfyHealth(base, timeoutMs) {
473
+ return (await probeComfyEndpoint(base, timeoutMs)) === "healthy";
474
+ }
475
+ /** Coerce a health-probe override's boolean (true→healthy / false→down) or an explicit
476
+ * ProbeStatus, so tests can script recovery sequences with plain booleans. */
477
+ function normalizeProbe(v) {
478
+ if (v === true)
479
+ return "healthy";
480
+ if (v === false)
481
+ return "down";
482
+ return v;
483
+ }
484
+ /**
485
+ * The FIXED ComfyUI base URL to health-probe during a reboot readiness wait, or
486
+ * null when we must fall back to the panel round-trip (as before #509). Captured by
487
+ * the handler BEFORE it dispatches comfy_reboot and held for the whole wait.
488
+ *
489
+ * SECURITY (coordinator codex P1): the probe TARGET is the orchestrator's own
490
+ * PROCESS-START local ComfyUI endpoint (getBootLocalComfyUIBaseUrl) — captured at
491
+ * boot and IMMUTABLE. It is deliberately NOT getComfyUIBaseUrl(): that reflects the
492
+ * mutable runtime config a panel `hello` can retarget (applyComfyuiUrl →
493
+ * setComfyuiTarget), so a client could steer it. And it is NEVER the client-advertised
494
+ * `hello.comfyui_url` (spoofable; comfyuiFetch would leak the configured ComfyUI auth
495
+ * headers to an attacker-chosen origin). The tab origin is used ONLY as a gate, and the
496
+ * gate reads the SERVER-OBSERVED handshake Origin (tabServerOrigin) — which the browser
497
+ * sets and blocks page JS from forging — NOT the spoofable hello.comfyui_url: we
498
+ * self-probe our OWN boot endpoint solely when the rebooted tab PROVABLY (by its
499
+ * handshake) fronts THAT SAME instance, so a socket that merely CLAIMS the boot URL can't
500
+ * ride an unrelated boot-instance cycle to a false certification (codex High). Null
501
+ * (→ honest dispatched-unconfirmed) when:
502
+ * - cloud OR remote mode; or
503
+ * - the orchestrator didn't boot against a LOCAL loopback ComfyUI; or
504
+ * - the tab isn't SERVER-TRUSTED-local (tabIsLocal — arrived on the token-less
505
+ * loopback primary listener; relay/tunnel/LAN/pairing → false); or
506
+ * - the tab's HANDSHAKE origin is absent, ambiguous (`localhost`), or does NOT match our
507
+ * boot endpoint by scheme+host+port (concrete-family loopback-canonicalized) — i.e. it
508
+ * drives a DIFFERENT instance / family, or one we can't verify; AND, because a handshake
509
+ * Origin carries NO path, a boot target mounted under a basePath fails path-aware
510
+ * identity and is (soundly) fail-closed to dispatched-unconfirmed.
511
+ */
512
+ function captureRebootHealthBase(ctx) {
513
+ if (isCloudMode() || isRemoteMode())
514
+ return null;
515
+ const bootBase = getBootLocalComfyUIBaseUrl(); // server-authorized, hello-immutable
516
+ if (!bootBase || !isLoopbackOrigin(bootBase))
517
+ return null;
518
+ const base = bootBase.replace(/\/+$/, "");
519
+ // Server-trusted provenance: the tab arrived on the token-less loopback listener.
520
+ if (ctx.bridge?.tabIsLocal?.(ctx.tabId) !== true)
521
+ return null;
522
+ // And the rebooted tab must provably front THAT SAME boot instance. Use the SERVER-
523
+ // OBSERVED handshake Origin (tabServerOrigin) — the browser sets it on the WS upgrade
524
+ // and blocks page JS from forging it — NOT the spoofable client hello.comfyui_url
525
+ // (tabOrigin): a non-Comfy socket on the host could otherwise CLAIM the boot URL, ack
526
+ // comfy_reboot without rebooting, and ride an unrelated boot-instance cycle to a false
527
+ // ready:true (codex High). A handshake Origin proves only scheme+host+port (it carries
528
+ // NO path), so we compare it path-AWARE (sameHttpBase) against the boot base: when the
529
+ // boot target is mounted under a basePath (e.g. …:8188/comfy) the pathless Origin cannot
530
+ // prove the tab fronts THAT mount vs another instance at the same host:port, so we FAIL
531
+ // CLOSED to the honest dispatched-unconfirmed result rather than certify unsoundly (codex
532
+ // P1). The common pathless boot base matches an equal Origin and certifies. Loopback
533
+ // identity canonicalizes only CONCRETE literals by family (127.0.0.1 ≡ a 0.0.0.0 bind;
534
+ // ::1 ≡ a :: bind) — a DNS-ambiguous `localhost` on EITHER side yields no family
535
+ // (loopbackFamily → null), so it never matches a concrete literal and this returns null
536
+ // (coordinator P0). A different instance / family / path / absent Origin → null too.
537
+ const origin = ctx.bridge?.tabServerOrigin?.(ctx.tabId);
538
+ if (!sameHttpBase(origin, base))
539
+ return null;
540
+ // Return a CONNECTABLE probe URL bound to the SAME concrete family identity matched
541
+ // above: a wildcard-bound (0.0.0.0/::) local ComfyUI is reachable on loopback, so probe
542
+ // the family literal at that port (127.0.0.1 / [::1]). The probe (and the auth headers
543
+ // it carries) can therefore never cross to a different-family instance.
544
+ return loopbackProbeUrl(base);
545
+ }
546
+ let healthProbeOverride = null;
224
547
  /**
225
- * Poll the panel bridge until ComfyUI is reachable again after a reboot. Each probe
226
- * is a lightweight `nodes_queue_status` round-trip (via ctx.call, which never
227
- * throws it returns an isError ToolResult while the tab/backend is down and
228
- * auto-heals onto the reconnected tab once it returns). Resolves ready:true on the
229
- * first successful probe (with how long recovery took), or ready:false when the
230
- * server never comes back within the bounded budget.
548
+ * Observe the boot endpoint's recovery AFTER a reboot was dispatched, and certify ONLY on
549
+ * an OBSERVED DOWN→UP cycle. Acceptance (dispatch confirmed/dropped) is the guard against
550
+ * a NO-OP, but we deliberately do NOT certify a lone healthy endpoint after a settle:
551
+ * the panel emits rebooting:true even when it merely INFERS a reboot from a dropped fetch
552
+ * (its comfy_reboot handler's catch branch), so a confirmed ack is NOT a guarantee that a
553
+ * real Manager reboot was accepted — treat it like the ambiguous DROP and require the
554
+ * endpoint to actually go DOWN then come back (coordinator: panel invariant unverifiable).
555
+ * - ANY single "down" (an ECONNREFUSED — the port stopped listening) marks it going down;
556
+ * the next "healthy" → observed-cycle.
557
+ * - Never healthy after an observed down, OR never a down at all → couldn't-confirm.
558
+ *
559
+ * CONCURRENT mode (a `gate` is supplied): the caller starts this BEFORE awaiting the full
560
+ * dispatch, so probes are already sampling the endpoint DURING the ack/drop/timeout window
561
+ * — catching a FAST reboot whose down→up completes before the ack returns (the #509 fast-
562
+ * reboot false-timeout). PROBE-FIRST-THEN-SLEEP: the observer AWAITS the post-write
563
+ * notification (gate.waitDispatched — no timer poll, so no leading window in which a
564
+ * sub-millisecond cycle could be missed), then takes its FIRST probe IMMEDIATELY at the
565
+ * post-write dispatch instant (no leading interval sleep), sleeping intervalMs only BETWEEN
566
+ * subsequent probes. COUNTING stays post-write: a sample
567
+ * marks the cycle only if taken at/after gate.dispatchedAt, so a pre-dispatch down never
568
+ * contributes. gate.deadline is the live deadline (tightened to ack-completion + budget so a
569
+ * slow ack doesn't eat it); gate.cancelled aborts.
570
+ * LEGACY mode (no gate): started AFTER the restart's synchronous work; probe immediately
571
+ * against the fixed `deadline`.
231
572
  */
232
- async function waitForPanelReady(ctx, timing) {
233
- if (timing.settleMs > 0)
234
- await sleep(timing.settleMs);
573
+ async function observeRecovery(timing, deadline, opts) {
235
574
  const start = Date.now();
236
- // Enforce an ABSOLUTE wall-clock deadline so the total wait honours budgetMs —
237
- // NOT budgetMs/intervalMs iterations that each also burn a probe timeout + a
238
- // sleep (which would overrun the advertised bound several-fold). Each probe and
239
- // each inter-probe sleep is capped by the time remaining.
240
- const deadline = start + timing.budgetMs;
241
- // Clamp interval to a floor so a 0/tiny env value can't hot-loop unbounded —
242
- // UNLESS a test override is active (tests inject small deterministic values).
575
+ const gate = opts.gate;
243
576
  const intervalMs = panelRebootTimingOverride
244
577
  ? Math.max(1, timing.intervalMs)
245
- : Math.max(250, timing.intervalMs);
578
+ : Math.max(50, timing.intervalMs);
579
+ const probe = healthProbeOverride ?? probeComfyEndpoint;
580
+ const currentDeadline = () => gate?.deadline ?? deadline;
581
+ let sawDown = false;
246
582
  let attempts = 0;
247
583
  for (;;) {
248
- const remaining = deadline - Date.now();
249
- const probeTimeoutMs = Math.max(1, Math.min(timing.probeTimeoutMs, remaining));
250
- const probe = await ctx.call({ cmd: "nodes_queue_status" }, probeTimeoutMs);
584
+ if (gate?.cancelled)
585
+ break;
586
+ if (currentDeadline() - Date.now() <= 0)
587
+ break;
588
+ if (gate && !gate.dispatched) {
589
+ // CONCURRENT mode, not yet dispatched: AWAIT the post-write NOTIFICATION (resolves the
590
+ // microtask after the socket write) WITHOUT probing — no timer poll, so there is NO
591
+ // leading window in which a sub-millisecond down→up could be missed (codex). The very
592
+ // first probe then fires the instant the command is dispatched (probe-first).
593
+ await gate.waitDispatched;
594
+ if (gate.cancelled)
595
+ break;
596
+ // Fall through and probe immediately (a fast non-accepted outcome that resolves before
597
+ // this observer wakes will already have set gate.cancelled above; otherwise a single
598
+ // read of our OWN boot endpoint during the sub-ack window is the accepted benign
599
+ // residual — see the handler's INHERENT TRADEOFF note — and is discarded on refusal).
600
+ }
601
+ // PROBE NOW (no leading interval sleep) — the first sample lands at the post-write
602
+ // dispatch instant so a sub-interval down→up is caught (coordinator: probe-first).
603
+ const sampleAt = Date.now();
251
604
  attempts++;
252
- if (!probe.isError) {
253
- return { ready: true, waited_ms: Date.now() - start, attempts };
605
+ const t = Math.max(1, Math.min(timing.probeTimeoutMs, currentDeadline() - Date.now()));
606
+ let status = "unknown";
607
+ try {
608
+ status = normalizeProbe(await probe(opts.healthBase, t));
254
609
  }
255
- const left = deadline - Date.now();
610
+ catch {
611
+ status = "unknown";
612
+ }
613
+ if (gate?.cancelled)
614
+ break;
615
+ // COUNTING gate: a sample contributes to the cycle only if taken at/after the post-write
616
+ // dispatched instant (defensive — the observer also defers its first probe to dispatch).
617
+ if (gate == null || sampleAt >= gate.dispatchedAt) {
618
+ if (status === "down") {
619
+ sawDown = true;
620
+ }
621
+ else if (status === "healthy" && sawDown) {
622
+ return { ready: true, waited_ms: Date.now() - start, attempts, via: "observed-cycle", sawDown };
623
+ }
624
+ // "healthy" without a prior down, and "unknown", are ignored — keep looking.
625
+ }
626
+ // Sleep BETWEEN probes (both modes).
627
+ const left = currentDeadline() - Date.now();
256
628
  if (left <= 0)
257
629
  break;
258
630
  await sleep(Math.min(intervalMs, left));
259
631
  }
260
- return { ready: false, waited_ms: Date.now() - start, attempts };
632
+ return { ready: false, waited_ms: Date.now() - start, attempts, sawDown };
261
633
  }
262
634
  // ---- workflow_open verify-after-timeout (#215/#319/#496) --------------------
263
635
  // `panel_open_workflow` forwards `workflow_open` over the UI bridge and waits for
@@ -267,7 +639,7 @@ async function waitForPanelReady(ctx, timing) {
267
639
  // genuinely happened (the executor ran; the ack just didn't make it back in the
268
640
  // window). Reporting that as a failure is a FALSE FAILURE: a follow-up
269
641
  // `workflow_list` shows the target IS the active tab, and it invites unsafe
270
- // retries. Mirroring the reboot-readiness pattern (waitForPanelReady / #497), on
642
+ // retries. Mirroring the reboot-readiness pattern (observeRecovery / #497), on
271
643
  // an ack-timeout we do NOT immediately fail — we VERIFY the AUTHORITATIVE active
272
644
  // workflow by polling `workflow_list` (a fresh bridge round-trip, never a stale
273
645
  // cache) and return SUCCESS with a `recovered` note if the target became active,
@@ -505,9 +877,11 @@ async function openWorkflowWithVerify(path, ctx) {
505
877
  /**
506
878
  * Resolve a caller-supplied pin `path` (path / filename / key, any form) to the
507
879
  * AUTHORITATIVE open-workflow record from a fresh `workflow_list` — the single
508
- * source of truth for which tabs exist and their canonical `key` (#259). Returns:
509
- * - the matched record when the workflow IS open (so the pin can be canonicalized
510
- * to its stable key and bound to the exact frontend tab identity);
880
+ * source of truth for which tabs exist, their canonical `key`, and which one is
881
+ * ACTIVE (#259). Returns:
882
+ * - a {record, isActive} pair when the workflow IS open (so the caller can
883
+ * canonicalize the pin to its stable key AND reject a background target that the
884
+ * panel could never route to — #556/#571);
511
885
  * - `null` when workflow_list is unreachable/empty or carries no `workflows`
512
886
  * array (indeterminate — caller should fall back to the raw path, NOT fail);
513
887
  * - the sentinel `NOT_OPEN` when the list IS known but the target is absent, so
@@ -530,16 +904,141 @@ async function resolveOpenWorkflow(ctx, path) {
530
904
  // No enumerable tab list (older panel / stub) — can't verify, don't fail closed.
531
905
  return null;
532
906
  }
533
- for (const wf of rawList) {
534
- if (activeMatchesTarget(wf, path))
535
- return wf;
907
+ const activeObj = parsed.active;
908
+ const activeLabel = workflowRecordLabel(activeObj);
909
+ // A caller token (path / filename / key) can match MORE THAN ONE open record —
910
+ // e.g. two tabs share the filename "A.json" from different dirs, or two never-saved
911
+ // tabs. Selecting the wrong same-token record and then reading its `active` flag would
912
+ // misjudge active-ness (codex P1). So gather ALL matches and disambiguate toward the
913
+ // one that is actually the live canvas before deciding.
914
+ const matches = rawList.filter((wf) => activeMatchesTarget(wf, path));
915
+ let rec;
916
+ if (matches.length === 1) {
917
+ rec = matches[0];
918
+ }
919
+ else if (matches.length > 1) {
920
+ // Prefer an EXACT stable-identity (key/path) match to the caller token; then prefer
921
+ // the record that is the active canvas; otherwise leave it unresolved and let the
922
+ // active-object branch / NOT_OPEN handle it (never guess among ambiguous tabs).
923
+ const exact = matches.filter((m) => m.key === path || m.path === path);
924
+ const activePreferred = matches.filter((m) => m.active === true || recMatchesActive(m, activeObj));
925
+ rec = exact.length === 1 ? exact[0] : activePreferred.length === 1 ? activePreferred[0] : undefined;
926
+ }
927
+ if (rec) {
928
+ return { record: rec, isActive: computeIsActive(rec, activeObj), activeLabel };
536
929
  }
537
930
  // The active object is authoritative too, in case it isn't mirrored in the array.
538
- if (activeMatchesTarget(parsed.active, path)) {
539
- return parsed.active;
931
+ if (activeMatchesTarget(activeObj, path)) {
932
+ return { record: activeObj, isActive: true, activeLabel };
540
933
  }
541
934
  return NOT_OPEN;
542
935
  }
936
+ /**
937
+ * Is `rec` the active canvas? TRI-STATE (#556/#571):
938
+ * - the record's OWN `active` boolean is authoritative and immune to filename aliasing;
939
+ * - else compare `rec` to the `active` object by STABLE identity (key/path/routing_key —
940
+ * never filename alone, which can collide across tabs). This yields `true`/`false`
941
+ * ONLY when the two share a COMPARABLE identity dimension;
942
+ * - else (no per-record flag AND nothing comparable) → `undefined` (indeterminate): the
943
+ * caller must stay lenient rather than fail a valid pin on an older/partial panel.
944
+ */
945
+ function computeIsActive(rec, activeObj) {
946
+ if (typeof rec.active === "boolean")
947
+ return rec.active;
948
+ return identityVerdict(rec, activeObj);
949
+ }
950
+ /**
951
+ * Stable-identity (key/path/routing_key) verdict between a record and the active object.
952
+ * Returns `true` on a positive match, `false` only when the two expose a COMPARABLE field
953
+ * (both non-empty) that DISAGREES, and `undefined` when they share no comparable field at
954
+ * all (so the caller cannot conclude "background" — stay lenient). Filename is never used
955
+ * (it collides across tabs).
956
+ */
957
+ function identityVerdict(rec, activeObj) {
958
+ if (!activeObj || typeof activeObj !== "object")
959
+ return undefined;
960
+ const a = activeObj;
961
+ const r = rec;
962
+ const nonEmpty = (v) => typeof v === "string" && v.trim() !== "";
963
+ const pairs = [
964
+ [r.key, a.key],
965
+ [r.path, a.path],
966
+ [r.routing_key, a.routing_key],
967
+ [r.key, a.routing_key],
968
+ [r.routing_key, a.key],
969
+ ];
970
+ let comparable = false;
971
+ for (const [x, y] of pairs) {
972
+ if (nonEmpty(x) && nonEmpty(y)) {
973
+ comparable = true;
974
+ if (x === y)
975
+ return true;
976
+ }
977
+ }
978
+ return comparable ? false : undefined;
979
+ }
980
+ /** Stable-identity match (positive only) — used to prefer the active record among matches. */
981
+ function recMatchesActive(rec, activeObj) {
982
+ return identityVerdict(rec, activeObj) === true;
983
+ }
984
+ /** Best-effort human label for a workflow_list record/active object. */
985
+ function workflowRecordLabel(rec) {
986
+ if (!rec || typeof rec !== "object")
987
+ return undefined;
988
+ const r = rec;
989
+ for (const v of [r.filename, r.title, r.path, r.key]) {
990
+ if (typeof v === "string" && v.trim())
991
+ return v;
992
+ }
993
+ return undefined;
994
+ }
995
+ /**
996
+ * Validate and canonicalize a pin `path` to a routable target, SHARED by every entry
997
+ * point that writes a pinned workflow target (the MCP tool AND the panel-driven
998
+ * set_workflow_target event) so none can bypass the guarantees:
999
+ * - FAIL CLOSED when the workflow isn't open (#259) — never route the pin to another tab;
1000
+ * - FAIL AT PIN TIME when it is open but NOT the active canvas (#556/#571) — the panel can
1001
+ * only read/edit the in-view workflow (panel #349/#186), so a background pin would only
1002
+ * defer a "workflow mismatch"; reject it honestly and immediately;
1003
+ * - otherwise canonicalize to the stable `key` (survives rename/reconnect);
1004
+ * - INDETERMINATE lists (older/partial panel — no `workflows` array, or no comparable
1005
+ * active identity) stay LENIENT: pin the raw path rather than fail a valid pin.
1006
+ */
1007
+ export async function resolvePinTarget(ctx, path, filename) {
1008
+ const resolved = await resolveOpenWorkflow(ctx, path);
1009
+ if (resolved === NOT_OPEN) {
1010
+ return {
1011
+ ok: false,
1012
+ error: `Cannot pin to "${path}" — it is not open in ComfyUI. Open it first ` +
1013
+ `(panel_open_workflow) or pick an open workflow from panel_list_workflows, ` +
1014
+ `then pin. (Refusing to pin to a workflow that isn't open so graph edits ` +
1015
+ `never land on the wrong tab.)`,
1016
+ };
1017
+ }
1018
+ if (resolved && resolved.isActive === false) {
1019
+ const activeName = resolved.activeLabel ? ` (currently "${resolved.activeLabel}")` : "";
1020
+ return {
1021
+ ok: false,
1022
+ error: `Cannot pin to "${filename ?? path}" — it is open but not the active canvas${activeName}. ` +
1023
+ `The panel can only read or edit the workflow that is currently in view, so a background ` +
1024
+ `pin would fail on your next panel_* graph call. To work on it, switch to it first with ` +
1025
+ `panel_open_workflow (that makes it the active canvas), then pin — or, if you meant to ` +
1026
+ `edit the workflow already in view, pin that one instead. (Pinning does not switch the ` +
1027
+ `user's view and cannot route edits to a background tab.)`,
1028
+ };
1029
+ }
1030
+ if (resolved) {
1031
+ // Canonicalize to the stable key so routing survives rename/reconnect.
1032
+ const rec = resolved.record;
1033
+ return {
1034
+ ok: true,
1035
+ pinPath: rec.key ?? rec.path ?? path,
1036
+ pinFilename: filename ?? rec.filename ?? rec.path,
1037
+ };
1038
+ }
1039
+ // Indeterminate list — stay lenient (older/partial panel).
1040
+ return { ok: true, pinPath: path, pinFilename: filename };
1041
+ }
543
1042
  export const __openWorkflowTestHooks = {
544
1043
  /** Inject fast open-verify timing so tests don't wait the real ~6s budget. */
545
1044
  setOpenVerifyTiming(timing) {
@@ -671,51 +1170,116 @@ function comfyWorkflowsDirs() {
671
1170
  join(base, "user", "workflows"),
672
1171
  ];
673
1172
  }
674
- /** Read + parse a UI workflow JSON from disk by path. Resolves an absolute path,
675
- * OR a path relative to a ComfyUI workflows dir (COMFYUI_PATH/user/default/workflows,
676
- * then user/workflows). Guards: must be .json, must exist/be readable, and must
677
- * parse to a UI workflow (a top-level `nodes` array). */
678
- function readWorkflowFromPath(rawPath) {
1173
+ /** Validate a parsed value is a UI/litegraph workflow (a top-level `nodes`
1174
+ * array), throwing a source-labelled error otherwise. */
1175
+ function assertUiWorkflow(parsed, sourceLabel) {
1176
+ if (!parsed || typeof parsed !== "object") {
1177
+ throw new Error(`${sourceLabel} did not parse to a workflow object.`);
1178
+ }
1179
+ if (!Array.isArray(parsed.nodes)) {
1180
+ throw new Error(`${sourceLabel} is not a UI workflow (missing a top-level \`nodes\` array). ` +
1181
+ `Provide a UI/litegraph workflow JSON, not API/prompt format.`);
1182
+ }
1183
+ return parsed;
1184
+ }
1185
+ /** Read + parse a UI workflow JSON by path. Resolves an ABSOLUTE path off the
1186
+ * orchestrator's disk, or a RELATIVE name authoritatively through the CONNECTED
1187
+ * ComfyUI's userdata API — which resolves under the server's RUNTIME
1188
+ * `--user-directory` (custom or default), so the RIGHT file always wins and a
1189
+ * stale same-named file under the guessed default dir can never shadow it
1190
+ * (#202). Only when the server can't serve the name (404 / unreachable) does it
1191
+ * fall back to the orchestrator's guessed local workflows dirs, so a
1192
+ * disk-staged file still opens. Guards: must be .json and must parse to a UI
1193
+ * workflow (a top-level `nodes` array). Fails loudly (never loads the wrong
1194
+ * file) when the name resolves nowhere. */
1195
+ async function readWorkflowFromPath(rawPath) {
679
1196
  const p = (rawPath ?? "").trim();
680
1197
  if (!p)
681
1198
  throw new Error("Provide a non-empty `path` to a workflow .json file.");
682
1199
  if (!/\.json$/i.test(p)) {
683
1200
  throw new Error(`"${p}" is not a .json file — pass the path to a ComfyUI workflow JSON.`);
684
1201
  }
685
- // Build the candidate absolute paths to try, in order.
686
- const candidates = [];
1202
+ const readLocal = (resolved) => {
1203
+ let parsed;
1204
+ try {
1205
+ parsed = JSON.parse(readFileSync(resolved, "utf8"));
1206
+ }
1207
+ catch (err) {
1208
+ throw new Error(`"${resolved}" is not valid JSON: ${err.message}`);
1209
+ }
1210
+ return assertUiWorkflow(parsed, `"${resolved}"`);
1211
+ };
1212
+ // ABSOLUTE path → the orchestrator's own disk, unchanged.
687
1213
  if (isAbsolute(p)) {
688
- candidates.push(resolve(p));
689
- }
690
- else {
691
- // Relative to each ComfyUI workflows dir (the common case a just-staged file).
692
- for (const dir of comfyWorkflowsDirs())
693
- candidates.push(resolve(dir, p));
694
- // Also relative to the orchestrator's CWD as a last resort.
695
- candidates.push(resolve(process.cwd(), p));
1214
+ const resolved = resolve(p);
1215
+ if (existsSync(resolved) && statSync(resolved).isFile())
1216
+ return readLocal(resolved);
1217
+ throw new Error(`No workflow file at "${p}". Looked under ${resolved}. ` +
1218
+ `Pass an absolute path, or a name relative to the ComfyUI workflows folder.`);
696
1219
  }
697
- const resolved = candidates.find((c) => existsSync(c) && statSync(c).isFile());
698
- if (!resolved) {
699
- const where = isAbsolute(p)
700
- ? candidates[0]
701
- : `the ComfyUI workflows dir (${comfyWorkflowsDirs().join(" or ") || "COMFYUI_PATH not set"}) or an absolute path`;
702
- throw new Error(`No workflow file at "${p}". Looked under ${where}. Pass an absolute path, or a name relative to the ComfyUI workflows folder.`);
703
- }
704
- let parsed;
1220
+ let outcome;
705
1221
  try {
706
- parsed = JSON.parse(readFileSync(resolved, "utf8"));
1222
+ const client = getClient();
1223
+ const encoded = encodeURIComponent(`workflows/${p.replace(/^[\\/]+/, "")}`);
1224
+ const res = await client.fetchApi(`/api/userdata/${encoded}`);
1225
+ if (res.ok) {
1226
+ // Read the body as TEXT and classify HERE so a malformed 2xx surfaces its
1227
+ // OWN error (no fallback), while ComfyUI's "200 + EMPTY body = file does
1228
+ // not exist" convention (some builds; see parseWorkflowLock) is treated as
1229
+ // an ABSENCE that DOES allow the local fallback — not a malformed error.
1230
+ const body = (await res.text()).trim();
1231
+ if (body === "") {
1232
+ outcome = { kind: "absent", detail: "was not in the ComfyUI userdata library (empty 200 response)" };
1233
+ }
1234
+ else {
1235
+ try {
1236
+ outcome = { kind: "found", parsed: JSON.parse(body) };
1237
+ }
1238
+ catch (err) {
1239
+ outcome = { kind: "malformed", detail: err instanceof Error ? err.message : String(err) };
1240
+ }
1241
+ }
1242
+ }
1243
+ else if (res.status === 404) {
1244
+ outcome = { kind: "absent", detail: "was not in the ComfyUI userdata library (HTTP 404)" };
1245
+ }
1246
+ else {
1247
+ outcome = { kind: "refused", detail: `ComfyUI userdata library returned HTTP ${res.status}` };
1248
+ }
707
1249
  }
708
1250
  catch (err) {
709
- throw new Error(`"${resolved}" is not valid JSON: ${err.message}`);
1251
+ outcome = {
1252
+ kind: "unreachable",
1253
+ detail: `ComfyUI userdata library was unreachable (${err instanceof Error ? err.message : String(err)})`,
1254
+ };
710
1255
  }
711
- if (!parsed || typeof parsed !== "object") {
712
- throw new Error(`"${resolved}" did not parse to a workflow object.`);
1256
+ if (outcome.kind === "found") {
1257
+ // A found-but-non-UI file must surface its own honest error, not silence.
1258
+ return assertUiWorkflow(outcome.parsed, `The workflow "${p}" from the ComfyUI userdata library`);
713
1259
  }
714
- if (!Array.isArray(parsed.nodes)) {
715
- throw new Error(`"${resolved}" is not a UI workflow (missing a top-level \`nodes\` array). ` +
716
- `Provide a UI/litegraph workflow JSON, not API/prompt format.`);
1260
+ if (outcome.kind === "malformed") {
1261
+ throw new Error(`The workflow "${p}" in the ComfyUI userdata library is not valid JSON: ${outcome.detail}`);
717
1262
  }
718
- return parsed;
1263
+ if (outcome.kind === "refused") {
1264
+ // Server is reachable but did not serve the file — do NOT fall back to a
1265
+ // possibly-stale local file; report the status honestly.
1266
+ throw new Error(`Could not read "${p}" from the connected ComfyUI: ${outcome.detail}. ` +
1267
+ `Pass an absolute path, or a name shown by panel_list_workflows.`);
1268
+ }
1269
+ // outcome.kind is "absent" (404) or "unreachable" — fall back to the
1270
+ // orchestrator's guessed local workflows dirs (best-effort; only meaningful on
1271
+ // a same-machine ComfyUI whose user-dir matches the default layout, or a file
1272
+ // staged straight to disk).
1273
+ const localCandidates = [
1274
+ ...comfyWorkflowsDirs().map((dir) => resolve(dir, p)),
1275
+ resolve(process.cwd(), p), // orchestrator CWD as a last local resort
1276
+ ];
1277
+ const local = localCandidates.find((c) => existsSync(c) && statSync(c).isFile());
1278
+ if (local)
1279
+ return readLocal(local);
1280
+ throw new Error(`No workflow file at "${p}". It ${outcome.detail}, and it is not under the orchestrator's workflows ` +
1281
+ `dir (${comfyWorkflowsDirs().join(" or ") || "COMFYUI_PATH not set"}). ` +
1282
+ `Pass an absolute path, or a name shown by panel_list_workflows.`);
719
1283
  }
720
1284
  // IMPORTANT (Codex parity): use `z.array(z.number())` — NOT `z.tuple([...])` — for
721
1285
  // fixed-length coordinate vectors. zod's `.tuple()` emits JSON-Schema draft-04
@@ -829,22 +1393,53 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets) {
829
1393
  // (We gate inside the tool because the SDK's canUseTool is bypassed under
830
1394
  // bypassPermissions, which the panel agent runs in; the Codex HTTP path runs
831
1395
  // approvalPolicy "never", so the same in-tool gate is the only safeguard.)
832
- const confirm = async (question, header) => {
1396
+ const confirm = async (question, header, timeoutMs) => {
1397
+ // #360: the enclosing MCP `tools/call` is killed at ~300s. A hardcoded 300s
1398
+ // card wait had ZERO margin below that budget — so an unanswered confirm blew
1399
+ // the whole tool call (a transport timeout) instead of returning cleanly, and
1400
+ // any late answer was lost. CLAMP the card deadline under the budget (the same
1401
+ // getAskTiming() ceiling panel_ask uses for #486) and, on a reply-timeout,
1402
+ // poll the bridge's late-reply buffer for a bounded grace so a slow-but-valid
1403
+ // yes/no is still HONORED. A genuine no-answer returns "timeout" (reported
1404
+ // honestly by the caller), never a silent decline.
1405
+ const base = getAskTiming();
1406
+ // A caller may pass a tighter WHOLE-confirm budget (#536: panel_restart bounds
1407
+ // confirm+dispatch+readiness under the outer limit). Treat timeoutMs as the HARD
1408
+ // ceiling on deadline+grace so we never overrun the caller's budget, while still
1409
+ // never exceeding the ask clamp. Absent → the full clamp (deadline+grace).
1410
+ const total = typeof timeoutMs === "number"
1411
+ ? Math.max(1, Math.min(timeoutMs, base.deadlineMs + base.graceMs))
1412
+ : base.deadlineMs + base.graceMs;
1413
+ const deadlineMs = Math.max(1, Math.min(base.deadlineMs, total));
1414
+ const graceMs = Math.max(0, total - deadlineMs);
1415
+ const timing = { deadlineMs, graceMs, pollMs: base.pollMs };
1416
+ const askId = randomUUID();
833
1417
  try {
834
1418
  ensureReachable();
835
1419
  const reply = await bridge.send({
836
1420
  cmd: "ask_user",
1421
+ ask_id: askId,
837
1422
  question,
838
1423
  header,
839
1424
  options: [
840
1425
  { label: "Yes, go ahead", description: "" },
841
1426
  { label: "No, cancel", description: "" },
842
1427
  ],
843
- }, { tabId: ctx.tabId, timeoutMs: 300000 });
844
- return isAffirmative(reply);
1428
+ }, { tabId: ctx.tabId, timeoutMs: timing.deadlineMs });
1429
+ return isAffirmative(reply) ? "yes" : "no";
845
1430
  }
846
- catch {
847
- return false;
1431
+ catch (err) {
1432
+ // Only a card-reply TIMEOUT is recoverable/honest-as-timeout: poll the late
1433
+ // buffer, then report "timeout" if still unanswered. Any other error (no
1434
+ // panel, transport failure) → "no" so the destructive op is SKIPPED, exactly
1435
+ // as the previous catch-all did.
1436
+ if (isReplyTimeoutError(err)) {
1437
+ const late = await pollLateAskReply(bridge, askId, timing);
1438
+ if (late !== undefined)
1439
+ return isAffirmative(late) ? "yes" : "no";
1440
+ return "timeout";
1441
+ }
1442
+ return "no";
848
1443
  }
849
1444
  };
850
1445
  // EXPLICIT self-heal — see PanelToolCtx.rebindToActiveTab. Only rebinds when
@@ -880,11 +1475,90 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets) {
880
1475
  * what I have open" is the common ask, and requiring a save-to-disk round trip
881
1476
  * first derailed real sessions (deleted placeholder files, 404 tabs).
882
1477
  */
883
- async function resolveWorkflowInput(args, ctx) {
1478
+ /**
1479
+ * Rebuild a UI-format workflow ({ nodes, links }) from the panel's back-compat
1480
+ * `graph_get_state` reply (the #384 fallback). Each summarized node carries its
1481
+ * widget values keyed BY NAME (`widgets`) and its inputs' upstream source
1482
+ * (`connected_from`), so we materialize:
1483
+ * - nodes with `widgets_values` as the name→value OBJECT — convertUiToApi maps
1484
+ * those by name, which also sidesteps the positional widget-order pitfalls,
1485
+ * - a synthetic links array + per-input `link` ids from `connected_from`.
1486
+ * Returns null when the reply has no usable nodes.
1487
+ */
1488
+ function reconstructUiFromState(reply) {
1489
+ const r = reply;
1490
+ const nodesIn = r?.nodes;
1491
+ if (!Array.isArray(nodesIn) || nodesIn.length === 0)
1492
+ return null;
1493
+ // graph_get_state caps at MAX_STATE_NODES (100) and flags the overflow. A
1494
+ // truncated capture would silently yield an INCOMPLETE executable graph, so
1495
+ // refuse it — the caller then surfaces the actionable "pass pack/path/graph"
1496
+ // error rather than stripping a partial workflow.
1497
+ if (r?.truncated === true)
1498
+ return null;
1499
+ if (typeof r?.node_count === "number" && r.node_count > nodesIn.length)
1500
+ return null;
1501
+ const uiNodes = nodesIn.map((raw) => {
1502
+ const n = raw;
1503
+ const mode = n.mode === "mute" ? 2 : n.mode === "bypass" ? 4 : 0;
1504
+ return {
1505
+ id: n.id,
1506
+ type: n.type,
1507
+ mode,
1508
+ pos: [0, 0],
1509
+ inputs: (n.inputs ?? []).map((inp) => ({
1510
+ name: inp.name,
1511
+ type: inp.type ?? "*",
1512
+ link: null,
1513
+ })),
1514
+ outputs: (n.outputs ?? []).map((o) => ({
1515
+ name: o.name,
1516
+ type: o.type ?? "*",
1517
+ links: [],
1518
+ })),
1519
+ widgets_values: n.widgets && typeof n.widgets === "object"
1520
+ ? n.widgets
1521
+ : [],
1522
+ properties: {},
1523
+ ...(n.title ? { title: n.title } : {}),
1524
+ };
1525
+ });
1526
+ const byId = new Map(uiNodes.map((n) => [n.id, n]));
1527
+ const links = [];
1528
+ let linkId = 0;
1529
+ nodesIn.forEach((raw, idx) => {
1530
+ const inputs = raw.inputs ?? [];
1531
+ const tgt = uiNodes[idx];
1532
+ inputs.forEach((inp, slot) => {
1533
+ const from = inp.connected_from;
1534
+ if (!from || from.node_id == null || !byId.has(from.node_id))
1535
+ return;
1536
+ const id = ++linkId;
1537
+ tgt.inputs[slot].link = id;
1538
+ const srcNode = byId.get(from.node_id);
1539
+ const srcSlot = from.output_slot ?? 0;
1540
+ while (srcNode.outputs.length <= srcSlot) {
1541
+ srcNode.outputs.push({ name: `out_${srcNode.outputs.length}`, type: "*", links: [] });
1542
+ }
1543
+ srcNode.outputs[srcSlot].links.push(id);
1544
+ links.push([id, from.node_id, srcSlot, tgt.id, slot, inp.type ?? "*"]);
1545
+ });
1546
+ });
1547
+ return { nodes: uiNodes, links };
1548
+ }
1549
+ async function resolveWorkflowInput(args, ctx,
1550
+ // The live-canvas graph_get_state fallback (#384) is LOSSY: it reconstructs
1551
+ // only nodes/links/widgets (name-keyed) — no layout, groups, properties, or
1552
+ // subgraph definitions. That's fine for panel_strip_workflow (API/prompt output
1553
+ // for inspection/execution), but panel_flatten_workflow LOADS its result back
1554
+ // ONTO the canvas and panel_slice_workflow needs groups to find its seeds, so
1555
+ // they must NOT take this fallback — they keep the actionable "update your
1556
+ // panel" error instead. Only strip opts in.
1557
+ allowStateFallback = false) {
884
1558
  if (args.pack)
885
1559
  return readPackWorkflow(args.pack);
886
1560
  if (args.path)
887
- return readWorkflowFromPath(args.path);
1561
+ return await readWorkflowFromPath(args.path);
888
1562
  if (args.graph != null) {
889
1563
  return (typeof args.graph === "string"
890
1564
  ? JSON.parse(args.graph)
@@ -907,7 +1581,31 @@ async function resolveWorkflowInput(args, ctx) {
907
1581
  });
908
1582
  }
909
1583
  catch (err) {
910
- throw new Error(`Couldn't capture the live canvas (${err instanceof Error ? err.message : String(err)}). ` +
1584
+ // #384: a panel too old to register graph_serialize (added at 0.11.4) still
1585
+ // answers the back-compat `graph_get_state`. On an "Unknown command" rejection
1586
+ // ONLY (a genuine transport/timeout error must surface as-is), fall back to it
1587
+ // and reconstruct the graph so "strip the live canvas" works without a
1588
+ // save-to-disk round trip.
1589
+ const msg = err instanceof Error ? err.message : String(err);
1590
+ if (allowStateFallback && /unknown command/i.test(msg)) {
1591
+ try {
1592
+ const target = ctx.workflowTarget?.get(ctx.tabId);
1593
+ const stateCmd = target
1594
+ ? withWorkflowTarget({ cmd: "graph_get_state" }, target)
1595
+ : { cmd: "graph_get_state" };
1596
+ const stateReply = await ctx.bridge.send(stateCmd, {
1597
+ tabId: ctx.tabId,
1598
+ timeoutMs: 30000,
1599
+ });
1600
+ const rebuilt = reconstructUiFromState(stateReply);
1601
+ if (rebuilt)
1602
+ return rebuilt;
1603
+ }
1604
+ catch {
1605
+ /* fall through to the actionable error below */
1606
+ }
1607
+ }
1608
+ throw new Error(`Couldn't capture the live canvas (${msg}). ` +
911
1609
  `An older panel version may not support graph_serialize — pass pack, path, or graph instead.`);
912
1610
  }
913
1611
  const wf = reply?.workflow;
@@ -916,6 +1614,133 @@ async function resolveWorkflowInput(args, ctx) {
916
1614
  }
917
1615
  return wf;
918
1616
  }
1617
+ let askTimingOverride = null;
1618
+ // The enclosing MCP `tools/call` is killed at ~300s. The card deadline PLUS the
1619
+ // late-answer grace poll must finish UNDER that, or a slow-but-valid pick is lost
1620
+ // to the framework before we can honor it (#486). This is the HARD ceiling on the
1621
+ // total ask budget — applied even when env overrides ask for more, so a
1622
+ // misconfigured COMFYUI_PANEL_ASK_DEADLINE_S/GRACE_S can never recreate #486.
1623
+ const ASK_TOTAL_BUDGET_CAP_MS = 285_000;
1624
+ function getAskTiming() {
1625
+ if (askTimingOverride)
1626
+ return askTimingOverride;
1627
+ const pollMs = Math.round(parsePositiveNumberEnv("COMFYUI_PANEL_ASK_POLL_S", 0.5) * 1000);
1628
+ // Defaults keep deadline + grace comfortably under the budget (240 + up to 45 =
1629
+ // 285s). Env overrides are HARD-clamped: the deadline is capped first (leaving at
1630
+ // least a 1s slice), then the grace gets only whatever budget remains, so
1631
+ // deadline + grace is guaranteed ≤ ASK_TOTAL_BUDGET_CAP_MS regardless of input.
1632
+ let deadlineMs = Math.round(parsePositiveNumberEnv("COMFYUI_PANEL_ASK_DEADLINE_S", 240) * 1000);
1633
+ let graceMs = Math.round(parsePositiveNumberEnv("COMFYUI_PANEL_ASK_GRACE_S", 45) * 1000);
1634
+ deadlineMs = Math.min(deadlineMs, ASK_TOTAL_BUDGET_CAP_MS - 1000);
1635
+ graceMs = Math.min(graceMs, Math.max(0, ASK_TOTAL_BUDGET_CAP_MS - deadlineMs));
1636
+ return { deadlineMs, graceMs, pollMs };
1637
+ }
1638
+ /** True when an error is the bridge's reply-TIMEOUT for a card (the tab never
1639
+ * replied within the window), NOT a genuine transport/command error. Only a
1640
+ * timeout warrants polling the late-reply buffer for a slow-but-valid answer. */
1641
+ function isReplyTimeoutError(err) {
1642
+ const msg = err instanceof Error ? err.message : String(err ?? "");
1643
+ return /did not reply to .* within \d+\s*ms|backgrounded or frozen/i.test(msg);
1644
+ }
1645
+ /**
1646
+ * Actionable error string when THIS session has no interactive surface able to
1647
+ * render an ask card (so the ask would block with no way to answer), or `null`
1648
+ * when a card can render. Uses the bridge's `isHeadless` on the tab the ask would
1649
+ * target (the current tab if reachable, else the resolved active tab). Defensive:
1650
+ * an unknown/lightweight bridge, or an ambiguous active-tab resolution, returns
1651
+ * null so the normal send path surfaces its own clear error instead.
1652
+ */
1653
+ function askSurfaceError(ctx) {
1654
+ const b = ctx.bridge;
1655
+ if (typeof b.isHeadless !== "function")
1656
+ return null; // lightweight/unknown bridge
1657
+ let targetId = ctx.tabId;
1658
+ if (typeof b.canReach === "function" && !b.canReach(targetId)) {
1659
+ if (typeof b.resolveActiveTabId !== "function")
1660
+ return null;
1661
+ try {
1662
+ targetId = b.resolveActiveTabId();
1663
+ }
1664
+ catch {
1665
+ return null; // no single active tab — let the send path report it clearly
1666
+ }
1667
+ }
1668
+ if (!b.isHeadless(targetId))
1669
+ return null;
1670
+ return ("No interactive panel surface can render a choice card in this session — the " +
1671
+ "connected client is canvas-less (a mobile mirror, a remote/headless viewer, or " +
1672
+ "an exec/headless run), so panel_ask can't be answered here and would block. Ask " +
1673
+ "the user directly in plain chat text, or invoke panel_ask from an interactive " +
1674
+ "ComfyUI browser tab (not nested inside an exec/headless call).");
1675
+ }
1676
+ /** Poll the bridge's late-reply buffer for a validated ask answer that arrived
1677
+ * after the card-reply timeout, up to the grace budget. undefined if none. */
1678
+ async function pollLateAskReply(bridge, askId, timing) {
1679
+ const take = bridge
1680
+ .takeLateAskReply;
1681
+ if (typeof take !== "function")
1682
+ return undefined;
1683
+ const deadline = Date.now() + timing.graceMs;
1684
+ for (;;) {
1685
+ const late = take.call(bridge, askId);
1686
+ if (late !== undefined)
1687
+ return late;
1688
+ const left = deadline - Date.now();
1689
+ if (left <= 0)
1690
+ return undefined;
1691
+ await sleep(Math.max(1, Math.min(timing.pollMs, left)));
1692
+ }
1693
+ }
1694
+ /**
1695
+ * Run a panel_ask: render the choice card and return the user's pick. Clamps the
1696
+ * card deadline under the MCP tools/call budget and, on a reply-timeout, honors a
1697
+ * late-but-valid answer from the bridge's late-reply buffer before failing (#486).
1698
+ * Sent DIRECTLY over the bridge (like the confirm/consent cards) so a stable
1699
+ * `ask_id` can key the late-reply buffer.
1700
+ */
1701
+ async function askUserWithGrace(ctx, ask) {
1702
+ const timing = getAskTiming();
1703
+ const askId = randomUUID();
1704
+ const cmd = {
1705
+ cmd: "ask_user",
1706
+ ask_id: askId,
1707
+ question: ask.question,
1708
+ options: ask.options,
1709
+ header: ask.header,
1710
+ multi_select: ask.multi_select,
1711
+ };
1712
+ try {
1713
+ ctx.ensureReachable?.();
1714
+ const reply = await ctx.bridge.send(cmd, {
1715
+ tabId: ctx.tabId,
1716
+ timeoutMs: timing.deadlineMs,
1717
+ });
1718
+ return ok(reply);
1719
+ }
1720
+ catch (err) {
1721
+ if (isReplyTimeoutError(err)) {
1722
+ const late = await pollLateAskReply(ctx.bridge, askId, timing);
1723
+ if (late !== undefined)
1724
+ return ok(late);
1725
+ return fail("The question card was not answered in time (or no interactive panel surface " +
1726
+ "rendered it — e.g. an exec/headless run), so nothing was selected. If you " +
1727
+ "still need the decision, ask the user directly in plain chat text, or " +
1728
+ "re-invoke panel_ask from an interactive ComfyUI tab.");
1729
+ }
1730
+ return fail(err);
1731
+ }
1732
+ }
1733
+ export const __panelAskTestHooks = {
1734
+ /** Inject fast ask timing so tests don't wait the real deadline/grace. */
1735
+ setAskTiming(timing) {
1736
+ askTimingOverride = timing;
1737
+ },
1738
+ /** The env-derived (hard-clamped) ask timing, for the budget-cap test. */
1739
+ getAskTiming,
1740
+ ASK_TOTAL_BUDGET_CAP_MS,
1741
+ askSurfaceError,
1742
+ isReplyTimeoutError,
1743
+ };
919
1744
  /**
920
1745
  * The SINGLE source of truth for the panel_* tool surface. Both transports
921
1746
  * register these exact definitions, so the Claude (in-process) and Codex (HTTP)
@@ -925,7 +1750,7 @@ export function buildPanelToolDefs() {
925
1750
  // Local helper so each def reads like the original `tool(...)` call.
926
1751
  const def = (name, description, schema, handler) => ({ name, description, schema, handler });
927
1752
  return [
928
- def("panel_query_graph", "QUERY the workflow the user is CURRENTLY VIEWING — filter, traverse, project, and aggregate over the live canvas 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.", {
1753
+ def("panel_query_graph", "FILTER/traverse a SUBSET of the live graph once you already know what you're looking for — NOT the tool for 'show me the canvas' / a full overview (use panel_graph_outline FIRST for that). QUERY the workflow the user is CURRENTLY VIEWING — filter, traverse, project, and aggregate over the live canvas 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.", {
929
1754
  types: z.array(z.string()).optional().describe("Node type contains ANY of these (case-insensitive)."),
930
1755
  title: z.string().optional().describe("Node title contains this."),
931
1756
  where: z
@@ -977,7 +1802,7 @@ export function buildPanelToolDefs() {
977
1802
  limit: args.limit,
978
1803
  max_chars: args.max_chars,
979
1804
  })),
980
- def("panel_graph_outline", "Read a COMPACT, dependency-ordered TEXT MAP of the workflow the user is viewing — the FASTEST way to UNDERSTAND a graph (especially a big loaded pack/template) before you touch it. Returns one `outline` string built for you to read top→down: nodes are topologically sorted (sources first, sinks last), each shown on its own block as `id Type \"title\" [bypass/mute] [OUTPUT] · group:X widget=value …` with `← inputs` (as source_node.output_name) and `→ outputs` (as target_node.input_name), preceded by a GROUPS index (title → member node ids). It shows the WIRING you'd otherwise have to reconstruct. Use this FIRST to get oriented; then panel_query_graph to filter/traverse/inspect (e.g. {ids:[42], fields:'detail'} for one node's exact slot/widget detail), or panel_find_nodes for free-text search. Read-only.", {}, async (_args, ctx) => ctx.call({ cmd: "graph_outline" })),
1805
+ def("panel_graph_outline", "'Show me what's on the canvas' / 'what's on the graph right now' / 'read the current workflow' / 'describe the open graph' -> THIS TOOL. It reads the LIVE canvas the user is looking at. NOT visualize_workflow (that renders a workflow you PASS IN — a saved file/JSON — into a Mermaid diagram, and does NOT read the live canvas) and NOT panel_query_graph (use that only to FILTER/traverse a SUBSET once you already know what you're looking for). Returns a COMPACT, dependency-ordered TEXT MAP of the whole open graph — the FASTEST way to UNDERSTAND it (especially a big loaded pack/template) before you touch it. One `outline` string, read top→down: nodes topologically sorted (sources first, sinks last), each on its own block as `id Type \"title\" [bypass/mute] [OUTPUT] · group:X widget=value …` with `← inputs` (as source_node.output_name) and `→ outputs` (as target_node.input_name), preceded by a GROUPS index (title → member node ids). It shows the WIRING you'd otherwise have to reconstruct. Use this FIRST to get oriented; then panel_query_graph to filter/traverse/inspect (e.g. {ids:[42], fields:'detail'} for one node's exact slot/widget detail), or panel_find_nodes for free-text search. Read-only.", {}, async (_args, ctx) => ctx.call({ cmd: "graph_outline" })),
981
1806
  def("panel_view_selected", "What the user has SELECTED on the canvas right now. Call this FIRST whenever they say \"this node\", \"the selected one\", \"the highlighted node\", \"where did I get this from\", or otherwise point at something without giving an id — the selection IS the answer, and reading it costs one call instead of scanning the graph. Returns the full detail summary (id, type, title, widgets, inputs with sources, outputs, mode) for each selected node, plus `selected_count` and any selected groups/reroutes. If `selected_count` is 0, nothing is selected — ask the user to click the node rather than guessing. NEVER dump the whole graph to work out which node they mean. Read-only.", {}, async (_args, ctx) => ctx.call({ cmd: "graph_view_selected" })),
982
1807
  def("panel_view_nodes_in_viewport", "The nodes the user can actually SEE — everything intersecting the current viewport (pan+zoom) of the canvas they're looking at. Use this to SCOPE your work to what's on their screen instead of reading a whole graph: when they say \"these nodes\", \"the ones here\", \"what am I looking at\", or when a graph is large and you only need the region in front of them. Returns the viewport rect in graph coordinates (x, y, width, height, zoom), `node_count` (whole graph) vs `in_view_count`, and the detail summary of each visible node. A node counts as visible if any part of it overlaps the viewport. On a big canvas this is dramatically cheaper than panel_graph_outline / panel_query_graph — prefer it when the user's framing is visual. Read-only.", {}, async (_args, ctx) => ctx.call({ cmd: "graph_view_nodes_in_viewport" })),
983
1808
  def("panel_audit_prompt_director", "Audit Prompt Director on the LIVE canvas without changing it. Correlates Prompt Director/Producer/Auto/Context/Reference/Critic widget values and wiring with detected model-loader filenames, every LoRA loader's actual model/CLIP strengths, and Prompt Director's latest sanitized runtime edit plan, resolved Model Explorer metadata, warnings, exact final prompt, and critic verdict. Returns observations plus proposed panel_set_widget changes with requires_confirmation=true. Call this when Prompt Director nodes are present, before saying the model/LoRA setup is correct, or when an edit prompt is ignored. READ-ONLY: present useful findings to the user and ask before applying any recommendation unless they already explicitly asked you to fix it.", {}, async (_args, ctx) => ctx.call({ cmd: "graph_prompt_director_audit" })),
@@ -1047,7 +1872,12 @@ export function buildPanelToolDefs() {
1047
1872
  }, async (args, ctx) => ctx.call({ cmd: "graph_add_node", class_type: args.class_type, pos: args.pos, title: args.title })),
1048
1873
  def("panel_remove_node", "Remove a node (and its connections) from the user's open graph by id. Undoable with Ctrl+Z.", { node_id: z.number().int().describe("Node id from panel_graph_outline / panel_query_graph.") }, async (args, ctx) => ctx.call({ cmd: "graph_remove_node", node_id: args.node_id })),
1049
1874
  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) => {
1050
- if (!(await ctx.confirm("Clear the canvas? This removes every node from the open workflow. (One Ctrl+Z undoes it.)", "Clear canvas"))) {
1875
+ const decision = await ctx.confirm("Clear the canvas? This removes every node from the open workflow. (One Ctrl+Z undoes it.)", "Clear canvas");
1876
+ if (decision === "timeout") {
1877
+ return ok("Timed out waiting for your confirmation, so I left the canvas as-is. " +
1878
+ "Tell me to clear it again when you're ready.");
1879
+ }
1880
+ if (decision !== "yes") {
1051
1881
  return ok("Cancelled — the canvas was left as-is.");
1052
1882
  }
1053
1883
  return ctx.call({ cmd: "graph_clear" });
@@ -1074,7 +1904,9 @@ export function buildPanelToolDefs() {
1074
1904
  .optional()
1075
1905
  .describe("Inline UI workflow (object or JSON string) to strip instead of a pack/path."),
1076
1906
  }, async (args, ctx) => {
1077
- const raw = await resolveWorkflowInput(args, ctx);
1907
+ // strip opts into the lossy live-canvas fallback (#384) — its API/prompt
1908
+ // output is for inspection/execution, never reloaded onto the canvas.
1909
+ const raw = await resolveWorkflowInput(args, ctx, true);
1078
1910
  const ui = raw;
1079
1911
  const bulk = await getObjectInfo();
1080
1912
  const objectInfo = await backfillObjectInfo(bulk, collectNodeTypes(ui));
@@ -1195,9 +2027,10 @@ export function buildPanelToolDefs() {
1195
2027
  data = readPackWorkflow(args.pack);
1196
2028
  }
1197
2029
  else if (args.path) {
1198
- // Read an arbitrary workflow JSON off the orchestrator's local disk
1199
- // same server-side-read pattern as `pack`, keeping the big JSON out of chat.
1200
- data = readWorkflowFromPath(args.path);
2030
+ // Read an arbitrary workflow JSON server-side a local disk path, or
2031
+ // (for a relative name under a custom --user-directory) the connected
2032
+ // ComfyUI's userdata API — keeping the big JSON out of chat (#202).
2033
+ data = await readWorkflowFromPath(args.path);
1201
2034
  }
1202
2035
  else if (args.graph != null) {
1203
2036
  data = typeof args.graph === "string" ? JSON.parse(args.graph) : args.graph;
@@ -1247,13 +2080,27 @@ export function buildPanelToolDefs() {
1247
2080
  node_id: z.number().int().describe("Node id whose input to disconnect."),
1248
2081
  input: slotRef.optional().describe("Input slot name or index (default 0)."),
1249
2082
  }, async (args, ctx) => ctx.call({ cmd: "graph_disconnect", node_id: args.node_id, input: args.input })),
1250
- 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.", {
2083
+ 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).", {
1251
2084
  node_id: z.number().int().describe("Node id from panel_graph_outline / panel_query_graph."),
1252
2085
  widget: z.string().describe("Widget name (e.g. 'steps', 'cfg', 'text')."),
1253
2086
  value: z
1254
2087
  .union([z.string(), z.number(), z.boolean()])
1255
- .describe("New value. Must match the widget's expected type."),
1256
- }, async (args, ctx) => ctx.call({ cmd: "graph_set_widget", node_id: args.node_id, widget: args.widget, value: args.value })),
2088
+ .optional()
2089
+ .describe("New value. Must match the widget's expected type. Optional only when `clear: true` is set (which forces an empty string)."),
2090
+ clear: z
2091
+ .boolean()
2092
+ .optional()
2093
+ .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`."),
2094
+ }, async (args, ctx) => {
2095
+ // Distinguish "value present but empty" from "value absent" by key
2096
+ // presence, NOT a truthiness check — an empty string is a legitimate
2097
+ // value. `clear: true` is the transport-independent way to set "".
2098
+ const value = args.clear === true ? "" : args.value;
2099
+ if (value === undefined) {
2100
+ return fail("panel_set_widget needs a `value`. To set an empty string, pass `clear: true` (some clients drop an empty-string `value`).");
2101
+ }
2102
+ return ctx.call({ cmd: "graph_set_widget", node_id: args.node_id, widget: args.widget, value });
2103
+ }),
1257
2104
  def("panel_move_node", "Move a node to a new canvas position [x, y] in the user's open graph. Undoable.", {
1258
2105
  node_id: z.number().int().describe("Node id from panel_graph_outline / panel_query_graph."),
1259
2106
  pos: xy().describe("New canvas [x, y] (two numbers)."),
@@ -1474,7 +2321,15 @@ export function buildPanelToolDefs() {
1474
2321
  if (args.target_kind !== "env") {
1475
2322
  return ok("The built-in comfyui server takes secrets as env vars — use target_kind 'env' (e.g. key 'CIVITAI_API_TOKEN').");
1476
2323
  }
1477
- setComfyuiSecret(args.key, `${args.value_prefix ?? ""}${secret}`);
2324
+ setComfyuiSecret(args.key, `${args.value_prefix ?? ""}${secret}`, {
2325
+ // This save ANSWERS an outstanding agent secret request — mark it so
2326
+ // the orchestrator injects the "retry the action" nudge, and carry
2327
+ // the requesting tab so ONLY that tab's agent is nudged (never a
2328
+ // broadcast to unrelated tabs). A Settings-panel slot save omits
2329
+ // both and never nudges (#164).
2330
+ requested: true,
2331
+ tabId: ctx.tabId,
2332
+ });
1478
2333
  // Redacted ack ONLY — the secret never enters the agent's context. The
1479
2334
  // respawn is deferred to this turn's end, so this is accurate.
1480
2335
  return ok(`🔒 Token saved for the built-in comfyui tools (env "${args.key}"). It's being applied now — the comfyui tools respawn with it as soon as this turn ends, then I'll retry. No reload needed.`);
@@ -1558,7 +2413,12 @@ export function buildPanelToolDefs() {
1558
2413
  .describe("Step state (default 'pending'). Mark the one you're on 'active'."),
1559
2414
  }))
1560
2415
  .describe("The full ordered checklist (replaces the current one). Empty array clears the tray."),
1561
- }, async (args, ctx) => ctx.call({ cmd: "set_todo", items: args.items }, 5000)),
2416
+ },
2417
+ // #322: a 5s ack deadline false-timed-out a responsive session whose tab was
2418
+ // momentarily backgrounded. set_todo is a non-destructive, idempotent full-
2419
+ // replace UI write (already in RETRY_SAFE_CMDS), so give it the same sane 15s
2420
+ // bound as the other UI-state writes (workflow_save) instead of a tight 5s.
2421
+ async (args, ctx) => ctx.call({ cmd: "set_todo", items: args.items }, 15000)),
1562
2422
  def("panel_open_civitai", "Open the in-panel CivitAI browser for the user, pre-seeded with a search term and suggested filters, so they can VISUALLY browse and pick a model / LoRA / checkpoint / workflow / image. When the user asks about — or you're recommending — specific CivitAI models/LoRAs/checkpoints (e.g. 'what's a good relight LoRA?'), PREFER opening this docked browser and highlighting your picks over a text-only answer: it docks beside the chat (dock defaults true) so chat and results stay visible together, and it lets the user SEE the actual cards instead of reading a table. Typical show-don't-tell flow: panel_open_civitai (docked) → panel_civitai_search to refine → panel_civitai_results to READ the metadata + URLs → panel_civitai_highlight the one(s) you recommend, with a brief text summary of why. Set a helpful query + filters matched to their goal (including the browsing level). Their selection comes back to you as a normal chat message — UNLESS the panel is muted, in which case they download it directly themselves. Prefer this over guessing a specific model or asking them to paste a URL.", {
1563
2423
  query: z
1564
2424
  .string()
@@ -1737,24 +2597,31 @@ export function buildPanelToolDefs() {
1737
2597
  .describe("The choices (at least 2). An 'Other' free-text field is added automatically."),
1738
2598
  header: z.string().optional().describe("Very short label/chip for the card (e.g. 'Sampler')."),
1739
2599
  multi_select: z.boolean().optional().describe("Allow selecting multiple options (default false)."),
1740
- }, async (args, ctx) => ctx.call({
1741
- cmd: "ask_user",
1742
- question: args.question,
1743
- options: args.options,
1744
- header: args.header,
1745
- multi_select: args.multi_select,
1746
- },
1747
- // Human-in-the-loop: wait up to 10 minutes for a pick.
1748
- 600000)),
1749
- def("panel_save_workflow", "Save the user's open workflow PROGRAMMATICALLY — no Save/Rename dialog ever pops. A never-saved workflow is auto-named and persisted; pass `name` to give it (or rename it to) a specific name. Use this freely (e.g. after building a graph) — it won't interrupt the user.", { name: z.string().optional().describe("Name to save/rename to (no .json needed). Omit to save in place / auto-name an unsaved workflow.") }, async (args, ctx) => args.name
2600
+ }, async (args, ctx) => {
2601
+ // #300: fail FAST with an actionable error when there is no interactive
2602
+ // surface to render the card (a canvas-less/headless client, or an exec/
2603
+ // headless run), rather than blocking with no way to answer.
2604
+ const surfaceErr = askSurfaceError(ctx);
2605
+ if (surfaceErr)
2606
+ return fail(surfaceErr);
2607
+ // #486: clamp the card deadline under the MCP tools/call budget and honor a
2608
+ // late-but-valid answer via the bridge's late-reply buffer.
2609
+ return askUserWithGrace(ctx, {
2610
+ question: args.question,
2611
+ options: args.options,
2612
+ header: args.header,
2613
+ multi_select: args.multi_select,
2614
+ });
2615
+ }),
2616
+ def("panel_save_workflow", "Save the user's open workflow PROGRAMMATICALLY — no Save/Rename dialog ever pops. With no `name`: saves in place (or auto-names + persists a never-saved workflow). With `name`: if the workflow is ALREADY saved under a different name this is a SAVE-AS — it writes a NEW file and leaves the original untouched on disk (it NEVER renames/moves/destroys the original); for a never-saved workflow it is simply the first save. The result reports what happened: `saved_as`+`copied_from`+`original_on_disk` (a disk-verified check that the original file still exists) for a Save-As copy, or `first_save` for a brand-new workflow. Use this freely (e.g. after building a graph) — it won't interrupt the user.", { name: z.string().optional().describe("Name for the workflow (no .json needed). If the workflow is already saved under a different name, this writes a NEW file (Save-As COPY) and leaves the original in place — it never renames/moves/destroys it. Omit to save in place / auto-name an unsaved workflow.") }, async (args, ctx) => args.name
1750
2617
  ? ctx.call({ cmd: "workflow_save_as", name: args.name }, 15000)
1751
2618
  : ctx.call({ cmd: "workflow_save" }, 15000)),
1752
2619
  def("panel_list_workflows", "List the user's OPEN workflow tabs and which one is active (path, filename, modified, persisted). Use this to know what's open before switching/renaming/closing. Read-only.", {}, async (_args, ctx) => ctx.call({ cmd: "workflow_list" })),
1753
- def("panel_get_workflow_target", "Read which workflow this agent is bound to edit. mode 'current' means graph tools follow whatever tab the user is viewing; mode 'pinned' means edits go to the pinned workflow even if the user switched to another tab. Call this when unsure which workflow your panel_* edits will affect.", {}, async (_args, ctx) => {
2620
+ def("panel_get_workflow_target", "Read which workflow this agent is bound to edit. mode 'current' means graph tools follow whatever tab the user is viewing; mode 'pinned' means edits are bound to the pinned workflow (which was the active canvas at pin time) — if the user later switches to another tab, your next graph call FAILS LOUDLY rather than silently editing the wrong graph. Call this when unsure which workflow your panel_* edits will affect.", {}, async (_args, ctx) => {
1754
2621
  const target = ctx.workflowTarget?.get(ctx.tabId) ?? { mode: "current" };
1755
2622
  return ok(target);
1756
2623
  }),
1757
- def("panel_set_workflow_target", "Pin the agent to a specific open workflow tab, or release the pin to follow the user's current tab. Use pinned when the user asks you to work on workflow A while they browse workflow B set mode:'pinned' and path from panel_list_workflows. Set mode:'current' (or omit path) to follow the active tab again. Does NOT switch what the user sees; it only routes your panel_* graph edits. mode:'current' is ALSO the explicit RECOVERY signal: if your panel_* calls started failing with `no connected tab` after ComfyUI reconnected, the panel reloaded, or the user switched to a different workflow FILE, call this with mode:'current' to rebind this session onto the tab that's live now.", {
2624
+ def("panel_set_workflow_target", "Pin the agent to a specific open workflow tab (it must be the ACTIVE/in-view workflow at pin time), or release the pin to follow the user's current tab. The panel can only read or edit the workflow currently in view, so pinning to a background tab is REJECTED at pin time — to work on a different open workflow, switch to it first with panel_open_workflow (that makes it active), then pin. Pinning does NOT change the user's view; it binds your panel_* graph edits to that workflow and makes a later mismatch (e.g. the user switches away) fail loudly instead of silently editing the wrong graph. Set mode:'pinned' with path from panel_list_workflows; set mode:'current' (or omit path) to follow the active tab again. mode:'current' is ALSO the explicit RECOVERY signal: if your panel_* calls started failing with `no connected tab` after ComfyUI reconnected, the panel reloaded, or the user switched to a different workflow FILE, call this with mode:'current' to rebind this session onto the tab that's live now.", {
1758
2625
  mode: z
1759
2626
  .enum(["current", "pinned"])
1760
2627
  .describe("'current' = follow the user's active workflow tab; 'pinned' = always edit the given path."),
@@ -1791,25 +2658,18 @@ export function buildPanelToolDefs() {
1791
2658
  }
1792
2659
  }
1793
2660
  // PIN: bind to the EXACT open-workflow identity from the authoritative
1794
- // workflow_list, canonicalizing to its stable `key` and FAILING CLOSED when
1795
- // the requested workflow isn't actually open instead of letting the panel
1796
- // silently route the pin to another tab (#259). Indeterminate lists (older
1797
- // panel / no `workflows` array) fall back to the raw path (unchanged).
2661
+ // workflow_list, canonicalizing to its stable `key`, FAILING CLOSED when the
2662
+ // requested workflow isn't open (#259), and FAILING AT PIN TIME when it is open
2663
+ // but not the active canvas (#556/#571). Shared with the panel-driven event path
2664
+ // so both entry points validate identically.
1798
2665
  let pinPath = path;
1799
2666
  let pinFilename = filename;
1800
2667
  if (mode === "pinned" && path) {
1801
- const resolved = await resolveOpenWorkflow(ctx, path);
1802
- if (resolved === NOT_OPEN) {
1803
- return fail(`Cannot pin to "${path}" — it is not open in ComfyUI. Open it first ` +
1804
- `(panel_open_workflow) or pick an open workflow from panel_list_workflows, ` +
1805
- `then pin. (Refusing to pin to a workflow that isn't open so graph edits ` +
1806
- `never land on the wrong tab.)`);
1807
- }
1808
- if (resolved) {
1809
- // Canonicalize to the stable key so routing survives rename/reconnect.
1810
- pinPath = resolved.key ?? resolved.path ?? path;
1811
- pinFilename = filename ?? resolved.filename ?? resolved.path;
1812
- }
2668
+ const res = await resolvePinTarget(ctx, path, filename);
2669
+ if (!res.ok)
2670
+ return fail(res.error);
2671
+ pinPath = res.pinPath;
2672
+ pinFilename = res.pinFilename;
1813
2673
  }
1814
2674
  const target = ctx.workflowTarget.set(ctx.tabId, {
1815
2675
  mode,
@@ -2010,100 +2870,290 @@ export function buildPanelToolDefs() {
2010
2870
  }, async (args, ctx) => ctx.call({ cmd: "graph_update_node", id: args.id, version: args.version, channel: args.channel, mode: args.mode }, 30000)),
2011
2871
  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)),
2012
2872
  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) => {
2013
- if (!(await ctx.confirm("Restart ComfyUI now? It (and this agent) will go down briefly, then reconnect and resume automatically.", "Restart ComfyUI"))) {
2873
+ // Whole-handler budget (#536): confirm + dispatch + readiness INCLUDING
2874
+ // the legacy path's UNPREEMPTIBLE synchronous execSync blocks — must ALL finish
2875
+ // under the outer ~300s tools/call limit. 255s + the legacy admission rule below
2876
+ // (kill+relaunch starts only with >=130s left, its ~40s of sync work FRONT-LOADED)
2877
+ // means the handler PROVABLY returns well under 300s. The confirm wait is bound
2878
+ // to the remaining budget (its deadline+grace can't overrun it — see confirm).
2879
+ const OVERALL_MAX_MS = 255_000;
2880
+ const overallDeadline = Date.now() + OVERALL_MAX_MS;
2881
+ const decision = await ctx.confirm("Restart ComfyUI now? It (and this agent) will go down briefly, then reconnect and resume automatically.", "Restart ComfyUI", Math.max(1, overallDeadline - Date.now()));
2882
+ if (decision === "timeout") {
2883
+ return ok("Timed out waiting for your confirmation, so I did NOT restart ComfyUI. " +
2884
+ "Tell me to restart it and I'll go ahead.");
2885
+ }
2886
+ if (decision !== "yes") {
2014
2887
  return ok("Cancelled — ComfyUI was not restarted.");
2015
2888
  }
2016
- const res = await ctx.call({ cmd: "comfy_reboot", force: force === true }, 15000);
2889
+ // Heal an orphaned session onto the live tab FIRST, then bind the reboot dispatch
2890
+ // to that ONE tab id (no await between capture and dispatch, so JS run-to-
2891
+ // completion prevents any rebind in between). The boot-endpoint probe target is
2892
+ // server-authorized + immutable, bound to the exact host FAMILY the reboot goes
2893
+ // to (null unless the bound tab provably fronts our boot instance).
2894
+ ctx.ensureReachable?.();
2895
+ const boundTabId = ctx.tabId;
2896
+ const healthBase = captureRebootHealthBase(ctx);
2897
+ const timing = getPanelRebootTiming();
2898
+ const dispatchTimeout = Math.max(1, Math.min(15000, overallDeadline - Date.now()));
2899
+ // CONCURRENT OBSERVATION (coordinator): start probing the fixed boot endpoint NOW,
2900
+ // in parallel with the dispatch, so a FAST reboot whose down→up completes entirely
2901
+ // inside the ack/drop/timeout window is still captured (the reopened #509 fast-reboot
2902
+ // false-timeout). COUNTING stays post-write via the gate: the observer neither probes
2903
+ // nor counts until gate.dispatched flips (the instant AFTER the socket write), so a
2904
+ // pre-dispatch down never contributes. gate.deadline starts at the whole-handler cap
2905
+ // (probing spans the ack window) and is tightened to ack-completion + budget below.
2906
+ //
2907
+ // INHERENT TRADEOFF (coordinator, verified: no early-accept signal exists — the bridge
2908
+ // resolves send() only with the single rid-correlated {rebooting} reply, so accept vs
2909
+ // REFUSE is known only IN that reply). To catch a fast reboot we MUST probe DURING the
2910
+ // ack window, i.e. before we know accept/refuse. The residual is BENIGN and bounded:
2911
+ // • the probe targets ONLY the orchestrator's OWN immutable, server-authorized boot
2912
+ // ComfyUI (captureRebootHealthBase → getBootLocalComfyUIBaseUrl) with the correct
2913
+ // configured auth — never a client-advertised, cross-family, or wrong instance, so
2914
+ // it is NOT an auth leak or a wrong-instance probe (handshake-Origin gated above);
2915
+ // • a genuinely REFUSED reboot does NOT restart ComfyUI, so no REAL ECONNREFUSED→
2916
+ // healthy cycle occurs to certify; and even a CONTRIVED one is explicitly discarded
2917
+ // (the refusal branch below returns the refusal verbatim and never reads the
2918
+ // observer — a refusal can NEVER certify).
2919
+ // Eliminating even this harmless own-endpoint read would require probing only AFTER the
2920
+ // reply, which reopens the #509 fast-reboot false-timeout — an unacceptable regression.
2921
+ let signalDispatched;
2922
+ const gate = {
2923
+ dispatched: false,
2924
+ dispatchedAt: Number.POSITIVE_INFINITY,
2925
+ cancelled: false,
2926
+ deadline: overallDeadline,
2927
+ waitDispatched: new Promise((r) => {
2928
+ signalDispatched = r;
2929
+ }),
2930
+ };
2931
+ const recoveryPromise = healthBase != null
2932
+ ? observeRecovery(timing, gate.deadline, { healthBase, gate })
2933
+ : null;
2934
+ // The AUTHORITATIVE, TYPED dispatch outcome from the bridge rejection (if any):
2935
+ // false = a PRE-write send failure (nothing transmitted), true = a POST-write
2936
+ // mid-command OUTCOME-UNKNOWN drop / reply-timeout. Captured from the RAW error —
2937
+ // text can't defeat it — so a pre-write failure whose detail happens to quote
2938
+ // "OUTCOME UNKNOWN" is still categorically NOT-dispatched (coordinator P1).
2939
+ let res;
2940
+ let dispatchOutcome;
2941
+ // ctx.bridge.send()'s Promise executor writes to the socket SYNCHRONOUSLY, so by the
2942
+ // time it returns the promise the command has been written (or synchronously pre-write
2943
+ // failed). Open the counting gate right here — this is the POST-write instant — then
2944
+ // await the ack. Probing (already running) begins the moment this flips.
2945
+ const sendPromise = ctx.bridge.send({ cmd: "comfy_reboot", force: force === true }, { tabId: boundTabId, timeoutMs: dispatchTimeout });
2946
+ gate.dispatched = true;
2947
+ gate.dispatchedAt = Date.now();
2948
+ // Wake the observer's FIRST probe IMMEDIATELY (microtask — no timer window) now that
2949
+ // the command has been written. Resolved on EVERY path (accept / drop / refuse /
2950
+ // pre-write failure), so the observer never hangs on gate.waitDispatched.
2951
+ signalDispatched();
2952
+ try {
2953
+ res = ok(await sendPromise);
2954
+ }
2955
+ catch (err) {
2956
+ res = fail(err);
2957
+ dispatchOutcome = dispatchOutcomeOf(err);
2958
+ }
2959
+ // A PRE-write send failure means nothing was transmitted — the reboot never happened,
2960
+ // so NOTHING may certify: abort the concurrent observer immediately (coordinator P1).
2961
+ if (dispatchOutcome === false)
2962
+ gate.cancelled = true;
2017
2963
  // Classify the reboot dispatch:
2018
2964
  // - CONFIRMED (rebooting:true): the panel acked before it went down.
2019
- // - EXPECTED DROP: the reboot handler exits the instant it accepts the
2020
- // request, so ComfyUI (and the tab it serves) goes down before it can
2021
- // ack — the bridge surfaces that as a mid-command "OUTCOME UNKNOWN" /
2022
- // disconnected error. That dropped connection IS the success signal of a
2023
- // reboot, NOT a failure (#493, panel #222/#263/#266/#306/#307).
2024
- // - REFUSAL: a busy-guard / Manager-forbidden / no-endpoint refusal comes
2025
- // back as a NON-error ToolResult with `rebooting:false` — the server is
2026
- // still up and was NOT restarted; return it verbatim and touch nothing.
2965
+ // - EXPECTED DROP: the reboot handler exits the instant it accepts the request,
2966
+ // so ComfyUI (and the tab it serves) goes down before it can ack — a bridge
2967
+ // mid-command "OUTCOME UNKNOWN"/disconnect. That drop IS the accept + went-down
2968
+ // signal (#493, panel #222/#263/#266/#306/#307).
2969
+ // - REFUSAL: a busy-guard / Manager-forbidden / no-endpoint refusal — the server
2970
+ // is still up and was NOT restarted; return it verbatim and touch nothing.
2027
2971
  const fired = rebootConfirmed(res);
2028
- const dropped = !fired && rebootDropped(res);
2972
+ // A pre-write send failure (typed dispatchOutcome === false) is categorically NOT an
2973
+ // accepted drop — never enter the probing path for a command that never left. The
2974
+ // text check (rebootDropped) is a defense-in-depth fallback for older bridges that
2975
+ // don't carry the typed flag.
2976
+ const dropped = !fired && dispatchOutcome !== false && (dispatchOutcome === true || rebootDropped(res));
2029
2977
  if (!fired && !dropped) {
2030
- // The panel could not fire a Manager reboot. If the SOLE reason is that
2031
- // NO Manager reboot endpoint answered (legacy Manager 3.x: v2 route 405s,
2032
- // legacy route 404s #425, panel #253/#266) AND the target is a LOCAL,
2033
- // process-controllable ComfyUI, fall back to the headless managed restart
2034
- // (kill + relaunch) the same mechanism as the `restart_comfyui` tool.
2035
- // A busy-guard or security refusal is deliberately NOT eligible
2036
- // (rebootNoEndpoint excludes those), so this never aborts a running
2037
- // render or bypasses Manager's security gate.
2038
- if (!isRemoteMode() && rebootNoEndpoint(res)) {
2978
+ // NOT accepted (e.g. a rebooting:false busy-guard/security REFUSAL). BELT-AND-
2979
+ // SUSPENDERS (coordinator): EXPLICITLY DISCARD any cycle the concurrent observer may
2980
+ // have sampled during the sub-ack window a refusal must NEVER certify. We cancel
2981
+ // the observer and, crucially, never read recoveryPromise on this path: whatever it
2982
+ // resolved to (even a contrived ready:true) is dropped, and we return the refusal
2983
+ // verbatim. (The legacy no-endpoint fallback below starts its OWN fresh observation
2984
+ // after the restart's synchronous work; it does not reuse this observer.)
2985
+ gate.cancelled = true;
2986
+ void recoveryPromise; // discarded — a refused reboot can never yield ready:true
2987
+ // If the SOLE reason is NO Manager reboot endpoint (legacy Manager
2988
+ // 3.x — #425, panel #253/#266) AND the target is a LOCAL, process-controllable
2989
+ // ComfyUI, fall back to the headless managed restart (kill + relaunch). A
2990
+ // busy-guard / security refusal is NOT eligible (rebootNoEndpoint excludes them).
2991
+ if (!isRemoteMode() &&
2992
+ rebootNoEndpoint(res) &&
2993
+ // INSTANCE BINDING: restartComfyUI() acts on the orchestrator's GLOBAL config
2994
+ // target (a hello can retarget it). Only run it when the bound tab provably
2995
+ // fronts our OWN boot instance AND that boot instance is the CURRENT global
2996
+ // target — so the relaunch cycles the SAME instance this tab rebooted.
2997
+ healthBase != null &&
2998
+ sameHttpBase(getComfyUIBaseUrl(), healthBase)) {
2999
+ // The managed kill+relaunch does UNPREEMPTIBLE synchronous execSync work — PID
3000
+ // discovery (~5+8s) + termination (~10s) + first port-free lookup (~13s) ≈ 40s
3001
+ // worst case (Windows) — that a Promise.race CANNOT interrupt, and it BLOCKS the
3002
+ // observer during that window. Admit it ONLY with enough budget for that sync
3003
+ // work AND a full cold-start observation AFTER it, and give the observer a
3004
+ // deadline that spans BOTH (coordinator P1: the proof deadline must start after,
3005
+ // not before, the restart's synchronous work — otherwise a genuine cold start
3006
+ // that finishes at sync+coldStart false-times-out).
3007
+ const LEGACY_SYNC_WORST_CASE_MS = 40_000; // execSync PID lookup + kill + port-free
3008
+ const LEGACY_COLD_START_OBS_MS = 100_000; // cold-start observation AFTER the sync
3009
+ const LEGACY_RESTART_MIN_BUDGET_MS = LEGACY_SYNC_WORST_CASE_MS + LEGACY_COLD_START_OBS_MS;
3010
+ if (overallDeadline - Date.now() < LEGACY_RESTART_MIN_BUDGET_MS) {
3011
+ return ok({
3012
+ rebooting: false,
3013
+ ready: false,
3014
+ confirmed_cycle: false,
3015
+ note: "The built-in Manager exposed no reboot endpoint (legacy Manager 3.x), and " +
3016
+ "there isn't enough remaining time to safely run the headless managed restart " +
3017
+ "(kill + relaunch). ComfyUI was NOT restarted — retry panel_restart_comfyui " +
3018
+ "(a fresh call gets the full budget).",
3019
+ });
3020
+ }
3021
+ // A managed kill+relaunch restarts ComfyUI out-of-band, so drop the memoized
3022
+ // caches. The observer watches the boot endpoint itself with a deadline spanning
3023
+ // the ~40s blocking sync + a full cold-start window, and certifies ONLY on an
3024
+ // OBSERVED down→up — a never-restarted healthy endpoint (a Desktop first-healthy
3025
+ // Manager-reboot / preflight no-op) is honestly couldn't-confirm (coordinator P1).
3026
+ resetClient();
3027
+ resetObjectInfoCache();
3028
+ // The observation window spans the ~40s blocking sync + a full cold-start
3029
+ // window. (Under a test timing override, use the injected budget instead so the
3030
+ // never-certify cases don't wait the real ~140s.)
3031
+ const legacyProofWindow = panelRebootTimingOverride
3032
+ ? timing.settleMs + timing.budgetMs
3033
+ : LEGACY_RESTART_MIN_BUDGET_MS;
3034
+ const proofDeadline = Math.min(Date.now() + legacyProofWindow, overallDeadline);
3035
+ const proofPromise = observeRecovery(timing, proofDeadline, { healthBase });
3036
+ const restartBudget = Math.max(1, overallDeadline - Date.now());
2039
3037
  let restart;
3038
+ let restartTimer;
2040
3039
  try {
2041
- restart = await restartComfyUI();
3040
+ restart = await Promise.race([
3041
+ restartComfyUI(),
3042
+ new Promise((resolve) => {
3043
+ restartTimer = setTimeout(() => resolve(undefined), restartBudget);
3044
+ restartTimer.unref?.();
3045
+ }),
3046
+ ]);
2042
3047
  }
2043
3048
  catch (err) {
3049
+ clearTimeout(restartTimer);
3050
+ void proofPromise.catch(() => { }); // self-terminates at proofDeadline
2044
3051
  return fail("The built-in Manager exposed no reboot endpoint (legacy Manager 3.x), " +
2045
3052
  "and the headless managed restart also failed: " +
2046
3053
  (err instanceof Error ? err.message : String(err)) +
2047
3054
  " — restart ComfyUI on the host, then reconnect.");
2048
3055
  }
2049
- if (!restart.started) {
3056
+ clearTimeout(restartTimer);
3057
+ // DEFINITIVE no-restart: a spawn failure, OR restartComfyUI refused before
3058
+ // stopping anything (no process found / unsafe relaunch → stopped:false &&
3059
+ // started:false). The process was NOT cycled, so the still-healthy endpoint is
3060
+ // the OLD one — fail clearly rather than certify a no-op (coordinator P1).
3061
+ if (restart?.spawn_error ||
3062
+ (restart != null && restart.stopped !== true && restart.started !== true)) {
3063
+ void proofPromise.catch(() => { });
2050
3064
  return fail("The built-in Manager exposed no reboot endpoint (legacy Manager 3.x). " +
2051
- "Tried the headless managed restart (kill + relaunch) as a fallback, but it " +
2052
- `could not restart ComfyUI: ${restart.message} ` +
3065
+ "Tried the headless managed restart (kill + relaunch), but it did not restart " +
3066
+ `ComfyUI: ${restart?.message ?? "unknown error"} ` +
2053
3067
  "Restart ComfyUI on the host, then reconnect.");
2054
3068
  }
2055
- // A managed kill+relaunch restarts ComfyUI out-of-band from the WS
2056
- // client, so drop the memoized caches exactly as the Manager-reboot
2057
- // path does before waiting for the panel to reconnect.
2058
- resetClient();
2059
- resetObjectInfoCache();
2060
- const timing = getPanelRebootTiming();
2061
- const recovery = await waitForPanelReady(ctx, timing);
3069
+ // Otherwise (the process WAS stopped/started, or restartComfyUI's own readiness
3070
+ // poll merely expired neither terminal) DEFER to OUR OWN observed DOWN→UP.
3071
+ const recovery = await proofPromise;
3072
+ const observed = recovery.via === "observed-cycle";
2062
3073
  return ok({
2063
3074
  rebooting: true,
2064
3075
  ready: recovery.ready,
3076
+ confirmed_cycle: observed, // true = we directly observed the down→up cycle
2065
3077
  recovered_ms: recovery.waited_ms,
2066
3078
  probes: recovery.attempts,
2067
- via: "headless-managed-restart",
2068
- note: "ComfyUI-Manager (legacy 3.x) had no reboot endpoint; restarted ComfyUI " +
2069
- `via the headless managed restart (kill + relaunch)${recovery.ready
2070
- ? ` and it came back ready in ${(recovery.waited_ms / 1000).toFixed(1)}s.`
2071
- : " but the panel did not reconnect within the readiness budget — verify with panel_node_queue_status."}`,
3079
+ saw_down: recovery.sawDown,
3080
+ via: recovery.ready ? recovery.via : undefined,
3081
+ note: "ComfyUI-Manager (legacy 3.x) had no reboot endpoint; ran the headless managed " +
3082
+ "restart (kill + relaunch) " +
3083
+ (recovery.ready
3084
+ ? `and it came back healthy in ${(recovery.waited_ms / 1000).toFixed(1)}s` +
3085
+ (observed ? " (observed it go down then come back)." : " (cycle not directly observed).")
3086
+ : `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.`),
2072
3087
  });
2073
3088
  }
2074
- // Genuine refusal (or an unrelated error) do NOT poll or reset caches.
2075
- // Resetting on a refusal would close the shared client mid-generation
2076
- // (codex WS-3 finding #2).
3089
+ // Genuine refusal (busy guard / security / no eligible fallback) return
3090
+ // verbatim; do NOT reset caches (that would close the shared client mid-render).
2077
3091
  return res;
2078
3092
  }
2079
- // A panel/Manager reboot restarts ComfyUI out-of-band from the MCP
2080
- // process-control path, so the orchestrator's WebSocket client and its
2081
- // memoized /object_info survive the restart and go stale: get_node_info
2082
- // then returns pre-restart schemas, model dropdowns, and required/optional
2083
- // placement (#353/#378/#394), and newly installed nodes stay invisible
2084
- // (#357). The reboot is the triggering event — drop both caches so the
2085
- // next call refetches against the fresh server.
3093
+ // ACCEPTED. A reboot restarts ComfyUI out-of-band, so the orchestrator's cached WS
3094
+ // client + /object_info go stale (#353/#357/#378/#394) drop both caches.
2086
3095
  resetClient();
2087
3096
  resetObjectInfoCache();
2088
- // The reboot has FIRED. The connection drop is EXPECTED, not an error do
2089
- // not surface it as a false timeout/failure. Instead poll readiness (a
2090
- // lightweight nodes_queue_status round-trip that auto-heals onto the
2091
- // reconnected tab) up to a generous bound and report SUCCESS once ComfyUI
2092
- // is reachable again, with how long recovery took. Only report FAILURE if
2093
- // it genuinely never comes back within the budget a truly-dead server
2094
- // still fails honestly.
2095
- const timing = getPanelRebootTiming();
2096
- const recovery = await waitForPanelReady(ctx, timing);
3097
+ // Observe recovery. There is exactly ONE sound proof that THIS ComfyUI instance
3098
+ // actually cycled: a directly OBSERVED down→up on the server-authorized, immutable,
3099
+ // family-bound boot endpoint (observeRecovery). We do NOT fabricate a second proof
3100
+ // from a weaker proxy. In particular a panel tab disconnecting→reconnecting proves
3101
+ // only that a panel↔orchestrator socket churned NOT that the (possibly remote)
3102
+ // ComfyUI cycled; `tab_id` is client-supplied and a different same-kind socket can
3103
+ // take that id over with a fresh nonce, so a tab reconnect can never certify a
3104
+ // same-instance restart (codex gate). So when there is NO probeable boot endpoint
3105
+ // (remote / cloud / older / untrusted-locality panel), we HONESTLY report the reboot
3106
+ // as dispatched-and-accepted but NOT server-confirmable — a non-error result that
3107
+ // tells the caller to verify, NOT the #509 false-TIMEOUT *error* (the real #509 local
3108
+ // case is a probeable boot endpoint and is certified by observeRecovery below).
3109
+ if (healthBase == null) {
3110
+ // No probeable boot endpoint — the concurrent observer was never started.
3111
+ return ok({
3112
+ rebooting: true,
3113
+ ready: false,
3114
+ confirmed_cycle: false,
3115
+ dispatched: true,
3116
+ note: "ComfyUI restart was dispatched and accepted; it is restarting out-of-band. " +
3117
+ "There is no local boot endpoint I can safely probe from here, so I can't " +
3118
+ "confirm it finished coming back — a panel reconnect wouldn't prove this " +
3119
+ "instance actually cycled. Check health_check / panel_node_queue_status in a " +
3120
+ "few seconds to confirm it's back.",
3121
+ });
3122
+ }
3123
+ // The concurrent observer has been probing since dispatch (catching a fast down→up
3124
+ // inside the ack window). Now measure the readiness budget from ACK COMPLETION — so a
3125
+ // slow ack doesn't eat it — by tightening the live deadline, then await the verdict.
3126
+ // Both fired and dropped are AMBIGUOUS (the panel emits rebooting:true even when it
3127
+ // only INFERS a reboot from a dropped fetch), so certification requires an OBSERVED
3128
+ // down→up, which the observer has been (and continues) watching for.
3129
+ gate.deadline = Math.min(Date.now() + timing.budgetMs, overallDeadline);
3130
+ const recovery = await recoveryPromise;
2097
3131
  if (!recovery.ready) {
2098
- return fail(`Reboot was triggered but ComfyUI did not come back within ${Math.round(timing.budgetMs / 1000)}s (${recovery.attempts} probes). Check the host — is ComfyUI-Manager restarting it? ` +
2099
- "Verify with panel_node_queue_status once a tab reconnects before retrying.");
3132
+ const waited = Math.round(recovery.waited_ms / 1000);
3133
+ return ok({
3134
+ rebooting: true,
3135
+ ready: false,
3136
+ confirmed_cycle: false,
3137
+ recovered_ms: recovery.waited_ms,
3138
+ probes: recovery.attempts,
3139
+ saw_down: recovery.sawDown,
3140
+ note: recovery.sawDown
3141
+ ? `Reboot was dispatched and ComfyUI went down, but it has not become healthy within ${waited}s — it may still be starting or the restart failed. Verify with health_check / panel_node_queue_status before retrying; do NOT assume it is back.`
3142
+ : `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.`,
3143
+ });
2100
3144
  }
2101
3145
  return ok({
2102
3146
  rebooting: true,
2103
3147
  ready: true,
3148
+ confirmed_cycle: true, // we directly observed the down→up cycle on the boot endpoint
2104
3149
  recovered_ms: recovery.waited_ms,
2105
3150
  probes: recovery.attempts,
2106
- note: `ComfyUI rebooted and came back ready in ${(recovery.waited_ms / 1000).toFixed(1)}s${dropped ? " (connection dropped as expected while it went down)" : ""}.`,
3151
+ saw_down: recovery.sawDown,
3152
+ via: recovery.via,
3153
+ note: `ComfyUI restart accepted and it is healthy again in ${(recovery.waited_ms / 1000).toFixed(1)}s` +
3154
+ " (observed it go down then come back)" +
3155
+ (dropped ? "; connection dropped as expected while it went down" : "") +
3156
+ ".",
2107
3157
  });
2108
3158
  }),
2109
3159
  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)),
@@ -2246,11 +3296,19 @@ export function createPanelMcpServer(bridge, tabId, workflowTargets) {
2246
3296
  const ctx = makePanelToolCtx(bridge, tabId, workflowTargets);
2247
3297
  const defs = buildPanelToolDefs();
2248
3298
  const tools = defs.map((d) => tool(d.name, d.description, d.schema, (args) => d.handler(args, ctx)));
2249
- return createSdkMcpServer({
3299
+ const server = createSdkMcpServer({
2250
3300
  name: "comfyui-panel",
2251
3301
  version: "1.0.0",
2252
3302
  tools,
2253
3303
  });
3304
+ // Re-point this server's bound tab after a panel tab-id migration (#568 Defect
3305
+ // 1). ctx.tabId is read LIVE by every handler (and by call/confirm), so updating
3306
+ // it in place moves ALL panel_* routing onto the migrated tab — no stale id that
3307
+ // makes the tools throw `no connected tab`. PanelAgent.rebindTabId() calls this.
3308
+ server.rebindTab = (newTabId) => {
3309
+ ctx.tabId = newTabId;
3310
+ };
3311
+ return server;
2254
3312
  }
2255
3313
  /**
2256
3314
  * Register the SHARED panel_* tools onto a `@modelcontextprotocol/sdk` McpServer