@newrelic/preflight 1.0.0 → 1.0.2

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 (46) hide show
  1. package/dist/config.d.ts.map +1 -1
  2. package/dist/config.js +12 -4
  3. package/dist/config.js.map +1 -1
  4. package/dist/dashboard/live-event-bus.d.ts.map +1 -1
  5. package/dist/dashboard/live-event-bus.js.map +1 -1
  6. package/dist/dashboard/routes/api-handler.d.ts.map +1 -1
  7. package/dist/dashboard/routes/api-handler.js +7 -8
  8. package/dist/dashboard/routes/api-handler.js.map +1 -1
  9. package/dist/dashboard/routes/sse-handler.js +11 -11
  10. package/dist/dashboard/routes/sse-handler.js.map +1 -1
  11. package/dist/dashboard/routes/static-handler.d.ts.map +1 -1
  12. package/dist/dashboard/routes/static-handler.js +21 -1
  13. package/dist/dashboard/routes/static-handler.js.map +1 -1
  14. package/dist/hooks/event-processor.d.ts +9 -2
  15. package/dist/hooks/event-processor.d.ts.map +1 -1
  16. package/dist/hooks/event-processor.js +12 -0
  17. package/dist/hooks/event-processor.js.map +1 -1
  18. package/dist/hooks/session-resolver.js +1 -1
  19. package/dist/hooks/session-resolver.js.map +1 -1
  20. package/dist/index.d.ts +6 -6
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +304 -153
  23. package/dist/index.js.map +1 -1
  24. package/dist/install/cli.d.ts.map +1 -1
  25. package/dist/install/cli.js +5 -1
  26. package/dist/install/cli.js.map +1 -1
  27. package/dist/install/install-helper.js +1 -1
  28. package/dist/install/install-helper.js.map +1 -1
  29. package/dist/install/schedule.d.ts +7 -0
  30. package/dist/install/schedule.d.ts.map +1 -1
  31. package/dist/install/schedule.js +75 -0
  32. package/dist/install/schedule.js.map +1 -1
  33. package/dist/install/setup-wizard.d.ts.map +1 -1
  34. package/dist/install/setup-wizard.js +29 -1
  35. package/dist/install/setup-wizard.js.map +1 -1
  36. package/dist/metrics/live-session-registry.js +4 -4
  37. package/dist/metrics/live-session-registry.js.map +1 -1
  38. package/dist/metrics/session-tracker.d.ts +2 -0
  39. package/dist/metrics/session-tracker.d.ts.map +1 -1
  40. package/dist/metrics/session-tracker.js +9 -2
  41. package/dist/metrics/session-tracker.js.map +1 -1
  42. package/dist/storage/local-store.js +1 -1
  43. package/dist/storage/local-store.js.map +1 -1
  44. package/dist/transport/nr-ingest.js +1 -1
  45. package/dist/transport/nr-ingest.js.map +1 -1
  46. package/package.json +6 -2
package/dist/index.js CHANGED
@@ -53,7 +53,7 @@ import { parseLocalAlertRules } from './alerts/local-alert-rule.js';
53
53
  import { localDateKey, todayPortionOfSessionCost } from './lib/date.js';
54
54
  import { FeedbackCollector } from './tools/workflow-tools.js';
55
55
  import { registerTools, registerPendingTools } from './tools/session-stats.js';
56
- import { resolveSessionId, resolveFromJobDir, resolveFromBreadcrumb, } from './hooks/session-resolver.js';
56
+ import { resolveSessionId, resolveFromJobDir, resolveFromBreadcrumb, isSyntheticSessionId, } from './hooks/session-resolver.js';
57
57
  import { initMcpTracer } from './tracing/mcp-tracer.js';
58
58
  import { SessionSpan } from './tracing/session-span.js';
59
59
  import { TaskSpanTracker } from './tracing/task-span-tracker.js';
@@ -97,7 +97,7 @@ export function classifyDashboardStartError(err, host, port) {
97
97
  }
98
98
  /**
99
99
  * Default interval (ms) between dashboard re-bind attempts when this MCP
100
- * started in headless mode (Fix 1 EADDRINUSE skip path). Overridable via
100
+ * started in headless mode (EADDRINUSE skip). Overridable via
101
101
  * NR_AI_DASHBOARD_REPOLL_MS — kept simple to avoid threading a new config
102
102
  * field through the loader for what is essentially a knob for tests.
103
103
  */
@@ -114,10 +114,10 @@ export function getDashboardRepollIntervalMs() {
114
114
  export function setupDashboardPostBind(addr, deps) {
115
115
  const log = createLogger('mcp-cli');
116
116
  log.info(`Dashboard ready at http://${addr.address}:${addr.port}`);
117
- // Task #18: only the dashboard owner runs orphan-buffer/breadcrumb GC —
118
- // running it from every MCP would race with itself and re-archive files
119
- // repeatedly. Run once at startup, then every 5 minutes. The interval is
120
- // unref'd so it doesn't keep the event loop alive.
117
+ // Only the dashboard owner runs orphan-buffer/breadcrumb GC — running it
118
+ // from every MCP would race with itself and re-archive files repeatedly.
119
+ // Run once at startup, then every 5 minutes. The interval is unref'd so
120
+ // it doesn't keep the event loop alive.
121
121
  const { localStore, liveSessionRegistry } = deps;
122
122
  const runGc = () => {
123
123
  try {
@@ -370,15 +370,21 @@ async function main() {
370
370
  let alertRulesWatchTimer;
371
371
  let localStoreForShutdown;
372
372
  let gcInterval;
373
- // Task #13: when this MCP starts headless (Fix 1 EADDRINUSE skip), this
374
- // interval retries dashboardServer.start() periodically so we can take
375
- // over if the current owner exits. Cleared in the shutdown handler.
373
+ // When this MCP starts headless (EADDRINUSE skip), this interval retries
374
+ // dashboardServer.start() periodically so we can take over if the current
375
+ // owner exits. Cleared in the shutdown handler.
376
376
  let dashboardRepollInterval;
377
+ // Aborts the async resolveSessionId polling loop when shutdown fires so
378
+ // the breadcrumb poll does not outlive the process.
379
+ let sessionResolutionAbort;
377
380
  let shuttingDown = false;
378
381
  const shutdown = async () => {
379
382
  if (shuttingDown)
380
383
  return;
381
384
  shuttingDown = true;
385
+ // Abort any in-progress session resolution so its polling loop exits
386
+ // cleanly rather than continuing after process.exit() is called.
387
+ sessionResolutionAbort?.abort();
382
388
  logger.info('Shutting down...');
383
389
  try {
384
390
  persistSession?.();
@@ -394,8 +400,8 @@ async function main() {
394
400
  clearInterval(gcInterval);
395
401
  if (dashboardRepollInterval)
396
402
  clearInterval(dashboardRepollInterval);
397
- // Task #18: remove this MCP's heartbeat so the next dashboard-owner GC
398
- // pass doesn't have to mtime-archive our buffer file.
403
+ // Remove this MCP's heartbeat so the next dashboard-owner GC pass
404
+ // doesn't have to mtime-archive our buffer file.
399
405
  localStoreForShutdown?.removeHeartbeat();
400
406
  if (alertRulesWatchTimer)
401
407
  clearTimeout(alertRulesWatchTimer);
@@ -463,24 +469,6 @@ async function main() {
463
469
  await mcpServer.close();
464
470
  process.exit(0);
465
471
  }
466
- // Fix 3 / D2: resolve the Claude Code session_id BEFORE constructing
467
- // anything that takes sessionTraceId as input. We try the cheap
468
- // synchronous paths first (CLAUDE_JOB_DIR, then a one-shot breadcrumb
469
- // probe). If both miss, register a "pending" tool handler so the MCP
470
- // can answer health/config requests while we poll for the breadcrumb,
471
- // then await full resolution.
472
- const configFilePathEarly = options.config ?? resolve(DEFAULT_STORAGE_PATH, 'config.json');
473
- const configSummaryEarly = {
474
- mode: config.mode,
475
- developer: config.developer,
476
- accountId: config.accountId ?? null,
477
- licenseKeyMasked: config.licenseKey ? maskCredential(config.licenseKey) : null,
478
- nrApiKeyMasked: config.nrApiKey ? maskCredential(config.nrApiKey) : null,
479
- region: config.collectorHost ?? 'us',
480
- storagePath: config.storagePath,
481
- dashboardUrl: `http://${config.dashboard.host}:${config.dashboard.port}`,
482
- configFilePath: configFilePathEarly,
483
- };
484
472
  const synchronouslyResolved = resolveFromJobDir(process.env.CLAUDE_JOB_DIR ?? null) ??
485
473
  resolveFromBreadcrumb(config.storagePath, process.ppid);
486
474
  if (synchronouslyResolved) {
@@ -488,31 +476,25 @@ async function main() {
488
476
  logger.info('Session ID resolved synchronously', { sessionTraceId });
489
477
  }
490
478
  else {
491
- // Tools must respond with a structured error during the resolution
492
- // window registerPendingTools wires that up. Once resolved we
493
- // overwrite the handlers via registerTools().
494
- registerPendingTools(mcpServer.server, {
495
- sessionStartMs: Date.now(),
496
- developer: config.developer,
497
- configSummary: configSummaryEarly,
498
- });
499
- logger.info('Awaiting session_id resolution (breadcrumb poll)');
500
- try {
501
- sessionTraceId = await resolveSessionId({ storagePath: config.storagePath });
502
- }
503
- catch (err) {
504
- logger.error('Session ID resolution failed; shutting down', { error: String(err) });
505
- await shutdown();
506
- return;
507
- }
479
+ // Use a provisional ID so the shared section (including dashboard) can
480
+ // start immediately. The real session ID is resolved asynchronously in
481
+ // the tail section below after all shared infrastructure is ready.
482
+ sessionTraceId = `pending-${Date.now()}`;
483
+ logger.info('Session ID not yet available; using provisional ID, dashboard will start early');
508
484
  }
485
+ // Create the span objects in Phase A so shutdown always has a valid
486
+ // sessionSpan reference. For the provisional case (pending-{ts}), defer
487
+ // start() to Phase B when the real session ID is known — starting here
488
+ // would emit a ghost span with a placeholder ID to the OTLP backend.
489
+ // SessionSpan.end() guards on started=false, so an unstarted provisional
490
+ // span is a safe no-op on shutdown.
509
491
  if (config.transport !== 'nr-events-api') {
510
492
  initMcpTracer();
511
- }
512
- sessionSpan = new SessionSpan(sessionTraceId, config.developer);
513
- taskSpanTracker = new TaskSpanTracker();
514
- if (config.transport !== 'nr-events-api') {
515
- sessionSpan.start();
493
+ sessionSpan = new SessionSpan(sessionTraceId, config.developer);
494
+ taskSpanTracker = new TaskSpanTracker();
495
+ if (!sessionTraceId.startsWith('pending-')) {
496
+ sessionSpan.start();
497
+ }
516
498
  }
517
499
  }
518
500
  else {
@@ -530,17 +512,18 @@ async function main() {
530
512
  }
531
513
  // Per-session buffer scoping: in --stdio mode the LocalStore is bound to
532
514
  // this MCP's resolved session_id so drainBuffer() only sees this session's
533
- // events. In --local mode no single session owns the buffer; we drain all
534
- // buffer-*.jsonl files via drainAllBuffers() instead.
535
- const localStore = options.stdio
515
+ // events. In --local mode (or the provisional window before session ID
516
+ // resolution) we use an unscoped store that drains all session buffers.
517
+ const isProvisional = options.stdio && sessionTraceId.startsWith('pending-');
518
+ const localStore = options.stdio && !isProvisional
536
519
  ? new LocalStore(config.storagePath, sessionTraceId)
537
520
  : new LocalStore(config.storagePath);
538
521
  localStore.initialize();
539
- // Task #18: every MCP writes its heartbeat once it has bound a session_id
540
- // so the dashboard owner's GC pass can tell which buffer files still have
541
- // a live owner. Removed in the shutdown handler below. No-op in --local
542
- // mode (no sessionId).
543
- if (options.stdio)
522
+ // Every MCP writes its heartbeat once it has bound a session_id so the
523
+ // dashboard owner's GC pass can tell which buffer files still have a live
524
+ // owner. Removed in the shutdown handler below. Skipped during the
525
+ // provisional window — the real heartbeat is written after resolution.
526
+ if (options.stdio && !isProvisional)
544
527
  localStore.writeHeartbeat();
545
528
  localStoreForShutdown = localStore;
546
529
  // Migrate any pre-Fix-3 events from the legacy shared `buffer.jsonl` into
@@ -868,8 +851,8 @@ async function main() {
868
851
  contextTracker,
869
852
  config,
870
853
  configFilePath: options.config ?? resolve(DEFAULT_STORAGE_PATH, 'config.json'),
871
- // Task #17 (D3): the dashboard owner reads every per-session
872
- // buffer file in read-only mode for the Today aggregate endpoint.
854
+ // The dashboard owner reads every per-session buffer file in
855
+ // read-only mode for the Today aggregate endpoint.
873
856
  // peekAllBuffers() returns HookEvent[] — widen at the boundary
874
857
  // so the dashboard tree stays decoupled from storage internals.
875
858
  localStore: {
@@ -894,21 +877,18 @@ async function main() {
894
877
  if (decision.kind === 'rethrow') {
895
878
  throw decision.error;
896
879
  }
897
- // In --local mode the HTTP server IS the process without it there is
898
- // nothing to keep the event loop alive. Treat EADDRINUSE as fatal so
899
- // the user gets an actionable error instead of a silent exit.
900
- if (options.local) {
901
- logger.error(`Dashboard port ${config.dashboard.port} is already in use. ` +
902
- `Stop the existing --local instance before starting another.`);
903
- process.exit(1);
904
- }
880
+ // In --local mode (e.g. a launchd daemon) EADDRINUSE means the port is
881
+ // owned by a --stdio MCP instance. Instead of exiting fatally, poll
882
+ // until the port is free and take over same as the --stdio repoll
883
+ // path. This lets the daemon coexist with active Claude Code sessions
884
+ // and seamlessly reclaim the dashboard when sessions end.
905
885
  logger.info(decision.message);
906
886
  addr = undefined;
907
887
  }
908
888
  // Capture deps for the post-bind helper. Both the initial-bind path
909
889
  // and the re-poll takeover path call this; keeping the closure small
910
890
  // ensures the two paths produce identical side effects (GC interval,
911
- // openOnStart warning, etc. — Task #18 + #13).
891
+ // openOnStart warning, etc.).
912
892
  const postBindDeps = {
913
893
  localStore,
914
894
  liveSessionRegistry,
@@ -919,9 +899,9 @@ async function main() {
919
899
  gcInterval = runPostBind(addr);
920
900
  }
921
901
  else {
922
- // Task #13: this MCP is headless. Schedule periodic re-bind attempts
923
- // so it can take over if the current dashboard owner exits. The
924
- // interval is unref'd and cleared by the shutdown handler.
902
+ // This MCP is headless. Schedule periodic re-bind attempts so it can
903
+ // take over if the current dashboard owner exits. The interval is
904
+ // unref'd and cleared by the shutdown handler.
925
905
  dashboardRepollInterval = startDashboardRepoll({
926
906
  dashboardServer,
927
907
  host: config.dashboard.host,
@@ -932,10 +912,18 @@ async function main() {
932
912
  },
933
913
  logger,
934
914
  });
915
+ // In --local mode the dashboard IS the process — the HTTP listener is
916
+ // the only thing that keeps the event loop alive. When EADDRINUSE fires
917
+ // the listener is never bound, so the repoll interval must be ref'd or
918
+ // Node exits immediately before it ever fires. In --stdio mode stdin
919
+ // acts as the keepalive, so leaving the interval unref'd is correct.
920
+ if (options.local) {
921
+ dashboardRepollInterval.ref?.();
922
+ }
935
923
  }
936
924
  }
937
925
  let capturedNrIngest;
938
- if (config.mode !== 'local') {
926
+ if (config.mode !== 'local' && !isProvisional) {
939
927
  if (!config.licenseKey || !config.accountId) {
940
928
  throw new Error('licenseKey and accountId must be defined. ' +
941
929
  'This should have been caught by config validation. ' +
@@ -996,9 +984,11 @@ async function main() {
996
984
  });
997
985
  eventProcessor = new HookEventProcessor({
998
986
  store: localStore,
999
- // --local mode owns no specific Claude Code session, so drain every
1000
- // per-session buffer so the dashboard sees all live sessions' events.
1001
- drainAllSessions: !options.stdio,
987
+ // --local mode and the provisional --stdio window own no specific Claude
988
+ // Code session; drain every per-session buffer so the dashboard sees all
989
+ // live sessions' events. After real session ID resolution the processor
990
+ // is hot-swapped to the scoped store via replaceStore().
991
+ drainAllSessions: !options.stdio || isProvisional,
1002
992
  onRecord: (record) => {
1003
993
  if (!config || !sessionTracker || !taskDetector) {
1004
994
  logger.warn('onRecord called before full initialization; skipping');
@@ -1042,12 +1032,12 @@ async function main() {
1042
1032
  // returned AuditRecord rather than recording a second time.
1043
1033
  const auditRecord = auditTrail.recordToolCall(record);
1044
1034
  capturedNrIngest?.ingestToolCall(record, auditRecord);
1045
- // Task #17 (D3): SSE consumers filter by sessionId for the per-
1046
- // session live tail. Records without a sessionId are pre-Fix-3
1047
- // legacy buffer leaks during the migrateLegacyBuffer() window on
1048
- // first boot — skip the live emit rather than fabricate a session
1049
- // by falling back to the MCP's resolved sessionTraceId, which would
1050
- // re-introduce the fictional-session-ID bug Fix 3 removed.
1035
+ // SSE consumers filter by sessionId for the per-session live tail.
1036
+ // Records without a sessionId are legacy buffer entries that surfaced
1037
+ // during the migrateLegacyBuffer() window on first boot — skip the
1038
+ // live emit rather than fabricate a session by falling back to the
1039
+ // MCP's resolved sessionTraceId, which would re-introduce the
1040
+ // fictional-session-ID bug the session-ID resolver removed.
1051
1041
  if (record.sessionId) {
1052
1042
  liveBus.emit('tool-call', {
1053
1043
  id: record.id,
@@ -1093,8 +1083,8 @@ async function main() {
1093
1083
  dailyFirstActivityMs,
1094
1084
  });
1095
1085
  liveBus.emit('cost-update', {
1096
- // Task #17 (D3): MCP-owned cost totals sessionId is always the
1097
- // resolved Claude Code session_id for this MCP instance.
1086
+ // sessionId is always the resolved Claude Code session_id for
1087
+ // this MCP instance so cost totals can be attributed per-session.
1098
1088
  sessionId: sessionTraceId,
1099
1089
  sessionTotalUsd: costMetrics.sessionTotalCostUsd,
1100
1090
  todayTotalUsd,
@@ -1113,9 +1103,9 @@ async function main() {
1113
1103
  taskSpanTracker.closeTask(task.taskId, task.toolCallCount);
1114
1104
  }
1115
1105
  const firstRecord = task.toolCalls[0];
1116
- // Fix 3: sessionTraceId is the resolved Claude Code session_id and is
1106
+ // sessionTraceId is the resolved Claude Code session_id and is
1117
1107
  // shared across the whole MCP, so we use it directly rather than
1118
- // peeking at the first record's sessionId (which may now be null).
1108
+ // peeking at the first record's sessionId (which may be null).
1119
1109
  const context = {
1120
1110
  sessionId: sessionTraceId,
1121
1111
  platform: typeof firstRecord?.platform === 'string' ? firstRecord.platform : undefined,
@@ -1126,8 +1116,8 @@ async function main() {
1126
1116
  for (const pattern of patterns) {
1127
1117
  capturedNrIngest?.ingestAntiPattern(pattern, context);
1128
1118
  liveBus.emit('anti-pattern', {
1129
- // Task #17 (D3): tag with the originating session so the Today
1130
- // view can render a "Session: <name>" pill on each alert row.
1119
+ // Tag with the originating session so the Today view can render
1120
+ // a "Session: <name>" pill on each alert row.
1131
1121
  sessionId: sessionTraceId,
1132
1122
  type: pattern.type,
1133
1123
  target: pattern.file ?? pattern.command ?? 'unknown',
@@ -1205,8 +1195,8 @@ async function main() {
1205
1195
  dailyFirstActivityMs,
1206
1196
  });
1207
1197
  liveBus.emit('cost-update', {
1208
- // Task #17 (D3): same as the per-tool-call cost-update emission —
1209
- // tag with the MCP's owning session_id.
1198
+ // Same as the per-tool-call cost-update emission — tag with the
1199
+ // MCP's owning session_id for per-session attribution.
1210
1200
  sessionId: sessionTraceId,
1211
1201
  sessionTotalUsd: costMetrics.sessionTotalCostUsd,
1212
1202
  todayTotalUsd,
@@ -1235,7 +1225,7 @@ async function main() {
1235
1225
  // bookkeeping; they don't correspond to a real Claude Code session
1236
1226
  // and produce confusing `local-...` rows in the dashboard's history
1237
1227
  // view that have no useful content to show.
1238
- const isSyntheticId = summary.sessionId.startsWith('local-') || summary.sessionId.startsWith('proxy-');
1228
+ const isSyntheticId = isSyntheticSessionId(summary.sessionId);
1239
1229
  if (isSyntheticId) {
1240
1230
  logger.info('Skipping synthetic session JSON persistence', {
1241
1231
  sessionId: summary.sessionId,
@@ -1257,66 +1247,227 @@ async function main() {
1257
1247
  // Same instance is shared with the DashboardServer and NrIngestManager so all
1258
1248
  // three see the same audit log.
1259
1249
  mcpServer.auditTrailManager = auditTrail;
1260
- // Re-register tools with full dependencies (replaces empty handlers)
1261
- const configFilePath = options.config ?? resolve(DEFAULT_STORAGE_PATH, 'config.json');
1262
- const configSummary = {
1263
- mode: config.mode,
1264
- developer: config.developer,
1265
- accountId: config.accountId ?? null,
1266
- licenseKeyMasked: config.licenseKey ? maskCredential(config.licenseKey) : null,
1267
- nrApiKeyMasked: config.nrApiKey ? maskCredential(config.nrApiKey) : null,
1268
- region: config.collectorHost ?? 'us',
1269
- storagePath: config.storagePath,
1270
- dashboardUrl: `http://${config.dashboard.host}:${config.dashboard.port}`,
1271
- configFilePath,
1272
- };
1273
- registerTools(mcpServer.server, {
1274
- sessionTracker,
1275
- costTracker,
1276
- budgetTracker,
1277
- taskDetector,
1278
- antiPatternDetector,
1279
- efficiencyScorer,
1280
- feedbackCollector,
1281
- sessionStore,
1282
- weeklySummaryGenerator,
1283
- trendAnalyzer,
1284
- collaborationProfiler,
1285
- claudeMdTracker,
1286
- costPerOutcomeAnalyzer,
1287
- recommendationEngine,
1288
- contextWindowTracker,
1289
- contextTracker,
1290
- latencyTracker,
1291
- taskCompletionTracker,
1292
- modelUsageTracker,
1293
- retryDetector,
1294
- contextCompositionTracker,
1295
- latencyDecompositionTracker,
1296
- decisionTracker,
1297
- instructionDriftTracker,
1298
- toolSelectionScorer,
1299
- toolCallBuffer: toolCallBufferAccessor,
1300
- qualityProxyTracker,
1301
- apiFailureTracker,
1302
- turnCostAttributor,
1303
- turnTracker,
1304
- gitEfficiencyTracker,
1305
- sessionTraceId,
1306
- sessionStartMs,
1307
- accountId: config.accountId,
1308
- teamId: config.teamId,
1309
- projectId: config.projectId,
1310
- developer: config.developer,
1311
- nrApiKey: config.nrApiKey,
1312
- collectorHost: config.collectorHost,
1313
- configFilePath,
1314
- configSummary,
1315
- });
1316
- nrIngest?.start();
1317
- logger.info('Server running on stdio transport');
1318
- // stdin 'end' and 'error' handlers are registered immediately after
1319
- // connectStdio() above so shutdown fires even during session-ID resolution.
1250
+ if (isProvisional) {
1251
+ // Dashboard is already live. Register pending tools so the MCP can
1252
+ // respond to health/config requests while the real session ID resolves.
1253
+ const pendingConfigFilePath = options.config ?? resolve(DEFAULT_STORAGE_PATH, 'config.json');
1254
+ registerPendingTools(mcpServer.server, {
1255
+ sessionStartMs: Date.now(),
1256
+ developer: config.developer,
1257
+ configSummary: {
1258
+ mode: config.mode,
1259
+ developer: config.developer,
1260
+ accountId: config.accountId ?? null,
1261
+ licenseKeyMasked: config.licenseKey ? maskCredential(config.licenseKey) : null,
1262
+ nrApiKeyMasked: config.nrApiKey ? maskCredential(config.nrApiKey) : null,
1263
+ region: config.collectorHost ?? 'us',
1264
+ storagePath: config.storagePath,
1265
+ dashboardUrl: `http://${config.dashboard.host}:${config.dashboard.port}`,
1266
+ configFilePath: pendingConfigFilePath,
1267
+ },
1268
+ });
1269
+ logger.info('Dashboard started early; awaiting session_id resolution (breadcrumb poll)');
1270
+ sessionResolutionAbort = new AbortController();
1271
+ void (async () => {
1272
+ try {
1273
+ const realId = await resolveSessionId({
1274
+ storagePath: config.storagePath,
1275
+ signal: sessionResolutionAbort.signal,
1276
+ });
1277
+ // Adopt the real session ID without clearing accumulated metrics.
1278
+ sessionTraceId = realId;
1279
+ sessionTracker.adoptSessionId(realId);
1280
+ // Replace the provisional unscoped LocalStore with the session-scoped one.
1281
+ const realLocalStore = new LocalStore(config.storagePath, realId);
1282
+ realLocalStore.initialize();
1283
+ realLocalStore.writeHeartbeat();
1284
+ localStoreForShutdown = realLocalStore;
1285
+ try {
1286
+ realLocalStore.migrateLegacyBuffer();
1287
+ }
1288
+ catch (err) {
1289
+ logger.warn('Legacy buffer migration failed (continuing)', { error: String(err) });
1290
+ }
1291
+ // Hot-swap the event processor to the scoped store so it only
1292
+ // drains this session's events going forward.
1293
+ eventProcessor.replaceStore(realLocalStore, false);
1294
+ // Replace the provisional span with a real-ID span. End the
1295
+ // provisional one first (end() is a no-op if never started).
1296
+ // initMcpTracer() was already called in Phase A — skip it here.
1297
+ if (config.transport !== 'nr-events-api') {
1298
+ sessionSpan?.end(0, 0);
1299
+ // Close any task spans opened against the provisional tracker
1300
+ // (cross-session events can open them during Phase A) before
1301
+ // replacing it with a clean real-session instance.
1302
+ taskSpanTracker?.closeAll();
1303
+ sessionSpan = new SessionSpan(realId, config.developer);
1304
+ taskSpanTracker = new TaskSpanTracker();
1305
+ sessionSpan.start();
1306
+ }
1307
+ // Complete NrIngest setup.
1308
+ if (config.mode !== 'local') {
1309
+ if (!config.licenseKey || !config.accountId) {
1310
+ throw new Error('licenseKey and accountId must be defined for non-local mode. ' +
1311
+ 'This should have been caught by config validation.');
1312
+ }
1313
+ nrIngest = new NrIngestManager({
1314
+ licenseKey: config.licenseKey,
1315
+ transportOptions: {
1316
+ accountId: config.accountId,
1317
+ collectorHost: config.collectorHost,
1318
+ },
1319
+ developer: config.developer,
1320
+ appName: config.appName,
1321
+ teamId: config.teamId,
1322
+ projectId: config.projectId,
1323
+ orgId: config.orgId,
1324
+ sessionTracker: sessionTracker,
1325
+ localStore: realLocalStore,
1326
+ auditTrail,
1327
+ eventHarvestIntervalMs: config.harvestIntervalMs.events,
1328
+ metricHarvestIntervalMs: config.harvestIntervalMs.metrics,
1329
+ costTracker,
1330
+ efficiencyScorer,
1331
+ turnCostAttributor,
1332
+ sessionTraceId: realId,
1333
+ });
1334
+ capturedNrIngest = nrIngest;
1335
+ nrIngest.start();
1336
+ }
1337
+ // Register full tools, replacing the pending handlers.
1338
+ const configFilePath = options.config ?? resolve(DEFAULT_STORAGE_PATH, 'config.json');
1339
+ const configSummary = {
1340
+ mode: config.mode,
1341
+ developer: config.developer,
1342
+ accountId: config.accountId ?? null,
1343
+ licenseKeyMasked: config.licenseKey ? maskCredential(config.licenseKey) : null,
1344
+ nrApiKeyMasked: config.nrApiKey ? maskCredential(config.nrApiKey) : null,
1345
+ region: config.collectorHost ?? 'us',
1346
+ storagePath: config.storagePath,
1347
+ dashboardUrl: `http://${config.dashboard.host}:${config.dashboard.port}`,
1348
+ configFilePath,
1349
+ };
1350
+ registerTools(mcpServer.server, {
1351
+ sessionTracker: sessionTracker,
1352
+ costTracker,
1353
+ budgetTracker,
1354
+ taskDetector: taskDetector,
1355
+ antiPatternDetector,
1356
+ efficiencyScorer,
1357
+ feedbackCollector,
1358
+ sessionStore,
1359
+ weeklySummaryGenerator,
1360
+ trendAnalyzer,
1361
+ collaborationProfiler,
1362
+ claudeMdTracker,
1363
+ costPerOutcomeAnalyzer,
1364
+ recommendationEngine,
1365
+ contextWindowTracker,
1366
+ contextTracker,
1367
+ latencyTracker,
1368
+ taskCompletionTracker,
1369
+ modelUsageTracker,
1370
+ retryDetector,
1371
+ contextCompositionTracker,
1372
+ latencyDecompositionTracker,
1373
+ decisionTracker,
1374
+ instructionDriftTracker,
1375
+ toolSelectionScorer,
1376
+ toolCallBuffer: toolCallBufferAccessor,
1377
+ qualityProxyTracker,
1378
+ apiFailureTracker,
1379
+ turnCostAttributor,
1380
+ turnTracker,
1381
+ gitEfficiencyTracker,
1382
+ sessionTraceId: realId,
1383
+ sessionStartMs,
1384
+ accountId: config.accountId,
1385
+ teamId: config.teamId,
1386
+ projectId: config.projectId,
1387
+ developer: config.developer,
1388
+ nrApiKey: config.nrApiKey,
1389
+ collectorHost: config.collectorHost,
1390
+ configFilePath,
1391
+ configSummary,
1392
+ });
1393
+ logger.info('Session ID resolved, full initialization complete', {
1394
+ sessionTraceId: realId,
1395
+ });
1396
+ }
1397
+ catch (err) {
1398
+ // Use the signal's own aborted flag rather than matching the error
1399
+ // message string — robust against future changes to the throw site.
1400
+ if (sessionResolutionAbort?.signal.aborted) {
1401
+ logger.info('Session ID resolution aborted by shutdown');
1402
+ return;
1403
+ }
1404
+ logger.error('Session ID resolution failed; shutting down', { error: String(err) });
1405
+ await shutdown();
1406
+ }
1407
+ })();
1408
+ }
1409
+ else {
1410
+ // Session ID resolved synchronously — proceed as normal.
1411
+ const configFilePath = options.config ?? resolve(DEFAULT_STORAGE_PATH, 'config.json');
1412
+ const configSummary = {
1413
+ mode: config.mode,
1414
+ developer: config.developer,
1415
+ accountId: config.accountId ?? null,
1416
+ licenseKeyMasked: config.licenseKey ? maskCredential(config.licenseKey) : null,
1417
+ nrApiKeyMasked: config.nrApiKey ? maskCredential(config.nrApiKey) : null,
1418
+ region: config.collectorHost ?? 'us',
1419
+ storagePath: config.storagePath,
1420
+ dashboardUrl: `http://${config.dashboard.host}:${config.dashboard.port}`,
1421
+ configFilePath,
1422
+ };
1423
+ registerTools(mcpServer.server, {
1424
+ sessionTracker,
1425
+ costTracker,
1426
+ budgetTracker,
1427
+ taskDetector,
1428
+ antiPatternDetector,
1429
+ efficiencyScorer,
1430
+ feedbackCollector,
1431
+ sessionStore,
1432
+ weeklySummaryGenerator,
1433
+ trendAnalyzer,
1434
+ collaborationProfiler,
1435
+ claudeMdTracker,
1436
+ costPerOutcomeAnalyzer,
1437
+ recommendationEngine,
1438
+ contextWindowTracker,
1439
+ contextTracker,
1440
+ latencyTracker,
1441
+ taskCompletionTracker,
1442
+ modelUsageTracker,
1443
+ retryDetector,
1444
+ contextCompositionTracker,
1445
+ latencyDecompositionTracker,
1446
+ decisionTracker,
1447
+ instructionDriftTracker,
1448
+ toolSelectionScorer,
1449
+ toolCallBuffer: toolCallBufferAccessor,
1450
+ qualityProxyTracker,
1451
+ apiFailureTracker,
1452
+ turnCostAttributor,
1453
+ turnTracker,
1454
+ gitEfficiencyTracker,
1455
+ sessionTraceId,
1456
+ sessionStartMs,
1457
+ accountId: config.accountId,
1458
+ teamId: config.teamId,
1459
+ projectId: config.projectId,
1460
+ developer: config.developer,
1461
+ nrApiKey: config.nrApiKey,
1462
+ collectorHost: config.collectorHost,
1463
+ configFilePath,
1464
+ configSummary,
1465
+ });
1466
+ nrIngest?.start();
1467
+ logger.info('Server running on stdio transport');
1468
+ // stdin 'end' and 'error' handlers are registered immediately after
1469
+ // connectStdio() above so shutdown fires even during session-ID resolution.
1470
+ }
1320
1471
  }
1321
1472
  else {
1322
1473
  logger.info('Server running in local dashboard mode (Ctrl+C to stop)');
@@ -1338,7 +1489,7 @@ async function main() {
1338
1489
  }
1339
1490
  // Proxy mode has no Claude Code session to resolve; use a deterministic
1340
1491
  // identifier instead of randomUUID so we don't fabricate something that
1341
- // looks like a real session id (Fix 3).
1492
+ // looks like a real session id.
1342
1493
  const sessionTraceId = `proxy-${Date.now()}`;
1343
1494
  proxyManager = new ProxyManager({
1344
1495
  port: config.port,