@portel/photon 1.14.0 → 1.16.1

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 (125) hide show
  1. package/dist/auto-ui/beam/photon-management.d.ts +1 -1
  2. package/dist/auto-ui/beam/photon-management.d.ts.map +1 -1
  3. package/dist/auto-ui/beam/photon-management.js +5 -1
  4. package/dist/auto-ui/beam/photon-management.js.map +1 -1
  5. package/dist/auto-ui/beam/routes/api-config.d.ts.map +1 -1
  6. package/dist/auto-ui/beam/routes/api-config.js +31 -9
  7. package/dist/auto-ui/beam/routes/api-config.js.map +1 -1
  8. package/dist/auto-ui/beam/routes/api-marketplace.d.ts.map +1 -1
  9. package/dist/auto-ui/beam/routes/api-marketplace.js +3 -0
  10. package/dist/auto-ui/beam/routes/api-marketplace.js.map +1 -1
  11. package/dist/auto-ui/beam.d.ts.map +1 -1
  12. package/dist/auto-ui/beam.js +205 -56
  13. package/dist/auto-ui/beam.js.map +1 -1
  14. package/dist/auto-ui/bridge/index.d.ts.map +1 -1
  15. package/dist/auto-ui/bridge/index.js +578 -0
  16. package/dist/auto-ui/bridge/index.js.map +1 -1
  17. package/dist/auto-ui/bridge/renderers.d.ts.map +1 -1
  18. package/dist/auto-ui/bridge/renderers.js +7 -3
  19. package/dist/auto-ui/bridge/renderers.js.map +1 -1
  20. package/dist/auto-ui/bridge/types.d.ts +6 -0
  21. package/dist/auto-ui/bridge/types.d.ts.map +1 -1
  22. package/dist/auto-ui/frontend/pure-view.html +289 -0
  23. package/dist/auto-ui/photon-bridge.d.ts +11 -0
  24. package/dist/auto-ui/photon-bridge.d.ts.map +1 -1
  25. package/dist/auto-ui/photon-bridge.js +75 -1
  26. package/dist/auto-ui/photon-bridge.js.map +1 -1
  27. package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
  28. package/dist/auto-ui/streamable-http-transport.js +29 -3
  29. package/dist/auto-ui/streamable-http-transport.js.map +1 -1
  30. package/dist/beam-form.bundle.js +5707 -0
  31. package/dist/beam-form.bundle.js.map +7 -0
  32. package/dist/beam.bundle.js +2863 -895
  33. package/dist/beam.bundle.js.map +4 -4
  34. package/dist/claude-code-plugin.js +11 -3
  35. package/dist/claude-code-plugin.js.map +1 -1
  36. package/dist/cli/commands/build.js +1 -1
  37. package/dist/cli/commands/build.js.map +1 -1
  38. package/dist/cli/commands/doctor.d.ts.map +1 -1
  39. package/dist/cli/commands/doctor.js +7 -5
  40. package/dist/cli/commands/doctor.js.map +1 -1
  41. package/dist/cli/commands/info.d.ts.map +1 -1
  42. package/dist/cli/commands/info.js +18 -4
  43. package/dist/cli/commands/info.js.map +1 -1
  44. package/dist/cli/commands/mcp.js +2 -2
  45. package/dist/cli/commands/mcp.js.map +1 -1
  46. package/dist/cli/commands/update.d.ts.map +1 -1
  47. package/dist/cli/commands/update.js +6 -2
  48. package/dist/cli/commands/update.js.map +1 -1
  49. package/dist/cli-alias.d.ts.map +1 -1
  50. package/dist/cli-alias.js +3 -2
  51. package/dist/cli-alias.js.map +1 -1
  52. package/dist/daemon/client.d.ts +5 -0
  53. package/dist/daemon/client.d.ts.map +1 -1
  54. package/dist/daemon/client.js +50 -0
  55. package/dist/daemon/client.js.map +1 -1
  56. package/dist/daemon/manager.d.ts +15 -0
  57. package/dist/daemon/manager.d.ts.map +1 -1
  58. package/dist/daemon/manager.js +142 -11
  59. package/dist/daemon/manager.js.map +1 -1
  60. package/dist/daemon/worker-manager.js +1 -1
  61. package/dist/daemon/worker-manager.js.map +1 -1
  62. package/dist/deploy/cloudflare.d.ts.map +1 -1
  63. package/dist/deploy/cloudflare.js +12 -10
  64. package/dist/deploy/cloudflare.js.map +1 -1
  65. package/dist/loader.d.ts.map +1 -1
  66. package/dist/loader.js +37 -2
  67. package/dist/loader.js.map +1 -1
  68. package/dist/marketplace-manager.d.ts +9 -0
  69. package/dist/marketplace-manager.d.ts.map +1 -1
  70. package/dist/marketplace-manager.js +115 -42
  71. package/dist/marketplace-manager.js.map +1 -1
  72. package/dist/meta.d.ts +51 -0
  73. package/dist/meta.d.ts.map +1 -0
  74. package/dist/meta.js +320 -0
  75. package/dist/meta.js.map +1 -0
  76. package/dist/photon-cli-runner.d.ts.map +1 -1
  77. package/dist/photon-cli-runner.js +30 -5
  78. package/dist/photon-cli-runner.js.map +1 -1
  79. package/dist/photon-doc-extractor.d.ts +1 -0
  80. package/dist/photon-doc-extractor.d.ts.map +1 -1
  81. package/dist/photon-doc-extractor.js +33 -21
  82. package/dist/photon-doc-extractor.js.map +1 -1
  83. package/dist/photons/builder-compass.photon.d.ts +167 -0
  84. package/dist/photons/builder-compass.photon.d.ts.map +1 -0
  85. package/dist/photons/builder-compass.photon.js +816 -0
  86. package/dist/photons/builder-compass.photon.js.map +1 -0
  87. package/dist/photons/builder-compass.photon.ts +1129 -0
  88. package/dist/photons/docs/ui/docs.html +441 -0
  89. package/dist/photons/docs.photon.d.ts +237 -0
  90. package/dist/photons/docs.photon.d.ts.map +1 -0
  91. package/dist/photons/docs.photon.js +483 -0
  92. package/dist/photons/docs.photon.js.map +1 -0
  93. package/dist/photons/docs.photon.ts +536 -0
  94. package/dist/photons/maker.photon.d.ts.map +1 -1
  95. package/dist/photons/maker.photon.js +19 -2
  96. package/dist/photons/maker.photon.js.map +1 -1
  97. package/dist/photons/maker.photon.ts +18 -2
  98. package/dist/photons/slides.photon.d.ts +212 -0
  99. package/dist/photons/slides.photon.d.ts.map +1 -0
  100. package/dist/photons/slides.photon.js +355 -0
  101. package/dist/photons/slides.photon.js.map +1 -0
  102. package/dist/photons/slides.photon.ts +370 -0
  103. package/dist/photons/spreadsheet/ui/spreadsheet.html +779 -0
  104. package/dist/photons/spreadsheet.photon.d.ts +554 -0
  105. package/dist/photons/spreadsheet.photon.d.ts.map +1 -0
  106. package/dist/photons/spreadsheet.photon.js +1050 -0
  107. package/dist/photons/spreadsheet.photon.js.map +1 -0
  108. package/dist/photons/spreadsheet.photon.ts +1239 -0
  109. package/dist/photons/ui/builder-compass.html +1199 -0
  110. package/dist/photons/ui/builder-compass.photon.html +380 -0
  111. package/dist/server.d.ts.map +1 -1
  112. package/dist/server.js +33 -59
  113. package/dist/server.js.map +1 -1
  114. package/dist/shared/error-handler.d.ts +8 -0
  115. package/dist/shared/error-handler.d.ts.map +1 -1
  116. package/dist/shared/error-handler.js +50 -0
  117. package/dist/shared/error-handler.js.map +1 -1
  118. package/dist/shared-utils.d.ts +16 -2
  119. package/dist/shared-utils.d.ts.map +1 -1
  120. package/dist/shared-utils.js +37 -3
  121. package/dist/shared-utils.js.map +1 -1
  122. package/dist/template-manager.d.ts.map +1 -1
  123. package/dist/template-manager.js +2 -1
  124. package/dist/template-manager.js.map +1 -1
  125. package/package.json +8 -3
@@ -13,7 +13,7 @@ import * as path from 'path';
13
13
  import * as os from 'os';
14
14
  import { fileURLToPath } from 'url';
15
15
  import { createHash } from 'crypto';
16
- import { setSecurityHeaders, SimpleRateLimiter } from '../shared/security.js';
16
+ import { setSecurityHeaders, SimpleRateLimiter, escapeHtml } from '../shared/security.js';
17
17
  /**
18
18
  * Check if shell integration has been installed (photon init cli).
19
19
  * Cached at module load since it won't change during a Beam session.
@@ -94,7 +94,7 @@ import { logger, createLogger } from '../shared/logger.js';
94
94
  import { getErrorMessage } from '../shared/error-handler.js';
95
95
  import { toEnvVarName } from '../shared/config-docs.js';
96
96
  import { MarketplaceManager } from '../marketplace-manager.js';
97
- import { subscribeChannel } from '../daemon/client.js';
97
+ import { subscribeChannel, reloadDaemonPhoton } from '../daemon/client.js';
98
98
  import { ensurePhotonEditorDeclaration, writePhotonEditorDeclaration, } from '../photon-editor-declarations.js';
99
99
  import { ensureDaemon } from '../daemon/manager.js';
100
100
  import { SchemaExtractor, } from '@portel/photon-core';
@@ -144,6 +144,11 @@ const extractCspFromSource = extractCspFromModule;
144
144
  * Example: { "chat": ["mentions", "direct-messages"], "tasks": ["deadline", "assigned-to-me"] }
145
145
  */
146
146
  const photonNotificationSubscriptions = new Map();
147
+ /**
148
+ * Track which state-changed channels we've already subscribed to,
149
+ * so dynamically discovered photons can be subscribed without duplicates.
150
+ */
151
+ const subscribedStateChannels = new Set();
147
152
  /**
148
153
  * Generate the service worker JS that validates the Beam backend
149
154
  * on PWA launch and shows a diagnostic page if something is wrong.
@@ -192,6 +197,7 @@ self.addEventListener('fetch', (event) => {
192
197
  url.pathname.startsWith('/api/') ||
193
198
  url.pathname === '/sw.js' ||
194
199
  url.pathname === '/beam.bundle.js' ||
200
+ url.pathname === '/beam-form.bundle.js' ||
195
201
  url.pathname === '/beam-ts-worker.js'
196
202
  ) return;
197
203
 
@@ -479,18 +485,43 @@ export async function startBeam(rawWorkingDir, port) {
479
485
  logger.warn(`Asset repair check failed: ${getErrorMessage(error)}`);
480
486
  }
481
487
  // Discover all photons with namespace metadata (user photons + bundled photons)
482
- const userPhotonListDetailed = await listPhotonFilesWithNamespace(workingDir);
483
- // Detect name collisions to decide sidebar display names
488
+ const scannedPhotonList = await listPhotonFilesWithNamespace(workingDir);
489
+ // Deduplicate aliases that resolve to the same underlying file (for example,
490
+ // a marketplace photon and a local alias/symlink that both point at the same
491
+ // source). Beam should not show both copies in the sidebar.
492
+ const userPhotonListDetailed = [];
493
+ const seenPhotonKeys = new Set();
494
+ for (const photon of scannedPhotonList) {
495
+ let realPath = photon.filePath;
496
+ try {
497
+ realPath = realpathSync(photon.filePath);
498
+ }
499
+ catch {
500
+ // Fall back to the discovered path if realpath resolution fails.
501
+ }
502
+ const dedupeKey = `${photon.name}::${realPath}`;
503
+ if (seenPhotonKeys.has(dedupeKey))
504
+ continue;
505
+ seenPhotonKeys.add(dedupeKey);
506
+ userPhotonListDetailed.push(photon);
507
+ }
508
+ // Detect name collisions and generate friendly duplicate labels. We should
509
+ // never leak namespace/owner names into the sidebar just because there are
510
+ // multiple copies of a photon with the same short name.
484
511
  const nameOccurrences = new Map();
485
512
  for (const p of userPhotonListDetailed) {
486
513
  nameOccurrences.set(p.name, (nameOccurrences.get(p.name) || 0) + 1);
487
514
  }
488
- // Build photon list: use qualifiedName when collision, short name when unique
489
- // Also track resolved paths from namespace scan
515
+ // Build photon list with short names plus a numeric suffix for duplicates.
516
+ // Also track resolved paths from namespace scan.
490
517
  const namespacePaths = new Map(); // displayName → filePath
491
518
  const userPhotonList = [];
519
+ const duplicateIndex = new Map();
492
520
  for (const p of userPhotonListDetailed) {
493
- const displayName = (nameOccurrences.get(p.name) || 0) > 1 ? p.qualifiedName : p.name;
521
+ const duplicateCount = nameOccurrences.get(p.name) || 0;
522
+ const nextIndex = (duplicateIndex.get(p.name) || 0) + 1;
523
+ duplicateIndex.set(p.name, nextIndex);
524
+ const displayName = duplicateCount > 1 ? `${p.name} (${nextIndex})` : p.name;
494
525
  userPhotonList.push(displayName);
495
526
  namespacePaths.set(displayName, p.filePath);
496
527
  }
@@ -643,6 +674,25 @@ export async function startBeam(rawWorkingDir, port) {
643
674
  }
644
675
  // Get UI assets for linking
645
676
  const uiAssets = mcp.assets?.ui || [];
677
+ // If loader didn't resolve UI assets but source has @ui tags,
678
+ // extract them from source to populate linkedUi for sidebar
679
+ if (uiAssets.length === 0 && schemaSource) {
680
+ const uiTagRegex = /^\s*\*\s*@ui\s+(\S+)(?:\s+(\S+))?/gm;
681
+ let uiMatch;
682
+ const classUiMatch = schemaSource.match(/^\s*\*\s*@ui\s+(\S+)\s+(\.\/\S+)/m);
683
+ if (classUiMatch) {
684
+ // Class-level @ui tag with file path: @ui dashboard ./ui/dashboard.html
685
+ const uiId = classUiMatch[1]; // e.g., "dashboard"
686
+ // Add synthetic asset entry for all methods tagged with this @ui id
687
+ schemas.forEach((schema) => {
688
+ // Check if method has @ui tag matching this id
689
+ const methodSource = schemaSource.match(new RegExp(`@ui\\s+${uiId}[\\s\\n]*\\*/[\\s\\n]*(?:async\\s+)?${schema.name}\\s*\\(`, 'm'));
690
+ if (methodSource) {
691
+ uiAssets.push({ id: uiId, linkedTool: schema.name });
692
+ }
693
+ });
694
+ }
695
+ }
646
696
  // Filter out lifecycle methods
647
697
  const lifecycleMethods = ['onInitialize', 'onShutdown', 'constructor'];
648
698
  const methods = schemas
@@ -994,7 +1044,12 @@ export async function startBeam(rawWorkingDir, port) {
994
1044
  return configurePhotonViaMCP(photonName, config, photons, photonMCPs, loader, savedConfig, workingDir, activeLoads);
995
1045
  },
996
1046
  reloadPhoton: async (photonName) => {
997
- return reloadPhotonViaMCP(photonName, photons, photonMCPs, loader, savedConfig, broadcastPhotonChange, activeLoads);
1047
+ return reloadPhotonViaMCP(photonName, photons, photonMCPs, loader, savedConfig, broadcastPhotonChange, activeLoads, (name, path, isStateful) => {
1048
+ if (isStateful) {
1049
+ subscribeStatefulPhoton(name).catch(() => { });
1050
+ reloadDaemonPhoton(name, path, workingDir).catch(() => { });
1051
+ }
1052
+ });
998
1053
  },
999
1054
  removePhoton: async (photonName) => {
1000
1055
  return removePhotonViaMCP(photonName, photons, photonMCPs, savedConfig, broadcastPhotonChange, workingDir);
@@ -1174,7 +1229,24 @@ export async function startBeam(rawWorkingDir, port) {
1174
1229
  }
1175
1230
  catch {
1176
1231
  res.writeHead(404);
1177
- res.end('Bundle not found. Run npm run build:beam first.');
1232
+ res.end('Bundle not found. Run the beam build script first.');
1233
+ }
1234
+ return;
1235
+ }
1236
+ // Serve form components bundle (invoke-form + custom inputs for pure-view)
1237
+ if (url.pathname === '/beam-form.bundle.js') {
1238
+ try {
1239
+ const formBundlePath = path.join(__dirname, '../../dist/beam-form.bundle.js');
1240
+ const content = await fs.readFile(formBundlePath, 'utf-8');
1241
+ res.writeHead(200, {
1242
+ 'Content-Type': 'text/javascript',
1243
+ 'Cache-Control': 'no-cache',
1244
+ });
1245
+ res.end(content);
1246
+ }
1247
+ catch {
1248
+ res.writeHead(404);
1249
+ res.end('Form bundle not found. Run the beam build script first.');
1178
1250
  }
1179
1251
  return;
1180
1252
  }
@@ -1190,7 +1262,7 @@ export async function startBeam(rawWorkingDir, port) {
1190
1262
  }
1191
1263
  catch {
1192
1264
  res.writeHead(404);
1193
- res.end('TS worker not found. Run npm run build:beam first.');
1265
+ res.end('TS worker not found. Run the beam build script first.');
1194
1266
  }
1195
1267
  return;
1196
1268
  }
@@ -1637,6 +1709,43 @@ export async function startBeam(rawWorkingDir, port) {
1637
1709
  res.end(html);
1638
1710
  return;
1639
1711
  }
1712
+ // Pure view mode — serve lightweight bridge-powered page (no beam-app shell).
1713
+ // All view modes (form, result, embed) use pure-view.html with the bridge.
1714
+ // Form views lazy-load the form-components bundle for custom inputs.
1715
+ const viewParam = url.searchParams.get('view');
1716
+ if ((viewParam === 'result' || viewParam === 'embed' || viewParam === 'form') &&
1717
+ url.pathname !== '/' &&
1718
+ !url.pathname.startsWith('/api')) {
1719
+ try {
1720
+ const pathParts = url.pathname.split('/').filter(Boolean);
1721
+ const photonName = pathParts[0] || '';
1722
+ const methodName = pathParts[1] || '';
1723
+ const params = {};
1724
+ for (const [k, v] of url.searchParams) {
1725
+ if (k !== 'view')
1726
+ params[k] = v;
1727
+ }
1728
+ const pureViewPath = path.join(__dirname, 'frontend/pure-view.html');
1729
+ let html = await fs.readFile(pureViewPath, 'utf-8');
1730
+ // Replace placeholders
1731
+ const argsJson = escapeHtml(JSON.stringify(params));
1732
+ html = html
1733
+ .replaceAll('__PHOTON__', encodeURIComponent(photonName))
1734
+ .replaceAll('__METHOD__', escapeHtml(methodName))
1735
+ .replaceAll('__ARGS__', argsJson);
1736
+ // Add data-view for form and result modes
1737
+ if (viewParam === 'form' || viewParam === 'result') {
1738
+ html = html.replace('data-method=', `data-view="${escapeHtml(viewParam)}" data-method=`);
1739
+ }
1740
+ res.writeHead(200, { 'Content-Type': 'text/html' });
1741
+ res.end(html);
1742
+ }
1743
+ catch (err) {
1744
+ res.writeHead(500);
1745
+ res.end('Error serving pure view: ' + String(err));
1746
+ }
1747
+ return;
1748
+ }
1640
1749
  // Default route: Serve Lit App
1641
1750
  if (url.pathname === '/' || !url.pathname.startsWith('/api')) {
1642
1751
  try {
@@ -1646,6 +1755,8 @@ export async function startBeam(rawWorkingDir, port) {
1646
1755
  if (_shellIntegrationInstalled) {
1647
1756
  content = content.replace('</head>', '<script>window.__PHOTON_SHELL_INIT=true</script></head>');
1648
1757
  }
1758
+ // Note: ?view=form and ?view=result are handled by pure-view.html above.
1759
+ // This default route only serves the full Beam app for the main UI.
1649
1760
  res.writeHead(200, { 'Content-Type': 'text/html' });
1650
1761
  res.end(content);
1651
1762
  }
@@ -2049,6 +2160,16 @@ export async function startBeam(rawWorkingDir, port) {
2049
2160
  }
2050
2161
  // else: photon was removed while we were reloading — discard result
2051
2162
  }
2163
+ // Subscribe to state-changed events for newly discovered stateful photons
2164
+ if (isStateful) {
2165
+ subscribeStatefulPhoton(photonName).catch((err) => {
2166
+ logger.warn(`Failed to subscribe dynamically to ${photonName}: ${getErrorMessage(err)}`);
2167
+ });
2168
+ // Tell daemon to reload so its instance has the new methods/code
2169
+ reloadDaemonPhoton(photonName, photonPath, workingDir).catch((err) => {
2170
+ logger.debug(`Daemon reload for ${photonName}: ${getErrorMessage(err)}`);
2171
+ });
2172
+ }
2052
2173
  // If this photon is symlinked and was previously errored (or new), set up
2053
2174
  // source-directory watchers that may have been skipped at startup.
2054
2175
  if (isNewPhoton || !previouslyConfigured) {
@@ -2265,6 +2386,79 @@ export async function startBeam(rawWorkingDir, port) {
2265
2386
  startup.ready();
2266
2387
  // Notify connected clients that photon list is now available
2267
2388
  broadcastPhotonChange();
2389
+ // Subscribe a single stateful photon to its state-changed daemon channel.
2390
+ // Extracted so it can be called both at startup and when new photons are discovered.
2391
+ async function subscribeStatefulPhoton(photonName) {
2392
+ const instanceNames = ['default'];
2393
+ for (const instanceName of instanceNames) {
2394
+ const channel = `${photonName}:${instanceName}:state-changed`;
2395
+ if (subscribedStateChannels.has(channel))
2396
+ continue;
2397
+ subscribedStateChannels.add(channel);
2398
+ subscribeChannel(photonName, channel, (message) => {
2399
+ // Sync Beam's local instance from the daemon-persisted state file BEFORE
2400
+ // notifying the frontend. The daemon persists state to disk after mutations,
2401
+ // and Beam's local instance must match before _silentRefresh re-queries it.
2402
+ void (async () => {
2403
+ if (message?.patch?.length > 0) {
2404
+ const mcp = photonMCPs.get(photonName);
2405
+ if (mcp?.instance) {
2406
+ const instName = instanceName || 'default';
2407
+ const stateFile = path.join(workingDir, 'state', photonName, `${instName}.json`);
2408
+ try {
2409
+ const json = await fs.readFile(stateFile, 'utf-8');
2410
+ const state = JSON.parse(json);
2411
+ for (const [key, value] of Object.entries(state)) {
2412
+ const target = mcp.instance[key];
2413
+ if (target && typeof target.splice === 'function' && Array.isArray(value)) {
2414
+ target.splice(0, target.length, ...value);
2415
+ }
2416
+ else {
2417
+ mcp.instance[key] = value;
2418
+ }
2419
+ }
2420
+ }
2421
+ catch {
2422
+ // State file not yet written — ignore
2423
+ // State file not yet written — ignore
2424
+ }
2425
+ }
2426
+ }
2427
+ if (message?.instance === instanceName || !message?.instance) {
2428
+ broadcastNotification('state-changed', {
2429
+ photon: photonName,
2430
+ instance: instanceName,
2431
+ patches: message?.patches,
2432
+ method: message?.method,
2433
+ params: message?.params,
2434
+ data: message?.data,
2435
+ ...(message?.patch && { patch: message.patch }),
2436
+ ...(message?.inversePatch && { inversePatch: message.inversePatch }),
2437
+ });
2438
+ }
2439
+ })();
2440
+ }, {
2441
+ reconnect: true,
2442
+ workingDir,
2443
+ onReconnect: () => logger.debug(`📡 Reconnected ${channel} subscription`),
2444
+ onRefreshNeeded: () => {
2445
+ logger.info(`📡 Refresh needed for ${channel} (events lost during daemon restart)`);
2446
+ broadcastNotification('state-changed', {
2447
+ photon: photonName,
2448
+ instance: instanceName,
2449
+ method: '_refresh',
2450
+ patches: undefined,
2451
+ });
2452
+ },
2453
+ })
2454
+ .then(() => {
2455
+ logger.info(`📡 Subscribed to ${channel} for cross-client sync`);
2456
+ })
2457
+ .catch((err) => {
2458
+ logger.warn(`Failed to subscribe to ${channel}: ${getErrorMessage(err)}`);
2459
+ });
2460
+ }
2461
+ }
2268
2462
  // Auto-start daemon and subscribe to state-changed events for stateful photons
2269
2463
  // Uses reconnect: true so subscriptions survive daemon restarts
2270
2464
  const statefulPhotons = photons.filter((p) => p.stateful && p.configured);
@@ -2272,52 +2466,7 @@ export async function startBeam(rawWorkingDir, port) {
2272
2466
  try {
2273
2467
  await ensureDaemon();
2274
2468
  for (const photon of statefulPhotons) {
2275
- const photonName = photon.name;
2276
- // Subscribe to 'default' instance + any other instances that appear
2277
- const instanceNames = ['default'];
2278
- for (const instanceName of instanceNames) {
2279
- // Channel is now instance-specific: photon:instance:state-changed
2280
- const channel = `${photonName}:${instanceName}:state-changed`;
2281
- subscribeChannel(photonName, channel, (message) => {
2282
- // Only broadcast if instance matches (prevents cross-instance leakage)
2283
- if (message?.instance === instanceName || !message?.instance) {
2284
- // Minimal transmission: include instance and patches for global sync
2285
- broadcastNotification('state-changed', {
2286
- photon: photonName,
2287
- instance: instanceName,
2288
- // JSON Patch array for client-side state sync
2289
- patches: message?.patches,
2290
- // Keep legacy fields for backward compatibility
2291
- method: message?.method,
2292
- params: message?.params,
2293
- data: message?.data,
2294
- // Optional fields for undo/redo support
2295
- ...(message?.patch && { patch: message.patch }),
2296
- ...(message?.inversePatch && { inversePatch: message.inversePatch }),
2297
- });
2298
- }
2299
- }, {
2300
- reconnect: true,
2301
- workingDir,
2302
- onReconnect: () => logger.debug(`📡 Reconnected ${channel} subscription`),
2303
- onRefreshNeeded: () => {
2304
- logger.info(`📡 Refresh needed for ${channel} (events lost during daemon restart)`);
2305
- // Broadcast minimal refresh signal to all clients
2306
- broadcastNotification('state-changed', {
2307
- photon: photonName,
2308
- instance: instanceName,
2309
- method: '_refresh',
2310
- patches: undefined, // No patches, signal full refresh needed
2311
- });
2312
- },
2313
- })
2314
- .then(() => {
2315
- logger.info(`📡 Subscribed to ${channel} for cross-client sync`);
2316
- })
2317
- .catch((err) => {
2318
- logger.warn(`Failed to subscribe to ${channel}: ${getErrorMessage(err)}`);
2319
- });
2320
- }
2469
+ await subscribeStatefulPhoton(photon.name);
2321
2470
  }
2322
2471
  }
2323
2472
  catch (err) {