comfyui-mcp 0.48.21 → 0.48.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (252) hide show
  1. package/README.md +2 -2
  2. package/dist/comfyui/client.js +46 -6
  3. package/dist/comfyui/client.js.map +1 -1
  4. package/dist/config.js +15 -0
  5. package/dist/config.js.map +1 -1
  6. package/dist/orchestrator/error-text.js +29 -1
  7. package/dist/orchestrator/error-text.js.map +1 -1
  8. package/dist/orchestrator/error-text.test.js +28 -0
  9. package/dist/orchestrator/error-text.test.js.map +1 -1
  10. package/dist/orchestrator/index.js +331 -56
  11. package/dist/orchestrator/index.js.map +1 -1
  12. package/dist/orchestrator/panel-agent.js +122 -31
  13. package/dist/orchestrator/panel-agent.js.map +1 -1
  14. package/dist/orchestrator/panel-tools.js +1256 -198
  15. package/dist/orchestrator/panel-tools.js.map +1 -1
  16. package/dist/orchestrator/ready-banner.js +70 -0
  17. package/dist/orchestrator/ready-banner.js.map +1 -0
  18. package/dist/orchestrator/session-store.js +163 -28
  19. package/dist/orchestrator/session-store.js.map +1 -1
  20. package/dist/services/api-nodes.js +5 -7
  21. package/dist/services/api-nodes.js.map +1 -1
  22. package/dist/services/download-cache.js +126 -18
  23. package/dist/services/download-cache.js.map +1 -1
  24. package/dist/services/download-jobs.js +463 -14
  25. package/dist/services/download-jobs.js.map +1 -1
  26. package/dist/services/download-progress.js +252 -3
  27. package/dist/services/download-progress.js.map +1 -1
  28. package/dist/services/env-capabilities.js +80 -9
  29. package/dist/services/env-capabilities.js.map +1 -1
  30. package/dist/services/generate-audio.js +16 -0
  31. package/dist/services/generate-audio.js.map +1 -1
  32. package/dist/services/local-models-fallback.js +7 -3
  33. package/dist/services/local-models-fallback.js.map +1 -1
  34. package/dist/services/manifest.js +180 -65
  35. package/dist/services/manifest.js.map +1 -1
  36. package/dist/services/model-resolver.js +267 -11
  37. package/dist/services/model-resolver.js.map +1 -1
  38. package/dist/services/node-management.js +179 -45
  39. package/dist/services/node-management.js.map +1 -1
  40. package/dist/services/output-dir.js +33 -3
  41. package/dist/services/output-dir.js.map +1 -1
  42. package/dist/services/panel-secrets.js +19 -7
  43. package/dist/services/panel-secrets.js.map +1 -1
  44. package/dist/services/process-control.js +107 -8
  45. package/dist/services/process-control.js.map +1 -1
  46. package/dist/services/queue-monitor.js +45 -2
  47. package/dist/services/queue-monitor.js.map +1 -1
  48. package/dist/services/queue-monitor.stall.test.js +78 -0
  49. package/dist/services/queue-monitor.stall.test.js.map +1 -0
  50. package/dist/services/secure-bridge.js +84 -0
  51. package/dist/services/secure-bridge.js.map +1 -1
  52. package/dist/services/storage/azure-blob.js +8 -3
  53. package/dist/services/storage/azure-blob.js.map +1 -1
  54. package/dist/services/storage/index.js +3 -3
  55. package/dist/services/storage/index.js.map +1 -1
  56. package/dist/services/storage/s3.js +9 -3
  57. package/dist/services/storage/s3.js.map +1 -1
  58. package/dist/services/ui-bridge.js +341 -24
  59. package/dist/services/ui-bridge.js.map +1 -1
  60. package/dist/services/workflow-converter.js +767 -72
  61. package/dist/services/workflow-converter.js.map +1 -1
  62. package/dist/services/workflow-deps.js +20 -1
  63. package/dist/services/workflow-deps.js.map +1 -1
  64. package/dist/services/workflow-lock.js +127 -27
  65. package/dist/services/workflow-lock.js.map +1 -1
  66. package/dist/services/workflow-target-store.js +7 -3
  67. package/dist/services/workflow-target-store.js.map +1 -1
  68. package/dist/services/workspace-env.js +48 -0
  69. package/dist/services/workspace-env.js.map +1 -1
  70. package/dist/tools/diagnostics.js +10 -1
  71. package/dist/tools/diagnostics.js.map +1 -1
  72. package/dist/tools/generate-audio.js +44 -0
  73. package/dist/tools/generate-audio.js.map +1 -1
  74. package/dist/tools/model-explorer.js +85 -5
  75. package/dist/tools/model-explorer.js.map +1 -1
  76. package/dist/tools/model-extras.js +24 -4
  77. package/dist/tools/model-extras.js.map +1 -1
  78. package/dist/tools/model-management.js +124 -14
  79. package/dist/tools/model-management.js.map +1 -1
  80. package/dist/tools/report-issue.js +220 -66
  81. package/dist/tools/report-issue.js.map +1 -1
  82. package/dist/tools/report-issue.test.js +223 -83
  83. package/dist/tools/report-issue.test.js.map +1 -1
  84. package/dist/tools/skills-access.js +7 -2
  85. package/dist/tools/skills-access.js.map +1 -1
  86. package/dist/tools/template-schema.js +5 -5
  87. package/dist/tools/template-schema.js.map +1 -1
  88. package/dist/tools/workflow-compose.js +18 -3
  89. package/dist/tools/workflow-compose.js.map +1 -1
  90. package/dist/tools/workflow-visualize.js +1 -1
  91. package/dist/tools/workflow-visualize.js.map +1 -1
  92. package/package.json +1 -1
  93. package/packs/anima/install-runpod.sh +0 -1
  94. package/packs/anima/install-windows.bat +0 -1
  95. package/packs/anima/manifest.yaml +0 -1
  96. package/packs/anima-img2img/install-runpod.sh +0 -1
  97. package/packs/anima-img2img/install-windows.bat +0 -1
  98. package/packs/anima-img2img/manifest.yaml +0 -1
  99. package/packs/anima-inpaint/install-runpod.sh +0 -1
  100. package/packs/anima-inpaint/install-windows.bat +0 -1
  101. package/packs/anima-inpaint/manifest.yaml +0 -1
  102. package/packs/anima-txt2img/install-runpod.sh +0 -1
  103. package/packs/anima-txt2img/install-windows.bat +0 -1
  104. package/packs/anima-txt2img/manifest.yaml +0 -1
  105. package/packs/artokun-flow/install-runpod.sh +0 -1
  106. package/packs/artokun-flow/install-windows.bat +0 -1
  107. package/packs/artokun-flow/manifest.yaml +0 -1
  108. package/packs/ernie/install-runpod.sh +0 -1
  109. package/packs/ernie/install-windows.bat +0 -1
  110. package/packs/ernie/manifest.yaml +0 -1
  111. package/packs/ernie-combo/install-runpod.sh +0 -1
  112. package/packs/ernie-combo/install-windows.bat +0 -1
  113. package/packs/ernie-combo/manifest.yaml +0 -1
  114. package/packs/ernie-img2img/install-runpod.sh +0 -1
  115. package/packs/ernie-img2img/install-windows.bat +0 -1
  116. package/packs/ernie-img2img/manifest.yaml +0 -1
  117. package/packs/ernie-txt2img/install-runpod.sh +0 -1
  118. package/packs/ernie-txt2img/install-windows.bat +0 -1
  119. package/packs/ernie-txt2img/manifest.yaml +0 -1
  120. package/packs/ideogram/install-runpod.sh +0 -1
  121. package/packs/ideogram/install-windows.bat +0 -1
  122. package/packs/ideogram/manifest.yaml +0 -1
  123. package/packs/ideogram-img2img/install-runpod.sh +0 -1
  124. package/packs/ideogram-img2img/install-windows.bat +0 -1
  125. package/packs/ideogram-img2img/manifest.yaml +0 -1
  126. package/packs/ideogram-txt2img/install-runpod.sh +0 -1
  127. package/packs/ideogram-txt2img/install-windows.bat +0 -1
  128. package/packs/ideogram-txt2img/manifest.yaml +0 -1
  129. package/packs/ltx-2.3/install-runpod.sh +0 -1
  130. package/packs/ltx-2.3/install-windows.bat +0 -1
  131. package/packs/ltx-2.3/manifest.yaml +0 -1
  132. package/packs/ltx-2.3-extender/install-runpod.sh +0 -1
  133. package/packs/ltx-2.3-extender/install-windows.bat +0 -1
  134. package/packs/ltx-2.3-extender/manifest.yaml +0 -1
  135. package/packs/ltx-2.3-extender-no-audio/install-runpod.sh +0 -1
  136. package/packs/ltx-2.3-extender-no-audio/install-windows.bat +0 -1
  137. package/packs/ltx-2.3-extender-no-audio/manifest.yaml +0 -1
  138. package/packs/ltx-2.3-flf/install-runpod.sh +0 -1
  139. package/packs/ltx-2.3-flf/install-windows.bat +0 -1
  140. package/packs/ltx-2.3-flf/manifest.yaml +0 -1
  141. package/packs/ltx-2.3-img2vid/install-runpod.sh +0 -1
  142. package/packs/ltx-2.3-img2vid/install-windows.bat +0 -1
  143. package/packs/ltx-2.3-img2vid/manifest.yaml +3 -4
  144. package/packs/ltx-2.3-txt2vid/install-runpod.sh +0 -1
  145. package/packs/ltx-2.3-txt2vid/install-windows.bat +0 -1
  146. package/packs/ltx-2.3-txt2vid/manifest.yaml +3 -4
  147. package/packs/ltx-2.3-xy-plot/install-runpod.sh +0 -1
  148. package/packs/ltx-2.3-xy-plot/install-windows.bat +0 -1
  149. package/packs/ltx-2.3-xy-plot/manifest.yaml +0 -1
  150. package/packs/ltx23-distill-3stage/install-runpod.sh +0 -1
  151. package/packs/ltx23-distill-3stage/install-windows.bat +0 -1
  152. package/packs/ltx23-distill-3stage/manifest.yaml +0 -1
  153. package/packs/qwen-image/install-runpod.sh +0 -1
  154. package/packs/qwen-image/install-windows.bat +0 -1
  155. package/packs/qwen-image/manifest.yaml +0 -1
  156. package/packs/qwen-image-edit/install-runpod.sh +0 -1
  157. package/packs/qwen-image-edit/install-windows.bat +0 -1
  158. package/packs/qwen-image-edit/manifest.yaml +0 -1
  159. package/packs/qwen-image-edit-edit/install-runpod.sh +0 -1
  160. package/packs/qwen-image-edit-edit/install-windows.bat +0 -1
  161. package/packs/qwen-image-edit-edit/manifest.yaml +0 -1
  162. package/packs/wan-animate/install-runpod.sh +0 -1
  163. package/packs/wan-animate/install-windows.bat +0 -1
  164. package/packs/wan-animate/manifest.yaml +0 -1
  165. package/packs/wan-animate-character/install-runpod.sh +0 -1
  166. package/packs/wan-animate-character/install-windows.bat +0 -1
  167. package/packs/wan-animate-character/manifest.yaml +0 -1
  168. package/packs/wan-animate-ofm/install-runpod.sh +0 -1
  169. package/packs/wan-animate-ofm/install-windows.bat +0 -1
  170. package/packs/wan-animate-ofm/manifest.yaml +0 -1
  171. package/packs/wan-longer-videos/install-runpod.sh +0 -1
  172. package/packs/wan-longer-videos/install-windows.bat +0 -1
  173. package/packs/wan-longer-videos/manifest.yaml +0 -1
  174. package/packs/wan-longer-videos-i2v/install-runpod.sh +0 -1
  175. package/packs/wan-longer-videos-i2v/install-windows.bat +0 -1
  176. package/packs/wan-longer-videos-i2v/manifest.yaml +0 -1
  177. package/packs/wan-longer-videos-i2v-96gb/install-runpod.sh +0 -1
  178. package/packs/wan-longer-videos-i2v-96gb/install-windows.bat +0 -1
  179. package/packs/wan-longer-videos-i2v-96gb/manifest.yaml +0 -1
  180. package/packs/wan-longer-videos-t2v/install-runpod.sh +0 -1
  181. package/packs/wan-longer-videos-t2v/install-windows.bat +0 -1
  182. package/packs/wan-longer-videos-t2v/manifest.yaml +0 -1
  183. package/packs/wan-longer-videos-t2v-96gb/install-runpod.sh +0 -1
  184. package/packs/wan-longer-videos-t2v-96gb/install-windows.bat +0 -1
  185. package/packs/wan-longer-videos-t2v-96gb/manifest.yaml +0 -1
  186. package/packs/wan-longer-videos-v2v/install-runpod.sh +0 -1
  187. package/packs/wan-longer-videos-v2v/install-windows.bat +0 -1
  188. package/packs/wan-longer-videos-v2v/manifest.yaml +0 -1
  189. package/packs/wan-longer-videos-v2v-96gb/install-runpod.sh +0 -1
  190. package/packs/wan-longer-videos-v2v-96gb/install-windows.bat +0 -1
  191. package/packs/wan-longer-videos-v2v-96gb/manifest.yaml +0 -1
  192. package/packs/wan-multitalk/install-runpod.sh +0 -1
  193. package/packs/wan-multitalk/install-windows.bat +0 -1
  194. package/packs/wan-multitalk/manifest.yaml +0 -1
  195. package/packs/wan-pusa-extend/install-runpod.sh +0 -1
  196. package/packs/wan-pusa-extend/install-windows.bat +0 -1
  197. package/packs/wan-pusa-extend/manifest.yaml +0 -1
  198. package/packs/wan-transparent/install-runpod.sh +0 -1
  199. package/packs/wan-transparent/install-windows.bat +0 -1
  200. package/packs/wan-transparent/manifest.yaml +0 -1
  201. package/packs/wan-transparent-img2vid/install-runpod.sh +0 -1
  202. package/packs/wan-transparent-img2vid/install-windows.bat +0 -1
  203. package/packs/wan-transparent-img2vid/manifest.yaml +0 -1
  204. package/packs/wan-transparent-img2vid-96gb/install-runpod.sh +0 -1
  205. package/packs/wan-transparent-img2vid-96gb/install-windows.bat +0 -1
  206. package/packs/wan-transparent-img2vid-96gb/manifest.yaml +0 -1
  207. package/packs/z-image-base/install-runpod.sh +0 -1
  208. package/packs/z-image-base/install-windows.bat +0 -1
  209. package/packs/z-image-base/manifest.yaml +0 -1
  210. package/packs/z-image-base-combo/install-runpod.sh +0 -1
  211. package/packs/z-image-base-combo/install-windows.bat +0 -1
  212. package/packs/z-image-base-combo/manifest.yaml +0 -1
  213. package/packs/z-image-base-controlnet/install-runpod.sh +0 -1
  214. package/packs/z-image-base-controlnet/install-windows.bat +0 -1
  215. package/packs/z-image-base-controlnet/manifest.yaml +0 -1
  216. package/packs/z-image-base-img2img/install-runpod.sh +0 -1
  217. package/packs/z-image-base-img2img/install-windows.bat +0 -1
  218. package/packs/z-image-base-img2img/manifest.yaml +0 -1
  219. package/packs/z-image-base-inpaint/install-runpod.sh +0 -1
  220. package/packs/z-image-base-inpaint/install-windows.bat +0 -1
  221. package/packs/z-image-base-inpaint/manifest.yaml +0 -1
  222. package/packs/z-image-base-txt2img/install-runpod.sh +0 -1
  223. package/packs/z-image-base-txt2img/install-windows.bat +0 -1
  224. package/packs/z-image-base-txt2img/manifest.yaml +0 -1
  225. package/packs/z-image-turbo/install-runpod.sh +0 -1
  226. package/packs/z-image-turbo/install-windows.bat +0 -1
  227. package/packs/z-image-turbo/manifest.yaml +0 -1
  228. package/packs/z-image-turbo-combo/install-runpod.sh +0 -1
  229. package/packs/z-image-turbo-combo/install-windows.bat +0 -1
  230. package/packs/z-image-turbo-combo/manifest.yaml +0 -1
  231. package/packs/z-image-turbo-controlnet/install-runpod.sh +0 -1
  232. package/packs/z-image-turbo-controlnet/install-windows.bat +0 -1
  233. package/packs/z-image-turbo-controlnet/manifest.yaml +0 -1
  234. package/packs/z-image-turbo-detail-daemon/install-runpod.sh +0 -1
  235. package/packs/z-image-turbo-detail-daemon/install-windows.bat +0 -1
  236. package/packs/z-image-turbo-detail-daemon/manifest.yaml +0 -1
  237. package/packs/z-image-turbo-img2img/install-runpod.sh +0 -1
  238. package/packs/z-image-turbo-img2img/install-windows.bat +0 -1
  239. package/packs/z-image-turbo-img2img/manifest.yaml +0 -1
  240. package/packs/z-image-turbo-inpainting/install-runpod.sh +0 -1
  241. package/packs/z-image-turbo-inpainting/install-windows.bat +0 -1
  242. package/packs/z-image-turbo-inpainting/manifest.yaml +0 -1
  243. package/packs/z-image-turbo-txt2img/install-runpod.sh +0 -1
  244. package/packs/z-image-turbo-txt2img/install-windows.bat +0 -1
  245. package/packs/z-image-turbo-txt2img/manifest.yaml +0 -1
  246. package/packs/z-image-xy-plot/install-runpod.sh +0 -1
  247. package/packs/z-image-xy-plot/install-windows.bat +0 -1
  248. package/packs/z-image-xy-plot/manifest.yaml +0 -1
  249. package/plugin/skills/flux-txt2img/SKILL.md +9 -4
  250. package/plugin/skills/wan-scail-replacement/SKILL.md +95 -0
  251. package/scripts/sync-agents.mjs +18 -8
  252. package/scripts/test-packs.sh +8 -3
@@ -17,10 +17,58 @@
17
17
  import { randomUUID, timingSafeEqual } from "node:crypto";
18
18
  import { WebSocketServer, WebSocket } from "ws";
19
19
  import { logger } from "../utils/logger.js";
20
+ import { compareSemver } from "./self-update.js";
20
21
  export const DEFAULT_BRIDGE_PORT = 9101;
21
22
  /** Panel build that first implements the full graph_* / ui_* bridge command set.
22
- * Reports of "Unknown command <x>" come from panels older than this. */
23
+ * Used as the DEFAULT minimum for any bridge command not listed in
24
+ * BRIDGE_CMD_MIN_PANEL_VERSION below. Individual commands shipped EARLIER than
25
+ * this (see the map) and must quote their own, lower minimum. */
23
26
  export const MIN_PANEL_VERSION_FOR_BRIDGE_COMMANDS = "0.11.4";
27
+ /** The panel version each bridge command was ACTUALLY introduced in (from the
28
+ * comfyui-mcp-panel CHANGELOG). The old code quoted a single blanket 0.11.4 for
29
+ * EVERY command, which is wrong for commands that shipped much earlier —
30
+ * graph_outline has existed since panel 0.4.6, so a "too old — update to ≥0.11.4"
31
+ * verdict for it is a false, inflated requirement (#352). Anything not listed
32
+ * falls back to MIN_PANEL_VERSION_FOR_BRIDGE_COMMANDS (the full-set baseline). */
33
+ export const BRIDGE_CMD_MIN_PANEL_VERSION = {
34
+ graph_outline: "0.4.6",
35
+ graph_find_nodes: "0.4.6",
36
+ graph_query: "0.7.0",
37
+ graph_serialize: "0.8.2",
38
+ };
39
+ /** The minimum panel version that supports `cmd`. */
40
+ export function minPanelVersionForCmd(cmd) {
41
+ return BRIDGE_CMD_MIN_PANEL_VERSION[cmd] ?? MIN_PANEL_VERSION_FOR_BRIDGE_COMMANDS;
42
+ }
43
+ /** A version string compareSemver can actually parse. compareSemver returns 0
44
+ * BOTH for "equal" and for "unparseable", so callers that must distinguish an
45
+ * unparseable input from a genuine tie (like panelSupportsCmd's `>= 0`) have to
46
+ * screen the input first — otherwise `panel_version: "dev"` would compare as 0
47
+ * and be mistaken for a match. */
48
+ const SEMVER_RE = /^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/;
49
+ /** True when the panel ADVERTISED a PARSEABLE version (in its `hello`) that already
50
+ * meets `cmd`'s real minimum — i.e. this panel is new enough to support the command,
51
+ * so an "Unknown command" reply is NOT an age problem and must never be rewritten
52
+ * into a "too old — update your panel" verdict (#352 false negative). Missing OR
53
+ * unparseable panelVersion → we can't PROVE it's new enough, so treat as not-proven
54
+ * (fall through to the honest too-old path, which quotes the correct minimum, and
55
+ * keep the #236 learning path intact). */
56
+ function panelSupportsCmd(cmd, panelVersion) {
57
+ if (!panelVersion || !SEMVER_RE.test(panelVersion.trim()))
58
+ return false;
59
+ return compareSemver(panelVersion, minPanelVersionForCmd(cmd)) >= 0;
60
+ }
61
+ /** The actionable "update your panel" message for a command a connected panel has
62
+ * been discovered NOT to support. Shared by the reactive path (the panel's own
63
+ * "Unknown command" reply, mapped by makeUnknownCommandError) and the proactive
64
+ * gate (#236 — a command already known-unsupported for THIS connection, from an
65
+ * earlier call in the same session) so both produce the identical message. The
66
+ * quoted minimum is COMMAND-SPECIFIC (#352) — not a blanket 0.11.4. */
67
+ function buildPanelTooOldError(cmd, panelVersion) {
68
+ const detected = panelVersion ? ` (detected ${panelVersion})` : "";
69
+ return new Error(`This ComfyUI-MCP panel is too old for "${cmd}"${detected} — update the ComfyUI-MCP panel ` +
70
+ `to ≥${minPanelVersionForCmd(cmd)} (ComfyUI Manager → update comfyui-mcp panel), then reconnect.`);
71
+ }
24
72
  /**
25
73
  * A panel replies `{ ok: false, error: 'Unknown command "<cmd>"' }` when the
26
74
  * orchestrator dispatches a bridge command the (older) panel doesn't register —
@@ -40,9 +88,99 @@ export function makeUnknownCommandError(error, panelVersion) {
40
88
  if (!m)
41
89
  return null;
42
90
  const cmd = m[1];
43
- const detected = panelVersion ? ` (detected ${panelVersion})` : "";
44
- return new Error(`This ComfyUI-MCP panel is too old for "${cmd}"${detected} update the ComfyUI-MCP panel ` +
45
- `to ≥${MIN_PANEL_VERSION_FOR_BRIDGE_COMMANDS} (ComfyUI Manager update comfyui-mcp panel), then reconnect.`);
91
+ // #352 FALSE-NEGATIVE GUARD: if the panel advertised a version that already
92
+ // meets this command's real minimum, the panel is NOT too old — an "Unknown
93
+ // command" reply here means something else (a genuinely retired/renamed command,
94
+ // or a transient), so do NOT rewrite it into a bogus "update your panel" verdict
95
+ // (which would also POISON the #236 unsupported-cmd gate against a capable panel).
96
+ // Return null so the raw error surfaces and the gate is never poisoned.
97
+ if (panelSupportsCmd(cmd, panelVersion))
98
+ return null;
99
+ return buildPanelTooOldError(cmd, panelVersion);
100
+ }
101
+ /**
102
+ * Bridge commands with NO side effect — safe to re-dispatch after a reconnect and
103
+ * safe to WAIT ON longer (a read can't be double-applied). Module-level so both
104
+ * the mutating-vs-read classification and the default-timeout policy share one
105
+ * authoritative list. Everything not listed is treated as mutating.
106
+ */
107
+ export const BRIDGE_READONLY_CMDS = new Set([
108
+ "graph_serialize",
109
+ "graph_outline",
110
+ "graph_get_errors",
111
+ "graph_get_state",
112
+ "graph_get_subgraph",
113
+ "graph_view_selected",
114
+ "graph_view_nodes_in_viewport",
115
+ "graph_prompt_director_audit",
116
+ "graph_query",
117
+ "civitai_results",
118
+ "get_todo",
119
+ ]);
120
+ /** Tight default reply timeout for a MUTATING command with no explicit timeout —
121
+ * fail fast so the agent isn't blocked on a stuck write. */
122
+ export const BRIDGE_DEFAULT_TIMEOUT_MS = 6000;
123
+ /** More tolerant default for a READ (idempotent) command with no explicit timeout.
124
+ * A legitimately busy-but-alive panel main thread — e.g. Preview3D parsing a large
125
+ * FBX — can take many seconds to service a graph_query; failing it at the tight
126
+ * 6s default surfaced a FALSE "tab backgrounded or frozen" timeout even though a
127
+ * retry moments later succeeded (#357). Reads have no side effect, so waiting
128
+ * longer is safe; a genuinely dead/frozen tab still times out, just at this bound.
129
+ * Note: a WebSocket pong is answered by the browser network stack even while JS is
130
+ * blocked, so it can't distinguish busy-JS from dead-JS — a tolerant read timeout,
131
+ * not pong-liveness, is the correct lever here. */
132
+ export const BRIDGE_READ_DEFAULT_TIMEOUT_MS = 20_000;
133
+ /** The default reply timeout to use for `cmd` when the caller passed none. Read
134
+ * ops get the tolerant bound; everything else stays tight (#357). */
135
+ export function defaultBridgeTimeoutMs(cmdName) {
136
+ return BRIDGE_READONLY_CMDS.has(cmdName)
137
+ ? BRIDGE_READ_DEFAULT_TIMEOUT_MS
138
+ : BRIDGE_DEFAULT_TIMEOUT_MS;
139
+ }
140
+ /** Normalize a WebSocket handshake `Origin` header into a canonical scheme://host:port
141
+ * string, or undefined when it is absent, the opaque literal `"null"` (a sandboxed /
142
+ * file:// / data: origin, which proves nothing), or unparseable. The browser sets this
143
+ * header on the upgrade and forbids page JS from overriding it, so a value here is
144
+ * server-trusted provenance of the page the socket runs in. Host case is lowercased and
145
+ * the default port made explicit so it compares cleanly against a probe base. */
146
+ function normalizeHandshakeOrigin(raw) {
147
+ const val = Array.isArray(raw) ? raw[0] : raw;
148
+ if (typeof val !== "string" || val === "" || val.toLowerCase() === "null")
149
+ return undefined;
150
+ try {
151
+ const u = new URL(val);
152
+ const port = u.port || (u.protocol === "https:" ? "443" : "80");
153
+ return `${u.protocol}//${u.hostname.toLowerCase()}:${port}`;
154
+ }
155
+ catch {
156
+ return undefined;
157
+ }
158
+ }
159
+ /** AUTHORITATIVE, TYPED dispatch outcome carried on a bridge command REJECTION so a
160
+ * caller never has to infer it from message text:
161
+ * - `false` = the command was NEVER written to the socket (a PRE-write send() failure);
162
+ * - `true` = the command WAS written and the socket then died before a reply (a
163
+ * POST-write mid-command OUTCOME-UNKNOWN drop).
164
+ * Absent on all other errors (a reply-timeout, a genuine refusal, "panel gone", etc.). */
165
+ const DISPATCHED = Symbol.for("comfyui-mcp.bridge.dispatched");
166
+ /** Tag an error with the typed {@link DISPATCHED} outcome and return it (for throw/reject). */
167
+ export function markDispatched(err, dispatched) {
168
+ Object.defineProperty(err, DISPATCHED, {
169
+ value: dispatched,
170
+ enumerable: false,
171
+ configurable: true,
172
+ });
173
+ return err;
174
+ }
175
+ /** Read the typed dispatch outcome from an error, or undefined if it carries none.
176
+ * A reboot readiness check uses this to classify a PRE-write send failure
177
+ * (`false`) categorically as NOT-dispatched — never as an accepted/dropped reboot —
178
+ * regardless of any post-write phrase its message text may quote. */
179
+ export function dispatchOutcomeOf(err) {
180
+ if (err == null || (typeof err !== "object" && typeof err !== "function"))
181
+ return undefined;
182
+ const v = err[DISPATCHED];
183
+ return typeof v === "boolean" ? v : undefined;
46
184
  }
47
185
  /**
48
186
  * Frame `type`s that are safe to MIRROR to a tab's viewers — genuine shared-session
@@ -133,6 +271,20 @@ export class UiBridge {
133
271
  missedFrames = new Map();
134
272
  static MAX_MISSED_FRAMES = 100;
135
273
  pending = new Map();
274
+ /** ask_user card sends (rid → {ask_id, ts}): lets a reply that validates AFTER
275
+ * the reply timer fired (dropped from `pending`) still be buffered for the
276
+ * caller, instead of being discarded as a "late reply for a timed-out command"
277
+ * (#486). Timestamped so an abandoned mapping (timeout/disconnect/send-failure
278
+ * whose late reply never arrives) is TTL-pruned rather than kept forever. */
279
+ askRidToId = new Map();
280
+ /** Buffered late-but-valid ask_user answers (ask_id → result), drained by the
281
+ * caller via takeLateAskReply(). Bounded by a short TTL — a stale unclaimed
282
+ * answer is pruned rather than kept forever. */
283
+ lateAskReplies = new Map();
284
+ /** TTL for a buffered late ask answer / an unresolved rid→ask_id mapping. Long
285
+ * enough to cover a slow human pick within the MCP tools/call budget, short
286
+ * enough that abandoned entries don't accumulate. */
287
+ static LATE_ASK_TTL_MS = 5 * 60 * 1000;
136
288
  /** In-flight IDEMPOTENT reads whose socket dropped mid-command, parked per tabId
137
289
  * waiting a bounded grace for that tab to reconnect so we can re-dispatch them
138
290
  * (resume) instead of hard-failing. Never holds mutating commands. */
@@ -142,17 +294,10 @@ export class UiBridge {
142
294
  static RECONNECT_GRACE_MS = 4000;
143
295
  /** Bridge commands with no side effect — safe to re-dispatch after a reconnect.
144
296
  * Everything else is treated as mutating (no auto-retry) so a render/edit is
145
- * never silently double-applied. */
146
- static READONLY_CMDS = new Set([
147
- "graph_serialize",
148
- "graph_outline",
149
- "graph_get_errors",
150
- "graph_get_subgraph",
151
- "graph_prompt_director_audit",
152
- "graph_query",
153
- "civitai_results",
154
- "get_todo",
155
- ]);
297
+ * never silently double-applied. Single source of truth at module scope
298
+ * (BRIDGE_READONLY_CMDS) so the mutating flag and the default-timeout policy
299
+ * can never drift apart. */
300
+ static READONLY_CMDS = BRIDGE_READONLY_CMDS;
156
301
  portInUse = false;
157
302
  bindRetryTimer = null;
158
303
  heartbeatTimer = null;
@@ -332,14 +477,20 @@ export class UiBridge {
332
477
  this.readyResolve = null;
333
478
  }
334
479
  });
335
- wss.on("connection", (sock) => {
480
+ wss.on("connection", (sock, req) => {
336
481
  // Keepalive bookkeeping for the SERVER-LEVEL heartbeat loop, which only
337
482
  // iterates real wss.clients (relay-mediated shim connections aren't real
338
483
  // sockets bound by this server, so they're intentionally excluded — see
339
484
  // relay-client.ts / the relay README for why that's believed low-risk).
340
485
  this.missedPongs.set(sock, 0);
341
486
  sock.on("pong", () => this.missedPongs.set(sock, 0));
342
- this.handleConnection(sock);
487
+ // SERVER-OBSERVED handshake Origin (the browser sets it and forbids page JS from
488
+ // overriding it) — the trusted proof of which page this socket runs in, distinct
489
+ // from the spoofable hello.comfyui_url (#509 self-probe gate).
490
+ const handshakeOrigin = normalizeHandshakeOrigin(req?.headers?.origin);
491
+ // Trusted-local ONLY when the primary listener is a token-less loopback bind
492
+ // (no LAN bind, no tunnel/secure token in front) — see handleConnection's doc.
493
+ this.handleConnection(sock, !this.token && isLoopbackBindHost(this.host), handshakeOrigin);
343
494
  });
344
495
  }
345
496
  /**
@@ -411,7 +562,18 @@ export class UiBridge {
411
562
  });
412
563
  });
413
564
  }
414
- handleConnection(sock) {
565
+ /**
566
+ * @param local SERVER-TRUSTED provenance: true only when the socket arrived on
567
+ * the token-less loopback primary listener, i.e. a browser genuinely on the
568
+ * orchestrator's own host. A token-gated/tunnel-fronted primary, a relay shim,
569
+ * and any LAN/pairing listener are all `false` — a browser reaching those can
570
+ * sit on a DIFFERENT machine yet advertise its OWN 127.0.0.1 ComfyUI, which
571
+ * must never be treated as the orchestrator's local host (#509).
572
+ * @param serverOrigin SERVER-OBSERVED handshake `Origin` (scheme://host:port), captured
573
+ * from the WebSocket upgrade — NOT client-supplied. Undefined for non-browser/relay
574
+ * connections. Bound to the tab so the reboot self-probe can trust which page it fronts.
575
+ */
576
+ handleConnection(sock, local = false, serverOrigin) {
415
577
  // The connection is anonymous until its hello frame names a tab id.
416
578
  let tabId = null;
417
579
  // A socket's KIND (canvas-owning panel vs headless viewer) is pinned on its
@@ -518,6 +680,25 @@ export class UiBridge {
518
680
  panelVersion: typeof msg.panel_version === "string"
519
681
  ? (msg.panel_version || undefined)
520
682
  : existing?.panelVersion,
683
+ // Fresh per hello — see the field's doc comment (#236).
684
+ unsupportedCmds: new Set(),
685
+ // window.location the browser was served from — the ComfyUI instance THIS
686
+ // tab fronts. Preserved across a SAME-SOCKET re-hello that omits it, but
687
+ // NEVER inherited by a DIFFERENT socket reusing this (possibly recurring
688
+ // wf:<hash>) tab id — that could let an unrelated instance's origin certify
689
+ // this tab's restart (#509, codex: cross-ownership inheritance).
690
+ originUrl: typeof msg.comfyui_url === "string" &&
691
+ msg.comfyui_url
692
+ ? msg.comfyui_url
693
+ : existing?.sock === sock
694
+ ? existing?.originUrl
695
+ : undefined,
696
+ // SERVER-OBSERVED handshake Origin of THIS socket (from the WS upgrade, not the
697
+ // hello) — bound per-socket. A same-socket re-hello keeps it; a DIFFERENT socket
698
+ // taking over the id carries its OWN handshake Origin (never inherits the old).
699
+ serverOrigin: existing?.sock === sock ? (serverOrigin ?? existing?.serverOrigin) : serverOrigin,
700
+ // Server-trusted provenance of THIS socket (not client-controlled).
701
+ local,
521
702
  });
522
703
  if (incomingHeadless)
523
704
  this.headlessSeen.add(tabId);
@@ -560,10 +741,24 @@ export class UiBridge {
560
741
  const rid = typeof msg.rid === "string" ? msg.rid : undefined;
561
742
  if (rid) {
562
743
  const p = this.pending.get(rid);
563
- if (!p)
564
- return; // late reply for a timed-out command
744
+ if (!p) {
745
+ // Late reply for a timed-out command. If it was an ask_user card whose
746
+ // caller may still be within the MCP tools/call budget, BUFFER the
747
+ // validated answer keyed by its ask_id so the caller can retrieve it via
748
+ // takeLateAskReply() instead of losing it (#486). Everything else drops.
749
+ const entry = this.askRidToId.get(rid);
750
+ if (entry) {
751
+ this.askRidToId.delete(rid);
752
+ if (msg.ok) {
753
+ this.pruneLateAsk();
754
+ this.lateAskReplies.set(entry.askId, { result: msg.result, ts: Date.now() });
755
+ }
756
+ }
757
+ return;
758
+ }
565
759
  clearTimeout(p.timer);
566
760
  this.pending.delete(rid);
761
+ this.askRidToId.delete(rid);
567
762
  if (msg.ok) {
568
763
  p.resolve(msg.result);
569
764
  }
@@ -726,6 +921,48 @@ export class UiBridge {
726
921
  resolveActiveTabId() {
727
922
  return this.resolveTarget().tabId;
728
923
  }
924
+ /** The ComfyUI origin the given tab's browser was served from (`comfyui_url` in
925
+ * its `hello`), or undefined if the tab never advertised one / is unknown. Lets a
926
+ * tool HTTP-probe the EXACT instance THIS tab fronts rather than the process-
927
+ * global target a different instance may have retargeted (#509). Resolves the id
928
+ * through the SAME prefix + migration-alias path as command routing (resolveTarget)
929
+ * so a migrated session still finds its origin; never throws. */
930
+ tabOrigin(tabId) {
931
+ try {
932
+ return this.resolveTarget(tabId).originUrl;
933
+ }
934
+ catch {
935
+ return undefined;
936
+ }
937
+ }
938
+ /** SERVER-OBSERVED origin (scheme://host:port) of the given tab's WebSocket handshake —
939
+ * the page the browser was actually serving when it opened this socket. Unlike
940
+ * {@link tabOrigin} (client-supplied `hello.comfyui_url`, spoofable), the browser sets
941
+ * the handshake `Origin` and blocks page JS from forging it, so this is the TRUSTED
942
+ * proof of which ComfyUI a real tab fronts — the origin the reboot self-probe binds to
943
+ * (#509). Undefined when the handshake carried no usable Origin. Resolves migration
944
+ * aliases like tabOrigin; never throws. */
945
+ tabServerOrigin(tabId) {
946
+ try {
947
+ return this.resolveTarget(tabId).serverOrigin;
948
+ }
949
+ catch {
950
+ return undefined;
951
+ }
952
+ }
953
+ /** SERVER-TRUSTED: true only when this tab's socket arrived on the token-less
954
+ * loopback primary listener (a browser on the orchestrator's OWN host), so its
955
+ * advertised loopback ComfyUI origin really is the orchestrator's local host and
956
+ * may be directly health-probed (#509). Relay/tunnel/LAN/pairing tabs → false.
957
+ * Resolves migration aliases like tabOrigin; unknown → false. */
958
+ tabIsLocal(tabId) {
959
+ try {
960
+ return this.resolveTarget(tabId).local === true;
961
+ }
962
+ catch {
963
+ return false;
964
+ }
965
+ }
729
966
  /** True when the tab advertised itself as a canvas-less (mobile/remote) client
730
967
  * in its `hello`. Unknown tabs → false. */
731
968
  isHeadless(tabId) {
@@ -734,6 +971,45 @@ export class UiBridge {
734
971
  // bytes-less viewRef) and is renderable when the mailbox flushes to the phone.
735
972
  return this.conns.get(tabId)?.headless === true || this.headlessSeen.has(tabId);
736
973
  }
974
+ /** Drop expired late-ask entries (buffered answers + unresolved rid mappings) so
975
+ * an abandoned card never leaks memory. Cheap; called on each ask send/take. */
976
+ pruneLateAsk() {
977
+ const now = Date.now();
978
+ for (const [id, e] of this.lateAskReplies) {
979
+ if (now - e.ts > UiBridge.LATE_ASK_TTL_MS)
980
+ this.lateAskReplies.delete(id);
981
+ }
982
+ // TTL-prune abandoned rid→ask_id mappings (a card that timed out / dropped and
983
+ // whose late reply never came), so they don't linger past the window a caller
984
+ // could still claim them.
985
+ for (const [rid, e] of this.askRidToId) {
986
+ if (now - e.ts > UiBridge.LATE_ASK_TTL_MS)
987
+ this.askRidToId.delete(rid);
988
+ }
989
+ // Belt-and-suspenders cardinality cap in case of a burst of asks within one TTL
990
+ // window — drop the oldest-inserted mappings so the map can never grow unbounded.
991
+ if (this.askRidToId.size > 256) {
992
+ const excess = this.askRidToId.size - 256;
993
+ let i = 0;
994
+ for (const rid of this.askRidToId.keys()) {
995
+ if (i++ >= excess)
996
+ break;
997
+ this.askRidToId.delete(rid);
998
+ }
999
+ }
1000
+ }
1001
+ /** Retrieve (and remove) a late-but-valid ask_user answer buffered for `askId`
1002
+ * after the card-reply timeout, or undefined if none arrived. The panel_ask
1003
+ * handler polls this for a bounded grace so a slow-but-valid pick is honored
1004
+ * rather than discarded (#486). */
1005
+ takeLateAskReply(askId) {
1006
+ this.pruneLateAsk();
1007
+ const e = this.lateAskReplies.get(askId);
1008
+ if (!e)
1009
+ return undefined;
1010
+ this.lateAskReplies.delete(askId);
1011
+ return e.result;
1012
+ }
737
1013
  /** All currently connected tabs, most recent hello last. */
738
1014
  tabs() {
739
1015
  return Array.from(this.conns.values()).map((c) => ({
@@ -837,7 +1113,10 @@ export class UiBridge {
837
1113
  }
838
1114
  }
839
1115
  send(cmd, opts = {}) {
840
- const timeoutMs = opts.timeoutMs ?? 6000;
1116
+ // #357: read (idempotent) ops get a more tolerant default so a busy-but-alive
1117
+ // panel main thread (e.g. Preview3D loading a large FBX) isn't declared frozen;
1118
+ // mutating ops keep the tight default. An explicit opts.timeoutMs always wins.
1119
+ const timeoutMs = opts.timeoutMs ?? defaultBridgeTimeoutMs(cmd.cmd);
841
1120
  let conn;
842
1121
  try {
843
1122
  conn = this.resolveTarget(opts.tabId);
@@ -851,6 +1130,15 @@ export class UiBridge {
851
1130
  }
852
1131
  return Promise.reject(err instanceof Error ? err : new Error(String(err)));
853
1132
  }
1133
+ // #236 — proactively gate a command this exact connection has already proven
1134
+ // unsupported (see Conn.unsupportedCmds), instead of dispatching it again just
1135
+ // to have the panel reject it and re-parse the same "Unknown command" string.
1136
+ // Never a false gate: membership here is only ever set from a REAL rejection
1137
+ // on THIS connection (in dispatch()'s rejectMapped below), never inferred from
1138
+ // panelVersion alone.
1139
+ if (conn.unsupportedCmds.has(cmd.cmd)) {
1140
+ return Promise.reject(buildPanelTooOldError(cmd.cmd, conn.panelVersion));
1141
+ }
854
1142
  if (conn.sock.readyState !== WebSocket.OPEN) {
855
1143
  if (opts.tabId && UiBridge.isMailboxable(cmd)) {
856
1144
  this.storeMailbox(opts.tabId, cmd);
@@ -889,17 +1177,38 @@ export class UiBridge {
889
1177
  }
890
1178
  const replyTimeoutMs = Math.min(ctx.timeoutMs, remaining);
891
1179
  const rid = randomUUID();
1180
+ // Track an ask_user card's stable ask_id against this attempt's rid so a reply
1181
+ // that validates AFTER the reply timer fires (and drops out of `pending`) can
1182
+ // still be buffered for the caller by rid, not discarded (#486 late answer).
1183
+ const askId = cmd.ask_id;
1184
+ if (typeof askId === "string" && askId) {
1185
+ this.pruneLateAsk();
1186
+ this.askRidToId.set(rid, { askId, ts: Date.now() });
1187
+ }
892
1188
  // Rewrite an old-panel "Unknown command" rejection into an actionable
893
1189
  // update-your-panel message (see makeUnknownCommandError). Applied to the
894
1190
  // reply-error path only; the happy path and genuine command errors are
895
1191
  // passed through untouched.
896
1192
  const rejectMapped = (err) => {
897
1193
  const friendly = makeUnknownCommandError(err.message, conn.panelVersion);
1194
+ if (friendly) {
1195
+ // Learned, not assumed (#236) — this exact connection just proved it
1196
+ // doesn't support cmd.cmd, so every later call in this session is gated
1197
+ // proactively (see the `send()` preflight above) instead of round-
1198
+ // tripping to the panel again.
1199
+ conn.unsupportedCmds.add(cmd.cmd);
1200
+ }
898
1201
  ctx.reject(friendly ?? err);
899
1202
  };
900
1203
  const timer = setTimeout(() => {
901
1204
  this.pending.delete(rid);
902
- ctx.reject(new Error(`Panel tab ${conn.tabId.slice(0, 8)} did not reply to "${cmd.cmd}" within ${ctx.timeoutMs} ms — the ComfyUI tab may be backgrounded or frozen`));
1205
+ // This timer only fires AFTER sock.send() below returned successfully — the command
1206
+ // WAS WRITTEN to the socket; the tab (possibly backgrounded/frozen) merely didn't
1207
+ // reply in time and may still apply it. So this is a POST-write outcome: tag it
1208
+ // dispatched:true so a mutating caller (e.g. comfy_reboot readiness) treats it as an
1209
+ // accepted-but-unacked dispatch and verifies by observation, rather than mistaking a
1210
+ // genuinely-sent command for one that never went out (#509).
1211
+ ctx.reject(markDispatched(new Error(`Panel tab ${conn.tabId.slice(0, 8)} did not reply to "${cmd.cmd}" within ${ctx.timeoutMs} ms — the ComfyUI tab may be backgrounded or frozen`), true));
903
1212
  }, replyTimeoutMs);
904
1213
  const pending = {
905
1214
  resolve: ctx.resolve,
@@ -916,7 +1225,15 @@ export class UiBridge {
916
1225
  catch (err) {
917
1226
  clearTimeout(timer);
918
1227
  this.pending.delete(rid);
919
- ctx.reject(err instanceof Error ? err : new Error(String(err)));
1228
+ // The write to the socket FAILED — the command was NEVER transmitted, so nothing
1229
+ // was dispatched (distinct from a POST-write mid-command drop). Surface that
1230
+ // explicitly so callers don't mistake it for an in-flight/accepted command (a
1231
+ // reboot readiness check must NOT treat a pre-write send failure as a fired reboot).
1232
+ // Carry an AUTHORITATIVE TYPED flag `dispatched:false` so a caller classifies this
1233
+ // categorically — never by string-matching a detail that might quote a post-write
1234
+ // phrase ("OUTCOME UNKNOWN") from the underlying socket error.
1235
+ const detail = err instanceof Error ? err.message : String(err);
1236
+ ctx.reject(markDispatched(new Error(`failed to send "${cmd.cmd}" to panel tab ${conn.tabId.slice(0, 8)} — the command was NOT dispatched (${detail})`), false));
920
1237
  }
921
1238
  }
922
1239
  /** A pending command's socket died before its reply arrived. Decide, WITHOUT
@@ -958,7 +1275,7 @@ export class UiBridge {
958
1275
  // bare failure invites a blind retry that double-applies the action (e.g. a
959
1276
  // second render). Say the outcome is UNKNOWN so the caller verifies first.
960
1277
  if (ctx.mutating) {
961
- ctx.reject(new Error(`panel tab ${short} disconnected mid-command ("${cmd}") — OUTCOME UNKNOWN: the command was already sent, so the panel may have applied it (for a run, ComfyUI may already be rendering). Verify before retrying (e.g. check get_queue / list_output_images) instead of re-issuing it blindly.`));
1278
+ ctx.reject(markDispatched(new Error(`panel tab ${short} disconnected mid-command ("${cmd}") — OUTCOME UNKNOWN: the command was already sent, so the panel may have applied it (for a run, ComfyUI may already be rendering). Verify before retrying (e.g. check get_queue / list_output_images) instead of re-issuing it blindly.`), true));
962
1279
  }
963
1280
  else {
964
1281
  ctx.reject(new Error(`panel tab ${short} disconnected mid-command ("${cmd}") — panel genuinely gone; retry once a tab is connected`));