@portel/photon 1.11.0 → 1.13.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 (87) 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 +994 -34
  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 +53 -12
  18. package/dist/auto-ui/streamable-http-transport.js.map +1 -1
  19. package/dist/auto-ui/types.d.ts +28 -1
  20. package/dist/auto-ui/types.d.ts.map +1 -1
  21. package/dist/auto-ui/types.js +23 -0
  22. package/dist/auto-ui/types.js.map +1 -1
  23. package/dist/beam.bundle.js +2894 -329
  24. package/dist/beam.bundle.js.map +4 -4
  25. package/dist/cli/commands/build.d.ts +3 -0
  26. package/dist/cli/commands/build.d.ts.map +1 -0
  27. package/dist/cli/commands/build.js +339 -0
  28. package/dist/cli/commands/build.js.map +1 -0
  29. package/dist/cli/commands/package-app.d.ts.map +1 -1
  30. package/dist/cli/commands/package-app.js +116 -35
  31. package/dist/cli/commands/package-app.js.map +1 -1
  32. package/dist/cli/commands/run.d.ts.map +1 -1
  33. package/dist/cli/commands/run.js +2 -0
  34. package/dist/cli/commands/run.js.map +1 -1
  35. package/dist/cli/index.d.ts.map +1 -1
  36. package/dist/cli/index.js +2 -0
  37. package/dist/cli/index.js.map +1 -1
  38. package/dist/context-store.d.ts +5 -0
  39. package/dist/context-store.d.ts.map +1 -1
  40. package/dist/context-store.js +9 -0
  41. package/dist/context-store.js.map +1 -1
  42. package/dist/daemon/client.d.ts.map +1 -1
  43. package/dist/daemon/client.js +81 -0
  44. package/dist/daemon/client.js.map +1 -1
  45. package/dist/daemon/protocol.d.ts +3 -1
  46. package/dist/daemon/protocol.d.ts.map +1 -1
  47. package/dist/daemon/protocol.js +1 -1
  48. package/dist/daemon/protocol.js.map +1 -1
  49. package/dist/daemon/server.js +513 -18
  50. package/dist/daemon/server.js.map +1 -1
  51. package/dist/embedded-runtime.d.ts +38 -0
  52. package/dist/embedded-runtime.d.ts.map +1 -0
  53. package/dist/embedded-runtime.js +326 -0
  54. package/dist/embedded-runtime.js.map +1 -0
  55. package/dist/index.d.ts +1 -0
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +1 -0
  58. package/dist/index.js.map +1 -1
  59. package/dist/loader.d.ts +38 -1
  60. package/dist/loader.d.ts.map +1 -1
  61. package/dist/loader.js +455 -15
  62. package/dist/loader.js.map +1 -1
  63. package/dist/photon-cli-runner.d.ts +22 -0
  64. package/dist/photon-cli-runner.d.ts.map +1 -1
  65. package/dist/photon-cli-runner.js +244 -12
  66. package/dist/photon-cli-runner.js.map +1 -1
  67. package/dist/photon-doc-extractor.d.ts +6 -0
  68. package/dist/photon-doc-extractor.d.ts.map +1 -1
  69. package/dist/photon-doc-extractor.js +22 -0
  70. package/dist/photon-doc-extractor.js.map +1 -1
  71. package/dist/photons/tunnel.photon.d.ts +5 -9
  72. package/dist/photons/tunnel.photon.d.ts.map +1 -1
  73. package/dist/photons/tunnel.photon.js +36 -96
  74. package/dist/photons/tunnel.photon.js.map +1 -1
  75. package/dist/photons/tunnel.photon.ts +40 -112
  76. package/dist/server.d.ts +30 -0
  77. package/dist/server.d.ts.map +1 -1
  78. package/dist/server.js +155 -10
  79. package/dist/server.js.map +1 -1
  80. package/dist/test-runner.d.ts +13 -1
  81. package/dist/test-runner.d.ts.map +1 -1
  82. package/dist/test-runner.js +529 -122
  83. package/dist/test-runner.js.map +1 -1
  84. package/dist/version.d.ts.map +1 -1
  85. package/dist/version.js +10 -2
  86. package/dist/version.js.map +1 -1
  87. package/package.json +23 -6
@@ -8,11 +8,28 @@
8
8
  import * as http from 'http';
9
9
  import * as net from 'net';
10
10
  import * as fs from 'fs/promises';
11
- import { existsSync, lstatSync, mkdirSync, realpathSync, watch } from 'fs';
11
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, watch, } from 'fs';
12
12
  import * as path from 'path';
13
+ import * as os from 'os';
13
14
  import { fileURLToPath } from 'url';
14
15
  import { createHash } from 'crypto';
15
16
  import { setSecurityHeaders, SimpleRateLimiter } from '../shared/security.js';
17
+ /**
18
+ * Check if shell integration has been installed (photon init cli).
19
+ * Cached at module load since it won't change during a Beam session.
20
+ */
21
+ const _shellIntegrationInstalled = (() => {
22
+ const shell = process.env.SHELL || '';
23
+ const rcFile = shell.includes('zsh')
24
+ ? path.join(os.homedir(), '.zshrc')
25
+ : path.join(os.homedir(), '.bashrc');
26
+ try {
27
+ return readFileSync(rcFile, 'utf-8').includes('# photon shell integration');
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ })();
16
33
  /**
17
34
  * Generate a unique ID for a photon based on its path.
18
35
  * This ensures photons with the same name from different paths are distinguishable.
@@ -21,6 +38,52 @@ import { setSecurityHeaders, SimpleRateLimiter } from '../shared/security.js';
21
38
  function generatePhotonId(photonPath) {
22
39
  return createHash('sha256').update(photonPath).digest('hex').slice(0, 12);
23
40
  }
41
+ /**
42
+ * MIME type map for icon images
43
+ */
44
+ const ICON_MIME_TYPES = {
45
+ '.png': 'image/png',
46
+ '.jpg': 'image/jpeg',
47
+ '.jpeg': 'image/jpeg',
48
+ '.gif': 'image/gif',
49
+ '.svg': 'image/svg+xml',
50
+ '.webp': 'image/webp',
51
+ '.ico': 'image/x-icon',
52
+ };
53
+ /**
54
+ * Resolve raw icon image paths to MCP Icon[] format (data URIs)
55
+ */
56
+ async function resolveIconImages(iconImages, photonPath) {
57
+ if (!iconImages || iconImages.length === 0)
58
+ return undefined;
59
+ const photonDir = path.dirname(photonPath);
60
+ const icons = [];
61
+ for (const entry of iconImages) {
62
+ try {
63
+ const resolvedPath = path.resolve(photonDir, entry.path);
64
+ const ext = path.extname(resolvedPath).toLowerCase();
65
+ const mimeType = ICON_MIME_TYPES[ext];
66
+ if (!mimeType)
67
+ continue;
68
+ const data = await fs.readFile(resolvedPath);
69
+ const base64 = data.toString('base64');
70
+ const dataUri = `data:${mimeType};base64,${base64}`;
71
+ const icon = {
72
+ src: dataUri,
73
+ mimeType,
74
+ };
75
+ if (entry.sizes)
76
+ icon.sizes = entry.sizes;
77
+ if (entry.theme)
78
+ icon.theme = entry.theme;
79
+ icons.push(icon);
80
+ }
81
+ catch {
82
+ // Skip unreadable icon files silently
83
+ }
84
+ }
85
+ return icons.length > 0 ? icons : undefined;
86
+ }
24
87
  const __filename = fileURLToPath(import.meta.url);
25
88
  const __dirname = path.dirname(__filename);
26
89
  import { withTimeout } from '../async/index.js';
@@ -69,6 +132,318 @@ const backfillEnvDefaults = backfillEnvDefaultsFromModule;
69
132
  const extractClassMetadataFromSource = extractClassMetadataFromModule;
70
133
  const applyMethodVisibility = applyMethodVisibilityFromModule;
71
134
  const extractCspFromSource = extractCspFromModule;
135
+ // ═══════════════════════════════════════════════════════════════════════════════
136
+ // NOTIFICATION SUBSCRIPTIONS
137
+ // ═══════════════════════════════════════════════════════════════════════════════
138
+ /**
139
+ * Map to store notification subscriptions per photon
140
+ * Key: photon name, Value: list of event types this photon cares about
141
+ * Example: { "chat": ["mentions", "direct-messages"], "tasks": ["deadline", "assigned-to-me"] }
142
+ */
143
+ const photonNotificationSubscriptions = new Map();
144
+ /**
145
+ * Generate the service worker JS that validates the Beam backend
146
+ * on PWA launch and shows a diagnostic page if something is wrong.
147
+ */
148
+ function generateServiceWorker(workingDir) {
149
+ return `
150
+ // Photon Beam Service Worker
151
+ // Validates the backend is running and healthy before serving the app.
152
+ const CACHE_NAME = 'photon-pwa-v1';
153
+ const EXPECTED_WORKING_DIR = ${JSON.stringify(workingDir)};
154
+ const HEALTH_ENDPOINT = '/api/diagnostics';
155
+
156
+ // Cache the boot page on install
157
+ self.addEventListener('install', (event) => {
158
+ event.waitUntil(
159
+ caches.open(CACHE_NAME).then((cache) => cache.put('/_pwa_boot', new Response(BOOT_PAGE, {
160
+ headers: { 'Content-Type': 'text/html' }
161
+ }))).then(() => self.skipWaiting())
162
+ );
163
+ });
164
+
165
+ self.addEventListener('activate', (event) => {
166
+ event.waitUntil(self.clients.claim());
167
+ });
168
+
169
+ self.addEventListener('fetch', (event) => {
170
+ const url = new URL(event.request.url);
171
+
172
+ // PWA icon PNG generation — intercept /api/pwa/icon-png requests and render
173
+ // the SVG icon onto OffscreenCanvas, returning a real PNG response that
174
+ // satisfies Chrome's installability requirement for raster icons.
175
+ if (url.pathname === '/api/pwa/icon-png') {
176
+ event.respondWith(handleIconPng(url));
177
+ return;
178
+ }
179
+
180
+ // Only intercept navigation requests (page loads, not API/asset fetches)
181
+ if (event.request.mode !== 'navigate') return;
182
+
183
+ // Skip API routes and static assets — let them pass through
184
+ if (url.pathname.startsWith('/api/') || url.pathname === '/sw.js' || url.pathname === '/beam.bundle.js') return;
185
+
186
+ // All navigation requests go through health check
187
+ event.respondWith(handlePWANavigation(event.request));
188
+ });
189
+
190
+ async function handlePWANavigation(request) {
191
+ try {
192
+ // Try to reach the backend
193
+ const healthRes = await fetch(HEALTH_ENDPOINT, { signal: AbortSignal.timeout(3000) });
194
+ if (!healthRes.ok) throw new Error('Health check failed');
195
+
196
+ const health = await healthRes.json();
197
+
198
+ // Validate this is actually Beam serving the expected directory
199
+ if (!health.photonVersion) {
200
+ return serveBoot('wrong-service', JSON.stringify(health));
201
+ }
202
+ if (health.workingDir !== EXPECTED_WORKING_DIR) {
203
+ return serveBoot('wrong-directory', JSON.stringify({
204
+ expected: EXPECTED_WORKING_DIR,
205
+ actual: health.workingDir
206
+ }));
207
+ }
208
+
209
+ // Backend is healthy and correct — serve the real page
210
+ return fetch(request);
211
+ } catch (err) {
212
+ // Backend is unreachable
213
+ return serveBoot('not-running', err.message);
214
+ }
215
+ }
216
+
217
+ async function handleIconPng(url) {
218
+ const photon = url.searchParams.get('photon') || '';
219
+ const size = parseInt(url.searchParams.get('size') || '192', 10);
220
+ const cacheKey = '/_pwa_icon_' + photon + '_' + size;
221
+
222
+ // Check cache first
223
+ const cache = await caches.open(CACHE_NAME);
224
+ const cached = await cache.match(cacheKey);
225
+ if (cached) return cached;
226
+
227
+ try {
228
+ // Fetch the icon from the server (may be SVG, PNG, JPEG, etc.)
229
+ const iconRes = await fetch('/api/pwa/icon?photon=' + encodeURIComponent(photon), { signal: AbortSignal.timeout(10000) });
230
+ if (!iconRes.ok) throw new Error('Icon fetch failed: ' + iconRes.status);
231
+
232
+ const contentType = (iconRes.headers.get('Content-Type') || '').toLowerCase();
233
+
234
+ // For raster images (PNG, JPEG, WebP), resize via OffscreenCanvas if needed
235
+ // For SVG or emoji-generated SVG, render to canvas at target size
236
+ let bmp;
237
+ if (contentType.includes('svg')) {
238
+ // SVG (emoji-generated or file) — parse as text, create bitmap
239
+ const svgText = await iconRes.text();
240
+ const svgBlob = new Blob([svgText], { type: 'image/svg+xml' });
241
+ bmp = await createImageBitmap(svgBlob, { resizeWidth: size, resizeHeight: size });
242
+ } else {
243
+ // Raster image (PNG, JPEG, WebP) — decode directly
244
+ const imgBlob = await iconRes.blob();
245
+ bmp = await createImageBitmap(imgBlob, { resizeWidth: size, resizeHeight: size });
246
+ }
247
+
248
+ const canvas = new OffscreenCanvas(size, size);
249
+ const ctx = canvas.getContext('2d');
250
+
251
+ // Dark rounded-rect background
252
+ ctx.fillStyle = '#1a1a1a';
253
+ const r = size * 0.2;
254
+ ctx.beginPath();
255
+ ctx.moveTo(r, 0);
256
+ ctx.lineTo(size - r, 0);
257
+ ctx.quadraticCurveTo(size, 0, size, r);
258
+ ctx.lineTo(size, size - r);
259
+ ctx.quadraticCurveTo(size, size, size - r, size);
260
+ ctx.lineTo(r, size);
261
+ ctx.quadraticCurveTo(0, size, 0, size - r);
262
+ ctx.lineTo(0, r);
263
+ ctx.quadraticCurveTo(0, 0, r, 0);
264
+ ctx.closePath();
265
+ ctx.fill();
266
+
267
+ // Draw the icon
268
+ ctx.drawImage(bmp, 0, 0, size, size);
269
+
270
+ const pngBlob = await canvas.convertToBlob({ type: 'image/png' });
271
+ const pngResponse = new Response(pngBlob, {
272
+ headers: {
273
+ 'Content-Type': 'image/png',
274
+ 'Cache-Control': 'public, max-age=86400'
275
+ }
276
+ });
277
+
278
+ // Cache the generated PNG
279
+ await cache.put(cacheKey, pngResponse.clone());
280
+ return pngResponse;
281
+ } catch (err) {
282
+ // Fallback: generate a simple colored square with initial letter
283
+ const canvas = new OffscreenCanvas(size, size);
284
+ const ctx = canvas.getContext('2d');
285
+ ctx.fillStyle = '#1a1a1a';
286
+ ctx.fillRect(0, 0, size, size);
287
+ ctx.fillStyle = '#4ade80';
288
+ ctx.font = (size * 0.4) + 'px sans-serif';
289
+ ctx.textAlign = 'center';
290
+ ctx.textBaseline = 'middle';
291
+ ctx.fillText(photon.charAt(0).toUpperCase() || 'P', size / 2, size / 2);
292
+ const pngBlob = await canvas.convertToBlob({ type: 'image/png' });
293
+ return new Response(pngBlob, {
294
+ headers: { 'Content-Type': 'image/png' }
295
+ });
296
+ }
297
+ }
298
+
299
+ async function serveBoot(reason, detail) {
300
+ const cache = await caches.open(CACHE_NAME);
301
+ const cached = await cache.match('/_pwa_boot');
302
+ if (cached) {
303
+ const html = await cached.text();
304
+ const injected = html
305
+ .replace('__BOOT_REASON__', reason)
306
+ .replace('__BOOT_DETAIL__', detail || '')
307
+ .replace('__EXPECTED_DIR__', EXPECTED_WORKING_DIR);
308
+ return new Response(injected, { headers: { 'Content-Type': 'text/html' } });
309
+ }
310
+ return new Response('Photon Beam is not available. Run: photon beam', {
311
+ status: 503, headers: { 'Content-Type': 'text/plain' }
312
+ });
313
+ }
314
+
315
+ const BOOT_PAGE = \`<!DOCTYPE html>
316
+ <html lang="en">
317
+ <head>
318
+ <meta charset="UTF-8">
319
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
320
+ <title>Photon Beam</title>
321
+ <meta name="theme-color" content="#1a1a1a">
322
+ <style>
323
+ * { margin: 0; padding: 0; box-sizing: border-box; }
324
+ body {
325
+ min-height: 100vh; display: flex; align-items: center; justify-content: center;
326
+ background: #1a1a1a; color: #e5e5e5; font-family: system-ui, -apple-system, sans-serif;
327
+ }
328
+ .container { text-align: center; padding: 40px; max-width: 500px; }
329
+ .icon { font-size: 56px; margin-bottom: 24px; }
330
+ h1 { font-size: 22px; font-weight: 600; margin-bottom: 12px; color: #fff; }
331
+ .message { font-size: 15px; line-height: 1.6; color: #999; margin-bottom: 28px; }
332
+ .command {
333
+ display: inline-block; background: #2a2a2a; border: 1px solid #333;
334
+ padding: 10px 20px; border-radius: 8px; font-family: 'JetBrains Mono', monospace;
335
+ font-size: 14px; color: #4ade80; margin-bottom: 20px; user-select: all;
336
+ }
337
+ .detail {
338
+ font-size: 12px; color: #666; font-family: 'JetBrains Mono', monospace;
339
+ background: #222; border-radius: 6px; padding: 10px; margin-bottom: 20px;
340
+ word-break: break-all; display: none;
341
+ }
342
+ .detail.show { display: block; }
343
+ .retry {
344
+ padding: 10px 24px; background: #333; border: 1px solid #444; border-radius: 8px;
345
+ color: #fff; cursor: pointer; font-size: 14px; transition: background 0.2s;
346
+ }
347
+ .retry:hover { background: #444; }
348
+ .spinner {
349
+ display: none; width: 20px; height: 20px; border: 2px solid #444;
350
+ border-top-color: #4ade80; border-radius: 50%; animation: spin 0.8s linear infinite;
351
+ margin: 0 auto 16px;
352
+ }
353
+ .spinner.show { display: block; }
354
+ @keyframes spin { to { transform: rotate(360deg); } }
355
+ </style>
356
+ </head>
357
+ <body>
358
+ <div class="container">
359
+ <div class="spinner" id="spinner"></div>
360
+ <div id="content">
361
+ <div class="icon" id="icon"></div>
362
+ <h1 id="title"></h1>
363
+ <p class="message" id="message"></p>
364
+ <div class="detail" id="detail"></div>
365
+ <code class="command" id="command" style="display:none"></code>
366
+ <br><br>
367
+ <button class="retry" onclick="checkAndRetry()">Retry</button>
368
+ </div>
369
+ </div>
370
+ <script>
371
+ const reason = '__BOOT_REASON__';
372
+ const detail = '__BOOT_DETAIL__';
373
+ const expectedDir = '__EXPECTED_DIR__';
374
+
375
+ const states = {
376
+ 'not-running': {
377
+ icon: '\\u26a1',
378
+ title: 'Beam is not running',
379
+ message: 'Start Photon Beam to use this app:',
380
+ command: 'photon beam'
381
+ },
382
+ 'wrong-service': {
383
+ icon: '\\u26a0\\ufe0f',
384
+ title: 'Port is in use by another service',
385
+ message: 'Something else is running on this port. Stop the other service or reconfigure Beam:',
386
+ command: 'photon beam --port <available-port>',
387
+ showDetail: true
388
+ },
389
+ 'wrong-directory': {
390
+ icon: '\\ud83d\\udcc1',
391
+ title: 'Beam is serving a different project',
392
+ message: 'Beam is running but pointing to a different directory. Start it with the correct path:',
393
+ command: 'photon beam ' + expectedDir,
394
+ showDetail: true
395
+ }
396
+ };
397
+
398
+ function render() {
399
+ const s = states[reason] || states['not-running'];
400
+ document.getElementById('icon').textContent = s.icon;
401
+ document.getElementById('title').textContent = s.title;
402
+ document.getElementById('message').textContent = s.message;
403
+ const cmdEl = document.getElementById('command');
404
+ cmdEl.textContent = s.command;
405
+ cmdEl.style.display = 'inline-block';
406
+ if (s.showDetail && detail) {
407
+ const el = document.getElementById('detail');
408
+ el.textContent = detail;
409
+ el.classList.add('show');
410
+ }
411
+ }
412
+
413
+ async function checkAndRetry() {
414
+ document.getElementById('content').style.display = 'none';
415
+ document.getElementById('spinner').classList.add('show');
416
+ try {
417
+ const res = await fetch('/api/diagnostics', { signal: AbortSignal.timeout(3000) });
418
+ if (res.ok) {
419
+ const h = await res.json();
420
+ if (h.photonVersion && h.workingDir === expectedDir) {
421
+ location.reload();
422
+ return;
423
+ }
424
+ }
425
+ } catch {}
426
+ document.getElementById('spinner').classList.remove('show');
427
+ document.getElementById('content').style.display = '';
428
+ render();
429
+ }
430
+
431
+ render();
432
+ // Auto-retry every 5 seconds
433
+ setInterval(async () => {
434
+ try {
435
+ const res = await fetch('/api/diagnostics', { signal: AbortSignal.timeout(2000) });
436
+ if (res.ok) {
437
+ const h = await res.json();
438
+ if (h.photonVersion && h.workingDir === expectedDir) location.reload();
439
+ }
440
+ } catch {}
441
+ }, 5000);
442
+ </script>
443
+ </body>
444
+ </html>\`;
445
+ `;
446
+ }
72
447
  export async function startBeam(rawWorkingDir, port) {
73
448
  const workingDir = path.resolve(rawWorkingDir);
74
449
  const { PHOTON_VERSION } = await import('../version.js');
@@ -229,8 +604,18 @@ export async function startBeam(rawWorkingDir, port) {
229
604
  backfillEnvDefaults(instance, constructorParams);
230
605
  // Extract schema for UI — reuse source read from above
231
606
  const schemaSource = source || (await fs.readFile(photonPath, 'utf-8'));
232
- const { tools: schemas, templates } = extractor.extractAllFromSource(schemaSource);
607
+ const metadata = extractor.extractAllFromSource(schemaSource);
608
+ const schemas = metadata.tools;
609
+ const templates = metadata.templates;
233
610
  mcp.schemas = schemas;
611
+ // Store notification subscriptions per photon
612
+ if (metadata.notificationSubscriptions?.watchFor) {
613
+ photonNotificationSubscriptions.set(name, metadata.notificationSubscriptions.watchFor);
614
+ }
615
+ else {
616
+ // Clear previous subscription if photon no longer has @notify-on
617
+ photonNotificationSubscriptions.delete(name);
618
+ }
234
619
  // Get UI assets for linking
235
620
  const uiAssets = mcp.assets?.ui || [];
236
621
  // Filter out lifecycle methods
@@ -256,8 +641,30 @@ export async function startBeam(rawWorkingDir, port) {
256
641
  ? { scheduled: schema.scheduled || schema.cron }
257
642
  : {}),
258
643
  ...(schema.locked ? { locked: schema.locked } : {}),
644
+ // MCP standard annotations
645
+ ...(schema.title ? { title: schema.title } : {}),
646
+ ...(schema.readOnlyHint ? { readOnlyHint: true } : {}),
647
+ ...(schema.destructiveHint ? { destructiveHint: true } : {}),
648
+ ...(schema.idempotentHint ? { idempotentHint: true } : {}),
649
+ ...(schema.openWorldHint !== undefined ? { openWorldHint: schema.openWorldHint } : {}),
650
+ ...(schema.audience ? { audience: schema.audience } : {}),
651
+ ...(schema.contentPriority !== undefined
652
+ ? { contentPriority: schema.contentPriority }
653
+ : {}),
654
+ ...(schema.outputSchema ? { outputSchema: schema.outputSchema } : {}),
259
655
  };
260
656
  });
657
+ // Resolve icon images (file paths → data URIs) for methods that have them
658
+ for (const schema of schemas) {
659
+ if (!schema.iconImages)
660
+ continue;
661
+ const method = methods.find((m) => m.name === schema.name);
662
+ if (!method)
663
+ continue;
664
+ const resolved = await resolveIconImages(schema.iconImages, photonPath);
665
+ if (resolved)
666
+ method.icons = resolved;
667
+ }
261
668
  // Add templates as methods with isTemplate flag and markdown output format
262
669
  templates.forEach((template) => {
263
670
  if (!lifecycleMethods.includes(template.name)) {
@@ -536,12 +943,25 @@ export async function startBeam(rawWorkingDir, port) {
536
943
  if (await handleConfigRoutes(req, res, url, beamState))
537
944
  return;
538
945
  }
946
+ // Service worker for PWA support
947
+ if (url.pathname === '/sw.js') {
948
+ res.writeHead(200, {
949
+ 'Content-Type': 'application/javascript',
950
+ 'Service-Worker-Allowed': '/',
951
+ 'Cache-Control': 'no-cache',
952
+ });
953
+ res.end(generateServiceWorker(beamState.workingDir));
954
+ return;
955
+ }
539
956
  // Serve static frontend bundle
540
957
  if (url.pathname === '/beam.bundle.js') {
541
958
  try {
542
959
  const bundlePath = path.join(__dirname, '../../dist/beam.bundle.js');
543
960
  const content = await fs.readFile(bundlePath, 'utf-8');
544
- res.writeHead(200, { 'Content-Type': 'text/javascript' });
961
+ res.writeHead(200, {
962
+ 'Content-Type': 'text/javascript',
963
+ 'Cache-Control': 'no-cache',
964
+ });
545
965
  res.end(content);
546
966
  }
547
967
  catch {
@@ -550,11 +970,458 @@ export async function startBeam(rawWorkingDir, port) {
550
970
  }
551
971
  return;
552
972
  }
973
+ // Standalone PWA app route: /app/{photonName}
974
+ // Full-featured PWA host shell with diagnostics, postMessage bridge, service worker, and install prompt
975
+ const appMatch = url.pathname.match(/^\/app\/([^/]+)$/);
976
+ if (appMatch) {
977
+ const photonName = appMatch[1];
978
+ const photon = beamState.photons.find((p) => p.name === photonName);
979
+ if (!photon) {
980
+ res.writeHead(404);
981
+ res.end(`Photon not found: ${photonName}`);
982
+ return;
983
+ }
984
+ const label = photon?.label ||
985
+ photonName.charAt(0).toUpperCase() + photonName.slice(1).replace(/-/g, ' ');
986
+ const description = photon?.description || `${label} - Photon App`;
987
+ const iconValue = photon?.icon || '📦';
988
+ const encodedName = encodeURIComponent(photonName);
989
+ // Sanitize strings for safe embedding in HTML
990
+ const safeLabel = label.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c] || c);
991
+ const safeDesc = description.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c] || c);
992
+ const html = `<!DOCTYPE html>
993
+ <html lang="en">
994
+ <head>
995
+ <meta charset="UTF-8">
996
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
997
+ <title>${safeLabel}</title>
998
+ <meta name="description" content="${safeDesc}">
999
+ <meta name="theme-color" content="#1a1a1a">
1000
+ <meta name="apple-mobile-web-app-capable" content="yes">
1001
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
1002
+ <meta name="apple-mobile-web-app-title" content="${safeLabel}">
1003
+ <link rel="manifest" href="/api/pwa/manifest.json?photon=${encodedName}">
1004
+ <link rel="apple-touch-icon" href="/api/pwa/icon?photon=${encodedName}">
1005
+ <style>
1006
+ * { margin: 0; padding: 0; box-sizing: border-box; }
1007
+ html, body { width: 100%; height: 100%; overflow: hidden; background: #1a1a1a; font-family: system-ui, -apple-system, sans-serif; color: #e5e5e5; }
1008
+ #app { width: 100%; height: 100vh; }
1009
+ iframe { width: 100%; height: 100vh; border: none; display: block; }
1010
+
1011
+ .status-page {
1012
+ display: none; width: 100%; height: 100vh;
1013
+ flex-direction: column; align-items: center; justify-content: center;
1014
+ text-align: center; padding: 40px;
1015
+ }
1016
+ .status-page.show { display: flex; }
1017
+ .status-page .icon { font-size: 64px; margin-bottom: 24px; }
1018
+ .status-page h2 { font-size: 20px; font-weight: 600; margin-bottom: 12px; color: #e5e5e5; }
1019
+ .status-page p { font-size: 14px; color: #888; max-width: 400px; line-height: 1.6; margin-bottom: 8px; }
1020
+ .status-page code {
1021
+ display: inline-block; background: #2a2a2a; padding: 8px 16px; border-radius: 6px;
1022
+ font-size: 13px; color: #4ade80; font-family: 'SF Mono', Monaco, monospace; margin-top: 8px;
1023
+ }
1024
+ .status-page .spinner {
1025
+ width: 24px; height: 24px; border: 2px solid #333; border-top-color: #4ade80;
1026
+ border-radius: 50%; animation: spin 0.8s linear infinite; margin-bottom: 16px;
1027
+ }
1028
+ @keyframes spin { to { transform: rotate(360deg); } }
1029
+ .status-page .retry-btn {
1030
+ margin-top: 16px; padding: 8px 20px; background: #333; border: 1px solid #444;
1031
+ border-radius: 6px; color: #e5e5e5; cursor: pointer; font-size: 13px; font-family: inherit;
1032
+ }
1033
+ .status-page .retry-btn:hover { background: #444; }
1034
+
1035
+ </style>
1036
+ </head>
1037
+ <body>
1038
+ <!-- Status: Starting (shown while waiting for Beam) -->
1039
+ <div id="status-starting" class="status-page">
1040
+ <div class="spinner"></div>
1041
+ <h2>Starting ${safeLabel}...</h2>
1042
+ <p>Waiting for Beam server</p>
1043
+ </div>
1044
+
1045
+ <!-- Status: Not running (shown when Beam is down) -->
1046
+ <div id="status-offline" class="status-page">
1047
+ <div class="icon">${iconValue}</div>
1048
+ <h2>${safeLabel}</h2>
1049
+ <p>Server is not running. Start Photon to use this app:</p>
1050
+ <code>photon beam</code>
1051
+ <button class="retry-btn" onclick="checkAndLoad()">Retry</button>
1052
+ </div>
1053
+
1054
+ <!-- Status: Port conflict -->
1055
+ <div id="status-conflict" class="status-page">
1056
+ <div class="icon">⚠️</div>
1057
+ <h2>Port Conflict</h2>
1058
+ <p id="conflict-msg">Another process is using the required port.</p>
1059
+ <code id="conflict-cmd"></code>
1060
+ <button class="retry-btn" onclick="checkAndLoad()">Retry</button>
1061
+ </div>
1062
+
1063
+ <!-- App container with iframe -->
1064
+ <div id="app" style="display:none"></div>
1065
+
1066
+ <script>
1067
+ const PHOTON = ${JSON.stringify(photonName)};
1068
+ const appEl = document.getElementById('app');
1069
+ const statusStarting = document.getElementById('status-starting');
1070
+ const statusOffline = document.getElementById('status-offline');
1071
+ const statusConflict = document.getElementById('status-conflict');
1072
+ let retryTimer = null;
1073
+
1074
+ function hideAll() {
1075
+ statusStarting.classList.remove('show');
1076
+ statusOffline.classList.remove('show');
1077
+ statusConflict.classList.remove('show');
1078
+ appEl.style.display = 'none';
1079
+ }
1080
+
1081
+ // Diagnostics-first loading: check server health before loading the app
1082
+ async function checkAndLoad() {
1083
+ if (retryTimer) { clearInterval(retryTimer); retryTimer = null; }
1084
+ hideAll();
1085
+ statusStarting.classList.add('show');
1086
+
1087
+ try {
1088
+ const res = await fetch('/api/diagnostics', { signal: AbortSignal.timeout(5000) });
1089
+ if (!res.ok) throw new Error('Server error');
1090
+ const diag = await res.json();
1091
+
1092
+ // Check for port conflicts
1093
+ if (diag.portConflict) {
1094
+ hideAll();
1095
+ statusConflict.classList.add('show');
1096
+ const conflictMsg = document.getElementById('conflict-msg');
1097
+ const conflictCmd = document.getElementById('conflict-cmd');
1098
+ if (diag.portConflict.port) {
1099
+ conflictMsg.textContent = 'Port ' + diag.portConflict.port + ' is in use by another process.';
1100
+ }
1101
+ if (diag.portConflict.pid) {
1102
+ conflictCmd.textContent = 'kill ' + diag.portConflict.pid;
1103
+ }
1104
+ return;
1105
+ }
1106
+
1107
+ // Server is healthy — establish MCP session then load the app
1108
+ hideAll();
1109
+ appEl.style.display = 'block';
1110
+ await connectSSE();
1111
+ await loadApp();
1112
+ } catch (err) {
1113
+ // Server unreachable — show offline state with auto-retry
1114
+ hideAll();
1115
+ statusOffline.classList.add('show');
1116
+ retryTimer = setInterval(async () => {
1117
+ try {
1118
+ const res = await fetch('/api/diagnostics', { signal: AbortSignal.timeout(3000) });
1119
+ if (res.ok) {
1120
+ clearInterval(retryTimer);
1121
+ retryTimer = null;
1122
+ checkAndLoad();
1123
+ }
1124
+ } catch { /* still offline */ }
1125
+ }, 3000);
1126
+ }
1127
+ }
1128
+
1129
+ // Load the @ui template into a full-viewport iframe with platform bridge.
1130
+ // Discovers template URL client-side: tries class-level @ui first (/api/template),
1131
+ // then falls back to method-level @ui by querying diagnostics for the app entry's linkedUi.
1132
+ async function loadApp() {
1133
+ try {
1134
+ // Step 1: Discover the template URL
1135
+ let templateUrl = '/api/template?photon=' + encodeURIComponent(PHOTON);
1136
+ let bridgeMethod = 'main';
1137
+
1138
+ // Try class-level @ui first
1139
+ let templateRes = await fetch(templateUrl, { signal: AbortSignal.timeout(10000) });
1140
+ if (!templateRes.ok) {
1141
+ // Fall back: query diagnostics for this photon's appEntry linkedUi
1142
+ const diagRes = await fetch('/api/diagnostics', { signal: AbortSignal.timeout(10000) });
1143
+ if (diagRes.ok) {
1144
+ const diag = await diagRes.json();
1145
+ const photonInfo = (diag.photons || []).find(function(p) { return p.name === PHOTON; });
1146
+ if (photonInfo && photonInfo.appEntry && photonInfo.appEntry.linkedUi) {
1147
+ templateUrl = '/api/ui?photon=' + encodeURIComponent(PHOTON) + '&id=' + encodeURIComponent(photonInfo.appEntry.linkedUi);
1148
+ bridgeMethod = photonInfo.appEntry.name || 'main';
1149
+ templateRes = await fetch(templateUrl, { signal: AbortSignal.timeout(10000) });
1150
+ }
1151
+ }
1152
+ if (!templateRes.ok) throw new Error('Template not available');
1153
+ }
1154
+
1155
+ // Step 2: Fetch platform bridge script
1156
+ const bridgeRes = await fetch('/api/platform-bridge?photon=' + encodeURIComponent(PHOTON) + '&method=' + encodeURIComponent(bridgeMethod) + '&theme=dark', { signal: AbortSignal.timeout(10000) });
1157
+
1158
+ let templateHtml = await templateRes.text();
1159
+ const bridgeScript = bridgeRes.ok ? await bridgeRes.text() : '';
1160
+
1161
+ // Inject platform bridge before </head>
1162
+ if (templateHtml.includes('</head>')) {
1163
+ templateHtml = templateHtml.replace('</head>', bridgeScript + '</head>');
1164
+ } else {
1165
+ templateHtml = '<html><head>' + bridgeScript + '</head><body>' + templateHtml + '</body></html>';
1166
+ }
1167
+
1168
+ const iframe = document.createElement('iframe');
1169
+ iframe.setAttribute('sandbox', 'allow-scripts allow-forms allow-same-origin allow-popups allow-modals');
1170
+
1171
+ const blob = new Blob([templateHtml], { type: 'text/html' });
1172
+ iframe.src = URL.createObjectURL(blob);
1173
+
1174
+ appEl.innerHTML = '';
1175
+ appEl.appendChild(iframe);
1176
+ initBridge(iframe, bridgeMethod);
1177
+ } catch (err) {
1178
+ appEl.innerHTML = '<div class="status-page show"><div class="icon">⚠️</div>'
1179
+ + '<h2>Failed to load</h2><p>' + err.message + '</p>'
1180
+ + '<button class="retry-btn" onclick="checkAndLoad()">Retry</button></div>';
1181
+ }
1182
+ }
1183
+
1184
+ // postMessage bridge: relays JSON-RPC from iframe through MCP for full event pipeline
1185
+ var mcpSessionId = null;
1186
+ var mcpCallId = 100;
1187
+
1188
+ function initBridge(iframe, bridgeMethod) {
1189
+ window.addEventListener('message', async (e) => {
1190
+ const msg = e.data;
1191
+ if (!msg || typeof msg !== 'object') return;
1192
+
1193
+ // Handle MCP Apps ui/initialize request — respond so bridge sends initialized
1194
+ if (msg.jsonrpc === '2.0' && msg.method === 'ui/initialize' && msg.id != null) {
1195
+ iframe.contentWindow.postMessage({
1196
+ jsonrpc: '2.0',
1197
+ id: msg.id,
1198
+ result: {
1199
+ protocolVersion: '2026-01-26',
1200
+ hostInfo: { name: 'photon-pwa', version: '1.0.0' },
1201
+ hostCapabilities: {},
1202
+ hostContext: { theme: 'dark' },
1203
+ },
1204
+ }, '*');
1205
+ return;
1206
+ }
1207
+
1208
+ // Handle JSON-RPC tools/call from iframe — route through MCP for event broadcasting
1209
+ if (msg.jsonrpc === '2.0' && msg.method === 'tools/call' && msg.id != null) {
1210
+ const { name: toolName, arguments: toolArgs } = msg.params || {};
1211
+ // Prefix with photon name if not already prefixed (bridge sends bare method names)
1212
+ var fullToolName = toolName.includes('/') ? toolName : PHOTON + '/' + toolName;
1213
+ try {
1214
+ var headers = { 'Content-Type': 'application/json', 'Accept': 'application/json' };
1215
+ if (mcpSessionId) headers['Mcp-Session-Id'] = mcpSessionId;
1216
+ var mcpRes = await fetch('/mcp', {
1217
+ method: 'POST',
1218
+ headers: headers,
1219
+ body: JSON.stringify({
1220
+ jsonrpc: '2.0', id: mcpCallId++,
1221
+ method: 'tools/call',
1222
+ params: { name: fullToolName, arguments: toolArgs || {} },
1223
+ }),
1224
+ signal: AbortSignal.timeout(60000),
1225
+ });
1226
+ var mcpData = await mcpRes.json();
1227
+ // Extract result from MCP response (content array → structured or text)
1228
+ var result = undefined;
1229
+ var error = undefined;
1230
+ if (mcpData.error) {
1231
+ error = { code: mcpData.error.code || -32000, message: mcpData.error.message || 'Unknown error' };
1232
+ } else if (mcpData.result) {
1233
+ var content = mcpData.result.content || [];
1234
+ var textParts = content.filter(function(c) { return c.type === 'text'; });
1235
+ if (textParts.length > 0) {
1236
+ try { result = JSON.parse(textParts[0].text); } catch(e) { result = textParts[0].text; }
1237
+ }
1238
+ if (mcpData.result.structuredContent) result = mcpData.result.structuredContent;
1239
+ }
1240
+ iframe.contentWindow.postMessage({
1241
+ jsonrpc: '2.0', id: msg.id, result: result, error: error,
1242
+ }, '*');
1243
+ } catch (err) {
1244
+ iframe.contentWindow.postMessage({
1245
+ jsonrpc: '2.0', id: msg.id,
1246
+ error: { code: -32000, message: err.message },
1247
+ }, '*');
1248
+ }
1249
+ }
1250
+ });
1251
+
1252
+ // Send photon:init context to iframe once loaded
1253
+ iframe.onload = () => {
1254
+ iframe.contentWindow.postMessage({
1255
+ type: 'photon:init',
1256
+ context: { photon: PHOTON, theme: 'dark', displayMode: 'standalone' }
1257
+ }, '*');
1258
+ };
1259
+
1260
+ // Wait for bridge ready signal, then auto-invoke main() and deliver result
1261
+ var bridgeReady = false;
1262
+ window.addEventListener('message', async function onReady(e) {
1263
+ if (bridgeReady) return;
1264
+ var msg = e.data;
1265
+ if (!msg || typeof msg !== 'object') return;
1266
+ // Bridge sends both legacy photon:ready and MCP Apps ui/notifications/initialized
1267
+ var isReady = msg.type === 'photon:ready' ||
1268
+ (msg.jsonrpc === '2.0' && msg.method === 'ui/notifications/initialized');
1269
+ if (!isReady) return;
1270
+ bridgeReady = true;
1271
+ window.removeEventListener('message', onReady);
1272
+
1273
+ try {
1274
+ var headers = { 'Content-Type': 'application/json', 'Accept': 'application/json' };
1275
+ if (mcpSessionId) headers['Mcp-Session-Id'] = mcpSessionId;
1276
+ var invokeRes = await fetch('/mcp', {
1277
+ method: 'POST',
1278
+ headers: headers,
1279
+ body: JSON.stringify({
1280
+ jsonrpc: '2.0', id: mcpCallId++,
1281
+ method: 'tools/call',
1282
+ params: { name: PHOTON + '/' + bridgeMethod, arguments: {} },
1283
+ }),
1284
+ signal: AbortSignal.timeout(30000),
1285
+ });
1286
+ var mcpData = await invokeRes.json();
1287
+ if (!mcpData.error && mcpData.result) {
1288
+ var content = mcpData.result.content || [];
1289
+ var textParts = content.filter(function(c) { return c.type === 'text'; });
1290
+ var result = undefined;
1291
+ if (textParts.length > 0) {
1292
+ try { result = JSON.parse(textParts[0].text); } catch(e) { result = textParts[0].text; }
1293
+ }
1294
+ if (mcpData.result.structuredContent) result = mcpData.result.structuredContent;
1295
+ if (result !== undefined) {
1296
+ iframe.contentWindow.postMessage({
1297
+ jsonrpc: '2.0',
1298
+ method: 'ui/notifications/tool-result',
1299
+ params: { result: result },
1300
+ }, '*');
1301
+ }
1302
+ }
1303
+ } catch (err) {
1304
+ console.warn('[PWA] Auto-invoke failed:', err.message);
1305
+ }
1306
+ });
1307
+ }
1308
+
1309
+ // --- Service Worker Registration ---
1310
+ if ('serviceWorker' in navigator) {
1311
+ navigator.serviceWorker.register('/sw.js', { scope: '/' })
1312
+ .then(reg => console.log('[PWA] SW registered:', reg.scope))
1313
+ .catch(err => console.warn('[PWA] SW registration failed:', err));
1314
+ }
1315
+
1316
+ // --- Real-time SSE subscription for cross-client sync ---
1317
+ async function connectSSE() {
1318
+ try {
1319
+ // Step 1: Initialize MCP session as beam client to get session ID
1320
+ var initRes = await fetch('/mcp', {
1321
+ method: 'POST',
1322
+ headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
1323
+ signal: AbortSignal.timeout(10000),
1324
+ body: JSON.stringify({
1325
+ jsonrpc: '2.0', id: 1, method: 'initialize',
1326
+ params: {
1327
+ protocolVersion: '2025-03-26',
1328
+ clientInfo: { name: 'beam', version: '1.0.0' },
1329
+ capabilities: {}
1330
+ }
1331
+ })
1332
+ });
1333
+ var sessionId = initRes.headers.get('mcp-session-id');
1334
+ if (!sessionId) return;
1335
+ mcpSessionId = sessionId;
1336
+
1337
+ // Step 2: Open SSE on the same session
1338
+ var sseUrl = '/mcp?sessionId=' + encodeURIComponent(sessionId);
1339
+ var es = new EventSource(sseUrl);
1340
+ es.onmessage = function(event) {
1341
+ try {
1342
+ var msg = JSON.parse(event.data);
1343
+ if (msg.type === 'keepalive') return;
1344
+ var iframe = document.querySelector('iframe');
1345
+ if (!iframe || !iframe.contentWindow) return;
1346
+
1347
+ // Handle @stateful state-changed events — re-invoke main() to refresh UI
1348
+ if (msg.method === 'state-changed' && msg.params?.photon === PHOTON) {
1349
+ // Re-fetch data by calling main() and delivering result to iframe
1350
+ var refreshHeaders = { 'Content-Type': 'application/json', 'Accept': 'application/json' };
1351
+ if (mcpSessionId) refreshHeaders['Mcp-Session-Id'] = mcpSessionId;
1352
+ fetch('/mcp', {
1353
+ method: 'POST',
1354
+ headers: refreshHeaders,
1355
+ body: JSON.stringify({
1356
+ jsonrpc: '2.0', id: mcpCallId++,
1357
+ method: 'tools/call',
1358
+ params: { name: PHOTON + '/' + bridgeMethod, arguments: {} },
1359
+ }),
1360
+ signal: AbortSignal.timeout(15000),
1361
+ }).then(function(r) { return r.json(); }).then(function(mcpData) {
1362
+ if (!mcpData.error && mcpData.result) {
1363
+ var content = mcpData.result.content || [];
1364
+ var textParts = content.filter(function(c) { return c.type === 'text'; });
1365
+ var result = undefined;
1366
+ if (textParts.length > 0) {
1367
+ try { result = JSON.parse(textParts[0].text); } catch(e) { result = textParts[0].text; }
1368
+ }
1369
+ if (mcpData.result.structuredContent) result = mcpData.result.structuredContent;
1370
+ if (result !== undefined) {
1371
+ iframe.contentWindow.postMessage({
1372
+ jsonrpc: '2.0',
1373
+ method: 'ui/notifications/tool-result',
1374
+ params: { result: result },
1375
+ }, '*');
1376
+ }
1377
+ }
1378
+ }).catch(function() {});
1379
+ return;
1380
+ }
1381
+
1382
+ // Forward other events to iframe
1383
+ if (msg.method === 'photon/board-update') {
1384
+ iframe.contentWindow.postMessage({
1385
+ jsonrpc: '2.0',
1386
+ method: 'photon/notifications/emit',
1387
+ params: { emit: 'board-update', ...msg.params },
1388
+ }, '*');
1389
+ } else if (msg.method === 'photon/channel-event') {
1390
+ iframe.contentWindow.postMessage({
1391
+ jsonrpc: '2.0',
1392
+ method: 'photon/notifications/emit',
1393
+ params: msg.params,
1394
+ }, '*');
1395
+ }
1396
+ } catch (e) {}
1397
+ };
1398
+ es.onerror = function() {
1399
+ es.close();
1400
+ setTimeout(connectSSE, 5000);
1401
+ };
1402
+ } catch (e) {
1403
+ setTimeout(connectSSE, 5000);
1404
+ }
1405
+ }
1406
+
1407
+ // Start the diagnostics-first loading flow
1408
+ checkAndLoad();
1409
+ </script>
1410
+ </body>
1411
+ </html>`;
1412
+ res.writeHead(200, { 'Content-Type': 'text/html' });
1413
+ res.end(html);
1414
+ return;
1415
+ }
553
1416
  // Default route: Serve Lit App
554
1417
  if (url.pathname === '/' || !url.pathname.startsWith('/api')) {
555
1418
  try {
556
1419
  const indexPath = path.join(__dirname, 'frontend/index.html');
557
- const content = await fs.readFile(indexPath, 'utf-8');
1420
+ let content = await fs.readFile(indexPath, 'utf-8');
1421
+ // Inject shell integration flag so frontend can strip CLI prefix
1422
+ if (_shellIntegrationInstalled) {
1423
+ content = content.replace('</head>', '<script>window.__PHOTON_SHELL_INIT=true</script></head>');
1424
+ }
558
1425
  res.writeHead(200, { 'Content-Type': 'text/html' });
559
1426
  res.end(content);
560
1427
  }
@@ -644,20 +1511,13 @@ export async function startBeam(rawWorkingDir, port) {
644
1511
  // Determine which photon a file change belongs to
645
1512
  const getPhotonForPath = (changedPath) => {
646
1513
  const relativePath = path.relative(workingDir, changedPath);
647
- const parts = relativePath.split(path.sep);
648
- // Direct .photon.ts file change
649
- if (relativePath.endsWith('.photon.ts')) {
1514
+ // Only react to top-level .photon.ts file changes.
1515
+ // Subfolders under workingDir (~/.photon/) are runtime data (auth, media, state),
1516
+ // NOT source assets. Asset folders live at the symlink target and are watched
1517
+ // separately by setupSymlinkWatcher.
1518
+ if (relativePath.endsWith('.photon.ts') && !relativePath.includes(path.sep)) {
650
1519
  return path.basename(relativePath, '.photon.ts');
651
1520
  }
652
- // Asset folder change - first segment is the photon name
653
- if (parts.length > 1) {
654
- const folderName = parts[0];
655
- // Check if corresponding .photon.ts exists
656
- const photon = photons.find((p) => p.name === folderName);
657
- if (photon) {
658
- return folderName;
659
- }
660
- }
661
1521
  return null;
662
1522
  };
663
1523
  // Handle file change with debounce
@@ -802,8 +1662,17 @@ export async function startBeam(rawWorkingDir, port) {
802
1662
  // Re-extract schema - use extractAllFromSource to get both tools and templates
803
1663
  const extractor = new SchemaExtractor();
804
1664
  const reloadSource = await fs.readFile(photonPath, 'utf-8');
805
- const { tools: schemas, templates } = extractor.extractAllFromSource(reloadSource);
1665
+ const reloadMetadata = extractor.extractAllFromSource(reloadSource);
1666
+ const schemas = reloadMetadata.tools;
1667
+ const templates = reloadMetadata.templates;
806
1668
  mcp.schemas = schemas; // Store schemas for result rendering
1669
+ // Update notification subscriptions for reloaded photon
1670
+ if (reloadMetadata.notificationSubscriptions?.watchFor) {
1671
+ photonNotificationSubscriptions.set(photonName, reloadMetadata.notificationSubscriptions.watchFor);
1672
+ }
1673
+ else {
1674
+ photonNotificationSubscriptions.delete(photonName);
1675
+ }
807
1676
  const lifecycleMethods = ['onInitialize', 'onShutdown', 'constructor'];
808
1677
  const uiAssets = mcp.assets?.ui || [];
809
1678
  const methods = schemas
@@ -821,8 +1690,32 @@ export async function startBeam(rawWorkingDir, port) {
821
1690
  buttonLabel: schema.buttonLabel,
822
1691
  icon: schema.icon,
823
1692
  linkedUi: linkedAsset?.id,
1693
+ // MCP standard annotations
1694
+ ...(schema.title ? { title: schema.title } : {}),
1695
+ ...(schema.readOnlyHint ? { readOnlyHint: true } : {}),
1696
+ ...(schema.destructiveHint ? { destructiveHint: true } : {}),
1697
+ ...(schema.idempotentHint ? { idempotentHint: true } : {}),
1698
+ ...(schema.openWorldHint !== undefined
1699
+ ? { openWorldHint: schema.openWorldHint }
1700
+ : {}),
1701
+ ...(schema.audience ? { audience: schema.audience } : {}),
1702
+ ...(schema.contentPriority !== undefined
1703
+ ? { contentPriority: schema.contentPriority }
1704
+ : {}),
1705
+ ...(schema.outputSchema ? { outputSchema: schema.outputSchema } : {}),
824
1706
  };
825
1707
  });
1708
+ // Resolve icon images for hot-reloaded methods
1709
+ for (const schema of schemas) {
1710
+ if (!schema.iconImages)
1711
+ continue;
1712
+ const method = methods.find((m) => m.name === schema.name);
1713
+ if (!method)
1714
+ continue;
1715
+ const resolved = await resolveIconImages(schema.iconImages, photonPath);
1716
+ if (resolved)
1717
+ method.icons = resolved;
1718
+ }
826
1719
  // Add templates as methods
827
1720
  templates.forEach((template) => {
828
1721
  if (!lifecycleMethods.includes(template.name)) {
@@ -1141,36 +2034,103 @@ export async function startBeam(rawWorkingDir, port) {
1141
2034
  await ensureDaemon();
1142
2035
  for (const photon of statefulPhotons) {
1143
2036
  const photonName = photon.name;
1144
- const channel = `${photonName}:state-changed`;
1145
- subscribeChannel(photonName, channel, (message) => {
1146
- broadcastToBeam('photon/state-changed', {
2037
+ // Subscribe to 'default' instance + any other instances that appear
2038
+ const instanceNames = ['default'];
2039
+ for (const instanceName of instanceNames) {
2040
+ // Channel is now instance-specific: photon:instance:state-changed
2041
+ const channel = `${photonName}:${instanceName}:state-changed`;
2042
+ subscribeChannel(photonName, channel, (message) => {
2043
+ // Only broadcast if instance matches (prevents cross-instance leakage)
2044
+ if (message?.instance === instanceName || !message?.instance) {
2045
+ // Minimal transmission: include instance and patches for global sync
2046
+ broadcastNotification('state-changed', {
2047
+ photon: photonName,
2048
+ instance: instanceName,
2049
+ // JSON Patch array for client-side state sync
2050
+ patches: message?.patches,
2051
+ // Keep legacy fields for backward compatibility
2052
+ method: message?.method,
2053
+ params: message?.params,
2054
+ data: message?.data,
2055
+ // Optional fields for undo/redo support
2056
+ ...(message?.patch && { patch: message.patch }),
2057
+ ...(message?.inversePatch && { inversePatch: message.inversePatch }),
2058
+ });
2059
+ }
2060
+ }, {
2061
+ reconnect: true,
2062
+ workingDir,
2063
+ onReconnect: () => logger.debug(`📡 Reconnected ${channel} subscription`),
2064
+ onRefreshNeeded: () => {
2065
+ logger.info(`📡 Refresh needed for ${channel} (events lost during daemon restart)`);
2066
+ // Broadcast minimal refresh signal to all clients
2067
+ broadcastNotification('state-changed', {
2068
+ photon: photonName,
2069
+ instance: instanceName,
2070
+ method: '_refresh',
2071
+ patches: undefined, // No patches, signal full refresh needed
2072
+ });
2073
+ },
2074
+ })
2075
+ .then(() => {
2076
+ logger.info(`📡 Subscribed to ${channel} for cross-client sync`);
2077
+ })
2078
+ .catch((err) => {
2079
+ logger.warn(`Failed to subscribe to ${channel}: ${getErrorMessage(err)}`);
2080
+ });
2081
+ }
2082
+ }
2083
+ }
2084
+ catch (err) {
2085
+ logger.warn(`Failed to start daemon for stateful photons: ${getErrorMessage(err)}`);
2086
+ }
2087
+ }
2088
+ // ═══════════════════════════════════════════════════════════════════════════════
2089
+ // NOTIFICATION SUBSCRIPTIONS - Subscribe to all photon notification channels
2090
+ // ═══════════════════════════════════════════════════════════════════════════════
2091
+ // Unlike state-changed (active photon only), notifications are always subscribed
2092
+ // but filtered server-side based on each photon's @notify-on declarations
2093
+ if (statefulPhotons.length > 0) {
2094
+ try {
2095
+ for (const photon of statefulPhotons) {
2096
+ const photonName = photon.name;
2097
+ const instanceName = 'default'; // TODO: support multi-instance notifications
2098
+ // Subscribe to notifications channel (always-on, not just active)
2099
+ const notificationChannel = `${photonName}:${instanceName}:notifications`;
2100
+ // Get this photon's notification subscriptions from @notify-on tags
2101
+ const watchFor = photonNotificationSubscriptions.get(photonName);
2102
+ subscribeChannel(photonName, notificationChannel, (message) => {
2103
+ // Check if this photon cares about this notification type
2104
+ if (!watchFor || !watchFor.includes(message?.type)) {
2105
+ logger.debug(`📡 Notification filtered: ${photonName} doesn't care about "${message?.type}"`);
2106
+ return; // Don't broadcast notifications this photon doesn't care about
2107
+ }
2108
+ // Only broadcast relevant notifications
2109
+ logger.debug(`📡 Broadcasting notification: ${photonName} [${message?.type}]`);
2110
+ broadcastNotification('photon/notification', {
1147
2111
  photon: photonName,
1148
- method: message?.method,
1149
- data: message?.data,
2112
+ type: message?.type,
2113
+ priority: message?.priority || 'info',
2114
+ message: message?.message,
2115
+ ...(message?.action && { action: message.action }),
2116
+ ...(message?.sound && { sound: message.sound }),
2117
+ ...(message?.data && { data: message.data }),
1150
2118
  });
1151
2119
  }, {
1152
2120
  reconnect: true,
1153
2121
  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
- },
2122
+ onReconnect: () => logger.debug(`📡 Reconnected ${notificationChannel} subscription`),
1163
2123
  })
1164
2124
  .then(() => {
1165
- logger.info(`📡 Subscribed to ${channel} for cross-client sync`);
2125
+ logger.info(`📡 Subscribed to ${notificationChannel} for notifications${watchFor ? ` (watching: ${watchFor.join(', ')})` : ''}`);
1166
2126
  })
1167
2127
  .catch((err) => {
1168
- logger.warn(`Failed to subscribe to ${channel}: ${getErrorMessage(err)}`);
2128
+ logger.warn(`Failed to subscribe to ${notificationChannel}: ${getErrorMessage(err)}`);
1169
2129
  });
1170
2130
  }
1171
2131
  }
1172
2132
  catch (err) {
1173
- logger.warn(`Failed to start daemon for stateful photons: ${getErrorMessage(err)}`);
2133
+ logger.warn(`Failed to set up notification subscriptions: ${getErrorMessage(err)}`);
1174
2134
  }
1175
2135
  }
1176
2136
  // Set up file watchers for symlinked and bundled photon assets (now that photons are loaded)