@portel/photon 1.32.2 → 1.32.4

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 (52) hide show
  1. package/README.md +8 -4
  2. package/dist/auto-ui/beam.d.ts.map +1 -1
  3. package/dist/auto-ui/beam.js +56 -14
  4. package/dist/auto-ui/beam.js.map +1 -1
  5. package/dist/auto-ui/bridge/index.js +1 -1
  6. package/dist/auto-ui/streamable-http-transport.d.ts +5 -0
  7. package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
  8. package/dist/auto-ui/streamable-http-transport.js +452 -175
  9. package/dist/auto-ui/streamable-http-transport.js.map +1 -1
  10. package/dist/auto-ui/types.d.ts +34 -0
  11. package/dist/auto-ui/types.d.ts.map +1 -1
  12. package/dist/auto-ui/types.js +57 -0
  13. package/dist/auto-ui/types.js.map +1 -1
  14. package/dist/beam.bundle.js +2492 -1442
  15. package/dist/beam.bundle.js.map +4 -4
  16. package/dist/claude-code-plugin.js +9 -3
  17. package/dist/claude-code-plugin.js.map +1 -1
  18. package/dist/cli/commands/beam.d.ts.map +1 -1
  19. package/dist/cli/commands/beam.js +5 -0
  20. package/dist/cli/commands/beam.js.map +1 -1
  21. package/dist/context.d.ts.map +1 -1
  22. package/dist/context.js +12 -6
  23. package/dist/context.js.map +1 -1
  24. package/dist/daemon/client.d.ts.map +1 -1
  25. package/dist/daemon/client.js +187 -489
  26. package/dist/daemon/client.js.map +1 -1
  27. package/dist/daemon/manager.d.ts +2 -1
  28. package/dist/daemon/manager.d.ts.map +1 -1
  29. package/dist/daemon/manager.js +57 -29
  30. package/dist/daemon/manager.js.map +1 -1
  31. package/dist/daemon/server.js +120 -31
  32. package/dist/daemon/server.js.map +1 -1
  33. package/dist/loader.d.ts.map +1 -1
  34. package/dist/loader.js +19 -8
  35. package/dist/loader.js.map +1 -1
  36. package/dist/photons/marketplace.photon.d.ts.map +1 -1
  37. package/dist/photons/marketplace.photon.js +34 -7
  38. package/dist/photons/marketplace.photon.js.map +1 -1
  39. package/dist/photons/marketplace.photon.ts +35 -7
  40. package/dist/server.d.ts.map +1 -1
  41. package/dist/server.js +40 -6
  42. package/dist/server.js.map +1 -1
  43. package/dist/types/server-types.d.ts +2 -0
  44. package/dist/types/server-types.d.ts.map +1 -1
  45. package/dist/version-notify.d.ts +5 -0
  46. package/dist/version-notify.d.ts.map +1 -1
  47. package/dist/version-notify.js +57 -7
  48. package/dist/version-notify.js.map +1 -1
  49. package/dist/watcher.d.ts.map +1 -1
  50. package/dist/watcher.js +8 -3
  51. package/dist/watcher.js.map +1 -1
  52. package/package.json +89 -73
@@ -49,36 +49,43 @@ const ownerFile = getOwnerFilePath(socketPath);
49
49
  let daemonOwnershipConfirmed = false;
50
50
  async function isSocketResponsive(target) {
51
51
  // Windows named pipes have no filesystem entry; skip the FS gate on
52
- // win32 and let net.createConnection probe the pipe directly. The
53
- // 'error' handler below resolves false on failure; the try/catch
54
- // wrapper guards against sync throws.
52
+ // win32 and let the socket probe the pipe directly. Register the
53
+ // error handler before connect() so Bun cannot surface ENOENT before
54
+ // listeners exist.
55
55
  const isPipe = process.platform === 'win32' && target.startsWith('\\\\.\\pipe\\');
56
56
  if (!isPipe && !fs.existsSync(target))
57
57
  return false;
58
58
  return new Promise((resolve) => {
59
- let client;
60
- try {
61
- // Bun can throw synchronously on a missing/unreachable unix socket
62
- // before the 'error' listener attaches — TOCTOU vs. existsSync above.
63
- client = net.createConnection(target);
64
- }
65
- catch {
66
- resolve(false);
67
- return;
68
- }
59
+ const client = new net.Socket();
60
+ let done = false;
61
+ const finish = (alive) => {
62
+ if (done)
63
+ return;
64
+ done = true;
65
+ clearTimeout(timer);
66
+ if (alive) {
67
+ client.end();
68
+ }
69
+ else {
70
+ client.destroy();
71
+ }
72
+ resolve(alive);
73
+ };
69
74
  const timer = setTimeout(() => {
70
- client.destroy();
71
- resolve(false);
75
+ finish(false);
72
76
  }, 1000);
73
- client.on('connect', () => {
74
- clearTimeout(timer);
75
- client.destroy();
76
- resolve(true);
77
- });
78
77
  client.on('error', () => {
79
- clearTimeout(timer);
80
- resolve(false);
78
+ finish(false);
79
+ });
80
+ client.on('connect', () => {
81
+ finish(true);
81
82
  });
83
+ try {
84
+ client.connect(target);
85
+ }
86
+ catch {
87
+ finish(false);
88
+ }
82
89
  });
83
90
  }
84
91
  // ════════════════════════════════════════════════════════════════════════════════
@@ -193,6 +200,13 @@ const workingDirs = new Map(); // compositeKey -> workingDir
193
200
  // Keyed by resolved file path (realpathSync), not composite key.
194
201
  const fileWatchers = new Map();
195
202
  const watchDebounce = new Map(); // keyed by base:filename, not photon
203
+ const activeFileWatcherReloads = new Set(); // resolved file paths currently being reloaded
204
+ const HOT_RELOAD_DEBOUNCE_MS = parseNonNegativeEnvInt('PHOTON_DAEMON_HOT_RELOAD_DEBOUNCE_MS', 1000);
205
+ const PROACTIVE_METADATA_DEBOUNCE_MS = parseNonNegativeEnvInt('PHOTON_PROACTIVE_METADATA_DEBOUNCE_MS', 1000);
206
+ function parseNonNegativeEnvInt(name, fallback) {
207
+ const parsed = Number(process.env[name]);
208
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
209
+ }
196
210
  const photonSourceStats = new Map();
197
211
  /** Snapshot the stat fields we use as change signal. Null on ENOENT etc. */
198
212
  function statOrNull(filePath) {
@@ -212,6 +226,21 @@ function recordPhotonSourceStat(key, photonPath) {
212
226
  if (s)
213
227
  photonSourceStats.set(key, s);
214
228
  }
229
+ function samePhotonSourceStat(a, b) {
230
+ return !!a && a.mtimeMs === b.mtimeMs && a.size === b.size && a.ino === b.ino;
231
+ }
232
+ function photonSourceAlreadyLoaded(photonName, photonPath) {
233
+ const current = statOrNull(photonPath);
234
+ if (!current)
235
+ return false;
236
+ for (const [key, storedPath] of photonPaths.entries()) {
237
+ if (!key.endsWith(`::${photonName}`) || storedPath !== photonPath)
238
+ continue;
239
+ if (samePhotonSourceStat(photonSourceStats.get(key), current))
240
+ return true;
241
+ }
242
+ return false;
243
+ }
215
244
  /**
216
245
  * Compare the current file stat against the last-recorded stat. If the
217
246
  * file has changed since the cached photon was loaded, trigger a
@@ -232,9 +261,7 @@ async function statGate(key, photonName, photonPath, workingDir) {
232
261
  const current = statOrNull(photonPath);
233
262
  if (!current)
234
263
  return;
235
- if (current.mtimeMs === cached.mtimeMs &&
236
- current.size === cached.size &&
237
- current.ino === cached.ino) {
264
+ if (samePhotonSourceStat(cached, current)) {
238
265
  return;
239
266
  }
240
267
  logger.info('stat-gate: source changed since last load, syncing reload', {
@@ -266,6 +293,9 @@ const stateDirWatchers = new Map();
266
293
  // @scheduled cron changes and @webhook additions reach the declared-set
267
294
  // without a daemon restart. See watchBaseForProactiveMetadata().
268
295
  const baseDirWatchers = new Map();
296
+ // photonDir -> SafeWatcher: startup watcher for new .photon.ts files in each
297
+ // active base. Tracked separately so Bun poll timers are closed on shutdown.
298
+ const startupPhotonDirWatchers = new Map();
269
299
  import { compositeKey as _compositeKey, declaredKey as _declaredKey, webhookKey as _webhookKey, locationKey as _locationKey, findByPhoton, asScheduleKey, } from './registry-keys.js';
270
300
  /**
271
301
  * Create a composite key from photonName + workingDir for map lookups.
@@ -1572,7 +1602,7 @@ async function discoverProactiveMetadataAtBoot() {
1572
1602
  * `@scheduled` cron expressions or `@webhook` additions only reached
1573
1603
  * the daemon at restart.
1574
1604
  *
1575
- * Deduped by filename via the existing watchDebounce map (~150 ms) so
1605
+ * Deduped by filename via the existing watchDebounce map so
1576
1606
  * editors that save-twice don't trigger two scans. Skips files that
1577
1607
  * aren't `.photon.ts`.
1578
1608
  */
@@ -1640,7 +1670,7 @@ function watchBaseForProactiveMetadata(basePath, _isDefaultBase) {
1640
1670
  });
1641
1671
  }
1642
1672
  })();
1643
- }, 150);
1673
+ }, PROACTIVE_METADATA_DEBOUNCE_MS);
1644
1674
  timer.unref();
1645
1675
  watchDebounce.set(debounceKey, timer);
1646
1676
  };
@@ -4320,7 +4350,7 @@ async function applyPatchToInstance(instance, photonName, ops, workingDir) {
4320
4350
  if (keys.length === 0)
4321
4351
  return;
4322
4352
  // Get current state as plain JSON
4323
- const snapshot = await snapshotState(instance, photonName);
4353
+ const snapshot = await snapshotState(instance, photonName, workingDir);
4324
4354
  if (!snapshot)
4325
4355
  return;
4326
4356
  // Apply patch operations
@@ -4515,7 +4545,7 @@ function watchPhotonFile(photonName, photonPath) {
4515
4545
  return;
4516
4546
  try {
4517
4547
  const watcher = safeWatchFile(watchPath, (eventType) => {
4518
- // Debounce: 100ms (same as Beam)
4548
+ // Trailing debounce: wait for write bursts to settle before reloading.
4519
4549
  const existing = watchDebounce.get(watchPath);
4520
4550
  if (existing)
4521
4551
  clearTimeout(existing);
@@ -4554,10 +4584,25 @@ function watchPhotonFile(photonName, photonPath) {
4554
4584
  }
4555
4585
  if (!fs.existsSync(photonPath))
4556
4586
  return;
4587
+ if (activeFileWatcherReloads.has(watchPath)) {
4588
+ logger.debug('Coalescing file watcher event during active reload', {
4589
+ photonName,
4590
+ path: photonPath,
4591
+ });
4592
+ return;
4593
+ }
4594
+ if (photonSourceAlreadyLoaded(photonName, photonPath)) {
4595
+ logger.debug('Ignoring file watcher event for already-loaded source', {
4596
+ photonName,
4597
+ path: photonPath,
4598
+ });
4599
+ return;
4600
+ }
4557
4601
  logger.info('File changed, auto-reloading', { photonName, path: photonPath });
4558
4602
  // Invalidate cached state keys so they're re-extracted from fresh source
4559
4603
  stateKeysCache.delete(photonName);
4560
4604
  try {
4605
+ activeFileWatcherReloads.add(watchPath);
4561
4606
  await reloadPhoton(photonName, photonPath);
4562
4607
  }
4563
4608
  catch (err) {
@@ -4571,8 +4616,11 @@ function watchPhotonFile(photonName, photonPath) {
4571
4616
  error: getErrorMessage(err),
4572
4617
  });
4573
4618
  }
4619
+ finally {
4620
+ activeFileWatcherReloads.delete(watchPath);
4621
+ }
4574
4622
  })();
4575
- }, 100);
4623
+ }, HOT_RELOAD_DEBOUNCE_MS);
4576
4624
  watchDebounce.set(watchPath, timer);
4577
4625
  });
4578
4626
  if (typeof watcher.on === 'function') {
@@ -5117,15 +5165,33 @@ function startupWatchPhotons() {
5117
5165
  const defaultBase = getDefaultContext().baseDir;
5118
5166
  const bases = new Set([defaultBase]);
5119
5167
  for (const base of listActiveBases()) {
5168
+ if (!shouldStartupWatchBase(base.path, defaultBase))
5169
+ continue;
5120
5170
  bases.add(base.path);
5121
5171
  }
5122
5172
  for (const photonDir of bases) {
5123
5173
  startupWatchPhotonDir(photonDir, defaultBase);
5124
5174
  }
5125
5175
  }
5176
+ function shouldStartupWatchBase(basePath, defaultBase) {
5177
+ const resolved = path.resolve(basePath);
5178
+ if (resolved === path.resolve(defaultBase))
5179
+ return true;
5180
+ // Temporary directories are common in Beam and daemon regression tests. The
5181
+ // bases registry keeps them while the OS temp cleaner has not removed them,
5182
+ // but a production daemon should not spend long-lived watchers on them at
5183
+ // every startup. If a user actively invokes a temp photon later, the normal
5184
+ // request path still loads it and registers on-demand watchers.
5185
+ const tmpRoot = path.resolve(os.tmpdir());
5186
+ if (resolved === tmpRoot || resolved.startsWith(tmpRoot + path.sep))
5187
+ return false;
5188
+ return true;
5189
+ }
5126
5190
  function startupWatchPhotonDir(photonDir, defaultBase) {
5127
5191
  if (!fs.existsSync(photonDir))
5128
5192
  return;
5193
+ if (startupPhotonDirWatchers.has(photonDir))
5194
+ return;
5129
5195
  // Host mode: when the default base is host-disabled, skip the file
5130
5196
  // watcher, the eager onInitialize loader, and the directory watcher
5131
5197
  // entirely. They all activate background work that the marker is
@@ -5250,6 +5316,7 @@ function startupWatchPhotonDir(photonDir, defaultBase) {
5250
5316
  if (typeof dirWatcher.on === 'function') {
5251
5317
  dirWatcher.on('error', () => { }); // Non-fatal
5252
5318
  }
5319
+ startupPhotonDirWatchers.set(photonDir, dirWatcher);
5253
5320
  }
5254
5321
  catch {
5255
5322
  logger.warn('Failed to watch photon directory for new files', { dir: photonDir });
@@ -5297,7 +5364,13 @@ function startServer() {
5297
5364
  cleanupSocketSubscriptions(socket);
5298
5365
  });
5299
5366
  socket.on('error', (error) => {
5300
- logger.warn('Socket error', { error: getErrorMessage(error) });
5367
+ const code = error?.code;
5368
+ if (code === 'ECONNRESET' || code === 'EPIPE') {
5369
+ logger.debug('Socket closed by client', { error: getErrorMessage(error) });
5370
+ }
5371
+ else {
5372
+ logger.warn('Socket error', { error: getErrorMessage(error) });
5373
+ }
5301
5374
  connectedSockets.delete(socket);
5302
5375
  cleanupSocketSubscriptions(socket);
5303
5376
  });
@@ -5632,6 +5705,22 @@ function shutdown() {
5632
5705
  for (const photonPath of fileWatchers.keys()) {
5633
5706
  unwatchPhotonFile(photonPath);
5634
5707
  }
5708
+ for (const watcher of startupPhotonDirWatchers.values()) {
5709
+ watcher.close();
5710
+ }
5711
+ startupPhotonDirWatchers.clear();
5712
+ for (const watcher of baseDirWatchers.values()) {
5713
+ watcher.close();
5714
+ }
5715
+ baseDirWatchers.clear();
5716
+ for (const watcher of parentDirWatchers.values()) {
5717
+ watcher.close();
5718
+ }
5719
+ parentDirWatchers.clear();
5720
+ for (const watcher of stateDirWatchers.values()) {
5721
+ watcher.close();
5722
+ }
5723
+ stateDirWatchers.clear();
5635
5724
  // Clean up poll-based watchers (bun fallback)
5636
5725
  for (const timer of pollTimers) {
5637
5726
  clearInterval(timer);