@portel/photon 1.7.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +23 -24
  2. package/dist/auto-ui/beam.d.ts.map +1 -1
  3. package/dist/auto-ui/beam.js +117 -42
  4. package/dist/auto-ui/beam.js.map +1 -1
  5. package/dist/auto-ui/design-system/tokens.d.ts +1 -1
  6. package/dist/auto-ui/design-system/tokens.d.ts.map +1 -1
  7. package/dist/auto-ui/design-system/tokens.js +1 -1
  8. package/dist/auto-ui/design-system/tokens.js.map +1 -1
  9. package/dist/auto-ui/frontend/index.html +1 -1
  10. package/dist/auto-ui/rendering/components.d.ts.map +1 -1
  11. package/dist/auto-ui/rendering/components.js +568 -0
  12. package/dist/auto-ui/rendering/components.js.map +1 -1
  13. package/dist/auto-ui/rendering/field-analyzer.d.ts +56 -0
  14. package/dist/auto-ui/rendering/field-analyzer.d.ts.map +1 -1
  15. package/dist/auto-ui/rendering/field-analyzer.js +177 -0
  16. package/dist/auto-ui/rendering/field-analyzer.js.map +1 -1
  17. package/dist/auto-ui/rendering/layout-selector.d.ts +14 -2
  18. package/dist/auto-ui/rendering/layout-selector.d.ts.map +1 -1
  19. package/dist/auto-ui/rendering/layout-selector.js +125 -1
  20. package/dist/auto-ui/rendering/layout-selector.js.map +1 -1
  21. package/dist/auto-ui/streamable-http-transport.d.ts +1 -1
  22. package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
  23. package/dist/auto-ui/streamable-http-transport.js +353 -19
  24. package/dist/auto-ui/streamable-http-transport.js.map +1 -1
  25. package/dist/auto-ui/types.d.ts +7 -1
  26. package/dist/auto-ui/types.d.ts.map +1 -1
  27. package/dist/auto-ui/types.js.map +1 -1
  28. package/dist/beam.bundle.js +22441 -4216
  29. package/dist/beam.bundle.js.map +4 -4
  30. package/dist/cli/commands/info.d.ts.map +1 -1
  31. package/dist/cli/commands/info.js +37 -0
  32. package/dist/cli/commands/info.js.map +1 -1
  33. package/dist/cli/commands/package.d.ts.map +1 -1
  34. package/dist/cli/commands/package.js +16 -0
  35. package/dist/cli/commands/package.js.map +1 -1
  36. package/dist/cli.d.ts.map +1 -1
  37. package/dist/cli.js +628 -14
  38. package/dist/cli.js.map +1 -1
  39. package/dist/context-store.d.ts +79 -0
  40. package/dist/context-store.d.ts.map +1 -0
  41. package/dist/context-store.js +210 -0
  42. package/dist/context-store.js.map +1 -0
  43. package/dist/daemon/client.d.ts +13 -4
  44. package/dist/daemon/client.d.ts.map +1 -1
  45. package/dist/daemon/client.js +138 -77
  46. package/dist/daemon/client.js.map +1 -1
  47. package/dist/daemon/manager.d.ts +0 -25
  48. package/dist/daemon/manager.d.ts.map +1 -1
  49. package/dist/daemon/manager.js +10 -38
  50. package/dist/daemon/manager.js.map +1 -1
  51. package/dist/daemon/protocol.d.ts +7 -2
  52. package/dist/daemon/protocol.d.ts.map +1 -1
  53. package/dist/daemon/protocol.js.map +1 -1
  54. package/dist/daemon/server.js +257 -35
  55. package/dist/daemon/server.js.map +1 -1
  56. package/dist/daemon/session-manager.d.ts +24 -4
  57. package/dist/daemon/session-manager.d.ts.map +1 -1
  58. package/dist/daemon/session-manager.js +62 -12
  59. package/dist/daemon/session-manager.js.map +1 -1
  60. package/dist/index.d.ts +0 -1
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +0 -3
  63. package/dist/index.js.map +1 -1
  64. package/dist/loader.d.ts +3 -20
  65. package/dist/loader.d.ts.map +1 -1
  66. package/dist/loader.js +53 -75
  67. package/dist/loader.js.map +1 -1
  68. package/dist/photon-cli-runner.d.ts.map +1 -1
  69. package/dist/photon-cli-runner.js +258 -218
  70. package/dist/photon-cli-runner.js.map +1 -1
  71. package/dist/photon-doc-extractor.d.ts +2 -0
  72. package/dist/photon-doc-extractor.d.ts.map +1 -1
  73. package/dist/photon-doc-extractor.js +42 -6
  74. package/dist/photon-doc-extractor.js.map +1 -1
  75. package/dist/photons/maker.photon.d.ts.map +1 -1
  76. package/dist/photons/maker.photon.js +3 -1
  77. package/dist/photons/maker.photon.js.map +1 -1
  78. package/dist/photons/maker.photon.ts +3 -1
  79. package/dist/serv/index.d.ts.map +1 -1
  80. package/dist/serv/index.js.map +1 -1
  81. package/dist/server.d.ts +32 -15
  82. package/dist/server.d.ts.map +1 -1
  83. package/dist/server.js +468 -469
  84. package/dist/server.js.map +1 -1
  85. package/dist/shared/security.d.ts.map +1 -1
  86. package/dist/shared/security.js +4 -8
  87. package/dist/shared/security.js.map +1 -1
  88. package/dist/shell-completions.d.ts +21 -0
  89. package/dist/shell-completions.d.ts.map +1 -0
  90. package/dist/shell-completions.js +102 -0
  91. package/dist/shell-completions.js.map +1 -0
  92. package/dist/template-manager.d.ts.map +1 -1
  93. package/dist/template-manager.js.map +1 -1
  94. package/package.json +10 -6
package/README.md CHANGED
@@ -278,7 +278,7 @@ VideoProcessor requires the following CLI tools to be installed:
278
278
  - ffmpeg: Install from https://ffmpeg.org/download.html
279
279
  ```
280
280
 
281
- > See the full [Tag Reference](./DOCBLOCK-TAGS.md) for all available tags. There are 30+ covering validation, UI hints, scheduling, webhooks, and more.
281
+ > See the full [Tag Reference](./docs/reference/DOCBLOCK-TAGS.md) for all available tags. There are 30+ covering validation, UI hints, scheduling, webhooks, and more.
282
282
 
283
283
  <div align="center">
284
284
  <img src="https://raw.githubusercontent.com/portel-dev/photon/main/assets/readme-step-4.png" alt="Step 4 — Validation and formatting in Beam" width="100%">
@@ -322,7 +322,7 @@ export default class Weather {
322
322
 
323
323
  **What changes in Beam:** Instead of the auto-generated table, results render inside your custom HTML (a weather dashboard with icons, charts, or any visualization you build). The `window.photon` API bridges your UI to the tool system.
324
324
 
325
- > Custom UIs follow the [MCP Apps Extension (SEP-1865)](https://github.com/nicolo-ribaudo/modelcontextprotocol/blob/nicolo/sep-1865/docs/specification/draft/extensions/apps.mdx) standard and work across compatible hosts. See the [Custom UI Guide](./CUSTOM-UI.md).
325
+ > Custom UIs follow the [MCP Apps Extension (SEP-1865)](https://github.com/nicolo-ribaudo/modelcontextprotocol/blob/nicolo/sep-1865/docs/specification/draft/extensions/apps.mdx) standard and work across compatible hosts. See the [Custom UI Guide](./docs/guides/CUSTOM-UI.md).
326
326
 
327
327
  <div align="center">
328
328
  <img src="https://raw.githubusercontent.com/portel-dev/photon/main/assets/readme-step-5.png" alt="Step 5 — Custom UI result in Beam" width="100%">
@@ -351,12 +351,12 @@ If you are just skimming, here is what you need to know:
351
351
  | Concept | What it is | Learn more |
352
352
  |---------|-----------|------------|
353
353
  | **MCP** | A way for AI to use your tools. It's a standard. | [modelcontextprotocol.io](https://modelcontextprotocol.io/introduction) |
354
- | **Photon file** | A `.photon.ts` file. You define tools as methods in a class. | [Guide](./GUIDE.md) |
354
+ | **Photon file** | A `.photon.ts` file. You define tools as methods in a class. | [Guide](./docs/GUIDE.md) |
355
355
  | **Beam** | A web dashboard. It shows your tools as forms. | [Beam UI](#beam) |
356
356
  | **Marketplace** | A way to get other people's photons. | [Marketplace](#marketplace) |
357
- | **Daemon** | A background thing that handles messages and jobs. | [Daemon Pub/Sub](./DAEMON-PUBSUB.md) |
358
- | **Tags** | JSDoc comments that tell Photon what to do. | [Tag Reference](./DOCBLOCK-TAGS.md) |
359
- | **Custom UI** | When the auto-generated forms aren't enough. | [Custom UI Guide](./CUSTOM-UI.md) |
357
+ | **Daemon** | A background thing that handles messages and jobs. | [Daemon Pub/Sub](./docs/core/DAEMON-PUBSUB.md) |
358
+ | **Tags** | JSDoc comments that tell Photon what to do. | [Tag Reference](./docs/reference/DOCBLOCK-TAGS.md) |
359
+ | **Custom UI** | When the auto-generated forms aren't enough. | [Custom UI Guide](./docs/guides/CUSTOM-UI.md) |
360
360
 
361
361
  ---
362
362
 
@@ -377,7 +377,7 @@ photon maker new <name> # Scaffold a new photon
377
377
  # Manage
378
378
  photon info # List all photons
379
379
  photon info <name> --mcp # Get MCP client config (paste into Claude/Cursor)
380
- photon validate <name> # Check for errors
380
+ photon maker validate <name> # Check for errors
381
381
 
382
382
  # Marketplace
383
383
  photon add <name> # Install photon
@@ -386,7 +386,6 @@ photon upgrade # Upgrade all
386
386
 
387
387
  # Ops
388
388
  photon doctor # Diagnose environment
389
- photon audit # Security audit
390
389
  photon test # Run tests
391
390
  ```
392
391
 
@@ -412,7 +411,7 @@ Tags are JSDoc annotations that control how Photon processes your code. Here are
412
411
  | `@mcp` | Class | Inject another MCP server as a dependency |
413
412
  | `@icon` | Class/Method | Set emoji icon |
414
413
 
415
- > This is a subset. See the full [Tag Reference](./DOCBLOCK-TAGS.md) for all 30+ tags with examples.
414
+ > This is a subset. See the full [Tag Reference](./docs/reference/DOCBLOCK-TAGS.md) for all 30+ tags with examples.
416
415
 
417
416
  ---
418
417
 
@@ -422,33 +421,33 @@ Tags are JSDoc annotations that control how Photon processes your code. Here are
422
421
 
423
422
  | Guide | |
424
423
  |-------|-|
425
- | [Getting Started](./GUIDE.md) | Create your first photon, step by step |
426
- | [Tag Reference](./DOCBLOCK-TAGS.md) | Complete JSDoc tag reference with examples |
427
- | [Naming Conventions](./NAMING-CONVENTIONS.md) | How to name methods so they read naturally as CLI commands |
428
- | [Troubleshooting](./TROUBLESHOOTING.md) | Common issues and solutions |
424
+ | [Getting Started](./docs/GUIDE.md) | Create your first photon, step by step |
425
+ | [Tag Reference](./docs/reference/DOCBLOCK-TAGS.md) | Complete JSDoc tag reference with examples |
426
+ | [Naming Conventions](./docs/guides/NAMING-CONVENTIONS.md) | How to name methods so they read naturally as CLI commands |
427
+ | [Troubleshooting](./docs/TROUBLESHOOTING.md) | Common issues and solutions |
429
428
 
430
429
  **Build more:**
431
430
 
432
431
  | Topic | |
433
432
  |-------|-|
434
- | [Custom UI](./CUSTOM-UI.md) | Build rich interactive interfaces with `window.photon` |
435
- | [OAuth](./AUTH.md) | Built-in OAuth 2.1 with Google, GitHub, Microsoft |
436
- | [Daemon Pub/Sub](./DAEMON-PUBSUB.md) | Real-time cross-process messaging |
437
- | [Webhooks](./WEBHOOKS.md) | HTTP endpoints for external services |
438
- | [Locks](./LOCKS.md) | Distributed locks for exclusive access |
439
- | [Advanced Patterns](./ADVANCED.md) | Lifecycle hooks, dependency injection, interactive workflows |
440
- | [Deployment](./DEPLOYMENT.md) | Docker, Cloudflare Workers, AWS Lambda, Systemd |
433
+ | [Custom UI](./docs/guides/CUSTOM-UI.md) | Build rich interactive interfaces with `window.photon` |
434
+ | [OAuth](./docs/guides/AUTH.md) | Built-in OAuth 2.1 with Google, GitHub, Microsoft |
435
+ | [Daemon Pub/Sub](./docs/core/DAEMON-PUBSUB.md) | Real-time cross-process messaging |
436
+ | [Webhooks](./docs/reference/WEBHOOKS.md) | HTTP endpoints for external services |
437
+ | [Locks](./docs/reference/LOCKS.md) | Distributed locks for exclusive access |
438
+ | [Advanced Patterns](./docs/guides/ADVANCED.md) | Lifecycle hooks, dependency injection, interactive workflows |
439
+ | [Deployment](./docs/guides/DEPLOYMENT.md) | Docker, Cloudflare Workers, AWS Lambda, Systemd |
441
440
 
442
441
  **Operate:**
443
442
 
444
443
  | Topic | |
445
444
  |-------|-|
446
445
  | [Security](./SECURITY.md) | Best practices and audit checklist |
447
- | [Marketplace Publishing](./MARKETPLACE-PUBLISHING.md) | Create and share team marketplaces |
448
- | [Best Practices](./PHOTON_BEST_PRACTICES.md) | Patterns for production photons |
449
- | [Comparison](./COMPARISON.md) | Benchmarks vs official MCP implementations |
446
+ | [Marketplace Publishing](./docs/guides/MARKETPLACE-PUBLISHING.md) | Create and share team marketplaces |
447
+ | [Best Practices](./docs/guides/BEST-PRACTICES.md) | Patterns for production photons |
448
+ | [Comparison](./docs/COMPARISON.md) | Benchmarks vs official MCP implementations |
450
449
 
451
- **Reference:** [Architecture](./ARCHITECTURE.md) Ā· [Changelog](./CHANGELOG.md) Ā· [Contributing](./CONTRIBUTING.md)
450
+ **Reference:** [Architecture](./docs/core/ARCHITECTURE.md) Ā· [Changelog](./CHANGELOG.md) Ā· [Contributing](./CONTRIBUTING.md)
452
451
 
453
452
  ---
454
453
 
@@ -1 +1 @@
1
- {"version":3,"file":"beam.d.ts","sourceRoot":"","sources":["../../src/auto-ui/beam.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAixBH,wBAAsB,SAAS,CAAC,aAAa,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CA0/ElF;AAiYD;;;GAGG;AACH,wBAAsB,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC,CAsB9C"}
1
+ {"version":3,"file":"beam.d.ts","sourceRoot":"","sources":["../../src/auto-ui/beam.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AA2xBH,wBAAsB,SAAS,CAAC,aAAa,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAwkFlF;AAiYD;;;GAGG;AACH,wBAAsB,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC,CAsB9C"}
@@ -14,7 +14,7 @@ import * as os from 'os';
14
14
  import { spawn } from 'child_process';
15
15
  import { fileURLToPath } from 'url';
16
16
  import { createHash } from 'crypto';
17
- import { isPathWithin, isLocalRequest, setSecurityHeaders, readBody, SimpleRateLimiter } from '../shared/security.js';
17
+ import { isPathWithin, isLocalRequest, setSecurityHeaders, readBody, SimpleRateLimiter, } from '../shared/security.js';
18
18
  /**
19
19
  * Generate a unique ID for a photon based on its path.
20
20
  * This ensures photons with the same name from different paths are distinguishable.
@@ -29,15 +29,17 @@ const __dirname = path.dirname(__filename);
29
29
  import { listPhotonMCPs, resolvePhotonPath } from '../path-resolver.js';
30
30
  import { PhotonLoader } from '../loader.js';
31
31
  import { logger, createLogger } from '../shared/logger.js';
32
+ import { getErrorMessage } from '../shared/error-handler.js';
32
33
  import { toEnvVarName } from '../shared/config-docs.js';
33
34
  import { MarketplaceManager } from '../marketplace-manager.js';
34
35
  import { PhotonDocExtractor } from '../photon-doc-extractor.js';
35
36
  import { TemplateManager } from '../template-manager.js';
36
37
  import { subscribeChannel, pingDaemon } from '../daemon/client.js';
38
+ import { ensureDaemon } from '../daemon/manager.js';
37
39
  import { SchemaExtractor, } from '@portel/photon-core';
38
40
  import { generateOpenAPISpec } from './openapi-generator.js';
39
41
  import { handleStreamableHTTP, broadcastNotification, broadcastToBeam, sendToSession, requestExternalElicitation, } from './streamable-http-transport.js';
40
- import { SDKMCPClientFactory } from '../mcp-client.js';
42
+ import { SDKMCPClientFactory } from '@portel/photon-core';
41
43
  import { getBundledPhotonPath, BEAM_BUNDLED_PHOTONS } from '../shared-utils.js';
42
44
  // SDK imports for direct resource access (transport wrapper doesn't expose these yet)
43
45
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
@@ -46,7 +48,7 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/
46
48
  import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
47
49
  import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js';
48
50
  // Config file path
49
- const CONFIG_FILE = path.join(os.homedir(), '.photon', 'config.json');
51
+ const CONFIG_FILE = process.env.PHOTON_CONFIG_FILE || path.join(os.homedir(), '.photon', 'config.json');
50
52
  // ════════════════════════════════════════════════════════════════════════════════
51
53
  // EXTERNAL MCP STATE (module-level for MCP transport access)
52
54
  // ════════════════════════════════════════════════════════════════════════════════
@@ -277,19 +279,10 @@ async function loadExternalMCPs(config) {
277
279
  mcpInfo.methods = methods;
278
280
  }
279
281
  catch (sdkError) {
280
- // SDK client failed - fall back to wrapper client
281
- logger.debug(`SDK client failed for ${name}, using wrapper: ${sdkError}`);
282
- // Try wrapper client as fallback
283
- const tools = await client.list();
284
- methods = (tools || []).map((tool) => ({
285
- name: tool.name,
286
- description: tool.description || '',
287
- params: tool.inputSchema || { type: 'object', properties: {} },
288
- returns: { type: 'object' },
289
- icon: tool['x-icon'],
290
- }));
291
- mcpInfo.connected = true;
292
- mcpInfo.methods = methods;
282
+ // SDK client failed — don't fall back to wrapper for stdio MCPs
283
+ // (same command would fail identically, and wrapper spawns a process
284
+ // without stderr suppression, leaking raw Node.js stack traces)
285
+ throw sdkError;
293
286
  }
294
287
  }
295
288
  else {
@@ -318,7 +311,17 @@ async function loadExternalMCPs(config) {
318
311
  catch (error) {
319
312
  const errorMsg = error instanceof Error ? error.message : String(error);
320
313
  mcpInfo.errorMessage = errorMsg.slice(0, 200);
321
- logger.warn(`āš ļø Failed to connect to external MCP: ${name} - ${errorMsg}`);
314
+ // User-friendly error messages for common failures
315
+ const shortMsg = errorMsg.includes('Cannot find module')
316
+ ? `Module not found (run npm build in the MCP directory)`
317
+ : errorMsg.includes('ENOENT')
318
+ ? `Command not found: ${serverConfig.command}`
319
+ : errorMsg.includes('Connection timeout')
320
+ ? `Connection timed out (server may not be running)`
321
+ : errorMsg.includes('Connection closed')
322
+ ? `Server exited immediately (check configuration)`
323
+ : errorMsg.slice(0, 120);
324
+ logger.warn(`āš ļø External MCP "${name}" — ${shortMsg}`);
322
325
  }
323
326
  results.push(mcpInfo);
324
327
  }
@@ -799,6 +802,7 @@ export async function startBeam(rawWorkingDir, port) {
799
802
  catch {
800
803
  // No install metadata - that's fine
801
804
  }
805
+ const isStateful = schemaSource ? /@stateful\b/.test(schemaSource) : false;
802
806
  return {
803
807
  id: generatePhotonId(photonPath),
804
808
  name,
@@ -818,6 +822,7 @@ export async function startBeam(rawWorkingDir, port) {
818
822
  resourceCount,
819
823
  promptCount,
820
824
  installSource,
825
+ ...(isStateful && { stateful: true }),
821
826
  ...(constructorParams.length > 0 && { requiredParams: constructorParams }),
822
827
  ...(mcp.injectedPhotons &&
823
828
  mcp.injectedPhotons.length > 0 && { injectedPhotons: mcp.injectedPhotons }),
@@ -840,57 +845,58 @@ export async function startBeam(rawWorkingDir, port) {
840
845
  }
841
846
  }
842
847
  const channelSubscriptions = new Map();
843
- const EVENT_BUFFER_SIZE = 30; // Keep last 30 events per channel
848
+ /** Buffer retention window — events older than this are purged */
849
+ const EVENT_BUFFER_DURATION_MS = 5 * 60 * 1000; // 5 minutes
844
850
  const channelEventBuffers = new Map();
845
851
  // Store an event in the channel buffer
846
852
  function bufferEvent(channel, method, params) {
847
853
  let buffer = channelEventBuffers.get(channel);
848
854
  if (!buffer) {
849
- buffer = { events: [], nextId: 1 };
855
+ buffer = { events: [] };
850
856
  channelEventBuffers.set(channel, buffer);
851
857
  }
852
- const eventId = buffer.nextId++;
858
+ const now = Date.now();
853
859
  const event = {
854
- id: eventId,
860
+ id: now,
855
861
  method,
856
862
  params,
857
- timestamp: Date.now(),
863
+ timestamp: now,
858
864
  };
859
865
  buffer.events.push(event);
860
- // Keep only last N events (circular buffer)
861
- if (buffer.events.length > EVENT_BUFFER_SIZE) {
866
+ // Purge events older than retention window
867
+ const cutoff = now - EVENT_BUFFER_DURATION_MS;
868
+ while (buffer.events.length > 0 && buffer.events[0].timestamp < cutoff) {
862
869
  buffer.events.shift();
863
870
  }
864
- return eventId;
871
+ return now;
865
872
  }
866
- // Replay missed events to a specific session, or signal refresh needed
867
- function replayEventsToSession(sessionId, channel, lastEventId) {
873
+ // Replay missed events to a specific session, or signal full sync needed
874
+ function replayEventsToSession(sessionId, channel, lastTimestamp) {
868
875
  const buffer = channelEventBuffers.get(channel);
869
876
  // No buffer = no events ever sent on this channel
870
877
  if (!buffer || buffer.events.length === 0) {
871
878
  return { replayed: 0, refreshNeeded: false };
872
879
  }
873
- // No lastEventId = client is fresh, no replay needed
874
- if (lastEventId === undefined) {
880
+ // No lastTimestamp = client is fresh, no replay needed
881
+ if (lastTimestamp === undefined) {
875
882
  return { replayed: 0, refreshNeeded: false };
876
883
  }
877
884
  const oldestEvent = buffer.events[0];
878
- // If lastEventId is older than our oldest buffered event, signal refresh needed
879
- if (lastEventId < oldestEvent.id) {
885
+ // Stale: client's timestamp is older than buffer window → full sync needed
886
+ if (lastTimestamp < oldestEvent.timestamp) {
880
887
  sendToSession(sessionId, 'photon/refresh-needed', { channel });
881
- logger.info(`šŸ“” Replay: ${channel} - lastEventId ${lastEventId} too old (oldest: ${oldestEvent.id}), refresh needed`);
888
+ logger.info(`šŸ“” Stale client on ${channel} - last seen ${new Date(lastTimestamp).toISOString()}, oldest buffered ${new Date(oldestEvent.timestamp).toISOString()}, full sync needed`);
882
889
  return { replayed: 0, refreshNeeded: true };
883
890
  }
884
- // Find events to replay (all events after lastEventId)
885
- const eventsToReplay = buffer.events.filter((e) => e.id > lastEventId);
891
+ // Delta sync: replay events after client's last timestamp
892
+ const eventsToReplay = buffer.events.filter((e) => e.timestamp > lastTimestamp);
886
893
  if (eventsToReplay.length === 0) {
887
894
  return { replayed: 0, refreshNeeded: false };
888
895
  }
889
- // Replay each missed event to this session
890
896
  for (const event of eventsToReplay) {
891
- sendToSession(sessionId, event.method, { ...event.params, _eventId: event.id });
897
+ sendToSession(sessionId, event.method, { ...event.params, _eventId: event.timestamp });
892
898
  }
893
- logger.info(`šŸ“” Replay: ${channel} - replayed ${eventsToReplay.length} events (${lastEventId + 1} to ${buffer.nextId - 1})`);
899
+ logger.info(`šŸ“” Delta sync: ${channel} - replayed ${eventsToReplay.length} events`);
894
900
  return { replayed: eventsToReplay.length, refreshNeeded: false };
895
901
  }
896
902
  // ══════════════════════════════════════════════════════════════════════════════
@@ -964,8 +970,8 @@ export async function startBeam(rawWorkingDir, port) {
964
970
  // Called when a client starts viewing a board (from MCP notification)
965
971
  // photonId: hash of photon path (unique across servers)
966
972
  // itemId: whatever the photon uses to identify the item (e.g., board name)
967
- // lastEventId: optional - if provided, replay missed events or signal refresh needed
968
- function onClientViewingBoard(sessionId, photonId, itemId, lastEventId) {
973
+ // lastTimestamp: optional - if provided, delta sync missed events or signal full sync needed
974
+ function onClientViewingBoard(sessionId, photonId, itemId, lastTimestamp) {
969
975
  const prevState = sessionViewState.get(sessionId);
970
976
  // Unsubscribe from previous item if different
971
977
  if (prevState?.itemId && (prevState.photonId !== photonId || prevState.itemId !== itemId)) {
@@ -976,9 +982,9 @@ export async function startBeam(rawWorkingDir, port) {
976
982
  const channel = `${photonId}:${itemId}`;
977
983
  sessionViewState.set(sessionId, { photonId, itemId });
978
984
  subscribeToChannel(channel);
979
- // Replay missed events if lastEventId is provided
980
- if (lastEventId !== undefined) {
981
- replayEventsToSession(sessionId, channel, lastEventId);
985
+ // Delta sync missed events if lastTimestamp is provided
986
+ if (lastTimestamp !== undefined) {
987
+ replayEventsToSession(sessionId, channel, lastTimestamp);
982
988
  }
983
989
  }
984
990
  // Called when a client disconnects
@@ -2265,6 +2271,26 @@ export async function startBeam(rawWorkingDir, port) {
2265
2271
  logger.info(isNewPhoton
2266
2272
  ? `✨ New photon detected: ${photonName}`
2267
2273
  : `šŸ”„ File change detected, reloading ${photonName}...`);
2274
+ // Auto-scaffold empty photon files with a starter template
2275
+ if (isNewPhoton) {
2276
+ try {
2277
+ const rawContent = await fs.readFile(photonPath, 'utf-8');
2278
+ if (rawContent.trim().length === 0) {
2279
+ const className = photonName
2280
+ .split(/[-_]/)
2281
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
2282
+ .join('');
2283
+ const scaffold = `/**\n * ${className} Photon\n */\n\nexport default class ${className} {\n /**\n * Example tool\n * @param message Message to echo\n */\n async echo(params: { message: string }) {\n return \`Echo: \${params.message}\`;\n }\n}\n`;
2284
+ await fs.writeFile(photonPath, scaffold, 'utf-8');
2285
+ logger.info(`šŸ“ Scaffolded empty file: ${photonName}.photon.ts`);
2286
+ // The write triggers another watcher event which will load the scaffolded photon
2287
+ return;
2288
+ }
2289
+ }
2290
+ catch {
2291
+ // File read failed, continue with normal load attempt
2292
+ }
2293
+ }
2268
2294
  // For new photons, check if configuration is needed first
2269
2295
  if (isNewPhoton) {
2270
2296
  const extractor = new SchemaExtractor();
@@ -2375,6 +2401,7 @@ export async function startBeam(rawWorkingDir, port) {
2375
2401
  // Can't extract params
2376
2402
  }
2377
2403
  backfillEnvDefaults(mcp.instance, reloadConstructorParams);
2404
+ const isStateful = /@stateful\b/.test(reloadSource);
2378
2405
  const reloadedPhoton = {
2379
2406
  id: generatePhotonId(photonPath),
2380
2407
  name: photonName,
@@ -2386,6 +2413,7 @@ export async function startBeam(rawWorkingDir, port) {
2386
2413
  description: reloadClassMeta.description,
2387
2414
  icon: reloadClassMeta.icon,
2388
2415
  internal: reloadClassMeta.internal,
2416
+ ...(isStateful && { stateful: true }),
2389
2417
  ...(reloadConstructorParams.length > 0 && { requiredParams: reloadConstructorParams }),
2390
2418
  ...(mcp.injectedPhotons &&
2391
2419
  mcp.injectedPhotons.length > 0 && { injectedPhotons: mcp.injectedPhotons }),
@@ -2541,6 +2569,14 @@ export async function startBeam(rawWorkingDir, port) {
2541
2569
  console.log(`\n⚔ Photon Beam → ${url} (loading photons...)\n`);
2542
2570
  resolve();
2543
2571
  });
2572
+ // Configure server and socket timeouts to prevent premature disconnections
2573
+ // Disable server timeout for long-lived SSE connections (0 = no timeout)
2574
+ server.setTimeout(0);
2575
+ // Enable TCP keepalive on all connections to prevent intermediary timeouts
2576
+ server.on('connection', (socket) => {
2577
+ socket.setKeepAlive(true, 60000); // Send keepalive probe every 60s
2578
+ socket.setTimeout(0); // Disable socket inactivity timeout
2579
+ });
2544
2580
  };
2545
2581
  tryListen();
2546
2582
  });
@@ -2569,6 +2605,45 @@ export async function startBeam(rawWorkingDir, port) {
2569
2605
  console.log(`⚔ Photon Beam ready (${photonStatus}${mcpStatus})`);
2570
2606
  // Notify connected clients that photon list is now available
2571
2607
  broadcastPhotonChange();
2608
+ // Auto-start daemon and subscribe to state-changed events for stateful photons
2609
+ // Uses reconnect: true so subscriptions survive daemon restarts
2610
+ const statefulPhotons = photons.filter((p) => p.stateful && p.configured);
2611
+ if (statefulPhotons.length > 0) {
2612
+ try {
2613
+ await ensureDaemon();
2614
+ for (const photon of statefulPhotons) {
2615
+ const photonName = photon.name;
2616
+ const channel = `${photonName}:state-changed`;
2617
+ subscribeChannel(photonName, channel, (message) => {
2618
+ broadcastToBeam('photon/state-changed', {
2619
+ photon: photonName,
2620
+ method: message?.method,
2621
+ data: message?.data,
2622
+ });
2623
+ }, {
2624
+ reconnect: true,
2625
+ onReconnect: () => logger.info(`šŸ“” Reconnected ${channel} subscription`),
2626
+ onRefreshNeeded: () => {
2627
+ logger.info(`šŸ“” Refresh needed for ${channel} (events lost during daemon restart)`);
2628
+ broadcastToBeam('photon/state-changed', {
2629
+ photon: photonName,
2630
+ method: '_refresh',
2631
+ data: {},
2632
+ });
2633
+ },
2634
+ })
2635
+ .then(() => {
2636
+ logger.info(`šŸ“” Subscribed to ${channel} for cross-client sync`);
2637
+ })
2638
+ .catch((err) => {
2639
+ logger.warn(`Failed to subscribe to ${channel}: ${getErrorMessage(err)}`);
2640
+ });
2641
+ }
2642
+ }
2643
+ catch (err) {
2644
+ logger.warn(`Failed to start daemon for stateful photons: ${getErrorMessage(err)}`);
2645
+ }
2646
+ }
2572
2647
  // Set up file watchers for symlinked and bundled photon assets (now that photons are loaded)
2573
2648
  for (const photon of photons) {
2574
2649
  if (!photon.path) {