@portel/photon 1.22.0 → 1.23.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 (211) hide show
  1. package/README.md +19 -8
  2. package/dist/a2ui/mapper.d.ts +40 -0
  3. package/dist/a2ui/mapper.d.ts.map +1 -0
  4. package/dist/a2ui/mapper.js +286 -0
  5. package/dist/a2ui/mapper.js.map +1 -0
  6. package/dist/a2ui/types.d.ts +129 -0
  7. package/dist/a2ui/types.d.ts.map +1 -0
  8. package/dist/a2ui/types.js +20 -0
  9. package/dist/a2ui/types.js.map +1 -0
  10. package/dist/ag-ui/adapter.d.ts +9 -1
  11. package/dist/ag-ui/adapter.d.ts.map +1 -1
  12. package/dist/ag-ui/adapter.js +33 -16
  13. package/dist/ag-ui/adapter.js.map +1 -1
  14. package/dist/auto-ui/beam/routes/api-daemon.d.ts +18 -0
  15. package/dist/auto-ui/beam/routes/api-daemon.d.ts.map +1 -0
  16. package/dist/auto-ui/beam/routes/api-daemon.js +118 -0
  17. package/dist/auto-ui/beam/routes/api-daemon.js.map +1 -0
  18. package/dist/auto-ui/beam.d.ts.map +1 -1
  19. package/dist/auto-ui/beam.js +34 -34
  20. package/dist/auto-ui/beam.js.map +1 -1
  21. package/dist/auto-ui/bridge/renderers.d.ts.map +1 -1
  22. package/dist/auto-ui/bridge/renderers.js +371 -0
  23. package/dist/auto-ui/bridge/renderers.js.map +1 -1
  24. package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
  25. package/dist/auto-ui/streamable-http-transport.js +38 -1
  26. package/dist/auto-ui/streamable-http-transport.js.map +1 -1
  27. package/dist/auto-ui/types.d.ts +19 -0
  28. package/dist/auto-ui/types.d.ts.map +1 -1
  29. package/dist/auto-ui/types.js.map +1 -1
  30. package/dist/beam.bundle.js +757 -107
  31. package/dist/beam.bundle.js.map +4 -4
  32. package/dist/cli/commands/beam.d.ts.map +1 -1
  33. package/dist/cli/commands/beam.js +2 -0
  34. package/dist/cli/commands/beam.js.map +1 -1
  35. package/dist/cli/commands/build.d.ts.map +1 -1
  36. package/dist/cli/commands/build.js +2 -0
  37. package/dist/cli/commands/build.js.map +1 -1
  38. package/dist/cli/commands/doctor.d.ts.map +1 -1
  39. package/dist/cli/commands/doctor.js +92 -3
  40. package/dist/cli/commands/doctor.js.map +1 -1
  41. package/dist/cli/commands/host.d.ts.map +1 -1
  42. package/dist/cli/commands/host.js +9 -1
  43. package/dist/cli/commands/host.js.map +1 -1
  44. package/dist/cli/commands/info.d.ts.map +1 -1
  45. package/dist/cli/commands/info.js +7 -3
  46. package/dist/cli/commands/info.js.map +1 -1
  47. package/dist/cli/commands/init.d.ts.map +1 -1
  48. package/dist/cli/commands/init.js +4 -0
  49. package/dist/cli/commands/init.js.map +1 -1
  50. package/dist/cli/commands/maker.d.ts +8 -0
  51. package/dist/cli/commands/maker.d.ts.map +1 -1
  52. package/dist/cli/commands/maker.js +113 -46
  53. package/dist/cli/commands/maker.js.map +1 -1
  54. package/dist/cli/commands/marketplace.d.ts.map +1 -1
  55. package/dist/cli/commands/marketplace.js +7 -1
  56. package/dist/cli/commands/marketplace.js.map +1 -1
  57. package/dist/cli/commands/mcp.d.ts +10 -0
  58. package/dist/cli/commands/mcp.d.ts.map +1 -1
  59. package/dist/cli/commands/mcp.js +215 -4
  60. package/dist/cli/commands/mcp.js.map +1 -1
  61. package/dist/cli/commands/package.d.ts.map +1 -1
  62. package/dist/cli/commands/package.js +33 -15
  63. package/dist/cli/commands/package.js.map +1 -1
  64. package/dist/cli/commands/ps.d.ts +16 -0
  65. package/dist/cli/commands/ps.d.ts.map +1 -0
  66. package/dist/cli/commands/ps.js +267 -0
  67. package/dist/cli/commands/ps.js.map +1 -0
  68. package/dist/cli/commands/run.d.ts.map +1 -1
  69. package/dist/cli/commands/run.js +7 -0
  70. package/dist/cli/commands/run.js.map +1 -1
  71. package/dist/cli/commands/update.d.ts.map +1 -1
  72. package/dist/cli/commands/update.js +14 -4
  73. package/dist/cli/commands/update.js.map +1 -1
  74. package/dist/cli/index.d.ts.map +1 -1
  75. package/dist/cli/index.js +9 -4
  76. package/dist/cli/index.js.map +1 -1
  77. package/dist/context-store.d.ts +4 -4
  78. package/dist/context-store.d.ts.map +1 -1
  79. package/dist/context-store.js +20 -17
  80. package/dist/context-store.js.map +1 -1
  81. package/dist/context.d.ts +5 -4
  82. package/dist/context.d.ts.map +1 -1
  83. package/dist/context.js +68 -14
  84. package/dist/context.js.map +1 -1
  85. package/dist/daemon/client.d.ts +60 -0
  86. package/dist/daemon/client.d.ts.map +1 -1
  87. package/dist/daemon/client.js +76 -0
  88. package/dist/daemon/client.js.map +1 -1
  89. package/dist/daemon/execution-history-sqlite.d.ts +50 -0
  90. package/dist/daemon/execution-history-sqlite.d.ts.map +1 -0
  91. package/dist/daemon/execution-history-sqlite.js +165 -0
  92. package/dist/daemon/execution-history-sqlite.js.map +1 -0
  93. package/dist/daemon/execution-history.d.ts +78 -0
  94. package/dist/daemon/execution-history.d.ts.map +1 -0
  95. package/dist/daemon/execution-history.js +246 -0
  96. package/dist/daemon/execution-history.js.map +1 -0
  97. package/dist/daemon/hot-reload-state.d.ts +27 -0
  98. package/dist/daemon/hot-reload-state.d.ts.map +1 -0
  99. package/dist/daemon/hot-reload-state.js +48 -0
  100. package/dist/daemon/hot-reload-state.js.map +1 -0
  101. package/dist/daemon/protocol.d.ts +5 -1
  102. package/dist/daemon/protocol.d.ts.map +1 -1
  103. package/dist/daemon/protocol.js +13 -0
  104. package/dist/daemon/protocol.js.map +1 -1
  105. package/dist/daemon/registry-keys.d.ts +88 -0
  106. package/dist/daemon/registry-keys.d.ts.map +1 -0
  107. package/dist/daemon/registry-keys.js +91 -0
  108. package/dist/daemon/registry-keys.js.map +1 -0
  109. package/dist/daemon/server.js +1521 -186
  110. package/dist/daemon/server.js.map +1 -1
  111. package/dist/daemon/session-resolver.d.ts +28 -0
  112. package/dist/daemon/session-resolver.d.ts.map +1 -0
  113. package/dist/daemon/session-resolver.js +41 -0
  114. package/dist/daemon/session-resolver.js.map +1 -0
  115. package/dist/data-migration.js +20 -9
  116. package/dist/data-migration.js.map +1 -1
  117. package/dist/loader.d.ts +22 -8
  118. package/dist/loader.d.ts.map +1 -1
  119. package/dist/loader.js +214 -94
  120. package/dist/loader.js.map +1 -1
  121. package/dist/marketplace-manager.d.ts.map +1 -1
  122. package/dist/marketplace-manager.js +9 -5
  123. package/dist/marketplace-manager.js.map +1 -1
  124. package/dist/namespace-migration.d.ts.map +1 -1
  125. package/dist/namespace-migration.js +28 -23
  126. package/dist/namespace-migration.js.map +1 -1
  127. package/dist/photon-cli-runner.d.ts.map +1 -1
  128. package/dist/photon-cli-runner.js +57 -8
  129. package/dist/photon-cli-runner.js.map +1 -1
  130. package/dist/serv/auth/auth-store.d.ts +155 -0
  131. package/dist/serv/auth/auth-store.d.ts.map +1 -0
  132. package/dist/serv/auth/auth-store.js +240 -0
  133. package/dist/serv/auth/auth-store.js.map +1 -0
  134. package/dist/serv/auth/endpoints.d.ts +113 -0
  135. package/dist/serv/auth/endpoints.d.ts.map +1 -0
  136. package/dist/serv/auth/endpoints.js +1005 -0
  137. package/dist/serv/auth/endpoints.js.map +1 -0
  138. package/dist/serv/auth/http-adapter.d.ts +60 -0
  139. package/dist/serv/auth/http-adapter.d.ts.map +1 -0
  140. package/dist/serv/auth/http-adapter.js +235 -0
  141. package/dist/serv/auth/http-adapter.js.map +1 -0
  142. package/dist/serv/auth/jwt.d.ts +92 -6
  143. package/dist/serv/auth/jwt.d.ts.map +1 -1
  144. package/dist/serv/auth/jwt.js +226 -24
  145. package/dist/serv/auth/jwt.js.map +1 -1
  146. package/dist/serv/auth/oauth-sqlite-stores.d.ts +48 -0
  147. package/dist/serv/auth/oauth-sqlite-stores.d.ts.map +1 -0
  148. package/dist/serv/auth/oauth-sqlite-stores.js +212 -0
  149. package/dist/serv/auth/oauth-sqlite-stores.js.map +1 -0
  150. package/dist/serv/auth/sqlite-stores.d.ts +85 -0
  151. package/dist/serv/auth/sqlite-stores.d.ts.map +1 -0
  152. package/dist/serv/auth/sqlite-stores.js +446 -0
  153. package/dist/serv/auth/sqlite-stores.js.map +1 -0
  154. package/dist/serv/auth/well-known.d.ts +54 -1
  155. package/dist/serv/auth/well-known.d.ts.map +1 -1
  156. package/dist/serv/auth/well-known.js +166 -17
  157. package/dist/serv/auth/well-known.js.map +1 -1
  158. package/dist/serv/index.d.ts +45 -2
  159. package/dist/serv/index.d.ts.map +1 -1
  160. package/dist/serv/index.js +65 -1
  161. package/dist/serv/index.js.map +1 -1
  162. package/dist/serv/types/index.d.ts +80 -0
  163. package/dist/serv/types/index.d.ts.map +1 -1
  164. package/dist/serv/types/index.js.map +1 -1
  165. package/dist/server.d.ts.map +1 -1
  166. package/dist/server.js +61 -6
  167. package/dist/server.js.map +1 -1
  168. package/dist/shared/announce-context.d.ts +51 -0
  169. package/dist/shared/announce-context.d.ts.map +1 -0
  170. package/dist/shared/announce-context.js +73 -0
  171. package/dist/shared/announce-context.js.map +1 -0
  172. package/dist/shared/audit-sqlite.d.ts +63 -0
  173. package/dist/shared/audit-sqlite.d.ts.map +1 -0
  174. package/dist/shared/audit-sqlite.js +187 -0
  175. package/dist/shared/audit-sqlite.js.map +1 -0
  176. package/dist/shared/audit.d.ts +25 -3
  177. package/dist/shared/audit.d.ts.map +1 -1
  178. package/dist/shared/audit.js +97 -3
  179. package/dist/shared/audit.js.map +1 -1
  180. package/dist/shared/error-handler.d.ts +10 -1
  181. package/dist/shared/error-handler.d.ts.map +1 -1
  182. package/dist/shared/error-handler.js +17 -2
  183. package/dist/shared/error-handler.js.map +1 -1
  184. package/dist/shared/security.d.ts +12 -0
  185. package/dist/shared/security.d.ts.map +1 -1
  186. package/dist/shared/security.js +80 -0
  187. package/dist/shared/security.js.map +1 -1
  188. package/dist/shared/sqlite-runtime.d.ts +46 -0
  189. package/dist/shared/sqlite-runtime.d.ts.map +1 -0
  190. package/dist/shared/sqlite-runtime.js +110 -0
  191. package/dist/shared/sqlite-runtime.js.map +1 -0
  192. package/dist/tasks/store.d.ts +1 -1
  193. package/dist/tasks/store.d.ts.map +1 -1
  194. package/dist/tasks/store.js +29 -15
  195. package/dist/tasks/store.js.map +1 -1
  196. package/dist/telemetry/metrics.d.ts +26 -0
  197. package/dist/telemetry/metrics.d.ts.map +1 -1
  198. package/dist/telemetry/metrics.js +31 -0
  199. package/dist/telemetry/metrics.js.map +1 -1
  200. package/dist/test-runner.d.ts.map +1 -1
  201. package/dist/test-runner.js +3 -3
  202. package/dist/test-runner.js.map +1 -1
  203. package/dist/version-checker.d.ts.map +1 -1
  204. package/dist/version-checker.js +7 -14
  205. package/dist/version-checker.js.map +1 -1
  206. package/dist/version.d.ts +12 -0
  207. package/dist/version.d.ts.map +1 -1
  208. package/dist/version.js +103 -1
  209. package/dist/version.js.map +1 -1
  210. package/package.json +6 -2
  211. package/templates/photon.template.ts +7 -13
package/dist/loader.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * Loads a single .photon.ts file and extracts tools
5
5
  */
6
6
  import * as fs from 'fs/promises';
7
- import { realpathSync, existsSync, mkdirSync, symlinkSync, readFileSync } from 'fs';
7
+ import { realpathSync, existsSync, mkdirSync, symlinkSync, readFileSync, statSync, } from 'fs';
8
8
  import { readText, readJSON, writeText, writeJSON } from './shared/io.js';
9
9
  import { createRequire } from 'module';
10
10
  import * as path from 'path';
@@ -13,7 +13,7 @@ import * as crypto from 'crypto';
13
13
  import { startToolSpan } from './telemetry/otel.js';
14
14
  import { recordToolCall, recordCircuitStateChange, recordRateLimitRejection, recordBulkheadRejection, } from './telemetry/metrics.js';
15
15
  import { runWithRequestContext } from './telemetry/context.js';
16
- import { spawn } from 'child_process';
16
+ import { spawn, execSync } from 'child_process';
17
17
  import { SchemaExtractor, DependencyManager,
18
18
  // Generator utilities (ask/emit pattern from 1.2.0)
19
19
  isAsyncGenerator, executeGenerator,
@@ -38,9 +38,9 @@ executionContext,
38
38
  // Lock helper
39
39
  withLock as withLockHelper,
40
40
  // Middleware system
41
- builtinRegistry, MiddlewareRegistry, buildMiddlewareChain, detectNamespace, getCacheDir, } from '@portel/photon-core';
41
+ builtinRegistry, MiddlewareRegistry, buildMiddlewareChain, getCacheDir, } from '@portel/photon-core';
42
42
  import { getDefaultContext } from './context.js';
43
- import * as os from 'os';
43
+ import { getInstanceStatePath } from './context-store.js';
44
44
  import { MarketplaceManager } from './marketplace-manager.js';
45
45
  import { PHOTON_VERSION, getResolvedPhotonCoreVersion } from './version.js';
46
46
  // Timeout for external fetch requests (marketplace, GitHub)
@@ -686,10 +686,75 @@ export class PhotonLoader {
686
686
  // Set photon name and namespace for event source identification and data paths
687
687
  instance._photonName = name;
688
688
  instance._photonNamespace = this.resolveNamespace(absolutePath);
689
+ // Pin baseDir so this.memory (and any other .data/-rooted state)
690
+ // resolves to the same root no matter which process reads back.
691
+ // Without this, MemoryProvider falls through to getDefaultContext()
692
+ // and drifts between cwd-derived locations across daemon restarts.
693
+ instance._baseDir = this.baseDir;
689
694
  // Inject instance name for named instances (runtime concept, not code)
690
695
  instance.instanceName = options?.instanceName ?? '';
691
696
  // Inject file path for storage()/assets() resolution
692
697
  instance._photonFilePath = absolutePath;
698
+ // Inject this.shell — execSync wrapper with cwd defaulted to the
699
+ // photon's own folder. Shelling out from a photon method should run
700
+ // from where the photon lives, not the daemon's cwd, so that
701
+ // `photon cli peer method` inherits the same marketplace context the
702
+ // user would see when running the command from that folder directly.
703
+ // User-defined shell() on the class wins (no clobber).
704
+ if (typeof instance.shell !== 'function') {
705
+ const photonDir = path.dirname(absolutePath);
706
+ const loaderLogger = this.logger;
707
+ instance.shell = (cmd, timeoutMs = 30000) => {
708
+ try {
709
+ return execSync(cmd, {
710
+ cwd: photonDir,
711
+ timeout: timeoutMs,
712
+ encoding: 'utf-8',
713
+ stdio: ['ignore', 'pipe', 'pipe'],
714
+ });
715
+ }
716
+ catch (err) {
717
+ // execSync throws on non-zero exit or timeout. Preserve any
718
+ // partial stdout for callers that parse incremental output;
719
+ // surface stderr through the loader logger so the failure is
720
+ // not silent.
721
+ const e = err;
722
+ const stderr = typeof e.stderr === 'string' ? e.stderr : e.stderr?.toString('utf-8');
723
+ if (stderr) {
724
+ loaderLogger.warn(`this.shell error in ${name}`, {
725
+ cmd,
726
+ stderr: stderr.slice(0, 500),
727
+ });
728
+ }
729
+ const stdout = typeof e.stdout === 'string' ? e.stdout : e.stdout?.toString('utf-8');
730
+ return stdout ?? '';
731
+ }
732
+ };
733
+ }
734
+ // Stat-gate baseline for CLI-direct dispatch. Daemon-routed calls
735
+ // have their own equivalent at src/daemon/server.ts; this ensures
736
+ // `photon cli foo bar` run immediately after a `sed -i` sees the
737
+ // new code on the first call.
738
+ try {
739
+ const s = statSync(absolutePath);
740
+ instance._photonSourceStat = { mtimeMs: s.mtimeMs, size: s.size, ino: s.ino };
741
+ }
742
+ catch {
743
+ // No stat — the gate stays a no-op for this instance.
744
+ }
745
+ instance._photonReloader = async () => {
746
+ try {
747
+ await this.reloadFile(absolutePath);
748
+ const s = statSync(absolutePath);
749
+ instance._photonSourceStat = { mtimeMs: s.mtimeMs, size: s.size, ino: s.ino };
750
+ }
751
+ catch (err) {
752
+ this.logger.debug('Stat-gate reload failed', {
753
+ path: absolutePath,
754
+ error: getErrorMessage(err),
755
+ });
756
+ }
757
+ };
693
758
  // Inject dynamic photon resolver for this.photon.use()
694
759
  instance._photonResolver = (photonName, instanceName) => {
695
760
  return this.resolveAndLoadPhoton(photonName, absolutePath, instanceName);
@@ -754,9 +819,12 @@ export class PhotonLoader {
754
819
  this.log(`🔍 Detected capabilities for ${name}: ${[...caps].join(', ')}`);
755
820
  }
756
821
  this.injectPathHelpers(instance, tsContent);
757
- if (caps.has('emit')) {
758
- // Inject emit() that reads from executionContext (set during executeTool)
759
- // Falls back to last known outputHandler for timer-based emits after method returns
822
+ // Always-inject emit + its helpers (render/toast/log/status/progress/
823
+ // thinking). The injection is a pure closure — zero cost if unused.
824
+ // Dropping the capability-detection gate means typed-access patterns
825
+ // like `(this as any).emit(...)` work even if the photon-core regex
826
+ // misses them. User-defined emit on the class wins (no clobber).
827
+ if (!instance.emit) {
760
828
  instance.emit = (data) => {
761
829
  const store = executionContext.getStore();
762
830
  const emitData = instance._photonName && typeof data === 'object' && data !== null
@@ -811,8 +879,12 @@ export class PhotonLoader {
811
879
  configurable: true,
812
880
  });
813
881
  }
814
- if (caps.has('call') && !instance.call) {
815
- // Inject call() for cross-photon communication
882
+ // Inject call() for cross-photon communication. Always-inject (no
883
+ // capability-detection gate) because the underlying _callHandler
884
+ // is ALWAYS wired above. Gating on a regex that matches literal
885
+ // `this.call(` misses typed-access patterns like
886
+ // `(this as any).call(...)` and fails silently at runtime.
887
+ if (!instance.call) {
816
888
  instance.call = async (target, params = {}, options) => {
817
889
  const dotIndex = target.indexOf('.');
818
890
  if (dotIndex === -1) {
@@ -826,7 +898,8 @@ export class PhotonLoader {
826
898
  return instance._callHandler(photonName, methodName, params, options?.instance);
827
899
  };
828
900
  }
829
- if (caps.has('mcp') && !instance.mcp) {
901
+ // Always-inject mcp(). Pure closure. User-defined mcp wins.
902
+ if (!instance.mcp) {
830
903
  // Inject mcp() accessor for external MCP server access
831
904
  const mcpClients = new Map();
832
905
  instance.mcp = (mcpName) => {
@@ -842,9 +915,10 @@ export class PhotonLoader {
842
915
  return client;
843
916
  };
844
917
  }
845
- if (caps.has('caller') &&
846
- !Object.getOwnPropertyDescriptor(Object.getPrototypeOf(instance), 'caller')) {
847
- // Inject caller getter that reads from executionContext
918
+ // Always-inject caller getter. The prototype check preserves any
919
+ // user-defined getter on the class (e.g. Photon base class has its
920
+ // own) so we don't clobber.
921
+ if (!Object.getOwnPropertyDescriptor(Object.getPrototypeOf(instance), 'caller')) {
848
922
  Object.defineProperty(instance, 'caller', {
849
923
  get() {
850
924
  const store = executionContext.getStore();
@@ -853,8 +927,8 @@ export class PhotonLoader {
853
927
  configurable: true,
854
928
  });
855
929
  }
856
- if (caps.has('lock') && !instance.withLock) {
857
- // Inject withLock() helper
930
+ // Always-inject withLock. Pure closure. User-defined wins.
931
+ if (!instance.withLock) {
858
932
  instance.withLock = async (lockName, fn, timeout) => {
859
933
  return withLockHelper(lockName, fn, timeout);
860
934
  };
@@ -884,8 +958,7 @@ export class PhotonLoader {
884
958
  if (caps.has('instanceMeta')) {
885
959
  // Inject instance metadata (file-stat-based timestamps)
886
960
  const instanceName = options?.instanceName || 'default';
887
- const stateDir = path.join(this.baseDir, 'state', name);
888
- const stateFile = path.join(stateDir, `${instanceName}.json`);
961
+ const stateFile = getInstanceStatePath(name, instanceName, this.baseDir);
889
962
  try {
890
963
  const stat = await fs.stat(stateFile);
891
964
  instance.instanceMeta = {
@@ -903,37 +976,43 @@ export class PhotonLoader {
903
976
  };
904
977
  }
905
978
  }
906
- if (caps.has('allInstances')) {
907
- // Inject cross-instance iterator
979
+ // Always-inject allInstances. The async generator is lazy — directory
980
+ // reads only happen when the caller iterates. User-defined wins.
981
+ if (!instance.allInstances) {
982
+ // Inject cross-instance iterator. Walks the Option B layout —
983
+ // `.data/{photon}/state/{instance}/state.json` — one subdirectory
984
+ // per instance. getInstanceStatePath keeps the path source truth
985
+ // in one place.
908
986
  const photonName = name;
909
987
  const loaderBaseDir = this.baseDir;
910
988
  instance.allInstances = async function* () {
911
- const stateDir = path.join(loaderBaseDir, 'state', photonName);
989
+ // Derive the parent state dir from a known instance path.
990
+ const stateDir = path.dirname(path.dirname(getInstanceStatePath(photonName, 'default', loaderBaseDir)));
991
+ let subdirs;
912
992
  try {
913
- const files = await fs.readdir(stateDir);
914
- for (const file of files.filter((f) => f.endsWith('.json'))) {
915
- const instName = file.replace('.json', '');
916
- const statePath = path.join(stateDir, file);
917
- try {
918
- const snapshot = await readJSON(statePath);
919
- const stat = await fs.stat(statePath);
920
- yield {
921
- name: instName,
922
- meta: {
923
- name: instName,
924
- createdAt: stat.birthtime.toISOString(),
925
- updatedAt: stat.mtime.toISOString(),
926
- },
927
- state: snapshot,
928
- };
929
- }
930
- catch {
931
- // Skip corrupted state files
932
- }
933
- }
993
+ subdirs = await fs.readdir(stateDir);
934
994
  }
935
995
  catch {
936
- // State dir doesn't exist yet — no instances
996
+ return; // state dir doesn't exist yet — no instances
997
+ }
998
+ for (const instName of subdirs) {
999
+ const statePath = getInstanceStatePath(photonName, instName, loaderBaseDir);
1000
+ try {
1001
+ const snapshot = await readJSON(statePath);
1002
+ const stat = await fs.stat(statePath);
1003
+ yield {
1004
+ name: instName,
1005
+ meta: {
1006
+ name: instName,
1007
+ createdAt: stat.birthtime.toISOString(),
1008
+ updatedAt: stat.mtime.toISOString(),
1009
+ },
1010
+ state: snapshot,
1011
+ };
1012
+ }
1013
+ catch {
1014
+ // Not a state subdir, or a corrupted / legacy file — skip.
1015
+ }
937
1016
  }
938
1017
  };
939
1018
  }
@@ -1042,26 +1121,11 @@ export class PhotonLoader {
1042
1121
  if (tsContent) {
1043
1122
  await this.checkCLIDependencies(tsContent, name);
1044
1123
  }
1045
- // Call lifecycle hook if present with error handling
1046
- // skipInitialize is used during hot-reload: state is transferred from the old
1047
- // instance after loadFile returns, then onInitialize is called manually.
1048
- const onInitialize = instance.onInitialize;
1049
- if (typeof onInitialize === 'function' && !options?.skipInitialize) {
1050
- this.onProgress?.('running onInitialize');
1051
- try {
1052
- await onInitialize.call(instance);
1053
- }
1054
- catch (error) {
1055
- const initError = new Error(`Initialization failed for ${name}: ${getErrorMessage(error)}\n` +
1056
- `\nThe onInitialize() lifecycle hook threw an error.\n` +
1057
- `Check your constructor configuration and initialization logic.`);
1058
- initError.name = 'PhotonInitializationError';
1059
- if (error instanceof Error && error.stack) {
1060
- initError.stack = error.stack;
1061
- }
1062
- throw initError;
1063
- }
1064
- }
1124
+ // Call lifecycle hook if present with error handling.
1125
+ // skipInitialize is used during hot-reload: state is transferred from
1126
+ // the old instance after loadFile returns, then onInitialize is called
1127
+ // manually.
1128
+ await this.invokeInitialize(instance, name, options);
1065
1129
  // Auto-wrap public methods in @stateful classes to emit events
1066
1130
  // Must happen AFTER capability injection so that emit() is available
1067
1131
  if (tsContent) {
@@ -1180,13 +1244,46 @@ export class PhotonLoader {
1180
1244
  }
1181
1245
  instance._photonName = name;
1182
1246
  instance._photonNamespace = this.resolveNamespace(absolutePath);
1247
+ // Same pin as the file-load path above — see that comment for why
1248
+ // this.memory drifts without it.
1249
+ instance._baseDir = this.baseDir;
1183
1250
  instance.instanceName = options?.instanceName ?? '';
1184
1251
  // Inject file path for storage()/assets() resolution.
1185
- // For preloaded modules (compiled binaries), remap to ~/.photon/ so storage()
1186
- // resolves under the deployment directory instead of the build machine's source tree.
1252
+ // For preloaded modules (compiled binaries), remap to the resolved
1253
+ // PHOTON_DIR (this.baseDir) so storage() resolves under the deployment
1254
+ // directory instead of the build machine's source tree. Respects Option B:
1255
+ // storage follows wherever the photon is deployed, not always ~/.photon.
1187
1256
  // Assets still use absolutePath (resolved from embedded source paths).
1188
- const storagePath = path.join(os.homedir(), '.photon', path.basename(absolutePath));
1257
+ const storagePath = path.join(this.baseDir, path.basename(absolutePath));
1189
1258
  instance._photonFilePath = storagePath;
1259
+ // Inject this.shell — see the primary load path for rationale.
1260
+ // User-defined shell() wins; we don't clobber.
1261
+ if (typeof instance.shell !== 'function') {
1262
+ const photonDir = path.dirname(absolutePath);
1263
+ const loaderLogger = this.logger;
1264
+ instance.shell = (cmd, timeoutMs = 30000) => {
1265
+ try {
1266
+ return execSync(cmd, {
1267
+ cwd: photonDir,
1268
+ timeout: timeoutMs,
1269
+ encoding: 'utf-8',
1270
+ stdio: ['ignore', 'pipe', 'pipe'],
1271
+ });
1272
+ }
1273
+ catch (err) {
1274
+ const e = err;
1275
+ const stderr = typeof e.stderr === 'string' ? e.stderr : e.stderr?.toString('utf-8');
1276
+ if (stderr) {
1277
+ loaderLogger.warn(`this.shell error in ${name}`, {
1278
+ cmd,
1279
+ stderr: stderr.slice(0, 500),
1280
+ });
1281
+ }
1282
+ const stdout = typeof e.stdout === 'string' ? e.stdout : e.stdout?.toString('utf-8');
1283
+ return stdout ?? '';
1284
+ }
1285
+ };
1286
+ }
1190
1287
  // Inject dynamic photon resolver for this.photon.use()
1191
1288
  instance._photonResolver = (photonName, instanceName) => {
1192
1289
  return this.resolveAndLoadPhoton(photonName, absolutePath, instanceName);
@@ -1225,7 +1322,8 @@ export class PhotonLoader {
1225
1322
  if (detectEmitHelperUsage(tsContent))
1226
1323
  caps.add('emit');
1227
1324
  this.injectPathHelpers(instance, tsContent);
1228
- if (caps.has('emit')) {
1325
+ // Always-inject emit (see primary path). User-defined wins.
1326
+ if (!instance.emit) {
1229
1327
  instance.emit = (data) => {
1230
1328
  const store = executionContext.getStore();
1231
1329
  const emitData = instance._photonName && typeof data === 'object' && data !== null
@@ -1273,7 +1371,8 @@ export class PhotonLoader {
1273
1371
  configurable: true,
1274
1372
  });
1275
1373
  }
1276
- if (caps.has('call') && !instance.call) {
1374
+ // Always-inject call() (see the primary load path for rationale).
1375
+ if (!instance.call) {
1277
1376
  instance.call = async (target, params = {}, opts) => {
1278
1377
  const dotIndex = target.indexOf('.');
1279
1378
  if (dotIndex === -1) {
@@ -1348,18 +1447,7 @@ export class PhotonLoader {
1348
1447
  await this.checkCLIDependencies(tsContent, name);
1349
1448
  }
1350
1449
  // Call lifecycle hook
1351
- const onInitialize = instance.onInitialize;
1352
- if (typeof onInitialize === 'function' && !options?.skipInitialize) {
1353
- this.onProgress?.('running onInitialize');
1354
- try {
1355
- await onInitialize.call(instance);
1356
- }
1357
- catch (error) {
1358
- const initError = new Error(`Initialization failed for ${name}: ${getErrorMessage(error)}`);
1359
- initError.name = 'PhotonInitializationError';
1360
- throw initError;
1361
- }
1362
- }
1450
+ await this.invokeInitialize(instance, name, options);
1363
1451
  // Extract tools and metadata from embedded source (no disk I/O)
1364
1452
  const { tools, templates, statics, settingsSchema, auth: extractedAuth, } = await this.extractTools(MCPClass, absolutePath, tsContent);
1365
1453
  // Settings injection
@@ -1419,7 +1507,7 @@ export class PhotonLoader {
1419
1507
  /**
1420
1508
  * Reload a Photon MCP file (for hot reload)
1421
1509
  */
1422
- async reloadFile(filePath) {
1510
+ async reloadFile(filePath, options) {
1423
1511
  // Invalidate the cache for this file
1424
1512
  const absolutePath = path.resolve(filePath);
1425
1513
  if (absolutePath.endsWith('.ts')) {
@@ -1445,7 +1533,7 @@ export class PhotonLoader {
1445
1533
  }
1446
1534
  }
1447
1535
  }
1448
- return this.loadFile(filePath);
1536
+ return this.loadFile(filePath, options);
1449
1537
  }
1450
1538
  /**
1451
1539
  * Compile TypeScript file to JavaScript and cache it
@@ -1677,10 +1765,16 @@ export class PhotonLoader {
1677
1765
  // SETTINGS — property-driven configuration with auto-persistence
1678
1766
  // ════════════════════════════════════════════════════════════════════════════
1679
1767
  /**
1680
- * Get the settings persistence path for a photon instance
1768
+ * Get the settings persistence path for a photon instance.
1769
+ * Co-located with state under Option B: the settings JSON lives next to
1770
+ * state.json inside the per-instance directory.
1681
1771
  */
1682
1772
  getSettingsPath(photonName, instanceName) {
1683
- return path.join(this.baseDir, 'state', photonName, `${instanceName}-settings.json`);
1773
+ // getInstanceStatePath returns `.../state/{instance}/state.json`; swap
1774
+ // the filename so settings land at `.../state/{instance}/settings.json`.
1775
+ // Keeps the source of truth for the layout in context-store.
1776
+ const statePath = getInstanceStatePath(photonName, instanceName, this.baseDir);
1777
+ return path.join(path.dirname(statePath), 'settings.json');
1684
1778
  }
1685
1779
  /**
1686
1780
  * Load persisted settings from disk
@@ -3668,23 +3762,49 @@ Run: photon mcp ${mcpName} --config
3668
3762
  });
3669
3763
  }
3670
3764
  /**
3671
- * Resolve the namespace for a photon based on its file path.
3672
- * Extracts the directory name between baseDir and the photon file.
3765
+ * Resolve the namespace for a photon based purely on its directory
3766
+ * position relative to baseDir. See docs/internals/PHOTON-DIR-AND-NAMESPACE.md.
3767
+ *
3768
+ * {baseDir}/foo.photon.ts → '' (flat at root)
3769
+ * {baseDir}/alice/foo.photon.ts → 'alice'
3770
+ * {baseDir}/org/team/foo.photon.ts → 'org/team'
3673
3771
  *
3674
- * Examples:
3675
- * ~/.photon/portel-dev/todo.photon.ts 'portel-dev'
3676
- * ~/.photon/acme/todo.photon.ts → 'acme'
3677
- * ~/.photon/todo.photon.ts → detected from git or 'local'
3772
+ * The runtime never consults git state. PHOTON_DIR is the outer boundary;
3773
+ * the file's position within it is the only namespace signal.
3678
3774
  */
3679
3775
  resolveNamespace(absolutePath) {
3680
3776
  const rel = path.relative(this.baseDir, absolutePath);
3681
3777
  const parts = rel.split(path.sep);
3682
- // If file is in a subdirectory (namespace/photon.ts), use that as namespace
3683
- if (parts.length >= 2) {
3684
- return parts[0];
3778
+ if (parts.length < 2)
3779
+ return '';
3780
+ return parts.slice(0, -1).join(path.sep);
3781
+ }
3782
+ /**
3783
+ * Invoke the `onInitialize` lifecycle hook on a loaded photon instance,
3784
+ * honoring the `skipInitialize` option and wrapping any thrown error in
3785
+ * a PhotonInitializationError so the caller can surface hook failures
3786
+ * distinctly from runtime errors.
3787
+ */
3788
+ async invokeInitialize(instance, name, options, ctx) {
3789
+ const hook = instance?.onInitialize;
3790
+ if (typeof hook !== 'function' || options?.skipInitialize)
3791
+ return;
3792
+ this.onProgress?.('running onInitialize');
3793
+ try {
3794
+ await hook.call(instance, ctx);
3795
+ }
3796
+ catch (error) {
3797
+ const message = ctx
3798
+ ? `Initialization failed for ${name}: ${getErrorMessage(error)}`
3799
+ : `Initialization failed for ${name}: ${getErrorMessage(error)}\n` +
3800
+ `\nThe onInitialize() lifecycle hook threw an error.\n` +
3801
+ `Check your constructor configuration and initialization logic.`;
3802
+ const initError = new Error(message);
3803
+ initError.name = 'PhotonInitializationError';
3804
+ if (error instanceof Error && error.stack)
3805
+ initError.stack = error.stack;
3806
+ throw initError;
3685
3807
  }
3686
- // Flat file at root — detect from git remote or default to 'local'
3687
- return detectNamespace(this.baseDir);
3688
3808
  }
3689
3809
  /**
3690
3810
  * Discover and extract assets from a Photon file