@portel/photon 1.10.0 → 1.12.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 (56) hide show
  1. package/README.md +81 -72
  2. package/dist/auto-ui/beam/photon-management.d.ts.map +1 -1
  3. package/dist/auto-ui/beam/photon-management.js +5 -0
  4. package/dist/auto-ui/beam/photon-management.js.map +1 -1
  5. package/dist/auto-ui/beam/routes/api-browse.d.ts +1 -2
  6. package/dist/auto-ui/beam/routes/api-browse.d.ts.map +1 -1
  7. package/dist/auto-ui/beam/routes/api-browse.js +140 -191
  8. package/dist/auto-ui/beam/routes/api-browse.js.map +1 -1
  9. package/dist/auto-ui/beam/routes/api-config.d.ts.map +1 -1
  10. package/dist/auto-ui/beam/routes/api-config.js +44 -1
  11. package/dist/auto-ui/beam/routes/api-config.js.map +1 -1
  12. package/dist/auto-ui/beam.d.ts.map +1 -1
  13. package/dist/auto-ui/beam.js +874 -20
  14. package/dist/auto-ui/beam.js.map +1 -1
  15. package/dist/auto-ui/frontend/index.html +83 -60
  16. package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
  17. package/dist/auto-ui/streamable-http-transport.js +16 -2
  18. package/dist/auto-ui/streamable-http-transport.js.map +1 -1
  19. package/dist/auto-ui/types.d.ts +1 -1
  20. package/dist/auto-ui/types.d.ts.map +1 -1
  21. package/dist/auto-ui/types.js.map +1 -1
  22. package/dist/beam.bundle.js +2836 -357
  23. package/dist/beam.bundle.js.map +4 -4
  24. package/dist/cli/commands/package-app.d.ts.map +1 -1
  25. package/dist/cli/commands/package-app.js +116 -35
  26. package/dist/cli/commands/package-app.js.map +1 -1
  27. package/dist/context-store.d.ts +5 -0
  28. package/dist/context-store.d.ts.map +1 -1
  29. package/dist/context-store.js +9 -0
  30. package/dist/context-store.js.map +1 -1
  31. package/dist/daemon/server.js +303 -6
  32. package/dist/daemon/server.js.map +1 -1
  33. package/dist/loader.d.ts +21 -0
  34. package/dist/loader.d.ts.map +1 -1
  35. package/dist/loader.js +277 -0
  36. package/dist/loader.js.map +1 -1
  37. package/dist/photon-cli-runner.d.ts.map +1 -1
  38. package/dist/photon-cli-runner.js +21 -1
  39. package/dist/photon-cli-runner.js.map +1 -1
  40. package/dist/photon-doc-extractor.d.ts +6 -0
  41. package/dist/photon-doc-extractor.d.ts.map +1 -1
  42. package/dist/photon-doc-extractor.js +22 -0
  43. package/dist/photon-doc-extractor.js.map +1 -1
  44. package/dist/photons/tunnel.photon.d.ts +5 -9
  45. package/dist/photons/tunnel.photon.d.ts.map +1 -1
  46. package/dist/photons/tunnel.photon.js +36 -96
  47. package/dist/photons/tunnel.photon.js.map +1 -1
  48. package/dist/photons/tunnel.photon.ts +40 -112
  49. package/dist/server.d.ts.map +1 -1
  50. package/dist/server.js +27 -2
  51. package/dist/server.js.map +1 -1
  52. package/dist/test-runner.d.ts +13 -1
  53. package/dist/test-runner.d.ts.map +1 -1
  54. package/dist/test-runner.js +529 -122
  55. package/dist/test-runner.js.map +1 -1
  56. package/package.json +22 -6
@@ -69,6 +69,318 @@ const backfillEnvDefaults = backfillEnvDefaultsFromModule;
69
69
  const extractClassMetadataFromSource = extractClassMetadataFromModule;
70
70
  const applyMethodVisibility = applyMethodVisibilityFromModule;
71
71
  const extractCspFromSource = extractCspFromModule;
72
+ // ═══════════════════════════════════════════════════════════════════════════════
73
+ // NOTIFICATION SUBSCRIPTIONS
74
+ // ═══════════════════════════════════════════════════════════════════════════════
75
+ /**
76
+ * Map to store notification subscriptions per photon
77
+ * Key: photon name, Value: list of event types this photon cares about
78
+ * Example: { "chat": ["mentions", "direct-messages"], "tasks": ["deadline", "assigned-to-me"] }
79
+ */
80
+ const photonNotificationSubscriptions = new Map();
81
+ /**
82
+ * Generate the service worker JS that validates the Beam backend
83
+ * on PWA launch and shows a diagnostic page if something is wrong.
84
+ */
85
+ function generateServiceWorker(workingDir) {
86
+ return `
87
+ // Photon Beam Service Worker
88
+ // Validates the backend is running and healthy before serving the app.
89
+ const CACHE_NAME = 'photon-pwa-v1';
90
+ const EXPECTED_WORKING_DIR = ${JSON.stringify(workingDir)};
91
+ const HEALTH_ENDPOINT = '/api/diagnostics';
92
+
93
+ // Cache the boot page on install
94
+ self.addEventListener('install', (event) => {
95
+ event.waitUntil(
96
+ caches.open(CACHE_NAME).then((cache) => cache.put('/_pwa_boot', new Response(BOOT_PAGE, {
97
+ headers: { 'Content-Type': 'text/html' }
98
+ }))).then(() => self.skipWaiting())
99
+ );
100
+ });
101
+
102
+ self.addEventListener('activate', (event) => {
103
+ event.waitUntil(self.clients.claim());
104
+ });
105
+
106
+ self.addEventListener('fetch', (event) => {
107
+ const url = new URL(event.request.url);
108
+
109
+ // PWA icon PNG generation — intercept /api/pwa/icon-png requests and render
110
+ // the SVG icon onto OffscreenCanvas, returning a real PNG response that
111
+ // satisfies Chrome's installability requirement for raster icons.
112
+ if (url.pathname === '/api/pwa/icon-png') {
113
+ event.respondWith(handleIconPng(url));
114
+ return;
115
+ }
116
+
117
+ // Only intercept navigation requests (page loads, not API/asset fetches)
118
+ if (event.request.mode !== 'navigate') return;
119
+
120
+ // Skip API routes and static assets — let them pass through
121
+ if (url.pathname.startsWith('/api/') || url.pathname === '/sw.js' || url.pathname === '/beam.bundle.js') return;
122
+
123
+ // All navigation requests go through health check
124
+ event.respondWith(handlePWANavigation(event.request));
125
+ });
126
+
127
+ async function handlePWANavigation(request) {
128
+ try {
129
+ // Try to reach the backend
130
+ const healthRes = await fetch(HEALTH_ENDPOINT, { signal: AbortSignal.timeout(3000) });
131
+ if (!healthRes.ok) throw new Error('Health check failed');
132
+
133
+ const health = await healthRes.json();
134
+
135
+ // Validate this is actually Beam serving the expected directory
136
+ if (!health.photonVersion) {
137
+ return serveBoot('wrong-service', JSON.stringify(health));
138
+ }
139
+ if (health.workingDir !== EXPECTED_WORKING_DIR) {
140
+ return serveBoot('wrong-directory', JSON.stringify({
141
+ expected: EXPECTED_WORKING_DIR,
142
+ actual: health.workingDir
143
+ }));
144
+ }
145
+
146
+ // Backend is healthy and correct — serve the real page
147
+ return fetch(request);
148
+ } catch (err) {
149
+ // Backend is unreachable
150
+ return serveBoot('not-running', err.message);
151
+ }
152
+ }
153
+
154
+ async function handleIconPng(url) {
155
+ const photon = url.searchParams.get('photon') || '';
156
+ const size = parseInt(url.searchParams.get('size') || '192', 10);
157
+ const cacheKey = '/_pwa_icon_' + photon + '_' + size;
158
+
159
+ // Check cache first
160
+ const cache = await caches.open(CACHE_NAME);
161
+ const cached = await cache.match(cacheKey);
162
+ if (cached) return cached;
163
+
164
+ try {
165
+ // Fetch the icon from the server (may be SVG, PNG, JPEG, etc.)
166
+ const iconRes = await fetch('/api/pwa/icon?photon=' + encodeURIComponent(photon), { signal: AbortSignal.timeout(10000) });
167
+ if (!iconRes.ok) throw new Error('Icon fetch failed: ' + iconRes.status);
168
+
169
+ const contentType = (iconRes.headers.get('Content-Type') || '').toLowerCase();
170
+
171
+ // For raster images (PNG, JPEG, WebP), resize via OffscreenCanvas if needed
172
+ // For SVG or emoji-generated SVG, render to canvas at target size
173
+ let bmp;
174
+ if (contentType.includes('svg')) {
175
+ // SVG (emoji-generated or file) — parse as text, create bitmap
176
+ const svgText = await iconRes.text();
177
+ const svgBlob = new Blob([svgText], { type: 'image/svg+xml' });
178
+ bmp = await createImageBitmap(svgBlob, { resizeWidth: size, resizeHeight: size });
179
+ } else {
180
+ // Raster image (PNG, JPEG, WebP) — decode directly
181
+ const imgBlob = await iconRes.blob();
182
+ bmp = await createImageBitmap(imgBlob, { resizeWidth: size, resizeHeight: size });
183
+ }
184
+
185
+ const canvas = new OffscreenCanvas(size, size);
186
+ const ctx = canvas.getContext('2d');
187
+
188
+ // Dark rounded-rect background
189
+ ctx.fillStyle = '#1a1a1a';
190
+ const r = size * 0.2;
191
+ ctx.beginPath();
192
+ ctx.moveTo(r, 0);
193
+ ctx.lineTo(size - r, 0);
194
+ ctx.quadraticCurveTo(size, 0, size, r);
195
+ ctx.lineTo(size, size - r);
196
+ ctx.quadraticCurveTo(size, size, size - r, size);
197
+ ctx.lineTo(r, size);
198
+ ctx.quadraticCurveTo(0, size, 0, size - r);
199
+ ctx.lineTo(0, r);
200
+ ctx.quadraticCurveTo(0, 0, r, 0);
201
+ ctx.closePath();
202
+ ctx.fill();
203
+
204
+ // Draw the icon
205
+ ctx.drawImage(bmp, 0, 0, size, size);
206
+
207
+ const pngBlob = await canvas.convertToBlob({ type: 'image/png' });
208
+ const pngResponse = new Response(pngBlob, {
209
+ headers: {
210
+ 'Content-Type': 'image/png',
211
+ 'Cache-Control': 'public, max-age=86400'
212
+ }
213
+ });
214
+
215
+ // Cache the generated PNG
216
+ await cache.put(cacheKey, pngResponse.clone());
217
+ return pngResponse;
218
+ } catch (err) {
219
+ // Fallback: generate a simple colored square with initial letter
220
+ const canvas = new OffscreenCanvas(size, size);
221
+ const ctx = canvas.getContext('2d');
222
+ ctx.fillStyle = '#1a1a1a';
223
+ ctx.fillRect(0, 0, size, size);
224
+ ctx.fillStyle = '#4ade80';
225
+ ctx.font = (size * 0.4) + 'px sans-serif';
226
+ ctx.textAlign = 'center';
227
+ ctx.textBaseline = 'middle';
228
+ ctx.fillText(photon.charAt(0).toUpperCase() || 'P', size / 2, size / 2);
229
+ const pngBlob = await canvas.convertToBlob({ type: 'image/png' });
230
+ return new Response(pngBlob, {
231
+ headers: { 'Content-Type': 'image/png' }
232
+ });
233
+ }
234
+ }
235
+
236
+ async function serveBoot(reason, detail) {
237
+ const cache = await caches.open(CACHE_NAME);
238
+ const cached = await cache.match('/_pwa_boot');
239
+ if (cached) {
240
+ const html = await cached.text();
241
+ const injected = html
242
+ .replace('__BOOT_REASON__', reason)
243
+ .replace('__BOOT_DETAIL__', detail || '')
244
+ .replace('__EXPECTED_DIR__', EXPECTED_WORKING_DIR);
245
+ return new Response(injected, { headers: { 'Content-Type': 'text/html' } });
246
+ }
247
+ return new Response('Photon Beam is not available. Run: photon beam', {
248
+ status: 503, headers: { 'Content-Type': 'text/plain' }
249
+ });
250
+ }
251
+
252
+ const BOOT_PAGE = \`<!DOCTYPE html>
253
+ <html lang="en">
254
+ <head>
255
+ <meta charset="UTF-8">
256
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
257
+ <title>Photon Beam</title>
258
+ <meta name="theme-color" content="#1a1a1a">
259
+ <style>
260
+ * { margin: 0; padding: 0; box-sizing: border-box; }
261
+ body {
262
+ min-height: 100vh; display: flex; align-items: center; justify-content: center;
263
+ background: #1a1a1a; color: #e5e5e5; font-family: system-ui, -apple-system, sans-serif;
264
+ }
265
+ .container { text-align: center; padding: 40px; max-width: 500px; }
266
+ .icon { font-size: 56px; margin-bottom: 24px; }
267
+ h1 { font-size: 22px; font-weight: 600; margin-bottom: 12px; color: #fff; }
268
+ .message { font-size: 15px; line-height: 1.6; color: #999; margin-bottom: 28px; }
269
+ .command {
270
+ display: inline-block; background: #2a2a2a; border: 1px solid #333;
271
+ padding: 10px 20px; border-radius: 8px; font-family: 'JetBrains Mono', monospace;
272
+ font-size: 14px; color: #4ade80; margin-bottom: 20px; user-select: all;
273
+ }
274
+ .detail {
275
+ font-size: 12px; color: #666; font-family: 'JetBrains Mono', monospace;
276
+ background: #222; border-radius: 6px; padding: 10px; margin-bottom: 20px;
277
+ word-break: break-all; display: none;
278
+ }
279
+ .detail.show { display: block; }
280
+ .retry {
281
+ padding: 10px 24px; background: #333; border: 1px solid #444; border-radius: 8px;
282
+ color: #fff; cursor: pointer; font-size: 14px; transition: background 0.2s;
283
+ }
284
+ .retry:hover { background: #444; }
285
+ .spinner {
286
+ display: none; width: 20px; height: 20px; border: 2px solid #444;
287
+ border-top-color: #4ade80; border-radius: 50%; animation: spin 0.8s linear infinite;
288
+ margin: 0 auto 16px;
289
+ }
290
+ .spinner.show { display: block; }
291
+ @keyframes spin { to { transform: rotate(360deg); } }
292
+ </style>
293
+ </head>
294
+ <body>
295
+ <div class="container">
296
+ <div class="spinner" id="spinner"></div>
297
+ <div id="content">
298
+ <div class="icon" id="icon"></div>
299
+ <h1 id="title"></h1>
300
+ <p class="message" id="message"></p>
301
+ <div class="detail" id="detail"></div>
302
+ <code class="command" id="command" style="display:none"></code>
303
+ <br><br>
304
+ <button class="retry" onclick="checkAndRetry()">Retry</button>
305
+ </div>
306
+ </div>
307
+ <script>
308
+ const reason = '__BOOT_REASON__';
309
+ const detail = '__BOOT_DETAIL__';
310
+ const expectedDir = '__EXPECTED_DIR__';
311
+
312
+ const states = {
313
+ 'not-running': {
314
+ icon: '\\u26a1',
315
+ title: 'Beam is not running',
316
+ message: 'Start Photon Beam to use this app:',
317
+ command: 'photon beam'
318
+ },
319
+ 'wrong-service': {
320
+ icon: '\\u26a0\\ufe0f',
321
+ title: 'Port is in use by another service',
322
+ message: 'Something else is running on this port. Stop the other service or reconfigure Beam:',
323
+ command: 'photon beam --port <available-port>',
324
+ showDetail: true
325
+ },
326
+ 'wrong-directory': {
327
+ icon: '\\ud83d\\udcc1',
328
+ title: 'Beam is serving a different project',
329
+ message: 'Beam is running but pointing to a different directory. Start it with the correct path:',
330
+ command: 'photon beam ' + expectedDir,
331
+ showDetail: true
332
+ }
333
+ };
334
+
335
+ function render() {
336
+ const s = states[reason] || states['not-running'];
337
+ document.getElementById('icon').textContent = s.icon;
338
+ document.getElementById('title').textContent = s.title;
339
+ document.getElementById('message').textContent = s.message;
340
+ const cmdEl = document.getElementById('command');
341
+ cmdEl.textContent = s.command;
342
+ cmdEl.style.display = 'inline-block';
343
+ if (s.showDetail && detail) {
344
+ const el = document.getElementById('detail');
345
+ el.textContent = detail;
346
+ el.classList.add('show');
347
+ }
348
+ }
349
+
350
+ async function checkAndRetry() {
351
+ document.getElementById('content').style.display = 'none';
352
+ document.getElementById('spinner').classList.add('show');
353
+ try {
354
+ const res = await fetch('/api/diagnostics', { signal: AbortSignal.timeout(3000) });
355
+ if (res.ok) {
356
+ const h = await res.json();
357
+ if (h.photonVersion && h.workingDir === expectedDir) {
358
+ location.reload();
359
+ return;
360
+ }
361
+ }
362
+ } catch {}
363
+ document.getElementById('spinner').classList.remove('show');
364
+ document.getElementById('content').style.display = '';
365
+ render();
366
+ }
367
+
368
+ render();
369
+ // Auto-retry every 5 seconds
370
+ setInterval(async () => {
371
+ try {
372
+ const res = await fetch('/api/diagnostics', { signal: AbortSignal.timeout(2000) });
373
+ if (res.ok) {
374
+ const h = await res.json();
375
+ if (h.photonVersion && h.workingDir === expectedDir) location.reload();
376
+ }
377
+ } catch {}
378
+ }, 5000);
379
+ </script>
380
+ </body>
381
+ </html>\`;
382
+ `;
383
+ }
72
384
  export async function startBeam(rawWorkingDir, port) {
73
385
  const workingDir = path.resolve(rawWorkingDir);
74
386
  const { PHOTON_VERSION } = await import('../version.js');
@@ -229,8 +541,18 @@ export async function startBeam(rawWorkingDir, port) {
229
541
  backfillEnvDefaults(instance, constructorParams);
230
542
  // Extract schema for UI — reuse source read from above
231
543
  const schemaSource = source || (await fs.readFile(photonPath, 'utf-8'));
232
- const { tools: schemas, templates } = extractor.extractAllFromSource(schemaSource);
544
+ const metadata = extractor.extractAllFromSource(schemaSource);
545
+ const schemas = metadata.tools;
546
+ const templates = metadata.templates;
233
547
  mcp.schemas = schemas;
548
+ // Store notification subscriptions per photon
549
+ if (metadata.notificationSubscriptions?.watchFor) {
550
+ photonNotificationSubscriptions.set(name, metadata.notificationSubscriptions.watchFor);
551
+ }
552
+ else {
553
+ // Clear previous subscription if photon no longer has @notify-on
554
+ photonNotificationSubscriptions.delete(name);
555
+ }
234
556
  // Get UI assets for linking
235
557
  const uiAssets = mcp.assets?.ui || [];
236
558
  // Filter out lifecycle methods
@@ -536,12 +858,25 @@ export async function startBeam(rawWorkingDir, port) {
536
858
  if (await handleConfigRoutes(req, res, url, beamState))
537
859
  return;
538
860
  }
861
+ // Service worker for PWA support
862
+ if (url.pathname === '/sw.js') {
863
+ res.writeHead(200, {
864
+ 'Content-Type': 'application/javascript',
865
+ 'Service-Worker-Allowed': '/',
866
+ 'Cache-Control': 'no-cache',
867
+ });
868
+ res.end(generateServiceWorker(beamState.workingDir));
869
+ return;
870
+ }
539
871
  // Serve static frontend bundle
540
872
  if (url.pathname === '/beam.bundle.js') {
541
873
  try {
542
874
  const bundlePath = path.join(__dirname, '../../dist/beam.bundle.js');
543
875
  const content = await fs.readFile(bundlePath, 'utf-8');
544
- res.writeHead(200, { 'Content-Type': 'text/javascript' });
876
+ res.writeHead(200, {
877
+ 'Content-Type': 'text/javascript',
878
+ 'Cache-Control': 'no-cache',
879
+ });
545
880
  res.end(content);
546
881
  }
547
882
  catch {
@@ -550,6 +885,449 @@ export async function startBeam(rawWorkingDir, port) {
550
885
  }
551
886
  return;
552
887
  }
888
+ // Standalone PWA app route: /app/{photonName}
889
+ // Full-featured PWA host shell with diagnostics, postMessage bridge, service worker, and install prompt
890
+ const appMatch = url.pathname.match(/^\/app\/([^/]+)$/);
891
+ if (appMatch) {
892
+ const photonName = appMatch[1];
893
+ const photon = beamState.photons.find((p) => p.name === photonName);
894
+ if (!photon) {
895
+ res.writeHead(404);
896
+ res.end(`Photon not found: ${photonName}`);
897
+ return;
898
+ }
899
+ const label = photon?.label ||
900
+ photonName.charAt(0).toUpperCase() + photonName.slice(1).replace(/-/g, ' ');
901
+ const description = photon?.description || `${label} - Photon App`;
902
+ const iconValue = photon?.icon || '📦';
903
+ const encodedName = encodeURIComponent(photonName);
904
+ // Sanitize strings for safe embedding in HTML
905
+ const safeLabel = label.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c] || c);
906
+ const safeDesc = description.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c] || c);
907
+ const html = `<!DOCTYPE html>
908
+ <html lang="en">
909
+ <head>
910
+ <meta charset="UTF-8">
911
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
912
+ <title>${safeLabel}</title>
913
+ <meta name="description" content="${safeDesc}">
914
+ <meta name="theme-color" content="#1a1a1a">
915
+ <meta name="apple-mobile-web-app-capable" content="yes">
916
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
917
+ <meta name="apple-mobile-web-app-title" content="${safeLabel}">
918
+ <link rel="manifest" href="/api/pwa/manifest.json?photon=${encodedName}">
919
+ <link rel="apple-touch-icon" href="/api/pwa/icon?photon=${encodedName}">
920
+ <style>
921
+ * { margin: 0; padding: 0; box-sizing: border-box; }
922
+ html, body { width: 100%; height: 100%; overflow: hidden; background: #1a1a1a; font-family: system-ui, -apple-system, sans-serif; color: #e5e5e5; }
923
+ #app { width: 100%; height: 100vh; }
924
+ iframe { width: 100%; height: 100vh; border: none; display: block; }
925
+
926
+ .status-page {
927
+ display: none; width: 100%; height: 100vh;
928
+ flex-direction: column; align-items: center; justify-content: center;
929
+ text-align: center; padding: 40px;
930
+ }
931
+ .status-page.show { display: flex; }
932
+ .status-page .icon { font-size: 64px; margin-bottom: 24px; }
933
+ .status-page h2 { font-size: 20px; font-weight: 600; margin-bottom: 12px; color: #e5e5e5; }
934
+ .status-page p { font-size: 14px; color: #888; max-width: 400px; line-height: 1.6; margin-bottom: 8px; }
935
+ .status-page code {
936
+ display: inline-block; background: #2a2a2a; padding: 8px 16px; border-radius: 6px;
937
+ font-size: 13px; color: #4ade80; font-family: 'SF Mono', Monaco, monospace; margin-top: 8px;
938
+ }
939
+ .status-page .spinner {
940
+ width: 24px; height: 24px; border: 2px solid #333; border-top-color: #4ade80;
941
+ border-radius: 50%; animation: spin 0.8s linear infinite; margin-bottom: 16px;
942
+ }
943
+ @keyframes spin { to { transform: rotate(360deg); } }
944
+ .status-page .retry-btn {
945
+ margin-top: 16px; padding: 8px 20px; background: #333; border: 1px solid #444;
946
+ border-radius: 6px; color: #e5e5e5; cursor: pointer; font-size: 13px; font-family: inherit;
947
+ }
948
+ .status-page .retry-btn:hover { background: #444; }
949
+
950
+ </style>
951
+ </head>
952
+ <body>
953
+ <!-- Status: Starting (shown while waiting for Beam) -->
954
+ <div id="status-starting" class="status-page">
955
+ <div class="spinner"></div>
956
+ <h2>Starting ${safeLabel}...</h2>
957
+ <p>Waiting for Beam server</p>
958
+ </div>
959
+
960
+ <!-- Status: Not running (shown when Beam is down) -->
961
+ <div id="status-offline" class="status-page">
962
+ <div class="icon">${iconValue}</div>
963
+ <h2>${safeLabel}</h2>
964
+ <p>Server is not running. Start Photon to use this app:</p>
965
+ <code>photon beam</code>
966
+ <button class="retry-btn" onclick="checkAndLoad()">Retry</button>
967
+ </div>
968
+
969
+ <!-- Status: Port conflict -->
970
+ <div id="status-conflict" class="status-page">
971
+ <div class="icon">⚠️</div>
972
+ <h2>Port Conflict</h2>
973
+ <p id="conflict-msg">Another process is using the required port.</p>
974
+ <code id="conflict-cmd"></code>
975
+ <button class="retry-btn" onclick="checkAndLoad()">Retry</button>
976
+ </div>
977
+
978
+ <!-- App container with iframe -->
979
+ <div id="app" style="display:none"></div>
980
+
981
+ <script>
982
+ const PHOTON = ${JSON.stringify(photonName)};
983
+ const appEl = document.getElementById('app');
984
+ const statusStarting = document.getElementById('status-starting');
985
+ const statusOffline = document.getElementById('status-offline');
986
+ const statusConflict = document.getElementById('status-conflict');
987
+ let retryTimer = null;
988
+
989
+ function hideAll() {
990
+ statusStarting.classList.remove('show');
991
+ statusOffline.classList.remove('show');
992
+ statusConflict.classList.remove('show');
993
+ appEl.style.display = 'none';
994
+ }
995
+
996
+ // Diagnostics-first loading: check server health before loading the app
997
+ async function checkAndLoad() {
998
+ if (retryTimer) { clearInterval(retryTimer); retryTimer = null; }
999
+ hideAll();
1000
+ statusStarting.classList.add('show');
1001
+
1002
+ try {
1003
+ const res = await fetch('/api/diagnostics', { signal: AbortSignal.timeout(5000) });
1004
+ if (!res.ok) throw new Error('Server error');
1005
+ const diag = await res.json();
1006
+
1007
+ // Check for port conflicts
1008
+ if (diag.portConflict) {
1009
+ hideAll();
1010
+ statusConflict.classList.add('show');
1011
+ const conflictMsg = document.getElementById('conflict-msg');
1012
+ const conflictCmd = document.getElementById('conflict-cmd');
1013
+ if (diag.portConflict.port) {
1014
+ conflictMsg.textContent = 'Port ' + diag.portConflict.port + ' is in use by another process.';
1015
+ }
1016
+ if (diag.portConflict.pid) {
1017
+ conflictCmd.textContent = 'kill ' + diag.portConflict.pid;
1018
+ }
1019
+ return;
1020
+ }
1021
+
1022
+ // Server is healthy — establish MCP session then load the app
1023
+ hideAll();
1024
+ appEl.style.display = 'block';
1025
+ await connectSSE();
1026
+ await loadApp();
1027
+ } catch (err) {
1028
+ // Server unreachable — show offline state with auto-retry
1029
+ hideAll();
1030
+ statusOffline.classList.add('show');
1031
+ retryTimer = setInterval(async () => {
1032
+ try {
1033
+ const res = await fetch('/api/diagnostics', { signal: AbortSignal.timeout(3000) });
1034
+ if (res.ok) {
1035
+ clearInterval(retryTimer);
1036
+ retryTimer = null;
1037
+ checkAndLoad();
1038
+ }
1039
+ } catch { /* still offline */ }
1040
+ }, 3000);
1041
+ }
1042
+ }
1043
+
1044
+ // Load the @ui template into a full-viewport iframe with platform bridge.
1045
+ // Discovers template URL client-side: tries class-level @ui first (/api/template),
1046
+ // then falls back to method-level @ui by querying diagnostics for the app entry's linkedUi.
1047
+ async function loadApp() {
1048
+ try {
1049
+ // Step 1: Discover the template URL
1050
+ let templateUrl = '/api/template?photon=' + encodeURIComponent(PHOTON);
1051
+ let bridgeMethod = 'main';
1052
+
1053
+ // Try class-level @ui first
1054
+ let templateRes = await fetch(templateUrl, { signal: AbortSignal.timeout(10000) });
1055
+ if (!templateRes.ok) {
1056
+ // Fall back: query diagnostics for this photon's appEntry linkedUi
1057
+ const diagRes = await fetch('/api/diagnostics', { signal: AbortSignal.timeout(10000) });
1058
+ if (diagRes.ok) {
1059
+ const diag = await diagRes.json();
1060
+ const photonInfo = (diag.photons || []).find(function(p) { return p.name === PHOTON; });
1061
+ if (photonInfo && photonInfo.appEntry && photonInfo.appEntry.linkedUi) {
1062
+ templateUrl = '/api/ui?photon=' + encodeURIComponent(PHOTON) + '&id=' + encodeURIComponent(photonInfo.appEntry.linkedUi);
1063
+ bridgeMethod = photonInfo.appEntry.name || 'main';
1064
+ templateRes = await fetch(templateUrl, { signal: AbortSignal.timeout(10000) });
1065
+ }
1066
+ }
1067
+ if (!templateRes.ok) throw new Error('Template not available');
1068
+ }
1069
+
1070
+ // Step 2: Fetch platform bridge script
1071
+ const bridgeRes = await fetch('/api/platform-bridge?photon=' + encodeURIComponent(PHOTON) + '&method=' + encodeURIComponent(bridgeMethod) + '&theme=dark', { signal: AbortSignal.timeout(10000) });
1072
+
1073
+ let templateHtml = await templateRes.text();
1074
+ const bridgeScript = bridgeRes.ok ? await bridgeRes.text() : '';
1075
+
1076
+ // Inject platform bridge before </head>
1077
+ if (templateHtml.includes('</head>')) {
1078
+ templateHtml = templateHtml.replace('</head>', bridgeScript + '</head>');
1079
+ } else {
1080
+ templateHtml = '<html><head>' + bridgeScript + '</head><body>' + templateHtml + '</body></html>';
1081
+ }
1082
+
1083
+ const iframe = document.createElement('iframe');
1084
+ iframe.setAttribute('sandbox', 'allow-scripts allow-forms allow-same-origin allow-popups allow-modals');
1085
+
1086
+ const blob = new Blob([templateHtml], { type: 'text/html' });
1087
+ iframe.src = URL.createObjectURL(blob);
1088
+
1089
+ appEl.innerHTML = '';
1090
+ appEl.appendChild(iframe);
1091
+ initBridge(iframe, bridgeMethod);
1092
+ } catch (err) {
1093
+ appEl.innerHTML = '<div class="status-page show"><div class="icon">⚠️</div>'
1094
+ + '<h2>Failed to load</h2><p>' + err.message + '</p>'
1095
+ + '<button class="retry-btn" onclick="checkAndLoad()">Retry</button></div>';
1096
+ }
1097
+ }
1098
+
1099
+ // postMessage bridge: relays JSON-RPC from iframe through MCP for full event pipeline
1100
+ var mcpSessionId = null;
1101
+ var mcpCallId = 100;
1102
+
1103
+ function initBridge(iframe, bridgeMethod) {
1104
+ window.addEventListener('message', async (e) => {
1105
+ const msg = e.data;
1106
+ if (!msg || typeof msg !== 'object') return;
1107
+
1108
+ // Handle MCP Apps ui/initialize request — respond so bridge sends initialized
1109
+ if (msg.jsonrpc === '2.0' && msg.method === 'ui/initialize' && msg.id != null) {
1110
+ iframe.contentWindow.postMessage({
1111
+ jsonrpc: '2.0',
1112
+ id: msg.id,
1113
+ result: {
1114
+ protocolVersion: '2026-01-26',
1115
+ hostInfo: { name: 'photon-pwa', version: '1.0.0' },
1116
+ hostCapabilities: {},
1117
+ hostContext: { theme: 'dark' },
1118
+ },
1119
+ }, '*');
1120
+ return;
1121
+ }
1122
+
1123
+ // Handle JSON-RPC tools/call from iframe — route through MCP for event broadcasting
1124
+ if (msg.jsonrpc === '2.0' && msg.method === 'tools/call' && msg.id != null) {
1125
+ const { name: toolName, arguments: toolArgs } = msg.params || {};
1126
+ // Prefix with photon name if not already prefixed (bridge sends bare method names)
1127
+ var fullToolName = toolName.includes('/') ? toolName : PHOTON + '/' + toolName;
1128
+ try {
1129
+ var headers = { 'Content-Type': 'application/json', 'Accept': 'application/json' };
1130
+ if (mcpSessionId) headers['Mcp-Session-Id'] = mcpSessionId;
1131
+ var mcpRes = await fetch('/mcp', {
1132
+ method: 'POST',
1133
+ headers: headers,
1134
+ body: JSON.stringify({
1135
+ jsonrpc: '2.0', id: mcpCallId++,
1136
+ method: 'tools/call',
1137
+ params: { name: fullToolName, arguments: toolArgs || {} },
1138
+ }),
1139
+ signal: AbortSignal.timeout(60000),
1140
+ });
1141
+ var mcpData = await mcpRes.json();
1142
+ // Extract result from MCP response (content array → structured or text)
1143
+ var result = undefined;
1144
+ var error = undefined;
1145
+ if (mcpData.error) {
1146
+ error = { code: mcpData.error.code || -32000, message: mcpData.error.message || 'Unknown error' };
1147
+ } else if (mcpData.result) {
1148
+ var content = mcpData.result.content || [];
1149
+ var textParts = content.filter(function(c) { return c.type === 'text'; });
1150
+ if (textParts.length > 0) {
1151
+ try { result = JSON.parse(textParts[0].text); } catch(e) { result = textParts[0].text; }
1152
+ }
1153
+ if (mcpData.result.structuredContent) result = mcpData.result.structuredContent;
1154
+ }
1155
+ iframe.contentWindow.postMessage({
1156
+ jsonrpc: '2.0', id: msg.id, result: result, error: error,
1157
+ }, '*');
1158
+ } catch (err) {
1159
+ iframe.contentWindow.postMessage({
1160
+ jsonrpc: '2.0', id: msg.id,
1161
+ error: { code: -32000, message: err.message },
1162
+ }, '*');
1163
+ }
1164
+ }
1165
+ });
1166
+
1167
+ // Send photon:init context to iframe once loaded
1168
+ iframe.onload = () => {
1169
+ iframe.contentWindow.postMessage({
1170
+ type: 'photon:init',
1171
+ context: { photon: PHOTON, theme: 'dark', displayMode: 'standalone' }
1172
+ }, '*');
1173
+ };
1174
+
1175
+ // Wait for bridge ready signal, then auto-invoke main() and deliver result
1176
+ var bridgeReady = false;
1177
+ window.addEventListener('message', async function onReady(e) {
1178
+ if (bridgeReady) return;
1179
+ var msg = e.data;
1180
+ if (!msg || typeof msg !== 'object') return;
1181
+ // Bridge sends both legacy photon:ready and MCP Apps ui/notifications/initialized
1182
+ var isReady = msg.type === 'photon:ready' ||
1183
+ (msg.jsonrpc === '2.0' && msg.method === 'ui/notifications/initialized');
1184
+ if (!isReady) return;
1185
+ bridgeReady = true;
1186
+ window.removeEventListener('message', onReady);
1187
+
1188
+ try {
1189
+ var headers = { 'Content-Type': 'application/json', 'Accept': 'application/json' };
1190
+ if (mcpSessionId) headers['Mcp-Session-Id'] = mcpSessionId;
1191
+ var invokeRes = await fetch('/mcp', {
1192
+ method: 'POST',
1193
+ headers: headers,
1194
+ body: JSON.stringify({
1195
+ jsonrpc: '2.0', id: mcpCallId++,
1196
+ method: 'tools/call',
1197
+ params: { name: PHOTON + '/' + bridgeMethod, arguments: {} },
1198
+ }),
1199
+ signal: AbortSignal.timeout(30000),
1200
+ });
1201
+ var mcpData = await invokeRes.json();
1202
+ if (!mcpData.error && mcpData.result) {
1203
+ var content = mcpData.result.content || [];
1204
+ var textParts = content.filter(function(c) { return c.type === 'text'; });
1205
+ var result = undefined;
1206
+ if (textParts.length > 0) {
1207
+ try { result = JSON.parse(textParts[0].text); } catch(e) { result = textParts[0].text; }
1208
+ }
1209
+ if (mcpData.result.structuredContent) result = mcpData.result.structuredContent;
1210
+ if (result !== undefined) {
1211
+ iframe.contentWindow.postMessage({
1212
+ jsonrpc: '2.0',
1213
+ method: 'ui/notifications/tool-result',
1214
+ params: { result: result },
1215
+ }, '*');
1216
+ }
1217
+ }
1218
+ } catch (err) {
1219
+ console.warn('[PWA] Auto-invoke failed:', err.message);
1220
+ }
1221
+ });
1222
+ }
1223
+
1224
+ // --- Service Worker Registration ---
1225
+ if ('serviceWorker' in navigator) {
1226
+ navigator.serviceWorker.register('/sw.js', { scope: '/' })
1227
+ .then(reg => console.log('[PWA] SW registered:', reg.scope))
1228
+ .catch(err => console.warn('[PWA] SW registration failed:', err));
1229
+ }
1230
+
1231
+ // --- Real-time SSE subscription for cross-client sync ---
1232
+ async function connectSSE() {
1233
+ try {
1234
+ // Step 1: Initialize MCP session as beam client to get session ID
1235
+ var initRes = await fetch('/mcp', {
1236
+ method: 'POST',
1237
+ headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
1238
+ signal: AbortSignal.timeout(10000),
1239
+ body: JSON.stringify({
1240
+ jsonrpc: '2.0', id: 1, method: 'initialize',
1241
+ params: {
1242
+ protocolVersion: '2025-03-26',
1243
+ clientInfo: { name: 'beam', version: '1.0.0' },
1244
+ capabilities: {}
1245
+ }
1246
+ })
1247
+ });
1248
+ var sessionId = initRes.headers.get('mcp-session-id');
1249
+ if (!sessionId) return;
1250
+ mcpSessionId = sessionId;
1251
+
1252
+ // Step 2: Open SSE on the same session
1253
+ var sseUrl = '/mcp?sessionId=' + encodeURIComponent(sessionId);
1254
+ var es = new EventSource(sseUrl);
1255
+ es.onmessage = function(event) {
1256
+ try {
1257
+ var msg = JSON.parse(event.data);
1258
+ if (msg.type === 'keepalive') return;
1259
+ var iframe = document.querySelector('iframe');
1260
+ if (!iframe || !iframe.contentWindow) return;
1261
+
1262
+ // Handle @stateful state-changed events — re-invoke main() to refresh UI
1263
+ if (msg.method === 'state-changed' && msg.params?.photon === PHOTON) {
1264
+ // Re-fetch data by calling main() and delivering result to iframe
1265
+ var refreshHeaders = { 'Content-Type': 'application/json', 'Accept': 'application/json' };
1266
+ if (mcpSessionId) refreshHeaders['Mcp-Session-Id'] = mcpSessionId;
1267
+ fetch('/mcp', {
1268
+ method: 'POST',
1269
+ headers: refreshHeaders,
1270
+ body: JSON.stringify({
1271
+ jsonrpc: '2.0', id: mcpCallId++,
1272
+ method: 'tools/call',
1273
+ params: { name: PHOTON + '/' + bridgeMethod, arguments: {} },
1274
+ }),
1275
+ signal: AbortSignal.timeout(15000),
1276
+ }).then(function(r) { return r.json(); }).then(function(mcpData) {
1277
+ if (!mcpData.error && mcpData.result) {
1278
+ var content = mcpData.result.content || [];
1279
+ var textParts = content.filter(function(c) { return c.type === 'text'; });
1280
+ var result = undefined;
1281
+ if (textParts.length > 0) {
1282
+ try { result = JSON.parse(textParts[0].text); } catch(e) { result = textParts[0].text; }
1283
+ }
1284
+ if (mcpData.result.structuredContent) result = mcpData.result.structuredContent;
1285
+ if (result !== undefined) {
1286
+ iframe.contentWindow.postMessage({
1287
+ jsonrpc: '2.0',
1288
+ method: 'ui/notifications/tool-result',
1289
+ params: { result: result },
1290
+ }, '*');
1291
+ }
1292
+ }
1293
+ }).catch(function() {});
1294
+ return;
1295
+ }
1296
+
1297
+ // Forward other events to iframe
1298
+ if (msg.method === 'photon/board-update') {
1299
+ iframe.contentWindow.postMessage({
1300
+ jsonrpc: '2.0',
1301
+ method: 'photon/notifications/emit',
1302
+ params: { emit: 'board-update', ...msg.params },
1303
+ }, '*');
1304
+ } else if (msg.method === 'photon/channel-event') {
1305
+ iframe.contentWindow.postMessage({
1306
+ jsonrpc: '2.0',
1307
+ method: 'photon/notifications/emit',
1308
+ params: msg.params,
1309
+ }, '*');
1310
+ }
1311
+ } catch (e) {}
1312
+ };
1313
+ es.onerror = function() {
1314
+ es.close();
1315
+ setTimeout(connectSSE, 5000);
1316
+ };
1317
+ } catch (e) {
1318
+ setTimeout(connectSSE, 5000);
1319
+ }
1320
+ }
1321
+
1322
+ // Start the diagnostics-first loading flow
1323
+ checkAndLoad();
1324
+ </script>
1325
+ </body>
1326
+ </html>`;
1327
+ res.writeHead(200, { 'Content-Type': 'text/html' });
1328
+ res.end(html);
1329
+ return;
1330
+ }
553
1331
  // Default route: Serve Lit App
554
1332
  if (url.pathname === '/' || !url.pathname.startsWith('/api')) {
555
1333
  try {
@@ -802,8 +1580,17 @@ export async function startBeam(rawWorkingDir, port) {
802
1580
  // Re-extract schema - use extractAllFromSource to get both tools and templates
803
1581
  const extractor = new SchemaExtractor();
804
1582
  const reloadSource = await fs.readFile(photonPath, 'utf-8');
805
- const { tools: schemas, templates } = extractor.extractAllFromSource(reloadSource);
1583
+ const reloadMetadata = extractor.extractAllFromSource(reloadSource);
1584
+ const schemas = reloadMetadata.tools;
1585
+ const templates = reloadMetadata.templates;
806
1586
  mcp.schemas = schemas; // Store schemas for result rendering
1587
+ // Update notification subscriptions for reloaded photon
1588
+ if (reloadMetadata.notificationSubscriptions?.watchFor) {
1589
+ photonNotificationSubscriptions.set(photonName, reloadMetadata.notificationSubscriptions.watchFor);
1590
+ }
1591
+ else {
1592
+ photonNotificationSubscriptions.delete(photonName);
1593
+ }
807
1594
  const lifecycleMethods = ['onInitialize', 'onShutdown', 'constructor'];
808
1595
  const uiAssets = mcp.assets?.ui || [];
809
1596
  const methods = schemas
@@ -1141,36 +1928,103 @@ export async function startBeam(rawWorkingDir, port) {
1141
1928
  await ensureDaemon();
1142
1929
  for (const photon of statefulPhotons) {
1143
1930
  const photonName = photon.name;
1144
- const channel = `${photonName}:state-changed`;
1145
- subscribeChannel(photonName, channel, (message) => {
1146
- broadcastToBeam('photon/state-changed', {
1931
+ // Subscribe to 'default' instance + any other instances that appear
1932
+ const instanceNames = ['default'];
1933
+ for (const instanceName of instanceNames) {
1934
+ // Channel is now instance-specific: photon:instance:state-changed
1935
+ const channel = `${photonName}:${instanceName}:state-changed`;
1936
+ subscribeChannel(photonName, channel, (message) => {
1937
+ // Only broadcast if instance matches (prevents cross-instance leakage)
1938
+ if (message?.instance === instanceName || !message?.instance) {
1939
+ // Minimal transmission: include instance and patches for global sync
1940
+ broadcastNotification('state-changed', {
1941
+ photon: photonName,
1942
+ instance: instanceName,
1943
+ // JSON Patch array for client-side state sync
1944
+ patches: message?.patches,
1945
+ // Keep legacy fields for backward compatibility
1946
+ method: message?.method,
1947
+ params: message?.params,
1948
+ data: message?.data,
1949
+ // Optional fields for undo/redo support
1950
+ ...(message?.patch && { patch: message.patch }),
1951
+ ...(message?.inversePatch && { inversePatch: message.inversePatch }),
1952
+ });
1953
+ }
1954
+ }, {
1955
+ reconnect: true,
1956
+ workingDir,
1957
+ onReconnect: () => logger.debug(`📡 Reconnected ${channel} subscription`),
1958
+ onRefreshNeeded: () => {
1959
+ logger.info(`📡 Refresh needed for ${channel} (events lost during daemon restart)`);
1960
+ // Broadcast minimal refresh signal to all clients
1961
+ broadcastNotification('state-changed', {
1962
+ photon: photonName,
1963
+ instance: instanceName,
1964
+ method: '_refresh',
1965
+ patches: undefined, // No patches, signal full refresh needed
1966
+ });
1967
+ },
1968
+ })
1969
+ .then(() => {
1970
+ logger.info(`📡 Subscribed to ${channel} for cross-client sync`);
1971
+ })
1972
+ .catch((err) => {
1973
+ logger.warn(`Failed to subscribe to ${channel}: ${getErrorMessage(err)}`);
1974
+ });
1975
+ }
1976
+ }
1977
+ }
1978
+ catch (err) {
1979
+ logger.warn(`Failed to start daemon for stateful photons: ${getErrorMessage(err)}`);
1980
+ }
1981
+ }
1982
+ // ═══════════════════════════════════════════════════════════════════════════════
1983
+ // NOTIFICATION SUBSCRIPTIONS - Subscribe to all photon notification channels
1984
+ // ═══════════════════════════════════════════════════════════════════════════════
1985
+ // Unlike state-changed (active photon only), notifications are always subscribed
1986
+ // but filtered server-side based on each photon's @notify-on declarations
1987
+ if (statefulPhotons.length > 0) {
1988
+ try {
1989
+ for (const photon of statefulPhotons) {
1990
+ const photonName = photon.name;
1991
+ const instanceName = 'default'; // TODO: support multi-instance notifications
1992
+ // Subscribe to notifications channel (always-on, not just active)
1993
+ const notificationChannel = `${photonName}:${instanceName}:notifications`;
1994
+ // Get this photon's notification subscriptions from @notify-on tags
1995
+ const watchFor = photonNotificationSubscriptions.get(photonName);
1996
+ subscribeChannel(photonName, notificationChannel, (message) => {
1997
+ // Check if this photon cares about this notification type
1998
+ if (!watchFor || !watchFor.includes(message?.type)) {
1999
+ logger.debug(`📡 Notification filtered: ${photonName} doesn't care about "${message?.type}"`);
2000
+ return; // Don't broadcast notifications this photon doesn't care about
2001
+ }
2002
+ // Only broadcast relevant notifications
2003
+ logger.debug(`📡 Broadcasting notification: ${photonName} [${message?.type}]`);
2004
+ broadcastNotification('photon/notification', {
1147
2005
  photon: photonName,
1148
- method: message?.method,
1149
- data: message?.data,
2006
+ type: message?.type,
2007
+ priority: message?.priority || 'info',
2008
+ message: message?.message,
2009
+ ...(message?.action && { action: message.action }),
2010
+ ...(message?.sound && { sound: message.sound }),
2011
+ ...(message?.data && { data: message.data }),
1150
2012
  });
1151
2013
  }, {
1152
2014
  reconnect: true,
1153
2015
  workingDir,
1154
- onReconnect: () => logger.debug(`📡 Reconnected ${channel} subscription`),
1155
- onRefreshNeeded: () => {
1156
- logger.info(`📡 Refresh needed for ${channel} (events lost during daemon restart)`);
1157
- broadcastToBeam('photon/state-changed', {
1158
- photon: photonName,
1159
- method: '_refresh',
1160
- data: {},
1161
- });
1162
- },
2016
+ onReconnect: () => logger.debug(`📡 Reconnected ${notificationChannel} subscription`),
1163
2017
  })
1164
2018
  .then(() => {
1165
- logger.info(`📡 Subscribed to ${channel} for cross-client sync`);
2019
+ logger.info(`📡 Subscribed to ${notificationChannel} for notifications${watchFor ? ` (watching: ${watchFor.join(', ')})` : ''}`);
1166
2020
  })
1167
2021
  .catch((err) => {
1168
- logger.warn(`Failed to subscribe to ${channel}: ${getErrorMessage(err)}`);
2022
+ logger.warn(`Failed to subscribe to ${notificationChannel}: ${getErrorMessage(err)}`);
1169
2023
  });
1170
2024
  }
1171
2025
  }
1172
2026
  catch (err) {
1173
- logger.warn(`Failed to start daemon for stateful photons: ${getErrorMessage(err)}`);
2027
+ logger.warn(`Failed to set up notification subscriptions: ${getErrorMessage(err)}`);
1174
2028
  }
1175
2029
  }
1176
2030
  // Set up file watchers for symlinked and bundled photon assets (now that photons are loaded)