@newrelic/preflight 0.0.1-pre.1 → 1.0.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 (578) hide show
  1. package/LICENSE +183 -0
  2. package/README.md +498 -0
  3. package/dist/alerts/alert-log.d.ts +24 -0
  4. package/dist/alerts/alert-log.d.ts.map +1 -0
  5. package/dist/alerts/alert-log.js +159 -0
  6. package/dist/alerts/alert-log.js.map +1 -0
  7. package/dist/alerts/alert-snapshot-collector.d.ts +168 -0
  8. package/dist/alerts/alert-snapshot-collector.d.ts.map +1 -0
  9. package/dist/alerts/alert-snapshot-collector.js +243 -0
  10. package/dist/alerts/alert-snapshot-collector.js.map +1 -0
  11. package/dist/alerts/local-alert-engine.d.ts +86 -0
  12. package/dist/alerts/local-alert-engine.d.ts.map +1 -0
  13. package/dist/alerts/local-alert-engine.js +466 -0
  14. package/dist/alerts/local-alert-engine.js.map +1 -0
  15. package/dist/alerts/local-alert-rule.d.ts +439 -0
  16. package/dist/alerts/local-alert-rule.d.ts.map +1 -0
  17. package/dist/alerts/local-alert-rule.js +139 -0
  18. package/dist/alerts/local-alert-rule.js.map +1 -0
  19. package/dist/alerts/os-notifier.d.ts +39 -0
  20. package/dist/alerts/os-notifier.d.ts.map +1 -0
  21. package/dist/alerts/os-notifier.js +170 -0
  22. package/dist/alerts/os-notifier.js.map +1 -0
  23. package/dist/alerts/types.d.ts +35 -0
  24. package/dist/alerts/types.d.ts.map +1 -0
  25. package/dist/alerts/types.js +8 -0
  26. package/dist/alerts/types.js.map +1 -0
  27. package/dist/config.d.ts +169 -0
  28. package/dist/config.d.ts.map +1 -0
  29. package/dist/config.js +860 -0
  30. package/dist/config.js.map +1 -0
  31. package/dist/dashboard/dashboard-server.d.ts +38 -0
  32. package/dist/dashboard/dashboard-server.d.ts.map +1 -0
  33. package/dist/dashboard/dashboard-server.js +207 -0
  34. package/dist/dashboard/dashboard-server.js.map +1 -0
  35. package/dist/dashboard/index.d.ts +3 -0
  36. package/dist/dashboard/index.d.ts.map +1 -0
  37. package/dist/dashboard/index.js +2 -0
  38. package/dist/dashboard/index.js.map +1 -0
  39. package/dist/dashboard/live-event-bus.d.ts +99 -0
  40. package/dist/dashboard/live-event-bus.d.ts.map +1 -0
  41. package/dist/dashboard/live-event-bus.js +56 -0
  42. package/dist/dashboard/live-event-bus.js.map +1 -0
  43. package/dist/dashboard/routes/api-handler.d.ts +122 -0
  44. package/dist/dashboard/routes/api-handler.d.ts.map +1 -0
  45. package/dist/dashboard/routes/api-handler.js +1414 -0
  46. package/dist/dashboard/routes/api-handler.js.map +1 -0
  47. package/dist/dashboard/routes/replay-analyzer.d.ts +15 -0
  48. package/dist/dashboard/routes/replay-analyzer.d.ts.map +1 -0
  49. package/dist/dashboard/routes/replay-analyzer.js +227 -0
  50. package/dist/dashboard/routes/replay-analyzer.js.map +1 -0
  51. package/dist/dashboard/routes/sse-handler.d.ts +4 -0
  52. package/dist/dashboard/routes/sse-handler.d.ts.map +1 -0
  53. package/dist/dashboard/routes/sse-handler.js +122 -0
  54. package/dist/dashboard/routes/sse-handler.js.map +1 -0
  55. package/dist/dashboard/routes/static-handler.d.ts +3 -0
  56. package/dist/dashboard/routes/static-handler.d.ts.map +1 -0
  57. package/dist/dashboard/routes/static-handler.js +103 -0
  58. package/dist/dashboard/routes/static-handler.js.map +1 -0
  59. package/dist/data/alerts/conditions/01-daily-cost-spike.json +16 -0
  60. package/dist/data/alerts/conditions/02-low-efficiency-score.json +16 -0
  61. package/dist/data/alerts/conditions/03-stuck-loop-rate.json +16 -0
  62. package/dist/data/alerts/conditions/04-anti-pattern-rate.json +16 -0
  63. package/dist/data/alerts/conditions/05-session-cost-budget.json +16 -0
  64. package/dist/data/alerts/conditions-personal/01-personal-daily-cost.json +16 -0
  65. package/dist/data/alerts/conditions-personal/02-personal-session-cost.json +16 -0
  66. package/dist/data/alerts/conditions-personal/03-personal-low-efficiency.json +16 -0
  67. package/dist/data/alerts/conditions-personal/04-personal-anti-pattern-rate.json +16 -0
  68. package/dist/data/alerts/conditions-personal/05-personal-stuck-loop.json +16 -0
  69. package/dist/data/alerts/policy.json +4 -0
  70. package/dist/data/dashboards/ai-coding-assistant-manager-view.json +103 -0
  71. package/dist/data/dashboards/ai-coding-assistant-overview.json +239 -0
  72. package/dist/data/dashboards/ai-coding-assistant-personal.json +442 -0
  73. package/dist/data/dashboards/ai-coding-assistant-platform-comparison.json +320 -0
  74. package/dist/data/dashboards/ai-coding-assistant-security.json +275 -0
  75. package/dist/data/dashboards/ai-coding-assistant-session-detail.json +296 -0
  76. package/dist/data/dashboards/ai-coding-assistant-team-view.json +345 -0
  77. package/dist/deploy/data-paths.d.ts +22 -0
  78. package/dist/deploy/data-paths.d.ts.map +1 -0
  79. package/dist/deploy/data-paths.js +69 -0
  80. package/dist/deploy/data-paths.js.map +1 -0
  81. package/dist/deploy/deploy-alerts.d.ts +58 -0
  82. package/dist/deploy/deploy-alerts.d.ts.map +1 -0
  83. package/dist/deploy/deploy-alerts.js +371 -0
  84. package/dist/deploy/deploy-alerts.js.map +1 -0
  85. package/dist/deploy/deploy-dashboards.d.ts +92 -0
  86. package/dist/deploy/deploy-dashboards.d.ts.map +1 -0
  87. package/dist/deploy/deploy-dashboards.js +282 -0
  88. package/dist/deploy/deploy-dashboards.js.map +1 -0
  89. package/dist/digest/digest-formatter.d.ts +3 -0
  90. package/dist/digest/digest-formatter.d.ts.map +1 -0
  91. package/dist/digest/digest-formatter.js +37 -0
  92. package/dist/digest/digest-formatter.js.map +1 -0
  93. package/dist/digest/digest-sender.d.ts +2 -0
  94. package/dist/digest/digest-sender.d.ts.map +1 -0
  95. package/dist/digest/digest-sender.js +29 -0
  96. package/dist/digest/digest-sender.js.map +1 -0
  97. package/dist/hooks/bash-classifier.d.ts +26 -0
  98. package/dist/hooks/bash-classifier.d.ts.map +1 -0
  99. package/dist/hooks/bash-classifier.js +409 -0
  100. package/dist/hooks/bash-classifier.js.map +1 -0
  101. package/dist/hooks/collector-script.d.ts +47 -0
  102. package/dist/hooks/collector-script.d.ts.map +1 -0
  103. package/dist/hooks/collector-script.js +662 -0
  104. package/dist/hooks/collector-script.js.map +1 -0
  105. package/dist/hooks/event-processor.d.ts +65 -0
  106. package/dist/hooks/event-processor.d.ts.map +1 -0
  107. package/dist/hooks/event-processor.js +342 -0
  108. package/dist/hooks/event-processor.js.map +1 -0
  109. package/dist/hooks/index.d.ts +7 -0
  110. package/dist/hooks/index.d.ts.map +1 -0
  111. package/dist/hooks/index.js +5 -0
  112. package/dist/hooks/index.js.map +1 -0
  113. package/dist/hooks/session-resolver.d.ts +66 -0
  114. package/dist/hooks/session-resolver.d.ts.map +1 -0
  115. package/dist/hooks/session-resolver.js +196 -0
  116. package/dist/hooks/session-resolver.js.map +1 -0
  117. package/dist/hooks/tool-parsers.d.ts +19 -0
  118. package/dist/hooks/tool-parsers.d.ts.map +1 -0
  119. package/dist/hooks/tool-parsers.js +260 -0
  120. package/dist/hooks/tool-parsers.js.map +1 -0
  121. package/dist/index.d.ts +107 -0
  122. package/dist/index.d.ts.map +1 -0
  123. package/dist/index.js +1505 -0
  124. package/dist/index.js.map +1 -0
  125. package/dist/install/cli.d.ts +11 -0
  126. package/dist/install/cli.d.ts.map +1 -0
  127. package/dist/install/cli.js +365 -0
  128. package/dist/install/cli.js.map +1 -0
  129. package/dist/install/index.d.ts +4 -0
  130. package/dist/install/index.d.ts.map +1 -0
  131. package/dist/install/index.js +3 -0
  132. package/dist/install/index.js.map +1 -0
  133. package/dist/install/install-helper.d.ts +35 -0
  134. package/dist/install/install-helper.d.ts.map +1 -0
  135. package/dist/install/install-helper.js +227 -0
  136. package/dist/install/install-helper.js.map +1 -0
  137. package/dist/install/key-validator.d.ts +19 -0
  138. package/dist/install/key-validator.d.ts.map +1 -0
  139. package/dist/install/key-validator.js +122 -0
  140. package/dist/install/key-validator.js.map +1 -0
  141. package/dist/install/migrate.d.ts +12 -0
  142. package/dist/install/migrate.d.ts.map +1 -0
  143. package/dist/install/migrate.js +115 -0
  144. package/dist/install/migrate.js.map +1 -0
  145. package/dist/install/schedule.d.ts +11 -0
  146. package/dist/install/schedule.d.ts.map +1 -0
  147. package/dist/install/schedule.js +114 -0
  148. package/dist/install/schedule.js.map +1 -0
  149. package/dist/install/setup-wizard.d.ts +40 -0
  150. package/dist/install/setup-wizard.d.ts.map +1 -0
  151. package/dist/install/setup-wizard.js +489 -0
  152. package/dist/install/setup-wizard.js.map +1 -0
  153. package/dist/lib/date.d.ts +54 -0
  154. package/dist/lib/date.d.ts.map +1 -0
  155. package/dist/lib/date.js +85 -0
  156. package/dist/lib/date.js.map +1 -0
  157. package/dist/metrics/anti-patterns.d.ts +62 -0
  158. package/dist/metrics/anti-patterns.d.ts.map +1 -0
  159. package/dist/metrics/anti-patterns.js +301 -0
  160. package/dist/metrics/anti-patterns.js.map +1 -0
  161. package/dist/metrics/api-failure-tracker.d.ts +82 -0
  162. package/dist/metrics/api-failure-tracker.d.ts.map +1 -0
  163. package/dist/metrics/api-failure-tracker.js +202 -0
  164. package/dist/metrics/api-failure-tracker.js.map +1 -0
  165. package/dist/metrics/budget-tracker.d.ts +60 -0
  166. package/dist/metrics/budget-tracker.d.ts.map +1 -0
  167. package/dist/metrics/budget-tracker.js +130 -0
  168. package/dist/metrics/budget-tracker.js.map +1 -0
  169. package/dist/metrics/claudemd-tracker.d.ts +108 -0
  170. package/dist/metrics/claudemd-tracker.d.ts.map +1 -0
  171. package/dist/metrics/claudemd-tracker.js +337 -0
  172. package/dist/metrics/claudemd-tracker.js.map +1 -0
  173. package/dist/metrics/collaboration-profile.d.ts +65 -0
  174. package/dist/metrics/collaboration-profile.d.ts.map +1 -0
  175. package/dist/metrics/collaboration-profile.js +231 -0
  176. package/dist/metrics/collaboration-profile.js.map +1 -0
  177. package/dist/metrics/context-composition-tracker.d.ts +74 -0
  178. package/dist/metrics/context-composition-tracker.d.ts.map +1 -0
  179. package/dist/metrics/context-composition-tracker.js +202 -0
  180. package/dist/metrics/context-composition-tracker.js.map +1 -0
  181. package/dist/metrics/context-tracker.d.ts +78 -0
  182. package/dist/metrics/context-tracker.d.ts.map +1 -0
  183. package/dist/metrics/context-tracker.js +222 -0
  184. package/dist/metrics/context-tracker.js.map +1 -0
  185. package/dist/metrics/context-window-tracker.d.ts +18 -0
  186. package/dist/metrics/context-window-tracker.d.ts.map +1 -0
  187. package/dist/metrics/context-window-tracker.js +35 -0
  188. package/dist/metrics/context-window-tracker.js.map +1 -0
  189. package/dist/metrics/cost-forecast.d.ts +36 -0
  190. package/dist/metrics/cost-forecast.d.ts.map +1 -0
  191. package/dist/metrics/cost-forecast.js +91 -0
  192. package/dist/metrics/cost-forecast.js.map +1 -0
  193. package/dist/metrics/cost-per-outcome.d.ts +102 -0
  194. package/dist/metrics/cost-per-outcome.d.ts.map +1 -0
  195. package/dist/metrics/cost-per-outcome.js +266 -0
  196. package/dist/metrics/cost-per-outcome.js.map +1 -0
  197. package/dist/metrics/cost-tracker.d.ts +78 -0
  198. package/dist/metrics/cost-tracker.d.ts.map +1 -0
  199. package/dist/metrics/cost-tracker.js +169 -0
  200. package/dist/metrics/cost-tracker.js.map +1 -0
  201. package/dist/metrics/decision-tracker.d.ts +49 -0
  202. package/dist/metrics/decision-tracker.d.ts.map +1 -0
  203. package/dist/metrics/decision-tracker.js +161 -0
  204. package/dist/metrics/decision-tracker.js.map +1 -0
  205. package/dist/metrics/efficiency-score.d.ts +80 -0
  206. package/dist/metrics/efficiency-score.d.ts.map +1 -0
  207. package/dist/metrics/efficiency-score.js +219 -0
  208. package/dist/metrics/efficiency-score.js.map +1 -0
  209. package/dist/metrics/git-efficiency-tracker.d.ts +165 -0
  210. package/dist/metrics/git-efficiency-tracker.d.ts.map +1 -0
  211. package/dist/metrics/git-efficiency-tracker.js +1056 -0
  212. package/dist/metrics/git-efficiency-tracker.js.map +1 -0
  213. package/dist/metrics/index.d.ts +26 -0
  214. package/dist/metrics/index.d.ts.map +1 -0
  215. package/dist/metrics/index.js +14 -0
  216. package/dist/metrics/index.js.map +1 -0
  217. package/dist/metrics/instruction-drift-tracker.d.ts +69 -0
  218. package/dist/metrics/instruction-drift-tracker.d.ts.map +1 -0
  219. package/dist/metrics/instruction-drift-tracker.js +213 -0
  220. package/dist/metrics/instruction-drift-tracker.js.map +1 -0
  221. package/dist/metrics/latency-decomposition.d.ts +50 -0
  222. package/dist/metrics/latency-decomposition.d.ts.map +1 -0
  223. package/dist/metrics/latency-decomposition.js +112 -0
  224. package/dist/metrics/latency-decomposition.js.map +1 -0
  225. package/dist/metrics/latency-tracker.d.ts +33 -0
  226. package/dist/metrics/latency-tracker.d.ts.map +1 -0
  227. package/dist/metrics/latency-tracker.js +93 -0
  228. package/dist/metrics/latency-tracker.js.map +1 -0
  229. package/dist/metrics/live-session-registry.d.ts +29 -0
  230. package/dist/metrics/live-session-registry.d.ts.map +1 -0
  231. package/dist/metrics/live-session-registry.js +103 -0
  232. package/dist/metrics/live-session-registry.js.map +1 -0
  233. package/dist/metrics/model-usage-tracker.d.ts +21 -0
  234. package/dist/metrics/model-usage-tracker.d.ts.map +1 -0
  235. package/dist/metrics/model-usage-tracker.js +53 -0
  236. package/dist/metrics/model-usage-tracker.js.map +1 -0
  237. package/dist/metrics/percentile.d.ts +5 -0
  238. package/dist/metrics/percentile.d.ts.map +1 -0
  239. package/dist/metrics/percentile.js +10 -0
  240. package/dist/metrics/percentile.js.map +1 -0
  241. package/dist/metrics/personal-coach.d.ts +47 -0
  242. package/dist/metrics/personal-coach.d.ts.map +1 -0
  243. package/dist/metrics/personal-coach.js +241 -0
  244. package/dist/metrics/personal-coach.js.map +1 -0
  245. package/dist/metrics/prompt-feedback.d.ts +75 -0
  246. package/dist/metrics/prompt-feedback.d.ts.map +1 -0
  247. package/dist/metrics/prompt-feedback.js +286 -0
  248. package/dist/metrics/prompt-feedback.js.map +1 -0
  249. package/dist/metrics/proxy-metrics.d.ts +54 -0
  250. package/dist/metrics/proxy-metrics.d.ts.map +1 -0
  251. package/dist/metrics/proxy-metrics.js +228 -0
  252. package/dist/metrics/proxy-metrics.js.map +1 -0
  253. package/dist/metrics/quality-proxy-tracker.d.ts +51 -0
  254. package/dist/metrics/quality-proxy-tracker.d.ts.map +1 -0
  255. package/dist/metrics/quality-proxy-tracker.js +162 -0
  256. package/dist/metrics/quality-proxy-tracker.js.map +1 -0
  257. package/dist/metrics/recommendation-engine.d.ts +72 -0
  258. package/dist/metrics/recommendation-engine.d.ts.map +1 -0
  259. package/dist/metrics/recommendation-engine.js +207 -0
  260. package/dist/metrics/recommendation-engine.js.map +1 -0
  261. package/dist/metrics/retry-detector.d.ts +43 -0
  262. package/dist/metrics/retry-detector.d.ts.map +1 -0
  263. package/dist/metrics/retry-detector.js +179 -0
  264. package/dist/metrics/retry-detector.js.map +1 -0
  265. package/dist/metrics/session-tracker.d.ts +75 -0
  266. package/dist/metrics/session-tracker.d.ts.map +1 -0
  267. package/dist/metrics/session-tracker.js +249 -0
  268. package/dist/metrics/session-tracker.js.map +1 -0
  269. package/dist/metrics/task-completion-tracker.d.ts +15 -0
  270. package/dist/metrics/task-completion-tracker.d.ts.map +1 -0
  271. package/dist/metrics/task-completion-tracker.js +27 -0
  272. package/dist/metrics/task-completion-tracker.js.map +1 -0
  273. package/dist/metrics/task-detector.d.ts +84 -0
  274. package/dist/metrics/task-detector.d.ts.map +1 -0
  275. package/dist/metrics/task-detector.js +302 -0
  276. package/dist/metrics/task-detector.js.map +1 -0
  277. package/dist/metrics/tool-selection-scorer.d.ts +39 -0
  278. package/dist/metrics/tool-selection-scorer.d.ts.map +1 -0
  279. package/dist/metrics/tool-selection-scorer.js +193 -0
  280. package/dist/metrics/tool-selection-scorer.js.map +1 -0
  281. package/dist/metrics/trend-analyzer.d.ts +92 -0
  282. package/dist/metrics/trend-analyzer.d.ts.map +1 -0
  283. package/dist/metrics/trend-analyzer.js +293 -0
  284. package/dist/metrics/trend-analyzer.js.map +1 -0
  285. package/dist/metrics/turn-cost-attributor.d.ts +41 -0
  286. package/dist/metrics/turn-cost-attributor.d.ts.map +1 -0
  287. package/dist/metrics/turn-cost-attributor.js +118 -0
  288. package/dist/metrics/turn-cost-attributor.js.map +1 -0
  289. package/dist/metrics/turn-tracker.d.ts +49 -0
  290. package/dist/metrics/turn-tracker.d.ts.map +1 -0
  291. package/dist/metrics/turn-tracker.js +192 -0
  292. package/dist/metrics/turn-tracker.js.map +1 -0
  293. package/dist/platforms/amazon-q-adapter.d.ts +10 -0
  294. package/dist/platforms/amazon-q-adapter.d.ts.map +1 -0
  295. package/dist/platforms/amazon-q-adapter.js +75 -0
  296. package/dist/platforms/amazon-q-adapter.js.map +1 -0
  297. package/dist/platforms/claude-code-adapter.d.ts +10 -0
  298. package/dist/platforms/claude-code-adapter.d.ts.map +1 -0
  299. package/dist/platforms/claude-code-adapter.js +48 -0
  300. package/dist/platforms/claude-code-adapter.js.map +1 -0
  301. package/dist/platforms/continue-adapter.d.ts +10 -0
  302. package/dist/platforms/continue-adapter.d.ts.map +1 -0
  303. package/dist/platforms/continue-adapter.js +73 -0
  304. package/dist/platforms/continue-adapter.js.map +1 -0
  305. package/dist/platforms/copilot-adapter.d.ts +37 -0
  306. package/dist/platforms/copilot-adapter.d.ts.map +1 -0
  307. package/dist/platforms/copilot-adapter.js +66 -0
  308. package/dist/platforms/copilot-adapter.js.map +1 -0
  309. package/dist/platforms/cursor-adapter.d.ts +10 -0
  310. package/dist/platforms/cursor-adapter.d.ts.map +1 -0
  311. package/dist/platforms/cursor-adapter.js +60 -0
  312. package/dist/platforms/cursor-adapter.js.map +1 -0
  313. package/dist/platforms/generic-mcp-adapter.d.ts +113 -0
  314. package/dist/platforms/generic-mcp-adapter.d.ts.map +1 -0
  315. package/dist/platforms/generic-mcp-adapter.js +139 -0
  316. package/dist/platforms/generic-mcp-adapter.js.map +1 -0
  317. package/dist/platforms/index.d.ts +15 -0
  318. package/dist/platforms/index.d.ts.map +1 -0
  319. package/dist/platforms/index.js +12 -0
  320. package/dist/platforms/index.js.map +1 -0
  321. package/dist/platforms/platform-registry.d.ts +11 -0
  322. package/dist/platforms/platform-registry.d.ts.map +1 -0
  323. package/dist/platforms/platform-registry.js +54 -0
  324. package/dist/platforms/platform-registry.js.map +1 -0
  325. package/dist/platforms/types.d.ts +36 -0
  326. package/dist/platforms/types.d.ts.map +1 -0
  327. package/dist/platforms/types.js +2 -0
  328. package/dist/platforms/types.js.map +1 -0
  329. package/dist/platforms/windsurf-adapter.d.ts +10 -0
  330. package/dist/platforms/windsurf-adapter.d.ts.map +1 -0
  331. package/dist/platforms/windsurf-adapter.js +63 -0
  332. package/dist/platforms/windsurf-adapter.js.map +1 -0
  333. package/dist/platforms/zed-adapter.d.ts +10 -0
  334. package/dist/platforms/zed-adapter.d.ts.map +1 -0
  335. package/dist/platforms/zed-adapter.js +72 -0
  336. package/dist/platforms/zed-adapter.js.map +1 -0
  337. package/dist/proxy/index.d.ts +7 -0
  338. package/dist/proxy/index.d.ts.map +1 -0
  339. package/dist/proxy/index.js +5 -0
  340. package/dist/proxy/index.js.map +1 -0
  341. package/dist/proxy/otlp-receiver.d.ts +28 -0
  342. package/dist/proxy/otlp-receiver.d.ts.map +1 -0
  343. package/dist/proxy/otlp-receiver.js +319 -0
  344. package/dist/proxy/otlp-receiver.js.map +1 -0
  345. package/dist/proxy/proxy-manager.d.ts +47 -0
  346. package/dist/proxy/proxy-manager.d.ts.map +1 -0
  347. package/dist/proxy/proxy-manager.js +338 -0
  348. package/dist/proxy/proxy-manager.js.map +1 -0
  349. package/dist/proxy/types.d.ts +72 -0
  350. package/dist/proxy/types.d.ts.map +1 -0
  351. package/dist/proxy/types.js +33 -0
  352. package/dist/proxy/types.js.map +1 -0
  353. package/dist/proxy/upstream-http.d.ts +26 -0
  354. package/dist/proxy/upstream-http.d.ts.map +1 -0
  355. package/dist/proxy/upstream-http.js +209 -0
  356. package/dist/proxy/upstream-http.js.map +1 -0
  357. package/dist/proxy/upstream-stdio.d.ts +25 -0
  358. package/dist/proxy/upstream-stdio.d.ts.map +1 -0
  359. package/dist/proxy/upstream-stdio.js +256 -0
  360. package/dist/proxy/upstream-stdio.js.map +1 -0
  361. package/dist/security/audit-trail.d.ts +74 -0
  362. package/dist/security/audit-trail.d.ts.map +1 -0
  363. package/dist/security/audit-trail.js +338 -0
  364. package/dist/security/audit-trail.js.map +1 -0
  365. package/dist/security/index.d.ts +5 -0
  366. package/dist/security/index.d.ts.map +1 -0
  367. package/dist/security/index.js +4 -0
  368. package/dist/security/index.js.map +1 -0
  369. package/dist/security/ssrf.d.ts +2 -0
  370. package/dist/security/ssrf.d.ts.map +1 -0
  371. package/dist/security/ssrf.js +126 -0
  372. package/dist/security/ssrf.js.map +1 -0
  373. package/dist/server.d.ts +14 -0
  374. package/dist/server.d.ts.map +1 -0
  375. package/dist/server.js +117 -0
  376. package/dist/server.js.map +1 -0
  377. package/dist/shared/__test-utils__/log-output.d.ts +49 -0
  378. package/dist/shared/__test-utils__/log-output.d.ts.map +1 -0
  379. package/dist/shared/__test-utils__/log-output.js +38 -0
  380. package/dist/shared/__test-utils__/log-output.js.map +1 -0
  381. package/dist/shared/config.d.ts +56 -0
  382. package/dist/shared/config.d.ts.map +1 -0
  383. package/dist/shared/config.js +290 -0
  384. package/dist/shared/config.js.map +1 -0
  385. package/dist/shared/errors.d.ts +139 -0
  386. package/dist/shared/errors.d.ts.map +1 -0
  387. package/dist/shared/errors.js +406 -0
  388. package/dist/shared/errors.js.map +1 -0
  389. package/dist/shared/events/factory.d.ts +143 -0
  390. package/dist/shared/events/factory.d.ts.map +1 -0
  391. package/dist/shared/events/factory.js +351 -0
  392. package/dist/shared/events/factory.js.map +1 -0
  393. package/dist/shared/events/index.d.ts +6 -0
  394. package/dist/shared/events/index.d.ts.map +1 -0
  395. package/dist/shared/events/index.js +3 -0
  396. package/dist/shared/events/index.js.map +1 -0
  397. package/dist/shared/events/serialize.d.ts +87 -0
  398. package/dist/shared/events/serialize.d.ts.map +1 -0
  399. package/dist/shared/events/serialize.js +510 -0
  400. package/dist/shared/events/serialize.js.map +1 -0
  401. package/dist/shared/events/types.d.ts +139 -0
  402. package/dist/shared/events/types.d.ts.map +1 -0
  403. package/dist/shared/events/types.js +2 -0
  404. package/dist/shared/events/types.js.map +1 -0
  405. package/dist/shared/harvest/event-buffer.d.ts +59 -0
  406. package/dist/shared/harvest/event-buffer.d.ts.map +1 -0
  407. package/dist/shared/harvest/event-buffer.js +100 -0
  408. package/dist/shared/harvest/event-buffer.js.map +1 -0
  409. package/dist/shared/harvest/harvest-scheduler.d.ts +200 -0
  410. package/dist/shared/harvest/harvest-scheduler.d.ts.map +1 -0
  411. package/dist/shared/harvest/harvest-scheduler.js +647 -0
  412. package/dist/shared/harvest/harvest-scheduler.js.map +1 -0
  413. package/dist/shared/harvest/index.d.ts +7 -0
  414. package/dist/shared/harvest/index.d.ts.map +1 -0
  415. package/dist/shared/harvest/index.js +4 -0
  416. package/dist/shared/harvest/index.js.map +1 -0
  417. package/dist/shared/harvest/metric-aggregator.d.ts +115 -0
  418. package/dist/shared/harvest/metric-aggregator.d.ts.map +1 -0
  419. package/dist/shared/harvest/metric-aggregator.js +247 -0
  420. package/dist/shared/harvest/metric-aggregator.js.map +1 -0
  421. package/dist/shared/index.d.ts +22 -0
  422. package/dist/shared/index.d.ts.map +1 -0
  423. package/dist/shared/index.js +13 -0
  424. package/dist/shared/index.js.map +1 -0
  425. package/dist/shared/logger.d.ts +57 -0
  426. package/dist/shared/logger.d.ts.map +1 -0
  427. package/dist/shared/logger.js +166 -0
  428. package/dist/shared/logger.js.map +1 -0
  429. package/dist/shared/pricing-data.d.ts +4 -0
  430. package/dist/shared/pricing-data.d.ts.map +1 -0
  431. package/dist/shared/pricing-data.js +473 -0
  432. package/dist/shared/pricing-data.js.map +1 -0
  433. package/dist/shared/pricing.d.ts +148 -0
  434. package/dist/shared/pricing.d.ts.map +1 -0
  435. package/dist/shared/pricing.js +528 -0
  436. package/dist/shared/pricing.js.map +1 -0
  437. package/dist/shared/redact.d.ts +33 -0
  438. package/dist/shared/redact.d.ts.map +1 -0
  439. package/dist/shared/redact.js +110 -0
  440. package/dist/shared/redact.js.map +1 -0
  441. package/dist/shared/timing.d.ts +96 -0
  442. package/dist/shared/timing.d.ts.map +1 -0
  443. package/dist/shared/timing.js +173 -0
  444. package/dist/shared/timing.js.map +1 -0
  445. package/dist/shared/tokens.d.ts +145 -0
  446. package/dist/shared/tokens.d.ts.map +1 -0
  447. package/dist/shared/tokens.js +492 -0
  448. package/dist/shared/tokens.js.map +1 -0
  449. package/dist/shared/transport/events-api.d.ts +14 -0
  450. package/dist/shared/transport/events-api.d.ts.map +1 -0
  451. package/dist/shared/transport/events-api.js +29 -0
  452. package/dist/shared/transport/events-api.js.map +1 -0
  453. package/dist/shared/transport/http-client.d.ts +49 -0
  454. package/dist/shared/transport/http-client.d.ts.map +1 -0
  455. package/dist/shared/transport/http-client.js +381 -0
  456. package/dist/shared/transport/http-client.js.map +1 -0
  457. package/dist/shared/transport/index.d.ts +10 -0
  458. package/dist/shared/transport/index.d.ts.map +1 -0
  459. package/dist/shared/transport/index.js +6 -0
  460. package/dist/shared/transport/index.js.map +1 -0
  461. package/dist/shared/transport/logs-api.d.ts +29 -0
  462. package/dist/shared/transport/logs-api.d.ts.map +1 -0
  463. package/dist/shared/transport/logs-api.js +40 -0
  464. package/dist/shared/transport/logs-api.js.map +1 -0
  465. package/dist/shared/transport/metric-api.d.ts +9 -0
  466. package/dist/shared/transport/metric-api.d.ts.map +1 -0
  467. package/dist/shared/transport/metric-api.js +39 -0
  468. package/dist/shared/transport/metric-api.js.map +1 -0
  469. package/dist/shared/transport/otlp-event-bridge.d.ts +22 -0
  470. package/dist/shared/transport/otlp-event-bridge.d.ts.map +1 -0
  471. package/dist/shared/transport/otlp-event-bridge.js +50 -0
  472. package/dist/shared/transport/otlp-event-bridge.js.map +1 -0
  473. package/dist/shared/transport/otlp-shared.d.ts +14 -0
  474. package/dist/shared/transport/otlp-shared.d.ts.map +1 -0
  475. package/dist/shared/transport/otlp-shared.js +49 -0
  476. package/dist/shared/transport/otlp-shared.js.map +1 -0
  477. package/dist/shared/transport/otlp-transport.d.ts +58 -0
  478. package/dist/shared/transport/otlp-transport.d.ts.map +1 -0
  479. package/dist/shared/transport/otlp-transport.js +236 -0
  480. package/dist/shared/transport/otlp-transport.js.map +1 -0
  481. package/dist/shared/transport/types.d.ts +129 -0
  482. package/dist/shared/transport/types.d.ts.map +1 -0
  483. package/dist/shared/transport/types.js +2 -0
  484. package/dist/shared/transport/types.js.map +1 -0
  485. package/dist/shared/version.d.ts +2 -0
  486. package/dist/shared/version.d.ts.map +1 -0
  487. package/dist/shared/version.js +2 -0
  488. package/dist/shared/version.js.map +1 -0
  489. package/dist/storage/index.d.ts +7 -0
  490. package/dist/storage/index.d.ts.map +1 -0
  491. package/dist/storage/index.js +4 -0
  492. package/dist/storage/index.js.map +1 -0
  493. package/dist/storage/local-store.d.ts +153 -0
  494. package/dist/storage/local-store.d.ts.map +1 -0
  495. package/dist/storage/local-store.js +719 -0
  496. package/dist/storage/local-store.js.map +1 -0
  497. package/dist/storage/retention.d.ts +2 -0
  498. package/dist/storage/retention.d.ts.map +1 -0
  499. package/dist/storage/retention.js +53 -0
  500. package/dist/storage/retention.js.map +1 -0
  501. package/dist/storage/session-store.d.ts +97 -0
  502. package/dist/storage/session-store.d.ts.map +1 -0
  503. package/dist/storage/session-store.js +391 -0
  504. package/dist/storage/session-store.js.map +1 -0
  505. package/dist/storage/types.d.ts +64 -0
  506. package/dist/storage/types.d.ts.map +1 -0
  507. package/dist/storage/types.js +2 -0
  508. package/dist/storage/types.js.map +1 -0
  509. package/dist/storage/weekly-summary.d.ts +61 -0
  510. package/dist/storage/weekly-summary.d.ts.map +1 -0
  511. package/dist/storage/weekly-summary.js +243 -0
  512. package/dist/storage/weekly-summary.js.map +1 -0
  513. package/dist/tools/analytics-tools.d.ts +101 -0
  514. package/dist/tools/analytics-tools.d.ts.map +1 -0
  515. package/dist/tools/analytics-tools.js +71 -0
  516. package/dist/tools/analytics-tools.js.map +1 -0
  517. package/dist/tools/cost-tools.d.ts +121 -0
  518. package/dist/tools/cost-tools.d.ts.map +1 -0
  519. package/dist/tools/cost-tools.js +174 -0
  520. package/dist/tools/cost-tools.js.map +1 -0
  521. package/dist/tools/cross-session-tools.d.ts +376 -0
  522. package/dist/tools/cross-session-tools.d.ts.map +1 -0
  523. package/dist/tools/cross-session-tools.js +820 -0
  524. package/dist/tools/cross-session-tools.js.map +1 -0
  525. package/dist/tools/extended-analytics-tools.d.ts +164 -0
  526. package/dist/tools/extended-analytics-tools.d.ts.map +1 -0
  527. package/dist/tools/extended-analytics-tools.js +121 -0
  528. package/dist/tools/extended-analytics-tools.js.map +1 -0
  529. package/dist/tools/index.d.ts +7 -0
  530. package/dist/tools/index.d.ts.map +1 -0
  531. package/dist/tools/index.js +4 -0
  532. package/dist/tools/index.js.map +1 -0
  533. package/dist/tools/session-stats.d.ts +162 -0
  534. package/dist/tools/session-stats.d.ts.map +1 -0
  535. package/dist/tools/session-stats.js +1054 -0
  536. package/dist/tools/session-stats.js.map +1 -0
  537. package/dist/tools/workflow-tools.d.ts +126 -0
  538. package/dist/tools/workflow-tools.d.ts.map +1 -0
  539. package/dist/tools/workflow-tools.js +274 -0
  540. package/dist/tools/workflow-tools.js.map +1 -0
  541. package/dist/tracing/mcp-tracer.d.ts +4 -0
  542. package/dist/tracing/mcp-tracer.d.ts.map +1 -0
  543. package/dist/tracing/mcp-tracer.js +14 -0
  544. package/dist/tracing/mcp-tracer.js.map +1 -0
  545. package/dist/tracing/session-span.d.ts +14 -0
  546. package/dist/tracing/session-span.d.ts.map +1 -0
  547. package/dist/tracing/session-span.js +53 -0
  548. package/dist/tracing/session-span.js.map +1 -0
  549. package/dist/tracing/task-span-tracker.d.ts +11 -0
  550. package/dist/tracing/task-span-tracker.d.ts.map +1 -0
  551. package/dist/tracing/task-span-tracker.js +59 -0
  552. package/dist/tracing/task-span-tracker.js.map +1 -0
  553. package/dist/tracing/tool-call-span.d.ts +4 -0
  554. package/dist/tracing/tool-call-span.d.ts.map +1 -0
  555. package/dist/tracing/tool-call-span.js +60 -0
  556. package/dist/tracing/tool-call-span.js.map +1 -0
  557. package/dist/transport/index.d.ts +3 -0
  558. package/dist/transport/index.d.ts.map +1 -0
  559. package/dist/transport/index.js +2 -0
  560. package/dist/transport/index.js.map +1 -0
  561. package/dist/transport/log-ingest.d.ts +42 -0
  562. package/dist/transport/log-ingest.d.ts.map +1 -0
  563. package/dist/transport/log-ingest.js +151 -0
  564. package/dist/transport/log-ingest.js.map +1 -0
  565. package/dist/transport/nr-ingest.d.ts +171 -0
  566. package/dist/transport/nr-ingest.d.ts.map +1 -0
  567. package/dist/transport/nr-ingest.js +659 -0
  568. package/dist/transport/nr-ingest.js.map +1 -0
  569. package/dist/types.d.ts +45 -0
  570. package/dist/types.d.ts.map +1 -0
  571. package/dist/types.js +2 -0
  572. package/dist/types.js.map +1 -0
  573. package/dist/web/assets/index-BrL281N-.css +2 -0
  574. package/dist/web/assets/index-CcaYZzXm.js +42 -0
  575. package/dist/web/favicon.svg +15 -0
  576. package/dist/web/index.html +15 -0
  577. package/examples/local-alert-rules.json +106 -0
  578. package/package.json +125 -1
@@ -0,0 +1,1414 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import { localDateKey, localStartOfDay, todayPortionOfSessionCost } from '../../lib/date.js';
3
+ import { redactSensitive, normalizeDeveloperName } from '../../config.js';
4
+ import { handleSendDigest } from '../../tools/cross-session-tools.js';
5
+ import { attributeSessionCosts, } from '../../metrics/cost-per-outcome.js';
6
+ import { getIsoWeekId } from '../../storage/weekly-summary.js';
7
+ import { analyzeReplayTimeline } from './replay-analyzer.js';
8
+ import { isSyntheticSessionId } from '../../hooks/session-resolver.js';
9
+ function aggregateQualityFromHistory(sessions) {
10
+ let diffApplied = 0;
11
+ let diffFailed = 0;
12
+ let testPass = 0;
13
+ let testFail = 0;
14
+ let backtrackCount = 0;
15
+ let selfCorrectionCount = 0;
16
+ for (const raw of sessions) {
17
+ const session = raw;
18
+ const testRuns = session.testRunCount ?? 0;
19
+ const testPasses = session.testPassCount ?? 0;
20
+ // Derive diff success/failure from timeline when available
21
+ if (session.timeline && session.timeline.length > 0) {
22
+ let lastEditFile = null;
23
+ let lastEditIdx = -1;
24
+ for (let i = 0; i < session.timeline.length; i++) {
25
+ const entry = session.timeline[i];
26
+ if (entry.toolName === 'Edit' || entry.toolName === 'Write') {
27
+ if (entry.success)
28
+ diffApplied++;
29
+ else
30
+ diffFailed++;
31
+ // Detect self-correction: re-edit same file within 3 turns after a test failure
32
+ if (lastEditFile && entry.filePath === lastEditFile && i - lastEditIdx <= 3) {
33
+ const recentFail = session.timeline
34
+ .slice(lastEditIdx + 1, i)
35
+ .some((e) => e.isTestCommand && !e.success);
36
+ if (recentFail)
37
+ selfCorrectionCount++;
38
+ }
39
+ lastEditFile = entry.filePath ?? null;
40
+ lastEditIdx = i;
41
+ }
42
+ // Detect backtrack: Read of a recently edited file
43
+ if (entry.toolName === 'Read' &&
44
+ lastEditFile &&
45
+ entry.filePath === lastEditFile &&
46
+ i - lastEditIdx <= 2) {
47
+ backtrackCount++;
48
+ }
49
+ if (entry.isTestCommand) {
50
+ if (entry.success)
51
+ testPass++;
52
+ else
53
+ testFail++;
54
+ }
55
+ }
56
+ }
57
+ else {
58
+ // No timeline — use summary counts for test pass/fail only.
59
+ // Edit/Write counts from toolBreakdown have no success/failure split,
60
+ // so including them would always produce 100% diffApplyRate; skip them.
61
+ testPass += testPasses;
62
+ testFail += Math.max(0, testRuns - testPasses);
63
+ }
64
+ }
65
+ const totalDiffs = diffApplied + diffFailed;
66
+ const totalTests = testPass + testFail;
67
+ const totalSignals = totalDiffs + totalTests + backtrackCount + selfCorrectionCount;
68
+ return {
69
+ totalSignals,
70
+ diffApplyRate: totalDiffs > 0 ? Math.round((diffApplied / totalDiffs) * 1000) / 1000 : null,
71
+ testPassRate: totalTests > 0 ? Math.round((testPass / totalTests) * 1000) / 1000 : null,
72
+ backtrackCount,
73
+ selfCorrectionCount,
74
+ qualityByTurnBucket: [],
75
+ degradationDetected: false,
76
+ events: [],
77
+ };
78
+ }
79
+ function toAuditEntry(entry) {
80
+ const r = (entry ?? {});
81
+ const target = typeof r.detail === 'string' ? redactSensitive(r.detail) : '';
82
+ // Prefer the explicit security classification when present; fall back to
83
+ // 'other' for routine tool calls so the "All" filter still shows them
84
+ // while specific filters (sensitive_file/destructive_command/external_network)
85
+ // surface only flagged entries.
86
+ const classification = r.securityAlert?.alertType ?? 'other';
87
+ return {
88
+ ts: r.timestamp,
89
+ sessionId: r.sessionId ?? null,
90
+ tool: r.tool,
91
+ target,
92
+ classification,
93
+ };
94
+ }
95
+ function jsonOk(res, body) {
96
+ const payload = JSON.stringify(body);
97
+ res.writeHead(200, {
98
+ 'content-type': 'application/json; charset=utf-8',
99
+ 'content-length': String(Buffer.byteLength(payload)),
100
+ });
101
+ res.end(payload);
102
+ }
103
+ function unavailable(res, what) {
104
+ const payload = JSON.stringify({ error: 'unavailable', what });
105
+ res.writeHead(503, {
106
+ 'content-type': 'application/json; charset=utf-8',
107
+ 'content-length': String(Buffer.byteLength(payload)),
108
+ });
109
+ res.end(payload);
110
+ }
111
+ const MAX_BODY_BYTES = 64 * 1024; // 64 KB — generous for any settings payload
112
+ function readBody(req) {
113
+ return new Promise((resolve, reject) => {
114
+ const chunks = [];
115
+ let total = 0;
116
+ req.on('data', (c) => {
117
+ total += c.byteLength;
118
+ if (total > MAX_BODY_BYTES) {
119
+ req.destroy();
120
+ reject(new Error('Request body too large'));
121
+ return;
122
+ }
123
+ chunks.push(c);
124
+ });
125
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
126
+ req.on('error', reject);
127
+ });
128
+ }
129
+ const ACTIVITY_WINDOW_MS = 180_000; // 3 minutes — matches LiveSessionRegistry staleness
130
+ function mergeActivityWindows(timeline) {
131
+ const sorted = [...timeline].sort((a, b) => a.timestamp - b.timestamp);
132
+ const windows = [];
133
+ for (const entry of sorted) {
134
+ const start = entry.timestamp;
135
+ const end = start + ACTIVITY_WINDOW_MS;
136
+ if (windows.length > 0 && start <= windows[windows.length - 1].end) {
137
+ windows[windows.length - 1].end = Math.max(windows[windows.length - 1].end, end);
138
+ }
139
+ else {
140
+ windows.push({ start, end });
141
+ }
142
+ }
143
+ return windows;
144
+ }
145
+ // 96 × 15-minute buckets covering today (00:00 → 24:00 local). Each bucket
146
+ // holds the peak concurrent session count observed within its window — see
147
+ // computeTodayConcurrencyBuckets() below.
148
+ const CONCURRENCY_BUCKET_SIZE_MS = 15 * 60 * 1000;
149
+ const CONCURRENCY_BUCKET_COUNT = 96;
150
+ // Build [startTime, endTime] intervals for every session that has any activity
151
+ // today. Persisted sessions contribute their stored startTime/endTime.
152
+ // Currently-live sessions (in the registry but not yet persisted) derive
153
+ // startTime from the in-memory tool-call buffer (oldest observed timestamp)
154
+ // and use `now` as endTime — matching the convention used by /api/sessions/live.
155
+ // Sessions that appear in both the persisted store and the live registry are
156
+ // deduped by sessionId; the live (extended-to-now) interval wins.
157
+ function collectTodaySessionIntervals(todaySessions, liveSessionIds, bufferRecords, now) {
158
+ const byId = new Map();
159
+ for (const raw of todaySessions) {
160
+ const s = raw;
161
+ if (typeof s.startTime !== 'number')
162
+ continue;
163
+ const end = typeof s.endTime === 'number' ? s.endTime : now;
164
+ if (end <= s.startTime)
165
+ continue;
166
+ if (typeof s.sessionId === 'string' && s.sessionId.length > 0) {
167
+ byId.set(s.sessionId, { startTime: s.startTime, endTime: end });
168
+ }
169
+ else {
170
+ // No id — use a synthetic key so it still contributes.
171
+ byId.set(`__anon_${byId.size}`, { startTime: s.startTime, endTime: end });
172
+ }
173
+ }
174
+ if (liveSessionIds.length > 0) {
175
+ const firstTsBySession = new Map();
176
+ for (const r of bufferRecords) {
177
+ const sid = r.sessionId;
178
+ if (!sid)
179
+ continue;
180
+ const ts = r.timestamp ?? 0;
181
+ if (!ts)
182
+ continue;
183
+ const prev = firstTsBySession.get(sid);
184
+ if (prev === undefined || ts < prev)
185
+ firstTsBySession.set(sid, ts);
186
+ }
187
+ for (const sid of liveSessionIds) {
188
+ const existing = byId.get(sid);
189
+ const bufferStart = firstTsBySession.get(sid);
190
+ // Prefer the EARLIER startTime: a persisted entry's startTime is
191
+ // authoritative (e.g. 09:00) and would be clobbered if we keyed solely
192
+ // off the buffer's earliest record (which may be much later if the
193
+ // buffer was drained between flushes). Extend endTime to `now` —
194
+ // that's the original intent of this loop (a live session is, by
195
+ // definition, ongoing).
196
+ const candidates = [];
197
+ if (existing !== undefined)
198
+ candidates.push(existing.startTime);
199
+ if (bufferStart !== undefined)
200
+ candidates.push(bufferStart);
201
+ if (candidates.length === 0)
202
+ continue;
203
+ const startTime = Math.min(...candidates);
204
+ if (now <= startTime)
205
+ continue;
206
+ byId.set(sid, { startTime, endTime: now });
207
+ }
208
+ }
209
+ return Array.from(byId.values());
210
+ }
211
+ function computeTodayConcurrencyBuckets(intervals, startTimestamp) {
212
+ // Single pass O(N log N) — build delta events from every interval,
213
+ // clamped to today's window, then sort once and sweep across the 96
214
+ // buckets in lockstep with the event cursor. Buckets containing no
215
+ // events still need a count: the value carried over from the previous
216
+ // bucket's tail (a session that spans many buckets without a delta in
217
+ // between must propagate as count=1+ in those buckets).
218
+ const dayEnd = startTimestamp + CONCURRENCY_BUCKET_COUNT * CONCURRENCY_BUCKET_SIZE_MS;
219
+ const events = [];
220
+ for (const interval of intervals) {
221
+ const start = Math.max(interval.startTime, startTimestamp);
222
+ const end = Math.min(interval.endTime, dayEnd);
223
+ if (start < end) {
224
+ events.push({ ts: start, delta: 1 }, { ts: end, delta: -1 });
225
+ }
226
+ }
227
+ // Sort by ts ascending; at ties, +1 deltas precede -1 (open-before-close)
228
+ // so that exact-touch session boundaries (one ends as another starts)
229
+ // count as overlap for one instant. Matches computeTodayPeakConcurrency's
230
+ // sort and the headline `peak` semantics — without this, a bucket peak
231
+ // can fall 1 below the day peak at boundary conditions.
232
+ events.sort((a, b) => a.ts - b.ts || b.delta - a.delta);
233
+ const buckets = new Array(CONCURRENCY_BUCKET_COUNT);
234
+ let current = 0;
235
+ let cursor = 0;
236
+ for (let b = 0; b < CONCURRENCY_BUCKET_COUNT; b++) {
237
+ const bucketStart = startTimestamp + b * CONCURRENCY_BUCKET_SIZE_MS;
238
+ const bucketEnd = bucketStart + CONCURRENCY_BUCKET_SIZE_MS;
239
+ // Flush any boundary events whose ts falls AT OR BEFORE bucketStart.
240
+ // Without this, a session ending at exactly t=bucketStart (e.g. a
241
+ // 15-min-aligned endTime) would still be carried as current=1 into
242
+ // this bucket before the -1 fires, inflating the bucket's initial peak
243
+ // by 1. Flushing first means peak is initialised from the correct
244
+ // post-close concurrency level.
245
+ while (cursor < events.length && events[cursor].ts <= bucketStart) {
246
+ current += events[cursor].delta;
247
+ cursor++;
248
+ }
249
+ // Peak starts at the carried-over (post-flush) level. Sessions spanning
250
+ // many buckets with no mid-bucket events propagate as count=N+ here.
251
+ let peak = current;
252
+ while (cursor < events.length && events[cursor].ts < bucketEnd) {
253
+ current += events[cursor].delta;
254
+ if (current > peak)
255
+ peak = current;
256
+ cursor++;
257
+ }
258
+ buckets[b] = { timestamp: bucketStart, count: peak };
259
+ }
260
+ return buckets;
261
+ }
262
+ function computeTodayPeakConcurrency(sessions) {
263
+ const events = [];
264
+ for (const s of sessions) {
265
+ const session = s;
266
+ if (!session.timeline || session.timeline.length === 0)
267
+ continue;
268
+ const windows = mergeActivityWindows(session.timeline);
269
+ for (const w of windows) {
270
+ events.push({ ts: w.start, delta: 1 }, { ts: w.end, delta: -1 });
271
+ }
272
+ }
273
+ if (events.length === 0)
274
+ return 0;
275
+ events.sort((a, b) => a.ts - b.ts || b.delta - a.delta);
276
+ let current = 0;
277
+ let peak = 0;
278
+ for (const e of events) {
279
+ current += e.delta;
280
+ if (current > peak)
281
+ peak = current;
282
+ }
283
+ return peak;
284
+ }
285
+ function computeDailyPeakConcurrency(sessions, days) {
286
+ const now = new Date();
287
+ const result = [];
288
+ for (let d = days - 1; d >= 0; d--) {
289
+ const dayStart = new Date(now);
290
+ dayStart.setUTCDate(dayStart.getUTCDate() - d);
291
+ dayStart.setUTCHours(0, 0, 0, 0);
292
+ const dayEnd = new Date(dayStart);
293
+ dayEnd.setUTCDate(dayEnd.getUTCDate() + 1);
294
+ const dayStartMs = dayStart.getTime();
295
+ const dayEndMs = dayEnd.getTime();
296
+ const dateKey = dayStart.toISOString().slice(0, 10);
297
+ // Find sessions that overlap with this day and have timeline data
298
+ const events = [];
299
+ for (const s of sessions) {
300
+ const session = s;
301
+ if (!session.timeline || session.timeline.length === 0)
302
+ continue;
303
+ // Only include tool calls within this day
304
+ const dayEntries = session.timeline.filter((e) => e.timestamp >= dayStartMs && e.timestamp < dayEndMs);
305
+ if (dayEntries.length === 0)
306
+ continue;
307
+ const windows = mergeActivityWindows(dayEntries);
308
+ for (const w of windows) {
309
+ events.push({ ts: w.start, delta: 1 }, { ts: w.end, delta: -1 });
310
+ }
311
+ }
312
+ if (events.length === 0) {
313
+ result.push({ date: dateKey, peak: 0 });
314
+ continue;
315
+ }
316
+ events.sort((a, b) => a.ts - b.ts || b.delta - a.delta);
317
+ let concurrent = 0;
318
+ let peak = 0;
319
+ for (const e of events) {
320
+ concurrent += e.delta;
321
+ if (concurrent > peak)
322
+ peak = concurrent;
323
+ }
324
+ result.push({ date: dateKey, peak });
325
+ }
326
+ return result;
327
+ }
328
+ function toolCallToTimelineEntry(tc) {
329
+ return {
330
+ timestamp: tc.timestamp,
331
+ toolName: tc.toolName,
332
+ durationMs: tc.durationMs,
333
+ success: tc.success,
334
+ filePath: tc.filePath ? redactSensitive(String(tc.filePath)) : undefined,
335
+ command: tc.command ? redactSensitive(String(tc.command)) : undefined,
336
+ isTestCommand: tc.isTestCommand || undefined,
337
+ isBuildCommand: tc.isBuildCommand || undefined,
338
+ isLintCommand: tc.isLintCommand || undefined,
339
+ errorType: tc.errorType || undefined,
340
+ };
341
+ }
342
+ function buildReplayResponse(sessionId, deps) {
343
+ // Try persisted session first
344
+ if (deps.sessionStore) {
345
+ const session = deps.sessionStore.loadSession(sessionId);
346
+ if (session && Array.isArray(session['timeline'])) {
347
+ const rawTimeline = session['timeline'];
348
+ // Redact sensitive fields before sending to the browser
349
+ const timeline = rawTimeline.map((e) => ({
350
+ ...e,
351
+ filePath: e.filePath ? redactSensitive(String(e.filePath)) : undefined,
352
+ command: e.command ? redactSensitive(String(e.command)) : undefined,
353
+ }));
354
+ const analysis = analyzeReplayTimeline(timeline);
355
+ return {
356
+ sessionId,
357
+ timeline,
358
+ segments: analysis.segments,
359
+ worstSegment: analysis.worstSegment,
360
+ };
361
+ }
362
+ }
363
+ // Try live session from TaskDetector — filter to the requested sessionId
364
+ if (deps.taskDetector) {
365
+ const completed = deps.taskDetector.getCompletedTasks();
366
+ const current = deps.taskDetector.getCurrentTask();
367
+ const allCalls = [];
368
+ for (const task of completed) {
369
+ allCalls.push(...task.toolCalls);
370
+ }
371
+ if (current) {
372
+ allCalls.push(...current.toolCalls);
373
+ }
374
+ const sessionCalls = allCalls.filter((c) => c.sessionId === sessionId);
375
+ if (sessionCalls.length > 0) {
376
+ sessionCalls.sort((a, b) => a.timestamp - b.timestamp);
377
+ const timeline = sessionCalls.map(toolCallToTimelineEntry);
378
+ const analysis = analyzeReplayTimeline(timeline);
379
+ return {
380
+ sessionId,
381
+ timeline,
382
+ segments: analysis.segments,
383
+ worstSegment: analysis.worstSegment,
384
+ };
385
+ }
386
+ }
387
+ // Final fallback: scan the in-memory tool call buffer for events matching
388
+ // sessionId. TaskDetector only emits records once they're attributed to a
389
+ // task; tool calls that fire before a task starts (or for sessions other
390
+ // than the dashboard owner's) live in the buffer but not in TaskDetector.
391
+ // Today's live tail reads via SSE so it sees these immediately; without
392
+ // this fallback the Sessions detail view shows "No tool calls" for a
393
+ // newly-live session that hasn't yet completed a task.
394
+ if (deps.toolCallBuffer) {
395
+ const records = deps.toolCallBuffer.getRecords();
396
+ const sessionCalls = records.filter((c) => c.sessionId === sessionId);
397
+ if (sessionCalls.length > 0) {
398
+ sessionCalls.sort((a, b) => a.timestamp - b.timestamp);
399
+ const timeline = sessionCalls.map(toolCallToTimelineEntry);
400
+ const analysis = analyzeReplayTimeline(timeline);
401
+ return {
402
+ sessionId,
403
+ timeline,
404
+ segments: analysis.segments,
405
+ worstSegment: analysis.worstSegment,
406
+ };
407
+ }
408
+ }
409
+ return null;
410
+ }
411
+ export function createApiHandler(deps) {
412
+ const routes = new Map();
413
+ routes.set('GET /api/session/current', (_req, res) => {
414
+ if (!deps.sessionTracker)
415
+ return unavailable(res, 'sessionTracker');
416
+ // surface the rolling efficiency score as a sibling field so the
417
+ // SPA Today KPI can render it without a second round-trip. `null` when
418
+ // no tasks have been scored yet (or when the scorer wasn't wired in).
419
+ const efficiencyScore = deps.efficiencyScorer?.getSessionAverage()?.score ?? null;
420
+ const liveSessions = deps.liveSessionRegistry?.getLiveSessions() ?? [];
421
+ jsonOk(res, { ...deps.sessionTracker.getMetrics(), efficiencyScore, liveSessions });
422
+ });
423
+ routes.set('GET /api/session/today', (_req, res) => {
424
+ if (!deps.sessionStore)
425
+ return unavailable(res, 'sessionStore');
426
+ jsonOk(res, deps.sessionStore.loadTodaySessions());
427
+ });
428
+ routes.set('GET /api/sessions', (req, res) => {
429
+ if (!deps.sessionStore)
430
+ return unavailable(res, 'sessionStore');
431
+ const url = new URL(req.url ?? '/', 'http://localhost');
432
+ const limitStr = url.searchParams.get('limit') ?? '';
433
+ let limit = 50;
434
+ const parsed = parseInt(limitStr, 10);
435
+ if (!Number.isNaN(parsed)) {
436
+ limit = Math.min(Math.max(parsed, 1), 500);
437
+ }
438
+ const allSessions = deps.sessionStore.loadAllSessions
439
+ ? deps.sessionStore.loadAllSessions()
440
+ : deps.sessionStore.listSessions();
441
+ // Filter synthetic IDs (`local-<ts>`, `proxy-<ts>`) from the historical
442
+ // list. They're MCP-internal bookkeeping IDs persisted incidentally by
443
+ // older builds; they appear as confusing "duplicate" rows next to real
444
+ // Claude Code sessions. The synthetic-shutdown filter in src/index.ts
445
+ // prevents new ones from being persisted, but stale ones from before
446
+ // that filter shipped need to be hidden at read time.
447
+ const withActivity = allSessions.filter((s) => {
448
+ const sid = s.sessionId;
449
+ const calls = s.toolCallCount ?? 0;
450
+ return calls > 0 && (!sid || !isSyntheticSessionId(sid));
451
+ });
452
+ const sliced = withActivity.slice(-limit);
453
+ // Append the current live session so it appears in the list before
454
+ // shutdown — but skip synthetic sessionTraceIds from --local / proxy
455
+ // modes (the same filter as for persisted entries above).
456
+ if (deps.sessionTracker) {
457
+ const live = deps.sessionTracker.getMetrics();
458
+ const alreadyPersisted = sliced.some((s) => s.sessionId === live.sessionId);
459
+ if (!alreadyPersisted && live.toolCallCount > 0 && !isSyntheticSessionId(live.sessionId)) {
460
+ sliced.push({
461
+ sessionId: live.sessionId,
462
+ sessionName: live.sessionName ?? null,
463
+ startTime: live.sessionStartTime,
464
+ durationMs: live.sessionDurationMs,
465
+ toolCallCount: live.toolCallCount,
466
+ estimatedCostUsd: deps.costTracker?.getMetrics().sessionTotalCostUsd ?? null,
467
+ });
468
+ }
469
+ }
470
+ // Inject stub entries for live sessions not yet persisted to disk.
471
+ // Derive toolCallCount and startTime from the in-memory tool call buffer
472
+ // so concurrent sessions show real activity counts on the badges.
473
+ if (deps.liveSessionRegistry) {
474
+ const knownIds = new Set(sliced.map((s) => s.sessionId));
475
+ const records = deps.toolCallBuffer?.getRecords() ?? [];
476
+ const perSession = new Map();
477
+ for (const r of records) {
478
+ const sid = r.sessionId;
479
+ if (!sid)
480
+ continue;
481
+ const ts = r.timestamp ?? 0;
482
+ const entry = perSession.get(sid);
483
+ if (entry) {
484
+ entry.count++;
485
+ if (ts && ts < entry.firstTs)
486
+ entry.firstTs = ts;
487
+ if (ts && ts > entry.lastTs)
488
+ entry.lastTs = ts;
489
+ }
490
+ else {
491
+ perSession.set(sid, { count: 1, firstTs: ts || Date.now(), lastTs: ts || Date.now() });
492
+ }
493
+ }
494
+ for (const id of deps.liveSessionRegistry.getLiveSessions()) {
495
+ if (!knownIds.has(id)) {
496
+ const stats = perSession.get(id);
497
+ // getLastActivity is registry-maintained and survives buffer drains,
498
+ // so use it as the stable upper bound for durationMs. Without this,
499
+ // durationMs collapses to 0 every 5s when the harvest scheduler drains
500
+ // the buffer and perSession rebuilds from an empty set of records.
501
+ const lastActivityTs = deps.liveSessionRegistry.getLastActivity?.(id) ?? stats?.lastTs;
502
+ const sessionStart = stats?.firstTs ?? lastActivityTs ?? Date.now();
503
+ sliced.push({
504
+ sessionId: id,
505
+ sessionName: deps.liveSessionRegistry.getSessionName(id),
506
+ startTime: sessionStart,
507
+ durationMs: lastActivityTs != null ? Math.max(0, lastActivityTs - sessionStart) : 0,
508
+ toolCallCount: stats?.count ?? 0,
509
+ estimatedCostUsd: null,
510
+ });
511
+ }
512
+ }
513
+ }
514
+ // Strip heavy per-session fields the list view doesn't render. Without
515
+ // this, /api/sessions returns ~90KB per session × N sessions; first
516
+ // paint blocks on parsing 200KB+ of JSON the list never reads.
517
+ // The detail endpoint /api/sessions/:id returns the full session.
518
+ const HEAVY_FIELDS = new Set(['timeline', 'filesRead', 'filesModified']);
519
+ const slimmed = sliced.map((s) => {
520
+ const o = s;
521
+ const out = {};
522
+ for (const k of Object.keys(o)) {
523
+ if (!HEAVY_FIELDS.has(k))
524
+ out[k] = o[k];
525
+ }
526
+ return out;
527
+ });
528
+ jsonOk(res, slimmed.length > limit ? slimmed.slice(-limit) : slimmed);
529
+ });
530
+ // Task #17 (D3): currently-live session list with metadata. Sourced from
531
+ // LiveSessionRegistry (touch-based liveness within a 3-minute window) so
532
+ // the Today selector can default to the most-recently-active session even
533
+ // when nothing has been persisted to disk yet. The dashboard owner sees
534
+ // every live session — Fix 3 partitioned per-session buffer files but the
535
+ // registry is in-memory and shared via this MCP, so the data is one
536
+ // authoritative read.
537
+ //
538
+ // Falls back to the in-memory tool call buffer for `startTime` (oldest
539
+ // observed timestamp for that session) since the registry only tracks
540
+ // last-activity. When a session's first event hasn't reached the buffer
541
+ // yet, startTime defaults to lastActivity (or now() if both are missing).
542
+ routes.set('GET /api/sessions/live', (_req, res) => {
543
+ if (!deps.liveSessionRegistry)
544
+ return unavailable(res, 'liveSessionRegistry');
545
+ // Filter out synthetic session identities used by --local / proxy modes
546
+ // (`local-<ts>`, `proxy-<ts>`). They're MCP-internal bookkeeping IDs,
547
+ // not real Claude Code sessions, so they shouldn't appear as clickable
548
+ // rows in the dashboard's live-sessions selector. The real user sessions
549
+ // (with proper session_ids resolved via the breadcrumb / CLAUDE_JOB_DIR
550
+ // path) are what users want to see and click.
551
+ const ids = deps.liveSessionRegistry
552
+ .getLiveSessions()
553
+ .filter((id) => !isSyntheticSessionId(id));
554
+ const records = deps.toolCallBuffer?.getRecords() ?? [];
555
+ const perSession = new Map();
556
+ for (const r of records) {
557
+ const sid = r.sessionId;
558
+ if (!sid)
559
+ continue;
560
+ const ts = r.timestamp ?? 0;
561
+ if (!ts)
562
+ continue;
563
+ const entry = perSession.get(sid);
564
+ if (entry) {
565
+ if (ts < entry.firstTs)
566
+ entry.firstTs = ts;
567
+ if (ts > entry.lastTs)
568
+ entry.lastTs = ts;
569
+ }
570
+ else {
571
+ perSession.set(sid, { firstTs: ts, lastTs: ts });
572
+ }
573
+ }
574
+ const sessions = ids.map((id) => {
575
+ const stats = perSession.get(id);
576
+ const lastActivity = deps.liveSessionRegistry?.getLastActivity?.(id) ?? stats?.lastTs ?? Date.now();
577
+ return {
578
+ sessionId: id,
579
+ sessionName: deps.liveSessionRegistry?.getSessionName(id) ?? null,
580
+ startTime: stats?.firstTs ?? lastActivity,
581
+ lastActivity,
582
+ };
583
+ });
584
+ // Most-recently-active first so the Today selector can default to
585
+ // sessions[0] without re-sorting on the client.
586
+ sessions.sort((a, b) => b.lastActivity - a.lastActivity);
587
+ jsonOk(res, sessions);
588
+ });
589
+ // Task #17 (D3): cross-session aggregate KPIs for the Today view. Reads:
590
+ // 1. every per-session buffer-*.jsonl in read-only mode (post-Fix-3
591
+ // each MCP only drains its own; the dashboard owner needs the union)
592
+ // 2. completed session JSONs at ~/.newrelic-preflight/sessions/ (loaded via
593
+ // sessionStore.loadTodaySessions())
594
+ // 3. the in-memory tool call buffer (events from this MCP that have
595
+ // already been processed but not yet persisted)
596
+ //
597
+ // The minute-bucketed sparkline starts at 00:00 local and runs through
598
+ // the current minute. Aggregate `avgDurationMs` is the simple mean over
599
+ // every observed durationMs across all three sources.
600
+ //
601
+ // Caching: dashboards poll this endpoint every 5–10s; both reads (peek
602
+ // every buffer-*.jsonl + load every today-session JSON) hit disk and
603
+ // walk the full result. Cache the response in a 5-second bucket so a
604
+ // single tab burst plus a couple of mirror tabs collapses to one
605
+ // computation per bucket. TTL is short enough that the live-feel KPI
606
+ // (sparkline tail, tool-call count) lags by at most ~5s.
607
+ const AGGREGATE_TTL_MS = 5_000;
608
+ let aggregateCache = null;
609
+ routes.set('GET /api/sessions/today/aggregate', (_req, res) => {
610
+ const now = Date.now();
611
+ const currentBucket = Math.floor(now / AGGREGATE_TTL_MS);
612
+ if (aggregateCache && aggregateCache.bucket === currentBucket) {
613
+ jsonOk(res, aggregateCache.payload);
614
+ return;
615
+ }
616
+ const startMs = localStartOfDay(now);
617
+ const minuteBuckets = Math.max(1, Math.ceil((now - startMs) / 60_000));
618
+ const sparkline = new Array(minuteBuckets).fill(0);
619
+ let toolCallCount = 0;
620
+ let totalDurationMs = 0;
621
+ let durationSamples = 0;
622
+ let totalCostUsd = 0;
623
+ let antiPatternCount = 0;
624
+ const sessionsSeen = new Set();
625
+ // (1) live, undrained per-session buffer events
626
+ const peeked = deps.localStore?.peekAllBuffers() ?? [];
627
+ // Per-live-session earliest buffer timestamp (today only). Used as a
628
+ // cutoff against the persisted timeline below: persisted timeline
629
+ // entries strictly OLDER than the buffer's earliest event for the same
630
+ // sessionId are pre-resume (drained before persistence and replayed via
631
+ // saved JSON); entries at-or-after that cutoff are live buffer events
632
+ // we already counted in step (1) and must not double-count.
633
+ //
634
+ // In practice the resume-after-shutdown flow doesn't keep the buffer
635
+ // alive across restarts (each MCP starts fresh), so this dedup is a
636
+ // belt-and-suspenders safety net rather than a load-bearing invariant.
637
+ const bufferStartBySession = new Map();
638
+ for (const ev of peeked) {
639
+ const ts = typeof ev.timestamp === 'number' ? ev.timestamp : 0;
640
+ if (ts < startMs)
641
+ continue;
642
+ const sid = ev.sessionId;
643
+ if (typeof sid === 'string' && sid.length > 0) {
644
+ sessionsSeen.add(sid);
645
+ const prev = bufferStartBySession.get(sid);
646
+ if (prev === undefined || ts < prev)
647
+ bufferStartBySession.set(sid, ts);
648
+ }
649
+ // Hook events come through as either `pre`/`post`/`token`. We only
650
+ // count `post` events as completed tool calls — `pre` is the start
651
+ // marker and `token` is a cost event with no tool-call semantics.
652
+ if (ev.mode === 'post') {
653
+ toolCallCount++;
654
+ const idx = Math.floor((ts - startMs) / 60_000);
655
+ if (idx >= 0 && idx < sparkline.length)
656
+ sparkline[idx]++;
657
+ const dur = typeof ev.durationMs === 'number' ? ev.durationMs : null;
658
+ if (dur !== null) {
659
+ totalDurationMs += dur;
660
+ durationSamples++;
661
+ }
662
+ }
663
+ }
664
+ // Hoist the live-session set out of the inner loop so we don't pay
665
+ // O(timeline × liveSessions) lookups per aggregate request.
666
+ const liveSet = new Set(deps.liveSessionRegistry?.getLiveSessions() ?? []);
667
+ // (2) completed sessions persisted today
668
+ const todaySessions = deps.sessionStore?.loadTodaySessions() ?? [];
669
+ for (const raw of todaySessions) {
670
+ const session = raw;
671
+ if (typeof session.sessionId === 'string')
672
+ sessionsSeen.add(session.sessionId);
673
+ // Cost is summed in a separate pass below so cross-midnight sessions
674
+ // (loaded via loadSessionsOverlappingToday) contribute their today
675
+ // portion only. See "(2b) cost from sessions overlapping today" below.
676
+ antiPatternCount += session.antiPatterns?.length ?? 0;
677
+ // Walk timeline entries within today. Persisted timelines and the
678
+ // buffer cover disjoint time ranges by construction (persistence
679
+ // runs at shutdown, after all events have been processed; buffer
680
+ // contents are undrained events). Even in the resume-after-shutdown
681
+ // case, the persisted JSON holds pre-shutdown events while the
682
+ // buffer holds post-resume events — disjoint timestamps. Use a
683
+ // per-session timestamp cutoff (earliest buffer event for that
684
+ // sessionId) as a defensive dedup so we never double-count if the
685
+ // ranges ever DO overlap.
686
+ if (session.timeline) {
687
+ const sid = session.sessionId ?? '';
688
+ const bufferCutoff = liveSet.has(sid) ? bufferStartBySession.get(sid) : undefined;
689
+ for (const entry of session.timeline) {
690
+ if (entry.timestamp < startMs)
691
+ continue;
692
+ if (bufferCutoff !== undefined && entry.timestamp >= bufferCutoff)
693
+ continue;
694
+ toolCallCount++;
695
+ const idx = Math.floor((entry.timestamp - startMs) / 60_000);
696
+ if (idx >= 0 && idx < sparkline.length)
697
+ sparkline[idx]++;
698
+ if (entry.durationMs !== null) {
699
+ totalDurationMs += entry.durationMs;
700
+ durationSamples++;
701
+ }
702
+ }
703
+ }
704
+ }
705
+ // (2b) cost from sessions overlapping today (separate pass so cross-
706
+ // midnight sessions started yesterday but ending after midnight contribute
707
+ // their today portion to spend, while NOT inflating tool-call or
708
+ // anti-pattern counts above which are already today-bounded by timeline).
709
+ //
710
+ // Why a separate loader: loadTodaySessions() filters by file-name date
711
+ // (= start date), so it drops sessions that started yesterday and ended
712
+ // today. The cost path needs them; the tool-call path doesn't (its
713
+ // timeline filter would skip pre-midnight entries anyway).
714
+ const liveSid = deps.sessionTracker?.getMetrics().sessionId;
715
+ // Prefer overlapping-today loader (catches yesterday→today sessions).
716
+ // When the store doesn't implement it (older tests/fakes), reuse the
717
+ // already-loaded `todaySessions` rather than re-invoking
718
+ // loadTodaySessions — keeps disk reads at one per request and preserves
719
+ // the cache-hit assertions in api-handler.test.ts.
720
+ const overlappingTodaySessions = deps.sessionStore?.loadSessionsOverlappingToday?.() ?? todaySessions;
721
+ for (const raw of overlappingTodaySessions) {
722
+ const s = raw;
723
+ // Skip the live session here — its today-portion is added below from
724
+ // costTracker.getCostForDay(), which is more accurate (per-token-event)
725
+ // than pro-rating from a periodically-persisted snapshot.
726
+ if (s.sessionId === liveSid)
727
+ continue;
728
+ totalCostUsd += todayPortionOfSessionCost(s, now);
729
+ // Count anti-patterns and session for cross-midnight sessions not already
730
+ // captured by the todaySessions loop (which filtered by start-date).
731
+ if (typeof s.sessionId === 'string' && !sessionsSeen.has(s.sessionId)) {
732
+ sessionsSeen.add(s.sessionId);
733
+ antiPatternCount += s.antiPatterns?.length ?? 0;
734
+ }
735
+ }
736
+ // (3) include this MCP's live session today-portion. Per-day attribution
737
+ // comes from CostTracker, which buckets each token event by local-day at
738
+ // record time (see CostTracker.accumulateTokens). Falls back to session
739
+ // total if no per-day data is available (older deployments / first event).
740
+ const liveAlreadyPersisted = todaySessions.some((s) => s.sessionId === liveSid);
741
+ if (!liveAlreadyPersisted) {
742
+ const todayKey = localDateKey(now);
743
+ const liveTodayUsd = deps.costTracker?.getCostForDay?.(todayKey) ?? null;
744
+ if (typeof liveTodayUsd === 'number') {
745
+ totalCostUsd += liveTodayUsd;
746
+ }
747
+ else {
748
+ // Fallback for older deployments without per-day API. This is the
749
+ // pre-fix behavior; only hit when getCostForDay is missing.
750
+ const sessionCost = deps.costTracker?.getMetrics().sessionTotalCostUsd ?? null;
751
+ if (typeof sessionCost === 'number')
752
+ totalCostUsd += sessionCost;
753
+ }
754
+ }
755
+ // Live session anti-patterns (in-memory, not yet persisted).
756
+ // Mirror the alreadyPersisted guard used for cost above: if this MCP's
757
+ // session is already in todaySessions, its anti-patterns were counted in
758
+ // the loop above — don't double-count.
759
+ if (deps.antiPatternDetector && !liveAlreadyPersisted) {
760
+ const live = deps.antiPatternDetector.getCurrentPatterns();
761
+ antiPatternCount += live.length;
762
+ }
763
+ const avgDurationMs = durationSamples > 0 ? totalDurationMs / durationSamples : 0;
764
+ const payload = {
765
+ toolCallCount,
766
+ totalCostUsd: Math.round(totalCostUsd * 1000) / 1000,
767
+ antiPatternCount,
768
+ avgDurationMs: Math.round(avgDurationMs),
769
+ sessionCount: sessionsSeen.size,
770
+ sparkline: { startTimestamp: startMs, bucketSizeMs: 60_000, points: sparkline },
771
+ };
772
+ aggregateCache = { bucket: currentBucket, payload };
773
+ jsonOk(res, payload);
774
+ });
775
+ routes.set('GET /api/cost', (_req, res) => {
776
+ if (!deps.costTracker)
777
+ return unavailable(res, 'costTracker');
778
+ const cost = deps.costTracker.getMetrics();
779
+ const forecast = deps.costForecast?.() ?? null;
780
+ // sessionTodayUsd lets the client compute the correct EoD forecast fallback:
781
+ // todayTotal + (forecastEndOfDayUsd − sessionTodayUsd) = todayTotal + projected
782
+ // Without it, the client would add persistedTodaySpend to forecastEndOfDayUsd and
783
+ // risk double-counting the live session when its snapshot is already in persisted data.
784
+ const sessionTodayUsd = deps.costTracker.getCostForDay?.(localDateKey(Date.now())) ?? null;
785
+ jsonOk(res, { cost, forecast, sessionTodayUsd });
786
+ });
787
+ routes.set('GET /api/anti-patterns', (_req, res) => {
788
+ if (!deps.antiPatternDetector)
789
+ return unavailable(res, 'antiPatternDetector');
790
+ jsonOk(res, deps.antiPatternDetector.getCurrentPatterns());
791
+ });
792
+ routes.set('GET /api/audit', (_req, res) => {
793
+ if (!deps.auditTrailManager)
794
+ return unavailable(res, 'auditTrailManager');
795
+ const log = deps.auditTrailManager.getAuditLog();
796
+ jsonOk(res, log.map(toAuditEntry));
797
+ });
798
+ routes.set('GET /api/weekly', (req, res) => {
799
+ if (!deps.weeklySummaryGenerator)
800
+ return unavailable(res, 'weeklySummaryGenerator');
801
+ const url = new URL(req.url ?? '/', 'http://localhost');
802
+ const countStr = url.searchParams.get('count') ?? '';
803
+ let count = 12;
804
+ const parsed = parseInt(countStr, 10);
805
+ if (!Number.isNaN(parsed)) {
806
+ count = Math.min(Math.max(parsed, 1), 52);
807
+ }
808
+ try {
809
+ deps.weeklySummaryGenerator.generate(getIsoWeekId(new Date()));
810
+ }
811
+ catch (err) {
812
+ // best-effort — failure here means stale weekly data is returned, not a 500
813
+ console.error('Weekly summary generation failed', err);
814
+ }
815
+ jsonOk(res, deps.weeklySummaryGenerator.loadRecentWeeks(count));
816
+ });
817
+ routes.set('GET /api/budget', (_req, res) => {
818
+ if (!deps.budgetTracker)
819
+ return unavailable(res, 'budgetTracker');
820
+ jsonOk(res, deps.budgetTracker.getStatus());
821
+ });
822
+ routes.set('GET /api/latency', (_req, res) => {
823
+ if (!deps.latencyTracker)
824
+ return unavailable(res, 'latencyTracker');
825
+ jsonOk(res, deps.latencyTracker.getMetrics());
826
+ });
827
+ routes.set('GET /api/model-usage', (_req, res) => {
828
+ if (!deps.modelUsageTracker)
829
+ return unavailable(res, 'modelUsageTracker');
830
+ jsonOk(res, deps.modelUsageTracker.getMetrics());
831
+ });
832
+ routes.set('GET /api/cost-per-outcome', (req, res) => {
833
+ if (!deps.sessionStore?.loadAllSessions)
834
+ return unavailable(res, 'sessionStore.loadAllSessions');
835
+ const url = new URL(req.url ?? '/', 'http://localhost');
836
+ const daysStr = url.searchParams.get('days') ?? '';
837
+ let days = 30;
838
+ const parsedDays = parseInt(daysStr, 10);
839
+ if (!Number.isNaN(parsedDays)) {
840
+ days = Math.min(Math.max(parsedDays, 1), 365);
841
+ }
842
+ const since = new Date(Date.now() - days * 86_400_000);
843
+ const sessions = deps.sessionStore.loadAllSessions({ since });
844
+ jsonOk(res, attributeSessionCosts(sessions));
845
+ });
846
+ routes.set('GET /api/personal-coach', (_req, res) => {
847
+ if (!deps.personalCoach)
848
+ return unavailable(res, 'personalCoach');
849
+ jsonOk(res, deps.personalCoach.generate());
850
+ });
851
+ routes.set('GET /api/alerts/recent', async (_req, res) => {
852
+ // 404 (not 503) when alerts are not configured — the route does not
853
+ // exist as a logical resource in cloud-only mode or when alerts are
854
+ // disabled. Plan §8 acceptance criterion calls for 404.
855
+ if (!deps.alertLog) {
856
+ res.writeHead(404, { 'content-type': 'application/json' });
857
+ res.end(JSON.stringify({ error: 'not_found' }));
858
+ return;
859
+ }
860
+ // Fixed limit (50) — matches the dashboard panel cap.
861
+ try {
862
+ const entries = await deps.alertLog.readRecent(50);
863
+ jsonOk(res, entries);
864
+ }
865
+ catch (err) {
866
+ // Log full error details server-side; never echo to the HTTP client.
867
+ // Stringifying the raw Error leaks file paths, env-var names, and
868
+ // potential connection-string fragments via stack frames.
869
+ console.error('alertLog.readRecent failed', err);
870
+ res.writeHead(500, { 'content-type': 'application/json' });
871
+ res.end(JSON.stringify({ error: 'internal' }));
872
+ }
873
+ });
874
+ routes.set('GET /api/quality-proxy', (_req, res) => {
875
+ if (!deps.qualityProxyTracker)
876
+ return unavailable(res, 'qualityProxyTracker');
877
+ const live = deps.qualityProxyTracker.getMetrics();
878
+ if (live.totalSignals > 0 || !deps.sessionStore) {
879
+ jsonOk(res, live);
880
+ return;
881
+ }
882
+ jsonOk(res, aggregateQualityFromHistory(deps.sessionStore.loadTodaySessions()));
883
+ });
884
+ routes.set('GET /api/tool-selection-score', (_req, res) => {
885
+ if (!deps.toolSelectionScorer)
886
+ return unavailable(res, 'toolSelectionScorer');
887
+ const calls = deps.toolCallBuffer?.getRecords() ?? [];
888
+ jsonOk(res, deps.toolSelectionScorer.scoreSession(calls));
889
+ });
890
+ routes.set('GET /api/git-efficiency', (_req, res) => {
891
+ if (!deps.gitEfficiencyTracker)
892
+ return unavailable(res, 'gitEfficiencyTracker');
893
+ jsonOk(res, deps.gitEfficiencyTracker.getMetrics());
894
+ });
895
+ routes.set('GET /api/context', (req, res) => {
896
+ if (!deps.contextTracker)
897
+ return unavailable(res, 'contextTracker');
898
+ const url = new URL(req.url ?? '/', 'http://localhost');
899
+ const sessionId = url.searchParams.get('sessionId') ?? undefined;
900
+ jsonOk(res, deps.contextTracker.getMetrics(sessionId));
901
+ });
902
+ routes.set('GET /api/concurrency', (req, res) => {
903
+ if (!deps.concurrencyTracker)
904
+ return unavailable(res, 'concurrencyTracker');
905
+ try {
906
+ const todaySessions = deps.sessionStore?.loadTodaySessions() ?? [];
907
+ const historicalPeak = computeTodayPeakConcurrency(todaySessions);
908
+ const livePeak = deps.concurrencyTracker.getPeakConcurrent();
909
+ const url = new URL(req.url ?? '/', 'http://localhost');
910
+ const view = url.searchParams.get('view');
911
+ if (view === 'history') {
912
+ const daysParam = url.searchParams.get('days');
913
+ const days = daysParam ? Math.min(Math.max(parseInt(daysParam, 10) || 30, 1), 90) : 30;
914
+ const since = new Date(localStartOfDay());
915
+ since.setDate(since.getDate() - days);
916
+ const allSessions = deps.sessionStore?.loadAllSessions?.({ since }) ?? [];
917
+ const dailyPeaks = computeDailyPeakConcurrency(allSessions, days);
918
+ // Override today's bucket with the live peak — disk-derived
919
+ // concurrency only sees persisted (completed) sessions, so a
920
+ // dashboard with active concurrent sessions but nothing flushed
921
+ // to disk yet would otherwise report peak=0 for today.
922
+ if (dailyPeaks.length > 0 && livePeak > 0) {
923
+ const today = dailyPeaks[dailyPeaks.length - 1];
924
+ if (today.peak < livePeak) {
925
+ dailyPeaks[dailyPeaks.length - 1] = { ...today, peak: livePeak };
926
+ }
927
+ }
928
+ jsonOk(res, { dailyPeaks });
929
+ return;
930
+ }
931
+ const allSessions = deps.sessionStore?.loadAllSessions?.() ?? [];
932
+ const allTimePeak = computeTodayPeakConcurrency(allSessions);
933
+ // 96 × 15-minute fixed-grid buckets covering today (local midnight →
934
+ // local midnight + 24h). Each bucket holds the peak concurrent
935
+ // session count within its window. Bounded payload (~10 KB) and
936
+ // renders cleanly at any zoom level. Replaces the prior unbounded
937
+ // 30-second rolling timeSeries.
938
+ const startTimestamp = localStartOfDay();
939
+ const liveIds = deps.liveSessionRegistry?.getLiveSessions() ?? [];
940
+ const bufferRecords = deps.toolCallBuffer?.getRecords() ?? [];
941
+ // Synthetic session ids (`local-*`, `proxy-*`) are hidden from
942
+ // /api/sessions and /api/sessions/live. Apply the same filter here
943
+ // so persisted synthetic records on disk don't contribute to bucket
944
+ // counts either — otherwise the buckets disagree with the session
945
+ // list shown alongside the chart.
946
+ const filteredTodaySessions = todaySessions.filter((s) => {
947
+ const sid = s.sessionId;
948
+ return !isSyntheticSessionId(sid);
949
+ });
950
+ const intervals = collectTodaySessionIntervals(filteredTodaySessions, liveIds.filter((id) => !isSyntheticSessionId(id)), bufferRecords, Date.now());
951
+ const buckets = computeTodayConcurrencyBuckets(intervals, startTimestamp);
952
+ jsonOk(res, {
953
+ current: deps.concurrencyTracker.getConcurrentCount(),
954
+ peak: Math.max(livePeak, historicalPeak),
955
+ allTimePeak: Math.max(livePeak, historicalPeak, allTimePeak),
956
+ bucketSizeMs: CONCURRENCY_BUCKET_SIZE_MS,
957
+ startTimestamp,
958
+ buckets,
959
+ });
960
+ }
961
+ catch {
962
+ res.writeHead(500, { 'content-type': 'application/json' });
963
+ res.end(JSON.stringify({ error: 'internal_error' }));
964
+ }
965
+ });
966
+ routes.set('GET /api/activity-heatmap', (req, res) => {
967
+ try {
968
+ const url = new URL(req.url ?? '/', 'http://localhost');
969
+ const view = url.searchParams.get('view') ?? 'today';
970
+ if (view === 'today') {
971
+ const now = Date.now();
972
+ const startMs = localStartOfDay(now);
973
+ const bucketSizeMs = 900_000;
974
+ const bucketCount = Math.ceil((now - startMs) / bucketSizeMs) || 1;
975
+ const buckets = new Array(bucketCount).fill(0);
976
+ const bufferRecords = deps.toolCallBuffer?.getRecords() ?? [];
977
+ for (const r of bufferRecords) {
978
+ if (r.timestamp >= startMs) {
979
+ const idx = Math.floor((r.timestamp - startMs) / bucketSizeMs);
980
+ if (idx >= 0 && idx < bucketCount) {
981
+ buckets[idx]++;
982
+ }
983
+ }
984
+ }
985
+ const todaySessions = deps.sessionStore?.loadTodaySessions() ?? [];
986
+ for (const s of todaySessions) {
987
+ const session = s;
988
+ if (session.timeline) {
989
+ for (const entry of session.timeline) {
990
+ if (entry.timestamp >= startMs) {
991
+ const idx = Math.floor((entry.timestamp - startMs) / bucketSizeMs);
992
+ if (idx >= 0 && idx < bucketCount) {
993
+ buckets[idx]++;
994
+ }
995
+ }
996
+ }
997
+ }
998
+ }
999
+ const maxCount = Math.max(...buckets, 1);
1000
+ jsonOk(res, { buckets, bucketSizeMs, startTimestamp: startMs, maxCount });
1001
+ return;
1002
+ }
1003
+ if (view === 'history') {
1004
+ const weeksParam = url.searchParams.get('weeks');
1005
+ const weeks = weeksParam ? Math.min(Math.max(parseInt(weeksParam, 10) || 12, 1), 52) : 12;
1006
+ const now = new Date();
1007
+ const startDate = new Date(now);
1008
+ startDate.setUTCDate(startDate.getUTCDate() - weeks * 7);
1009
+ startDate.setUTCHours(0, 0, 0, 0);
1010
+ const sessions = deps.sessionStore?.loadAllSessions?.({ since: startDate }) ?? [];
1011
+ const dayMap = new Map();
1012
+ const cursor = new Date(startDate);
1013
+ while (cursor <= now) {
1014
+ dayMap.set(cursor.toISOString().slice(0, 10), 0);
1015
+ cursor.setUTCDate(cursor.getUTCDate() + 1);
1016
+ }
1017
+ for (const s of sessions) {
1018
+ const session = s;
1019
+ if (!session.startTime)
1020
+ continue;
1021
+ const d = new Date(typeof session.startTime === 'number' ? session.startTime : session.startTime);
1022
+ if (d < startDate)
1023
+ continue;
1024
+ const key = d.toISOString().slice(0, 10);
1025
+ if (dayMap.has(key)) {
1026
+ dayMap.set(key, (dayMap.get(key) ?? 0) + (session.toolCallCount ?? 0));
1027
+ }
1028
+ }
1029
+ const days = Array.from(dayMap.entries()).map(([date, count]) => ({ date, count }));
1030
+ const maxCount = Math.max(...days.map((d) => d.count), 1);
1031
+ jsonOk(res, { days, maxCount });
1032
+ return;
1033
+ }
1034
+ res.writeHead(400, { 'content-type': 'application/json' });
1035
+ res.end(JSON.stringify({ error: 'invalid_view', message: 'Use view=today or view=history' }));
1036
+ }
1037
+ catch {
1038
+ res.writeHead(500, { 'content-type': 'application/json' });
1039
+ res.end(JSON.stringify({ error: 'internal_error' }));
1040
+ }
1041
+ });
1042
+ routes.set('GET /api/git-efficiency/repos', (_req, res) => {
1043
+ if (!deps.sessionStore)
1044
+ return unavailable(res, 'sessionStore');
1045
+ const todaySessions = deps.sessionStore.loadTodaySessions();
1046
+ const repoSet = new Set();
1047
+ for (const session of todaySessions) {
1048
+ if (typeof session.repoName === 'string' && session.repoName) {
1049
+ repoSet.add(session.repoName);
1050
+ }
1051
+ }
1052
+ // Include the current repo from git efficiency tracker if available
1053
+ let currentRepo = null;
1054
+ if (deps.gitEfficiencyTracker) {
1055
+ const metrics = deps.gitEfficiencyTracker.getMetrics();
1056
+ const trackerRepo = metrics.repoContext?.repoName ?? null;
1057
+ if (trackerRepo) {
1058
+ currentRepo = trackerRepo;
1059
+ repoSet.add(trackerRepo);
1060
+ }
1061
+ }
1062
+ jsonOk(res, { repos: [...repoSet].sort(), currentRepo });
1063
+ });
1064
+ // ── Settings endpoints ──────────────────────────────────────────────────
1065
+ routes.set('GET /api/settings', (_req, res) => {
1066
+ if (!deps.config)
1067
+ return unavailable(res, 'config');
1068
+ const c = deps.config;
1069
+ // Read editable fields from disk so the UI reflects the latest saved
1070
+ // values after a PATCH (deps.config is frozen at startup and never
1071
+ // updated in memory).
1072
+ let disk = {};
1073
+ if (deps.configFilePath) {
1074
+ try {
1075
+ disk = JSON.parse(readFileSync(deps.configFilePath, 'utf-8'));
1076
+ }
1077
+ catch {
1078
+ /* config file may not exist yet — fall through to startup defaults */
1079
+ }
1080
+ }
1081
+ const diskAlerts = (disk.alerts ?? {});
1082
+ const diskPersonal = (diskAlerts['personal'] ?? {});
1083
+ jsonOk(res, {
1084
+ // Editable fields: prefer disk, fall back to startup config
1085
+ developer: typeof disk.developer === 'string' ? disk.developer : c.developer,
1086
+ teamId: 'teamId' in disk ? disk.teamId : c.teamId,
1087
+ sessionBudgetUsd: 'sessionBudgetUsd' in disk ? disk.sessionBudgetUsd : c.sessionBudgetUsd,
1088
+ dailyBudgetUsd: 'dailyBudgetUsd' in disk ? disk.dailyBudgetUsd : c.dailyBudgetUsd,
1089
+ weeklyBudgetUsd: 'weeklyBudgetUsd' in disk ? disk.weeklyBudgetUsd : c.weeklyBudgetUsd,
1090
+ retainSessionsDays: 'retainSessionsDays' in disk
1091
+ ? disk.retainSessionsDays
1092
+ : c.retainSessionsDays,
1093
+ digestWebhookUrl: 'digestWebhookUrl' in disk ? disk.digestWebhookUrl : c.digestWebhookUrl,
1094
+ digestSchedule: typeof disk.digestSchedule === 'string' ? disk.digestSchedule : c.digestSchedule,
1095
+ alerts: {
1096
+ personal: {
1097
+ dailyCostUsd: typeof diskPersonal['dailyCostUsd'] === 'number'
1098
+ ? diskPersonal['dailyCostUsd']
1099
+ : c.personalAlertThresholds.dailyCostUsd,
1100
+ sessionCostUsd: typeof diskPersonal['sessionCostUsd'] === 'number'
1101
+ ? diskPersonal['sessionCostUsd']
1102
+ : c.personalAlertThresholds.sessionCostUsd,
1103
+ efficiencyScoreMin: typeof diskPersonal['efficiencyScoreMin'] === 'number'
1104
+ ? diskPersonal['efficiencyScoreMin']
1105
+ : c.personalAlertThresholds.efficiencyScoreMin,
1106
+ stuckLoopCountMax: typeof diskPersonal['stuckLoopCountMax'] === 'number'
1107
+ ? diskPersonal['stuckLoopCountMax']
1108
+ : c.personalAlertThresholds.stuckLoopCountMax,
1109
+ antiPatternCountMax: typeof diskPersonal['antiPatternCountMax'] === 'number'
1110
+ ? diskPersonal['antiPatternCountMax']
1111
+ : c.personalAlertThresholds.antiPatternCountMax,
1112
+ },
1113
+ },
1114
+ // Read-only fields always from startup config
1115
+ accountId: c.accountId ?? null,
1116
+ appName: c.appName,
1117
+ mode: c.mode,
1118
+ storagePath: c.storagePath,
1119
+ highSecurity: c.highSecurity,
1120
+ licenseKey: c.licenseKey ? '••••' + c.licenseKey.slice(-4) : null,
1121
+ });
1122
+ });
1123
+ routes.set('PATCH /api/settings', async (req, res) => {
1124
+ if (!deps.configFilePath)
1125
+ return unavailable(res, 'configFilePath');
1126
+ let body;
1127
+ try {
1128
+ body = JSON.parse(await readBody(req));
1129
+ }
1130
+ catch {
1131
+ res.writeHead(400, { 'content-type': 'application/json' });
1132
+ res.end(JSON.stringify({ error: 'invalid_json' }));
1133
+ return;
1134
+ }
1135
+ let existing = {};
1136
+ try {
1137
+ existing = JSON.parse(readFileSync(deps.configFilePath, 'utf-8'));
1138
+ }
1139
+ catch {
1140
+ /* no existing config — start fresh */
1141
+ }
1142
+ const errors = [];
1143
+ let digestUrlOnly = true; // tracks whether only digest URL changed
1144
+ if ('developer' in body) {
1145
+ if (typeof body.developer !== 'string') {
1146
+ errors.push('developer must be a string');
1147
+ }
1148
+ else {
1149
+ existing.developer = normalizeDeveloperName(body.developer);
1150
+ digestUrlOnly = false;
1151
+ }
1152
+ }
1153
+ if ('teamId' in body) {
1154
+ if (body.teamId !== null && typeof body.teamId !== 'string') {
1155
+ errors.push('teamId must be string or null');
1156
+ }
1157
+ else {
1158
+ existing.teamId = body.teamId;
1159
+ digestUrlOnly = false;
1160
+ }
1161
+ }
1162
+ if ('sessionBudgetUsd' in body) {
1163
+ if (body.sessionBudgetUsd !== null &&
1164
+ (typeof body.sessionBudgetUsd !== 'number' || body.sessionBudgetUsd <= 0)) {
1165
+ errors.push('sessionBudgetUsd must be a positive number or null');
1166
+ }
1167
+ else {
1168
+ existing.sessionBudgetUsd = body.sessionBudgetUsd;
1169
+ digestUrlOnly = false;
1170
+ }
1171
+ }
1172
+ if ('dailyBudgetUsd' in body) {
1173
+ if (body.dailyBudgetUsd !== null &&
1174
+ (typeof body.dailyBudgetUsd !== 'number' || body.dailyBudgetUsd <= 0)) {
1175
+ errors.push('dailyBudgetUsd must be a positive number or null');
1176
+ }
1177
+ else {
1178
+ existing.dailyBudgetUsd = body.dailyBudgetUsd;
1179
+ digestUrlOnly = false;
1180
+ }
1181
+ }
1182
+ if ('weeklyBudgetUsd' in body) {
1183
+ if (body.weeklyBudgetUsd !== null &&
1184
+ (typeof body.weeklyBudgetUsd !== 'number' || body.weeklyBudgetUsd <= 0)) {
1185
+ errors.push('weeklyBudgetUsd must be a positive number or null');
1186
+ }
1187
+ else {
1188
+ existing.weeklyBudgetUsd = body.weeklyBudgetUsd;
1189
+ digestUrlOnly = false;
1190
+ }
1191
+ }
1192
+ if ('retainSessionsDays' in body) {
1193
+ if (body.retainSessionsDays !== null &&
1194
+ (!Number.isInteger(body.retainSessionsDays) ||
1195
+ body.retainSessionsDays < 1 ||
1196
+ body.retainSessionsDays > 365)) {
1197
+ errors.push('retainSessionsDays must be integer 1-365 or null');
1198
+ }
1199
+ else {
1200
+ existing.retainSessionsDays = body.retainSessionsDays;
1201
+ digestUrlOnly = false;
1202
+ }
1203
+ }
1204
+ if ('digestWebhookUrl' in body) {
1205
+ if (body.digestWebhookUrl !== null &&
1206
+ (typeof body.digestWebhookUrl !== 'string' ||
1207
+ !body.digestWebhookUrl.startsWith('https://hooks.slack.com/'))) {
1208
+ errors.push('digestWebhookUrl must be a Slack incoming webhook URL (https://hooks.slack.com/...) or null');
1209
+ }
1210
+ else {
1211
+ existing.digestWebhookUrl = body.digestWebhookUrl ?? undefined;
1212
+ if (existing.digestWebhookUrl === undefined) {
1213
+ delete existing.digestWebhookUrl;
1214
+ }
1215
+ }
1216
+ }
1217
+ if ('digestSchedule' in body) {
1218
+ if (typeof body.digestSchedule !== 'string') {
1219
+ errors.push('digestSchedule must be a string');
1220
+ }
1221
+ else {
1222
+ existing.digestSchedule = body.digestSchedule;
1223
+ digestUrlOnly = false;
1224
+ }
1225
+ }
1226
+ if ('alerts' in body) {
1227
+ const alertsBody = body.alerts;
1228
+ const personal = alertsBody?.['personal'];
1229
+ if (personal) {
1230
+ const existingAlerts = (existing.alerts ?? {});
1231
+ const existingPersonal = (existingAlerts['personal'] ?? {});
1232
+ if ('dailyCostUsd' in personal) {
1233
+ if (typeof personal.dailyCostUsd !== 'number' || personal.dailyCostUsd < 0) {
1234
+ errors.push('alerts.personal.dailyCostUsd must be a non-negative number');
1235
+ }
1236
+ else {
1237
+ existingPersonal.dailyCostUsd = personal.dailyCostUsd;
1238
+ }
1239
+ }
1240
+ if ('sessionCostUsd' in personal) {
1241
+ if (typeof personal.sessionCostUsd !== 'number' || personal.sessionCostUsd < 0) {
1242
+ errors.push('alerts.personal.sessionCostUsd must be a non-negative number');
1243
+ }
1244
+ else {
1245
+ existingPersonal.sessionCostUsd = personal.sessionCostUsd;
1246
+ }
1247
+ }
1248
+ if ('efficiencyScoreMin' in personal) {
1249
+ if (typeof personal.efficiencyScoreMin !== 'number' ||
1250
+ personal.efficiencyScoreMin < 0 ||
1251
+ personal.efficiencyScoreMin > 1) {
1252
+ errors.push('alerts.personal.efficiencyScoreMin must be 0-1');
1253
+ }
1254
+ else {
1255
+ existingPersonal.efficiencyScoreMin = personal.efficiencyScoreMin;
1256
+ }
1257
+ }
1258
+ if ('stuckLoopCountMax' in personal) {
1259
+ if (!Number.isInteger(personal.stuckLoopCountMax) ||
1260
+ personal.stuckLoopCountMax < 0) {
1261
+ errors.push('alerts.personal.stuckLoopCountMax must be a non-negative integer');
1262
+ }
1263
+ else {
1264
+ existingPersonal.stuckLoopCountMax = personal.stuckLoopCountMax;
1265
+ }
1266
+ }
1267
+ if ('antiPatternCountMax' in personal) {
1268
+ if (!Number.isInteger(personal.antiPatternCountMax) ||
1269
+ personal.antiPatternCountMax < 0) {
1270
+ errors.push('alerts.personal.antiPatternCountMax must be a non-negative integer');
1271
+ }
1272
+ else {
1273
+ existingPersonal.antiPatternCountMax = personal.antiPatternCountMax;
1274
+ }
1275
+ }
1276
+ existingAlerts['personal'] = existingPersonal;
1277
+ existing.alerts = existingAlerts;
1278
+ digestUrlOnly = false;
1279
+ }
1280
+ }
1281
+ if (errors.length > 0) {
1282
+ res.writeHead(400, { 'content-type': 'application/json' });
1283
+ res.end(JSON.stringify({ error: 'validation_failed', errors }));
1284
+ return;
1285
+ }
1286
+ writeFileSync(deps.configFilePath, JSON.stringify(existing, null, 2), { mode: 0o600 });
1287
+ jsonOk(res, { ok: true, restartRequired: !digestUrlOnly });
1288
+ });
1289
+ routes.set('POST /api/digest/send', async (_req, res) => {
1290
+ if (!deps.weeklySummaryGenerator || !deps.configFilePath)
1291
+ return unavailable(res, 'digest');
1292
+ const result = await handleSendDigest(deps.weeklySummaryGenerator, deps.configFilePath);
1293
+ jsonOk(res, result);
1294
+ });
1295
+ return async (req, res) => {
1296
+ try {
1297
+ const path = (req.url ?? '/').split('?')[0] ?? '/';
1298
+ const key = `${req.method ?? 'GET'} ${path}`;
1299
+ const fn = routes.get(key);
1300
+ if (fn) {
1301
+ await fn(req, res);
1302
+ return;
1303
+ }
1304
+ // Try dynamic routes
1305
+ const replayMatch = /^\/api\/sessions\/([A-Za-z0-9_-]{1,128})\/replay$/.exec(path);
1306
+ if (req.method === 'GET' && replayMatch) {
1307
+ const sessionId = replayMatch[1];
1308
+ const replay = buildReplayResponse(sessionId, deps);
1309
+ if (replay === null) {
1310
+ res.writeHead(404, { 'content-type': 'application/json' });
1311
+ res.end(JSON.stringify({ error: 'no_replay_data' }));
1312
+ return;
1313
+ }
1314
+ jsonOk(res, replay);
1315
+ return;
1316
+ }
1317
+ const sessionIdMatch = /^\/api\/sessions\/([A-Za-z0-9_-]{1,128})$/.exec(path);
1318
+ if (req.method === 'GET' && sessionIdMatch) {
1319
+ const sessionId = sessionIdMatch[1];
1320
+ if (!deps.sessionStore)
1321
+ return unavailable(res, 'sessionStore');
1322
+ const session = deps.sessionStore.loadSession(sessionId);
1323
+ if (session != null) {
1324
+ jsonOk(res, session);
1325
+ return;
1326
+ }
1327
+ // Not persisted — check if it's the current live session
1328
+ if (deps.sessionTracker) {
1329
+ const live = deps.sessionTracker.getMetrics();
1330
+ if (live.sessionId === sessionId) {
1331
+ const costMetrics = deps.costTracker?.getMetrics();
1332
+ const costUsd = costMetrics?.sessionTotalCostUsd ?? null;
1333
+ const model = costMetrics?.model ?? null;
1334
+ const antiPatterns = deps.antiPatternDetector
1335
+ ? deps.antiPatternDetector.getCurrentPatterns()
1336
+ : [];
1337
+ jsonOk(res, {
1338
+ sessionId: live.sessionId,
1339
+ sessionName: live.sessionName ?? null,
1340
+ startTime: live.sessionStartTime,
1341
+ durationMs: live.sessionDurationMs,
1342
+ toolCallCount: live.toolCallCount,
1343
+ estimatedCostUsd: costUsd,
1344
+ model,
1345
+ outcome: 'in progress',
1346
+ toolBreakdown: live.toolCallCountByTool,
1347
+ antiPatterns,
1348
+ // Use the same `timeline` shape as persisted sessions so the
1349
+ // Sessions and Replay views can consume one type. See
1350
+ // src/storage/types.ts ReplayTimelineEntry.
1351
+ timeline: live.toolCallTimeline.map((t) => ({
1352
+ timestamp: t.timestamp,
1353
+ toolName: t.toolName,
1354
+ durationMs: t.durationMs,
1355
+ success: t.success ?? true,
1356
+ })),
1357
+ });
1358
+ return;
1359
+ }
1360
+ }
1361
+ // Concurrent live session tracked by the registry but not this server's
1362
+ // own session — synthesize from tool call buffer records.
1363
+ if (deps.liveSessionRegistry?.getLiveSessions().includes(sessionId)) {
1364
+ const allRecords = deps.toolCallBuffer?.getRecords() ?? [];
1365
+ const records = allRecords.filter((r) => r.sessionId === sessionId);
1366
+ const timeline = records
1367
+ .map((r) => ({
1368
+ timestamp: r.timestamp,
1369
+ toolName: r.toolName,
1370
+ durationMs: r.durationMs ?? null,
1371
+ success: r.success,
1372
+ filePath: r.filePath ? redactSensitive(String(r.filePath)) : undefined,
1373
+ command: r.command ? redactSensitive(String(r.command)) : undefined,
1374
+ }))
1375
+ .sort((a, b) => a.timestamp - b.timestamp);
1376
+ const breakdown = Object.create(null);
1377
+ for (const r of records) {
1378
+ breakdown[r.toolName] = (breakdown[r.toolName] ?? 0) + 1;
1379
+ }
1380
+ const startTime = timeline.length > 0 ? timeline[0].timestamp : Date.now();
1381
+ const lastTs = timeline.length > 0 ? timeline[timeline.length - 1].timestamp : startTime;
1382
+ jsonOk(res, {
1383
+ sessionId,
1384
+ sessionName: deps.liveSessionRegistry.getSessionName(sessionId),
1385
+ startTime,
1386
+ durationMs: lastTs - startTime,
1387
+ toolCallCount: records.length,
1388
+ estimatedCostUsd: null,
1389
+ model: null,
1390
+ outcome: 'in progress',
1391
+ toolBreakdown: breakdown,
1392
+ antiPatterns: [],
1393
+ timeline,
1394
+ });
1395
+ return;
1396
+ }
1397
+ res.writeHead(404, { 'content-type': 'application/json' });
1398
+ res.end(JSON.stringify({ error: 'not_found' }));
1399
+ return;
1400
+ }
1401
+ res.writeHead(404, { 'content-type': 'application/json' });
1402
+ res.end(JSON.stringify({ error: 'not_found' }));
1403
+ }
1404
+ catch (err) {
1405
+ const logger = (await import('../../shared/index.js')).createLogger('api-handler');
1406
+ logger.error('Unhandled error in API route handler', { error: String(err) });
1407
+ if (!res.headersSent) {
1408
+ res.writeHead(500, { 'content-type': 'application/json' });
1409
+ res.end(JSON.stringify({ error: 'internal_error' }));
1410
+ }
1411
+ }
1412
+ };
1413
+ }
1414
+ //# sourceMappingURL=api-handler.js.map