@portel/photon 1.20.1 → 1.22.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 (148) hide show
  1. package/README.md +5 -5
  2. package/dist/ag-ui/adapter.d.ts +4 -1
  3. package/dist/ag-ui/adapter.d.ts.map +1 -1
  4. package/dist/ag-ui/adapter.js +58 -3
  5. package/dist/ag-ui/adapter.js.map +1 -1
  6. package/dist/ag-ui/types.d.ts +12 -0
  7. package/dist/ag-ui/types.d.ts.map +1 -1
  8. package/dist/auto-ui/beam/routes/api-browse.d.ts.map +1 -1
  9. package/dist/auto-ui/beam/routes/api-browse.js +8 -49
  10. package/dist/auto-ui/beam/routes/api-browse.js.map +1 -1
  11. package/dist/auto-ui/beam/routes/api-config.d.ts +1 -1
  12. package/dist/auto-ui/beam/routes/api-config.d.ts.map +1 -1
  13. package/dist/auto-ui/beam/routes/api-config.js +79 -1
  14. package/dist/auto-ui/beam/routes/api-config.js.map +1 -1
  15. package/dist/auto-ui/beam.d.ts.map +1 -1
  16. package/dist/auto-ui/beam.js +23 -31
  17. package/dist/auto-ui/beam.js.map +1 -1
  18. package/dist/auto-ui/bridge/index.d.ts.map +1 -1
  19. package/dist/auto-ui/bridge/index.js +107 -11
  20. package/dist/auto-ui/bridge/index.js.map +1 -1
  21. package/dist/auto-ui/bridge/renderers.d.ts +14 -0
  22. package/dist/auto-ui/bridge/renderers.d.ts.map +1 -1
  23. package/dist/auto-ui/bridge/renderers.js +680 -57
  24. package/dist/auto-ui/bridge/renderers.js.map +1 -1
  25. package/dist/auto-ui/frontend/index.html +3 -3
  26. package/dist/auto-ui/frontend/pure-view.html +19 -19
  27. package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
  28. package/dist/auto-ui/streamable-http-transport.js +53 -2
  29. package/dist/auto-ui/streamable-http-transport.js.map +1 -1
  30. package/dist/auto-ui/ui-resolver.d.ts +25 -0
  31. package/dist/auto-ui/ui-resolver.d.ts.map +1 -0
  32. package/dist/auto-ui/ui-resolver.js +95 -0
  33. package/dist/auto-ui/ui-resolver.js.map +1 -0
  34. package/dist/beam-form.bundle.js +7 -7
  35. package/dist/beam-form.bundle.js.map +1 -1
  36. package/dist/beam.bundle.js +905 -185
  37. package/dist/beam.bundle.js.map +4 -4
  38. package/dist/cli/commands/build.d.ts.map +1 -1
  39. package/dist/cli/commands/build.js +9 -5
  40. package/dist/cli/commands/build.js.map +1 -1
  41. package/dist/cli/commands/init.d.ts.map +1 -1
  42. package/dist/cli/commands/init.js +93 -53
  43. package/dist/cli/commands/init.js.map +1 -1
  44. package/dist/cli/commands/publish.d.ts +14 -0
  45. package/dist/cli/commands/publish.d.ts.map +1 -0
  46. package/dist/cli/commands/publish.js +126 -0
  47. package/dist/cli/commands/publish.js.map +1 -0
  48. package/dist/cli/commands/run.d.ts.map +1 -1
  49. package/dist/cli/commands/run.js +2 -0
  50. package/dist/cli/commands/run.js.map +1 -1
  51. package/dist/cli/index.d.ts.map +1 -1
  52. package/dist/cli/index.js +3 -0
  53. package/dist/cli/index.js.map +1 -1
  54. package/dist/cli.d.ts +4 -0
  55. package/dist/cli.d.ts.map +1 -1
  56. package/dist/cli.js +11 -1
  57. package/dist/cli.js.map +1 -1
  58. package/dist/context.d.ts +6 -0
  59. package/dist/context.d.ts.map +1 -1
  60. package/dist/context.js +17 -5
  61. package/dist/context.js.map +1 -1
  62. package/dist/daemon/client.d.ts +9 -1
  63. package/dist/daemon/client.d.ts.map +1 -1
  64. package/dist/daemon/client.js +54 -1
  65. package/dist/daemon/client.js.map +1 -1
  66. package/dist/daemon/manager.d.ts +3 -0
  67. package/dist/daemon/manager.d.ts.map +1 -1
  68. package/dist/daemon/manager.js +88 -38
  69. package/dist/daemon/manager.js.map +1 -1
  70. package/dist/daemon/ownership.d.ts +12 -0
  71. package/dist/daemon/ownership.d.ts.map +1 -0
  72. package/dist/daemon/ownership.js +55 -0
  73. package/dist/daemon/ownership.js.map +1 -0
  74. package/dist/daemon/protocol.d.ts +4 -2
  75. package/dist/daemon/protocol.d.ts.map +1 -1
  76. package/dist/daemon/protocol.js +15 -2
  77. package/dist/daemon/protocol.js.map +1 -1
  78. package/dist/daemon/server.js +557 -83
  79. package/dist/daemon/server.js.map +1 -1
  80. package/dist/daemon/session-manager.d.ts +9 -1
  81. package/dist/daemon/session-manager.d.ts.map +1 -1
  82. package/dist/daemon/session-manager.js +54 -1
  83. package/dist/daemon/session-manager.js.map +1 -1
  84. package/dist/daemon/worker-manager.d.ts +12 -0
  85. package/dist/daemon/worker-manager.d.ts.map +1 -1
  86. package/dist/daemon/worker-manager.js +89 -6
  87. package/dist/daemon/worker-manager.js.map +1 -1
  88. package/dist/loader.d.ts +17 -9
  89. package/dist/loader.d.ts.map +1 -1
  90. package/dist/loader.js +415 -141
  91. package/dist/loader.js.map +1 -1
  92. package/dist/photon-cli-runner.d.ts.map +1 -1
  93. package/dist/photon-cli-runner.js +26 -2
  94. package/dist/photon-cli-runner.js.map +1 -1
  95. package/dist/photons/canvas/ui/canvas.photon.html +1493 -0
  96. package/dist/photons/canvas.photon.d.ts +400 -0
  97. package/dist/photons/canvas.photon.d.ts.map +1 -0
  98. package/dist/photons/canvas.photon.js +662 -0
  99. package/dist/photons/canvas.photon.js.map +1 -0
  100. package/dist/photons/canvas.photon.ts +814 -0
  101. package/dist/photons/publish.photon.d.ts +97 -0
  102. package/dist/photons/publish.photon.d.ts.map +1 -0
  103. package/dist/photons/publish.photon.js +569 -0
  104. package/dist/photons/publish.photon.js.map +1 -0
  105. package/dist/photons/publish.photon.ts +683 -0
  106. package/dist/photons/ui/canvas.photon.html +624 -0
  107. package/dist/resource-server.d.ts.map +1 -1
  108. package/dist/resource-server.js +7 -1
  109. package/dist/resource-server.js.map +1 -1
  110. package/dist/server.d.ts +7 -0
  111. package/dist/server.d.ts.map +1 -1
  112. package/dist/server.js +67 -37
  113. package/dist/server.js.map +1 -1
  114. package/dist/shared/error-handler.d.ts +1 -0
  115. package/dist/shared/error-handler.d.ts.map +1 -1
  116. package/dist/shared/error-handler.js +68 -10
  117. package/dist/shared/error-handler.js.map +1 -1
  118. package/dist/shared/logger.d.ts.map +1 -1
  119. package/dist/shared/logger.js +34 -0
  120. package/dist/shared/logger.js.map +1 -1
  121. package/dist/shared-utils.d.ts.map +1 -1
  122. package/dist/shared-utils.js +2 -2
  123. package/dist/shared-utils.js.map +1 -1
  124. package/dist/telemetry/context.d.ts +24 -0
  125. package/dist/telemetry/context.d.ts.map +1 -0
  126. package/dist/telemetry/context.js +17 -0
  127. package/dist/telemetry/context.js.map +1 -0
  128. package/dist/telemetry/logs.d.ts +38 -0
  129. package/dist/telemetry/logs.d.ts.map +1 -0
  130. package/dist/telemetry/logs.js +108 -0
  131. package/dist/telemetry/logs.js.map +1 -0
  132. package/dist/telemetry/metrics.d.ts +71 -0
  133. package/dist/telemetry/metrics.d.ts.map +1 -0
  134. package/dist/telemetry/metrics.js +184 -0
  135. package/dist/telemetry/metrics.js.map +1 -0
  136. package/dist/telemetry/otel.d.ts +20 -1
  137. package/dist/telemetry/otel.d.ts.map +1 -1
  138. package/dist/telemetry/otel.js +79 -2
  139. package/dist/telemetry/otel.js.map +1 -1
  140. package/dist/telemetry/sdk.d.ts +49 -0
  141. package/dist/telemetry/sdk.d.ts.map +1 -0
  142. package/dist/telemetry/sdk.js +110 -0
  143. package/dist/telemetry/sdk.js.map +1 -0
  144. package/dist/tsx-compiler.d.ts +23 -0
  145. package/dist/tsx-compiler.d.ts.map +1 -0
  146. package/dist/tsx-compiler.js +221 -0
  147. package/dist/tsx-compiler.js.map +1 -0
  148. package/package.json +7 -7
package/dist/loader.js CHANGED
@@ -8,9 +8,11 @@ import { realpathSync, existsSync, mkdirSync, symlinkSync, readFileSync } from '
8
8
  import { readText, readJSON, writeText, writeJSON } from './shared/io.js';
9
9
  import { createRequire } from 'module';
10
10
  import * as path from 'path';
11
- import { pathToFileURL } from 'url';
11
+ import { fileURLToPath, pathToFileURL } from 'url';
12
12
  import * as crypto from 'crypto';
13
13
  import { startToolSpan } from './telemetry/otel.js';
14
+ import { recordToolCall, recordCircuitStateChange, recordRateLimitRejection, recordBulkheadRejection, } from './telemetry/metrics.js';
15
+ import { runWithRequestContext } from './telemetry/context.js';
14
16
  import { spawn } from 'child_process';
15
17
  import { SchemaExtractor, DependencyManager,
16
18
  // Generator utilities (ask/emit pattern from 1.2.0)
@@ -130,6 +132,38 @@ const cliRenderZone = new CLIRenderZone();
130
132
  export function clearRenderZone() {
131
133
  cliRenderZone.clear();
132
134
  }
135
+ /**
136
+ * Inject emit-based convenience helpers on a plain-class instance.
137
+ * Every helper is a thin wrapper around this.emit so generator-yield and
138
+ * imperative-call styles produce identical wire events.
139
+ */
140
+ function injectEmitHelpers(instance) {
141
+ const emit = (data) => instance.emit(data);
142
+ // Mirrors photon-core base-class render(): UI-feedback formats route to their
143
+ // dedicated emit events; all other formats go through the render channel.
144
+ instance.render = (format, value) => {
145
+ if (format === undefined)
146
+ return emit({ emit: 'render:clear' });
147
+ if (format === 'status')
148
+ return emit(typeof value === 'string'
149
+ ? { emit: 'status', message: value }
150
+ : { emit: 'status', ...value });
151
+ if (format === 'progress')
152
+ return emit(typeof value === 'number' ? { emit: 'progress', value } : { emit: 'progress', ...value });
153
+ if (format === 'toast')
154
+ return emit(typeof value === 'string' ? { emit: 'toast', message: value } : { emit: 'toast', ...value });
155
+ emit({ emit: 'render', format, value });
156
+ };
157
+ instance.toast = (message, opts = {}) => emit({ emit: 'toast', message, ...opts });
158
+ instance.log = (message, opts = {}) => emit({ emit: 'log', message, level: opts.level ?? 'info', data: opts.data });
159
+ instance.status = (message) => emit({ emit: 'status', message });
160
+ instance.progress = (value, message) => emit({ emit: 'progress', value, message });
161
+ instance.thinking = (active = true) => emit({ emit: 'thinking', active });
162
+ }
163
+ /** Extra regex checks that force 'emit' capability when helper methods are used. */
164
+ function detectEmitHelperUsage(source) {
165
+ return /this\.(toast|log|status|progress|thinking)\s*\(/.test(source);
166
+ }
133
167
  /**
134
168
  * Render a formatted value in the CLI using @portel/cli's formatOutput.
135
169
  * Uses clear-and-replace semantics — each call overwrites the previous render.
@@ -203,6 +237,19 @@ export class PhotonLoader {
203
237
  middlewareStates = new Map();
204
238
  /** Per-photon custom middleware definitions discovered from module exports */
205
239
  photonMiddleware = new Map();
240
+ /** Shadow registry of circuit breaker states, keyed by `${photon}:${instance}:${tool}` */
241
+ circuitHealthTracker = new Map();
242
+ /**
243
+ * Returns all tracked circuit breaker states across all photons.
244
+ * Each entry uses the key format `${photon}:${instance}:${tool}`.
245
+ */
246
+ getCircuitHealth() {
247
+ const result = {};
248
+ for (const [key, entry] of this.circuitHealthTracker) {
249
+ result[key] = { ...entry };
250
+ }
251
+ return result;
252
+ }
206
253
  /** Base directory for state/config/cache (defaults to ~/.photon) */
207
254
  baseDir;
208
255
  /**
@@ -650,15 +697,12 @@ export class PhotonLoader {
650
697
  // Auto-wire ReactiveArray/Map/Set properties for zero-boilerplate reactivity
651
698
  // Developers just `import { Array } from '@portel/photon-core'` and use normally
652
699
  this.wireReactiveCollections(instance);
700
+ // Inject format catalog — this.formats
701
+ this.injectFormatCatalog(instance);
653
702
  // Inject @mcp dependencies from source (this.github, this.fs, etc.)
654
703
  if (tsContent) {
655
704
  await this.injectMCPDependencies(instance, tsContent, name);
656
705
  }
657
- // Auto-wrap public methods in @stateful classes to emit events
658
- // All method calls automatically produce events with params, result, timestamp
659
- if (tsContent) {
660
- this.wrapStatefulMethods(instance, tsContent);
661
- }
662
706
  // Inject MCP client factory if available (enables this.mcp() calls)
663
707
  const setMCPFactory = instance.setMCPFactory;
664
708
  if (this.mcpClientFactory && typeof setMCPFactory === 'function') {
@@ -669,9 +713,31 @@ export class PhotonLoader {
669
713
  if (typeof instance._callHandler === 'undefined' || instance._callHandler === undefined) {
670
714
  const callBaseDir = this.baseDir;
671
715
  instance._callHandler = async (photonName, method, params, targetInstance) => {
716
+ // Propagate trace context to nested photon-to-photon calls so the
717
+ // downstream span chains as a child of the current span. Uses
718
+ // in-band `_meta.traceparent` which executeTool peeks at before
719
+ // schema validation — works through worker threads transparently.
720
+ const ctxMod = await import('./telemetry/context.js');
721
+ const ctx = ctxMod.getRequestContext();
722
+ let forwardedParams = params;
723
+ if (ctx) {
724
+ const traceparent = ctx.parentTraceparent ||
725
+ (ctx.traceId
726
+ ? `00-${ctx.traceId}-${crypto.randomBytes(8).toString('hex')}-01`
727
+ : undefined);
728
+ if (traceparent) {
729
+ const existingMeta = params?._meta;
730
+ forwardedParams = {
731
+ ...(params || {}),
732
+ _meta: existingMeta && typeof existingMeta === 'object'
733
+ ? { traceparent, ...existingMeta }
734
+ : { traceparent },
735
+ };
736
+ }
737
+ }
672
738
  // Dynamic import to avoid circular dependency
673
739
  const { sendCommand } = await import('./daemon/client.js');
674
- return sendCommand(photonName, method, params, {
740
+ return sendCommand(photonName, method, forwardedParams, {
675
741
  workingDir: callBaseDir,
676
742
  targetInstance,
677
743
  });
@@ -682,6 +748,8 @@ export class PhotonLoader {
682
748
  // Photon base class already has these built-in, so only inject for plain classes
683
749
  if (tsContent && typeof instance.executeTool !== 'function') {
684
750
  const caps = detectCapabilities(tsContent);
751
+ if (detectEmitHelperUsage(tsContent))
752
+ caps.add('emit');
685
753
  if (caps.size > 0) {
686
754
  this.log(`🔍 Detected capabilities for ${name}: ${[...caps].join(', ')}`);
687
755
  }
@@ -727,15 +795,8 @@ export class PhotonLoader {
727
795
  });
728
796
  }
729
797
  };
730
- // Also inject render() convenience wrapper around emit
731
- instance.render = (format, value) => {
732
- if (format === undefined) {
733
- instance.emit({ emit: 'render:clear' });
734
- }
735
- else {
736
- instance.emit({ emit: 'render', format, value });
737
- }
738
- };
798
+ // Inject convenience helpers (render, toast, log, status, progress, thinking)
799
+ injectEmitHelpers(instance);
739
800
  }
740
801
  if (caps.has('memory')) {
741
802
  // Inject lazy memory provider — capture baseDir from loader context
@@ -1001,8 +1062,13 @@ export class PhotonLoader {
1001
1062
  throw initError;
1002
1063
  }
1003
1064
  }
1065
+ // Auto-wrap public methods in @stateful classes to emit events
1066
+ // Must happen AFTER capability injection so that emit() is available
1067
+ if (tsContent) {
1068
+ this.wrapStatefulMethods(instance, tsContent);
1069
+ }
1004
1070
  // Extract tools, templates, and statics (with schema override support)
1005
- const { tools, templates, statics, settingsSchema } = await this.extractTools(MCPClass, absolutePath);
1071
+ const { tools, templates, statics, settingsSchema, auth: extractedAuth, } = await this.extractTools(MCPClass, absolutePath);
1006
1072
  // ═══ SETTINGS INJECTION ═══
1007
1073
  // If the photon declared `protected settings = { ... }`, inject persistence + proxy
1008
1074
  if (settingsSchema?.hasSettings &&
@@ -1052,6 +1118,8 @@ export class PhotonLoader {
1052
1118
  result.icon = classIcon;
1053
1119
  if (isStateful)
1054
1120
  result.stateful = true;
1121
+ if (extractedAuth)
1122
+ result.auth = extractedAuth;
1055
1123
  // Store class constructor for static method access
1056
1124
  result.classConstructor = MCPClass;
1057
1125
  // Store settings schema for Beam UI
@@ -1123,6 +1191,8 @@ export class PhotonLoader {
1123
1191
  instance._photonResolver = (photonName, instanceName) => {
1124
1192
  return this.resolveAndLoadPhoton(photonName, absolutePath, instanceName);
1125
1193
  };
1194
+ // Inject format catalog — this.formats
1195
+ this.injectFormatCatalog(instance);
1126
1196
  // Wire reactive collections
1127
1197
  this.wireReactiveCollections(instance);
1128
1198
  // Inject @mcp dependencies
@@ -1152,6 +1222,8 @@ export class PhotonLoader {
1152
1222
  // Detect and inject capabilities for plain classes
1153
1223
  if (tsContent && typeof instance.executeTool !== 'function') {
1154
1224
  const caps = detectCapabilities(tsContent);
1225
+ if (detectEmitHelperUsage(tsContent))
1226
+ caps.add('emit');
1155
1227
  this.injectPathHelpers(instance, tsContent);
1156
1228
  if (caps.has('emit')) {
1157
1229
  instance.emit = (data) => {
@@ -1186,15 +1258,8 @@ export class PhotonLoader {
1186
1258
  });
1187
1259
  }
1188
1260
  };
1189
- // Also inject render() convenience wrapper around emit
1190
- instance.render = (format, value) => {
1191
- if (format === undefined) {
1192
- instance.emit({ emit: 'render:clear' });
1193
- }
1194
- else {
1195
- instance.emit({ emit: 'render', format, value });
1196
- }
1197
- };
1261
+ // Inject convenience helpers (render, toast, log, status, progress, thinking)
1262
+ injectEmitHelpers(instance);
1198
1263
  }
1199
1264
  if (caps.has('memory')) {
1200
1265
  const memoryBaseDir = this.baseDir;
@@ -1296,7 +1361,7 @@ export class PhotonLoader {
1296
1361
  }
1297
1362
  }
1298
1363
  // Extract tools and metadata from embedded source (no disk I/O)
1299
- const { tools, templates, statics, settingsSchema } = await this.extractTools(MCPClass, absolutePath, tsContent);
1364
+ const { tools, templates, statics, settingsSchema, auth: extractedAuth, } = await this.extractTools(MCPClass, absolutePath, tsContent);
1300
1365
  // Settings injection
1301
1366
  if (settingsSchema?.hasSettings && instance.settings && typeof instance.settings === 'object') {
1302
1367
  const instanceName = options?.instanceName || 'default';
@@ -1326,6 +1391,8 @@ export class PhotonLoader {
1326
1391
  result.icon = classIcon;
1327
1392
  if (isStateful)
1328
1393
  result.stateful = true;
1394
+ if (extractedAuth)
1395
+ result.auth = extractedAuth;
1329
1396
  result.classConstructor = MCPClass;
1330
1397
  if (settingsSchema?.hasSettings) {
1331
1398
  result.settingsSchema = settingsSchema;
@@ -1341,6 +1408,14 @@ export class PhotonLoader {
1341
1408
  const match = source.match(/\/\*\*([\s\S]*?)\*\/\s*export\s+default\s+class\b/);
1342
1409
  return match ? match[1] : '';
1343
1410
  }
1411
+ extractAuthTag(source) {
1412
+ const docblock = this.extractClassDocblock(source);
1413
+ // Use \b to avoid matching @author, @authorize, etc.
1414
+ const match = docblock.match(/@auth\b(?:\s+(\S+))?/i);
1415
+ if (!match)
1416
+ return undefined;
1417
+ return match[1]?.trim() || 'required';
1418
+ }
1344
1419
  /**
1345
1420
  * Reload a Photon MCP file (for hot reload)
1346
1421
  */
@@ -1456,7 +1531,7 @@ export class PhotonLoader {
1456
1531
  if (!description)
1457
1532
  return '';
1458
1533
  if (description.includes('@emits') && process.env.PHOTON_DEBUG_EXTRACT) {
1459
- console.log(`[stripJSDocTags] Input: "${description}"`);
1534
+ this.logger.debug(`[stripJSDocTags] Input: "${description}"`);
1460
1535
  }
1461
1536
  // Remove lines that start with @ (full line removal)
1462
1537
  let cleaned = description
@@ -1467,7 +1542,7 @@ export class PhotonLoader {
1467
1542
  // Also remove inline @ tags (e.g., "text @emits ... " at end of line)
1468
1543
  cleaned = cleaned.replace(/\s*@\w+.*$/gm, '').trim();
1469
1544
  if (description.includes('@emits') && process.env.PHOTON_DEBUG_EXTRACT) {
1470
- console.log(`[stripJSDocTags] Output: "${cleaned}"`);
1545
+ this.logger.debug(`[stripJSDocTags] Output: "${cleaned}"`);
1471
1546
  }
1472
1547
  return cleaned;
1473
1548
  }
@@ -1550,7 +1625,7 @@ export class PhotonLoader {
1550
1625
  if (process.env.PHOTON_DEBUG_EXTRACT) {
1551
1626
  tools.forEach((t) => {
1552
1627
  if (t.description?.includes('@')) {
1553
- console.log(`[EXTRACTOR] Before clean: ${t.name}: "${t.description}"`);
1628
+ this.logger.debug(`[EXTRACTOR] Before clean: ${t.name}: "${t.description}"`);
1554
1629
  }
1555
1630
  });
1556
1631
  }
@@ -1563,11 +1638,17 @@ export class PhotonLoader {
1563
1638
  if (process.env.PHOTON_DEBUG_EXTRACT) {
1564
1639
  tools.forEach((t) => {
1565
1640
  if (t.name === 'clear') {
1566
- console.log(`[EXTRACTOR] After clean: ${t.name}: "${t.description}"`);
1641
+ this.logger.debug(`[EXTRACTOR] After clean: ${t.name}: "${t.description}"`);
1567
1642
  }
1568
1643
  });
1569
1644
  }
1570
- return { tools, templates, statics, settingsSchema: metadata.settingsSchema };
1645
+ return {
1646
+ tools,
1647
+ templates,
1648
+ statics,
1649
+ settingsSchema: metadata.settingsSchema,
1650
+ auth: this.extractAuthTag(source),
1651
+ };
1571
1652
  }
1572
1653
  throw jsonError;
1573
1654
  }
@@ -2241,11 +2322,14 @@ export class PhotonLoader {
2241
2322
  return;
2242
2323
  if (path.dirname(realCurrentPhotonPath) !== path.dirname(resolvedPath))
2243
2324
  return;
2244
- const linkPath = path.join(this.baseDir, path.basename(resolvedPath));
2325
+ // Symlink into the directory where the current photon's symlink lives
2326
+ // (e.g. ~/.photon/), NOT this.baseDir which may be a different workspace.
2327
+ const symlinkDir = path.dirname(currentPhotonPath);
2328
+ const linkPath = path.join(symlinkDir, path.basename(resolvedPath));
2245
2329
  await this.createSymlinkIfMissing(resolvedPath, linkPath);
2246
2330
  const depName = path.basename(resolvedPath).replace(/\.photon\.(ts|js)$/, '');
2247
2331
  const sourceAssetDir = path.join(path.dirname(resolvedPath), depName);
2248
- const targetAssetDir = path.join(this.baseDir, depName);
2332
+ const targetAssetDir = path.join(symlinkDir, depName);
2249
2333
  if (existsSync(sourceAssetDir)) {
2250
2334
  await this.createSymlinkIfMissing(sourceAssetDir, targetAssetDir, 'dir');
2251
2335
  }
@@ -2546,14 +2630,17 @@ Run: photon mcp ${mcpName} --config
2546
2630
  * Tags become composable wrappers applied in the correct order via phase-sorted middleware.
2547
2631
  * All middleware (built-in and custom) follows the same code path — no if-chain.
2548
2632
  */
2549
- applyMiddleware(execute, toolMeta, photonName, toolName, parameters, instanceName) {
2633
+ applyMiddleware(execute, toolMeta, photonName, toolName, parameters, instanceName, outputHandler) {
2550
2634
  // Build middleware context
2635
+ const store = executionContext.getStore();
2551
2636
  const ctx = {
2552
2637
  photon: photonName,
2553
2638
  tool: toolName,
2554
2639
  instance: instanceName || 'default',
2555
2640
  params: parameters,
2556
- };
2641
+ caller: store?.caller,
2642
+ outputHandler,
2643
+ }; // MiddlewareContext type is in photon-core (read-only); caller/outputHandler added via runtime extension
2557
2644
  // Get declarations from the new middleware[] field
2558
2645
  const declarations = toolMeta.middleware || [];
2559
2646
  if (declarations.length === 0) {
@@ -2605,6 +2692,94 @@ Run: photon mcp ${mcpName} --config
2605
2692
  return withLockHelper(lockName, next);
2606
2693
  };
2607
2694
  });
2695
+ // Handler override for circuitBreaker — mirror state into circuitHealthTracker for introspection
2696
+ handlerOverrides.set('circuitBreaker', (config, _state) => {
2697
+ const tracker = this.circuitHealthTracker;
2698
+ return async (ctx, next) => {
2699
+ const key = `${ctx.photon}:${ctx.instance}:${ctx.tool}`;
2700
+ let circuit = tracker.get(key);
2701
+ if (!circuit) {
2702
+ circuit = { failures: 0, state: 'closed', openedAt: 0 };
2703
+ tracker.set(key, circuit);
2704
+ }
2705
+ const openError = () => {
2706
+ const error = new Error(`Circuit open: ${ctx.photon}.${ctx.tool} has failed ${config.threshold} consecutive times. Resets in ${Math.ceil((config.resetAfterMs - (Date.now() - circuit.openedAt)) / 1000)}s`);
2707
+ error.name = 'PhotonCircuitOpenError';
2708
+ return error;
2709
+ };
2710
+ const emitTransition = (from, to) => {
2711
+ if (from === to)
2712
+ return;
2713
+ recordCircuitStateChange({
2714
+ photon: ctx.photon,
2715
+ tool: ctx.tool,
2716
+ instance: ctx.instance,
2717
+ from,
2718
+ to,
2719
+ });
2720
+ // Broadcast state-change as a domain event so AG-UI clients
2721
+ // (via createAGUIOutputHandler) receive a STATE_DELTA in real
2722
+ // time instead of polling /api/health/circuits.
2723
+ const handler = ctx.outputHandler;
2724
+ if (handler) {
2725
+ try {
2726
+ handler({
2727
+ channel: `${ctx.photon}:circuits`,
2728
+ event: 'state-change',
2729
+ data: {
2730
+ key: `${ctx.photon}:${ctx.instance}:${ctx.tool}`,
2731
+ from,
2732
+ to,
2733
+ timestamp: Date.now(),
2734
+ },
2735
+ });
2736
+ }
2737
+ catch {
2738
+ /* best-effort — don't fail the middleware chain */
2739
+ }
2740
+ }
2741
+ };
2742
+ if (circuit.state === 'open') {
2743
+ if (Date.now() - circuit.openedAt >= config.resetAfterMs) {
2744
+ // Transition to half-open and allow exactly one probe through.
2745
+ emitTransition('open', 'half-open');
2746
+ circuit.state = 'half-open';
2747
+ circuit.probeInFlight = true;
2748
+ }
2749
+ else {
2750
+ throw openError();
2751
+ }
2752
+ }
2753
+ else if (circuit.state === 'half-open') {
2754
+ // Probe already in flight — reject concurrent requests to avoid stampede.
2755
+ if (circuit.probeInFlight) {
2756
+ throw openError();
2757
+ }
2758
+ circuit.probeInFlight = true;
2759
+ }
2760
+ const stateBefore = circuit.state;
2761
+ try {
2762
+ const result = await next();
2763
+ circuit.failures = 0;
2764
+ if (stateBefore !== 'closed')
2765
+ emitTransition(stateBefore, 'closed');
2766
+ circuit.state = 'closed';
2767
+ circuit.probeInFlight = false;
2768
+ return result;
2769
+ }
2770
+ catch (error) {
2771
+ circuit.failures++;
2772
+ circuit.probeInFlight = false;
2773
+ // A failed probe in half-open immediately reopens the circuit.
2774
+ if (stateBefore === 'half-open' || circuit.failures >= config.threshold) {
2775
+ emitTransition(circuit.state, 'open');
2776
+ circuit.state = 'open';
2777
+ circuit.openedAt = Date.now();
2778
+ }
2779
+ throw error;
2780
+ }
2781
+ };
2782
+ });
2608
2783
  return buildMiddlewareChain(execute, declarations, combinedRegistry, this.middlewareStates, ctx, handlerOverrides);
2609
2784
  }
2610
2785
  /**
@@ -2621,11 +2796,54 @@ Run: photon mcp ${mcpName} --config
2621
2796
  * @returns Tool result, or wrapped result with runId for stateful workflows
2622
2797
  */
2623
2798
  async executeTool(mcp, toolName, parameters, options) {
2799
+ // Resolve parentTraceparent from options or in-band `_meta.traceparent` so
2800
+ // the ALS context reflects the true parent for any nested `this.call()`.
2801
+ let resolvedParentTraceparent = options?.parentTraceparent;
2802
+ if (!resolvedParentTraceparent &&
2803
+ parameters &&
2804
+ typeof parameters === 'object' &&
2805
+ '_meta' in parameters) {
2806
+ const metaPeek = parameters._meta;
2807
+ if (metaPeek && typeof metaPeek.traceparent === 'string') {
2808
+ resolvedParentTraceparent = metaPeek.traceparent;
2809
+ }
2810
+ }
2811
+ return runWithRequestContext({
2812
+ photon: mcp.name,
2813
+ tool: toolName,
2814
+ traceId: options?.traceId,
2815
+ parentTraceparent: resolvedParentTraceparent,
2816
+ caller: options?.caller,
2817
+ startedAt: Date.now(),
2818
+ }, () => this._executeToolInner(mcp, toolName, parameters, {
2819
+ ...options,
2820
+ parentTraceparent: resolvedParentTraceparent,
2821
+ }));
2822
+ }
2823
+ async _executeToolInner(mcp, toolName, parameters, options) {
2624
2824
  // Start audit trail recording
2625
2825
  const audit = getAuditTrail();
2626
2826
  const { finish: auditFinish } = audit.start(mcp.name, toolName, parameters || {});
2827
+ // Start wall-clock timer for tool-call duration histogram.
2828
+ const toolStartedAt = Date.now();
2829
+ let metricsStatus = 'ok';
2830
+ let metricsErrorType;
2831
+ // Peek at _meta.traceparent before span creation so distributed traces chain.
2832
+ // Explicit option wins over in-band _meta (server may have extracted from headers).
2833
+ let parentTraceparent = options?.parentTraceparent;
2834
+ if (!parentTraceparent &&
2835
+ parameters &&
2836
+ typeof parameters === 'object' &&
2837
+ '_meta' in parameters) {
2838
+ const metaPeek = parameters._meta;
2839
+ if (metaPeek && typeof metaPeek === 'object' && typeof metaPeek.traceparent === 'string') {
2840
+ parentTraceparent = metaPeek.traceparent;
2841
+ }
2842
+ }
2627
2843
  // Start OTel span for tool execution (no-op if SDK not installed)
2628
- const span = startToolSpan(mcp.name, toolName, parameters);
2844
+ const toolMetaForSpan = mcp?.meta?.tools?.[toolName];
2845
+ const isStateful = Boolean(toolMetaForSpan?.stateful ?? mcp?.meta?.stateful);
2846
+ const span = startToolSpan(mcp.name, toolName, parameters, options?.traceId, isStateful, parentTraceparent);
2629
2847
  if (mcp.instance?.instanceName) {
2630
2848
  span.setAttribute('photon.instance', mcp.instance.instanceName);
2631
2849
  }
@@ -2658,6 +2876,39 @@ Run: photon mcp ${mcpName} --config
2658
2876
  if (mcp.instance._photonConfigError) {
2659
2877
  throw new Error(mcp.instance._photonConfigError);
2660
2878
  }
2879
+ // Enforce @auth at method level — works across ALL transports (CLI, STDIO, HTTP, daemon)
2880
+ const photonAuth = mcp.auth;
2881
+ if (photonAuth === 'required') {
2882
+ const caller = options?.caller;
2883
+ if (!caller || caller.anonymous) {
2884
+ const inputProvider = options?.inputProvider || this.createInputProvider();
2885
+ try {
2886
+ const token = await inputProvider({
2887
+ ask: 'password',
2888
+ message: `${mcp.name} requires authentication. Enter your access token:`,
2889
+ });
2890
+ if (token && typeof token === 'string') {
2891
+ options = {
2892
+ ...options,
2893
+ caller: {
2894
+ id: token.slice(0, 8),
2895
+ name: 'authenticated-user',
2896
+ anonymous: false,
2897
+ claims: { token },
2898
+ },
2899
+ };
2900
+ }
2901
+ else {
2902
+ throw new Error(`Authentication required: ${mcp.name} has @auth required but no credentials were provided`);
2903
+ }
2904
+ }
2905
+ catch (e) {
2906
+ if (e.message?.includes('Authentication required'))
2907
+ throw e;
2908
+ throw new Error(`Authentication required: ${mcp.name} has @auth required. Provide credentials via Authorization header, MCP elicitation, or CLI prompt.`);
2909
+ }
2910
+ }
2911
+ }
2661
2912
  // Intercept auto-generated settings tool
2662
2913
  if (toolName === 'settings' && mcp.instance._settingsBacking) {
2663
2914
  const result = await this.executeSettingsTool(mcp.instance, parameters, {
@@ -2707,7 +2958,7 @@ Run: photon mcp ${mcpName} --config
2707
2958
  };
2708
2959
  // Apply functional tag middleware if any tags present
2709
2960
  if (hasFunctionalTags) {
2710
- executeBase = this.applyMiddleware(executeBase, toolMeta, mcp.name, toolName, parameters, mcp.instance.instanceName);
2961
+ executeBase = this.applyMiddleware(executeBase, toolMeta, mcp.name, toolName, parameters, mcp.instance.instanceName, options?.outputHandler);
2711
2962
  }
2712
2963
  const result = await executeBase();
2713
2964
  this.progressRenderer.done();
@@ -2735,30 +2986,36 @@ Run: photon mcp ${mcpName} --config
2735
2986
  configurable: true,
2736
2987
  });
2737
2988
  }
2738
- // Construct event data with full context for transmission
2739
- // CRITICAL: emit() expects { channel, event, data } structure for daemon pub/sub routing
2740
- const eventPayload = {
2989
+ // Construct CloudEvents 1.0 envelope for @stateful event emissions.
2990
+ // The `data` field carries the photon-specific payload; `channel` and `event`
2991
+ // are transport metadata used by the daemon pub/sub router.
2992
+ const cloudEventData = {
2741
2993
  method: toolName,
2742
2994
  params: parameters,
2743
2995
  result,
2744
- timestamp: new Date().toISOString(),
2996
+ instance: mcp.instance.instanceName ?? null,
2745
2997
  };
2746
- // Add instance name if available
2747
- if (mcp.instance.instanceName) {
2748
- eventPayload.instance = mcp.instance.instanceName;
2749
- }
2750
2998
  // Add index/pagination info if result is from items array
2751
2999
  if (result && typeof result === 'object' && Array.isArray(mcp.instance.items)) {
2752
3000
  const index = mcp.instance.items.findIndex((item) => item === result);
2753
3001
  if (index !== -1) {
2754
- eventPayload.index = index;
2755
- eventPayload.totalCount = mcp.instance.items.length;
2756
- eventPayload.affectedRange = {
3002
+ cloudEventData.index = index;
3003
+ cloudEventData.totalCount = mcp.instance.items.length;
3004
+ cloudEventData.affectedRange = {
2757
3005
  start: index,
2758
3006
  end: index + 1,
2759
3007
  };
2760
3008
  }
2761
3009
  }
3010
+ // CloudEvents 1.0 envelope (https://cloudevents.io)
3011
+ const eventPayload = {
3012
+ specversion: '1.0',
3013
+ id: crypto.randomUUID(),
3014
+ source: `photon/${photonName}`,
3015
+ type: `photon.${photonName}.${toolName}.executed`,
3016
+ time: new Date().toISOString(),
3017
+ data: cloudEventData,
3018
+ };
2762
3019
  // Wrap in daemon pub/sub format: { channel, event, data }
2763
3020
  const eventData = {
2764
3021
  channel: `${photonName}:${toolName}`, // For daemon pub/sub routing
@@ -2770,18 +3027,24 @@ Run: photon mcp ${mcpName} --config
2770
3027
  if (options?.outputHandler) {
2771
3028
  try {
2772
3029
  if (process.env.PHOTON_DEBUG_EMIT === '1') {
2773
- console.error(`[EMIT-DEBUG] Sending event: method=${eventPayload.method}, channel=${eventData.channel}, hasMeta=${!!result?.__meta}`);
3030
+ this.logger.debug('[emit] sending event', {
3031
+ method: eventPayload.data.method,
3032
+ channel: eventData.channel,
3033
+ hasMeta: !!result?.__meta,
3034
+ });
2774
3035
  }
2775
3036
  // Cast to DaemonEventEnvelope - outputHandler is flexible and routes any object with channel property
2776
3037
  void Promise.resolve(options.outputHandler(eventData)).catch((e) => {
2777
3038
  this.logger.debug('Output handler failed for event', { error: e?.message || e });
2778
3039
  });
2779
3040
  if (process.env.PHOTON_DEBUG_EMIT === '1') {
2780
- console.error(`[EMIT-DEBUG] Event transmitted to outputHandler`);
3041
+ this.logger.debug('[emit] event transmitted to outputHandler');
2781
3042
  }
2782
3043
  }
2783
3044
  catch (e) {
2784
- console.error(`[EMIT-ERROR] Failed to send event through outputHandler: ${e instanceof Error ? e.message : String(e)}`);
3045
+ this.logger.error('Failed to send event through outputHandler', {
3046
+ error: e instanceof Error ? e.message : String(e),
3047
+ });
2785
3048
  }
2786
3049
  }
2787
3050
  else if (mcp.instance && typeof mcp.instance.emit === 'function') {
@@ -2789,19 +3052,22 @@ Run: photon mcp ${mcpName} --config
2789
3052
  // (this path won't route to daemon pub/sub, but at least calls emit)
2790
3053
  try {
2791
3054
  if (process.env.PHOTON_DEBUG_EMIT === '1') {
2792
- console.error(`[EMIT-DEBUG] No outputHandler, falling back to instance.emit`);
3055
+ this.logger.debug('[emit] no outputHandler, falling back to instance.emit');
2793
3056
  }
2794
3057
  mcp.instance.emit(eventData);
2795
3058
  }
2796
3059
  catch (e) {
2797
- console.error(`[EMIT-ERROR] Failed to emit: ${e instanceof Error ? e.message : String(e)}`);
3060
+ this.logger.error('Failed to emit event via instance.emit', {
3061
+ error: e instanceof Error ? e.message : String(e),
3062
+ });
2798
3063
  }
2799
3064
  }
2800
3065
  }
2801
3066
  catch (e) {
2802
3067
  // Log emit errors but don't break tool execution
2803
- console.error(`[EMIT-ERROR] Failed to emit @stateful event: ${e instanceof Error ? e.message : String(e)}`);
2804
- this.logger.debug(`Failed to emit @stateful event: ${e instanceof Error ? e.message : String(e)}`);
3068
+ this.logger.error('Failed to emit @stateful event', {
3069
+ error: e instanceof Error ? e.message : String(e),
3070
+ });
2805
3071
  }
2806
3072
  }
2807
3073
  // Apply _meta post-processing (format transformation, field selection)
@@ -2858,7 +3124,7 @@ Run: photon mcp ${mcpName} --config
2858
3124
  });
2859
3125
  // Apply functional tag middleware if any tags present
2860
3126
  if (hasFunctionalTags) {
2861
- generatorFn = this.applyMiddleware(generatorFn, toolMeta, mcp.name, toolName, parameters, mcp.instance.instanceName);
3127
+ generatorFn = this.applyMiddleware(generatorFn, toolMeta, mcp.name, toolName, parameters, mcp.instance.instanceName, options?.outputHandler);
2862
3128
  }
2863
3129
  // Use maybeStatefulExecute for all executions
2864
3130
  // It handles both regular async and generators, detecting checkpoint yields
@@ -2881,6 +3147,7 @@ Run: photon mcp ${mcpName} --config
2881
3147
  if (execResult.runId) {
2882
3148
  error.runId = execResult.runId;
2883
3149
  }
3150
+ span.recordException(error);
2884
3151
  span.setStatus('ERROR', error.message);
2885
3152
  auditFinish(null, error);
2886
3153
  throw error;
@@ -2913,13 +3180,41 @@ Run: photon mcp ${mcpName} --config
2913
3180
  catch (error) {
2914
3181
  // Clear progress on error too
2915
3182
  this.progressRenderer.done();
3183
+ span.recordException(error);
2916
3184
  span.setStatus('ERROR', error instanceof Error ? error.message : String(error));
2917
3185
  auditFinish(null, error);
3186
+ metricsStatus = 'error';
3187
+ metricsErrorType = error instanceof Error ? error.name || 'Error' : 'unknown';
3188
+ // Emit a dedicated counter for rate-limit rejections so dashboards can
3189
+ // alert on sustained throttling without scanning the generic error
3190
+ // stream.
3191
+ if (metricsErrorType === 'PhotonRateLimitError') {
3192
+ recordRateLimitRejection({
3193
+ photon: mcp.name,
3194
+ tool: toolName,
3195
+ instance: mcp.instance?.instanceName,
3196
+ });
3197
+ }
3198
+ else if (metricsErrorType === 'PhotonBulkheadFullError') {
3199
+ recordBulkheadRejection({
3200
+ photon: mcp.name,
3201
+ tool: toolName,
3202
+ instance: mcp.instance?.instanceName,
3203
+ });
3204
+ }
2918
3205
  this.logger.error(`Tool execution failed: ${toolName} - ${getErrorMessage(error)}`);
2919
3206
  throw error;
2920
3207
  }
2921
3208
  finally {
2922
3209
  span.end();
3210
+ recordToolCall({
3211
+ photon: mcp.name,
3212
+ tool: toolName,
3213
+ durationMs: Date.now() - toolStartedAt,
3214
+ status: metricsStatus,
3215
+ errorType: metricsErrorType,
3216
+ stateful: isStateful,
3217
+ });
2923
3218
  }
2924
3219
  }
2925
3220
  normalizeNestedParamsTool(toolMeta, parameters) {
@@ -3048,7 +3343,7 @@ Run: photon mcp ${mcpName} --config
3048
3343
  : emit.type === 'success'
3049
3344
  ? '✅'
3050
3345
  : 'ℹ';
3051
- this.logger.info(`${icon} ${emit.message}`);
3346
+ process.stdout.write(`${icon} ${emit.message}\n`);
3052
3347
  break;
3053
3348
  case 'thinking':
3054
3349
  if (emit.active) {
@@ -3115,6 +3410,30 @@ Run: photon mcp ${mcpName} --config
3115
3410
  * }
3116
3411
  * ```
3117
3412
  */
3413
+ _formatCatalogCache = null;
3414
+ injectFormatCatalog(instance) {
3415
+ if (instance.formats)
3416
+ return;
3417
+ // Eagerly load once per loader, cached across all photon instances
3418
+ if (!this._formatCatalogCache) {
3419
+ try {
3420
+ const loaderDir = path.dirname(fileURLToPath(import.meta.url));
3421
+ const renderersPath = path.join(loaderDir, 'auto-ui', 'bridge', 'renderers.js');
3422
+ const esmRequire = createRequire(import.meta.url);
3423
+ this._formatCatalogCache = esmRequire(renderersPath).FORMAT_CATALOG;
3424
+ }
3425
+ catch (e) {
3426
+ this.logger.debug('Failed to load format catalog', { error: e?.message });
3427
+ this._formatCatalogCache = {};
3428
+ }
3429
+ }
3430
+ Object.defineProperty(instance, 'formats', {
3431
+ value: this._formatCatalogCache,
3432
+ configurable: true,
3433
+ enumerable: false,
3434
+ writable: false,
3435
+ });
3436
+ }
3118
3437
  wireReactiveCollections(instance) {
3119
3438
  // Get the emit function if available
3120
3439
  const emit = typeof instance.emit === 'function'
@@ -3163,7 +3482,18 @@ Run: photon mcp ${mcpName} --config
3163
3482
  // Get all public method names from the instance
3164
3483
  // Skip runtime-injected methods (emit, render, push, ask) — these are
3165
3484
  // capability methods injected by the loader, not user-defined tools
3166
- const RUNTIME_METHODS = new Set(['emit', 'render', 'channel', 'ask', 'call']);
3485
+ const RUNTIME_METHODS = new Set([
3486
+ 'emit',
3487
+ 'render',
3488
+ 'channel',
3489
+ 'ask',
3490
+ 'call',
3491
+ 'toast',
3492
+ 'log',
3493
+ 'status',
3494
+ 'progress',
3495
+ 'thinking',
3496
+ ]);
3167
3497
  const proto = Object.getPrototypeOf(instance);
3168
3498
  const methodNames = Object.getOwnPropertyNames(proto).filter((name) => {
3169
3499
  // Skip constructor and private/protected methods
@@ -3188,95 +3518,39 @@ Run: photon mcp ${mcpName} --config
3188
3518
  if (typeof original !== 'function')
3189
3519
  continue;
3190
3520
  instance[methodName] = function (...args) {
3191
- // Extract parameter names and map arguments to them
3192
- const paramNames = PhotonLoader.extractParamNames(original);
3193
- const params = Object.fromEntries(paramNames.map((name, i) => [name, args[i]]));
3194
- // Call the original method
3195
3521
  const result = original.apply(this, args);
3196
- // Attach __meta to returned objects for audit trail
3197
- const resultObj = result;
3198
- if (resultObj &&
3199
- typeof resultObj === 'object' &&
3200
- !Array.isArray(resultObj) &&
3201
- !resultObj.__meta) {
3202
- const timestamp = new Date().toISOString();
3203
- Object.defineProperty(result, '__meta', {
3204
- value: {
3205
- createdAt: timestamp,
3206
- createdBy: methodName,
3207
- modifiedAt: null,
3208
- modifiedBy: null,
3209
- modifications: [],
3210
- },
3211
- enumerable: false,
3212
- writable: true,
3213
- configurable: true,
3214
- });
3215
- }
3216
- // Emit event with complete context
3217
- const eventData = {
3218
- method: methodName,
3219
- params,
3220
- result,
3221
- timestamp: new Date().toISOString(),
3222
- };
3223
- if (this.instanceName) {
3224
- eventData.instance = this.instanceName;
3225
- }
3226
- // Detect array mutations for range-based pagination support (Phase 5)
3227
- // If result is an object from this.items, add index and array metadata
3228
- if (result && typeof result === 'object' && Array.isArray(this.items)) {
3229
- const index = this.items.findIndex((item) => item === result);
3230
- if (index !== -1) {
3231
- eventData.index = index;
3232
- eventData.totalCount = this.items.length;
3233
- // Affected range: just this item
3234
- eventData.affectedRange = {
3235
- start: index,
3236
- end: index + 1,
3237
- };
3522
+ const attachMeta = (obj) => {
3523
+ if (obj && typeof obj === 'object' && !Array.isArray(obj) && !obj.__meta) {
3524
+ Object.defineProperty(obj, '__meta', {
3525
+ value: {
3526
+ createdAt: new Date().toISOString(),
3527
+ createdBy: methodName,
3528
+ modifiedAt: null,
3529
+ modifiedBy: null,
3530
+ modifications: [],
3531
+ },
3532
+ enumerable: false,
3533
+ writable: true,
3534
+ configurable: true,
3535
+ });
3238
3536
  }
3537
+ };
3538
+ // Generators must pass through unwrapped - .then() would kill the iterator protocol
3539
+ if (result && typeof result[Symbol.asyncIterator] === 'function') {
3540
+ return result;
3541
+ }
3542
+ // For async methods, attach __meta to the resolved value, not the Promise
3543
+ if (result && typeof result.then === 'function') {
3544
+ return result.then((resolved) => {
3545
+ attachMeta(resolved);
3546
+ return resolved;
3547
+ });
3239
3548
  }
3240
- // NOTE: Don't emit here - the real emission happens at executeTool level
3241
- // (line 2362 via outputHandler). This method wrapper is only called during
3242
- // direct instantiation testing, not in actual MCP execution paths where executeTool
3243
- // is the proper routing point.
3244
- //
3245
- // If we emit here too, we get duplicate messages:
3246
- // 1. This wrapper emits directly: emit(eventData)
3247
- // 2. executeTool emits via outputHandler: outputHandler(eventData)
3248
- // Both route to daemon, causing double notifications.
3549
+ attachMeta(result);
3249
3550
  return result;
3250
3551
  };
3251
3552
  }
3252
3553
  }
3253
- /**
3254
- * Extract parameter names from a function by parsing its signature
3255
- *
3256
- * Examples:
3257
- * - (text, priority = 'medium') => ['text', 'priority']
3258
- * - (id) => ['id']
3259
- * - () => []
3260
- */
3261
- static extractParamNames(fn) {
3262
- const fnStr = fn.toString();
3263
- // Match parameters inside parentheses: ( ... )
3264
- const match = fnStr.match(/\(([^)]*)\)/);
3265
- if (!match?.[1]) {
3266
- return [];
3267
- }
3268
- return match[1]
3269
- .split(',')
3270
- .map((param) => {
3271
- const cleaned = param
3272
- .trim()
3273
- .split('=')[0] // Remove default value
3274
- .split(':')[0] // Remove type annotations
3275
- .trim();
3276
- return cleaned;
3277
- })
3278
- .filter((name) => name && name !== 'this');
3279
- }
3280
3554
  /**
3281
3555
  * Extract @mcp dependencies from source and inject them as instance properties
3282
3556
  *