comfyui-mcp 0.48.21 → 0.48.22

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 (235) 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 +151 -50
  11. package/dist/orchestrator/index.js.map +1 -1
  12. package/dist/orchestrator/panel-agent.js +21 -4
  13. package/dist/orchestrator/panel-agent.js.map +1 -1
  14. package/dist/orchestrator/panel-tools.js +1098 -168
  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/services/api-nodes.js +5 -7
  19. package/dist/services/api-nodes.js.map +1 -1
  20. package/dist/services/download-cache.js +126 -18
  21. package/dist/services/download-cache.js.map +1 -1
  22. package/dist/services/download-jobs.js +463 -14
  23. package/dist/services/download-jobs.js.map +1 -1
  24. package/dist/services/download-progress.js +247 -2
  25. package/dist/services/download-progress.js.map +1 -1
  26. package/dist/services/env-capabilities.js +80 -9
  27. package/dist/services/env-capabilities.js.map +1 -1
  28. package/dist/services/generate-audio.js +16 -0
  29. package/dist/services/generate-audio.js.map +1 -1
  30. package/dist/services/local-models-fallback.js +7 -3
  31. package/dist/services/local-models-fallback.js.map +1 -1
  32. package/dist/services/manifest.js +180 -65
  33. package/dist/services/manifest.js.map +1 -1
  34. package/dist/services/model-resolver.js +267 -11
  35. package/dist/services/model-resolver.js.map +1 -1
  36. package/dist/services/node-management.js +38 -27
  37. package/dist/services/node-management.js.map +1 -1
  38. package/dist/services/output-dir.js +33 -3
  39. package/dist/services/output-dir.js.map +1 -1
  40. package/dist/services/panel-secrets.js +19 -7
  41. package/dist/services/panel-secrets.js.map +1 -1
  42. package/dist/services/queue-monitor.js +45 -2
  43. package/dist/services/queue-monitor.js.map +1 -1
  44. package/dist/services/queue-monitor.stall.test.js +78 -0
  45. package/dist/services/queue-monitor.stall.test.js.map +1 -0
  46. package/dist/services/secure-bridge.js +84 -0
  47. package/dist/services/secure-bridge.js.map +1 -1
  48. package/dist/services/storage/azure-blob.js +8 -3
  49. package/dist/services/storage/azure-blob.js.map +1 -1
  50. package/dist/services/storage/index.js +3 -3
  51. package/dist/services/storage/index.js.map +1 -1
  52. package/dist/services/storage/s3.js +9 -3
  53. package/dist/services/storage/s3.js.map +1 -1
  54. package/dist/services/ui-bridge.js +294 -24
  55. package/dist/services/ui-bridge.js.map +1 -1
  56. package/dist/services/workflow-converter.js +767 -72
  57. package/dist/services/workflow-converter.js.map +1 -1
  58. package/dist/services/workflow-lock.js +127 -27
  59. package/dist/services/workflow-lock.js.map +1 -1
  60. package/dist/services/workspace-env.js +48 -0
  61. package/dist/services/workspace-env.js.map +1 -1
  62. package/dist/tools/generate-audio.js +44 -0
  63. package/dist/tools/generate-audio.js.map +1 -1
  64. package/dist/tools/model-extras.js +24 -4
  65. package/dist/tools/model-extras.js.map +1 -1
  66. package/dist/tools/model-management.js +124 -14
  67. package/dist/tools/model-management.js.map +1 -1
  68. package/dist/tools/report-issue.js +220 -66
  69. package/dist/tools/report-issue.js.map +1 -1
  70. package/dist/tools/report-issue.test.js +223 -83
  71. package/dist/tools/report-issue.test.js.map +1 -1
  72. package/dist/tools/skills-access.js +7 -2
  73. package/dist/tools/skills-access.js.map +1 -1
  74. package/dist/tools/template-schema.js +5 -5
  75. package/dist/tools/template-schema.js.map +1 -1
  76. package/dist/tools/workflow-compose.js +18 -3
  77. package/dist/tools/workflow-compose.js.map +1 -1
  78. package/package.json +1 -1
  79. package/packs/anima/install-runpod.sh +0 -1
  80. package/packs/anima/install-windows.bat +0 -1
  81. package/packs/anima/manifest.yaml +0 -1
  82. package/packs/anima-img2img/install-runpod.sh +0 -1
  83. package/packs/anima-img2img/install-windows.bat +0 -1
  84. package/packs/anima-img2img/manifest.yaml +0 -1
  85. package/packs/anima-inpaint/install-runpod.sh +0 -1
  86. package/packs/anima-inpaint/install-windows.bat +0 -1
  87. package/packs/anima-inpaint/manifest.yaml +0 -1
  88. package/packs/anima-txt2img/install-runpod.sh +0 -1
  89. package/packs/anima-txt2img/install-windows.bat +0 -1
  90. package/packs/anima-txt2img/manifest.yaml +0 -1
  91. package/packs/artokun-flow/install-runpod.sh +0 -1
  92. package/packs/artokun-flow/install-windows.bat +0 -1
  93. package/packs/artokun-flow/manifest.yaml +0 -1
  94. package/packs/ernie/install-runpod.sh +0 -1
  95. package/packs/ernie/install-windows.bat +0 -1
  96. package/packs/ernie/manifest.yaml +0 -1
  97. package/packs/ernie-combo/install-runpod.sh +0 -1
  98. package/packs/ernie-combo/install-windows.bat +0 -1
  99. package/packs/ernie-combo/manifest.yaml +0 -1
  100. package/packs/ernie-img2img/install-runpod.sh +0 -1
  101. package/packs/ernie-img2img/install-windows.bat +0 -1
  102. package/packs/ernie-img2img/manifest.yaml +0 -1
  103. package/packs/ernie-txt2img/install-runpod.sh +0 -1
  104. package/packs/ernie-txt2img/install-windows.bat +0 -1
  105. package/packs/ernie-txt2img/manifest.yaml +0 -1
  106. package/packs/ideogram/install-runpod.sh +0 -1
  107. package/packs/ideogram/install-windows.bat +0 -1
  108. package/packs/ideogram/manifest.yaml +0 -1
  109. package/packs/ideogram-img2img/install-runpod.sh +0 -1
  110. package/packs/ideogram-img2img/install-windows.bat +0 -1
  111. package/packs/ideogram-img2img/manifest.yaml +0 -1
  112. package/packs/ideogram-txt2img/install-runpod.sh +0 -1
  113. package/packs/ideogram-txt2img/install-windows.bat +0 -1
  114. package/packs/ideogram-txt2img/manifest.yaml +0 -1
  115. package/packs/ltx-2.3/install-runpod.sh +0 -1
  116. package/packs/ltx-2.3/install-windows.bat +0 -1
  117. package/packs/ltx-2.3/manifest.yaml +0 -1
  118. package/packs/ltx-2.3-extender/install-runpod.sh +0 -1
  119. package/packs/ltx-2.3-extender/install-windows.bat +0 -1
  120. package/packs/ltx-2.3-extender/manifest.yaml +0 -1
  121. package/packs/ltx-2.3-extender-no-audio/install-runpod.sh +0 -1
  122. package/packs/ltx-2.3-extender-no-audio/install-windows.bat +0 -1
  123. package/packs/ltx-2.3-extender-no-audio/manifest.yaml +0 -1
  124. package/packs/ltx-2.3-flf/install-runpod.sh +0 -1
  125. package/packs/ltx-2.3-flf/install-windows.bat +0 -1
  126. package/packs/ltx-2.3-flf/manifest.yaml +0 -1
  127. package/packs/ltx-2.3-img2vid/install-runpod.sh +0 -1
  128. package/packs/ltx-2.3-img2vid/install-windows.bat +0 -1
  129. package/packs/ltx-2.3-img2vid/manifest.yaml +3 -4
  130. package/packs/ltx-2.3-txt2vid/install-runpod.sh +0 -1
  131. package/packs/ltx-2.3-txt2vid/install-windows.bat +0 -1
  132. package/packs/ltx-2.3-txt2vid/manifest.yaml +3 -4
  133. package/packs/ltx-2.3-xy-plot/install-runpod.sh +0 -1
  134. package/packs/ltx-2.3-xy-plot/install-windows.bat +0 -1
  135. package/packs/ltx-2.3-xy-plot/manifest.yaml +0 -1
  136. package/packs/ltx23-distill-3stage/install-runpod.sh +0 -1
  137. package/packs/ltx23-distill-3stage/install-windows.bat +0 -1
  138. package/packs/ltx23-distill-3stage/manifest.yaml +0 -1
  139. package/packs/qwen-image/install-runpod.sh +0 -1
  140. package/packs/qwen-image/install-windows.bat +0 -1
  141. package/packs/qwen-image/manifest.yaml +0 -1
  142. package/packs/qwen-image-edit/install-runpod.sh +0 -1
  143. package/packs/qwen-image-edit/install-windows.bat +0 -1
  144. package/packs/qwen-image-edit/manifest.yaml +0 -1
  145. package/packs/qwen-image-edit-edit/install-runpod.sh +0 -1
  146. package/packs/qwen-image-edit-edit/install-windows.bat +0 -1
  147. package/packs/qwen-image-edit-edit/manifest.yaml +0 -1
  148. package/packs/wan-animate/install-runpod.sh +0 -1
  149. package/packs/wan-animate/install-windows.bat +0 -1
  150. package/packs/wan-animate/manifest.yaml +0 -1
  151. package/packs/wan-animate-character/install-runpod.sh +0 -1
  152. package/packs/wan-animate-character/install-windows.bat +0 -1
  153. package/packs/wan-animate-character/manifest.yaml +0 -1
  154. package/packs/wan-animate-ofm/install-runpod.sh +0 -1
  155. package/packs/wan-animate-ofm/install-windows.bat +0 -1
  156. package/packs/wan-animate-ofm/manifest.yaml +0 -1
  157. package/packs/wan-longer-videos/install-runpod.sh +0 -1
  158. package/packs/wan-longer-videos/install-windows.bat +0 -1
  159. package/packs/wan-longer-videos/manifest.yaml +0 -1
  160. package/packs/wan-longer-videos-i2v/install-runpod.sh +0 -1
  161. package/packs/wan-longer-videos-i2v/install-windows.bat +0 -1
  162. package/packs/wan-longer-videos-i2v/manifest.yaml +0 -1
  163. package/packs/wan-longer-videos-i2v-96gb/install-runpod.sh +0 -1
  164. package/packs/wan-longer-videos-i2v-96gb/install-windows.bat +0 -1
  165. package/packs/wan-longer-videos-i2v-96gb/manifest.yaml +0 -1
  166. package/packs/wan-longer-videos-t2v/install-runpod.sh +0 -1
  167. package/packs/wan-longer-videos-t2v/install-windows.bat +0 -1
  168. package/packs/wan-longer-videos-t2v/manifest.yaml +0 -1
  169. package/packs/wan-longer-videos-t2v-96gb/install-runpod.sh +0 -1
  170. package/packs/wan-longer-videos-t2v-96gb/install-windows.bat +0 -1
  171. package/packs/wan-longer-videos-t2v-96gb/manifest.yaml +0 -1
  172. package/packs/wan-longer-videos-v2v/install-runpod.sh +0 -1
  173. package/packs/wan-longer-videos-v2v/install-windows.bat +0 -1
  174. package/packs/wan-longer-videos-v2v/manifest.yaml +0 -1
  175. package/packs/wan-longer-videos-v2v-96gb/install-runpod.sh +0 -1
  176. package/packs/wan-longer-videos-v2v-96gb/install-windows.bat +0 -1
  177. package/packs/wan-longer-videos-v2v-96gb/manifest.yaml +0 -1
  178. package/packs/wan-multitalk/install-runpod.sh +0 -1
  179. package/packs/wan-multitalk/install-windows.bat +0 -1
  180. package/packs/wan-multitalk/manifest.yaml +0 -1
  181. package/packs/wan-pusa-extend/install-runpod.sh +0 -1
  182. package/packs/wan-pusa-extend/install-windows.bat +0 -1
  183. package/packs/wan-pusa-extend/manifest.yaml +0 -1
  184. package/packs/wan-transparent/install-runpod.sh +0 -1
  185. package/packs/wan-transparent/install-windows.bat +0 -1
  186. package/packs/wan-transparent/manifest.yaml +0 -1
  187. package/packs/wan-transparent-img2vid/install-runpod.sh +0 -1
  188. package/packs/wan-transparent-img2vid/install-windows.bat +0 -1
  189. package/packs/wan-transparent-img2vid/manifest.yaml +0 -1
  190. package/packs/wan-transparent-img2vid-96gb/install-runpod.sh +0 -1
  191. package/packs/wan-transparent-img2vid-96gb/install-windows.bat +0 -1
  192. package/packs/wan-transparent-img2vid-96gb/manifest.yaml +0 -1
  193. package/packs/z-image-base/install-runpod.sh +0 -1
  194. package/packs/z-image-base/install-windows.bat +0 -1
  195. package/packs/z-image-base/manifest.yaml +0 -1
  196. package/packs/z-image-base-combo/install-runpod.sh +0 -1
  197. package/packs/z-image-base-combo/install-windows.bat +0 -1
  198. package/packs/z-image-base-combo/manifest.yaml +0 -1
  199. package/packs/z-image-base-controlnet/install-runpod.sh +0 -1
  200. package/packs/z-image-base-controlnet/install-windows.bat +0 -1
  201. package/packs/z-image-base-controlnet/manifest.yaml +0 -1
  202. package/packs/z-image-base-img2img/install-runpod.sh +0 -1
  203. package/packs/z-image-base-img2img/install-windows.bat +0 -1
  204. package/packs/z-image-base-img2img/manifest.yaml +0 -1
  205. package/packs/z-image-base-inpaint/install-runpod.sh +0 -1
  206. package/packs/z-image-base-inpaint/install-windows.bat +0 -1
  207. package/packs/z-image-base-inpaint/manifest.yaml +0 -1
  208. package/packs/z-image-base-txt2img/install-runpod.sh +0 -1
  209. package/packs/z-image-base-txt2img/install-windows.bat +0 -1
  210. package/packs/z-image-base-txt2img/manifest.yaml +0 -1
  211. package/packs/z-image-turbo/install-runpod.sh +0 -1
  212. package/packs/z-image-turbo/install-windows.bat +0 -1
  213. package/packs/z-image-turbo/manifest.yaml +0 -1
  214. package/packs/z-image-turbo-combo/install-runpod.sh +0 -1
  215. package/packs/z-image-turbo-combo/install-windows.bat +0 -1
  216. package/packs/z-image-turbo-combo/manifest.yaml +0 -1
  217. package/packs/z-image-turbo-controlnet/install-runpod.sh +0 -1
  218. package/packs/z-image-turbo-controlnet/install-windows.bat +0 -1
  219. package/packs/z-image-turbo-controlnet/manifest.yaml +0 -1
  220. package/packs/z-image-turbo-detail-daemon/install-runpod.sh +0 -1
  221. package/packs/z-image-turbo-detail-daemon/install-windows.bat +0 -1
  222. package/packs/z-image-turbo-detail-daemon/manifest.yaml +0 -1
  223. package/packs/z-image-turbo-img2img/install-runpod.sh +0 -1
  224. package/packs/z-image-turbo-img2img/install-windows.bat +0 -1
  225. package/packs/z-image-turbo-img2img/manifest.yaml +0 -1
  226. package/packs/z-image-turbo-inpainting/install-runpod.sh +0 -1
  227. package/packs/z-image-turbo-inpainting/install-windows.bat +0 -1
  228. package/packs/z-image-turbo-inpainting/manifest.yaml +0 -1
  229. package/packs/z-image-turbo-txt2img/install-runpod.sh +0 -1
  230. package/packs/z-image-turbo-txt2img/install-windows.bat +0 -1
  231. package/packs/z-image-turbo-txt2img/manifest.yaml +0 -1
  232. package/packs/z-image-xy-plot/install-runpod.sh +0 -1
  233. package/packs/z-image-xy-plot/install-windows.bat +0 -1
  234. package/packs/z-image-xy-plot/manifest.yaml +0 -1
  235. 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
+ }
224
484
  /**
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.
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.
231
511
  */
232
- async function waitForPanelReady(ctx, timing) {
233
- if (timing.settleMs > 0)
234
- await sleep(timing.settleMs);
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;
547
+ /**
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`.
572
+ */
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,
@@ -671,51 +1043,116 @@ function comfyWorkflowsDirs() {
671
1043
  join(base, "user", "workflows"),
672
1044
  ];
673
1045
  }
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) {
1046
+ /** Validate a parsed value is a UI/litegraph workflow (a top-level `nodes`
1047
+ * array), throwing a source-labelled error otherwise. */
1048
+ function assertUiWorkflow(parsed, sourceLabel) {
1049
+ if (!parsed || typeof parsed !== "object") {
1050
+ throw new Error(`${sourceLabel} did not parse to a workflow object.`);
1051
+ }
1052
+ if (!Array.isArray(parsed.nodes)) {
1053
+ throw new Error(`${sourceLabel} is not a UI workflow (missing a top-level \`nodes\` array). ` +
1054
+ `Provide a UI/litegraph workflow JSON, not API/prompt format.`);
1055
+ }
1056
+ return parsed;
1057
+ }
1058
+ /** Read + parse a UI workflow JSON by path. Resolves an ABSOLUTE path off the
1059
+ * orchestrator's disk, or a RELATIVE name authoritatively through the CONNECTED
1060
+ * ComfyUI's userdata API — which resolves under the server's RUNTIME
1061
+ * `--user-directory` (custom or default), so the RIGHT file always wins and a
1062
+ * stale same-named file under the guessed default dir can never shadow it
1063
+ * (#202). Only when the server can't serve the name (404 / unreachable) does it
1064
+ * fall back to the orchestrator's guessed local workflows dirs, so a
1065
+ * disk-staged file still opens. Guards: must be .json and must parse to a UI
1066
+ * workflow (a top-level `nodes` array). Fails loudly (never loads the wrong
1067
+ * file) when the name resolves nowhere. */
1068
+ async function readWorkflowFromPath(rawPath) {
679
1069
  const p = (rawPath ?? "").trim();
680
1070
  if (!p)
681
1071
  throw new Error("Provide a non-empty `path` to a workflow .json file.");
682
1072
  if (!/\.json$/i.test(p)) {
683
1073
  throw new Error(`"${p}" is not a .json file — pass the path to a ComfyUI workflow JSON.`);
684
1074
  }
685
- // Build the candidate absolute paths to try, in order.
686
- const candidates = [];
1075
+ const readLocal = (resolved) => {
1076
+ let parsed;
1077
+ try {
1078
+ parsed = JSON.parse(readFileSync(resolved, "utf8"));
1079
+ }
1080
+ catch (err) {
1081
+ throw new Error(`"${resolved}" is not valid JSON: ${err.message}`);
1082
+ }
1083
+ return assertUiWorkflow(parsed, `"${resolved}"`);
1084
+ };
1085
+ // ABSOLUTE path → the orchestrator's own disk, unchanged.
687
1086
  if (isAbsolute(p)) {
688
- candidates.push(resolve(p));
1087
+ const resolved = resolve(p);
1088
+ if (existsSync(resolved) && statSync(resolved).isFile())
1089
+ return readLocal(resolved);
1090
+ throw new Error(`No workflow file at "${p}". Looked under ${resolved}. ` +
1091
+ `Pass an absolute path, or a name relative to the ComfyUI workflows folder.`);
689
1092
  }
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));
696
- }
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;
1093
+ let outcome;
705
1094
  try {
706
- parsed = JSON.parse(readFileSync(resolved, "utf8"));
1095
+ const client = getClient();
1096
+ const encoded = encodeURIComponent(`workflows/${p.replace(/^[\\/]+/, "")}`);
1097
+ const res = await client.fetchApi(`/api/userdata/${encoded}`);
1098
+ if (res.ok) {
1099
+ // Read the body as TEXT and classify HERE so a malformed 2xx surfaces its
1100
+ // OWN error (no fallback), while ComfyUI's "200 + EMPTY body = file does
1101
+ // not exist" convention (some builds; see parseWorkflowLock) is treated as
1102
+ // an ABSENCE that DOES allow the local fallback — not a malformed error.
1103
+ const body = (await res.text()).trim();
1104
+ if (body === "") {
1105
+ outcome = { kind: "absent", detail: "was not in the ComfyUI userdata library (empty 200 response)" };
1106
+ }
1107
+ else {
1108
+ try {
1109
+ outcome = { kind: "found", parsed: JSON.parse(body) };
1110
+ }
1111
+ catch (err) {
1112
+ outcome = { kind: "malformed", detail: err instanceof Error ? err.message : String(err) };
1113
+ }
1114
+ }
1115
+ }
1116
+ else if (res.status === 404) {
1117
+ outcome = { kind: "absent", detail: "was not in the ComfyUI userdata library (HTTP 404)" };
1118
+ }
1119
+ else {
1120
+ outcome = { kind: "refused", detail: `ComfyUI userdata library returned HTTP ${res.status}` };
1121
+ }
707
1122
  }
708
1123
  catch (err) {
709
- throw new Error(`"${resolved}" is not valid JSON: ${err.message}`);
1124
+ outcome = {
1125
+ kind: "unreachable",
1126
+ detail: `ComfyUI userdata library was unreachable (${err instanceof Error ? err.message : String(err)})`,
1127
+ };
710
1128
  }
711
- if (!parsed || typeof parsed !== "object") {
712
- throw new Error(`"${resolved}" did not parse to a workflow object.`);
1129
+ if (outcome.kind === "found") {
1130
+ // A found-but-non-UI file must surface its own honest error, not silence.
1131
+ return assertUiWorkflow(outcome.parsed, `The workflow "${p}" from the ComfyUI userdata library`);
713
1132
  }
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.`);
1133
+ if (outcome.kind === "malformed") {
1134
+ throw new Error(`The workflow "${p}" in the ComfyUI userdata library is not valid JSON: ${outcome.detail}`);
717
1135
  }
718
- return parsed;
1136
+ if (outcome.kind === "refused") {
1137
+ // Server is reachable but did not serve the file — do NOT fall back to a
1138
+ // possibly-stale local file; report the status honestly.
1139
+ throw new Error(`Could not read "${p}" from the connected ComfyUI: ${outcome.detail}. ` +
1140
+ `Pass an absolute path, or a name shown by panel_list_workflows.`);
1141
+ }
1142
+ // outcome.kind is "absent" (404) or "unreachable" — fall back to the
1143
+ // orchestrator's guessed local workflows dirs (best-effort; only meaningful on
1144
+ // a same-machine ComfyUI whose user-dir matches the default layout, or a file
1145
+ // staged straight to disk).
1146
+ const localCandidates = [
1147
+ ...comfyWorkflowsDirs().map((dir) => resolve(dir, p)),
1148
+ resolve(process.cwd(), p), // orchestrator CWD as a last local resort
1149
+ ];
1150
+ const local = localCandidates.find((c) => existsSync(c) && statSync(c).isFile());
1151
+ if (local)
1152
+ return readLocal(local);
1153
+ throw new Error(`No workflow file at "${p}". It ${outcome.detail}, and it is not under the orchestrator's workflows ` +
1154
+ `dir (${comfyWorkflowsDirs().join(" or ") || "COMFYUI_PATH not set"}). ` +
1155
+ `Pass an absolute path, or a name shown by panel_list_workflows.`);
719
1156
  }
720
1157
  // IMPORTANT (Codex parity): use `z.array(z.number())` — NOT `z.tuple([...])` — for
721
1158
  // fixed-length coordinate vectors. zod's `.tuple()` emits JSON-Schema draft-04
@@ -829,22 +1266,53 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets) {
829
1266
  // (We gate inside the tool because the SDK's canUseTool is bypassed under
830
1267
  // bypassPermissions, which the panel agent runs in; the Codex HTTP path runs
831
1268
  // approvalPolicy "never", so the same in-tool gate is the only safeguard.)
832
- const confirm = async (question, header) => {
1269
+ const confirm = async (question, header, timeoutMs) => {
1270
+ // #360: the enclosing MCP `tools/call` is killed at ~300s. A hardcoded 300s
1271
+ // card wait had ZERO margin below that budget — so an unanswered confirm blew
1272
+ // the whole tool call (a transport timeout) instead of returning cleanly, and
1273
+ // any late answer was lost. CLAMP the card deadline under the budget (the same
1274
+ // getAskTiming() ceiling panel_ask uses for #486) and, on a reply-timeout,
1275
+ // poll the bridge's late-reply buffer for a bounded grace so a slow-but-valid
1276
+ // yes/no is still HONORED. A genuine no-answer returns "timeout" (reported
1277
+ // honestly by the caller), never a silent decline.
1278
+ const base = getAskTiming();
1279
+ // A caller may pass a tighter WHOLE-confirm budget (#536: panel_restart bounds
1280
+ // confirm+dispatch+readiness under the outer limit). Treat timeoutMs as the HARD
1281
+ // ceiling on deadline+grace so we never overrun the caller's budget, while still
1282
+ // never exceeding the ask clamp. Absent → the full clamp (deadline+grace).
1283
+ const total = typeof timeoutMs === "number"
1284
+ ? Math.max(1, Math.min(timeoutMs, base.deadlineMs + base.graceMs))
1285
+ : base.deadlineMs + base.graceMs;
1286
+ const deadlineMs = Math.max(1, Math.min(base.deadlineMs, total));
1287
+ const graceMs = Math.max(0, total - deadlineMs);
1288
+ const timing = { deadlineMs, graceMs, pollMs: base.pollMs };
1289
+ const askId = randomUUID();
833
1290
  try {
834
1291
  ensureReachable();
835
1292
  const reply = await bridge.send({
836
1293
  cmd: "ask_user",
1294
+ ask_id: askId,
837
1295
  question,
838
1296
  header,
839
1297
  options: [
840
1298
  { label: "Yes, go ahead", description: "" },
841
1299
  { label: "No, cancel", description: "" },
842
1300
  ],
843
- }, { tabId: ctx.tabId, timeoutMs: 300000 });
844
- return isAffirmative(reply);
1301
+ }, { tabId: ctx.tabId, timeoutMs: timing.deadlineMs });
1302
+ return isAffirmative(reply) ? "yes" : "no";
845
1303
  }
846
- catch {
847
- return false;
1304
+ catch (err) {
1305
+ // Only a card-reply TIMEOUT is recoverable/honest-as-timeout: poll the late
1306
+ // buffer, then report "timeout" if still unanswered. Any other error (no
1307
+ // panel, transport failure) → "no" so the destructive op is SKIPPED, exactly
1308
+ // as the previous catch-all did.
1309
+ if (isReplyTimeoutError(err)) {
1310
+ const late = await pollLateAskReply(bridge, askId, timing);
1311
+ if (late !== undefined)
1312
+ return isAffirmative(late) ? "yes" : "no";
1313
+ return "timeout";
1314
+ }
1315
+ return "no";
848
1316
  }
849
1317
  };
850
1318
  // EXPLICIT self-heal — see PanelToolCtx.rebindToActiveTab. Only rebinds when
@@ -880,11 +1348,90 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets) {
880
1348
  * what I have open" is the common ask, and requiring a save-to-disk round trip
881
1349
  * first derailed real sessions (deleted placeholder files, 404 tabs).
882
1350
  */
883
- async function resolveWorkflowInput(args, ctx) {
1351
+ /**
1352
+ * Rebuild a UI-format workflow ({ nodes, links }) from the panel's back-compat
1353
+ * `graph_get_state` reply (the #384 fallback). Each summarized node carries its
1354
+ * widget values keyed BY NAME (`widgets`) and its inputs' upstream source
1355
+ * (`connected_from`), so we materialize:
1356
+ * - nodes with `widgets_values` as the name→value OBJECT — convertUiToApi maps
1357
+ * those by name, which also sidesteps the positional widget-order pitfalls,
1358
+ * - a synthetic links array + per-input `link` ids from `connected_from`.
1359
+ * Returns null when the reply has no usable nodes.
1360
+ */
1361
+ function reconstructUiFromState(reply) {
1362
+ const r = reply;
1363
+ const nodesIn = r?.nodes;
1364
+ if (!Array.isArray(nodesIn) || nodesIn.length === 0)
1365
+ return null;
1366
+ // graph_get_state caps at MAX_STATE_NODES (100) and flags the overflow. A
1367
+ // truncated capture would silently yield an INCOMPLETE executable graph, so
1368
+ // refuse it — the caller then surfaces the actionable "pass pack/path/graph"
1369
+ // error rather than stripping a partial workflow.
1370
+ if (r?.truncated === true)
1371
+ return null;
1372
+ if (typeof r?.node_count === "number" && r.node_count > nodesIn.length)
1373
+ return null;
1374
+ const uiNodes = nodesIn.map((raw) => {
1375
+ const n = raw;
1376
+ const mode = n.mode === "mute" ? 2 : n.mode === "bypass" ? 4 : 0;
1377
+ return {
1378
+ id: n.id,
1379
+ type: n.type,
1380
+ mode,
1381
+ pos: [0, 0],
1382
+ inputs: (n.inputs ?? []).map((inp) => ({
1383
+ name: inp.name,
1384
+ type: inp.type ?? "*",
1385
+ link: null,
1386
+ })),
1387
+ outputs: (n.outputs ?? []).map((o) => ({
1388
+ name: o.name,
1389
+ type: o.type ?? "*",
1390
+ links: [],
1391
+ })),
1392
+ widgets_values: n.widgets && typeof n.widgets === "object"
1393
+ ? n.widgets
1394
+ : [],
1395
+ properties: {},
1396
+ ...(n.title ? { title: n.title } : {}),
1397
+ };
1398
+ });
1399
+ const byId = new Map(uiNodes.map((n) => [n.id, n]));
1400
+ const links = [];
1401
+ let linkId = 0;
1402
+ nodesIn.forEach((raw, idx) => {
1403
+ const inputs = raw.inputs ?? [];
1404
+ const tgt = uiNodes[idx];
1405
+ inputs.forEach((inp, slot) => {
1406
+ const from = inp.connected_from;
1407
+ if (!from || from.node_id == null || !byId.has(from.node_id))
1408
+ return;
1409
+ const id = ++linkId;
1410
+ tgt.inputs[slot].link = id;
1411
+ const srcNode = byId.get(from.node_id);
1412
+ const srcSlot = from.output_slot ?? 0;
1413
+ while (srcNode.outputs.length <= srcSlot) {
1414
+ srcNode.outputs.push({ name: `out_${srcNode.outputs.length}`, type: "*", links: [] });
1415
+ }
1416
+ srcNode.outputs[srcSlot].links.push(id);
1417
+ links.push([id, from.node_id, srcSlot, tgt.id, slot, inp.type ?? "*"]);
1418
+ });
1419
+ });
1420
+ return { nodes: uiNodes, links };
1421
+ }
1422
+ async function resolveWorkflowInput(args, ctx,
1423
+ // The live-canvas graph_get_state fallback (#384) is LOSSY: it reconstructs
1424
+ // only nodes/links/widgets (name-keyed) — no layout, groups, properties, or
1425
+ // subgraph definitions. That's fine for panel_strip_workflow (API/prompt output
1426
+ // for inspection/execution), but panel_flatten_workflow LOADS its result back
1427
+ // ONTO the canvas and panel_slice_workflow needs groups to find its seeds, so
1428
+ // they must NOT take this fallback — they keep the actionable "update your
1429
+ // panel" error instead. Only strip opts in.
1430
+ allowStateFallback = false) {
884
1431
  if (args.pack)
885
1432
  return readPackWorkflow(args.pack);
886
1433
  if (args.path)
887
- return readWorkflowFromPath(args.path);
1434
+ return await readWorkflowFromPath(args.path);
888
1435
  if (args.graph != null) {
889
1436
  return (typeof args.graph === "string"
890
1437
  ? JSON.parse(args.graph)
@@ -907,7 +1454,31 @@ async function resolveWorkflowInput(args, ctx) {
907
1454
  });
908
1455
  }
909
1456
  catch (err) {
910
- throw new Error(`Couldn't capture the live canvas (${err instanceof Error ? err.message : String(err)}). ` +
1457
+ // #384: a panel too old to register graph_serialize (added at 0.11.4) still
1458
+ // answers the back-compat `graph_get_state`. On an "Unknown command" rejection
1459
+ // ONLY (a genuine transport/timeout error must surface as-is), fall back to it
1460
+ // and reconstruct the graph so "strip the live canvas" works without a
1461
+ // save-to-disk round trip.
1462
+ const msg = err instanceof Error ? err.message : String(err);
1463
+ if (allowStateFallback && /unknown command/i.test(msg)) {
1464
+ try {
1465
+ const target = ctx.workflowTarget?.get(ctx.tabId);
1466
+ const stateCmd = target
1467
+ ? withWorkflowTarget({ cmd: "graph_get_state" }, target)
1468
+ : { cmd: "graph_get_state" };
1469
+ const stateReply = await ctx.bridge.send(stateCmd, {
1470
+ tabId: ctx.tabId,
1471
+ timeoutMs: 30000,
1472
+ });
1473
+ const rebuilt = reconstructUiFromState(stateReply);
1474
+ if (rebuilt)
1475
+ return rebuilt;
1476
+ }
1477
+ catch {
1478
+ /* fall through to the actionable error below */
1479
+ }
1480
+ }
1481
+ throw new Error(`Couldn't capture the live canvas (${msg}). ` +
911
1482
  `An older panel version may not support graph_serialize — pass pack, path, or graph instead.`);
912
1483
  }
913
1484
  const wf = reply?.workflow;
@@ -916,6 +1487,133 @@ async function resolveWorkflowInput(args, ctx) {
916
1487
  }
917
1488
  return wf;
918
1489
  }
1490
+ let askTimingOverride = null;
1491
+ // The enclosing MCP `tools/call` is killed at ~300s. The card deadline PLUS the
1492
+ // late-answer grace poll must finish UNDER that, or a slow-but-valid pick is lost
1493
+ // to the framework before we can honor it (#486). This is the HARD ceiling on the
1494
+ // total ask budget — applied even when env overrides ask for more, so a
1495
+ // misconfigured COMFYUI_PANEL_ASK_DEADLINE_S/GRACE_S can never recreate #486.
1496
+ const ASK_TOTAL_BUDGET_CAP_MS = 285_000;
1497
+ function getAskTiming() {
1498
+ if (askTimingOverride)
1499
+ return askTimingOverride;
1500
+ const pollMs = Math.round(parsePositiveNumberEnv("COMFYUI_PANEL_ASK_POLL_S", 0.5) * 1000);
1501
+ // Defaults keep deadline + grace comfortably under the budget (240 + up to 45 =
1502
+ // 285s). Env overrides are HARD-clamped: the deadline is capped first (leaving at
1503
+ // least a 1s slice), then the grace gets only whatever budget remains, so
1504
+ // deadline + grace is guaranteed ≤ ASK_TOTAL_BUDGET_CAP_MS regardless of input.
1505
+ let deadlineMs = Math.round(parsePositiveNumberEnv("COMFYUI_PANEL_ASK_DEADLINE_S", 240) * 1000);
1506
+ let graceMs = Math.round(parsePositiveNumberEnv("COMFYUI_PANEL_ASK_GRACE_S", 45) * 1000);
1507
+ deadlineMs = Math.min(deadlineMs, ASK_TOTAL_BUDGET_CAP_MS - 1000);
1508
+ graceMs = Math.min(graceMs, Math.max(0, ASK_TOTAL_BUDGET_CAP_MS - deadlineMs));
1509
+ return { deadlineMs, graceMs, pollMs };
1510
+ }
1511
+ /** True when an error is the bridge's reply-TIMEOUT for a card (the tab never
1512
+ * replied within the window), NOT a genuine transport/command error. Only a
1513
+ * timeout warrants polling the late-reply buffer for a slow-but-valid answer. */
1514
+ function isReplyTimeoutError(err) {
1515
+ const msg = err instanceof Error ? err.message : String(err ?? "");
1516
+ return /did not reply to .* within \d+\s*ms|backgrounded or frozen/i.test(msg);
1517
+ }
1518
+ /**
1519
+ * Actionable error string when THIS session has no interactive surface able to
1520
+ * render an ask card (so the ask would block with no way to answer), or `null`
1521
+ * when a card can render. Uses the bridge's `isHeadless` on the tab the ask would
1522
+ * target (the current tab if reachable, else the resolved active tab). Defensive:
1523
+ * an unknown/lightweight bridge, or an ambiguous active-tab resolution, returns
1524
+ * null so the normal send path surfaces its own clear error instead.
1525
+ */
1526
+ function askSurfaceError(ctx) {
1527
+ const b = ctx.bridge;
1528
+ if (typeof b.isHeadless !== "function")
1529
+ return null; // lightweight/unknown bridge
1530
+ let targetId = ctx.tabId;
1531
+ if (typeof b.canReach === "function" && !b.canReach(targetId)) {
1532
+ if (typeof b.resolveActiveTabId !== "function")
1533
+ return null;
1534
+ try {
1535
+ targetId = b.resolveActiveTabId();
1536
+ }
1537
+ catch {
1538
+ return null; // no single active tab — let the send path report it clearly
1539
+ }
1540
+ }
1541
+ if (!b.isHeadless(targetId))
1542
+ return null;
1543
+ return ("No interactive panel surface can render a choice card in this session — the " +
1544
+ "connected client is canvas-less (a mobile mirror, a remote/headless viewer, or " +
1545
+ "an exec/headless run), so panel_ask can't be answered here and would block. Ask " +
1546
+ "the user directly in plain chat text, or invoke panel_ask from an interactive " +
1547
+ "ComfyUI browser tab (not nested inside an exec/headless call).");
1548
+ }
1549
+ /** Poll the bridge's late-reply buffer for a validated ask answer that arrived
1550
+ * after the card-reply timeout, up to the grace budget. undefined if none. */
1551
+ async function pollLateAskReply(bridge, askId, timing) {
1552
+ const take = bridge
1553
+ .takeLateAskReply;
1554
+ if (typeof take !== "function")
1555
+ return undefined;
1556
+ const deadline = Date.now() + timing.graceMs;
1557
+ for (;;) {
1558
+ const late = take.call(bridge, askId);
1559
+ if (late !== undefined)
1560
+ return late;
1561
+ const left = deadline - Date.now();
1562
+ if (left <= 0)
1563
+ return undefined;
1564
+ await sleep(Math.max(1, Math.min(timing.pollMs, left)));
1565
+ }
1566
+ }
1567
+ /**
1568
+ * Run a panel_ask: render the choice card and return the user's pick. Clamps the
1569
+ * card deadline under the MCP tools/call budget and, on a reply-timeout, honors a
1570
+ * late-but-valid answer from the bridge's late-reply buffer before failing (#486).
1571
+ * Sent DIRECTLY over the bridge (like the confirm/consent cards) so a stable
1572
+ * `ask_id` can key the late-reply buffer.
1573
+ */
1574
+ async function askUserWithGrace(ctx, ask) {
1575
+ const timing = getAskTiming();
1576
+ const askId = randomUUID();
1577
+ const cmd = {
1578
+ cmd: "ask_user",
1579
+ ask_id: askId,
1580
+ question: ask.question,
1581
+ options: ask.options,
1582
+ header: ask.header,
1583
+ multi_select: ask.multi_select,
1584
+ };
1585
+ try {
1586
+ ctx.ensureReachable?.();
1587
+ const reply = await ctx.bridge.send(cmd, {
1588
+ tabId: ctx.tabId,
1589
+ timeoutMs: timing.deadlineMs,
1590
+ });
1591
+ return ok(reply);
1592
+ }
1593
+ catch (err) {
1594
+ if (isReplyTimeoutError(err)) {
1595
+ const late = await pollLateAskReply(ctx.bridge, askId, timing);
1596
+ if (late !== undefined)
1597
+ return ok(late);
1598
+ return fail("The question card was not answered in time (or no interactive panel surface " +
1599
+ "rendered it — e.g. an exec/headless run), so nothing was selected. If you " +
1600
+ "still need the decision, ask the user directly in plain chat text, or " +
1601
+ "re-invoke panel_ask from an interactive ComfyUI tab.");
1602
+ }
1603
+ return fail(err);
1604
+ }
1605
+ }
1606
+ export const __panelAskTestHooks = {
1607
+ /** Inject fast ask timing so tests don't wait the real deadline/grace. */
1608
+ setAskTiming(timing) {
1609
+ askTimingOverride = timing;
1610
+ },
1611
+ /** The env-derived (hard-clamped) ask timing, for the budget-cap test. */
1612
+ getAskTiming,
1613
+ ASK_TOTAL_BUDGET_CAP_MS,
1614
+ askSurfaceError,
1615
+ isReplyTimeoutError,
1616
+ };
919
1617
  /**
920
1618
  * The SINGLE source of truth for the panel_* tool surface. Both transports
921
1619
  * register these exact definitions, so the Claude (in-process) and Codex (HTTP)
@@ -1047,7 +1745,12 @@ export function buildPanelToolDefs() {
1047
1745
  }, async (args, ctx) => ctx.call({ cmd: "graph_add_node", class_type: args.class_type, pos: args.pos, title: args.title })),
1048
1746
  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
1747
  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"))) {
1748
+ const decision = await ctx.confirm("Clear the canvas? This removes every node from the open workflow. (One Ctrl+Z undoes it.)", "Clear canvas");
1749
+ if (decision === "timeout") {
1750
+ return ok("Timed out waiting for your confirmation, so I left the canvas as-is. " +
1751
+ "Tell me to clear it again when you're ready.");
1752
+ }
1753
+ if (decision !== "yes") {
1051
1754
  return ok("Cancelled — the canvas was left as-is.");
1052
1755
  }
1053
1756
  return ctx.call({ cmd: "graph_clear" });
@@ -1074,7 +1777,9 @@ export function buildPanelToolDefs() {
1074
1777
  .optional()
1075
1778
  .describe("Inline UI workflow (object or JSON string) to strip instead of a pack/path."),
1076
1779
  }, async (args, ctx) => {
1077
- const raw = await resolveWorkflowInput(args, ctx);
1780
+ // strip opts into the lossy live-canvas fallback (#384) — its API/prompt
1781
+ // output is for inspection/execution, never reloaded onto the canvas.
1782
+ const raw = await resolveWorkflowInput(args, ctx, true);
1078
1783
  const ui = raw;
1079
1784
  const bulk = await getObjectInfo();
1080
1785
  const objectInfo = await backfillObjectInfo(bulk, collectNodeTypes(ui));
@@ -1195,9 +1900,10 @@ export function buildPanelToolDefs() {
1195
1900
  data = readPackWorkflow(args.pack);
1196
1901
  }
1197
1902
  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);
1903
+ // Read an arbitrary workflow JSON server-side a local disk path, or
1904
+ // (for a relative name under a custom --user-directory) the connected
1905
+ // ComfyUI's userdata API — keeping the big JSON out of chat (#202).
1906
+ data = await readWorkflowFromPath(args.path);
1201
1907
  }
1202
1908
  else if (args.graph != null) {
1203
1909
  data = typeof args.graph === "string" ? JSON.parse(args.graph) : args.graph;
@@ -1247,13 +1953,27 @@ export function buildPanelToolDefs() {
1247
1953
  node_id: z.number().int().describe("Node id whose input to disconnect."),
1248
1954
  input: slotRef.optional().describe("Input slot name or index (default 0)."),
1249
1955
  }, 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.", {
1956
+ 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
1957
  node_id: z.number().int().describe("Node id from panel_graph_outline / panel_query_graph."),
1252
1958
  widget: z.string().describe("Widget name (e.g. 'steps', 'cfg', 'text')."),
1253
1959
  value: z
1254
1960
  .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 })),
1961
+ .optional()
1962
+ .describe("New value. Must match the widget's expected type. Optional only when `clear: true` is set (which forces an empty string)."),
1963
+ clear: z
1964
+ .boolean()
1965
+ .optional()
1966
+ .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`."),
1967
+ }, async (args, ctx) => {
1968
+ // Distinguish "value present but empty" from "value absent" by key
1969
+ // presence, NOT a truthiness check — an empty string is a legitimate
1970
+ // value. `clear: true` is the transport-independent way to set "".
1971
+ const value = args.clear === true ? "" : args.value;
1972
+ if (value === undefined) {
1973
+ return fail("panel_set_widget needs a `value`. To set an empty string, pass `clear: true` (some clients drop an empty-string `value`).");
1974
+ }
1975
+ return ctx.call({ cmd: "graph_set_widget", node_id: args.node_id, widget: args.widget, value });
1976
+ }),
1257
1977
  def("panel_move_node", "Move a node to a new canvas position [x, y] in the user's open graph. Undoable.", {
1258
1978
  node_id: z.number().int().describe("Node id from panel_graph_outline / panel_query_graph."),
1259
1979
  pos: xy().describe("New canvas [x, y] (two numbers)."),
@@ -1474,7 +2194,15 @@ export function buildPanelToolDefs() {
1474
2194
  if (args.target_kind !== "env") {
1475
2195
  return ok("The built-in comfyui server takes secrets as env vars — use target_kind 'env' (e.g. key 'CIVITAI_API_TOKEN').");
1476
2196
  }
1477
- setComfyuiSecret(args.key, `${args.value_prefix ?? ""}${secret}`);
2197
+ setComfyuiSecret(args.key, `${args.value_prefix ?? ""}${secret}`, {
2198
+ // This save ANSWERS an outstanding agent secret request — mark it so
2199
+ // the orchestrator injects the "retry the action" nudge, and carry
2200
+ // the requesting tab so ONLY that tab's agent is nudged (never a
2201
+ // broadcast to unrelated tabs). A Settings-panel slot save omits
2202
+ // both and never nudges (#164).
2203
+ requested: true,
2204
+ tabId: ctx.tabId,
2205
+ });
1478
2206
  // Redacted ack ONLY — the secret never enters the agent's context. The
1479
2207
  // respawn is deferred to this turn's end, so this is accurate.
1480
2208
  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 +2286,12 @@ export function buildPanelToolDefs() {
1558
2286
  .describe("Step state (default 'pending'). Mark the one you're on 'active'."),
1559
2287
  }))
1560
2288
  .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)),
2289
+ },
2290
+ // #322: a 5s ack deadline false-timed-out a responsive session whose tab was
2291
+ // momentarily backgrounded. set_todo is a non-destructive, idempotent full-
2292
+ // replace UI write (already in RETRY_SAFE_CMDS), so give it the same sane 15s
2293
+ // bound as the other UI-state writes (workflow_save) instead of a tight 5s.
2294
+ async (args, ctx) => ctx.call({ cmd: "set_todo", items: args.items }, 15000)),
1562
2295
  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
2296
  query: z
1564
2297
  .string()
@@ -1737,15 +2470,22 @@ export function buildPanelToolDefs() {
1737
2470
  .describe("The choices (at least 2). An 'Other' free-text field is added automatically."),
1738
2471
  header: z.string().optional().describe("Very short label/chip for the card (e.g. 'Sampler')."),
1739
2472
  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)),
2473
+ }, async (args, ctx) => {
2474
+ // #300: fail FAST with an actionable error when there is no interactive
2475
+ // surface to render the card (a canvas-less/headless client, or an exec/
2476
+ // headless run), rather than blocking with no way to answer.
2477
+ const surfaceErr = askSurfaceError(ctx);
2478
+ if (surfaceErr)
2479
+ return fail(surfaceErr);
2480
+ // #486: clamp the card deadline under the MCP tools/call budget and honor a
2481
+ // late-but-valid answer via the bridge's late-reply buffer.
2482
+ return askUserWithGrace(ctx, {
2483
+ question: args.question,
2484
+ options: args.options,
2485
+ header: args.header,
2486
+ multi_select: args.multi_select,
2487
+ });
2488
+ }),
1749
2489
  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
1750
2490
  ? ctx.call({ cmd: "workflow_save_as", name: args.name }, 15000)
1751
2491
  : ctx.call({ cmd: "workflow_save" }, 15000)),
@@ -2010,100 +2750,290 @@ export function buildPanelToolDefs() {
2010
2750
  }, async (args, ctx) => ctx.call({ cmd: "graph_update_node", id: args.id, version: args.version, channel: args.channel, mode: args.mode }, 30000)),
2011
2751
  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
2752
  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"))) {
2753
+ // Whole-handler budget (#536): confirm + dispatch + readiness INCLUDING
2754
+ // the legacy path's UNPREEMPTIBLE synchronous execSync blocks — must ALL finish
2755
+ // under the outer ~300s tools/call limit. 255s + the legacy admission rule below
2756
+ // (kill+relaunch starts only with >=130s left, its ~40s of sync work FRONT-LOADED)
2757
+ // means the handler PROVABLY returns well under 300s. The confirm wait is bound
2758
+ // to the remaining budget (its deadline+grace can't overrun it — see confirm).
2759
+ const OVERALL_MAX_MS = 255_000;
2760
+ const overallDeadline = Date.now() + OVERALL_MAX_MS;
2761
+ 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()));
2762
+ if (decision === "timeout") {
2763
+ return ok("Timed out waiting for your confirmation, so I did NOT restart ComfyUI. " +
2764
+ "Tell me to restart it and I'll go ahead.");
2765
+ }
2766
+ if (decision !== "yes") {
2014
2767
  return ok("Cancelled — ComfyUI was not restarted.");
2015
2768
  }
2016
- const res = await ctx.call({ cmd: "comfy_reboot", force: force === true }, 15000);
2769
+ // Heal an orphaned session onto the live tab FIRST, then bind the reboot dispatch
2770
+ // to that ONE tab id (no await between capture and dispatch, so JS run-to-
2771
+ // completion prevents any rebind in between). The boot-endpoint probe target is
2772
+ // server-authorized + immutable, bound to the exact host FAMILY the reboot goes
2773
+ // to (null unless the bound tab provably fronts our boot instance).
2774
+ ctx.ensureReachable?.();
2775
+ const boundTabId = ctx.tabId;
2776
+ const healthBase = captureRebootHealthBase(ctx);
2777
+ const timing = getPanelRebootTiming();
2778
+ const dispatchTimeout = Math.max(1, Math.min(15000, overallDeadline - Date.now()));
2779
+ // CONCURRENT OBSERVATION (coordinator): start probing the fixed boot endpoint NOW,
2780
+ // in parallel with the dispatch, so a FAST reboot whose down→up completes entirely
2781
+ // inside the ack/drop/timeout window is still captured (the reopened #509 fast-reboot
2782
+ // false-timeout). COUNTING stays post-write via the gate: the observer neither probes
2783
+ // nor counts until gate.dispatched flips (the instant AFTER the socket write), so a
2784
+ // pre-dispatch down never contributes. gate.deadline starts at the whole-handler cap
2785
+ // (probing spans the ack window) and is tightened to ack-completion + budget below.
2786
+ //
2787
+ // INHERENT TRADEOFF (coordinator, verified: no early-accept signal exists — the bridge
2788
+ // resolves send() only with the single rid-correlated {rebooting} reply, so accept vs
2789
+ // REFUSE is known only IN that reply). To catch a fast reboot we MUST probe DURING the
2790
+ // ack window, i.e. before we know accept/refuse. The residual is BENIGN and bounded:
2791
+ // • the probe targets ONLY the orchestrator's OWN immutable, server-authorized boot
2792
+ // ComfyUI (captureRebootHealthBase → getBootLocalComfyUIBaseUrl) with the correct
2793
+ // configured auth — never a client-advertised, cross-family, or wrong instance, so
2794
+ // it is NOT an auth leak or a wrong-instance probe (handshake-Origin gated above);
2795
+ // • a genuinely REFUSED reboot does NOT restart ComfyUI, so no REAL ECONNREFUSED→
2796
+ // healthy cycle occurs to certify; and even a CONTRIVED one is explicitly discarded
2797
+ // (the refusal branch below returns the refusal verbatim and never reads the
2798
+ // observer — a refusal can NEVER certify).
2799
+ // Eliminating even this harmless own-endpoint read would require probing only AFTER the
2800
+ // reply, which reopens the #509 fast-reboot false-timeout — an unacceptable regression.
2801
+ let signalDispatched;
2802
+ const gate = {
2803
+ dispatched: false,
2804
+ dispatchedAt: Number.POSITIVE_INFINITY,
2805
+ cancelled: false,
2806
+ deadline: overallDeadline,
2807
+ waitDispatched: new Promise((r) => {
2808
+ signalDispatched = r;
2809
+ }),
2810
+ };
2811
+ const recoveryPromise = healthBase != null
2812
+ ? observeRecovery(timing, gate.deadline, { healthBase, gate })
2813
+ : null;
2814
+ // The AUTHORITATIVE, TYPED dispatch outcome from the bridge rejection (if any):
2815
+ // false = a PRE-write send failure (nothing transmitted), true = a POST-write
2816
+ // mid-command OUTCOME-UNKNOWN drop / reply-timeout. Captured from the RAW error —
2817
+ // text can't defeat it — so a pre-write failure whose detail happens to quote
2818
+ // "OUTCOME UNKNOWN" is still categorically NOT-dispatched (coordinator P1).
2819
+ let res;
2820
+ let dispatchOutcome;
2821
+ // ctx.bridge.send()'s Promise executor writes to the socket SYNCHRONOUSLY, so by the
2822
+ // time it returns the promise the command has been written (or synchronously pre-write
2823
+ // failed). Open the counting gate right here — this is the POST-write instant — then
2824
+ // await the ack. Probing (already running) begins the moment this flips.
2825
+ const sendPromise = ctx.bridge.send({ cmd: "comfy_reboot", force: force === true }, { tabId: boundTabId, timeoutMs: dispatchTimeout });
2826
+ gate.dispatched = true;
2827
+ gate.dispatchedAt = Date.now();
2828
+ // Wake the observer's FIRST probe IMMEDIATELY (microtask — no timer window) now that
2829
+ // the command has been written. Resolved on EVERY path (accept / drop / refuse /
2830
+ // pre-write failure), so the observer never hangs on gate.waitDispatched.
2831
+ signalDispatched();
2832
+ try {
2833
+ res = ok(await sendPromise);
2834
+ }
2835
+ catch (err) {
2836
+ res = fail(err);
2837
+ dispatchOutcome = dispatchOutcomeOf(err);
2838
+ }
2839
+ // A PRE-write send failure means nothing was transmitted — the reboot never happened,
2840
+ // so NOTHING may certify: abort the concurrent observer immediately (coordinator P1).
2841
+ if (dispatchOutcome === false)
2842
+ gate.cancelled = true;
2017
2843
  // Classify the reboot dispatch:
2018
2844
  // - 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.
2845
+ // - EXPECTED DROP: the reboot handler exits the instant it accepts the request,
2846
+ // so ComfyUI (and the tab it serves) goes down before it can ack — a bridge
2847
+ // mid-command "OUTCOME UNKNOWN"/disconnect. That drop IS the accept + went-down
2848
+ // signal (#493, panel #222/#263/#266/#306/#307).
2849
+ // - REFUSAL: a busy-guard / Manager-forbidden / no-endpoint refusal — the server
2850
+ // is still up and was NOT restarted; return it verbatim and touch nothing.
2027
2851
  const fired = rebootConfirmed(res);
2028
- const dropped = !fired && rebootDropped(res);
2852
+ // A pre-write send failure (typed dispatchOutcome === false) is categorically NOT an
2853
+ // accepted drop — never enter the probing path for a command that never left. The
2854
+ // text check (rebootDropped) is a defense-in-depth fallback for older bridges that
2855
+ // don't carry the typed flag.
2856
+ const dropped = !fired && dispatchOutcome !== false && (dispatchOutcome === true || rebootDropped(res));
2029
2857
  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)) {
2858
+ // NOT accepted (e.g. a rebooting:false busy-guard/security REFUSAL). BELT-AND-
2859
+ // SUSPENDERS (coordinator): EXPLICITLY DISCARD any cycle the concurrent observer may
2860
+ // have sampled during the sub-ack window a refusal must NEVER certify. We cancel
2861
+ // the observer and, crucially, never read recoveryPromise on this path: whatever it
2862
+ // resolved to (even a contrived ready:true) is dropped, and we return the refusal
2863
+ // verbatim. (The legacy no-endpoint fallback below starts its OWN fresh observation
2864
+ // after the restart's synchronous work; it does not reuse this observer.)
2865
+ gate.cancelled = true;
2866
+ void recoveryPromise; // discarded — a refused reboot can never yield ready:true
2867
+ // If the SOLE reason is NO Manager reboot endpoint (legacy Manager
2868
+ // 3.x — #425, panel #253/#266) AND the target is a LOCAL, process-controllable
2869
+ // ComfyUI, fall back to the headless managed restart (kill + relaunch). A
2870
+ // busy-guard / security refusal is NOT eligible (rebootNoEndpoint excludes them).
2871
+ if (!isRemoteMode() &&
2872
+ rebootNoEndpoint(res) &&
2873
+ // INSTANCE BINDING: restartComfyUI() acts on the orchestrator's GLOBAL config
2874
+ // target (a hello can retarget it). Only run it when the bound tab provably
2875
+ // fronts our OWN boot instance AND that boot instance is the CURRENT global
2876
+ // target — so the relaunch cycles the SAME instance this tab rebooted.
2877
+ healthBase != null &&
2878
+ sameHttpBase(getComfyUIBaseUrl(), healthBase)) {
2879
+ // The managed kill+relaunch does UNPREEMPTIBLE synchronous execSync work — PID
2880
+ // discovery (~5+8s) + termination (~10s) + first port-free lookup (~13s) ≈ 40s
2881
+ // worst case (Windows) — that a Promise.race CANNOT interrupt, and it BLOCKS the
2882
+ // observer during that window. Admit it ONLY with enough budget for that sync
2883
+ // work AND a full cold-start observation AFTER it, and give the observer a
2884
+ // deadline that spans BOTH (coordinator P1: the proof deadline must start after,
2885
+ // not before, the restart's synchronous work — otherwise a genuine cold start
2886
+ // that finishes at sync+coldStart false-times-out).
2887
+ const LEGACY_SYNC_WORST_CASE_MS = 40_000; // execSync PID lookup + kill + port-free
2888
+ const LEGACY_COLD_START_OBS_MS = 100_000; // cold-start observation AFTER the sync
2889
+ const LEGACY_RESTART_MIN_BUDGET_MS = LEGACY_SYNC_WORST_CASE_MS + LEGACY_COLD_START_OBS_MS;
2890
+ if (overallDeadline - Date.now() < LEGACY_RESTART_MIN_BUDGET_MS) {
2891
+ return ok({
2892
+ rebooting: false,
2893
+ ready: false,
2894
+ confirmed_cycle: false,
2895
+ note: "The built-in Manager exposed no reboot endpoint (legacy Manager 3.x), and " +
2896
+ "there isn't enough remaining time to safely run the headless managed restart " +
2897
+ "(kill + relaunch). ComfyUI was NOT restarted — retry panel_restart_comfyui " +
2898
+ "(a fresh call gets the full budget).",
2899
+ });
2900
+ }
2901
+ // A managed kill+relaunch restarts ComfyUI out-of-band, so drop the memoized
2902
+ // caches. The observer watches the boot endpoint itself with a deadline spanning
2903
+ // the ~40s blocking sync + a full cold-start window, and certifies ONLY on an
2904
+ // OBSERVED down→up — a never-restarted healthy endpoint (a Desktop first-healthy
2905
+ // Manager-reboot / preflight no-op) is honestly couldn't-confirm (coordinator P1).
2906
+ resetClient();
2907
+ resetObjectInfoCache();
2908
+ // The observation window spans the ~40s blocking sync + a full cold-start
2909
+ // window. (Under a test timing override, use the injected budget instead so the
2910
+ // never-certify cases don't wait the real ~140s.)
2911
+ const legacyProofWindow = panelRebootTimingOverride
2912
+ ? timing.settleMs + timing.budgetMs
2913
+ : LEGACY_RESTART_MIN_BUDGET_MS;
2914
+ const proofDeadline = Math.min(Date.now() + legacyProofWindow, overallDeadline);
2915
+ const proofPromise = observeRecovery(timing, proofDeadline, { healthBase });
2916
+ const restartBudget = Math.max(1, overallDeadline - Date.now());
2039
2917
  let restart;
2918
+ let restartTimer;
2040
2919
  try {
2041
- restart = await restartComfyUI();
2920
+ restart = await Promise.race([
2921
+ restartComfyUI(),
2922
+ new Promise((resolve) => {
2923
+ restartTimer = setTimeout(() => resolve(undefined), restartBudget);
2924
+ restartTimer.unref?.();
2925
+ }),
2926
+ ]);
2042
2927
  }
2043
2928
  catch (err) {
2929
+ clearTimeout(restartTimer);
2930
+ void proofPromise.catch(() => { }); // self-terminates at proofDeadline
2044
2931
  return fail("The built-in Manager exposed no reboot endpoint (legacy Manager 3.x), " +
2045
2932
  "and the headless managed restart also failed: " +
2046
2933
  (err instanceof Error ? err.message : String(err)) +
2047
2934
  " — restart ComfyUI on the host, then reconnect.");
2048
2935
  }
2049
- if (!restart.started) {
2936
+ clearTimeout(restartTimer);
2937
+ // DEFINITIVE no-restart: a spawn failure, OR restartComfyUI refused before
2938
+ // stopping anything (no process found / unsafe relaunch → stopped:false &&
2939
+ // started:false). The process was NOT cycled, so the still-healthy endpoint is
2940
+ // the OLD one — fail clearly rather than certify a no-op (coordinator P1).
2941
+ if (restart?.spawn_error ||
2942
+ (restart != null && restart.stopped !== true && restart.started !== true)) {
2943
+ void proofPromise.catch(() => { });
2050
2944
  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} ` +
2945
+ "Tried the headless managed restart (kill + relaunch), but it did not restart " +
2946
+ `ComfyUI: ${restart?.message ?? "unknown error"} ` +
2053
2947
  "Restart ComfyUI on the host, then reconnect.");
2054
2948
  }
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);
2949
+ // Otherwise (the process WAS stopped/started, or restartComfyUI's own readiness
2950
+ // poll merely expired neither terminal) DEFER to OUR OWN observed DOWN→UP.
2951
+ const recovery = await proofPromise;
2952
+ const observed = recovery.via === "observed-cycle";
2062
2953
  return ok({
2063
2954
  rebooting: true,
2064
2955
  ready: recovery.ready,
2956
+ confirmed_cycle: observed, // true = we directly observed the down→up cycle
2065
2957
  recovered_ms: recovery.waited_ms,
2066
2958
  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."}`,
2959
+ saw_down: recovery.sawDown,
2960
+ via: recovery.ready ? recovery.via : undefined,
2961
+ note: "ComfyUI-Manager (legacy 3.x) had no reboot endpoint; ran the headless managed " +
2962
+ "restart (kill + relaunch) " +
2963
+ (recovery.ready
2964
+ ? `and it came back healthy in ${(recovery.waited_ms / 1000).toFixed(1)}s` +
2965
+ (observed ? " (observed it go down then come back)." : " (cycle not directly observed).")
2966
+ : `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
2967
  });
2073
2968
  }
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).
2969
+ // Genuine refusal (busy guard / security / no eligible fallback) return
2970
+ // verbatim; do NOT reset caches (that would close the shared client mid-render).
2077
2971
  return res;
2078
2972
  }
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.
2973
+ // ACCEPTED. A reboot restarts ComfyUI out-of-band, so the orchestrator's cached WS
2974
+ // client + /object_info go stale (#353/#357/#378/#394) drop both caches.
2086
2975
  resetClient();
2087
2976
  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);
2977
+ // Observe recovery. There is exactly ONE sound proof that THIS ComfyUI instance
2978
+ // actually cycled: a directly OBSERVED down→up on the server-authorized, immutable,
2979
+ // family-bound boot endpoint (observeRecovery). We do NOT fabricate a second proof
2980
+ // from a weaker proxy. In particular a panel tab disconnecting→reconnecting proves
2981
+ // only that a panel↔orchestrator socket churned NOT that the (possibly remote)
2982
+ // ComfyUI cycled; `tab_id` is client-supplied and a different same-kind socket can
2983
+ // take that id over with a fresh nonce, so a tab reconnect can never certify a
2984
+ // same-instance restart (codex gate). So when there is NO probeable boot endpoint
2985
+ // (remote / cloud / older / untrusted-locality panel), we HONESTLY report the reboot
2986
+ // as dispatched-and-accepted but NOT server-confirmable — a non-error result that
2987
+ // tells the caller to verify, NOT the #509 false-TIMEOUT *error* (the real #509 local
2988
+ // case is a probeable boot endpoint and is certified by observeRecovery below).
2989
+ if (healthBase == null) {
2990
+ // No probeable boot endpoint — the concurrent observer was never started.
2991
+ return ok({
2992
+ rebooting: true,
2993
+ ready: false,
2994
+ confirmed_cycle: false,
2995
+ dispatched: true,
2996
+ note: "ComfyUI restart was dispatched and accepted; it is restarting out-of-band. " +
2997
+ "There is no local boot endpoint I can safely probe from here, so I can't " +
2998
+ "confirm it finished coming back — a panel reconnect wouldn't prove this " +
2999
+ "instance actually cycled. Check health_check / panel_node_queue_status in a " +
3000
+ "few seconds to confirm it's back.",
3001
+ });
3002
+ }
3003
+ // The concurrent observer has been probing since dispatch (catching a fast down→up
3004
+ // inside the ack window). Now measure the readiness budget from ACK COMPLETION — so a
3005
+ // slow ack doesn't eat it — by tightening the live deadline, then await the verdict.
3006
+ // Both fired and dropped are AMBIGUOUS (the panel emits rebooting:true even when it
3007
+ // only INFERS a reboot from a dropped fetch), so certification requires an OBSERVED
3008
+ // down→up, which the observer has been (and continues) watching for.
3009
+ gate.deadline = Math.min(Date.now() + timing.budgetMs, overallDeadline);
3010
+ const recovery = await recoveryPromise;
2097
3011
  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.");
3012
+ const waited = Math.round(recovery.waited_ms / 1000);
3013
+ return ok({
3014
+ rebooting: true,
3015
+ ready: false,
3016
+ confirmed_cycle: false,
3017
+ recovered_ms: recovery.waited_ms,
3018
+ probes: recovery.attempts,
3019
+ saw_down: recovery.sawDown,
3020
+ note: recovery.sawDown
3021
+ ? `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.`
3022
+ : `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.`,
3023
+ });
2100
3024
  }
2101
3025
  return ok({
2102
3026
  rebooting: true,
2103
3027
  ready: true,
3028
+ confirmed_cycle: true, // we directly observed the down→up cycle on the boot endpoint
2104
3029
  recovered_ms: recovery.waited_ms,
2105
3030
  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)" : ""}.`,
3031
+ saw_down: recovery.sawDown,
3032
+ via: recovery.via,
3033
+ note: `ComfyUI restart accepted and it is healthy again in ${(recovery.waited_ms / 1000).toFixed(1)}s` +
3034
+ " (observed it go down then come back)" +
3035
+ (dropped ? "; connection dropped as expected while it went down" : "") +
3036
+ ".",
2107
3037
  });
2108
3038
  }),
2109
3039
  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)),