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