@portel/photon 1.4.1 → 1.6.0

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 (395) hide show
  1. package/README.md +326 -1177
  2. package/dist/auto-ui/beam.d.ts +14 -0
  3. package/dist/auto-ui/beam.d.ts.map +1 -0
  4. package/dist/auto-ui/beam.js +3057 -0
  5. package/dist/auto-ui/beam.js.map +1 -0
  6. package/dist/auto-ui/bridge/index.d.ts +37 -0
  7. package/dist/auto-ui/bridge/index.d.ts.map +1 -0
  8. package/dist/auto-ui/bridge/index.js +555 -0
  9. package/dist/auto-ui/bridge/index.js.map +1 -0
  10. package/dist/auto-ui/bridge/openai-shim.d.ts +20 -0
  11. package/dist/auto-ui/bridge/openai-shim.d.ts.map +1 -0
  12. package/dist/auto-ui/bridge/openai-shim.js +231 -0
  13. package/dist/auto-ui/bridge/openai-shim.js.map +1 -0
  14. package/dist/auto-ui/bridge/photon-app.d.ts +162 -0
  15. package/dist/auto-ui/bridge/photon-app.d.ts.map +1 -0
  16. package/dist/auto-ui/bridge/photon-app.js +460 -0
  17. package/dist/auto-ui/bridge/photon-app.js.map +1 -0
  18. package/dist/auto-ui/bridge/types.d.ts +128 -0
  19. package/dist/auto-ui/bridge/types.d.ts.map +1 -0
  20. package/dist/auto-ui/bridge/types.js +7 -0
  21. package/dist/auto-ui/bridge/types.js.map +1 -0
  22. package/dist/auto-ui/components/card.d.ts +13 -0
  23. package/dist/auto-ui/components/card.d.ts.map +1 -0
  24. package/dist/auto-ui/components/card.js +64 -0
  25. package/dist/auto-ui/components/card.js.map +1 -0
  26. package/dist/auto-ui/components/form.d.ts +15 -0
  27. package/dist/auto-ui/components/form.d.ts.map +1 -0
  28. package/dist/auto-ui/components/form.js +72 -0
  29. package/dist/auto-ui/components/form.js.map +1 -0
  30. package/dist/auto-ui/components/list.d.ts +13 -0
  31. package/dist/auto-ui/components/list.d.ts.map +1 -0
  32. package/dist/auto-ui/components/list.js +58 -0
  33. package/dist/auto-ui/components/list.js.map +1 -0
  34. package/dist/auto-ui/components/progress.d.ts +18 -0
  35. package/dist/auto-ui/components/progress.d.ts.map +1 -0
  36. package/dist/auto-ui/components/progress.js +125 -0
  37. package/dist/auto-ui/components/progress.js.map +1 -0
  38. package/dist/auto-ui/components/table.d.ts +13 -0
  39. package/dist/auto-ui/components/table.d.ts.map +1 -0
  40. package/dist/auto-ui/components/table.js +82 -0
  41. package/dist/auto-ui/components/table.js.map +1 -0
  42. package/dist/auto-ui/components/tree.d.ts +13 -0
  43. package/dist/auto-ui/components/tree.d.ts.map +1 -0
  44. package/dist/auto-ui/components/tree.js +61 -0
  45. package/dist/auto-ui/components/tree.js.map +1 -0
  46. package/dist/auto-ui/daemon-tools.d.ts +45 -0
  47. package/dist/auto-ui/daemon-tools.d.ts.map +1 -0
  48. package/dist/auto-ui/daemon-tools.js +580 -0
  49. package/dist/auto-ui/daemon-tools.js.map +1 -0
  50. package/dist/auto-ui/design-system/index.d.ts +21 -0
  51. package/dist/auto-ui/design-system/index.d.ts.map +1 -0
  52. package/dist/auto-ui/design-system/index.js +27 -0
  53. package/dist/auto-ui/design-system/index.js.map +1 -0
  54. package/dist/auto-ui/design-system/tokens.d.ts +9 -0
  55. package/dist/auto-ui/design-system/tokens.d.ts.map +1 -0
  56. package/dist/auto-ui/design-system/tokens.js +27 -0
  57. package/dist/auto-ui/design-system/tokens.js.map +1 -0
  58. package/dist/auto-ui/design-system/transaction-ui.d.ts +70 -0
  59. package/dist/auto-ui/design-system/transaction-ui.d.ts.map +1 -0
  60. package/dist/auto-ui/design-system/transaction-ui.js +982 -0
  61. package/dist/auto-ui/design-system/transaction-ui.js.map +1 -0
  62. package/dist/auto-ui/frontend/index.html +84 -0
  63. package/dist/auto-ui/index.d.ts +23 -0
  64. package/dist/auto-ui/index.d.ts.map +1 -0
  65. package/dist/auto-ui/index.js +28 -0
  66. package/dist/auto-ui/index.js.map +1 -0
  67. package/dist/auto-ui/openapi-generator.d.ts +71 -0
  68. package/dist/auto-ui/openapi-generator.d.ts.map +1 -0
  69. package/dist/auto-ui/openapi-generator.js +223 -0
  70. package/dist/auto-ui/openapi-generator.js.map +1 -0
  71. package/dist/auto-ui/photon-bridge.d.ts +159 -0
  72. package/dist/auto-ui/photon-bridge.d.ts.map +1 -0
  73. package/dist/auto-ui/photon-bridge.js +262 -0
  74. package/dist/auto-ui/photon-bridge.js.map +1 -0
  75. package/dist/auto-ui/photon-host.d.ts +113 -0
  76. package/dist/auto-ui/photon-host.d.ts.map +1 -0
  77. package/dist/auto-ui/photon-host.js +284 -0
  78. package/dist/auto-ui/photon-host.js.map +1 -0
  79. package/dist/auto-ui/platform-compat.d.ts +71 -0
  80. package/dist/auto-ui/platform-compat.d.ts.map +1 -0
  81. package/dist/auto-ui/platform-compat.js +628 -0
  82. package/dist/auto-ui/platform-compat.js.map +1 -0
  83. package/dist/auto-ui/playground-html.d.ts +15 -0
  84. package/dist/auto-ui/playground-html.d.ts.map +1 -0
  85. package/dist/auto-ui/playground-html.js +1113 -0
  86. package/dist/auto-ui/playground-html.js.map +1 -0
  87. package/dist/auto-ui/playground-server.d.ts +7 -0
  88. package/dist/auto-ui/playground-server.d.ts.map +1 -0
  89. package/dist/auto-ui/playground-server.js +840 -0
  90. package/dist/auto-ui/playground-server.js.map +1 -0
  91. package/dist/auto-ui/registry.d.ts +13 -0
  92. package/dist/auto-ui/registry.d.ts.map +1 -0
  93. package/dist/auto-ui/registry.js +62 -0
  94. package/dist/auto-ui/registry.js.map +1 -0
  95. package/dist/auto-ui/renderer.d.ts +14 -0
  96. package/dist/auto-ui/renderer.d.ts.map +1 -0
  97. package/dist/auto-ui/renderer.js +88 -0
  98. package/dist/auto-ui/renderer.js.map +1 -0
  99. package/dist/auto-ui/rendering/components.d.ts +29 -0
  100. package/dist/auto-ui/rendering/components.d.ts.map +1 -0
  101. package/dist/auto-ui/rendering/components.js +773 -0
  102. package/dist/auto-ui/rendering/components.js.map +1 -0
  103. package/dist/auto-ui/rendering/field-analyzer.d.ts +48 -0
  104. package/dist/auto-ui/rendering/field-analyzer.d.ts.map +1 -0
  105. package/dist/auto-ui/rendering/field-analyzer.js +270 -0
  106. package/dist/auto-ui/rendering/field-analyzer.js.map +1 -0
  107. package/dist/auto-ui/rendering/field-renderers.d.ts +64 -0
  108. package/dist/auto-ui/rendering/field-renderers.d.ts.map +1 -0
  109. package/dist/auto-ui/rendering/field-renderers.js +317 -0
  110. package/dist/auto-ui/rendering/field-renderers.js.map +1 -0
  111. package/dist/auto-ui/rendering/index.d.ts +28 -0
  112. package/dist/auto-ui/rendering/index.d.ts.map +1 -0
  113. package/dist/auto-ui/rendering/index.js +60 -0
  114. package/dist/auto-ui/rendering/index.js.map +1 -0
  115. package/dist/auto-ui/rendering/layout-selector.d.ts +48 -0
  116. package/dist/auto-ui/rendering/layout-selector.d.ts.map +1 -0
  117. package/dist/auto-ui/rendering/layout-selector.js +352 -0
  118. package/dist/auto-ui/rendering/layout-selector.js.map +1 -0
  119. package/dist/auto-ui/rendering/template-engine.d.ts +41 -0
  120. package/dist/auto-ui/rendering/template-engine.d.ts.map +1 -0
  121. package/dist/auto-ui/rendering/template-engine.js +238 -0
  122. package/dist/auto-ui/rendering/template-engine.js.map +1 -0
  123. package/dist/auto-ui/streamable-http-transport.d.ts +103 -0
  124. package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -0
  125. package/dist/auto-ui/streamable-http-transport.js +1875 -0
  126. package/dist/auto-ui/streamable-http-transport.js.map +1 -0
  127. package/dist/auto-ui/types.d.ts +384 -0
  128. package/dist/auto-ui/types.d.ts.map +1 -0
  129. package/dist/auto-ui/types.js +92 -0
  130. package/dist/auto-ui/types.js.map +1 -0
  131. package/dist/beam.bundle.js +63137 -0
  132. package/dist/beam.bundle.js.map +7 -0
  133. package/dist/claude-code-plugin.d.ts.map +1 -1
  134. package/dist/claude-code-plugin.js +30 -30
  135. package/dist/claude-code-plugin.js.map +1 -1
  136. package/dist/cli/commands/info.d.ts +11 -0
  137. package/dist/cli/commands/info.d.ts.map +1 -0
  138. package/dist/cli/commands/info.js +313 -0
  139. package/dist/cli/commands/info.js.map +1 -0
  140. package/dist/cli/commands/marketplace.d.ts +11 -0
  141. package/dist/cli/commands/marketplace.d.ts.map +1 -0
  142. package/dist/cli/commands/marketplace.js +198 -0
  143. package/dist/cli/commands/marketplace.js.map +1 -0
  144. package/dist/cli/commands/package-app.d.ts +9 -0
  145. package/dist/cli/commands/package-app.d.ts.map +1 -0
  146. package/dist/cli/commands/package-app.js +191 -0
  147. package/dist/cli/commands/package-app.js.map +1 -0
  148. package/dist/cli/commands/package.d.ts +11 -0
  149. package/dist/cli/commands/package.d.ts.map +1 -0
  150. package/dist/cli/commands/package.js +573 -0
  151. package/dist/cli/commands/package.js.map +1 -0
  152. package/dist/cli-alias.d.ts.map +1 -1
  153. package/dist/cli-alias.js +30 -28
  154. package/dist/cli-alias.js.map +1 -1
  155. package/dist/cli-formatter.d.ts +8 -24
  156. package/dist/cli-formatter.d.ts.map +1 -1
  157. package/dist/cli-formatter.js +8 -325
  158. package/dist/cli-formatter.js.map +1 -1
  159. package/dist/cli.d.ts +15 -1
  160. package/dist/cli.d.ts.map +1 -1
  161. package/dist/cli.js +1166 -1131
  162. package/dist/cli.js.map +1 -1
  163. package/dist/daemon/client.d.ts +84 -3
  164. package/dist/daemon/client.d.ts.map +1 -1
  165. package/dist/daemon/client.js +561 -11
  166. package/dist/daemon/client.js.map +1 -1
  167. package/dist/daemon/manager.d.ts +51 -12
  168. package/dist/daemon/manager.d.ts.map +1 -1
  169. package/dist/daemon/manager.js +122 -61
  170. package/dist/daemon/manager.js.map +1 -1
  171. package/dist/daemon/protocol.d.ts +62 -6
  172. package/dist/daemon/protocol.d.ts.map +1 -1
  173. package/dist/daemon/protocol.js +76 -1
  174. package/dist/daemon/protocol.js.map +1 -1
  175. package/dist/daemon/server.d.ts +6 -6
  176. package/dist/daemon/server.js +743 -133
  177. package/dist/daemon/server.js.map +1 -1
  178. package/dist/daemon/session-manager.d.ts +8 -1
  179. package/dist/daemon/session-manager.d.ts.map +1 -1
  180. package/dist/daemon/session-manager.js +32 -9
  181. package/dist/daemon/session-manager.js.map +1 -1
  182. package/dist/deploy/cloudflare.d.ts +12 -0
  183. package/dist/deploy/cloudflare.d.ts.map +1 -0
  184. package/dist/deploy/cloudflare.js +216 -0
  185. package/dist/deploy/cloudflare.js.map +1 -0
  186. package/dist/index.d.ts +1 -0
  187. package/dist/index.d.ts.map +1 -1
  188. package/dist/index.js +3 -0
  189. package/dist/index.js.map +1 -1
  190. package/dist/loader.d.ts +191 -21
  191. package/dist/loader.d.ts.map +1 -1
  192. package/dist/loader.js +1186 -319
  193. package/dist/loader.js.map +1 -1
  194. package/dist/markdown-utils.d.ts +8 -0
  195. package/dist/markdown-utils.d.ts.map +1 -0
  196. package/dist/markdown-utils.js +63 -0
  197. package/dist/markdown-utils.js.map +1 -0
  198. package/dist/marketplace-manager.d.ts +10 -0
  199. package/dist/marketplace-manager.d.ts.map +1 -1
  200. package/dist/marketplace-manager.js +112 -28
  201. package/dist/marketplace-manager.js.map +1 -1
  202. package/dist/mcp-client.d.ts +9 -0
  203. package/dist/mcp-client.d.ts.map +1 -0
  204. package/dist/mcp-client.js +11 -0
  205. package/dist/mcp-client.js.map +1 -0
  206. package/dist/mcp-elicitation.d.ts +32 -0
  207. package/dist/mcp-elicitation.d.ts.map +1 -0
  208. package/dist/mcp-elicitation.js +26 -0
  209. package/dist/mcp-elicitation.js.map +1 -0
  210. package/dist/path-resolver.d.ts +9 -12
  211. package/dist/path-resolver.d.ts.map +1 -1
  212. package/dist/path-resolver.js +13 -43
  213. package/dist/path-resolver.js.map +1 -1
  214. package/dist/photon-cli-runner.d.ts.map +1 -1
  215. package/dist/photon-cli-runner.js +204 -77
  216. package/dist/photon-cli-runner.js.map +1 -1
  217. package/dist/photon-doc-extractor.d.ts +89 -0
  218. package/dist/photon-doc-extractor.d.ts.map +1 -1
  219. package/dist/photon-doc-extractor.js +560 -32
  220. package/dist/photon-doc-extractor.js.map +1 -1
  221. package/dist/photons/maker.photon.d.ts +182 -0
  222. package/dist/photons/maker.photon.d.ts.map +1 -0
  223. package/dist/photons/maker.photon.js +504 -0
  224. package/dist/photons/maker.photon.js.map +1 -0
  225. package/dist/photons/maker.photon.ts +626 -0
  226. package/dist/photons/marketplace.photon.d.ts +110 -0
  227. package/dist/photons/marketplace.photon.d.ts.map +1 -0
  228. package/dist/photons/marketplace.photon.js +260 -0
  229. package/dist/photons/marketplace.photon.js.map +1 -0
  230. package/dist/photons/marketplace.photon.ts +378 -0
  231. package/dist/photons/tunnel.photon.d.ts +80 -0
  232. package/dist/photons/tunnel.photon.d.ts.map +1 -0
  233. package/dist/photons/tunnel.photon.js +269 -0
  234. package/dist/photons/tunnel.photon.js.map +1 -0
  235. package/dist/photons/tunnel.photon.ts +345 -0
  236. package/dist/security-scanner.d.ts.map +1 -1
  237. package/dist/security-scanner.js +18 -15
  238. package/dist/security-scanner.js.map +1 -1
  239. package/dist/serv/auth/jwt.d.ts +89 -0
  240. package/dist/serv/auth/jwt.d.ts.map +1 -0
  241. package/dist/serv/auth/jwt.js +239 -0
  242. package/dist/serv/auth/jwt.js.map +1 -0
  243. package/dist/serv/auth/oauth.d.ts +117 -0
  244. package/dist/serv/auth/oauth.d.ts.map +1 -0
  245. package/dist/serv/auth/oauth.js +395 -0
  246. package/dist/serv/auth/oauth.js.map +1 -0
  247. package/dist/serv/auth/well-known.d.ts +60 -0
  248. package/dist/serv/auth/well-known.d.ts.map +1 -0
  249. package/dist/serv/auth/well-known.js +154 -0
  250. package/dist/serv/auth/well-known.js.map +1 -0
  251. package/dist/serv/db/d1-client.d.ts +65 -0
  252. package/dist/serv/db/d1-client.d.ts.map +1 -0
  253. package/dist/serv/db/d1-client.js +137 -0
  254. package/dist/serv/db/d1-client.js.map +1 -0
  255. package/dist/serv/db/d1-stores.d.ts +62 -0
  256. package/dist/serv/db/d1-stores.d.ts.map +1 -0
  257. package/dist/serv/db/d1-stores.js +307 -0
  258. package/dist/serv/db/d1-stores.js.map +1 -0
  259. package/dist/serv/index.d.ts +114 -0
  260. package/dist/serv/index.d.ts.map +1 -0
  261. package/dist/serv/index.js +172 -0
  262. package/dist/serv/index.js.map +1 -0
  263. package/dist/serv/local.d.ts +118 -0
  264. package/dist/serv/local.d.ts.map +1 -0
  265. package/dist/serv/local.js +392 -0
  266. package/dist/serv/local.js.map +1 -0
  267. package/dist/serv/middleware/auth.d.ts +66 -0
  268. package/dist/serv/middleware/auth.d.ts.map +1 -0
  269. package/dist/serv/middleware/auth.js +178 -0
  270. package/dist/serv/middleware/auth.js.map +1 -0
  271. package/dist/serv/middleware/tenant.d.ts +94 -0
  272. package/dist/serv/middleware/tenant.d.ts.map +1 -0
  273. package/dist/serv/middleware/tenant.js +152 -0
  274. package/dist/serv/middleware/tenant.js.map +1 -0
  275. package/dist/serv/runtime/executor.d.ts +76 -0
  276. package/dist/serv/runtime/executor.d.ts.map +1 -0
  277. package/dist/serv/runtime/executor.js +105 -0
  278. package/dist/serv/runtime/executor.js.map +1 -0
  279. package/dist/serv/runtime/index.d.ts +8 -0
  280. package/dist/serv/runtime/index.d.ts.map +1 -0
  281. package/dist/serv/runtime/index.js +10 -0
  282. package/dist/serv/runtime/index.js.map +1 -0
  283. package/dist/serv/runtime/oauth-context.d.ts +121 -0
  284. package/dist/serv/runtime/oauth-context.d.ts.map +1 -0
  285. package/dist/serv/runtime/oauth-context.js +153 -0
  286. package/dist/serv/runtime/oauth-context.js.map +1 -0
  287. package/dist/serv/session/kv-store.d.ts +54 -0
  288. package/dist/serv/session/kv-store.d.ts.map +1 -0
  289. package/dist/serv/session/kv-store.js +149 -0
  290. package/dist/serv/session/kv-store.js.map +1 -0
  291. package/dist/serv/session/store.d.ts +113 -0
  292. package/dist/serv/session/store.d.ts.map +1 -0
  293. package/dist/serv/session/store.js +284 -0
  294. package/dist/serv/session/store.js.map +1 -0
  295. package/dist/serv/types/index.d.ts +147 -0
  296. package/dist/serv/types/index.d.ts.map +1 -0
  297. package/dist/serv/types/index.js +8 -0
  298. package/dist/serv/types/index.js.map +1 -0
  299. package/dist/serv/vault/token-vault.d.ts +102 -0
  300. package/dist/serv/vault/token-vault.d.ts.map +1 -0
  301. package/dist/serv/vault/token-vault.js +177 -0
  302. package/dist/serv/vault/token-vault.js.map +1 -0
  303. package/dist/server.d.ts +184 -0
  304. package/dist/server.d.ts.map +1 -1
  305. package/dist/server.js +1995 -86
  306. package/dist/server.js.map +1 -1
  307. package/dist/shared/cli-sections.d.ts +6 -0
  308. package/dist/shared/cli-sections.d.ts.map +1 -0
  309. package/dist/shared/cli-sections.js +16 -0
  310. package/dist/shared/cli-sections.js.map +1 -0
  311. package/dist/shared/cli-utils.d.ts +81 -0
  312. package/dist/shared/cli-utils.d.ts.map +1 -0
  313. package/dist/shared/cli-utils.js +174 -0
  314. package/dist/shared/cli-utils.js.map +1 -0
  315. package/dist/shared/config-docs.d.ts +6 -0
  316. package/dist/shared/config-docs.d.ts.map +1 -0
  317. package/dist/shared/config-docs.js +6 -0
  318. package/dist/shared/config-docs.js.map +1 -0
  319. package/dist/shared/error-handler.d.ts +128 -0
  320. package/dist/shared/error-handler.d.ts.map +1 -0
  321. package/dist/shared/error-handler.js +342 -0
  322. package/dist/shared/error-handler.js.map +1 -0
  323. package/dist/shared/logger.d.ts +42 -0
  324. package/dist/shared/logger.d.ts.map +1 -0
  325. package/dist/shared/logger.js +123 -0
  326. package/dist/shared/logger.js.map +1 -0
  327. package/dist/shared/performance.d.ts +65 -0
  328. package/dist/shared/performance.d.ts.map +1 -0
  329. package/dist/shared/performance.js +136 -0
  330. package/dist/shared/performance.js.map +1 -0
  331. package/dist/shared/task-runner.d.ts +2 -0
  332. package/dist/shared/task-runner.d.ts.map +1 -0
  333. package/dist/shared/task-runner.js +16 -0
  334. package/dist/shared/task-runner.js.map +1 -0
  335. package/dist/shared/validation.d.ts +6 -0
  336. package/dist/shared/validation.d.ts.map +1 -0
  337. package/dist/shared/validation.js +6 -0
  338. package/dist/shared/validation.js.map +1 -0
  339. package/dist/shared-utils.d.ts +63 -0
  340. package/dist/shared-utils.d.ts.map +1 -0
  341. package/dist/shared-utils.js +123 -0
  342. package/dist/shared-utils.js.map +1 -0
  343. package/dist/template-manager.d.ts +23 -2
  344. package/dist/template-manager.d.ts.map +1 -1
  345. package/dist/template-manager.js +176 -87
  346. package/dist/template-manager.js.map +1 -1
  347. package/dist/test-client.d.ts.map +1 -1
  348. package/dist/test-client.js +10 -8
  349. package/dist/test-client.js.map +1 -1
  350. package/dist/test-runner.d.ts +52 -0
  351. package/dist/test-runner.d.ts.map +1 -0
  352. package/dist/test-runner.js +785 -0
  353. package/dist/test-runner.js.map +1 -0
  354. package/dist/testing.d.ts +103 -0
  355. package/dist/testing.d.ts.map +1 -0
  356. package/dist/testing.js +163 -0
  357. package/dist/testing.js.map +1 -0
  358. package/dist/version-checker.d.ts.map +1 -1
  359. package/dist/version-checker.js +2 -2
  360. package/dist/version-checker.js.map +1 -1
  361. package/dist/version.d.ts +10 -0
  362. package/dist/version.d.ts.map +1 -0
  363. package/dist/version.js +21 -0
  364. package/dist/version.js.map +1 -0
  365. package/dist/watcher.d.ts +6 -3
  366. package/dist/watcher.d.ts.map +1 -1
  367. package/dist/watcher.js +49 -10
  368. package/dist/watcher.js.map +1 -1
  369. package/package.json +57 -7
  370. package/templates/cloudflare/worker.ts.template +381 -0
  371. package/templates/cloudflare/wrangler.toml.template +9 -0
  372. package/dist/base.d.ts +0 -58
  373. package/dist/base.d.ts.map +0 -1
  374. package/dist/base.js +0 -92
  375. package/dist/base.js.map +0 -1
  376. package/dist/dependency-manager.d.ts +0 -49
  377. package/dist/dependency-manager.d.ts.map +0 -1
  378. package/dist/dependency-manager.js +0 -165
  379. package/dist/dependency-manager.js.map +0 -1
  380. package/dist/registry-manager.d.ts +0 -76
  381. package/dist/registry-manager.d.ts.map +0 -1
  382. package/dist/registry-manager.js +0 -220
  383. package/dist/registry-manager.js.map +0 -1
  384. package/dist/schema-extractor.d.ts +0 -110
  385. package/dist/schema-extractor.d.ts.map +0 -1
  386. package/dist/schema-extractor.js +0 -727
  387. package/dist/schema-extractor.js.map +0 -1
  388. package/dist/test-marketplace-sources.d.ts +0 -5
  389. package/dist/test-marketplace-sources.d.ts.map +0 -1
  390. package/dist/test-marketplace-sources.js +0 -53
  391. package/dist/test-marketplace-sources.js.map +0 -1
  392. package/dist/types.d.ts +0 -109
  393. package/dist/types.d.ts.map +0 -1
  394. package/dist/types.js +0 -12
  395. package/dist/types.js.map +0 -1
@@ -0,0 +1,3057 @@
1
+ /**
2
+ * Photon Beam - Interactive Control Panel
3
+ *
4
+ * A unified UI to interact with all your photons.
5
+ * Uses MCP Streamable HTTP (POST + SSE) for real-time communication.
6
+ * Version: 2.0.0 (SSE Architecture)
7
+ */
8
+ import * as http from 'http';
9
+ import * as net from 'net';
10
+ import * as fs from 'fs/promises';
11
+ import { existsSync, lstatSync, realpathSync, watch } from 'fs';
12
+ import * as path from 'path';
13
+ import * as os from 'os';
14
+ import { spawn } from 'child_process';
15
+ import { fileURLToPath } from 'url';
16
+ import { createHash } from 'crypto';
17
+ /**
18
+ * Generate a unique ID for a photon based on its path.
19
+ * This ensures photons with the same name from different paths are distinguishable.
20
+ * Returns first 12 chars of SHA-256 hash for brevity while maintaining uniqueness.
21
+ */
22
+ function generatePhotonId(photonPath) {
23
+ return createHash('sha256').update(photonPath).digest('hex').slice(0, 12);
24
+ }
25
+ const __filename = fileURLToPath(import.meta.url);
26
+ const __dirname = path.dirname(__filename);
27
+ // WebSocket removed - now using MCP Streamable HTTP (SSE) only
28
+ import { listPhotonMCPs, resolvePhotonPath } from '../path-resolver.js';
29
+ import { PhotonLoader } from '../loader.js';
30
+ import { logger, createLogger } from '../shared/logger.js';
31
+ import { toEnvVarName } from '../shared/config-docs.js';
32
+ import { MarketplaceManager } from '../marketplace-manager.js';
33
+ import { PhotonDocExtractor } from '../photon-doc-extractor.js';
34
+ import { TemplateManager } from '../template-manager.js';
35
+ import { subscribeChannel, pingDaemon } from '../daemon/client.js';
36
+ import { SchemaExtractor, } from '@portel/photon-core';
37
+ import { generateOpenAPISpec } from './openapi-generator.js';
38
+ import { handleStreamableHTTP, broadcastNotification, broadcastToBeam, sendToSession, requestExternalElicitation, } from './streamable-http-transport.js';
39
+ import { SDKMCPClientFactory } from '../mcp-client.js';
40
+ import { getBundledPhotonPath, BEAM_BUNDLED_PHOTONS } from '../shared-utils.js';
41
+ // SDK imports for direct resource access (transport wrapper doesn't expose these yet)
42
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
43
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
44
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
45
+ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
46
+ import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js';
47
+ // Config file path
48
+ const CONFIG_FILE = path.join(os.homedir(), '.photon', 'config.json');
49
+ // ════════════════════════════════════════════════════════════════════════════════
50
+ // EXTERNAL MCP STATE (module-level for MCP transport access)
51
+ // ════════════════════════════════════════════════════════════════════════════════
52
+ /** External MCP servers loaded from config */
53
+ const externalMCPs = [];
54
+ /** Active MCP client instances for external MCPs */
55
+ const externalMCPClients = new Map();
56
+ /** Direct SDK clients for resource access (listResources, readResource) */
57
+ const externalMCPSDKClients = new Map();
58
+ /**
59
+ * Generate a unique ID for an external MCP based on its name
60
+ */
61
+ function generateExternalMCPId(name) {
62
+ return createHash('sha256').update(`external:${name}`).digest('hex').slice(0, 12);
63
+ }
64
+ /**
65
+ * Convert a tool name to a display label
66
+ */
67
+ function prettifyToolName(name) {
68
+ return name
69
+ .split(/[-_]/)
70
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
71
+ .join(' ');
72
+ }
73
+ /**
74
+ * Create an HTTP transport for a URL-based MCP.
75
+ * Tries Streamable HTTP first; falls back to legacy SSE.
76
+ */
77
+ async function connectHTTPClient(url, mcpName) {
78
+ const sdkClient = new Client({ name: 'beam-mcp-client', version: '1.0.0' }, {
79
+ capabilities: {
80
+ elicitation: {}, // Declare elicitation support
81
+ experimental: {
82
+ ui: {}, // Request SEP-1865 format for MCP Apps
83
+ },
84
+ },
85
+ });
86
+ // Set up elicitation handler
87
+ sdkClient.setRequestHandler(ElicitRequestSchema, async (request) => {
88
+ const params = request.params;
89
+ const result = await requestExternalElicitation(mcpName, {
90
+ mode: params.mode,
91
+ message: params.message,
92
+ requestedSchema: params.requestedSchema,
93
+ url: params.url,
94
+ });
95
+ return result;
96
+ });
97
+ try {
98
+ const transport = new StreamableHTTPClientTransport(new URL(url));
99
+ const connectPromise = sdkClient.connect(transport);
100
+ const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Connection timeout (10s)')), 10000));
101
+ await Promise.race([connectPromise, timeoutPromise]);
102
+ logger.debug(`Connected to ${url} via Streamable HTTP`);
103
+ return sdkClient;
104
+ }
105
+ catch (streamableError) {
106
+ logger.debug(`Streamable HTTP failed for ${url}, trying legacy SSE: ${streamableError}`);
107
+ }
108
+ // Fallback: legacy SSE transport
109
+ const sseClient = new Client({ name: 'beam-mcp-client', version: '1.0.0' }, {
110
+ capabilities: {
111
+ elicitation: {}, // Declare elicitation support
112
+ experimental: {
113
+ ui: {}, // Request SEP-1865 format for MCP Apps
114
+ },
115
+ },
116
+ });
117
+ // Set up elicitation handler for SSE client too
118
+ sseClient.setRequestHandler(ElicitRequestSchema, async (request) => {
119
+ const params = request.params;
120
+ const result = await requestExternalElicitation(mcpName, {
121
+ mode: params.mode,
122
+ message: params.message,
123
+ requestedSchema: params.requestedSchema,
124
+ url: params.url,
125
+ });
126
+ return result;
127
+ });
128
+ const sseTransport = new SSEClientTransport(new URL(url));
129
+ const connectPromise = sseClient.connect(sseTransport);
130
+ const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Connection timeout (10s)')), 10000));
131
+ await Promise.race([connectPromise, timeoutPromise]);
132
+ logger.debug(`Connected to ${url} via legacy SSE`);
133
+ return sseClient;
134
+ }
135
+ /**
136
+ * Load external MCPs from config.json mcpServers section
137
+ *
138
+ * @param config - The PhotonConfig with mcpServers section
139
+ * @returns Array of ExternalMCPInfo objects (populated with connected status)
140
+ */
141
+ async function loadExternalMCPs(config) {
142
+ const mcpServers = config.mcpServers || {};
143
+ const results = [];
144
+ for (const [name, serverConfig] of Object.entries(mcpServers)) {
145
+ const mcpId = generateExternalMCPId(name);
146
+ // Create the MCP info with initial disconnected state
147
+ const mcpInfo = {
148
+ type: 'external-mcp',
149
+ id: mcpId,
150
+ name,
151
+ connected: false,
152
+ methods: [],
153
+ label: prettifyToolName(name),
154
+ icon: '🔌',
155
+ config: serverConfig,
156
+ };
157
+ try {
158
+ let methods = [];
159
+ if (serverConfig.url) {
160
+ // HTTP transport — SDK client only (no wrapper needed)
161
+ // Tries Streamable HTTP first, falls back to legacy SSE
162
+ const sdkClient = await connectHTTPClient(serverConfig.url, name);
163
+ externalMCPSDKClients.set(name, sdkClient);
164
+ // List tools with full metadata using SDK client
165
+ const toolsResult = await sdkClient.listTools();
166
+ const tools = toolsResult.tools || [];
167
+ // Convert tools to MethodInfo[] with full _meta support
168
+ methods = tools.map((tool) => ({
169
+ name: tool.name,
170
+ description: tool.description || '',
171
+ params: tool.inputSchema || { type: 'object', properties: {} },
172
+ returns: { type: 'object' },
173
+ icon: tool['x-icon'],
174
+ linkedUi: tool._meta?.ui?.resourceUri,
175
+ visibility: tool._meta?.ui?.visibility,
176
+ }));
177
+ // Fetch resources to detect MCP Apps
178
+ try {
179
+ const resourcesResult = await sdkClient.listResources();
180
+ const resources = resourcesResult.resources || [];
181
+ const appResources = resources.filter((r) => r.uri?.startsWith('ui://') ||
182
+ r.mimeType === 'application/vnd.mcp.ui+html');
183
+ // Count only non-UI resources (UI resources are internal implementation detail)
184
+ mcpInfo.resourceCount = resources.length - appResources.length;
185
+ if (appResources.length > 0) {
186
+ mcpInfo.hasApp = true;
187
+ mcpInfo.appResourceUri = appResources[0].uri;
188
+ mcpInfo.appResourceUris = appResources.map((r) => r.uri);
189
+ const uriList = mcpInfo.appResourceUris.join(', ');
190
+ logger.info(`🎨 MCP App detected: ${name} (${uriList})`);
191
+ }
192
+ }
193
+ catch (resourceError) {
194
+ logger.debug(`Resources not supported by ${name}`);
195
+ }
196
+ mcpInfo.connected = true;
197
+ mcpInfo.methods = methods;
198
+ }
199
+ else if (serverConfig.command) {
200
+ // Stdio transport — create wrapper client as fallback, SDK client as primary
201
+ const mcpConfig = {
202
+ mcpServers: {
203
+ [name]: serverConfig,
204
+ },
205
+ };
206
+ const factory = new SDKMCPClientFactory(mcpConfig, false);
207
+ const client = factory.create(name);
208
+ externalMCPClients.set(name, client);
209
+ try {
210
+ const sdkTransport = new StdioClientTransport({
211
+ command: serverConfig.command,
212
+ args: serverConfig.args,
213
+ cwd: serverConfig.cwd,
214
+ env: serverConfig.env,
215
+ stderr: 'ignore', // Suppress stderr to avoid ugly tracebacks on shutdown
216
+ });
217
+ const sdkClient = new Client({ name: 'beam-mcp-client', version: '1.0.0' }, {
218
+ capabilities: {
219
+ elicitation: {}, // Declare elicitation support
220
+ experimental: {
221
+ ui: {}, // Request SEP-1865 format for MCP Apps
222
+ },
223
+ },
224
+ });
225
+ // Set up elicitation handler BEFORE connecting
226
+ // This handles elicitation/create requests from the server
227
+ sdkClient.setRequestHandler(ElicitRequestSchema, async (request) => {
228
+ const params = request.params;
229
+ const result = await requestExternalElicitation(name, {
230
+ mode: params.mode,
231
+ message: params.message,
232
+ requestedSchema: params.requestedSchema,
233
+ url: params.url,
234
+ });
235
+ return result;
236
+ });
237
+ const connectPromise = sdkClient.connect(sdkTransport);
238
+ const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Connection timeout (10s)')), 10000));
239
+ await Promise.race([connectPromise, timeoutPromise]);
240
+ externalMCPSDKClients.set(name, sdkClient);
241
+ // List tools with full metadata using SDK client
242
+ const toolsResult = await sdkClient.listTools();
243
+ const tools = toolsResult.tools || [];
244
+ // Convert tools to MethodInfo[] with full _meta support
245
+ methods = tools.map((tool) => ({
246
+ name: tool.name,
247
+ description: tool.description || '',
248
+ params: tool.inputSchema || { type: 'object', properties: {} },
249
+ returns: { type: 'object' },
250
+ icon: tool['x-icon'],
251
+ // Preserve MCP App linkage from tool metadata
252
+ linkedUi: tool._meta?.ui?.resourceUri,
253
+ visibility: tool._meta?.ui?.visibility,
254
+ }));
255
+ // Fetch resources to detect MCP Apps
256
+ try {
257
+ const resourcesResult = await sdkClient.listResources();
258
+ const resources = resourcesResult.resources || [];
259
+ // Check for MCP App resources (ui:// scheme or application/vnd.mcp.ui+html mime)
260
+ const appResources = resources.filter((r) => r.uri?.startsWith('ui://') ||
261
+ r.mimeType === 'application/vnd.mcp.ui+html');
262
+ // Count only non-UI resources (UI resources are internal implementation detail)
263
+ mcpInfo.resourceCount = resources.length - appResources.length;
264
+ if (appResources.length > 0) {
265
+ mcpInfo.hasApp = true;
266
+ mcpInfo.appResourceUri = appResources[0].uri; // Default to first
267
+ mcpInfo.appResourceUris = appResources.map((r) => r.uri);
268
+ const uriList = mcpInfo.appResourceUris.join(', ');
269
+ logger.info(`🎨 MCP App detected: ${name} (${uriList})`);
270
+ }
271
+ }
272
+ catch (resourceError) {
273
+ // Resources not supported - that's fine
274
+ logger.debug(`Resources not supported by ${name}`);
275
+ }
276
+ // Set connected state after successful SDK client setup
277
+ mcpInfo.connected = true;
278
+ mcpInfo.methods = methods;
279
+ }
280
+ catch (sdkError) {
281
+ // SDK client failed - fall back to wrapper client
282
+ logger.debug(`SDK client failed for ${name}, using wrapper: ${sdkError}`);
283
+ // Try wrapper client as fallback
284
+ const tools = await client.list();
285
+ methods = (tools || []).map((tool) => ({
286
+ name: tool.name,
287
+ description: tool.description || '',
288
+ params: tool.inputSchema || { type: 'object', properties: {} },
289
+ returns: { type: 'object' },
290
+ icon: tool['x-icon'],
291
+ }));
292
+ mcpInfo.connected = true;
293
+ mcpInfo.methods = methods;
294
+ }
295
+ }
296
+ else {
297
+ // No command or URL — create wrapper client (legacy fallback)
298
+ const mcpConfig = {
299
+ mcpServers: {
300
+ [name]: serverConfig,
301
+ },
302
+ };
303
+ const factory = new SDKMCPClientFactory(mcpConfig, false);
304
+ const client = factory.create(name);
305
+ externalMCPClients.set(name, client);
306
+ const tools = await client.list();
307
+ methods = (tools || []).map((tool) => ({
308
+ name: tool.name,
309
+ description: tool.description || '',
310
+ params: tool.inputSchema || { type: 'object', properties: {} },
311
+ returns: { type: 'object' },
312
+ icon: tool['x-icon'],
313
+ }));
314
+ mcpInfo.connected = true;
315
+ mcpInfo.methods = methods;
316
+ }
317
+ logger.info(`🔌 Connected to external MCP: ${name} (${methods.length} tools)`);
318
+ }
319
+ catch (error) {
320
+ const errorMsg = error instanceof Error ? error.message : String(error);
321
+ mcpInfo.errorMessage = errorMsg.slice(0, 200);
322
+ logger.warn(`⚠️ Failed to connect to external MCP: ${name} - ${errorMsg}`);
323
+ }
324
+ results.push(mcpInfo);
325
+ }
326
+ return results;
327
+ }
328
+ /**
329
+ * Reconnect a failed external MCP
330
+ *
331
+ * @param name - The MCP name to reconnect
332
+ * @returns Success status and error message if failed
333
+ */
334
+ async function reconnectExternalMCP(name) {
335
+ const mcpIndex = externalMCPs.findIndex((m) => m.name === name);
336
+ if (mcpIndex === -1) {
337
+ return { success: false, error: `External MCP not found: ${name}` };
338
+ }
339
+ const mcp = externalMCPs[mcpIndex];
340
+ try {
341
+ let methods = [];
342
+ if (mcp.config.url) {
343
+ // HTTP transport — tries Streamable HTTP, falls back to legacy SSE
344
+ const sdkClient = await connectHTTPClient(mcp.config.url, name);
345
+ externalMCPSDKClients.set(name, sdkClient);
346
+ const toolsResult = await sdkClient.listTools();
347
+ const tools = toolsResult.tools || [];
348
+ methods = tools.map((tool) => ({
349
+ name: tool.name,
350
+ description: tool.description || '',
351
+ params: tool.inputSchema || { type: 'object', properties: {} },
352
+ returns: { type: 'object' },
353
+ icon: tool['x-icon'],
354
+ linkedUi: tool._meta?.ui?.resourceUri,
355
+ visibility: tool._meta?.ui?.visibility,
356
+ }));
357
+ // Fetch resources to detect MCP Apps
358
+ try {
359
+ const resourcesResult = await sdkClient.listResources();
360
+ const resources = resourcesResult.resources || [];
361
+ const appResources = resources.filter((r) => r.uri?.startsWith('ui://') ||
362
+ r.mimeType === 'application/vnd.mcp.ui+html');
363
+ // Count only non-UI resources (UI resources are internal implementation detail)
364
+ mcp.resourceCount = resources.length - appResources.length;
365
+ if (appResources.length > 0) {
366
+ mcp.hasApp = true;
367
+ mcp.appResourceUri = appResources[0].uri;
368
+ mcp.appResourceUris = appResources.map((r) => r.uri);
369
+ }
370
+ }
371
+ catch {
372
+ // Resources not supported
373
+ }
374
+ }
375
+ else {
376
+ // Stdio / wrapper transport
377
+ const mcpConfig = {
378
+ mcpServers: {
379
+ [name]: mcp.config,
380
+ },
381
+ };
382
+ const factory = new SDKMCPClientFactory(mcpConfig, false);
383
+ const client = factory.create(name);
384
+ const connectPromise = client.list();
385
+ const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Connection timeout (10s)')), 10000));
386
+ const tools = (await Promise.race([connectPromise, timeoutPromise]));
387
+ methods = (tools || []).map((tool) => ({
388
+ name: tool.name,
389
+ description: tool.description || '',
390
+ params: tool.inputSchema || { type: 'object', properties: {} },
391
+ returns: { type: 'object' },
392
+ icon: tool['x-icon'],
393
+ }));
394
+ externalMCPClients.set(name, client);
395
+ }
396
+ // Update MCP info
397
+ mcp.connected = true;
398
+ mcp.methods = methods;
399
+ mcp.errorMessage = undefined;
400
+ logger.info(`🔌 Reconnected to external MCP: ${name} (${methods.length} tools)`);
401
+ return { success: true };
402
+ }
403
+ catch (error) {
404
+ const errorMsg = error instanceof Error ? error.message : String(error);
405
+ mcp.errorMessage = errorMsg.slice(0, 200);
406
+ logger.warn(`⚠️ Failed to reconnect to external MCP: ${name} - ${errorMsg}`);
407
+ return { success: false, error: errorMsg };
408
+ }
409
+ }
410
+ /**
411
+ * Migrate old flat config to new nested structure
412
+ */
413
+ function migrateConfig(config) {
414
+ // Already new format
415
+ if (config.photons !== undefined || config.mcpServers !== undefined) {
416
+ return {
417
+ photons: config.photons || {},
418
+ mcpServers: config.mcpServers || {},
419
+ };
420
+ }
421
+ // Old flat format → migrate all keys under photons
422
+ console.error('📦 Migrating config.json to new nested format...');
423
+ return {
424
+ photons: { ...config },
425
+ mcpServers: {},
426
+ };
427
+ }
428
+ async function loadConfig() {
429
+ try {
430
+ const data = await fs.readFile(CONFIG_FILE, 'utf-8');
431
+ const raw = JSON.parse(data);
432
+ const migrated = migrateConfig(raw);
433
+ // Save back if migration occurred (structure changed)
434
+ if (!raw.photons && Object.keys(raw).length > 0) {
435
+ await saveConfig(migrated);
436
+ console.error('✅ Config migrated successfully');
437
+ }
438
+ return migrated;
439
+ }
440
+ catch {
441
+ return { photons: {}, mcpServers: {} };
442
+ }
443
+ }
444
+ async function saveConfig(config) {
445
+ const dir = path.dirname(CONFIG_FILE);
446
+ await fs.mkdir(dir, { recursive: true });
447
+ await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
448
+ }
449
+ /**
450
+ * Extract class-level metadata (description, icon) from JSDoc comments
451
+ */
452
+ /**
453
+ * Convert a kebab-case name to a display label
454
+ * e.g. "filesystem" → "Filesystem", "git-box" → "Git Box"
455
+ */
456
+ function prettifyName(name) {
457
+ return name
458
+ .split('-')
459
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
460
+ .join(' ');
461
+ }
462
+ /**
463
+ * After loading a photon, backfill env vars for constructor params that used
464
+ * their TypeScript defaults (env var not set). This ensures the env var always
465
+ * reflects the effective value so other consumers (e.g. /api/browse) can read it.
466
+ */
467
+ function backfillEnvDefaults(instance, params) {
468
+ for (const param of params) {
469
+ if (!process.env[param.envVar] && param.hasDefault) {
470
+ const value = instance[param.name];
471
+ if (value !== undefined && value !== null) {
472
+ process.env[param.envVar] = String(value);
473
+ }
474
+ }
475
+ }
476
+ }
477
+ function extractClassMetadataFromSource(content) {
478
+ try {
479
+ // Find class-level JSDoc (immediately before class, or first JSDoc in file)
480
+ const classDocRegex = /\/\*\*([\s\S]*?)\*\/\s*\n?(?:export\s+)?(?:default\s+)?class\s+\w+/;
481
+ const match = content.match(classDocRegex) || content.match(/^\/\*\*([\s\S]*?)\*\//);
482
+ if (!match) {
483
+ return {};
484
+ }
485
+ const docContent = match[1];
486
+ const metadata = {};
487
+ // Extract @icon
488
+ const iconMatch = docContent.match(/@icon\s+(\S+)/);
489
+ if (iconMatch) {
490
+ metadata.icon = iconMatch[1];
491
+ }
492
+ // Extract @internal (presence indicates internal photon)
493
+ if (/@internal\b/.test(docContent)) {
494
+ metadata.internal = true;
495
+ }
496
+ // Extract @version
497
+ const versionMatch = docContent.match(/@version\s+(\S+)/);
498
+ if (versionMatch) {
499
+ metadata.version = versionMatch[1];
500
+ }
501
+ // Extract @author
502
+ const authorMatch = docContent.match(/@author\s+([^\n@]+)/);
503
+ if (authorMatch) {
504
+ metadata.author = authorMatch[1].trim();
505
+ }
506
+ // Extract @label (custom display name)
507
+ const labelMatch = docContent.match(/@label\s+([^\n@]+)/);
508
+ if (labelMatch) {
509
+ metadata.label = labelMatch[1].trim();
510
+ }
511
+ // Extract @description or first line of doc (not starting with @)
512
+ const descMatch = docContent.match(/@description\s+([^\n@]+)/);
513
+ if (descMatch) {
514
+ metadata.description = descMatch[1].trim();
515
+ }
516
+ else {
517
+ // Get first non-empty line that's not a tag
518
+ const lines = docContent
519
+ .split('\n')
520
+ .map((l) => l.replace(/^\s*\*\s?/, '').trim())
521
+ .filter((l) => l && !l.startsWith('@'));
522
+ if (lines.length > 0) {
523
+ metadata.description = lines[0];
524
+ }
525
+ }
526
+ return metadata;
527
+ }
528
+ catch {
529
+ return {};
530
+ }
531
+ }
532
+ /**
533
+ * Extract @visibility annotations from method-level JSDoc and apply to methods
534
+ * @visibility model,app → ['model', 'app']
535
+ */
536
+ function applyMethodVisibility(source, methods) {
537
+ const regex = /\/\*\*[\s\S]*?@visibility\s+([\w,\s]+)[\s\S]*?\*\/\s*(?:async\s+)?\*?\s*(\w+)/g;
538
+ let match;
539
+ while ((match = regex.exec(source)) !== null) {
540
+ const [, visibilityStr, methodName] = match;
541
+ const method = methods.find((m) => m.name === methodName);
542
+ if (method) {
543
+ method.visibility = visibilityStr
544
+ .split(',')
545
+ .map((v) => v.trim())
546
+ .filter((v) => v === 'model' || v === 'app');
547
+ }
548
+ }
549
+ }
550
+ /**
551
+ * Extract @csp annotations from class-level JSDoc
552
+ * @csp connect domain1,domain2
553
+ * @csp resource cdn.example.com
554
+ */
555
+ function extractCspFromSource(source) {
556
+ const result = {};
557
+ // Match class-level JSDoc with @csp tags
558
+ const classDocRegex = /\/\*\*([\s\S]*?)\*\/\s*\n?(?:export\s+)?(?:default\s+)?class\s+(\w+)/g;
559
+ let classMatch;
560
+ while ((classMatch = classDocRegex.exec(source)) !== null) {
561
+ const docContent = classMatch[1];
562
+ const csp = {};
563
+ let hasCsp = false;
564
+ const cspRegex = /@csp\s+(connect|resource|frame|base-uri)\s+([^\n@]+)/g;
565
+ let cspMatch;
566
+ while ((cspMatch = cspRegex.exec(docContent)) !== null) {
567
+ hasCsp = true;
568
+ const directive = cspMatch[1].trim();
569
+ const domains = cspMatch[2]
570
+ .trim()
571
+ .split(/[,\s]+/)
572
+ .filter(Boolean);
573
+ const key = directive === 'base-uri' ? 'baseUriDomains' : `${directive}Domains`;
574
+ csp[key] = (csp[key] || []).concat(domains);
575
+ }
576
+ if (hasCsp) {
577
+ result['__class__'] = csp;
578
+ }
579
+ }
580
+ return result;
581
+ }
582
+ export async function startBeam(rawWorkingDir, port) {
583
+ const workingDir = path.resolve(rawWorkingDir);
584
+ // Initialize marketplace manager for photon discovery and installation
585
+ const marketplace = new MarketplaceManager();
586
+ await marketplace.initialize();
587
+ // Auto-update stale caches in background
588
+ marketplace.autoUpdateStaleCaches().catch(() => { });
589
+ // Discover all photons (user photons + bundled photons)
590
+ const userPhotonList = await listPhotonMCPs(workingDir);
591
+ // Add bundled photons with their paths
592
+ const bundledPhotonPaths = new Map();
593
+ for (const name of BEAM_BUNDLED_PHOTONS) {
594
+ const bundledPath = getBundledPhotonPath(name, __dirname, BEAM_BUNDLED_PHOTONS);
595
+ if (bundledPath) {
596
+ bundledPhotonPaths.set(name, bundledPath);
597
+ }
598
+ }
599
+ // Combine: user photons first, then bundled photons (avoid duplicates)
600
+ const photonList = [...userPhotonList];
601
+ for (const name of BEAM_BUNDLED_PHOTONS) {
602
+ if (!photonList.includes(name) && bundledPhotonPaths.has(name)) {
603
+ photonList.push(name);
604
+ }
605
+ }
606
+ if (photonList.length === 0) {
607
+ logger.info('No photons found - showing management UI');
608
+ }
609
+ // Load saved config and apply to env
610
+ const savedConfig = await loadConfig();
611
+ // Extract metadata for all photons
612
+ const photons = [];
613
+ const photonMCPs = new Map(); // Store full MCP objects
614
+ // Use PhotonLoader with error-only logger to reduce verbosity
615
+ // Beam handles config errors gracefully via UI forms, but we still want to see actual errors
616
+ const errorOnlyLogger = createLogger({ level: 'error' });
617
+ const loader = new PhotonLoader(false, errorOnlyLogger);
618
+ // Counts updated after photon loading
619
+ let configuredCount = 0;
620
+ let unconfiguredCount = 0;
621
+ // Check for placeholder defaults or localhost URLs (which need local services running)
622
+ const isPlaceholderOrLocalDefault = (value) => {
623
+ if (value.includes('<') || value.includes('your-'))
624
+ return true;
625
+ if (value.includes('localhost') || value.includes('127.0.0.1'))
626
+ return true;
627
+ return false;
628
+ };
629
+ // Helper: load a single photon, returning the info to push into photons[]
630
+ async function loadSinglePhoton(name) {
631
+ const photonPath = bundledPhotonPaths.get(name) || (await resolvePhotonPath(name, workingDir));
632
+ if (!photonPath)
633
+ return null;
634
+ // Apply saved config to environment before loading
635
+ if (savedConfig.photons[name]) {
636
+ for (const [key, value] of Object.entries(savedConfig.photons[name])) {
637
+ process.env[key] = value;
638
+ }
639
+ }
640
+ // Read source once — used for constructor params, schema extraction, and class metadata
641
+ const extractor = new SchemaExtractor();
642
+ let constructorParams = [];
643
+ let templatePath;
644
+ let source;
645
+ let isInternal;
646
+ try {
647
+ source = await fs.readFile(photonPath, 'utf-8');
648
+ }
649
+ catch {
650
+ // Can't read source
651
+ }
652
+ // Extract @internal from class-level JSDoc only (not the entire source,
653
+ // which would false-positive on method-level @internal tags)
654
+ if (source) {
655
+ const earlyMeta = extractClassMetadataFromSource(source);
656
+ if (earlyMeta.internal) {
657
+ isInternal = true;
658
+ }
659
+ }
660
+ try {
661
+ if (source) {
662
+ const params = extractor.extractConstructorParams(source);
663
+ constructorParams = params
664
+ .filter((p) => p.isPrimitive)
665
+ .map((p) => ({
666
+ name: p.name,
667
+ envVar: toEnvVarName(name, p.name),
668
+ type: p.type,
669
+ isOptional: p.isOptional,
670
+ hasDefault: p.hasDefault,
671
+ defaultValue: p.defaultValue,
672
+ }));
673
+ // Extract @ui template path from class-level JSDoc
674
+ const classJsdocMatch = source.match(/\/\*\*[\s\S]*?\*\/\s*(?=export\s+default\s+class)/)
675
+ || source.match(/^\/\*\*([\s\S]*?)\*\//);
676
+ if (classJsdocMatch) {
677
+ const uiMatch = classJsdocMatch[0].match(/@ui\s+([^\s*]+)/);
678
+ if (uiMatch) {
679
+ templatePath = uiMatch[1];
680
+ }
681
+ }
682
+ }
683
+ }
684
+ catch {
685
+ // Can't extract params, try to load anyway
686
+ }
687
+ // Check if any required params are missing from environment
688
+ const missingRequired = constructorParams.filter((p) => !p.isOptional && !p.hasDefault && !process.env[p.envVar]);
689
+ const hasPlaceholderDefaults = constructorParams.some((p) => p.hasDefault &&
690
+ typeof p.defaultValue === 'string' &&
691
+ isPlaceholderOrLocalDefault(p.defaultValue));
692
+ const needsConfig = missingRequired.length > 0 ||
693
+ (hasPlaceholderDefaults &&
694
+ constructorParams.some((p) => p.hasDefault &&
695
+ typeof p.defaultValue === 'string' &&
696
+ isPlaceholderOrLocalDefault(p.defaultValue) &&
697
+ !process.env[p.envVar]));
698
+ if (needsConfig && constructorParams.length > 0) {
699
+ return {
700
+ id: generatePhotonId(photonPath),
701
+ name,
702
+ path: photonPath,
703
+ configured: false,
704
+ internal: isInternal,
705
+ requiredParams: constructorParams,
706
+ errorReason: 'missing-config',
707
+ errorMessage: missingRequired.length > 0
708
+ ? `Missing required: ${missingRequired.map((p) => p.name).join(', ')}`
709
+ : 'Has placeholder values that need configuration',
710
+ };
711
+ }
712
+ // All params satisfied, try to load with timeout
713
+ try {
714
+ const loadPromise = loader.loadFile(photonPath);
715
+ const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Loading timeout (10s)')), 10000));
716
+ const mcp = (await Promise.race([loadPromise, timeoutPromise]));
717
+ const instance = mcp.instance;
718
+ if (!instance) {
719
+ return null;
720
+ }
721
+ photonMCPs.set(name, mcp);
722
+ backfillEnvDefaults(instance, constructorParams);
723
+ // Extract schema for UI — reuse source read from above
724
+ const schemaSource = source || (await fs.readFile(photonPath, 'utf-8'));
725
+ const { tools: schemas, templates } = extractor.extractAllFromSource(schemaSource);
726
+ mcp.schemas = schemas;
727
+ // Get UI assets for linking
728
+ const uiAssets = mcp.assets?.ui || [];
729
+ // Filter out lifecycle methods
730
+ const lifecycleMethods = ['onInitialize', 'onShutdown', 'constructor'];
731
+ const methods = schemas
732
+ .filter((schema) => !lifecycleMethods.includes(schema.name))
733
+ .map((schema) => {
734
+ const linkedAsset = uiAssets.find((ui) => ui.linkedTool === schema.name);
735
+ return {
736
+ name: schema.name,
737
+ description: schema.description || '',
738
+ params: schema.inputSchema || { type: 'object', properties: {}, required: [] },
739
+ returns: { type: 'object' },
740
+ autorun: schema.autorun || false,
741
+ outputFormat: schema.outputFormat,
742
+ layoutHints: schema.layoutHints,
743
+ buttonLabel: schema.buttonLabel,
744
+ icon: schema.icon,
745
+ linkedUi: linkedAsset?.id,
746
+ ...(schema.isStatic ? { isStatic: true } : {}),
747
+ ...(schema.webhook ? { webhook: schema.webhook } : {}),
748
+ ...(schema.scheduled || schema.cron ? { scheduled: schema.scheduled || schema.cron } : {}),
749
+ ...(schema.locked ? { locked: schema.locked } : {}),
750
+ };
751
+ });
752
+ // Add templates as methods with isTemplate flag and markdown output format
753
+ templates.forEach((template) => {
754
+ if (!lifecycleMethods.includes(template.name)) {
755
+ methods.push({
756
+ name: template.name,
757
+ description: template.description || '',
758
+ params: template.inputSchema || { type: 'object', properties: {}, required: [] },
759
+ returns: { type: 'object' },
760
+ isTemplate: true,
761
+ outputFormat: 'markdown',
762
+ });
763
+ }
764
+ });
765
+ // Apply @visibility annotations from source to methods
766
+ applyMethodVisibility(schemaSource, methods);
767
+ // Check if this is an App (has main() method with @ui)
768
+ const mainMethod = methods.find((m) => m.name === 'main' && m.linkedUi);
769
+ // Extract class-level metadata — reuse source already read
770
+ const classMetadata = extractClassMetadataFromSource(schemaSource);
771
+ // Extract class-level @csp metadata and apply to all UI assets
772
+ const cspData = extractCspFromSource(schemaSource);
773
+ if (cspData['__class__'] && mcp.assets?.ui) {
774
+ for (const uiAsset of mcp.assets.ui) {
775
+ uiAsset.csp = cspData['__class__'];
776
+ }
777
+ }
778
+ // Count resources and prompts
779
+ const resourceCount = mcp.assets?.resources?.length || 0;
780
+ const promptCount = templates.length;
781
+ // Read install metadata for marketplace-installed photons
782
+ let installSource;
783
+ let metaVersion = classMetadata.version;
784
+ let metaAuthor = classMetadata.author;
785
+ try {
786
+ const { readLocalMetadata } = await import('../marketplace-manager.js');
787
+ const localMeta = await readLocalMetadata();
788
+ const installMeta = localMeta.photons[`${name}.photon.ts`];
789
+ if (installMeta) {
790
+ installSource = {
791
+ marketplace: installMeta.marketplace,
792
+ installedAt: installMeta.installedAt,
793
+ };
794
+ if (!metaVersion && installMeta.version) {
795
+ metaVersion = installMeta.version;
796
+ }
797
+ }
798
+ }
799
+ catch {
800
+ // No install metadata - that's fine
801
+ }
802
+ return {
803
+ id: generatePhotonId(photonPath),
804
+ name,
805
+ path: photonPath,
806
+ configured: true,
807
+ methods,
808
+ templatePath,
809
+ isApp: !!mainMethod,
810
+ appEntry: mainMethod,
811
+ assets: mcp.assets,
812
+ description: classMetadata.description || mcp.description || `${name} MCP`,
813
+ label: classMetadata.label || prettifyName(name),
814
+ icon: classMetadata.icon,
815
+ internal: isInternal || classMetadata.internal,
816
+ version: metaVersion,
817
+ author: metaAuthor,
818
+ resourceCount,
819
+ promptCount,
820
+ installSource,
821
+ ...(constructorParams.length > 0 && { requiredParams: constructorParams }),
822
+ ...(mcp.injectedPhotons && mcp.injectedPhotons.length > 0 && { injectedPhotons: mcp.injectedPhotons }),
823
+ };
824
+ }
825
+ catch (error) {
826
+ const errorMsg = error instanceof Error ? error.message : String(error);
827
+ // Always surface errored photons in the sidebar instead of silently dropping them
828
+ return {
829
+ id: generatePhotonId(photonPath),
830
+ name,
831
+ path: photonPath,
832
+ configured: false,
833
+ label: prettifyName(name),
834
+ internal: isInternal,
835
+ requiredParams: constructorParams,
836
+ errorReason: constructorParams.length > 0 ? 'missing-config' : 'load-error',
837
+ errorMessage: errorMsg.slice(0, 200),
838
+ };
839
+ }
840
+ }
841
+ const channelSubscriptions = new Map();
842
+ const EVENT_BUFFER_SIZE = 30; // Keep last 30 events per channel
843
+ const channelEventBuffers = new Map();
844
+ // Store an event in the channel buffer
845
+ function bufferEvent(channel, method, params) {
846
+ let buffer = channelEventBuffers.get(channel);
847
+ if (!buffer) {
848
+ buffer = { events: [], nextId: 1 };
849
+ channelEventBuffers.set(channel, buffer);
850
+ }
851
+ const eventId = buffer.nextId++;
852
+ const event = {
853
+ id: eventId,
854
+ method,
855
+ params,
856
+ timestamp: Date.now(),
857
+ };
858
+ buffer.events.push(event);
859
+ // Keep only last N events (circular buffer)
860
+ if (buffer.events.length > EVENT_BUFFER_SIZE) {
861
+ buffer.events.shift();
862
+ }
863
+ return eventId;
864
+ }
865
+ // Replay missed events to a specific session, or signal refresh needed
866
+ function replayEventsToSession(sessionId, channel, lastEventId) {
867
+ const buffer = channelEventBuffers.get(channel);
868
+ // No buffer = no events ever sent on this channel
869
+ if (!buffer || buffer.events.length === 0) {
870
+ return { replayed: 0, refreshNeeded: false };
871
+ }
872
+ // No lastEventId = client is fresh, no replay needed
873
+ if (lastEventId === undefined) {
874
+ return { replayed: 0, refreshNeeded: false };
875
+ }
876
+ const oldestEvent = buffer.events[0];
877
+ // If lastEventId is older than our oldest buffered event, signal refresh needed
878
+ if (lastEventId < oldestEvent.id) {
879
+ sendToSession(sessionId, 'photon/refresh-needed', { channel });
880
+ logger.info(`📡 Replay: ${channel} - lastEventId ${lastEventId} too old (oldest: ${oldestEvent.id}), refresh needed`);
881
+ return { replayed: 0, refreshNeeded: true };
882
+ }
883
+ // Find events to replay (all events after lastEventId)
884
+ const eventsToReplay = buffer.events.filter((e) => e.id > lastEventId);
885
+ if (eventsToReplay.length === 0) {
886
+ return { replayed: 0, refreshNeeded: false };
887
+ }
888
+ // Replay each missed event to this session
889
+ for (const event of eventsToReplay) {
890
+ sendToSession(sessionId, event.method, { ...event.params, _eventId: event.id });
891
+ }
892
+ logger.info(`📡 Replay: ${channel} - replayed ${eventsToReplay.length} events (${lastEventId + 1} to ${buffer.nextId - 1})`);
893
+ return { replayed: eventsToReplay.length, refreshNeeded: false };
894
+ }
895
+ // ══════════════════════════════════════════════════════════════════════════════
896
+ // Subscribe to a channel (increment ref count, actually subscribe if first)
897
+ // Channel format: {photonId}:{itemId} (e.g., "a3f2b1c4d5e6:photon")
898
+ async function subscribeToChannel(channel) {
899
+ const existing = channelSubscriptions.get(channel);
900
+ if (existing) {
901
+ existing.refCount++;
902
+ logger.debug(`Channel ${channel} ref count: ${existing.refCount}`);
903
+ return;
904
+ }
905
+ // First subscriber - actually subscribe to daemon
906
+ const subscription = { refCount: 1, unsubscribe: null };
907
+ channelSubscriptions.set(channel, subscription);
908
+ try {
909
+ // Extract photonId and itemId from channel (e.g., "a3f2b1:photon" -> photonId, itemId)
910
+ const [photonId, itemId] = channel.split(':');
911
+ // Look up photon name from ID
912
+ const photon = photons.find((p) => p.id === photonId);
913
+ if (!photon) {
914
+ logger.warn(`Cannot subscribe to ${channel}: unknown photon ID ${photonId}`);
915
+ return;
916
+ }
917
+ const photonName = photon.name;
918
+ // Daemon uses photonName:itemId as channel (not photonId)
919
+ const daemonChannel = `${photonName}:${itemId}`;
920
+ const isRunning = await pingDaemon(photonName);
921
+ if (isRunning) {
922
+ const unsubscribe = await subscribeChannel(photonName, daemonChannel, (message) => {
923
+ // Forward channel messages as events with delta
924
+ // Include both photonId (for client) and photonName (for display)
925
+ const params = {
926
+ photonId,
927
+ photon: photonName,
928
+ channel: daemonChannel,
929
+ event: message?.event,
930
+ data: message?.data || message,
931
+ };
932
+ // Buffer event for replay on reconnect
933
+ const eventId = bufferEvent(channel, 'photon/channel-event', params);
934
+ broadcastToBeam('photon/channel-event', { ...params, _eventId: eventId });
935
+ });
936
+ subscription.unsubscribe = unsubscribe;
937
+ logger.info(`📡 Subscribed to ${daemonChannel} (id: ${photonId}, ref: 1)`);
938
+ }
939
+ }
940
+ catch {
941
+ // Daemon not running - that's fine, in-process events still work
942
+ }
943
+ }
944
+ // Unsubscribe from a channel (decrement ref count, actually unsubscribe if last)
945
+ function unsubscribeFromChannel(channel) {
946
+ const subscription = channelSubscriptions.get(channel);
947
+ if (!subscription)
948
+ return;
949
+ subscription.refCount--;
950
+ logger.debug(`Channel ${channel} ref count: ${subscription.refCount}`);
951
+ if (subscription.refCount <= 0) {
952
+ // Last subscriber - actually unsubscribe
953
+ if (subscription.unsubscribe) {
954
+ subscription.unsubscribe();
955
+ logger.info(`📡 Unsubscribed from ${channel}`);
956
+ }
957
+ channelSubscriptions.delete(channel);
958
+ }
959
+ }
960
+ // Track what each session is viewing for cleanup on disconnect
961
+ // Uses photonId (hash) for unique identification across servers
962
+ const sessionViewState = new Map();
963
+ // Called when a client starts viewing a board (from MCP notification)
964
+ // photonId: hash of photon path (unique across servers)
965
+ // itemId: whatever the photon uses to identify the item (e.g., board name)
966
+ // lastEventId: optional - if provided, replay missed events or signal refresh needed
967
+ function onClientViewingBoard(sessionId, photonId, itemId, lastEventId) {
968
+ const prevState = sessionViewState.get(sessionId);
969
+ // Unsubscribe from previous item if different
970
+ if (prevState?.itemId && (prevState.photonId !== photonId || prevState.itemId !== itemId)) {
971
+ const prevChannel = `${prevState.photonId}:${prevState.itemId}`;
972
+ unsubscribeFromChannel(prevChannel);
973
+ }
974
+ // Subscribe to new item
975
+ const channel = `${photonId}:${itemId}`;
976
+ sessionViewState.set(sessionId, { photonId, itemId });
977
+ subscribeToChannel(channel);
978
+ // Replay missed events if lastEventId is provided
979
+ if (lastEventId !== undefined) {
980
+ replayEventsToSession(sessionId, channel, lastEventId);
981
+ }
982
+ }
983
+ // Called when a client disconnects
984
+ function onClientDisconnect(sessionId) {
985
+ const state = sessionViewState.get(sessionId);
986
+ if (state?.photonId && state?.itemId) {
987
+ const channel = `${state.photonId}:${state.itemId}`;
988
+ unsubscribeFromChannel(channel);
989
+ }
990
+ sessionViewState.delete(sessionId);
991
+ }
992
+ const subscriptionManager = {
993
+ onClientViewingBoard,
994
+ onClientDisconnect,
995
+ };
996
+ // UI asset loader for MCP resources/read
997
+ const loadUIAsset = async (photonName, uiId) => {
998
+ const photon = photons.find((p) => p.name === photonName);
999
+ if (!photon || !photon.configured)
1000
+ return null;
1001
+ const photonDir = path.dirname(photon.path);
1002
+ const asset = photon.assets?.ui?.find((u) => u.id === uiId);
1003
+ let uiPath;
1004
+ if (asset?.resolvedPath) {
1005
+ uiPath = asset.resolvedPath;
1006
+ }
1007
+ else {
1008
+ uiPath = path.join(photonDir, photonName, 'ui', `${uiId}.html`);
1009
+ }
1010
+ try {
1011
+ return await fs.readFile(uiPath, 'utf-8');
1012
+ }
1013
+ catch {
1014
+ return null; // UI asset not found
1015
+ }
1016
+ };
1017
+ // Create HTTP server
1018
+ const server = http.createServer(async (req, res) => {
1019
+ const url = new URL(req.url || '/', `http://${req.headers.host}`);
1020
+ // ══════════════════════════════════════════════════════════════════════════
1021
+ // MCP Streamable HTTP Transport (standard MCP clients like Claude Desktop)
1022
+ // Endpoint: /mcp (POST for requests, GET for SSE notifications)
1023
+ // ══════════════════════════════════════════════════════════════════════════
1024
+ if (url.pathname === '/mcp') {
1025
+ const handled = await handleStreamableHTTP(req, res, {
1026
+ photons, // Pass all photons including unconfigured for configurationSchema
1027
+ photonMCPs,
1028
+ externalMCPs,
1029
+ externalMCPClients,
1030
+ externalMCPSDKClients, // SDK clients for tool calls with structuredContent
1031
+ reconnectExternalMCP,
1032
+ loadUIAsset,
1033
+ configurePhoton: async (photonName, config) => {
1034
+ return configurePhotonViaMCP(photonName, config, photons, photonMCPs, loader, savedConfig);
1035
+ },
1036
+ reloadPhoton: async (photonName) => {
1037
+ return reloadPhotonViaMCP(photonName, photons, photonMCPs, loader, savedConfig, broadcastPhotonChange);
1038
+ },
1039
+ removePhoton: async (photonName) => {
1040
+ return removePhotonViaMCP(photonName, photons, photonMCPs, savedConfig, broadcastPhotonChange);
1041
+ },
1042
+ updateMetadata: async (photonName, methodName, metadata) => {
1043
+ return updateMetadataViaMCP(photonName, methodName, metadata, photons);
1044
+ },
1045
+ generatePhotonHelp: async (photonName) => {
1046
+ return generatePhotonHelpMarkdown(photonName, photons);
1047
+ },
1048
+ loader, // Pass loader for proper execution context (this.emit() support)
1049
+ subscriptionManager, // For on-demand channel subscriptions
1050
+ broadcast: (message) => {
1051
+ const msg = message;
1052
+ // Forward JSON-RPC notifications (progress, status, etc.)
1053
+ if (msg.jsonrpc === '2.0' && msg.method) {
1054
+ broadcastNotification(msg.method, msg.params || {});
1055
+ }
1056
+ // Forward channel events (task-moved, task-updated, etc.) with delta
1057
+ else if (msg.type === 'channel-event') {
1058
+ const params = {
1059
+ photon: msg.photon,
1060
+ channel: msg.channel,
1061
+ event: msg.event,
1062
+ data: msg.data,
1063
+ };
1064
+ // Buffer event for replay - find photonId from name for consistent channel key
1065
+ const photon = photons.find((p) => p.name === msg.photon);
1066
+ if (photon && msg.channel) {
1067
+ const [, itemId] = msg.channel.split(':');
1068
+ const bufferChannel = `${photon.id}:${itemId}`;
1069
+ const eventId = bufferEvent(bufferChannel, 'photon/channel-event', {
1070
+ ...params,
1071
+ photonId: photon.id,
1072
+ });
1073
+ broadcastToBeam('photon/channel-event', {
1074
+ ...params,
1075
+ photonId: photon.id,
1076
+ _eventId: eventId,
1077
+ });
1078
+ }
1079
+ else {
1080
+ broadcastToBeam('photon/channel-event', params);
1081
+ }
1082
+ }
1083
+ // Forward board-update for backwards compatibility
1084
+ else if (msg.type === 'board-update') {
1085
+ broadcastToBeam('photon/board-update', {
1086
+ photon: msg.photon,
1087
+ board: msg.board,
1088
+ });
1089
+ }
1090
+ },
1091
+ });
1092
+ if (handled)
1093
+ return;
1094
+ }
1095
+ // Serve static frontend bundle
1096
+ if (url.pathname === '/beam.bundle.js') {
1097
+ try {
1098
+ const bundlePath = path.join(__dirname, '../../dist/beam.bundle.js');
1099
+ const content = await fs.readFile(bundlePath, 'utf-8');
1100
+ res.writeHead(200, { 'Content-Type': 'text/javascript' });
1101
+ res.end(content);
1102
+ }
1103
+ catch {
1104
+ res.writeHead(404);
1105
+ res.end('Bundle not found. Run npm run build:beam first.');
1106
+ }
1107
+ return;
1108
+ }
1109
+ // Default route: Serve Lit App
1110
+ if (url.pathname === '/' || !url.pathname.startsWith('/api')) {
1111
+ try {
1112
+ const indexPath = path.join(__dirname, 'frontend/index.html');
1113
+ const content = await fs.readFile(indexPath, 'utf-8');
1114
+ res.writeHead(200, { 'Content-Type': 'text/html' });
1115
+ res.end(content);
1116
+ }
1117
+ catch (err) {
1118
+ res.writeHead(500);
1119
+ res.end('Error serving UI: ' + String(err));
1120
+ }
1121
+ return;
1122
+ }
1123
+ // File browser API
1124
+ if (url.pathname === '/api/browse') {
1125
+ res.setHeader('Content-Type', 'application/json');
1126
+ let root = url.searchParams.get('root');
1127
+ // Resolve photon's workdir as root constraint
1128
+ const photonParam = url.searchParams.get('photon');
1129
+ if (photonParam && !root) {
1130
+ const envPrefix = photonParam.toUpperCase().replace(/-/g, '_');
1131
+ const workdirEnv = process.env[`${envPrefix}_WORKDIR`];
1132
+ if (workdirEnv) {
1133
+ root = path.resolve(workdirEnv);
1134
+ }
1135
+ }
1136
+ const dirPath = url.searchParams.get('path') || root || workingDir;
1137
+ try {
1138
+ const resolved = path.resolve(dirPath);
1139
+ // Validate path is within root (if specified)
1140
+ if (root) {
1141
+ const resolvedRoot = path.resolve(root);
1142
+ if (!resolved.startsWith(resolvedRoot)) {
1143
+ res.writeHead(403);
1144
+ res.end(JSON.stringify({ error: 'Access denied: outside allowed directory' }));
1145
+ return;
1146
+ }
1147
+ }
1148
+ const stat = await fs.stat(resolved);
1149
+ if (!stat.isDirectory()) {
1150
+ res.writeHead(400);
1151
+ res.end(JSON.stringify({ error: 'Not a directory' }));
1152
+ return;
1153
+ }
1154
+ const entries = await fs.readdir(resolved, { withFileTypes: true });
1155
+ const items = entries
1156
+ .filter((e) => !e.name.startsWith('.') || e.name === '.photon')
1157
+ .map((e) => ({
1158
+ name: e.name,
1159
+ path: path.join(resolved, e.name),
1160
+ isDirectory: e.isDirectory(),
1161
+ }))
1162
+ .sort((a, b) => {
1163
+ if (a.isDirectory !== b.isDirectory)
1164
+ return a.isDirectory ? -1 : 1;
1165
+ return a.name.localeCompare(b.name);
1166
+ });
1167
+ res.writeHead(200);
1168
+ res.end(JSON.stringify({
1169
+ path: resolved,
1170
+ parent: path.dirname(resolved),
1171
+ root: root ? path.resolve(root) : null,
1172
+ items,
1173
+ }));
1174
+ }
1175
+ catch {
1176
+ res.writeHead(500);
1177
+ res.end(JSON.stringify({ error: 'Failed to read directory' }));
1178
+ }
1179
+ return;
1180
+ }
1181
+ // Serve a local file (for relative image paths in markdown previews, etc.)
1182
+ if (url.pathname === '/api/local-file') {
1183
+ const filePath = url.searchParams.get('path');
1184
+ if (!filePath) {
1185
+ res.writeHead(400);
1186
+ res.end('Missing path parameter');
1187
+ return;
1188
+ }
1189
+ const resolved = path.resolve(filePath);
1190
+ try {
1191
+ const fileStat = await fs.stat(resolved);
1192
+ if (!fileStat.isFile()) {
1193
+ res.writeHead(400);
1194
+ res.end('Not a file');
1195
+ return;
1196
+ }
1197
+ // Determine MIME type from extension
1198
+ const ext = path.extname(resolved).toLowerCase();
1199
+ const mimeTypes = {
1200
+ '.png': 'image/png',
1201
+ '.jpg': 'image/jpeg',
1202
+ '.jpeg': 'image/jpeg',
1203
+ '.gif': 'image/gif',
1204
+ '.svg': 'image/svg+xml',
1205
+ '.webp': 'image/webp',
1206
+ '.ico': 'image/x-icon',
1207
+ '.bmp': 'image/bmp',
1208
+ '.avif': 'image/avif',
1209
+ '.pdf': 'application/pdf',
1210
+ };
1211
+ const contentType = mimeTypes[ext] || 'application/octet-stream';
1212
+ const data = await fs.readFile(resolved);
1213
+ res.writeHead(200, {
1214
+ 'Content-Type': contentType,
1215
+ 'Content-Length': data.length,
1216
+ 'Cache-Control': 'public, max-age=300',
1217
+ });
1218
+ res.end(data);
1219
+ }
1220
+ catch {
1221
+ res.writeHead(404);
1222
+ res.end('File not found');
1223
+ }
1224
+ return;
1225
+ }
1226
+ // Get photon's workdir (if applicable)
1227
+ if (url.pathname === '/api/photon-workdir') {
1228
+ res.setHeader('Content-Type', 'application/json');
1229
+ const photonName = url.searchParams.get('name');
1230
+ // If no photon name provided, just return the default working directory
1231
+ if (!photonName) {
1232
+ res.writeHead(200);
1233
+ res.end(JSON.stringify({
1234
+ defaultWorkdir: workingDir,
1235
+ }));
1236
+ return;
1237
+ }
1238
+ const photon = photons.find((p) => p.name === photonName);
1239
+ if (!photon) {
1240
+ res.writeHead(404);
1241
+ res.end(JSON.stringify({ error: 'Photon not found' }));
1242
+ return;
1243
+ }
1244
+ // For filesystem photon, use BEAM's working directory
1245
+ // This ensures the file browser shows the same files BEAM is managing
1246
+ let photonWorkdir = null;
1247
+ if (photonName === 'filesystem') {
1248
+ photonWorkdir = workingDir;
1249
+ }
1250
+ res.writeHead(200);
1251
+ res.end(JSON.stringify({
1252
+ name: photonName,
1253
+ workdir: photonWorkdir,
1254
+ defaultWorkdir: workingDir,
1255
+ }));
1256
+ return;
1257
+ }
1258
+ // Serve UI templates for custom UI rendering
1259
+ if (url.pathname === '/api/ui') {
1260
+ const photonName = url.searchParams.get('photon');
1261
+ const uiId = url.searchParams.get('id');
1262
+ if (!photonName || !uiId) {
1263
+ res.writeHead(400);
1264
+ res.end(JSON.stringify({ error: 'Missing photon or id parameter' }));
1265
+ return;
1266
+ }
1267
+ const photon = photons.find((p) => p.name === photonName);
1268
+ if (!photon) {
1269
+ res.writeHead(404);
1270
+ res.end(JSON.stringify({ error: 'Photon not found' }));
1271
+ return;
1272
+ }
1273
+ // UI templates are in <photon-dir>/<photon-name>/ui/<id>.html
1274
+ const photonDir = path.dirname(photon.path);
1275
+ // Try to use resolved path from assets if available (respects JSDoc)
1276
+ const asset = photon.assets?.ui?.find((u) => u.id === uiId);
1277
+ let uiPath;
1278
+ if (asset && asset.resolvedPath) {
1279
+ uiPath = asset.resolvedPath;
1280
+ }
1281
+ else {
1282
+ uiPath = path.join(photonDir, photonName, 'ui', `${uiId}.html`);
1283
+ }
1284
+ try {
1285
+ const uiContent = await fs.readFile(uiPath, 'utf-8');
1286
+ res.setHeader('Content-Type', 'text/html');
1287
+ res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
1288
+ res.writeHead(200);
1289
+ res.end(uiContent);
1290
+ }
1291
+ catch {
1292
+ res.writeHead(404);
1293
+ res.end(JSON.stringify({ error: `UI template not found: ${uiId}` }));
1294
+ }
1295
+ return;
1296
+ }
1297
+ // Serve MCP App HTML from external MCPs with MCP Apps Extension
1298
+ if (url.pathname === '/api/mcp-app') {
1299
+ const mcpName = url.searchParams.get('mcp');
1300
+ const resourceUri = url.searchParams.get('uri');
1301
+ if (!mcpName || !resourceUri) {
1302
+ res.writeHead(400);
1303
+ res.end(JSON.stringify({ error: 'Missing mcp or uri parameter' }));
1304
+ return;
1305
+ }
1306
+ const sdkClient = externalMCPSDKClients.get(mcpName);
1307
+ if (!sdkClient) {
1308
+ res.writeHead(404);
1309
+ res.end(JSON.stringify({ error: `MCP not found or no SDK client: ${mcpName}` }));
1310
+ return;
1311
+ }
1312
+ try {
1313
+ const resourceResult = await sdkClient.readResource({ uri: resourceUri });
1314
+ const content = resourceResult.contents?.[0];
1315
+ if (!content) {
1316
+ res.writeHead(404);
1317
+ res.end(JSON.stringify({ error: `Resource not found: ${resourceUri}` }));
1318
+ return;
1319
+ }
1320
+ // Content can have either text or blob
1321
+ const contentText = 'text' in content ? content.text : null;
1322
+ const contentBlob = 'blob' in content ? content.blob : null;
1323
+ if (!contentText && !contentBlob) {
1324
+ res.writeHead(404);
1325
+ res.end(JSON.stringify({ error: `Resource has no content: ${resourceUri}` }));
1326
+ return;
1327
+ }
1328
+ res.setHeader('Content-Type', content.mimeType || 'text/html');
1329
+ res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
1330
+ res.writeHead(200);
1331
+ if (contentText) {
1332
+ res.end(contentText);
1333
+ }
1334
+ else if (contentBlob) {
1335
+ // blob is base64 encoded
1336
+ res.end(Buffer.from(contentBlob, 'base64'));
1337
+ }
1338
+ }
1339
+ catch (error) {
1340
+ logger.error(`Failed to read MCP App resource: ${error}`);
1341
+ res.writeHead(500);
1342
+ res.end(JSON.stringify({ error: `Failed to read resource: ${error}` }));
1343
+ }
1344
+ return;
1345
+ }
1346
+ // Serve @ui template files (class-level custom UI)
1347
+ if (url.pathname === '/api/template') {
1348
+ const photonName = url.searchParams.get('photon');
1349
+ const templatePathParam = url.searchParams.get('path');
1350
+ if (!photonName) {
1351
+ res.writeHead(400);
1352
+ res.end(JSON.stringify({ error: 'Missing photon parameter' }));
1353
+ return;
1354
+ }
1355
+ const photon = photons.find((p) => p.name === photonName);
1356
+ if (!photon || !photon.configured) {
1357
+ res.writeHead(404);
1358
+ res.end(JSON.stringify({ error: 'Photon not found or not configured' }));
1359
+ return;
1360
+ }
1361
+ // Use provided path or photon's templatePath
1362
+ const templateFile = templatePathParam || photon.templatePath;
1363
+ if (!templateFile) {
1364
+ res.writeHead(400);
1365
+ res.end(JSON.stringify({ error: 'No template path specified' }));
1366
+ return;
1367
+ }
1368
+ // Resolve template path relative to photon's directory
1369
+ const photonDir = path.dirname(photon.path);
1370
+ const fullTemplatePath = path.isAbsolute(templateFile)
1371
+ ? templateFile
1372
+ : path.join(photonDir, templateFile);
1373
+ try {
1374
+ const templateContent = await fs.readFile(fullTemplatePath, 'utf-8');
1375
+ res.setHeader('Content-Type', 'text/html');
1376
+ res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
1377
+ res.writeHead(200);
1378
+ res.end(templateContent);
1379
+ }
1380
+ catch {
1381
+ res.writeHead(404);
1382
+ res.end(JSON.stringify({ error: `Template not found: ${templateFile}` }));
1383
+ }
1384
+ return;
1385
+ }
1386
+ // PWA Manifest - Auto-generated for any photon
1387
+ if (url.pathname === '/api/pwa/manifest.json') {
1388
+ const photonName = url.searchParams.get('photon');
1389
+ if (!photonName) {
1390
+ res.writeHead(400);
1391
+ res.end(JSON.stringify({ error: 'Missing photon parameter' }));
1392
+ return;
1393
+ }
1394
+ const photon = photons.find((p) => p.name === photonName);
1395
+ const displayName = photon?.name || photonName;
1396
+ const description = photon?.description || `${displayName} - Photon App`;
1397
+ const manifest = {
1398
+ name: displayName,
1399
+ short_name: displayName,
1400
+ description,
1401
+ start_url: `/api/pwa/app?photon=${encodeURIComponent(photonName)}`,
1402
+ display: 'standalone',
1403
+ background_color: '#1a1a1a',
1404
+ theme_color: '#1a1a1a',
1405
+ orientation: 'any',
1406
+ icons: [
1407
+ {
1408
+ src: `/api/pwa/icon.svg?photon=${encodeURIComponent(photonName)}`,
1409
+ sizes: 'any',
1410
+ type: 'image/svg+xml',
1411
+ purpose: 'any',
1412
+ },
1413
+ ],
1414
+ categories: ['developer', 'utilities'],
1415
+ };
1416
+ res.setHeader('Content-Type', 'application/manifest+json');
1417
+ res.writeHead(200);
1418
+ res.end(JSON.stringify(manifest, null, 2));
1419
+ return;
1420
+ }
1421
+ // PWA Icon - Auto-generated SVG from photon emoji
1422
+ if (url.pathname === '/api/pwa/icon.svg') {
1423
+ const photonName = url.searchParams.get('photon');
1424
+ const photon = photons.find((p) => p.name === photonName);
1425
+ const emoji = photon?.icon || '📦';
1426
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
1427
+ <rect width="100" height="100" rx="20" fill="#1a1a1a"/>
1428
+ <text x="50" y="50" font-size="50" text-anchor="middle" dominant-baseline="central">${emoji}</text>
1429
+ </svg>`;
1430
+ res.setHeader('Content-Type', 'image/svg+xml');
1431
+ res.writeHead(200);
1432
+ res.end(svg);
1433
+ return;
1434
+ }
1435
+ // PWA App Entry - Serves the photon UI with PWA tags injected
1436
+ if (url.pathname === '/api/pwa/app') {
1437
+ const photonName = url.searchParams.get('photon');
1438
+ if (!photonName) {
1439
+ res.writeHead(400);
1440
+ res.end('Missing photon parameter');
1441
+ return;
1442
+ }
1443
+ const photon = photons.find((p) => p.name === photonName);
1444
+ if (!photon) {
1445
+ res.writeHead(404);
1446
+ res.end(`Photon not found: ${photonName}`);
1447
+ return;
1448
+ }
1449
+ const displayName = photon.name;
1450
+ const emoji = photon?.icon || '📦';
1451
+ const uiAssets = photon.assets?.ui || [];
1452
+ const asset = uiAssets.find((u) => u.linkedTool === 'main') || uiAssets[0];
1453
+ const uiId = asset?.id || 'main';
1454
+ // PWA Host page - embeds photon UI in iframe, handles postMessage
1455
+ const pwaHost = `<!DOCTYPE html>
1456
+ <html lang="en">
1457
+ <head>
1458
+ <meta charset="UTF-8">
1459
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1460
+ <title>${emoji} ${displayName}</title>
1461
+ <link rel="manifest" href="/api/pwa/manifest.json?photon=${encodeURIComponent(photonName)}">
1462
+ <meta name="theme-color" content="#1a1a1a">
1463
+ <meta name="mobile-web-app-capable" content="yes">
1464
+ <meta name="apple-mobile-web-app-capable" content="yes">
1465
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
1466
+ <meta name="apple-mobile-web-app-title" content="${displayName}">
1467
+ <link rel="apple-touch-icon" href="/api/pwa/icon.svg?photon=${encodeURIComponent(photonName)}">
1468
+ <style>
1469
+ * { margin: 0; padding: 0; box-sizing: border-box; }
1470
+ html, body { min-height: 100%; background: #1a1a1a; font-family: system-ui, sans-serif; color: #e5e5e5; }
1471
+ .app-container { display: flex; flex-direction: column; min-height: 100vh; }
1472
+ .app-frame { flex: 1; min-height: 80vh; }
1473
+ iframe { width: 100%; height: 100%; min-height: 80vh; border: none; }
1474
+
1475
+ .offline {
1476
+ display: none;
1477
+ height: 100vh;
1478
+ flex-direction: column;
1479
+ align-items: center;
1480
+ justify-content: center;
1481
+ color: #888;
1482
+ text-align: center;
1483
+ padding: 40px;
1484
+ }
1485
+ .offline.show { display: flex; }
1486
+ .offline h1 { font-size: 48px; margin-bottom: 20px; }
1487
+ .offline p { font-size: 18px; margin-bottom: 30px; max-width: 400px; line-height: 1.6; }
1488
+ .offline code {
1489
+ background: #2a2a2a;
1490
+ padding: 12px 24px;
1491
+ border-radius: 8px;
1492
+ font-size: 16px;
1493
+ color: #4ade80;
1494
+ font-family: monospace;
1495
+ }
1496
+ .offline .retry {
1497
+ margin-top: 20px;
1498
+ padding: 10px 20px;
1499
+ background: #333;
1500
+ border: none;
1501
+ border-radius: 6px;
1502
+ color: #fff;
1503
+ cursor: pointer;
1504
+ font-size: 14px;
1505
+ }
1506
+ .offline .retry:hover { background: #444; }
1507
+ </style>
1508
+ </head>
1509
+ <body>
1510
+ <div id="offline" class="offline">
1511
+ <h1>${emoji}</h1>
1512
+ <p>Server is not running. Start Photon to use ${displayName}:</p>
1513
+ <code>photon</code>
1514
+ <button class="retry" onclick="location.reload()">Retry</button>
1515
+ </div>
1516
+
1517
+ <div class="app-container" id="app-container" style="display:none">
1518
+ <iframe id="app"></iframe>
1519
+ </div>
1520
+
1521
+ <script>
1522
+ const iframe = document.getElementById('app');
1523
+ const offline = document.getElementById('offline');
1524
+ const appContainer = document.getElementById('app-container');
1525
+ const photonName = '${photonName}';
1526
+ const uiId = '${uiId}';
1527
+
1528
+ // Load UI with platform bridge injected
1529
+ async function loadApp() {
1530
+ try {
1531
+ // Fetch UI template and platform bridge
1532
+ const [uiRes, bridgeRes] = await Promise.all([
1533
+ fetch('/api/ui?photon=' + encodeURIComponent(photonName) + '&id=' + encodeURIComponent(uiId)),
1534
+ fetch('/api/platform-bridge?photon=' + encodeURIComponent(photonName) + '&method=' + encodeURIComponent(uiId) + '&theme=dark')
1535
+ ]);
1536
+
1537
+ if (!uiRes.ok) {
1538
+ offline.classList.add('show');
1539
+ return;
1540
+ }
1541
+
1542
+ let html = await uiRes.text();
1543
+ const bridge = bridgeRes.ok ? await bridgeRes.text() : '';
1544
+
1545
+ // Inject platform bridge before </head>
1546
+ html = html.replace('</head>', bridge + '</head>');
1547
+
1548
+ // Create blob URL and load in iframe
1549
+ const blob = new Blob([html], { type: 'text/html' });
1550
+ iframe.src = URL.createObjectURL(blob);
1551
+ appContainer.style.display = 'flex';
1552
+ initBridge();
1553
+ } catch (e) {
1554
+ offline.classList.add('show');
1555
+ }
1556
+ }
1557
+
1558
+ function initBridge() {
1559
+ // Listen for messages from iframe
1560
+ window.addEventListener('message', async (e) => {
1561
+ const msg = e.data;
1562
+ if (!msg || typeof msg !== 'object') return;
1563
+
1564
+ // Handle JSON-RPC tools/call from iframe
1565
+ if (msg.jsonrpc === '2.0' && msg.method === 'tools/call' && msg.id != null) {
1566
+ const { name: toolName, arguments: toolArgs } = msg.params || {};
1567
+ try {
1568
+ const res = await fetch('/api/invoke', {
1569
+ method: 'POST',
1570
+ headers: { 'Content-Type': 'application/json' },
1571
+ body: JSON.stringify({ photon: photonName, method: toolName, args: toolArgs || {} }),
1572
+ signal: AbortSignal.timeout(60000), // 60s for method calls
1573
+ });
1574
+ const data = await res.json();
1575
+ iframe.contentWindow.postMessage({
1576
+ jsonrpc: '2.0',
1577
+ id: msg.id,
1578
+ result: data.error ? undefined : (data.result !== undefined ? data.result : data),
1579
+ error: data.error ? { code: -32000, message: data.error } : undefined,
1580
+ }, '*');
1581
+ } catch (err) {
1582
+ iframe.contentWindow.postMessage({
1583
+ jsonrpc: '2.0',
1584
+ id: msg.id,
1585
+ error: { code: -32000, message: err.message },
1586
+ }, '*');
1587
+ }
1588
+ }
1589
+ });
1590
+
1591
+ // Send init message to iframe once loaded
1592
+ iframe.onload = () => {
1593
+ iframe.contentWindow.postMessage({
1594
+ type: 'photon:init',
1595
+ context: { photon: photonName, theme: 'dark', displayMode: 'fullscreen' }
1596
+ }, '*');
1597
+ };
1598
+ }
1599
+
1600
+ loadApp();
1601
+ </script>
1602
+ </body>
1603
+ </html>`;
1604
+ res.setHeader('Content-Type', 'text/html');
1605
+ res.writeHead(200);
1606
+ res.end(pwaHost);
1607
+ return;
1608
+ }
1609
+ // Invoke API: Direct HTTP endpoint for method invocation (used by PWA)
1610
+ if (url.pathname === '/api/invoke' && req.method === 'POST') {
1611
+ let body = '';
1612
+ req.on('data', (chunk) => (body += chunk));
1613
+ req.on('end', async () => {
1614
+ try {
1615
+ const { photon: photonName, method, args } = JSON.parse(body);
1616
+ if (!photonName || !method) {
1617
+ res.writeHead(400);
1618
+ res.end(JSON.stringify({ error: 'Missing photon or method' }));
1619
+ return;
1620
+ }
1621
+ const mcp = photonMCPs.get(photonName);
1622
+ if (!mcp || !mcp.instance) {
1623
+ res.writeHead(404);
1624
+ res.end(JSON.stringify({ error: `Photon not found: ${photonName}` }));
1625
+ return;
1626
+ }
1627
+ if (typeof mcp.instance[method] !== 'function') {
1628
+ res.writeHead(404);
1629
+ res.end(JSON.stringify({ error: `Method not found: ${method}` }));
1630
+ return;
1631
+ }
1632
+ const result = await mcp.instance[method](args || {});
1633
+ res.setHeader('Content-Type', 'application/json');
1634
+ res.writeHead(200);
1635
+ res.end(JSON.stringify({ result }));
1636
+ }
1637
+ catch (err) {
1638
+ res.setHeader('Content-Type', 'application/json');
1639
+ res.writeHead(500);
1640
+ res.end(JSON.stringify({ error: err.message || String(err) }));
1641
+ }
1642
+ });
1643
+ return;
1644
+ }
1645
+ // Platform Bridge API: Generate platform compatibility script
1646
+ // Uses the unified bridge architecture based on @modelcontextprotocol/ext-apps SDK
1647
+ if (url.pathname === '/api/platform-bridge') {
1648
+ const theme = (url.searchParams.get('theme') || 'dark');
1649
+ const photonName = url.searchParams.get('photon') || '';
1650
+ const methodName = url.searchParams.get('method') || '';
1651
+ // Look up injected photons for this photon
1652
+ const photon = photons.find((p) => p.name === photonName);
1653
+ const injectedPhotonsList = photon && photon.configured && photon.injectedPhotons;
1654
+ const { generateBridgeScript } = await import('./bridge/index.js');
1655
+ const script = generateBridgeScript({
1656
+ theme,
1657
+ locale: 'en-US',
1658
+ photon: photonName,
1659
+ method: methodName,
1660
+ hostName: 'beam',
1661
+ hostVersion: '1.5.0',
1662
+ injectedPhotons: injectedPhotonsList || [],
1663
+ });
1664
+ res.setHeader('Content-Type', 'text/html');
1665
+ res.writeHead(200);
1666
+ res.end(script);
1667
+ return;
1668
+ }
1669
+ // Diagnostics endpoint: server health and photon status
1670
+ if (url.pathname === '/api/diagnostics') {
1671
+ res.setHeader('Content-Type', 'application/json');
1672
+ try {
1673
+ const { PHOTON_VERSION } = await import('../version.js');
1674
+ const sources = marketplace.getAll();
1675
+ const photonStatus = photons.map((p) => ({
1676
+ name: p.name,
1677
+ status: p.configured ? 'loaded' : 'unconfigured',
1678
+ methods: p.configured ? p.methods.length : 0,
1679
+ error: !p.configured ? p.errorMessage : undefined,
1680
+ internal: p.internal || undefined,
1681
+ path: p.path || undefined,
1682
+ }));
1683
+ res.writeHead(200);
1684
+ res.end(JSON.stringify({
1685
+ nodeVersion: process.version,
1686
+ photonVersion: PHOTON_VERSION,
1687
+ workingDir,
1688
+ uptime: process.uptime(),
1689
+ photonCount: photons.length,
1690
+ configuredCount: photons.filter((p) => p.configured).length,
1691
+ unconfiguredCount: photons.filter((p) => !p.configured).length,
1692
+ marketplaceSources: sources.filter((s) => s.enabled).length,
1693
+ photons: photonStatus,
1694
+ }));
1695
+ }
1696
+ catch {
1697
+ res.writeHead(500);
1698
+ res.end(JSON.stringify({ error: 'Failed to generate diagnostics' }));
1699
+ }
1700
+ return;
1701
+ }
1702
+ // MCP Config Export endpoint: generate Claude Desktop config snippet
1703
+ if (url.pathname === '/api/export/mcp-config') {
1704
+ res.setHeader('Content-Type', 'application/json');
1705
+ const photonName = url.searchParams.get('photon');
1706
+ if (!photonName) {
1707
+ res.writeHead(400);
1708
+ res.end(JSON.stringify({ error: 'Missing photon query parameter' }));
1709
+ return;
1710
+ }
1711
+ const photon = photons.find((p) => p.name === photonName);
1712
+ if (!photon) {
1713
+ res.writeHead(404);
1714
+ res.end(JSON.stringify({ error: `Photon '${photonName}' not found` }));
1715
+ return;
1716
+ }
1717
+ res.writeHead(200);
1718
+ res.end(JSON.stringify({
1719
+ mcpServers: {
1720
+ [`photon-${photonName}`]: {
1721
+ command: 'npx',
1722
+ args: ['-y', '@portel/photon', 'mcp', photonName],
1723
+ },
1724
+ },
1725
+ }, null, 2));
1726
+ return;
1727
+ }
1728
+ // OpenAPI Specification endpoint
1729
+ // Serves auto-generated OpenAPI 3.1 spec from loaded photons
1730
+ if (url.pathname === '/api/openapi.json') {
1731
+ res.setHeader('Content-Type', 'application/json');
1732
+ res.setHeader('Access-Control-Allow-Origin', '*');
1733
+ try {
1734
+ const serverUrl = `http://${req.headers.host || 'localhost:' + port}`;
1735
+ const spec = generateOpenAPISpec(photons, serverUrl);
1736
+ res.writeHead(200);
1737
+ res.end(JSON.stringify(spec, null, 2));
1738
+ }
1739
+ catch (err) {
1740
+ res.writeHead(500);
1741
+ res.end(JSON.stringify({ error: 'Failed to generate OpenAPI spec' }));
1742
+ }
1743
+ return;
1744
+ }
1745
+ // Marketplace API: Search photons
1746
+ if (url.pathname === '/api/marketplace/search') {
1747
+ res.setHeader('Content-Type', 'application/json');
1748
+ const query = url.searchParams.get('q') || '';
1749
+ try {
1750
+ const results = await marketplace.search(query);
1751
+ const photonList = [];
1752
+ for (const [name, sources] of results) {
1753
+ const source = sources[0]; // Use first source
1754
+ photonList.push({
1755
+ name,
1756
+ description: source.metadata?.description || '',
1757
+ version: source.metadata?.version || '',
1758
+ author: source.metadata?.author || '',
1759
+ tags: source.metadata?.tags || [],
1760
+ marketplace: source.marketplace.name,
1761
+ installed: photonMCPs.has(name),
1762
+ });
1763
+ }
1764
+ res.writeHead(200);
1765
+ res.end(JSON.stringify({ photons: photonList }));
1766
+ }
1767
+ catch {
1768
+ res.writeHead(500);
1769
+ res.end(JSON.stringify({ error: 'Search failed' }));
1770
+ }
1771
+ return;
1772
+ }
1773
+ // Marketplace API: List all available photons
1774
+ if (url.pathname === '/api/marketplace/list') {
1775
+ res.setHeader('Content-Type', 'application/json');
1776
+ try {
1777
+ const allPhotons = await marketplace.getAllPhotons();
1778
+ const photonList = [];
1779
+ for (const [name, { metadata, marketplace: mp }] of allPhotons) {
1780
+ photonList.push({
1781
+ name,
1782
+ description: metadata.description || '',
1783
+ version: metadata.version || '',
1784
+ author: metadata.author || '',
1785
+ tags: metadata.tags || [],
1786
+ marketplace: mp.name,
1787
+ icon: metadata.icon,
1788
+ internal: metadata.internal,
1789
+ installed: photonMCPs.has(name),
1790
+ });
1791
+ }
1792
+ res.writeHead(200);
1793
+ res.end(JSON.stringify({ photons: photonList }));
1794
+ }
1795
+ catch {
1796
+ res.writeHead(500);
1797
+ res.end(JSON.stringify({ error: 'Failed to list photons' }));
1798
+ }
1799
+ return;
1800
+ }
1801
+ // Marketplace API: Add/install a photon
1802
+ if (url.pathname === '/api/marketplace/add' && req.method === 'POST') {
1803
+ res.setHeader('Content-Type', 'application/json');
1804
+ let body = '';
1805
+ req.on('data', (chunk) => {
1806
+ body += chunk;
1807
+ });
1808
+ req.on('end', async () => {
1809
+ try {
1810
+ const { name } = JSON.parse(body);
1811
+ if (!name) {
1812
+ res.writeHead(400);
1813
+ res.end(JSON.stringify({ error: 'Missing photon name' }));
1814
+ return;
1815
+ }
1816
+ // Fetch the photon from marketplace
1817
+ const result = await marketplace.fetchMCP(name);
1818
+ if (!result) {
1819
+ res.writeHead(404);
1820
+ res.end(JSON.stringify({ error: `Photon '${name}' not found in marketplace` }));
1821
+ return;
1822
+ }
1823
+ // Write to working directory
1824
+ const targetPath = path.join(workingDir, `${name}.photon.ts`);
1825
+ await fs.writeFile(targetPath, result.content, 'utf-8');
1826
+ // Save metadata if available
1827
+ if (result.metadata) {
1828
+ const hash = (await import('../marketplace-manager.js')).calculateHash(result.content);
1829
+ await marketplace.savePhotonMetadata(`${name}.photon.ts`, result.marketplace, result.metadata, hash);
1830
+ }
1831
+ res.writeHead(200);
1832
+ res.end(JSON.stringify({
1833
+ success: true,
1834
+ name,
1835
+ path: targetPath,
1836
+ version: result.metadata?.version,
1837
+ }));
1838
+ // Broadcast to connected clients to reload photon list
1839
+ broadcastPhotonChange();
1840
+ }
1841
+ catch {
1842
+ res.writeHead(500);
1843
+ res.end(JSON.stringify({ error: 'Failed to add photon' }));
1844
+ }
1845
+ });
1846
+ return;
1847
+ }
1848
+ // Marketplace API: Get all marketplace sources
1849
+ if (url.pathname === '/api/marketplace/sources') {
1850
+ res.setHeader('Content-Type', 'application/json');
1851
+ try {
1852
+ const sources = marketplace.getAll();
1853
+ const sourcesWithCounts = await Promise.all(sources.map(async (source) => {
1854
+ // Get photon count from cached manifest
1855
+ const manifest = await marketplace.getCachedManifest(source.name);
1856
+ return {
1857
+ name: source.name,
1858
+ repo: source.repo,
1859
+ source: source.source,
1860
+ sourceType: source.sourceType,
1861
+ enabled: source.enabled,
1862
+ photonCount: manifest?.photons?.length || 0,
1863
+ lastUpdated: source.lastUpdated,
1864
+ };
1865
+ }));
1866
+ res.writeHead(200);
1867
+ res.end(JSON.stringify({ sources: sourcesWithCounts }));
1868
+ }
1869
+ catch {
1870
+ res.writeHead(500);
1871
+ res.end(JSON.stringify({ error: 'Failed to get marketplace sources' }));
1872
+ }
1873
+ return;
1874
+ }
1875
+ // Marketplace API: Add a new marketplace source
1876
+ if (url.pathname === '/api/marketplace/sources/add' && req.method === 'POST') {
1877
+ res.setHeader('Content-Type', 'application/json');
1878
+ let body = '';
1879
+ req.on('data', (chunk) => {
1880
+ body += chunk;
1881
+ });
1882
+ req.on('end', async () => {
1883
+ try {
1884
+ const { source } = JSON.parse(body);
1885
+ if (!source) {
1886
+ res.writeHead(400);
1887
+ res.end(JSON.stringify({ error: 'Missing source parameter' }));
1888
+ return;
1889
+ }
1890
+ const result = await marketplace.add(source);
1891
+ // Update cache for the new marketplace
1892
+ if (result.added) {
1893
+ await marketplace.updateMarketplaceCache(result.marketplace.name);
1894
+ }
1895
+ res.writeHead(200);
1896
+ res.end(JSON.stringify({
1897
+ success: true,
1898
+ name: result.marketplace.name,
1899
+ added: result.added,
1900
+ }));
1901
+ }
1902
+ catch (err) {
1903
+ res.writeHead(400);
1904
+ res.end(JSON.stringify({ error: err.message }));
1905
+ }
1906
+ });
1907
+ return;
1908
+ }
1909
+ // Marketplace API: Remove a marketplace source
1910
+ if (url.pathname === '/api/marketplace/sources/remove' && req.method === 'POST') {
1911
+ res.setHeader('Content-Type', 'application/json');
1912
+ let body = '';
1913
+ req.on('data', (chunk) => {
1914
+ body += chunk;
1915
+ });
1916
+ req.on('end', async () => {
1917
+ try {
1918
+ const { name } = JSON.parse(body);
1919
+ if (!name) {
1920
+ res.writeHead(400);
1921
+ res.end(JSON.stringify({ error: 'Missing name parameter' }));
1922
+ return;
1923
+ }
1924
+ const removed = await marketplace.remove(name);
1925
+ if (!removed) {
1926
+ res.writeHead(404);
1927
+ res.end(JSON.stringify({ error: `Marketplace '${name}' not found` }));
1928
+ return;
1929
+ }
1930
+ res.writeHead(200);
1931
+ res.end(JSON.stringify({ success: true }));
1932
+ }
1933
+ catch (err) {
1934
+ res.writeHead(400);
1935
+ res.end(JSON.stringify({ error: err.message }));
1936
+ }
1937
+ });
1938
+ return;
1939
+ }
1940
+ // Marketplace API: Toggle marketplace enabled/disabled
1941
+ if (url.pathname === '/api/marketplace/sources/toggle' && req.method === 'POST') {
1942
+ res.setHeader('Content-Type', 'application/json');
1943
+ let body = '';
1944
+ req.on('data', (chunk) => {
1945
+ body += chunk;
1946
+ });
1947
+ req.on('end', async () => {
1948
+ try {
1949
+ const { name, enabled } = JSON.parse(body);
1950
+ if (!name || typeof enabled !== 'boolean') {
1951
+ res.writeHead(400);
1952
+ res.end(JSON.stringify({ error: 'Missing name or enabled parameter' }));
1953
+ return;
1954
+ }
1955
+ const success = await marketplace.setEnabled(name, enabled);
1956
+ if (!success) {
1957
+ res.writeHead(404);
1958
+ res.end(JSON.stringify({ error: `Marketplace '${name}' not found` }));
1959
+ return;
1960
+ }
1961
+ res.writeHead(200);
1962
+ res.end(JSON.stringify({ success: true }));
1963
+ }
1964
+ catch (err) {
1965
+ res.writeHead(500);
1966
+ res.end(JSON.stringify({ error: err.message }));
1967
+ }
1968
+ });
1969
+ return;
1970
+ }
1971
+ // Marketplace API: Refresh marketplace cache
1972
+ if (url.pathname === '/api/marketplace/refresh' && req.method === 'POST') {
1973
+ res.setHeader('Content-Type', 'application/json');
1974
+ let body = '';
1975
+ req.on('data', (chunk) => {
1976
+ body += chunk;
1977
+ });
1978
+ req.on('end', async () => {
1979
+ try {
1980
+ const { name } = JSON.parse(body || '{}');
1981
+ if (name) {
1982
+ // Refresh specific marketplace
1983
+ const success = await marketplace.updateMarketplaceCache(name);
1984
+ res.writeHead(200);
1985
+ res.end(JSON.stringify({ success, updated: success ? [name] : [] }));
1986
+ }
1987
+ else {
1988
+ // Refresh all enabled marketplaces
1989
+ const results = await marketplace.updateAllCaches();
1990
+ const updated = Array.from(results.entries())
1991
+ .filter(([, success]) => success)
1992
+ .map(([name]) => name);
1993
+ res.writeHead(200);
1994
+ res.end(JSON.stringify({ success: true, updated }));
1995
+ }
1996
+ }
1997
+ catch (err) {
1998
+ res.writeHead(500);
1999
+ res.end(JSON.stringify({ error: err.message }));
2000
+ }
2001
+ });
2002
+ return;
2003
+ }
2004
+ // Marketplace API: Check for available updates
2005
+ if (url.pathname === '/api/marketplace/updates') {
2006
+ res.setHeader('Content-Type', 'application/json');
2007
+ try {
2008
+ const { readLocalMetadata } = await import('../marketplace-manager.js');
2009
+ const localMetadata = await readLocalMetadata();
2010
+ const updates = [];
2011
+ // Check each installed photon for updates
2012
+ for (const [fileName, installMeta] of Object.entries(localMetadata.photons)) {
2013
+ const photonName = fileName.replace(/\.photon\.ts$/, '');
2014
+ const latestInfo = await marketplace.getPhotonMetadata(photonName);
2015
+ if (latestInfo && latestInfo.metadata.version !== installMeta.version) {
2016
+ updates.push({
2017
+ name: photonName,
2018
+ fileName,
2019
+ currentVersion: installMeta.version,
2020
+ latestVersion: latestInfo.metadata.version,
2021
+ marketplace: latestInfo.marketplace.name,
2022
+ });
2023
+ }
2024
+ }
2025
+ res.writeHead(200);
2026
+ res.end(JSON.stringify({ updates }));
2027
+ }
2028
+ catch {
2029
+ res.writeHead(500);
2030
+ res.end(JSON.stringify({ error: 'Failed to check for updates' }));
2031
+ }
2032
+ return;
2033
+ }
2034
+ // Test API: Run a single test
2035
+ // Supports modes: 'direct' (call instance method), 'mcp' (call via executeTool), 'cli' (spawn subprocess)
2036
+ if (url.pathname === '/api/test/run' && req.method === 'POST') {
2037
+ let body = '';
2038
+ req.on('data', (chunk) => (body += chunk));
2039
+ req.on('end', async () => {
2040
+ res.setHeader('Content-Type', 'application/json');
2041
+ try {
2042
+ const { photon: photonName, test: testName, mode = 'direct' } = JSON.parse(body);
2043
+ // Find the photon
2044
+ const photon = photons.find((p) => p.name === photonName);
2045
+ if (!photon) {
2046
+ res.writeHead(404);
2047
+ res.end(JSON.stringify({ passed: false, error: 'Photon not found', mode }));
2048
+ return;
2049
+ }
2050
+ // Get the MCP instance
2051
+ const mcp = photonMCPs.get(photonName);
2052
+ if (!mcp || !mcp.instance) {
2053
+ res.writeHead(404);
2054
+ res.end(JSON.stringify({ passed: false, error: 'Photon not loaded', mode }));
2055
+ return;
2056
+ }
2057
+ // Run the test method
2058
+ const start = Date.now();
2059
+ try {
2060
+ let result;
2061
+ if (mode === 'mcp') {
2062
+ // MCP mode: use executeTool to simulate MCP protocol
2063
+ // This tests the full tool execution path
2064
+ result = await loader.executeTool(mcp, testName, {}, {});
2065
+ }
2066
+ else if (mode === 'cli') {
2067
+ // CLI mode: spawn subprocess to test CLI interface
2068
+ const cliPath = path.resolve(__dirname, '..', 'cli.js');
2069
+ const args = ['cli', photonName, testName, '--json', '--dir', workingDir];
2070
+ result = await new Promise((resolve) => {
2071
+ const proc = spawn('node', [cliPath, ...args], {
2072
+ cwd: workingDir,
2073
+ timeout: 30000,
2074
+ env: { ...process.env },
2075
+ });
2076
+ let stdout = '';
2077
+ let stderr = '';
2078
+ proc.stdout.on('data', (data) => (stdout += data.toString()));
2079
+ proc.stderr.on('data', (data) => (stderr += data.toString()));
2080
+ proc.on('close', (code) => {
2081
+ const output = stdout.trim() || stderr.trim();
2082
+ const hasOutput = output.length > 0;
2083
+ const infraErrors = [
2084
+ 'Photon not found',
2085
+ 'command not found',
2086
+ 'Cannot find module',
2087
+ 'ENOENT',
2088
+ ];
2089
+ const isInfraError = infraErrors.some((e) => (stdout + stderr).includes(e));
2090
+ if (hasOutput && !isInfraError) {
2091
+ // CLI interface worked - transport successful
2092
+ resolve({ passed: true, message: 'CLI interface test passed' });
2093
+ }
2094
+ else if (isInfraError) {
2095
+ resolve({ passed: false, error: `CLI infrastructure error: ${output}` });
2096
+ }
2097
+ else {
2098
+ resolve({
2099
+ passed: false,
2100
+ error: `CLI test failed with code ${code}: no output`,
2101
+ });
2102
+ }
2103
+ });
2104
+ proc.on('error', (err) => {
2105
+ resolve({ passed: false, error: `CLI spawn error: ${err.message}` });
2106
+ });
2107
+ });
2108
+ }
2109
+ else {
2110
+ // Direct mode: call instance method directly
2111
+ result = await mcp.instance[testName]();
2112
+ }
2113
+ const duration = Date.now() - start;
2114
+ // Check result
2115
+ if (result && typeof result === 'object') {
2116
+ if (result.skipped === true) {
2117
+ res.writeHead(200);
2118
+ res.end(JSON.stringify({
2119
+ passed: true,
2120
+ skipped: true,
2121
+ message: result.reason || 'Skipped',
2122
+ duration,
2123
+ mode,
2124
+ }));
2125
+ }
2126
+ else if (result.passed === false) {
2127
+ res.writeHead(200);
2128
+ res.end(JSON.stringify({
2129
+ passed: false,
2130
+ error: result.error || result.message || 'Test failed',
2131
+ duration,
2132
+ mode,
2133
+ }));
2134
+ }
2135
+ else {
2136
+ res.writeHead(200);
2137
+ res.end(JSON.stringify({
2138
+ passed: true,
2139
+ message: result?.message,
2140
+ duration,
2141
+ mode,
2142
+ }));
2143
+ }
2144
+ }
2145
+ else {
2146
+ res.writeHead(200);
2147
+ res.end(JSON.stringify({
2148
+ passed: true,
2149
+ duration,
2150
+ mode,
2151
+ }));
2152
+ }
2153
+ }
2154
+ catch (testError) {
2155
+ const duration = Date.now() - start;
2156
+ res.writeHead(200);
2157
+ res.end(JSON.stringify({
2158
+ passed: false,
2159
+ error: testError.message || String(testError),
2160
+ duration,
2161
+ mode,
2162
+ }));
2163
+ }
2164
+ }
2165
+ catch {
2166
+ res.writeHead(400);
2167
+ res.end(JSON.stringify({ passed: false, error: 'Invalid request' }));
2168
+ }
2169
+ });
2170
+ return;
2171
+ }
2172
+ res.writeHead(404);
2173
+ res.end('Not Found');
2174
+ });
2175
+ // Broadcast photon changes to all connected clients via MCP SSE
2176
+ const broadcastPhotonChange = () => {
2177
+ // MCP Streamable HTTP clients (SSE) get tools/list_changed notification
2178
+ broadcastNotification('notifications/tools/list_changed');
2179
+ // Beam SSE clients get full photons list
2180
+ broadcastToBeam('beam/photons', { photons });
2181
+ };
2182
+ // File watcher for hot reload
2183
+ const watchers = [];
2184
+ const pendingReloads = new Map();
2185
+ // Determine which photon a file change belongs to
2186
+ const getPhotonForPath = (changedPath) => {
2187
+ const relativePath = path.relative(workingDir, changedPath);
2188
+ const parts = relativePath.split(path.sep);
2189
+ // Direct .photon.ts file change
2190
+ if (relativePath.endsWith('.photon.ts')) {
2191
+ return path.basename(relativePath, '.photon.ts');
2192
+ }
2193
+ // Asset folder change - first segment is the photon name
2194
+ if (parts.length > 1) {
2195
+ const folderName = parts[0];
2196
+ // Check if corresponding .photon.ts exists
2197
+ const photon = photons.find((p) => p.name === folderName);
2198
+ if (photon) {
2199
+ return folderName;
2200
+ }
2201
+ }
2202
+ return null;
2203
+ };
2204
+ // Handle file change with debounce
2205
+ const handleFileChange = async (photonName) => {
2206
+ // Clear any pending reload for this photon
2207
+ const pending = pendingReloads.get(photonName);
2208
+ if (pending)
2209
+ clearTimeout(pending);
2210
+ // Debounce - wait 100ms for batch saves
2211
+ pendingReloads.set(photonName, setTimeout(async () => {
2212
+ pendingReloads.delete(photonName);
2213
+ const photonIndex = photons.findIndex((p) => p.name === photonName);
2214
+ const isNewPhoton = photonIndex === -1;
2215
+ const photonPath = isNewPhoton
2216
+ ? path.join(workingDir, `${photonName}.photon.ts`)
2217
+ : photons[photonIndex].path;
2218
+ // Handle file deletion - if file no longer exists and photon is in list, remove it
2219
+ if (!isNewPhoton && photonPath && !existsSync(photonPath)) {
2220
+ logger.info(`🗑️ Photon file deleted: ${photonName}`);
2221
+ photons.splice(photonIndex, 1);
2222
+ photonMCPs.delete(photonName);
2223
+ // Also remove from saved config
2224
+ if (savedConfig.photons[photonName]) {
2225
+ delete savedConfig.photons[photonName];
2226
+ await saveConfig(savedConfig);
2227
+ }
2228
+ broadcastPhotonChange();
2229
+ broadcastToBeam('beam/photon-removed', { name: photonName });
2230
+ return;
2231
+ }
2232
+ logger.info(isNewPhoton
2233
+ ? `✨ New photon detected: ${photonName}`
2234
+ : `🔄 File change detected, reloading ${photonName}...`);
2235
+ // For new photons, check if configuration is needed first
2236
+ if (isNewPhoton) {
2237
+ const extractor = new SchemaExtractor();
2238
+ let constructorParams = [];
2239
+ try {
2240
+ const source = await fs.readFile(photonPath, 'utf-8');
2241
+ const params = extractor.extractConstructorParams(source);
2242
+ constructorParams = params
2243
+ .filter((p) => p.isPrimitive)
2244
+ .map((p) => ({
2245
+ name: p.name,
2246
+ envVar: toEnvVarName(photonName, p.name),
2247
+ type: p.type,
2248
+ isOptional: p.isOptional,
2249
+ hasDefault: p.hasDefault,
2250
+ defaultValue: p.defaultValue,
2251
+ }));
2252
+ }
2253
+ catch {
2254
+ // Can't extract params, try to load anyway
2255
+ }
2256
+ // Check if any required params are missing
2257
+ const missingRequired = constructorParams.filter((p) => !p.isOptional && !p.hasDefault && !process.env[p.envVar]);
2258
+ if (missingRequired.length > 0 && constructorParams.length > 0) {
2259
+ // Add as unconfigured photon
2260
+ const targetPhoton = {
2261
+ id: generatePhotonId(photonPath),
2262
+ name: photonName,
2263
+ path: photonPath,
2264
+ configured: false,
2265
+ requiredParams: constructorParams,
2266
+ errorReason: 'missing-config',
2267
+ errorMessage: `Missing required: ${missingRequired.map((p) => p.name).join(', ')}`,
2268
+ };
2269
+ photons.push(targetPhoton);
2270
+ broadcastPhotonChange();
2271
+ logger.info(`⚙️ ${photonName} added (needs configuration)`);
2272
+ return;
2273
+ }
2274
+ }
2275
+ try {
2276
+ // Load or reload the photon
2277
+ const mcp = isNewPhoton
2278
+ ? await loader.loadFile(photonPath)
2279
+ : await loader.reloadFile(photonPath);
2280
+ if (!mcp.instance)
2281
+ throw new Error('Failed to create instance');
2282
+ photonMCPs.set(photonName, mcp);
2283
+ // Re-extract schema - use extractAllFromSource to get both tools and templates
2284
+ const extractor = new SchemaExtractor();
2285
+ const reloadSource = await fs.readFile(photonPath, 'utf-8');
2286
+ const { tools: schemas, templates } = extractor.extractAllFromSource(reloadSource);
2287
+ mcp.schemas = schemas; // Store schemas for result rendering
2288
+ const lifecycleMethods = ['onInitialize', 'onShutdown', 'constructor'];
2289
+ const uiAssets = mcp.assets?.ui || [];
2290
+ const methods = schemas
2291
+ .filter((schema) => !lifecycleMethods.includes(schema.name))
2292
+ .map((schema) => {
2293
+ const linkedAsset = uiAssets.find((ui) => ui.linkedTool === schema.name);
2294
+ return {
2295
+ name: schema.name,
2296
+ description: schema.description || '',
2297
+ params: schema.inputSchema || { type: 'object', properties: {}, required: [] },
2298
+ returns: { type: 'object' },
2299
+ autorun: schema.autorun || false,
2300
+ outputFormat: schema.outputFormat,
2301
+ layoutHints: schema.layoutHints,
2302
+ buttonLabel: schema.buttonLabel,
2303
+ icon: schema.icon,
2304
+ linkedUi: linkedAsset?.id,
2305
+ };
2306
+ });
2307
+ // Add templates as methods
2308
+ templates.forEach((template) => {
2309
+ if (!lifecycleMethods.includes(template.name)) {
2310
+ methods.push({
2311
+ name: template.name,
2312
+ description: template.description || '',
2313
+ params: template.inputSchema || { type: 'object', properties: {}, required: [] },
2314
+ returns: { type: 'object' },
2315
+ isTemplate: true,
2316
+ outputFormat: 'markdown',
2317
+ });
2318
+ }
2319
+ });
2320
+ // Apply @visibility annotations
2321
+ applyMethodVisibility(reloadSource, methods);
2322
+ // Check if this is an App (has main() method with @ui)
2323
+ const mainMethod = methods.find((m) => m.name === 'main' && m.linkedUi);
2324
+ // Extract class metadata from source
2325
+ const reloadClassMeta = extractClassMetadataFromSource(reloadSource);
2326
+ // Extract constructor params for reconfiguration support
2327
+ let reloadConstructorParams = [];
2328
+ try {
2329
+ const reloadParams = extractor.extractConstructorParams(reloadSource);
2330
+ reloadConstructorParams = reloadParams
2331
+ .filter((p) => p.isPrimitive)
2332
+ .map((p) => ({
2333
+ name: p.name,
2334
+ envVar: toEnvVarName(photonName, p.name),
2335
+ type: p.type,
2336
+ isOptional: p.isOptional,
2337
+ hasDefault: p.hasDefault,
2338
+ defaultValue: p.defaultValue,
2339
+ }));
2340
+ }
2341
+ catch {
2342
+ // Can't extract params
2343
+ }
2344
+ backfillEnvDefaults(mcp.instance, reloadConstructorParams);
2345
+ const reloadedPhoton = {
2346
+ id: generatePhotonId(photonPath),
2347
+ name: photonName,
2348
+ path: photonPath,
2349
+ configured: true,
2350
+ methods,
2351
+ isApp: !!mainMethod,
2352
+ appEntry: mainMethod,
2353
+ description: reloadClassMeta.description,
2354
+ icon: reloadClassMeta.icon,
2355
+ internal: reloadClassMeta.internal,
2356
+ ...(reloadConstructorParams.length > 0 && { requiredParams: reloadConstructorParams }),
2357
+ ...(mcp.injectedPhotons && mcp.injectedPhotons.length > 0 && { injectedPhotons: mcp.injectedPhotons }),
2358
+ };
2359
+ if (isNewPhoton) {
2360
+ photons.push(reloadedPhoton);
2361
+ broadcastPhotonChange();
2362
+ logger.info(`✅ ${photonName} added`);
2363
+ }
2364
+ else {
2365
+ photons[photonIndex] = reloadedPhoton;
2366
+ logger.info(`📡 Broadcasting hot-reload for ${photonName}`);
2367
+ broadcastToBeam('beam/hot-reload', { photon: reloadedPhoton });
2368
+ broadcastPhotonChange();
2369
+ logger.info(`✅ ${photonName} hot reloaded`);
2370
+ }
2371
+ }
2372
+ catch (error) {
2373
+ const errorMsg = error instanceof Error ? error.message : String(error);
2374
+ // For new photons that fail to load, add as unconfigured
2375
+ if (isNewPhoton) {
2376
+ const extractor = new SchemaExtractor();
2377
+ let constructorParams = [];
2378
+ try {
2379
+ const source = await fs.readFile(photonPath, 'utf-8');
2380
+ const params = extractor.extractConstructorParams(source);
2381
+ constructorParams = params
2382
+ .filter((p) => p.isPrimitive)
2383
+ .map((p) => ({
2384
+ name: p.name,
2385
+ envVar: toEnvVarName(photonName, p.name),
2386
+ type: p.type,
2387
+ isOptional: p.isOptional,
2388
+ hasDefault: p.hasDefault,
2389
+ defaultValue: p.defaultValue,
2390
+ }));
2391
+ }
2392
+ catch {
2393
+ // Ignore extraction errors
2394
+ }
2395
+ const targetPhoton = {
2396
+ id: generatePhotonId(photonPath),
2397
+ name: photonName,
2398
+ path: photonPath,
2399
+ configured: false,
2400
+ requiredParams: constructorParams,
2401
+ errorReason: constructorParams.length > 0 ? 'missing-config' : 'load-error',
2402
+ errorMessage: errorMsg.slice(0, 200),
2403
+ };
2404
+ photons.push(targetPhoton);
2405
+ broadcastPhotonChange();
2406
+ logger.info(`⚙️ ${photonName} added (needs attention: ${targetPhoton.errorReason})`);
2407
+ return;
2408
+ }
2409
+ logger.error(`Hot reload failed for ${photonName}: ${errorMsg}`);
2410
+ broadcastToBeam('beam/error', {
2411
+ type: 'hot-reload-error',
2412
+ photon: photonName,
2413
+ message: errorMsg.slice(0, 200),
2414
+ });
2415
+ }
2416
+ }, 100));
2417
+ };
2418
+ // Watch working directory recursively
2419
+ try {
2420
+ const watcher = watch(workingDir, { recursive: true }, (eventType, filename) => {
2421
+ if (!filename)
2422
+ return;
2423
+ const fullPath = path.join(workingDir, filename);
2424
+ logger.debug(`📂 File event: ${eventType} ${filename}`);
2425
+ const photonName = getPhotonForPath(fullPath);
2426
+ if (photonName) {
2427
+ logger.info(`📁 Change detected: ${filename} → ${photonName}`);
2428
+ handleFileChange(photonName);
2429
+ }
2430
+ });
2431
+ // Handle watcher errors (e.g., EMFILE: too many open files)
2432
+ watcher.on('error', (err) => {
2433
+ logger.warn(`File watcher error (continuing without hot-reload): ${err.message}`);
2434
+ try {
2435
+ watcher.close();
2436
+ }
2437
+ catch {
2438
+ // Ignore close errors
2439
+ }
2440
+ });
2441
+ watchers.push(watcher);
2442
+ logger.info(`👀 Watching for changes in ${workingDir}`);
2443
+ }
2444
+ catch (error) {
2445
+ logger.warn(`File watching not available: ${error}`);
2446
+ }
2447
+ // Symlinked and bundled photon watchers are set up after photon loading (see below)
2448
+ // Bind to 0.0.0.0 for tunnel access, with port fallback
2449
+ // Start server BEFORE loading photons so the UI is immediately reachable
2450
+ const maxPortAttempts = 10;
2451
+ let currentPort = port;
2452
+ // Check if a port is available by attempting to connect to it
2453
+ // This catches cases where another server binds to 127.0.0.1 but not 0.0.0.0
2454
+ const isPortAvailable = (p) => {
2455
+ return new Promise((resolve) => {
2456
+ const socket = new net.Socket();
2457
+ socket.setTimeout(500);
2458
+ socket.once('connect', () => {
2459
+ socket.destroy();
2460
+ resolve(false); // Port is in use
2461
+ });
2462
+ socket.once('timeout', () => {
2463
+ socket.destroy();
2464
+ resolve(true); // Timeout = port likely free
2465
+ });
2466
+ socket.once('error', () => {
2467
+ socket.destroy();
2468
+ resolve(true); // Connection refused = port is free
2469
+ });
2470
+ socket.connect(p, '127.0.0.1');
2471
+ });
2472
+ };
2473
+ // Find an available port
2474
+ while (currentPort < port + maxPortAttempts) {
2475
+ const available = await isPortAvailable(currentPort);
2476
+ if (available)
2477
+ break;
2478
+ console.error(`⚠️ Port ${currentPort} is in use, trying ${currentPort + 1}...`);
2479
+ currentPort++;
2480
+ }
2481
+ if (currentPort >= port + maxPortAttempts) {
2482
+ console.error(`\n❌ No available port found (tried ${port}-${currentPort - 1}). Exiting.\n`);
2483
+ process.exit(1);
2484
+ }
2485
+ await new Promise((resolve) => {
2486
+ const tryListen = () => {
2487
+ server.once('error', (err) => {
2488
+ if (err.code === 'EADDRINUSE' && currentPort < port + maxPortAttempts) {
2489
+ currentPort++;
2490
+ console.error(`⚠️ Port ${currentPort - 1} is in use, trying ${currentPort}...`);
2491
+ tryListen();
2492
+ }
2493
+ else if (err.code === 'EADDRINUSE') {
2494
+ console.error(`\n❌ No available port found (tried ${port}-${currentPort}). Exiting.\n`);
2495
+ process.exit(1);
2496
+ }
2497
+ else {
2498
+ console.error(`\n❌ Server error: ${err.message}\n`);
2499
+ process.exit(1);
2500
+ }
2501
+ });
2502
+ server.listen(currentPort, '0.0.0.0', () => {
2503
+ process.env.BEAM_PORT = String(currentPort);
2504
+ const url = `http://localhost:${currentPort}`;
2505
+ console.log(`\n⚡ Photon Beam → ${url} (loading photons...)\n`);
2506
+ resolve();
2507
+ });
2508
+ };
2509
+ tryListen();
2510
+ });
2511
+ // Load photons in parallel batches (server is already listening)
2512
+ const LOAD_CONCURRENCY = 4;
2513
+ for (let i = 0; i < photonList.length; i += LOAD_CONCURRENCY) {
2514
+ const batch = photonList.slice(i, i + LOAD_CONCURRENCY);
2515
+ const results = await Promise.allSettled(batch.map((name) => loadSinglePhoton(name)));
2516
+ for (const result of results) {
2517
+ if (result.status === 'fulfilled' && result.value) {
2518
+ photons.push(result.value);
2519
+ }
2520
+ }
2521
+ }
2522
+ configuredCount = photons.filter((p) => p.configured).length;
2523
+ unconfiguredCount = photons.filter((p) => !p.configured).length;
2524
+ // Load external MCPs from config
2525
+ const externalMCPList = await loadExternalMCPs(savedConfig);
2526
+ externalMCPs.push(...externalMCPList);
2527
+ const connectedMCPs = externalMCPList.filter((m) => m.connected).length;
2528
+ const failedMCPs = externalMCPList.length - connectedMCPs;
2529
+ const photonStatus = unconfiguredCount > 0
2530
+ ? `${configuredCount} ready, ${unconfiguredCount} need setup`
2531
+ : `${configuredCount} photon${configuredCount !== 1 ? 's' : ''} ready`;
2532
+ const mcpStatus = externalMCPList.length > 0
2533
+ ? `, ${connectedMCPs}/${externalMCPList.length} MCPs`
2534
+ : '';
2535
+ console.log(`⚡ Photon Beam ready (${photonStatus}${mcpStatus})`);
2536
+ // Notify connected clients that photon list is now available
2537
+ broadcastPhotonChange();
2538
+ // Set up file watchers for symlinked and bundled photon assets (now that photons are loaded)
2539
+ for (const photon of photons) {
2540
+ if (!photon.path) {
2541
+ logger.debug(`⏭️ Skipping ${photon.name}: no path`);
2542
+ continue;
2543
+ }
2544
+ try {
2545
+ const stat = lstatSync(photon.path);
2546
+ if (stat.isSymbolicLink()) {
2547
+ const realPath = realpathSync(photon.path);
2548
+ const realDir = path.dirname(realPath);
2549
+ const assetFolder = path.join(realDir, photon.name);
2550
+ if (existsSync(assetFolder)) {
2551
+ const assetWatcher = watch(assetFolder, { recursive: true }, (eventType, filename) => {
2552
+ if (filename) {
2553
+ if (filename.endsWith('.json') ||
2554
+ filename.startsWith('boards/') ||
2555
+ filename === 'data.json') {
2556
+ logger.debug(`⏭️ Ignoring data file change: ${photon.name}/${filename}`);
2557
+ return;
2558
+ }
2559
+ logger.info(`📁 Asset change detected: ${photon.name}/${filename}`);
2560
+ handleFileChange(photon.name);
2561
+ }
2562
+ });
2563
+ assetWatcher.on('error', (err) => {
2564
+ logger.warn(`Watcher error for ${photon.name}/: ${err.message}`);
2565
+ });
2566
+ watchers.push(assetWatcher);
2567
+ logger.info(`👀 Watching ${photon.name}/ (symlinked → ${assetFolder})`);
2568
+ }
2569
+ else {
2570
+ logger.debug(`⏭️ Skipping ${photon.name}: asset folder not found at ${assetFolder}`);
2571
+ }
2572
+ }
2573
+ else {
2574
+ logger.debug(`⏭️ Skipping ${photon.name}: not a symlink`);
2575
+ }
2576
+ }
2577
+ catch (err) {
2578
+ logger.debug(`⏭️ Skipping ${photon.name}: ${err instanceof Error ? err.message : err}`);
2579
+ }
2580
+ }
2581
+ // Watch bundled photon asset folders
2582
+ for (const [photonName, photonPath] of bundledPhotonPaths) {
2583
+ const photonDir = path.dirname(photonPath);
2584
+ const isInWorkingDir = photonDir.startsWith(workingDir);
2585
+ if (isInWorkingDir) {
2586
+ const assetFolder = path.join(photonDir, photonName);
2587
+ if (existsSync(assetFolder)) {
2588
+ logger.info(`👀 Watching ${photonName}/ via main watcher`);
2589
+ }
2590
+ continue;
2591
+ }
2592
+ try {
2593
+ const photonWatcher = watch(photonPath, (eventType) => {
2594
+ if (eventType === 'change') {
2595
+ handleFileChange(photonName);
2596
+ }
2597
+ });
2598
+ photonWatcher.on('error', () => { });
2599
+ watchers.push(photonWatcher);
2600
+ }
2601
+ catch {
2602
+ // Ignore errors
2603
+ }
2604
+ const assetFolder = path.join(photonDir, photonName);
2605
+ try {
2606
+ const assetWatcher = watch(assetFolder, { recursive: true }, (eventType, filename) => {
2607
+ if (filename) {
2608
+ if (filename.endsWith('.json') ||
2609
+ filename.startsWith('boards/') ||
2610
+ filename === 'data.json') {
2611
+ logger.debug(`⏭️ Ignoring data file change: ${photonName}/${filename}`);
2612
+ return;
2613
+ }
2614
+ logger.info(`📁 Asset change detected: ${photonName}/${filename}`);
2615
+ handleFileChange(photonName);
2616
+ }
2617
+ });
2618
+ assetWatcher.on('error', () => { });
2619
+ watchers.push(assetWatcher);
2620
+ logger.info(`👀 Watching ${photonName}/ for asset changes`);
2621
+ }
2622
+ catch {
2623
+ // Asset folder doesn't exist or can't be watched - that's okay
2624
+ }
2625
+ }
2626
+ // ══════════════════════════════════════════════════════════════════════════════
2627
+ // CONFIG.JSON WATCHER — Detect external MCP changes without restart
2628
+ // Watch the parent directory (atomic writes via rename can miss single-file watches)
2629
+ // ══════════════════════════════════════════════════════════════════════════════
2630
+ try {
2631
+ const configDir = path.dirname(CONFIG_FILE);
2632
+ let configDebounce = null;
2633
+ const configWatcher = watch(configDir, (eventType, filename) => {
2634
+ if (filename !== 'config.json')
2635
+ return;
2636
+ if (configDebounce)
2637
+ clearTimeout(configDebounce);
2638
+ configDebounce = setTimeout(async () => {
2639
+ configDebounce = null;
2640
+ let newConfig;
2641
+ try {
2642
+ const data = await fs.readFile(CONFIG_FILE, 'utf-8');
2643
+ newConfig = migrateConfig(JSON.parse(data));
2644
+ }
2645
+ catch (err) {
2646
+ logger.warn(`⚠️ Failed to parse config.json: ${err instanceof Error ? err.message : err}`);
2647
+ return;
2648
+ }
2649
+ const oldServers = savedConfig.mcpServers || {};
2650
+ const newServers = newConfig.mcpServers || {};
2651
+ const oldKeys = new Set(Object.keys(oldServers));
2652
+ const newKeys = new Set(Object.keys(newServers));
2653
+ const added = [...newKeys].filter((k) => !oldKeys.has(k));
2654
+ const removed = [...oldKeys].filter((k) => !newKeys.has(k));
2655
+ const kept = [...newKeys].filter((k) => oldKeys.has(k));
2656
+ const modified = kept.filter((k) => JSON.stringify(oldServers[k]) !== JSON.stringify(newServers[k]));
2657
+ if (added.length === 0 && removed.length === 0 && modified.length === 0) {
2658
+ // Also sync photon config changes (env vars etc.)
2659
+ savedConfig.photons = newConfig.photons || {};
2660
+ return;
2661
+ }
2662
+ logger.info(`🔧 config.json changed — added: [${added}], removed: [${removed}], modified: [${modified}]`);
2663
+ // Remove MCPs
2664
+ for (const name of removed) {
2665
+ const idx = externalMCPs.findIndex((m) => m.name === name);
2666
+ if (idx !== -1)
2667
+ externalMCPs.splice(idx, 1);
2668
+ // Clean up clients
2669
+ try {
2670
+ const sdkClient = externalMCPSDKClients.get(name);
2671
+ if (sdkClient) {
2672
+ await sdkClient.close();
2673
+ externalMCPSDKClients.delete(name);
2674
+ }
2675
+ }
2676
+ catch { /* ignore */ }
2677
+ externalMCPClients.delete(name);
2678
+ logger.info(`🔌 Removed external MCP: ${name}`);
2679
+ }
2680
+ // Add new MCPs
2681
+ if (added.length > 0) {
2682
+ const addConfig = {
2683
+ photons: {},
2684
+ mcpServers: Object.fromEntries(added.map((k) => [k, newServers[k]])),
2685
+ };
2686
+ const newMCPs = await loadExternalMCPs(addConfig);
2687
+ externalMCPs.push(...newMCPs);
2688
+ for (const m of newMCPs) {
2689
+ logger.info(`🔌 Added external MCP: ${m.name} (${m.connected ? m.methods.length + ' tools' : 'failed'})`);
2690
+ }
2691
+ }
2692
+ // Reconnect modified MCPs
2693
+ for (const name of modified) {
2694
+ const idx = externalMCPs.findIndex((m) => m.name === name);
2695
+ if (idx !== -1) {
2696
+ // Clean up old clients
2697
+ try {
2698
+ const sdkClient = externalMCPSDKClients.get(name);
2699
+ if (sdkClient) {
2700
+ await sdkClient.close();
2701
+ externalMCPSDKClients.delete(name);
2702
+ }
2703
+ }
2704
+ catch { /* ignore */ }
2705
+ externalMCPClients.delete(name);
2706
+ externalMCPs.splice(idx, 1);
2707
+ }
2708
+ // Reconnect with new config
2709
+ const modConfig = {
2710
+ photons: {},
2711
+ mcpServers: { [name]: newServers[name] },
2712
+ };
2713
+ const reconnected = await loadExternalMCPs(modConfig);
2714
+ externalMCPs.push(...reconnected);
2715
+ logger.info(`🔌 Reconnected external MCP: ${name}`);
2716
+ }
2717
+ // Update savedConfig
2718
+ savedConfig.mcpServers = newConfig.mcpServers || {};
2719
+ savedConfig.photons = newConfig.photons || {};
2720
+ broadcastPhotonChange();
2721
+ }, 500);
2722
+ });
2723
+ configWatcher.on('error', (err) => {
2724
+ logger.warn(`Config watcher error: ${err.message}`);
2725
+ });
2726
+ watchers.push(configWatcher);
2727
+ logger.info(`👀 Watching config.json for external MCP changes`);
2728
+ }
2729
+ catch (error) {
2730
+ logger.warn(`Config watching not available: ${error}`);
2731
+ }
2732
+ }
2733
+ /**
2734
+ * Configure a photon via MCP
2735
+ */
2736
+ async function configurePhotonViaMCP(photonName, config, photons, photonMCPs, loader, savedConfig) {
2737
+ // Find the photon (configured or unconfigured)
2738
+ const photonIndex = photons.findIndex((p) => p.name === photonName);
2739
+ if (photonIndex === -1) {
2740
+ return { success: false, error: `Photon not found: ${photonName}` };
2741
+ }
2742
+ // Apply config to environment
2743
+ for (const [key, value] of Object.entries(config)) {
2744
+ process.env[key] = String(value);
2745
+ }
2746
+ // Save config to file (merge with existing config for edit mode)
2747
+ savedConfig.photons[photonName] = { ...(savedConfig.photons[photonName] || {}), ...config };
2748
+ await saveConfig(savedConfig);
2749
+ const targetPhoton = photons[photonIndex];
2750
+ const isReconfigure = targetPhoton.configured === true;
2751
+ // Try to reload the photon
2752
+ try {
2753
+ const mcp = isReconfigure
2754
+ ? await loader.reloadFile(targetPhoton.path)
2755
+ : await loader.loadFile(targetPhoton.path);
2756
+ const instance = mcp.instance;
2757
+ if (!instance) {
2758
+ throw new Error('Failed to create instance');
2759
+ }
2760
+ photonMCPs.set(photonName, mcp);
2761
+ backfillEnvDefaults(instance, targetPhoton.requiredParams || []);
2762
+ // Extract schema for UI
2763
+ const extractor = new SchemaExtractor();
2764
+ const configSource = await fs.readFile(targetPhoton.path, 'utf-8');
2765
+ const { tools: schemas, templates } = extractor.extractAllFromSource(configSource);
2766
+ mcp.schemas = schemas;
2767
+ // Get UI assets for linking
2768
+ const uiAssets = mcp.assets?.ui || [];
2769
+ const lifecycleMethods = ['onInitialize', 'onShutdown', 'constructor'];
2770
+ const methods = schemas
2771
+ .filter((schema) => !lifecycleMethods.includes(schema.name))
2772
+ .map((schema) => {
2773
+ const linkedAsset = uiAssets.find((ui) => ui.linkedTool === schema.name);
2774
+ return {
2775
+ name: schema.name,
2776
+ description: schema.description || '',
2777
+ params: schema.inputSchema || { type: 'object', properties: {}, required: [] },
2778
+ returns: { type: 'object' },
2779
+ autorun: schema.autorun || false,
2780
+ outputFormat: schema.outputFormat,
2781
+ layoutHints: schema.layoutHints,
2782
+ buttonLabel: schema.buttonLabel,
2783
+ icon: schema.icon,
2784
+ linkedUi: linkedAsset?.id,
2785
+ };
2786
+ });
2787
+ // Add templates as methods
2788
+ templates.forEach((template) => {
2789
+ if (!lifecycleMethods.includes(template.name)) {
2790
+ methods.push({
2791
+ name: template.name,
2792
+ description: template.description || '',
2793
+ params: template.inputSchema || { type: 'object', properties: {}, required: [] },
2794
+ returns: { type: 'object' },
2795
+ isTemplate: true,
2796
+ outputFormat: 'markdown',
2797
+ });
2798
+ }
2799
+ });
2800
+ // Apply @visibility annotations
2801
+ applyMethodVisibility(configSource, methods);
2802
+ // Check if this is an App
2803
+ const mainMethod = methods.find((m) => m.name === 'main' && m.linkedUi);
2804
+ const isApp = !!mainMethod;
2805
+ // Replace unconfigured photon with configured one
2806
+ const configuredPhoton = {
2807
+ id: generatePhotonId(targetPhoton.path),
2808
+ name: photonName,
2809
+ path: targetPhoton.path,
2810
+ configured: true,
2811
+ methods,
2812
+ isApp,
2813
+ appEntry: mainMethod,
2814
+ assets: mcp.assets,
2815
+ ...(mcp.injectedPhotons && mcp.injectedPhotons.length > 0 && { injectedPhotons: mcp.injectedPhotons }),
2816
+ };
2817
+ photons[photonIndex] = configuredPhoton;
2818
+ logger.info(`✅ ${photonName} configured via MCP`);
2819
+ // Notify connected MCP clients about tools list change
2820
+ broadcastNotification('notifications/tools/list_changed', {});
2821
+ broadcastToBeam('beam/configured', { photon: configuredPhoton });
2822
+ return { success: true };
2823
+ }
2824
+ catch (error) {
2825
+ const errorMsg = error instanceof Error ? error.message : String(error);
2826
+ logger.error(`Failed to configure ${photonName} via MCP: ${errorMsg}`);
2827
+ return { success: false, error: errorMsg };
2828
+ }
2829
+ }
2830
+ /**
2831
+ * Reload a photon via MCP
2832
+ */
2833
+ async function reloadPhotonViaMCP(photonName, photons, photonMCPs, loader, savedConfig, broadcastChange) {
2834
+ // Find the photon
2835
+ const photonIndex = photons.findIndex((p) => p.name === photonName);
2836
+ if (photonIndex === -1) {
2837
+ return { success: false, error: `Photon not found: ${photonName}` };
2838
+ }
2839
+ const photon = photons[photonIndex];
2840
+ const photonPath = photon.path;
2841
+ // Get saved config for this photon
2842
+ const config = savedConfig.photons[photonName] || {};
2843
+ // Apply config to environment
2844
+ for (const [key, value] of Object.entries(config)) {
2845
+ process.env[key] = value;
2846
+ }
2847
+ try {
2848
+ // Reload the photon (clears compiled cache for hot reload)
2849
+ const mcp = await loader.reloadFile(photonPath);
2850
+ const instance = mcp.instance;
2851
+ if (!instance) {
2852
+ throw new Error('Failed to create instance');
2853
+ }
2854
+ photonMCPs.set(photonName, mcp);
2855
+ backfillEnvDefaults(instance, photon.requiredParams || []);
2856
+ // Extract schema for UI
2857
+ const extractor = new SchemaExtractor();
2858
+ const reloadSrc = await fs.readFile(photonPath, 'utf-8');
2859
+ const { tools: schemas, templates } = extractor.extractAllFromSource(reloadSrc);
2860
+ mcp.schemas = schemas;
2861
+ const lifecycleMethods = ['onInitialize', 'onShutdown', 'constructor'];
2862
+ const uiAssets = mcp.assets?.ui || [];
2863
+ const methods = schemas
2864
+ .filter((schema) => !lifecycleMethods.includes(schema.name))
2865
+ .map((schema) => {
2866
+ const linkedAsset = uiAssets.find((ui) => ui.linkedTool === schema.name);
2867
+ return {
2868
+ name: schema.name,
2869
+ description: schema.description || '',
2870
+ params: schema.inputSchema || { type: 'object', properties: {}, required: [] },
2871
+ returns: { type: 'object' },
2872
+ autorun: schema.autorun || false,
2873
+ outputFormat: schema.outputFormat,
2874
+ layoutHints: schema.layoutHints,
2875
+ buttonLabel: schema.buttonLabel,
2876
+ icon: schema.icon,
2877
+ linkedUi: linkedAsset?.id,
2878
+ };
2879
+ });
2880
+ // Add templates as methods
2881
+ templates.forEach((template) => {
2882
+ if (!lifecycleMethods.includes(template.name)) {
2883
+ methods.push({
2884
+ name: template.name,
2885
+ description: template.description || '',
2886
+ params: template.inputSchema || { type: 'object', properties: {}, required: [] },
2887
+ returns: { type: 'object' },
2888
+ isTemplate: true,
2889
+ outputFormat: 'markdown',
2890
+ });
2891
+ }
2892
+ });
2893
+ // Apply @visibility annotations
2894
+ applyMethodVisibility(reloadSrc, methods);
2895
+ // Check if this is an App
2896
+ const mainMethod = methods.find((m) => m.name === 'main' && m.linkedUi);
2897
+ // Extract class metadata from source
2898
+ const reloadClassMeta = extractClassMetadataFromSource(reloadSrc);
2899
+ // Update photon info
2900
+ const reloadedPhoton = {
2901
+ id: generatePhotonId(photonPath),
2902
+ name: photonName,
2903
+ path: photonPath,
2904
+ configured: true,
2905
+ methods,
2906
+ isApp: !!mainMethod,
2907
+ appEntry: mainMethod,
2908
+ description: reloadClassMeta.description,
2909
+ icon: reloadClassMeta.icon,
2910
+ internal: reloadClassMeta.internal,
2911
+ ...(mcp.injectedPhotons && mcp.injectedPhotons.length > 0 && { injectedPhotons: mcp.injectedPhotons }),
2912
+ };
2913
+ photons[photonIndex] = reloadedPhoton;
2914
+ logger.info(`🔄 ${photonName} reloaded via MCP`);
2915
+ // Notify clients about the change
2916
+ broadcastChange();
2917
+ return { success: true, photon: reloadedPhoton };
2918
+ }
2919
+ catch (error) {
2920
+ const errorMsg = error instanceof Error ? error.message : String(error);
2921
+ logger.error(`Failed to reload ${photonName} via MCP: ${errorMsg}`);
2922
+ return { success: false, error: errorMsg };
2923
+ }
2924
+ }
2925
+ /**
2926
+ * Remove a photon via MCP
2927
+ */
2928
+ async function removePhotonViaMCP(photonName, photons, photonMCPs, savedConfig, broadcastChange) {
2929
+ // Find and remove the photon
2930
+ const photonIndex = photons.findIndex((p) => p.name === photonName);
2931
+ if (photonIndex === -1) {
2932
+ return { success: false, error: `Photon not found: ${photonName}` };
2933
+ }
2934
+ // Remove from arrays and maps
2935
+ photons.splice(photonIndex, 1);
2936
+ photonMCPs.delete(photonName);
2937
+ // Remove saved config
2938
+ if (savedConfig.photons[photonName]) {
2939
+ delete savedConfig.photons[photonName];
2940
+ await saveConfig(savedConfig);
2941
+ }
2942
+ logger.info(`🗑️ ${photonName} removed via MCP`);
2943
+ // Notify clients about the change
2944
+ broadcastChange();
2945
+ return { success: true };
2946
+ }
2947
+ /**
2948
+ * Update photon or method metadata via MCP
2949
+ */
2950
+ async function updateMetadataViaMCP(photonName, methodName, metadata, photons) {
2951
+ // Find the photon
2952
+ const photonIndex = photons.findIndex((p) => p.name === photonName);
2953
+ if (photonIndex === -1) {
2954
+ return { success: false, error: `Photon not found: ${photonName}` };
2955
+ }
2956
+ const photon = photons[photonIndex];
2957
+ if (methodName) {
2958
+ // Update method metadata
2959
+ if (!photon.configured || !photon.methods) {
2960
+ return { success: false, error: 'Photon is not configured or has no methods' };
2961
+ }
2962
+ const method = photon.methods.find((m) => m.name === methodName);
2963
+ if (!method) {
2964
+ return { success: false, error: `Method not found: ${methodName}` };
2965
+ }
2966
+ // Update method metadata
2967
+ if (metadata.description !== undefined) {
2968
+ method.description = metadata.description;
2969
+ }
2970
+ if (metadata.icon !== undefined) {
2971
+ method.icon = metadata.icon;
2972
+ }
2973
+ logger.info(`📝 Updated metadata for ${photonName}/${methodName}`);
2974
+ }
2975
+ else {
2976
+ // Update photon metadata
2977
+ if (metadata.description !== undefined) {
2978
+ photon.description = metadata.description;
2979
+ }
2980
+ if (metadata.icon !== undefined) {
2981
+ photon.icon = metadata.icon;
2982
+ }
2983
+ logger.info(`📝 Updated metadata for ${photonName}`);
2984
+ }
2985
+ return { success: true };
2986
+ }
2987
+ /**
2988
+ * Generate rich help markdown for a photon using PhotonDocExtractor + TemplateManager.
2989
+ * Checks for an existing .md file first; generates and saves one if missing.
2990
+ */
2991
+ async function generatePhotonHelpMarkdown(photonName, photons) {
2992
+ const photon = photons.find((p) => p.name === photonName);
2993
+ if (!photon) {
2994
+ throw new Error(`Photon not found: ${photonName}`);
2995
+ }
2996
+ if (!photon.path) {
2997
+ throw new Error(`Photon path not available: ${photonName}`);
2998
+ }
2999
+ const sourceDir = path.dirname(photon.path);
3000
+ const mdPath = path.join(sourceDir, `${photonName}.md`);
3001
+ // Check if .md file already exists and is newer than the photon source
3002
+ try {
3003
+ const [mdStat, srcStat] = await Promise.all([
3004
+ fs.stat(mdPath),
3005
+ fs.stat(photon.path),
3006
+ ]);
3007
+ if (mdStat.mtimeMs >= srcStat.mtimeMs) {
3008
+ const existing = await fs.readFile(mdPath, 'utf-8');
3009
+ if (existing.trim()) {
3010
+ return existing;
3011
+ }
3012
+ }
3013
+ }
3014
+ catch {
3015
+ // .md doesn't exist or stat failed - regenerate
3016
+ }
3017
+ // Extract metadata and render template
3018
+ const extractor = new PhotonDocExtractor(photon.path);
3019
+ const metadata = await extractor.extractFullMetadata();
3020
+ // Use TemplateManager to render the photon.md template
3021
+ const templateMgr = new TemplateManager(sourceDir);
3022
+ await templateMgr.ensureTemplates();
3023
+ const markdown = await templateMgr.renderTemplate('photon.md', metadata);
3024
+ // Try to save the generated .md file for future use
3025
+ try {
3026
+ await fs.writeFile(mdPath, markdown, 'utf-8');
3027
+ logger.info(`📄 Generated help doc: ${mdPath}`);
3028
+ }
3029
+ catch {
3030
+ // Write may fail for bundled/read-only photons - that's fine
3031
+ logger.debug(`Could not save help doc to ${mdPath} (read-only?)`);
3032
+ }
3033
+ return markdown;
3034
+ }
3035
+ /**
3036
+ * Gracefully stop Beam server and clean up resources.
3037
+ * Closes all external MCP SDK clients to prevent ugly tracebacks on shutdown.
3038
+ */
3039
+ export async function stopBeam() {
3040
+ // Close all SDK clients gracefully
3041
+ const closePromises = [];
3042
+ for (const [, client] of externalMCPSDKClients) {
3043
+ closePromises.push(client.close().catch(() => {
3044
+ // Ignore close errors - process is exiting anyway
3045
+ }));
3046
+ }
3047
+ // Wait for all clients to close (with timeout)
3048
+ if (closePromises.length > 0) {
3049
+ await Promise.race([
3050
+ Promise.all(closePromises),
3051
+ new Promise((resolve) => setTimeout(resolve, 1000)), // 1 second timeout
3052
+ ]);
3053
+ }
3054
+ externalMCPSDKClients.clear();
3055
+ externalMCPClients.clear();
3056
+ }
3057
+ //# sourceMappingURL=beam.js.map