pi-crew 0.9.53 → 0.9.55

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 (493) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/package.json +6 -3
  3. package/scripts/clean-strip-types.mjs +60 -0
  4. package/src/adapters/claude-adapter.ts +23 -0
  5. package/src/adapters/codex-adapter.ts +21 -0
  6. package/src/adapters/cursor-adapter.ts +17 -0
  7. package/src/adapters/export-util.ts +143 -0
  8. package/src/adapters/index.ts +15 -0
  9. package/src/adapters/registry.ts +18 -0
  10. package/src/adapters/types.ts +23 -0
  11. package/src/agents/agent-config.ts +194 -0
  12. package/src/agents/agent-search.ts +98 -0
  13. package/src/agents/agent-serializer.ts +38 -0
  14. package/src/agents/discover-agents.ts +623 -0
  15. package/src/benchmark/benchmark-runner.ts +313 -0
  16. package/src/benchmark/feedback-loop.ts +73 -0
  17. package/src/config/config.ts +1270 -0
  18. package/src/config/defaults.ts +193 -0
  19. package/src/config/drift-detector.ts +290 -0
  20. package/src/config/markers.ts +330 -0
  21. package/src/config/resilient-parser.ts +117 -0
  22. package/src/config/role-tools.ts +118 -0
  23. package/src/config/suggestions.ts +75 -0
  24. package/src/config/types.ts +280 -0
  25. package/src/errors.ts +194 -0
  26. package/src/extension/action-suggestions.ts +117 -0
  27. package/src/extension/async-notifier.ts +179 -0
  28. package/src/extension/autonomous-policy.ts +209 -0
  29. package/src/extension/command-completions.ts +120 -0
  30. package/src/extension/context-status-injection.ts +185 -0
  31. package/src/extension/crew-autocomplete.ts +131 -0
  32. package/src/extension/crew-cleanup.ts +168 -0
  33. package/src/extension/crew-input-router.ts +93 -0
  34. package/src/extension/crew-shortcuts.ts +87 -0
  35. package/src/extension/crew-vibes/cat-frames.ts +18 -0
  36. package/src/extension/crew-vibes/config.ts +195 -0
  37. package/src/extension/crew-vibes/figures.ts +101 -0
  38. package/src/extension/crew-vibes/font-detect.ts +71 -0
  39. package/src/extension/crew-vibes/footer.ts +292 -0
  40. package/src/extension/crew-vibes/index.ts +432 -0
  41. package/src/extension/crew-vibes/provider-usage.ts +396 -0
  42. package/src/extension/crew-vibes/render.ts +206 -0
  43. package/src/extension/crew-vibes/speed.ts +286 -0
  44. package/src/extension/cross-extension-rpc.ts +350 -0
  45. package/src/extension/help.ts +62 -0
  46. package/src/extension/import-index.ts +74 -0
  47. package/src/extension/knowledge-injection.ts +402 -0
  48. package/src/extension/management.ts +571 -0
  49. package/src/extension/message-renderers.ts +108 -0
  50. package/src/extension/notification-router.ts +152 -0
  51. package/src/extension/notification-sink.ts +54 -0
  52. package/src/extension/pi-api.ts +56 -0
  53. package/src/extension/plan-orchestrate.ts +302 -0
  54. package/src/extension/project-init.ts +170 -0
  55. package/src/extension/register.ts +113 -0
  56. package/src/extension/registration/artifact-cleanup.ts +20 -0
  57. package/src/extension/registration/command-registration.ts +58 -0
  58. package/src/extension/registration/command-utils.ts +58 -0
  59. package/src/extension/registration/commands.ts +1228 -0
  60. package/src/extension/registration/compaction-guard.ts +333 -0
  61. package/src/extension/registration/context-builder.ts +135 -0
  62. package/src/extension/registration/crash-recovery-cache.ts +54 -0
  63. package/src/extension/registration/foreground-run-controller.ts +238 -0
  64. package/src/extension/registration/hook-registration.ts +119 -0
  65. package/src/extension/registration/lazy-configurers.ts +124 -0
  66. package/src/extension/registration/lifecycle-handlers.ts +923 -0
  67. package/src/extension/registration/lifecycle.ts +259 -0
  68. package/src/extension/registration/observability.ts +322 -0
  69. package/src/extension/registration/registration-types.ts +158 -0
  70. package/src/extension/registration/runtime-cleanup.ts +230 -0
  71. package/src/extension/registration/subagent-helpers.ts +125 -0
  72. package/src/extension/registration/subagent-manager-setup.ts +330 -0
  73. package/src/extension/registration/subagent-tools.ts +456 -0
  74. package/src/extension/registration/team-tool.ts +225 -0
  75. package/src/extension/registration/tool-registration.ts +50 -0
  76. package/src/extension/registration/ui.ts +172 -0
  77. package/src/extension/registration/viewers.ts +121 -0
  78. package/src/extension/registration/wire-cross-extension.ts +37 -0
  79. package/src/extension/result-watcher.ts +139 -0
  80. package/src/extension/rpc-hmac.ts +256 -0
  81. package/src/extension/run-bundle-schema.ts +104 -0
  82. package/src/extension/run-export.ts +97 -0
  83. package/src/extension/run-import.ts +168 -0
  84. package/src/extension/run-index.ts +131 -0
  85. package/src/extension/run-maintenance.ts +190 -0
  86. package/src/extension/session-summary.ts +18 -0
  87. package/src/extension/team-manager-command.ts +153 -0
  88. package/src/extension/team-onboard.ts +180 -0
  89. package/src/extension/team-recommendation.ts +288 -0
  90. package/src/extension/team-tool/anchor.ts +172 -0
  91. package/src/extension/team-tool/api.ts +1244 -0
  92. package/src/extension/team-tool/auto-summarize.ts +142 -0
  93. package/src/extension/team-tool/cache-control.ts +19 -0
  94. package/src/extension/team-tool/cancel.ts +337 -0
  95. package/src/extension/team-tool/chain-dispatch.ts +95 -0
  96. package/src/extension/team-tool/chain-executor.ts +379 -0
  97. package/src/extension/team-tool/config-patch.ts +50 -0
  98. package/src/extension/team-tool/context.ts +149 -0
  99. package/src/extension/team-tool/destructive-gate.ts +52 -0
  100. package/src/extension/team-tool/dispatch/automate.ts +80 -0
  101. package/src/extension/team-tool/dispatch/control.ts +42 -0
  102. package/src/extension/team-tool/dispatch/index.ts +95 -0
  103. package/src/extension/team-tool/dispatch/manage.ts +161 -0
  104. package/src/extension/team-tool/dispatch/run.ts +53 -0
  105. package/src/extension/team-tool/dispatch/status.ts +187 -0
  106. package/src/extension/team-tool/doctor.ts +431 -0
  107. package/src/extension/team-tool/explain.ts +282 -0
  108. package/src/extension/team-tool/failure-patterns.ts +118 -0
  109. package/src/extension/team-tool/goal-wrap.ts +330 -0
  110. package/src/extension/team-tool/goal.ts +556 -0
  111. package/src/extension/team-tool/handle-schedule.ts +332 -0
  112. package/src/extension/team-tool/handle-settings.ts +520 -0
  113. package/src/extension/team-tool/health-monitor.ts +481 -0
  114. package/src/extension/team-tool/inspect.ts +88 -0
  115. package/src/extension/team-tool/intent-policy.ts +45 -0
  116. package/src/extension/team-tool/lifecycle-actions.ts +615 -0
  117. package/src/extension/team-tool/orchestrate.ts +98 -0
  118. package/src/extension/team-tool/parallel-dispatch.ts +183 -0
  119. package/src/extension/team-tool/plan.ts +50 -0
  120. package/src/extension/team-tool/respond.ts +150 -0
  121. package/src/extension/team-tool/run-deadline.ts +66 -0
  122. package/src/extension/team-tool/run-not-found.ts +49 -0
  123. package/src/extension/team-tool/run.ts +1051 -0
  124. package/src/extension/team-tool/status.ts +260 -0
  125. package/src/extension/team-tool/workflow-manage.ts +261 -0
  126. package/src/extension/team-tool-types.ts +26 -0
  127. package/src/extension/team-tool.ts +750 -0
  128. package/src/extension/tool-result.ts +16 -0
  129. package/src/extension/validate-resources.ts +108 -0
  130. package/src/hooks/registry.ts +200 -0
  131. package/src/hooks/types.ts +69 -0
  132. package/src/i18n.ts +212 -0
  133. package/src/observability/correlation.ts +50 -0
  134. package/src/observability/event-bus.ts +86 -0
  135. package/src/observability/event-to-metric.ts +170 -0
  136. package/src/observability/exporters/adapter.ts +27 -0
  137. package/src/observability/exporters/otlp-exporter.ts +356 -0
  138. package/src/observability/exporters/prometheus-exporter.ts +54 -0
  139. package/src/observability/metric-registry.ts +100 -0
  140. package/src/observability/metric-retention.ts +64 -0
  141. package/src/observability/metric-sink.ts +99 -0
  142. package/src/observability/metrics-primitives.ts +219 -0
  143. package/src/plugins/plugin-define.ts +6 -0
  144. package/src/plugins/plugin-registry.ts +32 -0
  145. package/src/plugins/plugins/index.ts +3 -0
  146. package/src/plugins/plugins/nextjs.ts +19 -0
  147. package/src/plugins/plugins/vite.ts +10 -0
  148. package/src/plugins/plugins/vitest.ts +9 -0
  149. package/src/prompt/prompt-runtime.ts +380 -0
  150. package/src/runtime/adaptive-plan.ts +563 -0
  151. package/src/runtime/agent-control.ts +215 -0
  152. package/src/runtime/agent-memory.ts +81 -0
  153. package/src/runtime/agent-observability.ts +122 -0
  154. package/src/runtime/anchor-manager.ts +475 -0
  155. package/src/runtime/async-marker.ts +32 -0
  156. package/src/runtime/async-runner.ts +381 -0
  157. package/src/runtime/attention-events.ts +28 -0
  158. package/src/runtime/auto-summarize.ts +344 -0
  159. package/src/runtime/background-runner.ts +840 -0
  160. package/src/runtime/batch-barrier.ts +147 -0
  161. package/src/runtime/broker-issuer.ts +39 -0
  162. package/src/runtime/cancellation-token.ts +99 -0
  163. package/src/runtime/cancellation.ts +99 -0
  164. package/src/runtime/capability-inventory.ts +137 -0
  165. package/src/runtime/chain-parser.ts +237 -0
  166. package/src/runtime/chain-runner.ts +606 -0
  167. package/src/runtime/checkpoint.ts +291 -0
  168. package/src/runtime/child-pi-constants.ts +42 -0
  169. package/src/runtime/child-pi-kill.ts +180 -0
  170. package/src/runtime/child-pi-pool.ts +68 -0
  171. package/src/runtime/child-pi-spawn.ts +287 -0
  172. package/src/runtime/child-pi-steering.ts +128 -0
  173. package/src/runtime/child-pi-streams.ts +296 -0
  174. package/src/runtime/child-pi-transcript.ts +169 -0
  175. package/src/runtime/child-pi.ts +1105 -0
  176. package/src/runtime/coalesce-tasks.ts +268 -0
  177. package/src/runtime/code-summary.ts +292 -0
  178. package/src/runtime/command-trace.ts +105 -0
  179. package/src/runtime/compact-pipeline.ts +56 -0
  180. package/src/runtime/compact-stages/ansi-strip-stage.ts +25 -0
  181. package/src/runtime/compact-stages/blank-collapse-stage.ts +31 -0
  182. package/src/runtime/compact-stages/deduplicate-stage.ts +34 -0
  183. package/src/runtime/compact-stages/head-snap-stage.ts +57 -0
  184. package/src/runtime/compact-stages/index.ts +23 -0
  185. package/src/runtime/compact-stages/tail-capture-stage.ts +77 -0
  186. package/src/runtime/compact-stages/truncation-stage.ts +74 -0
  187. package/src/runtime/compaction-summary.ts +278 -0
  188. package/src/runtime/completion-guard.ts +207 -0
  189. package/src/runtime/concurrency.ts +58 -0
  190. package/src/runtime/crash-classification.ts +229 -0
  191. package/src/runtime/crash-recovery.ts +594 -0
  192. package/src/runtime/crew-agent-records.ts +595 -0
  193. package/src/runtime/crew-agent-runtime.ts +62 -0
  194. package/src/runtime/crew-broker-child.ts +88 -0
  195. package/src/runtime/crew-broker-client.ts +673 -0
  196. package/src/runtime/crew-broker-tokens.ts +159 -0
  197. package/src/runtime/crew-broker.ts +1304 -0
  198. package/src/runtime/crew-hooks.ts +227 -0
  199. package/src/runtime/cross-extension-rpc.ts +143 -0
  200. package/src/runtime/custom-tools/irc-tool.ts +282 -0
  201. package/src/runtime/custom-tools/submit-result-tool.ts +102 -0
  202. package/src/runtime/deadletter.ts +47 -0
  203. package/src/runtime/delivery-coordinator.ts +211 -0
  204. package/src/runtime/delta-conflict.ts +348 -0
  205. package/src/runtime/deterministic-ast.ts +161 -0
  206. package/src/runtime/diagnostic-export.ts +171 -0
  207. package/src/runtime/direct-run.ts +45 -0
  208. package/src/runtime/dwf-state-store.ts +102 -0
  209. package/src/runtime/dynamic-workflow-context.ts +1013 -0
  210. package/src/runtime/dynamic-workflow-runner.ts +325 -0
  211. package/src/runtime/effectiveness.ts +90 -0
  212. package/src/runtime/errors/crew-errors.ts +162 -0
  213. package/src/runtime/event-stream-bridge.ts +98 -0
  214. package/src/runtime/foreground-control.ts +193 -0
  215. package/src/runtime/foreground-watchdog.ts +132 -0
  216. package/src/runtime/global-worker-cap.ts +96 -0
  217. package/src/runtime/goal-achievement.ts +148 -0
  218. package/src/runtime/goal-evaluator.ts +365 -0
  219. package/src/runtime/goal-loop-runner.ts +825 -0
  220. package/src/runtime/goal-state-store.ts +219 -0
  221. package/src/runtime/green-contract.ts +55 -0
  222. package/src/runtime/group-join.ts +267 -0
  223. package/src/runtime/handoff-manager.ts +598 -0
  224. package/src/runtime/heartbeat-gradient.ts +36 -0
  225. package/src/runtime/heartbeat-watcher.ts +239 -0
  226. package/src/runtime/hidden-handoff.ts +407 -0
  227. package/src/runtime/important-line-classifier.ts +140 -0
  228. package/src/runtime/intercom-bridge.ts +187 -0
  229. package/src/runtime/iteration-hooks.ts +305 -0
  230. package/src/runtime/live-agent-control.ts +136 -0
  231. package/src/runtime/live-agent-manager.ts +704 -0
  232. package/src/runtime/live-control-realtime.ts +56 -0
  233. package/src/runtime/live-extension-bridge.ts +148 -0
  234. package/src/runtime/live-irc.ts +97 -0
  235. package/src/runtime/live-session-health.ts +108 -0
  236. package/src/runtime/live-session-runtime.ts +1146 -0
  237. package/src/runtime/loop-gates.ts +128 -0
  238. package/src/runtime/manifest-cache.ts +375 -0
  239. package/src/runtime/mcp-proxy.ts +105 -0
  240. package/src/runtime/metric-parser.ts +36 -0
  241. package/src/runtime/model-fallback.ts +409 -0
  242. package/src/runtime/model-resolver.ts +126 -0
  243. package/src/runtime/model-scope.ts +158 -0
  244. package/src/runtime/orphan-worker-registry.ts +465 -0
  245. package/src/runtime/output-validator.ts +202 -0
  246. package/src/runtime/overflow-recovery.ts +206 -0
  247. package/src/runtime/parallel-research.ts +68 -0
  248. package/src/runtime/parallel-utils.ts +160 -0
  249. package/src/runtime/parent-guard.ts +134 -0
  250. package/src/runtime/path-overlap.ts +150 -0
  251. package/src/runtime/peer-dep.ts +292 -0
  252. package/src/runtime/per-write-validator.ts +181 -0
  253. package/src/runtime/phase-progress.ts +217 -0
  254. package/src/runtime/phase-tracker.ts +385 -0
  255. package/src/runtime/pi-args.ts +655 -0
  256. package/src/runtime/pi-json-output.ts +170 -0
  257. package/src/runtime/pi-spawn.ts +273 -0
  258. package/src/runtime/pipeline-runner.ts +523 -0
  259. package/src/runtime/plan-templates.ts +201 -0
  260. package/src/runtime/policy-engine.ts +115 -0
  261. package/src/runtime/post-checks.ts +142 -0
  262. package/src/runtime/post-exit-stdio-guard.ts +109 -0
  263. package/src/runtime/process-lifecycle.ts +491 -0
  264. package/src/runtime/process-status.ts +154 -0
  265. package/src/runtime/progress-event-coalescer.ts +44 -0
  266. package/src/runtime/progress-tracker.ts +124 -0
  267. package/src/runtime/prose-compressor.ts +162 -0
  268. package/src/runtime/recovery-recipes.ts +203 -0
  269. package/src/runtime/replace.ts +570 -0
  270. package/src/runtime/resilient-edit.ts +153 -0
  271. package/src/runtime/result-extractor.ts +217 -0
  272. package/src/runtime/retry-executor.ts +109 -0
  273. package/src/runtime/retry-runner.ts +336 -0
  274. package/src/runtime/role-permission.ts +59 -0
  275. package/src/runtime/run-coalesced-task-group.ts +308 -0
  276. package/src/runtime/run-drift.ts +219 -0
  277. package/src/runtime/run-tracker.ts +116 -0
  278. package/src/runtime/run-worker.ts +78 -0
  279. package/src/runtime/runtime-policy.ts +29 -0
  280. package/src/runtime/runtime-resolver.ts +172 -0
  281. package/src/runtime/runtime-warmup.ts +160 -0
  282. package/src/runtime/scheduler.ts +353 -0
  283. package/src/runtime/semaphore.ts +141 -0
  284. package/src/runtime/sensitive-paths.ts +93 -0
  285. package/src/runtime/session-resources.ts +25 -0
  286. package/src/runtime/session-snapshot.ts +59 -0
  287. package/src/runtime/session-usage.ts +79 -0
  288. package/src/runtime/settings-store.ts +155 -0
  289. package/src/runtime/sidechain-output.ts +35 -0
  290. package/src/runtime/single-agent-compose.ts +84 -0
  291. package/src/runtime/skill-effectiveness.ts +524 -0
  292. package/src/runtime/skill-instructions.ts +394 -0
  293. package/src/runtime/stale-reconciler.ts +680 -0
  294. package/src/runtime/stream-preview.ts +184 -0
  295. package/src/runtime/streaming-output.ts +50 -0
  296. package/src/runtime/subagent-manager.ts +561 -0
  297. package/src/runtime/subprocess-tool-registry.ts +70 -0
  298. package/src/runtime/supervisor-contact.ts +64 -0
  299. package/src/runtime/task-display.ts +52 -0
  300. package/src/runtime/task-graph-scheduler.ts +227 -0
  301. package/src/runtime/task-graph.ts +290 -0
  302. package/src/runtime/task-health.ts +83 -0
  303. package/src/runtime/task-id.ts +161 -0
  304. package/src/runtime/task-output-context.ts +602 -0
  305. package/src/runtime/task-packet.ts +268 -0
  306. package/src/runtime/task-quality.ts +199 -0
  307. package/src/runtime/task-runner/capabilities.ts +78 -0
  308. package/src/runtime/task-runner/child-executor.ts +790 -0
  309. package/src/runtime/task-runner/context-retrieval.ts +163 -0
  310. package/src/runtime/task-runner/live-executor.ts +227 -0
  311. package/src/runtime/task-runner/output-splitter.ts +152 -0
  312. package/src/runtime/task-runner/post-execution.ts +480 -0
  313. package/src/runtime/task-runner/pre-execution.ts +365 -0
  314. package/src/runtime/task-runner/progress.ts +157 -0
  315. package/src/runtime/task-runner/prompt-builder.ts +278 -0
  316. package/src/runtime/task-runner/prompt-pipeline.ts +88 -0
  317. package/src/runtime/task-runner/result-utils.ts +16 -0
  318. package/src/runtime/task-runner/retrieval-orchestrator.ts +310 -0
  319. package/src/runtime/task-runner/run-projection.ts +125 -0
  320. package/src/runtime/task-runner/scaffold-executor.ts +25 -0
  321. package/src/runtime/task-runner/state-helpers.ts +176 -0
  322. package/src/runtime/task-runner/tail-read.ts +34 -0
  323. package/src/runtime/task-runner.ts +216 -0
  324. package/src/runtime/team-runner-artifacts.ts +13 -0
  325. package/src/runtime/team-runner.ts +2396 -0
  326. package/src/runtime/tool-output-pruner.ts +333 -0
  327. package/src/runtime/tool-progress.ts +278 -0
  328. package/src/runtime/usage-tracker.ts +73 -0
  329. package/src/runtime/verification-gates.ts +579 -0
  330. package/src/runtime/verification-integrity.ts +108 -0
  331. package/src/runtime/verification-worktree.ts +186 -0
  332. package/src/runtime/worker-heartbeat.ts +25 -0
  333. package/src/runtime/worker-startup.ts +83 -0
  334. package/src/runtime/workflow-state.ts +195 -0
  335. package/src/runtime/workspace-lock.ts +443 -0
  336. package/src/runtime/workspace-tree.ts +312 -0
  337. package/src/runtime/yield-handler.ts +224 -0
  338. package/src/runtime/zombie-scanner.ts +298 -0
  339. package/src/schema/config-schema.ts +327 -0
  340. package/src/schema/team-tool-schema.ts +492 -0
  341. package/src/schema/validation-types.ts +159 -0
  342. package/src/skills/discover-skills.ts +203 -0
  343. package/src/skills/skill-templates.ts +456 -0
  344. package/src/skills/validate.ts +285 -0
  345. package/src/state/active-run-registry.ts +439 -0
  346. package/src/state/artifact-store.ts +176 -0
  347. package/src/state/atomic-write.ts +970 -0
  348. package/src/state/blob-store.ts +308 -0
  349. package/src/state/contracts.ts +160 -0
  350. package/src/state/crew-init.ts +269 -0
  351. package/src/state/decision-ledger.ts +372 -0
  352. package/src/state/event-log-rotation.ts +399 -0
  353. package/src/state/event-log.ts +1465 -0
  354. package/src/state/event-reconstructor.ts +229 -0
  355. package/src/state/gitignore-manager.ts +46 -0
  356. package/src/state/health-store.ts +79 -0
  357. package/src/state/hook-instinct-bridge.ts +94 -0
  358. package/src/state/hook-integrations.ts +51 -0
  359. package/src/state/instinct-store.ts +275 -0
  360. package/src/state/jsonl-writer.ts +107 -0
  361. package/src/state/locks.ts +562 -0
  362. package/src/state/mailbox.ts +916 -0
  363. package/src/state/observation-store.ts +176 -0
  364. package/src/state/run-cache.ts +193 -0
  365. package/src/state/run-graph.ts +183 -0
  366. package/src/state/run-metrics.ts +165 -0
  367. package/src/state/schedule.ts +166 -0
  368. package/src/state/session-state-map.ts +51 -0
  369. package/src/state/state-store.ts +973 -0
  370. package/src/state/task-claims.ts +61 -0
  371. package/src/state/tiered-eval.ts +480 -0
  372. package/src/state/types-eval.ts +58 -0
  373. package/src/state/types.ts +447 -0
  374. package/src/state/usage.ts +138 -0
  375. package/src/state/worker-atomic-writer.ts +198 -0
  376. package/src/subagents/async-entry.ts +1 -0
  377. package/src/subagents/index.ts +3 -0
  378. package/src/subagents/live/control.ts +1 -0
  379. package/src/subagents/live/manager.ts +1 -0
  380. package/src/subagents/live/realtime.ts +1 -0
  381. package/src/subagents/live/session-runtime.ts +1 -0
  382. package/src/subagents/manager.ts +1 -0
  383. package/src/subagents/spawn.ts +1 -0
  384. package/src/teams/discover-teams.ts +187 -0
  385. package/src/teams/team-config.ts +27 -0
  386. package/src/teams/team-serializer.ts +38 -0
  387. package/src/tools/safe-bash-extension.ts +54 -0
  388. package/src/tools/safe-bash.ts +505 -0
  389. package/src/types/diff.d.ts +18 -0
  390. package/src/types/new-api-types.ts +35 -0
  391. package/src/ui/agent-management-overlay.ts +160 -0
  392. package/src/ui/card-colors.ts +139 -0
  393. package/src/ui/crew-footer.ts +102 -0
  394. package/src/ui/crew-select-list.ts +112 -0
  395. package/src/ui/dashboard-panes/agents-pane.ts +164 -0
  396. package/src/ui/dashboard-panes/cancellation-pane.ts +66 -0
  397. package/src/ui/dashboard-panes/capability-pane.ts +77 -0
  398. package/src/ui/dashboard-panes/health-pane.ts +31 -0
  399. package/src/ui/dashboard-panes/mailbox-pane.ts +35 -0
  400. package/src/ui/dashboard-panes/metrics-pane.ts +37 -0
  401. package/src/ui/dashboard-panes/progress-pane.ts +38 -0
  402. package/src/ui/dashboard-panes/transcript-pane.ts +10 -0
  403. package/src/ui/deploy-bundled-themes.ts +71 -0
  404. package/src/ui/dwf-phase-display.ts +151 -0
  405. package/src/ui/dynamic-border.ts +35 -0
  406. package/src/ui/format-helpers.ts +31 -0
  407. package/src/ui/heartbeat-aggregator.ts +74 -0
  408. package/src/ui/key-utils.ts +42 -0
  409. package/src/ui/keybinding-map.ts +248 -0
  410. package/src/ui/layout-primitives.ts +106 -0
  411. package/src/ui/live-conversation-overlay.ts +183 -0
  412. package/src/ui/live-duration.ts +55 -0
  413. package/src/ui/live-run-sidebar.ts +293 -0
  414. package/src/ui/loaders.ts +6 -0
  415. package/src/ui/mascot.ts +449 -0
  416. package/src/ui/overlays/agent-picker-overlay.ts +66 -0
  417. package/src/ui/overlays/confirm-overlay.ts +60 -0
  418. package/src/ui/overlays/help-overlay.ts +177 -0
  419. package/src/ui/overlays/mailbox-compose-overlay.ts +181 -0
  420. package/src/ui/overlays/mailbox-compose-preview.ts +78 -0
  421. package/src/ui/overlays/mailbox-detail-overlay.ts +155 -0
  422. package/src/ui/pi-ui-compat.ts +68 -0
  423. package/src/ui/powerbar-publisher.ts +459 -0
  424. package/src/ui/render-coalescer.ts +101 -0
  425. package/src/ui/render-diff.ts +160 -0
  426. package/src/ui/render-scheduler.ts +278 -0
  427. package/src/ui/run-action-dispatcher.ts +182 -0
  428. package/src/ui/run-dashboard.ts +910 -0
  429. package/src/ui/run-event-bus.ts +381 -0
  430. package/src/ui/run-snapshot-cache.ts +1057 -0
  431. package/src/ui/settings-overlay.ts +1067 -0
  432. package/src/ui/shared-overlay-scheduler.ts +96 -0
  433. package/src/ui/snapshot-types.ts +90 -0
  434. package/src/ui/spinner.ts +17 -0
  435. package/src/ui/status-colors.ts +143 -0
  436. package/src/ui/syntax-highlight.ts +136 -0
  437. package/src/ui/terminal-status.ts +269 -0
  438. package/src/ui/theme-adapter.ts +271 -0
  439. package/src/ui/theme-discovery.ts +199 -0
  440. package/src/ui/tool-progress-formatter.ts +93 -0
  441. package/src/ui/tool-renderers/brief-mode.ts +225 -0
  442. package/src/ui/tool-renderers/index.ts +726 -0
  443. package/src/ui/transcript-cache.ts +128 -0
  444. package/src/ui/transcript-entries.ts +256 -0
  445. package/src/ui/transcript-viewer.ts +424 -0
  446. package/src/ui/widget/index.ts +418 -0
  447. package/src/ui/widget/widget-formatters.ts +182 -0
  448. package/src/ui/widget/widget-model.ts +110 -0
  449. package/src/ui/widget/widget-renderer.ts +182 -0
  450. package/src/ui/widget/widget-types.ts +36 -0
  451. package/src/utils/bm25-search.ts +235 -0
  452. package/src/utils/completion-dedupe.ts +63 -0
  453. package/src/utils/conflict-detect.ts +668 -0
  454. package/src/utils/env-allowlist.ts +30 -0
  455. package/src/utils/env-filter.ts +155 -0
  456. package/src/utils/file-coalescer.ts +98 -0
  457. package/src/utils/fingerprint.ts +180 -0
  458. package/src/utils/frontmatter.ts +70 -0
  459. package/src/utils/fs-watch.ts +37 -0
  460. package/src/utils/gh-protocol.ts +556 -0
  461. package/src/utils/git.ts +260 -0
  462. package/src/utils/guards.ts +107 -0
  463. package/src/utils/ids.ts +24 -0
  464. package/src/utils/incremental-reader.ts +220 -0
  465. package/src/utils/internal-error.ts +10 -0
  466. package/src/utils/names.ts +36 -0
  467. package/src/utils/ndjson.ts +115 -0
  468. package/src/utils/paths.ts +227 -0
  469. package/src/utils/project-detector.ts +160 -0
  470. package/src/utils/redaction.ts +370 -0
  471. package/src/utils/resolve-shell.ts +36 -0
  472. package/src/utils/run-watcher-registry.ts +152 -0
  473. package/src/utils/safe-paths.ts +393 -0
  474. package/src/utils/scan-cache.ts +144 -0
  475. package/src/utils/session-utils.ts +110 -0
  476. package/src/utils/sleep.ts +54 -0
  477. package/src/utils/socket-path.ts +164 -0
  478. package/src/utils/sse-parser.ts +131 -0
  479. package/src/utils/task-name-generator.ts +337 -0
  480. package/src/utils/timings.ts +33 -0
  481. package/src/utils/token-counter.ts +200 -0
  482. package/src/utils/visual.ts +207 -0
  483. package/src/workflows/cost-estimator.ts +34 -0
  484. package/src/workflows/discover-workflows.ts +308 -0
  485. package/src/workflows/intermediate-store.ts +166 -0
  486. package/src/workflows/preflight-validator.ts +168 -0
  487. package/src/workflows/topology-analyzer.ts +203 -0
  488. package/src/workflows/validate-workflow.ts +50 -0
  489. package/src/workflows/workflow-config.ts +75 -0
  490. package/src/workflows/workflow-serializer.ts +32 -0
  491. package/src/worktree/branch-freshness.ts +112 -0
  492. package/src/worktree/cleanup.ts +341 -0
  493. package/src/worktree/worktree-manager.ts +1204 -0
@@ -0,0 +1,1465 @@
1
+ import { createHash } from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { DEFAULT_EVENT_LOG } from "../config/defaults.ts";
5
+ import { errors } from "../errors.ts";
6
+ import { emitFromTeamEvent } from "../ui/run-event-bus.ts";
7
+ import { type IncrementalReadState, readJsonlSince, readJsonlTail } from "../utils/incremental-reader.ts";
8
+ import { logInternalError } from "../utils/internal-error.ts";
9
+ import { redactSecrets } from "../utils/redaction.ts";
10
+ import { sleep, sleepSync } from "../utils/sleep.ts";
11
+ import { atomicWriteFile } from "./atomic-write.ts";
12
+ import {
13
+ applyCompactionUnlocked,
14
+ currentGeneration,
15
+ needsRotation,
16
+ prepareCompaction,
17
+ rotateEventLogUnlocked,
18
+ } from "./event-log-rotation.ts";
19
+ import { appendFileViaWorker, isWorkerAtomicWriterEnabled } from "./worker-atomic-writer.ts";
20
+
21
+ export type TeamEventProvenance = "live_worker" | "test" | "healthcheck" | "replay" | "api" | "background" | "team_runner";
22
+ export type TeamWatcherAction = "act" | "observe" | "ignore";
23
+
24
+ export interface TeamEventSessionIdentity {
25
+ title: string;
26
+ workspace: string;
27
+ purpose: string;
28
+ placeholderReason?: string;
29
+ }
30
+
31
+ export interface TeamEventOwnership {
32
+ owner: string;
33
+ workflowScope: string;
34
+ watcherAction: TeamWatcherAction;
35
+ }
36
+
37
+ export interface TeamEventMetadata {
38
+ seq: number;
39
+ provenance: TeamEventProvenance;
40
+ parentEventId?: string;
41
+ attemptId?: string;
42
+ branchId?: string;
43
+ causationId?: string;
44
+ correlationId?: string;
45
+ sessionIdentity?: TeamEventSessionIdentity;
46
+ ownership?: TeamEventOwnership;
47
+ nudgeId?: string;
48
+ appended?: boolean;
49
+ fingerprint?: string;
50
+ confidence?: "low" | "medium" | "high";
51
+ }
52
+
53
+ export interface TeamEvent {
54
+ time: string;
55
+ type: string;
56
+ runId: string;
57
+ taskId?: string;
58
+ message?: string;
59
+ data?: Record<string, unknown>;
60
+ metadata?: TeamEventMetadata;
61
+ }
62
+
63
+ export type AppendTeamEvent = Omit<TeamEvent, "time" | "metadata"> & {
64
+ metadata?: Partial<TeamEventMetadata>;
65
+ };
66
+
67
+ const TERMINAL_EVENT_TYPES = new Set<string>(DEFAULT_EVENT_LOG.terminalEventTypes);
68
+ const MAX_EVENTS_BYTES = 50 * 1024 * 1024;
69
+
70
+ const sequenceCache = new Map<string, { size: number; mtimeMs: number; seq: number; lastAccessMs: number }>();
71
+ const MAX_SEQUENCE_CACHE_ENTRIES = 256;
72
+ let appendCounter = 0;
73
+ let overflowCounter = 0;
74
+
75
+ /** Simple cross-process lock for an eventsPath to prevent JSONL interleave on concurrent append.
76
+ * Detects stale locks by checking the owner PID written inside the lock directory.
77
+ *
78
+ * @deprecated Prefer `appendEventAsync()` for callers in async contexts. The sync lock
79
+ * uses `sleepSync` which blocks the event loop and prevents AbortSignal handlers from firing.
80
+ *
81
+ * SECURITY WARNING: This function uses `sleepSync` in its lock-acquire retry loop, which
82
+ * blocks the Node.js event loop for up to 5s. During that time, AbortSignal handlers
83
+ * cannot fire, SIGTERM handlers are delayed, and the process appears unresponsive to
84
+ * orchestrator health checks. Known callers include `appendEvent` (sync path),
85
+ * `flushOneEventLogBuffer`, and `state/mailbox.ts`. Prefer the async alternative
86
+ * (`appendEventAsync`) for all new code.
87
+ */
88
+ export function withEventLogLockSync<T>(eventsPath: string, fn: () => T, options?: { timeoutMs?: number; staleMs?: number }): T {
89
+ // Ensure parent directory exists before attempting lock
90
+ fs.mkdirSync(path.dirname(eventsPath), { recursive: true });
91
+ const lockDir = `${eventsPath}.mkdirlock`;
92
+ const pidFile = path.join(lockDir, "pid");
93
+ const start = Date.now();
94
+ // SECURITY (HIGH #2 fix): Reduced from 120s to 5s to prevent blocking the
95
+ // event loop indefinitely. ~100 retries × 50ms ≈ 5s max. After timeout, we
96
+ // throw a clear error instead of blocking forever. This ensures AbortSignal
97
+ // handlers, SIGTERM, and graceful shutdown can fire within seconds.
98
+ const timeout = options?.timeoutMs ?? 5000;
99
+ const staleMs = options?.staleMs ?? 10000;
100
+ let acquired = false;
101
+ while (true) {
102
+ try {
103
+ // NOTE: mkdir-based lock is acceptable here. On POSIX systems, directory
104
+ // creation via mkdir with O_CREAT|O_EXCL semantics is atomic — equivalent
105
+ // to O_EXCL file open. The stale detection below uses process.kill(pid, 0)
106
+ // which has a TOCTOU race, but O_EXCL is used to atomically verify-and-remove
107
+ // the stale lock in one operation, eliminating the race. The 5s timeout
108
+ // (reduced from 120s) is appropriate.
109
+ fs.mkdirSync(lockDir);
110
+ try {
111
+ atomicWriteFile(pidFile, String(process.pid));
112
+ } catch {
113
+ /* best-effort */
114
+ }
115
+ acquired = true;
116
+ break;
117
+ } catch {
118
+ if (Date.now() - start > timeout) {
119
+ // SECURITY (HIGH #2 fix): Throw instead of continuing without lock.
120
+ // Previously this logged and broke out of the loop, executing the
121
+ // operation without lock protection. Now we throw so callers can retry.
122
+ // E1 (Round 15): structured CrewError (E010) with help hint so users know
123
+ // to check for orphaned .mkdirlock dirs / stale processes.
124
+ throw errors.eventLogLockTimeout(eventsPath, timeout);
125
+ }
126
+ // Round 26 (BUG 3): mtime-based stale check INDEPENDENT of pidFile.
127
+ // If the holder crashed between mkdir and writing pidFile, there is no
128
+ // pidFile to read — the old code just slept until the 5s timeout, then
129
+ // threw, leaving the dir orphaned FOREVER (every retry repeats the
130
+ // timeout). Now: if the lock dir's mtime exceeds staleMs, reclaim it.
131
+ try {
132
+ const dirStat = fs.statSync(lockDir);
133
+ if (Date.now() - dirStat.mtimeMs > staleMs) {
134
+ fs.rmSync(lockDir, { recursive: true, force: true });
135
+ continue;
136
+ }
137
+ } catch {
138
+ /* dir vanished — let loop retry */
139
+ }
140
+ // Round 26 (BUG 4): the mtime check was previously NESTED inside
141
+ // `if (!alive)`, so a recycled PID (crashed holder's PID reused by an
142
+ // unrelated live process) kept `alive=true` and the mtime check NEVER
143
+ // fired → permanent wedge. mtime is now checked FIRST (above) for ALL
144
+ // holders. The PID check below is a secondary fast-path: if the holder
145
+ // PID is provably dead AND the lock isn't stale yet, we still wait
146
+ // (don't steal a fresh lock just because the pid lookup raced).
147
+ try {
148
+ const raw = fs.readFileSync(pidFile, "utf-8").trim();
149
+ const ownerPid = Number.parseInt(raw, 10);
150
+ if (!Number.isNaN(ownerPid) && ownerPid !== process.pid) {
151
+ let alive = false;
152
+ try {
153
+ process.kill(ownerPid, 0);
154
+ alive = true;
155
+ } catch {
156
+ /* dead */
157
+ }
158
+ // (mtime already handled above; nothing to do here for dead-but-fresh.)
159
+ void alive;
160
+ }
161
+ } catch {
162
+ /* no pid file — mtime check above already handles it */
163
+ }
164
+ sleepSync(50);
165
+ }
166
+ }
167
+ try {
168
+ return fn();
169
+ } finally {
170
+ if (acquired) {
171
+ // Round 26 (BUG 5): token/PID-guarded release. Previously the release
172
+ // was an UNCONDITIONAL rmSync. If our fn exceeded staleMs, another
173
+ // process could steal our lock (rm our dir, make its own); when our fn
174
+ // finished our finally block would then DELETE THE STEALER's dir → both
175
+ // in the critical section + lost lock. Verify the pidFile still records
176
+ // OUR pid before removing; if it doesn't, the lock was stolen and the
177
+ // current holder owns the dir.
178
+ try {
179
+ const currentPid = fs.readFileSync(pidFile, "utf-8").trim();
180
+ if (currentPid === String(process.pid)) {
181
+ fs.rmSync(lockDir, { recursive: true, force: true });
182
+ }
183
+ } catch {
184
+ /* lock stolen or already gone — do not touch */
185
+ }
186
+ }
187
+ }
188
+ }
189
+
190
+ function evictOldestSequenceCacheEntries(): void {
191
+ // FIX: Evict by lastAccessMs (access time), not insertion order.
192
+ // Frequently accessed entries should be retained even if older.
193
+ const toEvict = Math.ceil(MAX_SEQUENCE_CACHE_ENTRIES / 2);
194
+ // Sort entries by lastAccessMs ascending (oldest first)
195
+ const entries = [...sequenceCache.entries()].sort((a, b) => a[1].lastAccessMs - b[1].lastAccessMs);
196
+ // Evict the oldest half
197
+ for (let i = 0; i < toEvict && i < entries.length; i++) {
198
+ sequenceCache.delete(entries[i][0]);
199
+ }
200
+ }
201
+
202
+ /** @internal — exported for sequence-cache LRU testing (Round 19). */
203
+ export function __test__sequenceCacheSize(): number {
204
+ return sequenceCache.size;
205
+ }
206
+
207
+ /** @internal — seed an entry into the sequence cache for testing. */
208
+ export function __test__seedSequenceCache(eventsPath: string, lastAccessMs: number): void {
209
+ sequenceCache.set(eventsPath, {
210
+ size: 1,
211
+ mtimeMs: 0,
212
+ seq: 0,
213
+ lastAccessMs,
214
+ });
215
+ }
216
+
217
+ /** @internal — expose eviction for testing. */
218
+ export function __test__evictOldestSequenceCacheEntries(): void {
219
+ evictOldestSequenceCacheEntries();
220
+ }
221
+
222
+ /** @internal — clear the sequence cache. */
223
+ export function __test__clearSequenceCache(): void {
224
+ sequenceCache.clear();
225
+ }
226
+
227
+ /** @internal — clear the in-process seqCounters Map so nextSequence seeds
228
+ * fresh from the sidecar/file (simulates a process restart for testing). */
229
+ export function __test__clearSeqCounters(): void {
230
+ seqCounters.clear();
231
+ }
232
+
233
+ /** @internal — the raw nextSequence for testing (forces re-seed from disk
234
+ * by requiring the caller to have already cleared both caches). */
235
+ export function __test__nextSequence(eventsPath: string): number {
236
+ return nextSequence(eventsPath);
237
+ }
238
+
239
+ /** @internal — the max sequence cache entries bound. */
240
+ export const MAX_SEQUENCE_CACHE_ENTRIES_VALUE = MAX_SEQUENCE_CACHE_ENTRIES;
241
+
242
+ export function sequencePath(eventsPath: string): string {
243
+ return `${eventsPath}.seq`;
244
+ }
245
+
246
+ function parseSequence(raw: string): number | undefined {
247
+ const value = Number.parseInt(raw.trim(), 10);
248
+ return Number.isInteger(value) && value >= 0 ? value : undefined;
249
+ }
250
+
251
+ export function scanSequence(eventsPath: string): number {
252
+ if (!fs.existsSync(eventsPath)) return 0;
253
+ let max = 0;
254
+ let skipped = 0;
255
+ for (const line of fs.readFileSync(eventsPath, "utf-8").split("\n")) {
256
+ if (!line.trim()) continue;
257
+ try {
258
+ const event = JSON.parse(line) as TeamEvent;
259
+ max = Math.max(max, event.metadata?.seq ?? 0);
260
+ } catch {
261
+ skipped++;
262
+ }
263
+ }
264
+ if (skipped > 0) {
265
+ logInternalError("event-log.scanSequence.corrupt_lines", undefined, `${eventsPath}: skipped ${skipped} corrupt line(s)`);
266
+ }
267
+ return max;
268
+ }
269
+
270
+ function readStoredSequence(eventsPath: string): number | undefined {
271
+ try {
272
+ return parseSequence(fs.readFileSync(sequencePath(eventsPath), "utf-8"));
273
+ } catch {
274
+ return undefined;
275
+ }
276
+ }
277
+
278
+ function nextSequence(eventsPath: string): number {
279
+ if (!fs.existsSync(eventsPath)) return 1;
280
+ const stat = fs.statSync(eventsPath);
281
+ const cached = sequenceCache.get(eventsPath);
282
+ if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) {
283
+ return cached.seq + 1;
284
+ }
285
+ // FIX: Trust the sidecar seq file if it exists and the file is non-empty.
286
+ // Explicitly check for file shrinkage (stat.size < cached.size) to trigger
287
+ // re-scan when rotation or compaction has occurred.
288
+ const stored = readStoredSequence(eventsPath);
289
+ const fileShrunk = cached && stat.size < cached.size;
290
+ if (stored !== undefined && !fileShrunk) {
291
+ // EL-1: the sidecar can regress via sync/async interleave —
292
+ // appendEventAsync reserves a seq, yields during appendFile/fsync, a
293
+ // concurrent sync appendEvent reserves+persists a higher seq, then the
294
+ // async path persists its lower seq — regressing the sidecar below the
295
+ // file's true max. Trusting the regressed sidecar alone would return a
296
+ // duplicate seq on the next append. Take max with scanSequence so a
297
+ // regressed sidecar cannot cause duplicate sequence numbers.
298
+ const fileMax = scanSequence(eventsPath);
299
+ const safeSeq = Math.max(stored, fileMax);
300
+ sequenceCache.set(eventsPath, {
301
+ size: stat.size,
302
+ mtimeMs: stat.mtimeMs,
303
+ seq: safeSeq,
304
+ lastAccessMs: Date.now(),
305
+ });
306
+ return safeSeq + 1;
307
+ }
308
+ const current = scanSequence(eventsPath);
309
+ sequenceCache.set(eventsPath, {
310
+ size: stat.size,
311
+ mtimeMs: stat.mtimeMs,
312
+ seq: current,
313
+ lastAccessMs: Date.now(),
314
+ });
315
+ persistSequence(eventsPath, current);
316
+ return current + 1;
317
+ }
318
+
319
+ function persistSequence(eventsPath: string, seq: number): void {
320
+ try {
321
+ atomicWriteFile(sequencePath(eventsPath), String(seq));
322
+ } catch (error) {
323
+ logInternalError("event-log.persist-sequence-file", error, `eventsPath=${eventsPath}`);
324
+ }
325
+ }
326
+
327
+ // B7: single in-process monotonic sequence counter per eventsPath. The three
328
+ // append paths — sync appendEvent (withEventLogLockSync file lock), buffered
329
+ // flush (asyncLocks promise chain), and direct appendEventAsync (asyncQueues
330
+ // promise chain) — use DIFFERENT locks, so the old read-sidecar / compute /
331
+ // persist-sidecar sequence logic in nextSequence() raced ACROSS paths and
332
+ // produced duplicate sequence numbers (observed live: distinct events sharing
333
+ // a seq; no data loss — only the counter collided). A single in-process counter
334
+ // makes assignment atomic (JS is single-threaded); persistSequence() keeps the
335
+ // sidecar durable for crash recovery across restarts.
336
+ const seqCounters = new Map<string, number>();
337
+
338
+ /** Atomically reserve the next sequence number for `eventsPath`. */
339
+ function reserveSequence(eventsPath: string): number {
340
+ let last = seqCounters.get(eventsPath);
341
+ if (last === undefined) {
342
+ // Seed once from the authoritative source (sidecar / cache / file scan).
343
+ // nextSequence() returns the NEXT seq to assign, so the last assigned is one less.
344
+ last = nextSequence(eventsPath) - 1;
345
+ }
346
+ const next = last + 1;
347
+ seqCounters.set(eventsPath, next);
348
+ return next;
349
+ }
350
+
351
+ /** Keep the in-process counter monotonic w.r.t. an explicitly-provided seq
352
+ * (e.g. baseMetadata.seq) so a later auto-assigned seq never collides with it. */
353
+ function advanceSequenceCounter(eventsPath: string, seq: number): void {
354
+ const last = seqCounters.get(eventsPath);
355
+ if (last === undefined || seq > last) seqCounters.set(eventsPath, seq);
356
+ }
357
+
358
+ /** C-01: Reserve sequence INSIDE the cross-process lock. Reads the authoritative
359
+ * sidecar (.seq file) for the last seq persisted by ANY process, ensuring
360
+ * cross-process uniqueness. Falls back to scanSequence if no sidecar exists.
361
+ * The in-process seqCounters is kept monotonic via Math.max for defensive
362
+ * consistency with any in-process sequencing that hasn't been persisted yet. */
363
+ function reserveSequenceUnderLock(eventsPath: string): number {
364
+ let stored = readStoredSequence(eventsPath);
365
+ if (stored === undefined) {
366
+ stored = scanSequence(eventsPath);
367
+ }
368
+ const inProcess = seqCounters.get(eventsPath) ?? 0;
369
+ const last = Math.max(stored, inProcess);
370
+ const next = last + 1;
371
+ seqCounters.set(eventsPath, next);
372
+ return next;
373
+ }
374
+
375
+ export function computeEventFingerprint(event: Pick<TeamEvent, "type" | "runId" | "taskId" | "data">): string {
376
+ return createHash("sha256")
377
+ .update(
378
+ JSON.stringify({
379
+ type: event.type,
380
+ runId: event.runId,
381
+ taskId: event.taskId,
382
+ data: event.data ?? null,
383
+ }),
384
+ )
385
+ .digest("hex")
386
+ .slice(0, 16);
387
+ }
388
+
389
+ /**
390
+ * Check for sequence gaps between the sidecar file and the events file.
391
+ * This detects situations where the sidecar records a sequence number that has
392
+ * no corresponding event in the file (e.g., due to a crash between
393
+ * persistSequence and appendFile in older code, or other corruption).
394
+ *
395
+ * Returns an array of gap info: for each gap found, { missing: n } indicates
396
+ * sequence n is recorded in sidecar but has no corresponding event.
397
+ * An empty array means no gaps were found.
398
+ */
399
+ export function checkSequenceGaps(eventsPath: string): { missing: number }[] {
400
+ if (!fs.existsSync(eventsPath)) return [];
401
+ const gaps: { missing: number }[] = [];
402
+ const storedSeq = readStoredSequence(eventsPath);
403
+ if (storedSeq === undefined) return [];
404
+ const maxInFile = scanSequence(eventsPath);
405
+ // If sidecar is ahead of file, report the missing sequences
406
+ // (sidecar stores the NEXT sequence to use, so storedSeq is the last written)
407
+ if (storedSeq > maxInFile) {
408
+ for (let i = maxInFile + 1; i <= storedSeq; i++) {
409
+ gaps.push({ missing: i });
410
+ }
411
+ }
412
+ return gaps;
413
+ }
414
+
415
+ /**
416
+ * @deprecated Prefer `appendEventAsync()` in async contexts. The sync lock uses
417
+ * `sleepSync` which blocks the Node.js event loop, preventing AbortSignal handlers
418
+ * from firing and degrading live-agent responsiveness.
419
+ */
420
+ export function appendEvent(eventsPath: string, event: AppendTeamEvent): TeamEvent {
421
+ // NOTE: appendEvent is a sync function that uses withEventLogLockSync (sleepSync).
422
+ // It cannot route through appendEventBuffered because the buffer timer requires
423
+ // the event loop to fire, which sleepSync blocks. Both terminal and non-terminal
424
+ // events use the direct sync path here. For non-terminal events, callers should
425
+ // prefer appendEventAsync (which routes through the buffer for coalesced writes).
426
+ return withEventLogLockSync(eventsPath, () => appendEventInsideLock(eventsPath, event));
427
+ }
428
+
429
+ // --- Async write queue (non-blocking alternative to withEventLogLockSync) ---
430
+ const asyncQueues = new Map<string, Promise<unknown>>();
431
+
432
+ // --- Async lock for flush operations (non-blocking alternative to withEventLogLockSync) ---
433
+ // Uses promise-chain pattern to ensure sequential lock acquisition without blocking the event loop.
434
+ const asyncLocks = new Map<string, Promise<unknown>>();
435
+
436
+ /** Drain all pending async writes by awaiting all in-flight queue promises.
437
+ * Called on process exit to minimize event loss for crash-sensitive events.
438
+ * Note: SIGKILL (kill -9) cannot be intercepted and will still lose events.
439
+ */
440
+ async function drainAsyncQueues(): Promise<void> {
441
+ const promises = [...asyncQueues.values()];
442
+ if (promises.length === 0) return;
443
+ // Use allSettled to ensure a rejected promise doesn't prevent others from completing.
444
+ await Promise.allSettled(promises);
445
+ }
446
+
447
+ /** C-01: Async cross-process file lock for an eventsPath. Uses `fs.promises.mkdir`
448
+ * (atomic O_EXCL on POSIX) for cross-process mutual exclusion, and `await sleep(50)`
449
+ * for retry backoff — NOT sleepSync (which blocks the event loop and was the
450
+ * v0.9.26 deadlock root cause).
451
+ *
452
+ * Two-tier design: the `asyncLocks` promise chain provides in-process
453
+ * serialization of the lock-acquire/release cycle. The mkdir lock provides
454
+ * cross-process serialization. Callers wrapped in `asyncQueues`
455
+ * (appendEventAsync) or directly (flushOneEventLogBuffer) use this for the
456
+ * cross-process tier.
457
+ *
458
+ * Deadlock safety (v0.9.26 lesson): ALL retry backoff uses `await sleep(50)`
459
+ * (async timer — yields the event loop). NEVER sleepSync. The mkdir lock is
460
+ * SEPARATE LOCK DIR (`.alock`): the async path uses `${eventsPath}.alock` while
461
+ * the sync path (`withEventLogLockSync`) uses `${eventsPath}.mkdirlock`. This is
462
+ * REQUIRED because `withEventLogLockSync`'s retry loop uses `sleepSync(50)` which
463
+ * blocks the event loop continuously — if both paths shared the same lock dir,
464
+ * the sync retry loop would starve the async path (which needs event-loop
465
+ * iterations to complete), causing a 5s timeout deadlock. Within-process seq
466
+ * uniqueness is maintained by the shared `seqCounters` Map + `O_APPEND` writes.
467
+ * Cross-process async-vs-async is fully protected. Sync-vs-async cross-process
468
+ * on the same eventsPath is mitigated by `O_APPEND` atomic writes + the
469
+ * shared sidecar (extremely unlikely scenario — workers write to their own
470
+ * run-scoped events.jsonl, not the parent's).
471
+ *
472
+ * NOT re-entrant: callers inside this lock must use unlocked compaction
473
+ * variants (prepareCompaction + applyCompactionUnlocked, rotateEventLogUnlocked)
474
+ * to avoid self-deadlock. */
475
+ async function withEventLogLockAsync<T>(
476
+ eventsPath: string,
477
+ fn: () => Promise<T>,
478
+ options?: { timeoutMs?: number; staleMs?: number },
479
+ ): Promise<T> {
480
+ const queueKey = eventsPath;
481
+ // .then(() => undefined, () => undefined) prevents rejection-poisoning: if
482
+ // the previous call's chain rejected (e.g., lock timeout), the next caller
483
+ // starts fresh instead of propagating the rejection indefinitely.
484
+ const prev = (asyncLocks.get(queueKey) ?? Promise.resolve()).then(
485
+ () => undefined,
486
+ () => undefined,
487
+ );
488
+ const next = prev.then(async (): Promise<T> => {
489
+ // Ensure parent directory exists before attempting lock
490
+ await fs.promises.mkdir(path.dirname(eventsPath), { recursive: true });
491
+
492
+ const lockDir = `${eventsPath}.alock`;
493
+ const pidFile = path.join(lockDir, "pid");
494
+ const timeout = options?.timeoutMs ?? 5000;
495
+ const staleMs = options?.staleMs ?? 10000;
496
+ const start = Date.now();
497
+ let acquired = false;
498
+
499
+ // Cross-process lock acquisition loop (async, no sleepSync)
500
+ while (true) {
501
+ try {
502
+ await fs.promises.mkdir(lockDir);
503
+ try {
504
+ atomicWriteFile(pidFile, String(process.pid));
505
+ } catch {
506
+ /* best-effort */
507
+ }
508
+ acquired = true;
509
+ break;
510
+ } catch {
511
+ if (Date.now() - start > timeout) {
512
+ throw errors.eventLogLockTimeout(eventsPath, timeout);
513
+ }
514
+ // Stale detection: mtime-based (handles crash between mkdir and pidFile).
515
+ try {
516
+ const dirStat = await fs.promises.stat(lockDir);
517
+ if (Date.now() - dirStat.mtimeMs > staleMs) {
518
+ await fs.promises.rm(lockDir, { recursive: true, force: true });
519
+ continue;
520
+ }
521
+ } catch {
522
+ /* dir vanished — let loop retry */
523
+ }
524
+ // PID check (secondary fast-path for dead-but-fresh holders)
525
+ try {
526
+ const raw = await fs.promises.readFile(pidFile, "utf-8").catch(() => "");
527
+ const ownerPid = Number.parseInt(raw.trim(), 10);
528
+ if (!Number.isNaN(ownerPid) && ownerPid !== process.pid) {
529
+ try {
530
+ process.kill(ownerPid, 0);
531
+ } catch {
532
+ /* dead — but mtime not stale yet, keep waiting */
533
+ }
534
+ }
535
+ } catch {
536
+ /* no pid file — mtime check above handles it */
537
+ }
538
+ // ASYNC sleep — yields the event loop (NOT sleepSync)
539
+ await sleep(50);
540
+ }
541
+ }
542
+
543
+ try {
544
+ return await fn();
545
+ } finally {
546
+ if (acquired) {
547
+ // PID-guarded release: verify pidFile still records OUR pid before
548
+ // removing. If our fn exceeded staleMs, another process could have
549
+ // stolen our lock — don't delete the stealer's dir.
550
+ try {
551
+ const currentPid = await fs.promises.readFile(pidFile, "utf-8").catch(() => "");
552
+ if (currentPid.trim() === String(process.pid)) {
553
+ await fs.promises.rm(lockDir, { recursive: true, force: true });
554
+ }
555
+ } catch {
556
+ /* lock stolen or already gone — do not touch */
557
+ }
558
+ }
559
+ }
560
+ });
561
+ asyncLocks.set(queueKey, next);
562
+ try {
563
+ return await next;
564
+ } finally {
565
+ // Compare-and-delete: only remove our entry if it still points at our
566
+ // promise. With 3+ overlapping callers, an earlier caller's finally would
567
+ // otherwise delete a later caller's promise, letting the next caller start
568
+ // immediately (in parallel) -> broken mutual exclusion, duplicate seqs.
569
+ if (asyncLocks.get(queueKey) === next) {
570
+ asyncLocks.delete(queueKey);
571
+ }
572
+ }
573
+ }
574
+
575
+ /** Reset event log mode (for testing only). */
576
+ export function resetEventLogMode(): void {
577
+ asyncQueues.clear();
578
+ asyncLocks.clear();
579
+ // B7: clear in-process sequence counters alongside async state so tests
580
+ // don't leak seq state between runs.
581
+ seqCounters.clear();
582
+ }
583
+
584
+ /**
585
+ * Append an event to the event log using non-blocking async I/O.
586
+ *
587
+ * Uses a per-eventsPath promise-chain queue to ensure sequential writes without
588
+ * blocking the Node.js event loop. This allows AbortSignal handlers and other
589
+ * async operations to proceed while events are being persisted.
590
+ *
591
+ * For callers that are already in an async context (team-runner, task-runner,
592
+ * foreground-control, etc.), prefer this over the sync `appendEvent()`.
593
+ */
594
+ export async function appendEventAsync(eventsPath: string, event: AppendTeamEvent): Promise<TeamEvent> {
595
+ // FIX (v0.9.26): Do NOT route non-terminal events through appendEventBuffered.
596
+ // The buffer uses a 20ms timer + withEventLogLockAsync (promise chain), while
597
+ // the sync appendEvent path uses withEventLogLockSync (file lock with sleepSync).
598
+ // Mixing these two lock mechanisms on the same eventsPath causes a deadlock:
599
+ // the buffer timer can't fire while sleepSync blocks the event loop, and
600
+ // sleepSync can't acquire the lock while the buffer holds it via the promise
601
+ // chain. This deadlocked adaptive-implementation, implementation-fanout,
602
+ // parallel-research-dynamic, run-analysis, and team-run tests (>300s timeout).
603
+ // Reverted to v0.9.19 behavior: ALL events use the asyncQueues direct path.
604
+ // The buffer (appendEventBuffered) is still available for explicit callers
605
+ // that want coalesced writes (e.g., appendEventFireAndForget), but
606
+ // appendEventAsync itself does NOT buffer.
607
+ const queueKey = eventsPath;
608
+ // C-01: Body extracted to local function for two-tier lock wrapping.
609
+ // Two-tier: asyncQueues (in-process serialize) → withEventLogLockAsync
610
+ // (cross-process serialize via mkdir O_EXCL). Seq allocation + append run
611
+ // INSIDE the cross-process lock. Compaction uses UNLOCKED variants
612
+ // (mkdir lock is NOT re-entrant).
613
+ const doAppendUnderLock = async (): Promise<TeamEvent> => {
614
+ // Build metadata (same logic as appendEventInsideLock)
615
+ // FIX: Sequence is computed INSIDE the promise chain. We NO LONGER persist
616
+ // the sequence number before the append — that caused sequence reuse if
617
+ // appendFile failed after persistSequence succeeded. Instead, we persist
618
+ // ONLY AFTER successful appendFile, so the sidecar is only updated when
619
+ // the event is definitively written. If appendFile fails, the sidecar is
620
+ // not updated and nextSequence() will re-scan on next call, returning the
621
+ // correct value without reuse.
622
+ const baseMetadata = event.metadata;
623
+ let seq: number;
624
+ if (baseMetadata?.seq !== undefined) {
625
+ seq = baseMetadata.seq;
626
+ advanceSequenceCounter(eventsPath, seq);
627
+ } else {
628
+ seq = reserveSequenceUnderLock(eventsPath);
629
+ // NOTE: We do NOT call persistSequence here. It will be called AFTER
630
+ // successful appendFile below to ensure sidecar is only updated when
631
+ // the event is actually written.
632
+ }
633
+ let metadata: TeamEventMetadata = {
634
+ seq,
635
+ provenance: baseMetadata?.provenance ?? "team_runner",
636
+ ...(baseMetadata?.parentEventId ? { parentEventId: baseMetadata.parentEventId } : {}),
637
+ ...(baseMetadata?.attemptId ? { attemptId: baseMetadata.attemptId } : {}),
638
+ ...(baseMetadata?.branchId ? { branchId: baseMetadata.branchId } : {}),
639
+ ...(baseMetadata?.causationId ? { causationId: baseMetadata.causationId } : {}),
640
+ ...(baseMetadata?.correlationId ? { correlationId: baseMetadata.correlationId } : {}),
641
+ ...(baseMetadata?.sessionIdentity ? { sessionIdentity: baseMetadata.sessionIdentity } : {}),
642
+ ...(baseMetadata?.ownership ? { ownership: baseMetadata.ownership } : {}),
643
+ ...(baseMetadata?.nudgeId ? { nudgeId: baseMetadata.nudgeId } : {}),
644
+ ...(baseMetadata?.confidence ? { confidence: baseMetadata.confidence } : {}),
645
+ };
646
+ const fullEvent: TeamEvent = {
647
+ time: new Date().toISOString(),
648
+ ...event,
649
+ metadata,
650
+ };
651
+ if (baseMetadata?.fingerprint || TERMINAL_EVENT_TYPES.has(fullEvent.type)) {
652
+ metadata = {
653
+ ...metadata,
654
+ fingerprint: baseMetadata?.fingerprint ?? computeEventFingerprint(fullEvent),
655
+ };
656
+ fullEvent.metadata = metadata;
657
+ }
658
+
659
+ // Overflow handling: same logic as sync path
660
+ const isTerminal = TERMINAL_EVENT_TYPES.has(fullEvent.type);
661
+ let skippedDueToSize = false;
662
+ let fileStat: fs.Stats | undefined;
663
+ try {
664
+ fileStat = await fs.promises.stat(eventsPath).catch(() => undefined);
665
+ } catch {
666
+ /* file does not exist */
667
+ }
668
+ // FIND-10: track whether overflow handling modified the file so we can
669
+ // reuse fileStat for the post-overflow size check (avoids redundant stat).
670
+ let overflowHandled = false;
671
+ if (!isTerminal && fileStat) {
672
+ const stat = fileStat;
673
+ if (stat.size > MAX_EVENTS_BYTES) {
674
+ overflowHandled = true;
675
+ try {
676
+ const prepared = prepareCompaction(eventsPath);
677
+ if (prepared) applyCompactionUnlocked(eventsPath, prepared);
678
+ } catch (error) {
679
+ logInternalError("event-log.immediate-compact", error, `eventsPath=${eventsPath}`);
680
+ }
681
+ let afterCompactStat: fs.Stats | undefined;
682
+ try {
683
+ afterCompactStat = await fs.promises.stat(eventsPath).catch(() => undefined);
684
+ } catch {
685
+ /* file does not exist */
686
+ }
687
+ if (afterCompactStat) {
688
+ if (afterCompactStat.size > MAX_EVENTS_BYTES) {
689
+ rotateEventLogUnlocked(eventsPath);
690
+ }
691
+ }
692
+ }
693
+ }
694
+ // FIND-10: collapse redundant stat. If no overflow handling occurred,
695
+ // the file hasn't changed since fileStat — reuse it instead of re-stat'ing.
696
+ let sizeCheckStat: fs.Stats | undefined;
697
+ if (overflowHandled) {
698
+ try {
699
+ sizeCheckStat = await fs.promises.stat(eventsPath).catch(() => undefined);
700
+ } catch {
701
+ /* file does not exist */
702
+ }
703
+ } else {
704
+ sizeCheckStat = fileStat;
705
+ }
706
+ try {
707
+ if (sizeCheckStat && sizeCheckStat.size > MAX_EVENTS_BYTES) {
708
+ logInternalError(
709
+ "event-log.size-limit",
710
+ new Error(`events file ${eventsPath} exceeds ${MAX_EVENTS_BYTES} bytes after compaction`),
711
+ `eventsPath=${eventsPath}`,
712
+ );
713
+ skippedDueToSize = true;
714
+ }
715
+ } catch (error) {
716
+ logInternalError("event-log.size-check", error, `eventsPath=${eventsPath}`);
717
+ }
718
+
719
+ // FIND-10: post-append stat captured from the same fd (non-worker path)
720
+ // for reuse in the cache update below, avoiding a redundant path stat.
721
+ let postAppendStat: fs.Stats | undefined;
722
+ if (!skippedDueToSize) {
723
+ const line = JSON.stringify(redactSecrets(fullEvent)) + "\n";
724
+ // Phase 1.5: when worker atomic writer is enabled, append via worker.
725
+ if (isWorkerAtomicWriterEnabled()) {
726
+ await appendFileViaWorker(eventsPath, line);
727
+ // Worker path: fsync via a separate open (worker manages its own fd).
728
+ const fd = await fs.promises.open(eventsPath, "r+");
729
+ try {
730
+ await fd.sync();
731
+ } finally {
732
+ await fd.close();
733
+ }
734
+ } else {
735
+ // FIND-10: single-fd append+fsync. Opens in append mode, writes,
736
+ // fsyncs on the SAME fd, then closes — eliminating the separate
737
+ // open("r+") + sync that previously doubled the fd count. The
738
+ // fsync (seq-integrity protection) is preserved exactly: it still
739
+ // closes the crash window between append and persistSequence.
740
+ const fd = await fs.promises.open(eventsPath, "a");
741
+ try {
742
+ await fd.appendFile(line, "utf-8");
743
+ await fd.sync();
744
+ // FIND-10 R1 fix: the cache-optimization fd.stat() must NOT sit in the
745
+ // seq-durability critical path. If it threw (rare — fd invalidated),
746
+ // it would skip persistSequence below and reopen the seq-reuse
747
+ // window the fsync just closed. Guard it; fall back to undefined
748
+ // (the later cache-update takes a path stat instead).
749
+ try {
750
+ postAppendStat = await fd.stat();
751
+ } catch {
752
+ postAppendStat = undefined;
753
+ }
754
+ } finally {
755
+ await fd.close();
756
+ }
757
+ }
758
+ // FIX: Persist sequence AFTER successful appendFile to ensure sidecar
759
+ // is only updated when the event is definitively written. If appendFile
760
+ // threw, we would not reach here and the sidecar would not be updated,
761
+ // preventing sequence reuse on restart.
762
+ persistSequence(eventsPath, seq);
763
+ }
764
+ // FIND-10: track whether compaction happened after the append so the
765
+ // cache-update stat can safely reuse postAppendStat (file unchanged).
766
+ let compactedAfterAppend = false;
767
+ if (appendCounter % 100 === 0 && needsRotation(eventsPath)) {
768
+ compactedAfterAppend = true;
769
+ try {
770
+ const prepared = prepareCompaction(eventsPath);
771
+ if (prepared) applyCompactionUnlocked(eventsPath, prepared);
772
+ } catch (error) {
773
+ logInternalError("event-log.rotation", error, `eventsPath=${eventsPath}`);
774
+ }
775
+ }
776
+ try {
777
+ emitFromTeamEvent(fullEvent);
778
+ } catch (error) {
779
+ logInternalError("event-log.emit", error);
780
+ }
781
+
782
+ // FIX: Sequence was persisted AFTER appendFile in the append block above.
783
+ // Only update the cache here (the sidecar persist is already done).
784
+ const finalSeq = fullEvent.metadata?.seq ?? 0;
785
+ try {
786
+ // FIND-10: reuse post-append fd stat when available and no compaction
787
+ // happened after the append (file unchanged). Falls back to path stat
788
+ // for the worker path, skipped events, or post-compaction cases.
789
+ let statResult: fs.Stats | undefined;
790
+ if (postAppendStat && !compactedAfterAppend) {
791
+ statResult = postAppendStat;
792
+ } else {
793
+ try {
794
+ statResult = await fs.promises.stat(eventsPath).catch(() => undefined);
795
+ } catch {
796
+ /* file may not exist */
797
+ }
798
+ }
799
+ if (statResult) {
800
+ if (sequenceCache.size >= MAX_SEQUENCE_CACHE_ENTRIES) {
801
+ evictOldestSequenceCacheEntries();
802
+ }
803
+ sequenceCache.set(eventsPath, {
804
+ size: statResult.size,
805
+ mtimeMs: statResult.mtimeMs,
806
+ seq: finalSeq,
807
+ lastAccessMs: Date.now(),
808
+ });
809
+ }
810
+ // Note: persistSequence is NOT called here again - it was already called
811
+ // after the append to ensure the sidecar is current after the event is written.
812
+ } catch (error) {
813
+ logInternalError("event-log.persist-sequence", error, `eventsPath=${eventsPath}`);
814
+ }
815
+ return fullEvent;
816
+ };
817
+ // C-01: Two-tier lock — asyncQueues (in-process serialize) →
818
+ // withEventLogLockAsync (cross-process serialize via mkdir O_EXCL).
819
+ const prev = asyncQueues.get(queueKey) ?? Promise.resolve();
820
+ const next = prev.then(async (): Promise<TeamEvent> => {
821
+ await fs.promises.mkdir(path.dirname(eventsPath), { recursive: true });
822
+ return withEventLogLockAsync(eventsPath, doAppendUnderLock);
823
+ });
824
+ const tail = next.then(
825
+ () => {
826
+ // Compare-and-delete: only remove our entry if it still points at our
827
+ // tail promise. An older caller deleting unconditionally would wipe a
828
+ // newer caller's promise, letting the next caller bypass serialization
829
+ // -> duplicate seqs / interleaved appends.
830
+ if (asyncQueues.get(queueKey) === tail) {
831
+ asyncQueues.delete(queueKey);
832
+ }
833
+ },
834
+ (error) => {
835
+ // FIX: Wrap error handler in try-catch to ensure asyncQueues.delete
836
+ // always runs, even if logging itself throws.
837
+ try {
838
+ logInternalError("event-log.async-queue", error, eventsPath);
839
+ } catch {
840
+ // logging failed — ensure queue is still cleaned up
841
+ }
842
+ // FIX: Reset queue to a resolved state instead of deleting it.
843
+ // This prevents cascading failures where a single transient error
844
+ // (e.g., ENOSPC) causes all subsequent events on the same path to fail.
845
+ asyncQueues.set(queueKey, Promise.resolve());
846
+ },
847
+ );
848
+ asyncQueues.set(queueKey, tail);
849
+ return next;
850
+ }
851
+
852
+ /**
853
+ * Body of `appendEvent` assuming the caller already holds
854
+ * `withEventLogLockSync` for `eventsPath`. Used by `appendEventBuffered` to
855
+ * write a whole batch of pending events under a single lock acquire.
856
+ */
857
+ /**
858
+ * Batch variant used by the buffered flush path. Computes metadata for each
859
+ * event, writes the whole batch in a single appendFileSync + fsync, persists
860
+ * the sequence sidecar once with the last seq, and updates the sequence cache
861
+ * once. Resolves each item with its finalized event (carrying the assigned
862
+ * seq). This collapses N fsyncs into 1 for the buffered write path, which is
863
+ * the entire point of buffering — the previous per-event fsync made buffer
864
+ * coalescing useless and added ~30ms/event on tmpfs.
865
+ */
866
+ async function appendEventBatchInsideLock(eventsPath: string, queue: BufferedAppend[]): Promise<void> {
867
+ if (queue.length === 0) return;
868
+ fs.mkdirSync(path.dirname(eventsPath), { recursive: true });
869
+
870
+ // Pre-flight size check (mirrors appendEventInsideLock). We do it once for
871
+ // the batch instead of once per event.
872
+ try {
873
+ if (fs.existsSync(eventsPath)) {
874
+ const stat = fs.statSync(eventsPath);
875
+ if (stat.size > MAX_EVENTS_BYTES) {
876
+ try {
877
+ const prepared = prepareCompaction(eventsPath);
878
+ if (prepared) applyCompactionUnlocked(eventsPath, prepared);
879
+ } catch (error) {
880
+ logInternalError("event-log.batch-immediate-compact", error, `eventsPath=${eventsPath}`);
881
+ }
882
+ if (fs.existsSync(eventsPath) && fs.statSync(eventsPath).size > MAX_EVENTS_BYTES) {
883
+ rotateEventLogUnlocked(eventsPath);
884
+ }
885
+ }
886
+ }
887
+ } catch (error) {
888
+ logInternalError("event-log.batch-size-check", error, `eventsPath=${eventsPath}`);
889
+ }
890
+
891
+ // Phase 1: compute metadata + JSON lines for every event in the batch.
892
+ // Initialize nextSeq ONCE from nextSequence (or the first event's baseMetadata.seq),
893
+ // then increment locally for each subsequent event in the batch. Calling
894
+ // nextSequence() per-event would re-read file stat/sidecar with no writes
895
+ // in between — every call would see the same file state and return the same
896
+ // seq, breaking the "unique monotonic seq" contract. The cache update +
897
+ // persistSequence at the end refreshes the sidecar to the last assigned seq.
898
+ // B7: use reserveSequence for atomic seq assignment across all paths.
899
+ const startingSeq = queue[0]?.event.metadata?.seq ?? reserveSequence(eventsPath);
900
+ let nextSeq = startingSeq;
901
+ const finalized: { item: BufferedAppend; line: string; fullEvent: TeamEvent }[] = [];
902
+ let lastSeq = 0;
903
+ for (const item of queue) {
904
+ const baseMetadata = item.event.metadata;
905
+ const seq = baseMetadata?.seq ?? nextSeq++;
906
+ let metadata: TeamEventMetadata = {
907
+ seq,
908
+ provenance: baseMetadata?.provenance ?? "team_runner",
909
+ ...(baseMetadata?.parentEventId ? { parentEventId: baseMetadata.parentEventId } : {}),
910
+ ...(baseMetadata?.attemptId ? { attemptId: baseMetadata.attemptId } : {}),
911
+ ...(baseMetadata?.branchId ? { branchId: baseMetadata.branchId } : {}),
912
+ ...(baseMetadata?.causationId ? { causationId: baseMetadata.causationId } : {}),
913
+ ...(baseMetadata?.correlationId ? { correlationId: baseMetadata.correlationId } : {}),
914
+ ...(baseMetadata?.sessionIdentity ? { sessionIdentity: baseMetadata.sessionIdentity } : {}),
915
+ ...(baseMetadata?.ownership ? { ownership: baseMetadata.ownership } : {}),
916
+ ...(baseMetadata?.nudgeId ? { nudgeId: baseMetadata.nudgeId } : {}),
917
+ ...(baseMetadata?.confidence ? { confidence: baseMetadata.confidence } : {}),
918
+ };
919
+ const fullEvent: TeamEvent = {
920
+ time: new Date().toISOString(),
921
+ ...item.event,
922
+ metadata,
923
+ };
924
+ if (baseMetadata?.fingerprint || TERMINAL_EVENT_TYPES.has(fullEvent.type)) {
925
+ metadata = {
926
+ ...metadata,
927
+ fingerprint: baseMetadata?.fingerprint ?? computeEventFingerprint(fullEvent),
928
+ };
929
+ fullEvent.metadata = metadata;
930
+ }
931
+ finalized.push({ item, line: `${JSON.stringify(redactSecrets(fullEvent))}\n`, fullEvent });
932
+ lastSeq = seq;
933
+ }
934
+ // B7: advance counter past the entire batch so next reserveSequence returns the correct value.
935
+ advanceSequenceCounter(eventsPath, lastSeq);
936
+
937
+ // Phase 2: single appendFileSync + single fsync + single persistSequence.
938
+ // Before this fix, each event in the batch triggered its own fsync, which
939
+ // was the dominant cost on tmpfs and CI runners.
940
+ try {
941
+ if (fs.existsSync(eventsPath) && fs.statSync(eventsPath).size > MAX_EVENTS_BYTES) {
942
+ logInternalError(
943
+ "event-log.size-limit",
944
+ new Error(`events file ${eventsPath} exceeds ${MAX_EVENTS_BYTES} bytes after compaction`),
945
+ `eventsPath=${eventsPath}`,
946
+ );
947
+ // Reject the batch — caller will surface the error per item.
948
+ for (const { item } of finalized) item.reject(new Error("event log size limit exceeded"));
949
+ return;
950
+ }
951
+ } catch (error) {
952
+ logInternalError("event-log.batch-size-check-post", error, `eventsPath=${eventsPath}`);
953
+ }
954
+
955
+ fs.appendFileSync(eventsPath, finalized.map((f) => f.line).join(""), "utf-8");
956
+ const fd = fs.openSync(eventsPath, "r+");
957
+ try {
958
+ fs.fsyncSync(fd);
959
+ } catch {
960
+ // EPERM on Windows CI: best-effort flush
961
+ } finally {
962
+ fs.closeSync(fd);
963
+ }
964
+ persistSequence(eventsPath, lastSeq);
965
+
966
+ // Phase 3: cache update + resolve all promises.
967
+ try {
968
+ const stat = fs.statSync(eventsPath);
969
+ if (sequenceCache.size >= MAX_SEQUENCE_CACHE_ENTRIES) {
970
+ evictOldestSequenceCacheEntries();
971
+ }
972
+ sequenceCache.set(eventsPath, {
973
+ size: stat.size,
974
+ mtimeMs: stat.mtimeMs,
975
+ seq: lastSeq,
976
+ lastAccessMs: Date.now(),
977
+ });
978
+ } catch (error) {
979
+ logInternalError("event-log.batch-cache-update", error, `eventsPath=${eventsPath}`);
980
+ }
981
+
982
+ for (const { item, fullEvent } of finalized) item.resolve(fullEvent);
983
+ }
984
+
985
+ function appendEventInsideLock(eventsPath: string, event: AppendTeamEvent): TeamEvent {
986
+ fs.mkdirSync(path.dirname(eventsPath), { recursive: true });
987
+ const baseMetadata = event.metadata;
988
+ // B7: use reserveSequence for atomic seq assignment across all paths.
989
+ const explicitSeq = baseMetadata?.seq;
990
+ const seq = explicitSeq ?? reserveSequence(eventsPath);
991
+ if (explicitSeq !== undefined) advanceSequenceCounter(eventsPath, seq);
992
+ let metadata: TeamEventMetadata = {
993
+ seq,
994
+ provenance: baseMetadata?.provenance ?? "team_runner",
995
+ ...(baseMetadata?.parentEventId ? { parentEventId: baseMetadata.parentEventId } : {}),
996
+ ...(baseMetadata?.attemptId ? { attemptId: baseMetadata.attemptId } : {}),
997
+ ...(baseMetadata?.branchId ? { branchId: baseMetadata.branchId } : {}),
998
+ ...(baseMetadata?.causationId ? { causationId: baseMetadata.causationId } : {}),
999
+ ...(baseMetadata?.correlationId ? { correlationId: baseMetadata.correlationId } : {}),
1000
+ ...(baseMetadata?.sessionIdentity ? { sessionIdentity: baseMetadata.sessionIdentity } : {}),
1001
+ ...(baseMetadata?.ownership ? { ownership: baseMetadata.ownership } : {}),
1002
+ ...(baseMetadata?.nudgeId ? { nudgeId: baseMetadata.nudgeId } : {}),
1003
+ ...(baseMetadata?.confidence ? { confidence: baseMetadata.confidence } : {}),
1004
+ };
1005
+ const fullEvent: TeamEvent = {
1006
+ time: new Date().toISOString(),
1007
+ ...event,
1008
+ metadata,
1009
+ };
1010
+ if (baseMetadata?.fingerprint || TERMINAL_EVENT_TYPES.has(fullEvent.type)) {
1011
+ metadata = {
1012
+ ...metadata,
1013
+ fingerprint: baseMetadata?.fingerprint ?? computeEventFingerprint(fullEvent),
1014
+ };
1015
+ fullEvent.metadata = metadata;
1016
+ }
1017
+ // H1 fix: handle overflow before appending.
1018
+ // 1. Terminal events must always be persisted regardless of size.
1019
+ // 2. Non-terminal events exceeding MAX_EVENTS_BYTES trigger immediate compact.
1020
+ // 3. After compact, if still over limit, rotate.
1021
+ const isTerminal = TERMINAL_EVENT_TYPES.has(fullEvent.type);
1022
+ let skippedDueToSize = false;
1023
+ if (!isTerminal && fs.existsSync(eventsPath)) {
1024
+ const stat = fs.statSync(eventsPath);
1025
+ if (stat.size > MAX_EVENTS_BYTES) {
1026
+ // Try immediate compact (not waiting for counter % 100).
1027
+ // Round 24 (BUG 1): we are INSIDE withEventLogLockSync. Use the unlocked
1028
+ // apply/rotate cores — the locked variants would deadlock (mkdir lock
1029
+ // is not re-entrant → 5s timeout → compaction/rotation never ran →
1030
+ // unbounded log growth → events silently dropped past 50MB).
1031
+ try {
1032
+ const prepared = prepareCompaction(eventsPath);
1033
+ if (prepared) applyCompactionUnlocked(eventsPath, prepared);
1034
+ } catch (error) {
1035
+ logInternalError("event-log.immediate-compact", error, `eventsPath=${eventsPath}`);
1036
+ }
1037
+ // Check if still too large after compact — if so, rotate
1038
+ if (fs.existsSync(eventsPath)) {
1039
+ const afterCompact = fs.statSync(eventsPath);
1040
+ if (afterCompact.size > MAX_EVENTS_BYTES) {
1041
+ rotateEventLogUnlocked(eventsPath);
1042
+ }
1043
+ }
1044
+ }
1045
+ }
1046
+ try {
1047
+ if (fs.existsSync(eventsPath) && fs.statSync(eventsPath).size > MAX_EVENTS_BYTES) {
1048
+ // Only reach here for non-terminal events that still overflow after compact+rotate.
1049
+ // Log and mark as not appended.
1050
+ logInternalError(
1051
+ "event-log.size-limit",
1052
+ new Error(`events file ${eventsPath} exceeds ${MAX_EVENTS_BYTES} bytes after compaction`),
1053
+ `eventsPath=${eventsPath}`,
1054
+ );
1055
+ skippedDueToSize = true;
1056
+ }
1057
+ } catch (error) {
1058
+ logInternalError("event-log.size-check", error, `eventsPath=${eventsPath}`);
1059
+ }
1060
+ // seq is already computed above via reserveSequence — reuse it for persist/cache.
1061
+ // const seq declaration removed (B7: seq is now computed before metadata object).
1062
+ if (!skippedDueToSize) {
1063
+ fs.appendFileSync(eventsPath, `${JSON.stringify(redactSecrets(fullEvent))}\n`, "utf-8");
1064
+ // F3a: skip data fsync for non-terminal events. We still call `persistSequence`
1065
+ // below, which means the .seq sidecar might briefly outpace the actual data
1066
+ // on disk in a crash, but the event-reconstructor (`event-reconstructor.ts`)
1067
+ // already handles inconsistent-tail recovery (appends after the sidecar's
1068
+ // claimed sequence are simply ignored on a lossless recovery scan). We trade
1069
+ // 1 ms of fsync per event on Windows for informational events; terminal
1070
+ // events keep the strict window between append and persistSequence.
1071
+ if (isTerminal) {
1072
+ const fd = fs.openSync(eventsPath, "r+");
1073
+ try {
1074
+ fs.fsyncSync(fd);
1075
+ } catch {
1076
+ // EPERM on Windows CI: best-effort flush
1077
+ } finally {
1078
+ fs.closeSync(fd);
1079
+ }
1080
+ }
1081
+ // FIX: Persist sequence AFTER the event append to prevent sequence reuse
1082
+ // on crash. Only update the sidecar when the event is definitively written.
1083
+ persistSequence(eventsPath, seq);
1084
+ // FIX: Update cache AFTER append so cache and log are consistent with each other.
1085
+ // This matches the async path behavior where cache is updated after the append.
1086
+ // If a crash occurs after append but before cache update, the .seq file is
1087
+ // already correct and nextSequence() will return the correct value on restart.
1088
+ try {
1089
+ const stat = fs.statSync(eventsPath);
1090
+ if (sequenceCache.size >= MAX_SEQUENCE_CACHE_ENTRIES) {
1091
+ evictOldestSequenceCacheEntries();
1092
+ }
1093
+ sequenceCache.set(eventsPath, {
1094
+ size: stat.size,
1095
+ mtimeMs: stat.mtimeMs,
1096
+ seq,
1097
+ lastAccessMs: Date.now(),
1098
+ });
1099
+ } catch (error) {
1100
+ logInternalError("event-log.persist-sequence", error, `eventsPath=${eventsPath}`);
1101
+ }
1102
+ }
1103
+ appendCounter++;
1104
+ if (appendCounter % 100 === 0 && needsRotation(eventsPath)) {
1105
+ // Round 24 (BUG 1): we are INSIDE withEventLogLockSync here (called via
1106
+ // appendEventInsideLock). The mkdir lock is NOT re-entrant, so calling the
1107
+ // locked compactEventLog would deadlock → 5s timeout → compaction never
1108
+ // ran → unbounded log growth → events silently dropped past 50MB. Use the
1109
+ // unlocked apply path instead (lock already held).
1110
+ try {
1111
+ const prepared = prepareCompaction(eventsPath);
1112
+ if (prepared) applyCompactionUnlocked(eventsPath, prepared);
1113
+ } catch (error) {
1114
+ logInternalError("event-log.rotation", error, `eventsPath=${eventsPath}`);
1115
+ }
1116
+ }
1117
+ try {
1118
+ emitFromTeamEvent(fullEvent);
1119
+ } catch (error) {
1120
+ logInternalError("event-log.emit", error);
1121
+ }
1122
+ return fullEvent;
1123
+ }
1124
+
1125
+ // 2.2 — Buffered append API. Caller queues events and they are flushed under
1126
+ // a single `withEventLogLockSync` acquire after `bufferingMs` ms. The seq
1127
+ // invariant is preserved because the flush still goes through
1128
+ // appendEventInsideLock sequentially.
1129
+ //
1130
+ // Caveat: events still in the buffer at process kill -9 are lost. Callers
1131
+ // for whom durability is critical (lifecycle terminal events) should keep
1132
+ // using `appendEvent`. Used opportunistically for high-frequency events
1133
+ // like `task.progress` once integration tests cover crash semantics.
1134
+ interface BufferedAppend {
1135
+ event: AppendTeamEvent;
1136
+ resolve: (event: TeamEvent) => void;
1137
+ reject: (error: unknown) => void;
1138
+ }
1139
+ const bufferedQueues = new Map<string, BufferedAppend[]>();
1140
+ const bufferedTimers = new Map<string, ReturnType<typeof setTimeout>>();
1141
+ const DEFAULT_BUFFER_MS = 20;
1142
+
1143
+ export function appendEventBuffered(eventsPath: string, event: AppendTeamEvent, bufferMs = DEFAULT_BUFFER_MS): Promise<TeamEvent> {
1144
+ // FIX: Terminal events must bypass buffer to ensure they're written immediately.
1145
+ // Previously, terminal events like task.failed could be lost on process crash.
1146
+ if (TERMINAL_EVENT_TYPES.has(event.type)) {
1147
+ // FIX: Flush any pending buffered events before writing terminal event
1148
+ // to ensure durability of events that precede the terminal event in the
1149
+ // same flush cycle. Without this, a kill -9 after terminal event write
1150
+ // but before buffer flush would lose the buffered events.
1151
+ // C-01: Await the flush before writing the terminal event. Previously the
1152
+ // flush was fire-and-forget, which worked when withEventLogLockAsync was a
1153
+ // pure promise-chain (completed as a microtask before the caller resumed).
1154
+ // Now that withEventLogLockAsync acquires a cross-process mkdir lock (.alock),
1155
+ // the flush needs multiple event-loop iterations. Without awaiting, the
1156
+ // terminal event would be written before the buffered events.
1157
+ const flushPromise = bufferedQueues.has(eventsPath) ? flushOneEventLogBuffer(eventsPath).catch(() => undefined) : Promise.resolve();
1158
+ return flushPromise.then(() => appendEvent(eventsPath, event));
1159
+ }
1160
+ return new Promise<TeamEvent>((resolve, reject) => {
1161
+ const queue = bufferedQueues.get(eventsPath) ?? [];
1162
+ queue.push({ event, resolve, reject });
1163
+ bufferedQueues.set(eventsPath, queue);
1164
+ if (!bufferedTimers.has(eventsPath)) {
1165
+ // Wrap flush in async IIFE so the returned Promise is awaited (avoids
1166
+ // "floating promise" warnings under --test-force-exit and prevents
1167
+ // the timer from being treated as done before the flush actually
1168
+ // completes its async work).
1169
+ const timer = setTimeout(() => {
1170
+ flushOneEventLogBuffer(eventsPath).catch((error) => {
1171
+ logInternalError("event-log.buffered-flush", error, `eventsPath=${eventsPath}`);
1172
+ });
1173
+ }, bufferMs);
1174
+ bufferedTimers.set(eventsPath, timer);
1175
+ timer.unref();
1176
+ }
1177
+ });
1178
+ }
1179
+
1180
+ async function flushOneEventLogBuffer(eventsPath: string): Promise<void> {
1181
+ const queue = bufferedQueues.get(eventsPath);
1182
+ bufferedQueues.delete(eventsPath);
1183
+ const timer = bufferedTimers.get(eventsPath);
1184
+ // Timer is cleared in the finally block to ensure cleanup happens even on error
1185
+ try {
1186
+ if (!queue || queue.length === 0) return;
1187
+
1188
+ // FIX (Round 14, H3): When truncating the queue, explicitly reject the
1189
+ // dropped entries' promises. Previously `queue.splice()` silently
1190
+ // discarded the oldest items, and their associated Promises were never
1191
+ // resolved or rejected — causing callers to await forever and leaking
1192
+ // memory. We now reject with a clear error so callers can fall back.
1193
+ if (queue.length > 1000) {
1194
+ const dropped = queue.splice(0, queue.length - 500);
1195
+ overflowCounter++;
1196
+ // FIX: Include first/last dropped event type and sequence number in error
1197
+ // message to make debugging easier when events are dropped.
1198
+ const firstDroppedMeta = dropped[0]?.event.metadata;
1199
+ const lastDroppedMeta = dropped[dropped.length - 1]?.event.metadata;
1200
+ logInternalError(
1201
+ "event-log.buffer-overflow",
1202
+ new Error(
1203
+ `Buffer overflow #${overflowCounter}: Dropped ${dropped.length} events: first seq=${firstDroppedMeta?.seq} type=${dropped[0]?.event.type}, last seq=${lastDroppedMeta?.seq} type=${dropped[dropped.length - 1]?.event.type}`,
1204
+ ),
1205
+ `${eventsPath}: ${queue.length + dropped.length} entries > 1000 cap`,
1206
+ );
1207
+ for (const item of dropped) {
1208
+ item.reject(
1209
+ new Error(
1210
+ `Event log buffer overflow: ${queue.length + dropped.length} entries > 1000 cap; oldest ${dropped.length} dropped to keep memory bounded; first dropped seq=${firstDroppedMeta?.seq} type=${dropped[0]?.event.type}`,
1211
+ ),
1212
+ );
1213
+ }
1214
+ }
1215
+
1216
+ // FIX (Issue 2): Use async lock instead of withEventLogLockSync to avoid
1217
+ // blocking the event loop. The sync lock uses sleepSync which blocks for
1218
+ // up to 5s and prevents AbortSignal handlers from firing.
1219
+ // FIX (P0 follow-up): Batch the file write + fsync + persistSequence across
1220
+ // the whole queue. Previously each event triggered its own fsyncSync,
1221
+ // turning 100 buffered events into 100 fsyncs (~3s on tmpfs). Now we do
1222
+ // 1 appendFileSync + 1 fsync + 1 persistSequence for the whole batch.
1223
+ await withEventLogLockAsync(eventsPath, async () => {
1224
+ await appendEventBatchInsideLock(eventsPath, queue);
1225
+ });
1226
+ } catch (error) {
1227
+ // Lock acquire failed — fail every queued item so callers can fall back.
1228
+ if (queue) for (const item of queue) item.reject(error);
1229
+ } finally {
1230
+ bufferedTimers.delete(eventsPath);
1231
+ }
1232
+ }
1233
+
1234
+ /** Asynchronously flush every queued buffered event across all paths. */
1235
+ export async function flushEventLogBuffer(): Promise<void> {
1236
+ for (const eventsPath of [...bufferedQueues.keys()]) await flushOneEventLogBuffer(eventsPath);
1237
+ }
1238
+
1239
+ /**
1240
+ * EL-2: Synchronously flush every queued buffered event across all paths.
1241
+ * Used by the `exit` / `uncaughtException` / `SIGTERM` / `SIGINT` handlers,
1242
+ * which CANNOT await async work (process is terminating). The async
1243
+ * flushEventLogBuffer() previously called from these handlers created
1244
+ * floating promises that never resolved, and the process exited before
1245
+ * any buffered events were written — losing all events buffered via
1246
+ * appendEventBuffered (task.progress etc.). This sync variant writes the
1247
+ * buffered batches using the sync event-log lock + appendFileSync + fsync
1248
+ * + persistSequence, recovering buffered events before termination.
1249
+ * In-flight asyncQueues (appendEventAsync writes already dispatched to
1250
+ * the thread pool) remain best-effort and cannot be awaited on `exit` —
1251
+ * we clear them to drop stale state. SIGKILL cannot be intercepted.
1252
+ */
1253
+ export function flushBufferedQueuesSync(): void {
1254
+ for (const eventsPath of [...bufferedQueues.keys()]) {
1255
+ const queue = bufferedQueues.get(eventsPath);
1256
+ bufferedQueues.delete(eventsPath);
1257
+ if (!queue || queue.length === 0) continue;
1258
+ try {
1259
+ withEventLogLockSync(eventsPath, () => {
1260
+ // appendEventBatchInsideLock is declared async but its body is fully
1261
+ // synchronous (fs.appendFileSync + fs.fsyncSync + persistSequence,
1262
+ // no awaits). Invoking without await runs the body synchronously and
1263
+ // returns a resolved Promise that we discard.
1264
+ void appendEventBatchInsideLock(eventsPath, queue);
1265
+ });
1266
+ } catch (error) {
1267
+ logInternalError("event-log.sync-flush", error, eventsPath);
1268
+ }
1269
+ }
1270
+ for (const eventsPath of [...bufferedTimers.keys()]) bufferedTimers.delete(eventsPath);
1271
+ }
1272
+
1273
+ /**
1274
+ * Schedule an async event append without waiting for the result.
1275
+ * Uses the non-blocking async queue to avoid blocking the event loop.
1276
+ * Use only for events whose return value is ignored (high-frequency `task.progress`).
1277
+ * Errors are logged via logInternalError.
1278
+ */
1279
+ export function appendEventFireAndForget(eventsPath: string, event: AppendTeamEvent): void {
1280
+ appendEventAsync(eventsPath, event).catch((error) => logInternalError("event-log.fire-and-forget", error, eventsPath));
1281
+ }
1282
+
1283
+ // Auto-flush on process exit so buffered events do not silently leak.
1284
+ // Defense-in-depth: SIGTERM/SIGINT use setImmediate so the handler returns
1285
+ // immediately and the main thread is not blocked by sync I/O.
1286
+ // FIX (P0 follow-up): Only call flushEventLogBuffer() / drainAsyncQueues() if
1287
+ // there is actually pending work. Calling them unconditionally creates a new
1288
+ // floating Promise that the test runner detects under --test-force-exit,
1289
+ // failing tests with "Promise resolution is still pending but the event loop
1290
+ // has already resolved" even when the test body completed cleanly.
1291
+ process.on("exit", () => {
1292
+ // EL-2: synchronously flush buffered events before the process terminates.
1293
+ // `exit` is sync-only and cannot await async work; the previous async
1294
+ // flushEventLogBuffer()/drainAsyncQueues() created floating promises that
1295
+ // never resolved, and the process exited before any buffered events were
1296
+ // written. flushBufferedQueuesSync uses the sync lock + appendFileSync +
1297
+ // fsync + persistSequence to recover buffered events.
1298
+ flushBufferedQueuesSync();
1299
+ asyncQueues.clear();
1300
+ });
1301
+
1302
+ // FIX (P0 follow-up): Drain buffered events on `beforeExit` (async-aware).
1303
+ // The `exit` handler above is sync-only and cannot await the flush, leaving
1304
+ // pending promises that the test runner detects under --test-force-exit as
1305
+ // "Promise resolution is still pending but the event loop has already
1306
+ // resolved". `beforeExit` fires when the event loop drains naturally and
1307
+ // supports async handlers, so we can await the flush here and let the loop
1308
+ // drain cleanly before process.exit() is called.
1309
+ process.on("beforeExit", async () => {
1310
+ if (bufferedQueues.size > 0) {
1311
+ try {
1312
+ await flushEventLogBuffer();
1313
+ } catch {
1314
+ /* best-effort */
1315
+ }
1316
+ }
1317
+ if (asyncQueues.size > 0) {
1318
+ try {
1319
+ await drainAsyncQueues();
1320
+ } catch {
1321
+ /* best-effort */
1322
+ }
1323
+ }
1324
+ });
1325
+ process.on("SIGTERM", () => setImmediate(() => flushBufferedQueuesSync()));
1326
+ process.on("SIGINT", () => setImmediate(() => flushBufferedQueuesSync()));
1327
+ // FIX (Issue 1): Handle uncaught exceptions to flush buffered events before
1328
+ // the process terminates. The async queues use promise chains that will be
1329
+ // abandoned on crash; clearing the map prevents memory leaks and stale state.
1330
+ // Note: SIGKILL (kill -9) cannot be intercepted and is not handled.
1331
+ process.on("uncaughtException", (error) => {
1332
+ // EL-2: synchronously flush buffered events before re-throwing (which
1333
+ // terminates the process). The previous async flushEventLogBuffer() +
1334
+ // drainAsyncQueues() couldn't complete before process exit; use the sync
1335
+ // variant to recover buffered events.
1336
+ flushBufferedQueuesSync();
1337
+ asyncQueues.clear();
1338
+ // Re-throw to preserve default uncaught exception behavior (process exit)
1339
+ throw error;
1340
+ });
1341
+
1342
+ export function readEvents(eventsPath: string): TeamEvent[] {
1343
+ if (!fs.existsSync(eventsPath)) return [];
1344
+ return fs
1345
+ .readFileSync(eventsPath, "utf-8")
1346
+ .split("\n")
1347
+ .map((line) => line.trim())
1348
+ .filter(Boolean)
1349
+ .flatMap((line) => {
1350
+ try {
1351
+ return [JSON.parse(line) as TeamEvent];
1352
+ } catch {
1353
+ return [];
1354
+ }
1355
+ });
1356
+ }
1357
+
1358
+ export interface EventCursorOptions {
1359
+ sinceSeq?: number;
1360
+ limit?: number;
1361
+ fromByteOffset?: number;
1362
+ /** R-03: generation the caller captured on its previous read. When set, a
1363
+ * mismatch with the live generation signals the file was rotated/truncated
1364
+ * and the byte offset is stale — the cursor resets to 0 so the new file is
1365
+ * re-read from its start instead of missing post-rotation events. */
1366
+ generation?: number;
1367
+ }
1368
+
1369
+ export interface EventCursorResult {
1370
+ events: TeamEvent[];
1371
+ nextSeq: number;
1372
+ total: number;
1373
+ nextByteOffset?: number;
1374
+ /** R-03: live generation of the events file at read time. Callers doing
1375
+ * streaming byte-offset reads should echo this back as `generation` on the
1376
+ * next call so rotation is detected and the cursor resets. */
1377
+ generation?: number;
1378
+ }
1379
+
1380
+ function positiveInteger(value: number | undefined): number | undefined {
1381
+ return value !== undefined && Number.isInteger(value) && value >= 0 ? value : undefined;
1382
+ }
1383
+
1384
+ export function readEventsCursor(eventsPath: string, options: EventCursorOptions = {}): EventCursorResult {
1385
+ // Incremental byte-offset path: read only new bytes since last known offset
1386
+ if (options.fromByteOffset !== undefined) {
1387
+ // R-03: detect file rotation/truncation via the generation sidecar BEFORE
1388
+ // reusing the byte offset. If the file was rotated since the caller last
1389
+ // read, it was truncated to empty (pre-rotation content archived to
1390
+ // `<eventsPath>.<ts>.archive.jsonl`) and is growing again from 0 — the
1391
+ // caller's offset now points past EOF, so post-rotation events would be
1392
+ // silently missed. Reset to offset 0 to re-read the current file from its
1393
+ // start. Re-reading from 0 re-delivers no previously-returned events:
1394
+ // those live in the archive, not the (now fresh) current file.
1395
+ const liveGen = currentGeneration(eventsPath);
1396
+ const staleCursor = options.generation !== undefined && options.generation !== liveGen;
1397
+ const byteOffset = staleCursor ? 0 : (positiveInteger(options.fromByteOffset) ?? 0);
1398
+ const initialState: IncrementalReadState = { byteOffset, lineCount: 0 };
1399
+ const { items, state: newState, eof } = readJsonlSince<TeamEvent>(eventsPath, initialState);
1400
+ const sinceSeq = positiveInteger(options.sinceSeq) ?? 0;
1401
+ const filtered = items.filter((event) => (event.metadata?.seq ?? 0) > sinceSeq);
1402
+ const limit = positiveInteger(options.limit);
1403
+ const events = limit !== undefined ? filtered.slice(0, limit) : filtered;
1404
+ const returnedMaxSeq = events.reduce((max, event) => Math.max(max, event.metadata?.seq ?? 0), sinceSeq);
1405
+ return {
1406
+ events,
1407
+ nextSeq: returnedMaxSeq,
1408
+ total: filtered.length,
1409
+ nextByteOffset: newState.byteOffset,
1410
+ generation: liveGen,
1411
+ };
1412
+ }
1413
+
1414
+ // FIND-05 default path: byte-level tail read (last 4MB) instead of
1415
+ // full-file read. Bounds CPU to O(tail bytes) instead of O(total
1416
+ // events). The legacy readEvents() full parse path is preserved for
1417
+ // callers that explicitly need the full history (e.g. tests that
1418
+ // assert exact contents) and as a small-file fallback.
1419
+ //
1420
+ // The 5000-event tail cap and the "event-log.cursor-full-read"
1421
+ // warning are preserved. A separate cursor-tail-truncated warning is
1422
+ // emitted whenever the file exceeds the 4MB tail budget, signalling
1423
+ // that a prefix was dropped and callers should pass fromByteOffset for
1424
+ // streaming reads.
1425
+ const TAIL_BYTES = 4 * 1024 * 1024; // 4 MB
1426
+ const TAIL_EVENT_CAP = 5000;
1427
+ const sinceSeq = positiveInteger(options.sinceSeq) ?? 0;
1428
+ const limit = positiveInteger(options.limit);
1429
+
1430
+ const tail = readJsonlTail<TeamEvent>(eventsPath, TAIL_BYTES);
1431
+ let all = tail.items;
1432
+ if (tail.truncated) {
1433
+ logInternalError("event-log.cursor-tail-truncated", {
1434
+ eventsPath,
1435
+ returned: all.length,
1436
+ tailBytes: TAIL_BYTES,
1437
+ });
1438
+ }
1439
+ if (all.length > TAIL_EVENT_CAP) {
1440
+ logInternalError(
1441
+ "event-log.cursor-full-read",
1442
+ new Error(`readEventsCursor tail read dropped events from a larger log; pass fromByteOffset for incremental reads`),
1443
+ `eventsPath=${eventsPath}`,
1444
+ );
1445
+ all = all.slice(-TAIL_EVENT_CAP);
1446
+ }
1447
+ const filtered = all.filter((event) => (event.metadata?.seq ?? 0) > sinceSeq);
1448
+ const events = limit !== undefined ? filtered.slice(0, limit) : filtered;
1449
+ const returnedMaxSeq = events.reduce((max, event) => Math.max(max, event.metadata?.seq ?? 0), sinceSeq);
1450
+ return { events, nextSeq: returnedMaxSeq, total: filtered.length };
1451
+ }
1452
+
1453
+ export function dedupeTerminalEvents(events: TeamEvent[]): TeamEvent[] {
1454
+ const seen = new Set<string>();
1455
+ const output: TeamEvent[] = [];
1456
+ for (const event of events) {
1457
+ const fingerprint = event.metadata?.fingerprint;
1458
+ if (fingerprint && TERMINAL_EVENT_TYPES.has(event.type)) {
1459
+ if (seen.has(fingerprint)) continue;
1460
+ seen.add(fingerprint);
1461
+ }
1462
+ output.push(event);
1463
+ }
1464
+ return output;
1465
+ }