pi-crew 0.2.2 → 0.2.4

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 (354) hide show
  1. package/AGENTS.md +57 -32
  2. package/CHANGELOG.md +466 -413
  3. package/LICENSE +21 -21
  4. package/NOTICE.md +16 -16
  5. package/README.md +323 -323
  6. package/docs/FEATURE_INTAKE.md +126 -0
  7. package/docs/HARNESS.md +86 -0
  8. package/docs/HARNESS_BACKLOG.md +41 -0
  9. package/docs/TEST_MATRIX.md +49 -0
  10. package/docs/actions-reference.md +595 -595
  11. package/docs/architecture.md +180 -180
  12. package/docs/code-review-2026-05-11.md +592 -0
  13. package/docs/commands-reference.md +347 -347
  14. package/docs/comparison-pi-subagents-vs-pi-crew.md +303 -0
  15. package/docs/decisions/0001-durable-state.md +41 -0
  16. package/docs/decisions/0002-child-process-for-async.md +42 -0
  17. package/docs/decisions/0003-depth-guard.md +36 -0
  18. package/docs/decisions/0004-execfile-over-exec.md +34 -0
  19. package/docs/decisions/0005-no-parameter-properties.md +49 -0
  20. package/docs/decisions/0006-publish-bundled-esm.md +63 -0
  21. package/docs/decisions/0007-active-run-binary-index.md +54 -0
  22. package/docs/decisions/0008-child-pi-warm-pool.md +61 -0
  23. package/docs/decisions/README.md +23 -0
  24. package/docs/followup-plan-2026-05-12.md +463 -0
  25. package/docs/followup-review-2026-05-12.md +297 -0
  26. package/docs/followup-review-round3-2026-05-12.md +342 -0
  27. package/docs/followup-review-round4-2026-05-13.md +107 -0
  28. package/docs/implementation-plan-top3.md +333 -0
  29. package/docs/live-mailbox-runtime.md +36 -36
  30. package/docs/next-upgrade-roadmap.md +808 -808
  31. package/docs/oh-my-pi-research.md +509 -0
  32. package/docs/perf/baseline-2026-05.md +113 -0
  33. package/docs/perf/final-report-2026-05.md +206 -0
  34. package/docs/perf/sprint-1-report.md +71 -0
  35. package/docs/perf/sprint-2-report.md +81 -0
  36. package/docs/perf/sprint-2.5-report.md +53 -0
  37. package/docs/perf/sprint-3-report.md +36 -0
  38. package/docs/perf/sprint-4-report.md +47 -0
  39. package/docs/perf/sprint-5-report.md +51 -0
  40. package/docs/perf/sprint-6-report.md +94 -0
  41. package/docs/perf/sprint-7-report.md +74 -0
  42. package/docs/perf/upgrade-plan-2026-05.md +147 -0
  43. package/docs/pi-subagents3-deep-analysis.md +508 -0
  44. package/docs/product/README.md +31 -0
  45. package/docs/product/platform.md +27 -0
  46. package/docs/product/runtime-safety.md +37 -0
  47. package/docs/product/team-run.md +39 -0
  48. package/docs/product/team-tool.md +37 -0
  49. package/docs/publishing.md +65 -65
  50. package/docs/resource-formats.md +134 -134
  51. package/docs/runtime-analysis-child-vs-live.md +171 -0
  52. package/docs/runtime-flow.md +148 -148
  53. package/docs/runtime-migration-in-process-analysis.md +250 -0
  54. package/docs/stories/README.md +30 -0
  55. package/docs/stories/backlog.md +36 -0
  56. package/docs/templates/decision.md +27 -0
  57. package/docs/templates/story.md +44 -0
  58. package/docs/templates/validation-report.md +32 -0
  59. package/docs/usage.md +238 -238
  60. package/index.ts +7 -6
  61. package/install.mjs +65 -65
  62. package/package.json +107 -99
  63. package/schema.json +222 -222
  64. package/skills/child-pi-spawning/SKILL.md +213 -0
  65. package/skills/context-artifact-hygiene/SKILL.md +32 -0
  66. package/skills/event-log-tracing/SKILL.md +299 -0
  67. package/skills/git-master/SKILL.md +225 -24
  68. package/skills/live-agent-lifecycle/SKILL.md +192 -0
  69. package/skills/mailbox-interactive/SKILL.md +300 -19
  70. package/skills/model-routing-context/SKILL.md +94 -0
  71. package/skills/multi-perspective-review/SKILL.md +88 -0
  72. package/skills/read-only-explorer/SKILL.md +250 -26
  73. package/skills/safe-bash/SKILL.md +307 -21
  74. package/skills/verification-before-done/SKILL.md +11 -2
  75. package/skills/widget-rendering/SKILL.md +258 -0
  76. package/skills/workspace-isolation/SKILL.md +202 -0
  77. package/skills/worktree-isolation/SKILL.md +202 -18
  78. package/src/adapters/claude-adapter.ts +25 -25
  79. package/src/adapters/codex-adapter.ts +21 -21
  80. package/src/adapters/cursor-adapter.ts +17 -17
  81. package/src/adapters/export-util.ts +137 -137
  82. package/src/adapters/index.ts +15 -15
  83. package/src/adapters/registry.ts +18 -18
  84. package/src/adapters/types.ts +23 -23
  85. package/src/agents/agent-config.ts +38 -38
  86. package/src/agents/agent-serializer.ts +38 -38
  87. package/src/agents/discover-agents.ts +121 -118
  88. package/src/config/config.ts +740 -858
  89. package/src/config/defaults.ts +96 -96
  90. package/src/config/drift-detector.ts +211 -211
  91. package/src/config/markers.ts +327 -327
  92. package/src/config/resilient-parser.ts +109 -108
  93. package/src/config/suggestions.ts +74 -74
  94. package/src/config/types.ts +199 -0
  95. package/src/extension/async-notifier.ts +123 -89
  96. package/src/extension/autonomous-policy.ts +169 -169
  97. package/src/extension/cross-extension-rpc.ts +104 -103
  98. package/src/extension/help.ts +47 -47
  99. package/src/extension/import-index.ts +69 -69
  100. package/src/extension/management.ts +395 -382
  101. package/src/extension/notification-router.ts +116 -116
  102. package/src/extension/notification-sink.ts +51 -51
  103. package/src/extension/project-init.ts +168 -168
  104. package/src/extension/register.ts +859 -668
  105. package/src/extension/registration/artifact-cleanup.ts +15 -15
  106. package/src/extension/registration/command-utils.ts +54 -54
  107. package/src/extension/registration/commands.ts +559 -452
  108. package/src/extension/registration/compaction-guard.ts +125 -125
  109. package/src/extension/registration/subagent-helpers.ts +102 -102
  110. package/src/extension/registration/subagent-tools.ts +220 -158
  111. package/src/extension/registration/team-tool.ts +159 -98
  112. package/src/extension/registration/viewers.ts +29 -0
  113. package/src/extension/result-watcher.ts +128 -128
  114. package/src/extension/run-bundle-schema.ts +89 -89
  115. package/src/extension/run-export.ts +73 -73
  116. package/src/extension/run-import.ts +84 -84
  117. package/src/extension/run-index.ts +94 -94
  118. package/src/extension/run-maintenance.ts +142 -142
  119. package/src/extension/session-summary.ts +8 -8
  120. package/src/extension/team-manager-command.ts +96 -95
  121. package/src/extension/team-recommendation.ts +188 -188
  122. package/src/extension/team-tool/api.ts +5 -2
  123. package/src/extension/team-tool/cancel.ts +224 -209
  124. package/src/extension/team-tool/config-patch.ts +36 -36
  125. package/src/extension/team-tool/context.ts +60 -60
  126. package/src/extension/team-tool/doctor.ts +242 -242
  127. package/src/extension/team-tool/handle-settings.ts +421 -195
  128. package/src/extension/team-tool/inspect.ts +41 -41
  129. package/src/extension/team-tool/lifecycle-actions.ts +139 -139
  130. package/src/extension/team-tool/parallel-dispatch.ts +156 -156
  131. package/src/extension/team-tool/plan.ts +19 -19
  132. package/src/extension/team-tool/respond.ts +112 -111
  133. package/src/extension/team-tool/run.ts +246 -228
  134. package/src/extension/team-tool/status.ts +110 -110
  135. package/src/extension/team-tool-types.ts +13 -13
  136. package/src/extension/team-tool.ts +16 -4
  137. package/src/extension/tool-result.ts +16 -16
  138. package/src/extension/validate-resources.ts +77 -77
  139. package/src/hooks/registry.ts +61 -61
  140. package/src/hooks/types.ts +40 -40
  141. package/src/i18n.ts +184 -184
  142. package/src/observability/correlation.ts +35 -35
  143. package/src/observability/event-to-metric.ts +68 -68
  144. package/src/observability/exporters/adapter.ts +30 -30
  145. package/src/observability/exporters/otlp-exporter.ts +106 -92
  146. package/src/observability/exporters/prometheus-exporter.ts +54 -54
  147. package/src/observability/metric-registry.ts +87 -87
  148. package/src/observability/metric-retention.ts +54 -54
  149. package/src/observability/metric-sink.ts +81 -56
  150. package/src/observability/metrics-primitives.ts +167 -167
  151. package/src/prompt/prompt-runtime.ts +72 -72
  152. package/src/runtime/adaptive-plan.ts +338 -0
  153. package/src/runtime/agent-control.ts +169 -169
  154. package/src/runtime/agent-memory.ts +72 -72
  155. package/src/runtime/agent-observability.ts +114 -114
  156. package/src/runtime/async-marker.ts +26 -26
  157. package/src/runtime/async-runner.ts +153 -79
  158. package/src/runtime/attention-events.ts +28 -28
  159. package/src/runtime/auto-resume.ts +100 -100
  160. package/src/runtime/background-runner.ts +122 -88
  161. package/src/runtime/cancellation.ts +61 -61
  162. package/src/runtime/capability-inventory.ts +116 -116
  163. package/src/runtime/child-pi-pool.ts +68 -0
  164. package/src/runtime/child-pi.ts +541 -463
  165. package/src/runtime/code-summary.ts +247 -247
  166. package/src/runtime/compaction-summary.ts +271 -271
  167. package/src/runtime/concurrency.ts +58 -58
  168. package/src/runtime/crash-recovery.ts +317 -301
  169. package/src/runtime/crew-agent-records.ts +379 -281
  170. package/src/runtime/crew-agent-runtime.ts +60 -60
  171. package/src/runtime/cross-extension-rpc.ts +72 -0
  172. package/src/runtime/custom-tools/irc-tool.ts +201 -201
  173. package/src/runtime/custom-tools/submit-result-tool.ts +90 -90
  174. package/src/runtime/deadletter.ts +47 -47
  175. package/src/runtime/delivery-coordinator.ts +176 -176
  176. package/src/runtime/delta-conflict.ts +360 -360
  177. package/src/runtime/diagnostic-export.ts +102 -102
  178. package/src/runtime/direct-run.ts +35 -35
  179. package/src/runtime/effectiveness.ts +82 -81
  180. package/src/runtime/errors/crew-errors.ts +166 -0
  181. package/src/runtime/event-stream-bridge.ts +92 -92
  182. package/src/runtime/foreground-control.ts +82 -82
  183. package/src/runtime/green-contract.ts +46 -46
  184. package/src/runtime/group-join.ts +234 -106
  185. package/src/runtime/heartbeat-watcher.ts +145 -124
  186. package/src/runtime/iteration-hooks.ts +267 -264
  187. package/src/runtime/live-agent-control.ts +88 -88
  188. package/src/runtime/live-agent-manager.ts +377 -179
  189. package/src/runtime/live-control-realtime.ts +36 -36
  190. package/src/runtime/live-session-runtime.ts +676 -599
  191. package/src/runtime/loop-gates.ts +129 -129
  192. package/src/runtime/manifest-cache.ts +263 -263
  193. package/src/runtime/mcp-proxy.ts +113 -113
  194. package/src/runtime/metric-parser.ts +40 -40
  195. package/src/runtime/model-fallback.ts +282 -274
  196. package/src/runtime/model-resolver.ts +118 -0
  197. package/src/runtime/output-validator.ts +187 -187
  198. package/src/runtime/overflow-recovery.ts +175 -175
  199. package/src/runtime/parallel-research.ts +44 -44
  200. package/src/runtime/parallel-utils.ts +156 -156
  201. package/src/runtime/parent-guard.ts +80 -80
  202. package/src/runtime/phase-progress.ts +217 -217
  203. package/src/runtime/pi-args.ts +165 -165
  204. package/src/runtime/pi-json-output.ts +111 -111
  205. package/src/runtime/pi-spawn.ts +167 -167
  206. package/src/runtime/policy-engine.ts +79 -79
  207. package/src/runtime/post-checks.ts +125 -122
  208. package/src/runtime/post-exit-stdio-guard.ts +86 -86
  209. package/src/runtime/process-status.ts +97 -73
  210. package/src/runtime/progress-event-coalescer.ts +43 -43
  211. package/src/runtime/recovery-recipes.ts +74 -74
  212. package/src/runtime/retry-executor.ts +81 -81
  213. package/src/runtime/role-permission.ts +39 -39
  214. package/src/runtime/run-tracker.ts +99 -0
  215. package/src/runtime/runtime-policy.ts +21 -0
  216. package/src/runtime/runtime-resolver.ts +94 -90
  217. package/src/runtime/scheduler.ts +294 -0
  218. package/src/runtime/semaphore.ts +131 -131
  219. package/src/runtime/sensitive-paths.ts +92 -92
  220. package/src/runtime/session-usage.ts +79 -79
  221. package/src/runtime/settings-store.ts +103 -0
  222. package/src/runtime/sidechain-output.ts +29 -29
  223. package/src/runtime/skill-instructions.ts +222 -222
  224. package/src/runtime/stale-reconciler.ts +198 -189
  225. package/src/runtime/streaming-output.ts +47 -0
  226. package/src/runtime/subagent-manager.ts +404 -395
  227. package/src/runtime/subprocess-tool-registry.ts +67 -67
  228. package/src/runtime/task-display.ts +38 -38
  229. package/src/runtime/task-graph-scheduler.ts +122 -122
  230. package/src/runtime/task-graph.ts +207 -207
  231. package/src/runtime/task-output-context.ts +177 -177
  232. package/src/runtime/task-packet.ts +93 -93
  233. package/src/runtime/task-quality.ts +207 -207
  234. package/src/runtime/task-runner/capabilities.ts +78 -78
  235. package/src/runtime/task-runner/live-executor.ts +131 -113
  236. package/src/runtime/task-runner/progress.ts +119 -119
  237. package/src/runtime/task-runner/prompt-builder.ts +139 -139
  238. package/src/runtime/task-runner/prompt-pipeline.ts +64 -64
  239. package/src/runtime/task-runner/result-utils.ts +14 -14
  240. package/src/runtime/task-runner/run-projection.ts +103 -103
  241. package/src/runtime/task-runner/state-helpers.ts +22 -22
  242. package/src/runtime/task-runner.ts +469 -458
  243. package/src/runtime/team-runner.ts +693 -945
  244. package/src/runtime/usage-tracker.ts +71 -0
  245. package/src/runtime/worker-heartbeat.ts +21 -21
  246. package/src/runtime/worker-startup.ts +57 -57
  247. package/src/runtime/workflow-state.ts +187 -187
  248. package/src/runtime/yield-handler.ts +190 -189
  249. package/src/schema/config-schema.ts +172 -168
  250. package/src/schema/team-tool-schema.ts +126 -125
  251. package/src/schema/validation-types.ts +151 -148
  252. package/src/skills/discover-skills.ts +67 -67
  253. package/src/skills/skill-templates.ts +374 -374
  254. package/src/state/active-run-registry.ts +227 -191
  255. package/src/state/artifact-store.ts +130 -129
  256. package/src/state/atomic-write.ts +262 -178
  257. package/src/state/blob-store.ts +116 -116
  258. package/src/state/contracts.ts +111 -111
  259. package/src/state/event-log-rotation.ts +161 -158
  260. package/src/state/event-log.ts +383 -240
  261. package/src/state/event-reconstructor.ts +217 -217
  262. package/src/state/jsonl-writer.ts +82 -82
  263. package/src/state/locks.ts +146 -148
  264. package/src/state/mailbox.ts +446 -405
  265. package/src/state/state-store.ts +364 -351
  266. package/src/state/task-claims.ts +44 -44
  267. package/src/state/types.ts +285 -285
  268. package/src/state/usage.ts +29 -29
  269. package/src/subagents/async-entry.ts +1 -1
  270. package/src/subagents/index.ts +3 -3
  271. package/src/subagents/live/control.ts +1 -1
  272. package/src/subagents/live/manager.ts +1 -1
  273. package/src/subagents/live/realtime.ts +1 -1
  274. package/src/subagents/live/session-runtime.ts +1 -1
  275. package/src/subagents/manager.ts +1 -1
  276. package/src/subagents/spawn.ts +1 -1
  277. package/src/teams/discover-teams.ts +116 -116
  278. package/src/teams/team-config.ts +27 -27
  279. package/src/teams/team-serializer.ts +38 -38
  280. package/src/types/diff.d.ts +18 -18
  281. package/src/ui/agent-management-overlay.ts +144 -144
  282. package/src/ui/crew-widget.ts +487 -370
  283. package/src/ui/dashboard-panes/agents-pane.ts +109 -28
  284. package/src/ui/dashboard-panes/cancellation-pane.ts +42 -42
  285. package/src/ui/dashboard-panes/capability-pane.ts +59 -59
  286. package/src/ui/dashboard-panes/health-pane.ts +30 -30
  287. package/src/ui/dashboard-panes/mailbox-pane.ts +35 -35
  288. package/src/ui/dashboard-panes/progress-pane.ts +30 -30
  289. package/src/ui/dashboard-panes/transcript-pane.ts +10 -10
  290. package/src/ui/heartbeat-aggregator.ts +63 -63
  291. package/src/ui/keybinding-map.ts +97 -94
  292. package/src/ui/live-conversation-overlay.ts +152 -0
  293. package/src/ui/live-run-sidebar.ts +180 -180
  294. package/src/ui/mascot.ts +442 -442
  295. package/src/ui/overlays/agent-picker-overlay.ts +57 -57
  296. package/src/ui/overlays/confirm-overlay.ts +58 -58
  297. package/src/ui/overlays/mailbox-compose-overlay.ts +144 -144
  298. package/src/ui/overlays/mailbox-compose-preview.ts +63 -63
  299. package/src/ui/overlays/mailbox-detail-overlay.ts +122 -122
  300. package/src/ui/pi-ui-compat.ts +57 -57
  301. package/src/ui/powerbar-publisher.ts +221 -197
  302. package/src/ui/render-scheduler.ts +216 -143
  303. package/src/ui/run-action-dispatcher.ts +118 -117
  304. package/src/ui/run-dashboard.ts +526 -464
  305. package/src/ui/run-event-bus.ts +208 -208
  306. package/src/ui/run-snapshot-cache.ts +826 -777
  307. package/src/ui/settings-overlay.ts +721 -0
  308. package/src/ui/snapshot-types.ts +86 -70
  309. package/src/ui/theme-adapter.ts +190 -190
  310. package/src/ui/tool-progress-formatter.ts +89 -0
  311. package/src/ui/transcript-cache.ts +94 -94
  312. package/src/ui/transcript-viewer.ts +335 -335
  313. package/src/utils/conflict-detect.ts +662 -0
  314. package/src/utils/env-filter.ts +30 -0
  315. package/src/utils/file-coalescer.ts +86 -86
  316. package/src/utils/frontmatter.ts +68 -68
  317. package/src/utils/fs-watch.ts +88 -31
  318. package/src/utils/gh-protocol.ts +479 -0
  319. package/src/utils/ids.ts +17 -17
  320. package/src/utils/incremental-reader.ts +104 -104
  321. package/src/utils/internal-error.ts +6 -6
  322. package/src/utils/names.ts +27 -27
  323. package/src/utils/paths.ts +102 -63
  324. package/src/utils/redaction.ts +44 -44
  325. package/src/utils/resolve-shell.ts +34 -0
  326. package/src/utils/safe-paths.ts +47 -47
  327. package/src/utils/scan-cache.ts +136 -136
  328. package/src/utils/sleep.ts +2 -1
  329. package/src/utils/sse-parser.ts +134 -134
  330. package/src/utils/task-name-generator.ts +337 -337
  331. package/src/utils/timings.ts +33 -33
  332. package/src/utils/visual.ts +243 -198
  333. package/src/workflows/discover-workflows.ts +139 -139
  334. package/src/workflows/validate-workflow.ts +40 -40
  335. package/src/workflows/workflow-config.ts +26 -26
  336. package/src/workflows/workflow-serializer.ts +32 -32
  337. package/src/worktree/branch-freshness.ts +45 -45
  338. package/src/worktree/cleanup.ts +75 -72
  339. package/src/worktree/worktree-manager.ts +188 -146
  340. package/teams/default.team.md +12 -12
  341. package/teams/fast-fix.team.md +11 -11
  342. package/teams/implementation.team.md +18 -18
  343. package/teams/parallel-research.team.md +14 -14
  344. package/teams/research.team.md +11 -11
  345. package/teams/review.team.md +12 -12
  346. package/tsconfig.json +19 -19
  347. package/workflows/default.workflow.md +30 -30
  348. package/workflows/fast-fix.workflow.md +23 -23
  349. package/workflows/implementation.workflow.md +43 -43
  350. package/workflows/parallel-research.workflow.md +46 -46
  351. package/workflows/research.workflow.md +22 -22
  352. package/workflows/review.workflow.md +30 -30
  353. package/skills/task-packet/SKILL.md +0 -28
  354. package/skills/verify-evidence/SKILL.md +0 -27
@@ -1,777 +1,826 @@
1
- import { createHash } from "node:crypto";
2
- import * as fs from "node:fs";
3
- import * as path from "node:path";
4
- import { readCrewAgents, readCrewAgentsAsync, agentsPath, agentOutputPath } from "../runtime/crew-agent-records.ts";
5
- import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
6
- import { isActiveRunStatus } from "../runtime/process-status.ts";
7
- import type { TeamEvent } from "../state/event-log.ts";
8
- import type { MailboxMessageStatus } from "../state/mailbox.ts";
9
- import { loadRunManifestById, loadRunManifestByIdAsync } from "../state/state-store.ts";
10
- import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
11
- import type { RunSnapshotCache as RunSnapshotCacheBase, RunUiGroupJoin, RunUiMailbox, RunUiProgress, RunUiSnapshot, RunUiUsage } from "./snapshot-types.ts";
12
- import { runEventBus } from "./run-event-bus.ts";
13
-
14
- export interface RunSnapshotCache extends RunSnapshotCacheBase {
15
- preloadStale(runId: string): Promise<RunUiSnapshot | undefined>;
16
- preloadAllStale(runIds: string[]): Promise<void>;
17
- }
18
-
19
- const DEFAULT_TTL_MS = 500;
20
- const DEFAULT_MAX_ENTRIES = 24;
21
- const DEFAULT_RECENT_EVENTS = 20;
22
- const DEFAULT_RECENT_OUTPUT_LINES = 20;
23
- const MAX_TAIL_BYTES = 32 * 1024;
24
- /** Max JSONL lines to tail when reading growing files (events, mailbox). */
25
- const MAX_TAIL_LINES = 500;
26
-
27
- interface FileStamp {
28
- mtimeMs: number;
29
- size: number;
30
- }
31
-
32
- interface SnapshotStamps {
33
- manifest: FileStamp;
34
- tasks: FileStamp;
35
- agents: FileStamp;
36
- events: FileStamp;
37
- mailbox: FileStamp;
38
- output: FileStamp;
39
- }
40
-
41
- interface CacheEntry {
42
- snapshot: RunUiSnapshot;
43
- stamps: SnapshotStamps;
44
- loadedAtMs: number;
45
- lastAccessMs: number;
46
- }
47
-
48
- export interface RunSnapshotCacheOptions {
49
- ttlMs?: number;
50
- maxEntries?: number;
51
- recentEvents?: number;
52
- recentOutputLines?: number;
53
- }
54
-
55
- function zeroStamp(): FileStamp {
56
- return { mtimeMs: 0, size: 0 };
57
- }
58
-
59
- function stampFile(filePath: string | undefined): FileStamp {
60
- if (!filePath) return zeroStamp();
61
- try {
62
- const stat = fs.statSync(filePath);
63
- return { mtimeMs: stat.mtimeMs, size: stat.size };
64
- } catch {
65
- return zeroStamp();
66
- }
67
- }
68
-
69
- async function stampFileAsync(filePath: string | undefined): Promise<FileStamp> {
70
- if (!filePath) return zeroStamp();
71
- try {
72
- const stat = await fs.promises.stat(filePath);
73
- return { mtimeMs: stat.mtimeMs, size: stat.size };
74
- } catch {
75
- return zeroStamp();
76
- }
77
- }
78
-
79
- function combineStamps(stamps: FileStamp[]): FileStamp {
80
- return stamps.reduce((acc, stamp) => ({ mtimeMs: Math.max(acc.mtimeMs, stamp.mtimeMs), size: acc.size + stamp.size }), zeroStamp());
81
- }
82
-
83
- function mailboxStamp(manifest: TeamRunManifest): FileStamp {
84
- const root = path.join(manifest.stateRoot, "mailbox");
85
- const stamps: FileStamp[] = [
86
- stampFile(path.join(root, "inbox.jsonl")),
87
- stampFile(path.join(root, "outbox.jsonl")),
88
- stampFile(path.join(root, "delivery.json")),
89
- ];
90
- const tasksRoot = path.join(root, "tasks");
91
- try {
92
- for (const entry of fs.readdirSync(tasksRoot, { withFileTypes: true })) {
93
- if (!entry.isDirectory()) continue;
94
- stamps.push(stampFile(path.join(tasksRoot, entry.name, "inbox.jsonl")));
95
- stamps.push(stampFile(path.join(tasksRoot, entry.name, "outbox.jsonl")));
96
- }
97
- } catch {
98
- // No task mailbox yet.
99
- }
100
- return combineStamps(stamps);
101
- }
102
-
103
- async function mailboxStampAsync(manifest: TeamRunManifest): Promise<FileStamp> {
104
- const root = path.join(manifest.stateRoot, "mailbox");
105
- const stamps: FileStamp[] = [
106
- await stampFileAsync(path.join(root, "inbox.jsonl")),
107
- await stampFileAsync(path.join(root, "outbox.jsonl")),
108
- await stampFileAsync(path.join(root, "delivery.json")),
109
- ];
110
- const tasksRoot = path.join(root, "tasks");
111
- try {
112
- for (const entry of await fs.promises.readdir(tasksRoot, { withFileTypes: true })) {
113
- if (!entry.isDirectory()) continue;
114
- stamps.push(await stampFileAsync(path.join(tasksRoot, entry.name, "inbox.jsonl")));
115
- stamps.push(await stampFileAsync(path.join(tasksRoot, entry.name, "outbox.jsonl")));
116
- }
117
- } catch {
118
- // No task mailbox yet.
119
- }
120
- return combineStamps(stamps);
121
- }
122
-
123
- function safeAgentOutputPath(manifest: TeamRunManifest, agent: CrewAgentRecord): string | undefined {
124
- try {
125
- return agentOutputPath(manifest, agent.taskId);
126
- } catch {
127
- return undefined;
128
- }
129
- }
130
-
131
- function outputStamp(manifest: TeamRunManifest, agents: CrewAgentRecord[]): FileStamp {
132
- return combineStamps(agents.map((agent) => stampFile(safeAgentOutputPath(manifest, agent))));
133
- }
134
-
135
- async function outputStampAsync(manifest: TeamRunManifest, agents: CrewAgentRecord[]): Promise<FileStamp> {
136
- return combineStamps(await Promise.all(agents.map((agent) => stampFileAsync(safeAgentOutputPath(manifest, agent)))));
137
- }
138
-
139
- function sameStamp(a: FileStamp, b: FileStamp): boolean {
140
- return a.mtimeMs === b.mtimeMs && a.size === b.size;
141
- }
142
-
143
- function sameStamps(a: SnapshotStamps, b: SnapshotStamps): boolean {
144
- return sameStamp(a.manifest, b.manifest)
145
- && sameStamp(a.tasks, b.tasks)
146
- && sameStamp(a.agents, b.agents)
147
- && sameStamp(a.events, b.events)
148
- && sameStamp(a.mailbox, b.mailbox)
149
- && sameStamp(a.output, b.output);
150
- }
151
-
152
- function readTasks(tasksPath: string): TeamTaskState[] {
153
- try {
154
- const parsed = JSON.parse(fs.readFileSync(tasksPath, "utf-8")) as unknown;
155
- return Array.isArray(parsed) ? (parsed as TeamTaskState[]) : [];
156
- } catch {
157
- throw new Error(`Failed to parse tasks at ${tasksPath}`);
158
- }
159
- }
160
-
161
- /** Tail-read JSONL lines from a file, returning parsed objects (limited). */
162
- function tailJsonlLines<T>(filePath: string, limit: number, parse: (line: string) => T | undefined): T[] {
163
- if (limit <= 0) return [];
164
- try {
165
- const stat = fs.statSync(filePath);
166
- const bytesToRead = Math.min(stat.size, MAX_TAIL_BYTES);
167
- const fd = fs.openSync(filePath, "r");
168
- try {
169
- const buffer = Buffer.alloc(bytesToRead);
170
- fs.readSync(fd, buffer, 0, bytesToRead, stat.size - bytesToRead);
171
- const lines = buffer.toString("utf-8").split(/\r?\n/).filter(Boolean);
172
- return lines.flatMap((line) => {
173
- const item = parse(line);
174
- return item ? [item] : [];
175
- }).slice(-limit);
176
- } finally {
177
- fs.closeSync(fd);
178
- }
179
- } catch {
180
- return [];
181
- }
182
- }
183
-
184
- /** Async tail-read JSONL lines from a file, returning parsed objects (limited). */
185
- async function tailJsonlLinesAsync<T>(filePath: string, limit: number, parse: (line: string) => T | undefined): Promise<T[]> {
186
- if (limit <= 0) return [];
187
- try {
188
- const stat = await fs.promises.stat(filePath);
189
- const bytesToRead = Math.min(stat.size, MAX_TAIL_BYTES);
190
- const handle = await fs.promises.open(filePath, "r");
191
- try {
192
- const buffer = Buffer.alloc(bytesToRead);
193
- await handle.read(buffer, 0, bytesToRead, stat.size - bytesToRead);
194
- const lines = buffer.toString("utf-8").split(/\r?\n/).filter(Boolean);
195
- return lines.flatMap((line) => {
196
- const item = parse(line);
197
- return item ? [item] : [];
198
- }).slice(-limit);
199
- } finally {
200
- await handle.close();
201
- }
202
- } catch {
203
- return [];
204
- }
205
- }
206
-
207
- function safeRecentEvents(eventsPath: string, limit: number): TeamEvent[] {
208
- return tailJsonlLines(eventsPath, limit, (line) => {
209
- try {
210
- const parsed = JSON.parse(line) as unknown;
211
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as TeamEvent) : undefined;
212
- } catch {
213
- return undefined;
214
- }
215
- });
216
- }
217
-
218
- async function safeRecentEventsAsync(eventsPath: string, limit: number): Promise<TeamEvent[]> {
219
- return tailJsonlLinesAsync(eventsPath, limit, (line) => {
220
- try {
221
- const parsed = JSON.parse(line) as unknown;
222
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as TeamEvent) : undefined;
223
- } catch {
224
- return undefined;
225
- }
226
- });
227
- }
228
-
229
- function tailLines(filePath: string, limit: number): string[] {
230
- if (limit <= 0) return [];
231
- try {
232
- const stat = fs.statSync(filePath);
233
- const bytesToRead = Math.min(stat.size, MAX_TAIL_BYTES);
234
- const fd = fs.openSync(filePath, "r");
235
- try {
236
- const buffer = Buffer.alloc(bytesToRead);
237
- fs.readSync(fd, buffer, 0, bytesToRead, stat.size - bytesToRead);
238
- return buffer.toString("utf-8").split(/\r?\n/).filter(Boolean).slice(-limit);
239
- } finally {
240
- fs.closeSync(fd);
241
- }
242
- } catch {
243
- return [];
244
- }
245
- }
246
-
247
- async function tailLinesAsync(filePath: string, limit: number): Promise<string[]> {
248
- if (limit <= 0) return [];
249
- try {
250
- const stat = await fs.promises.stat(filePath);
251
- const bytesToRead = Math.min(stat.size, MAX_TAIL_BYTES);
252
- const handle = await fs.promises.open(filePath, "r");
253
- try {
254
- const buffer = Buffer.alloc(bytesToRead);
255
- await handle.read(buffer, 0, bytesToRead, stat.size - bytesToRead);
256
- return buffer.toString("utf-8").split(/\r?\n/).filter(Boolean).slice(-limit);
257
- } finally {
258
- await handle.close();
259
- }
260
- } catch {
261
- return [];
262
- }
263
- }
264
-
265
- function recentOutputLines(manifest: TeamRunManifest, agents: CrewAgentRecord[], limit: number): string[] {
266
- const fromProgress = agents.flatMap((agent) => agent.progress?.recentOutput ?? []);
267
- const fromFiles = agents.flatMap((agent) => {
268
- const outputPath = safeAgentOutputPath(manifest, agent);
269
- return outputPath ? tailLines(outputPath, limit) : [];
270
- });
271
- return [...fromProgress, ...fromFiles].map((line) => line.replace(/\s+/g, " ").trim()).filter(Boolean).slice(-limit);
272
- }
273
-
274
- async function recentOutputLinesAsync(manifest: TeamRunManifest, agents: CrewAgentRecord[], limit: number): Promise<string[]> {
275
- const fromProgress = agents.flatMap((agent) => agent.progress?.recentOutput ?? []);
276
- const fromFilesArrays = await Promise.all(agents.map((agent) => {
277
- const outputPath = safeAgentOutputPath(manifest, agent);
278
- return outputPath ? tailLinesAsync(outputPath, limit) : Promise.resolve([]);
279
- }));
280
- const fromFiles = fromFilesArrays.flat();
281
- return [...fromProgress, ...fromFiles].map((line) => line.replace(/\s+/g, " ").trim()).filter(Boolean).slice(-limit);
282
- }
283
-
284
- function progressFromTasks(tasks: TeamTaskState[]): RunUiProgress {
285
- const progress: RunUiProgress = { total: tasks.length, completed: 0, running: 0, failed: 0, queued: 0, waiting: 0, cancelled: 0, skipped: 0 };
286
- for (const task of tasks) {
287
- if (task.status === "completed") progress.completed += 1;
288
- else if (task.status === "running") progress.running += 1;
289
- else if (task.status === "failed") progress.failed += 1;
290
- else if (task.status === "queued") progress.queued += 1;
291
- else if (task.status === "waiting") progress.waiting = (progress.waiting ?? 0) + 1;
292
- else if (task.status === "cancelled") progress.cancelled = (progress.cancelled ?? 0) + 1;
293
- else if (task.status === "skipped") progress.skipped = (progress.skipped ?? 0) + 1;
294
- }
295
- return progress;
296
- }
297
-
298
- function usageFrom(tasks: TeamTaskState[], agents: CrewAgentRecord[]): RunUiUsage {
299
- const taskUsage = tasks.reduce((acc, task) => {
300
- acc.tokensIn += task.usage?.input ?? 0;
301
- acc.tokensOut += task.usage?.output ?? 0;
302
- acc.toolUses += task.agentProgress?.toolCount ?? 0;
303
- return acc;
304
- }, { tokensIn: 0, tokensOut: 0, toolUses: 0 });
305
- if (taskUsage.tokensIn || taskUsage.tokensOut || taskUsage.toolUses) return taskUsage;
306
- return agents.reduce((acc, agent) => {
307
- acc.tokensIn += agent.usage?.input ?? 0;
308
- acc.tokensOut += agent.usage?.output ?? agent.progress?.tokens ?? 0;
309
- acc.toolUses += agent.toolUses ?? agent.progress?.toolCount ?? 0;
310
- return acc;
311
- }, { tokensIn: 0, tokensOut: 0, toolUses: 0 });
312
- }
313
-
314
- function isMailboxStatus(value: unknown): value is MailboxMessageStatus {
315
- return value === "queued" || value === "delivered" || value === "acknowledged";
316
- }
317
-
318
- function readDeliveryMessages(filePath: string): Record<string, MailboxMessageStatus> {
319
- try {
320
- const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8")) as unknown;
321
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
322
- const messages = (parsed as { messages?: unknown }).messages;
323
- if (!messages || typeof messages !== "object" || Array.isArray(messages)) return {};
324
- const output: Record<string, MailboxMessageStatus> = {};
325
- for (const [id, status] of Object.entries(messages)) if (isMailboxStatus(status)) output[id] = status;
326
- return output;
327
- } catch {
328
- return {};
329
- }
330
- }
331
-
332
- async function readDeliveryMessagesAsync(filePath: string): Promise<Record<string, MailboxMessageStatus>> {
333
- try {
334
- const content = await fs.promises.readFile(filePath, "utf-8");
335
- const parsed = JSON.parse(content) as unknown;
336
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
337
- const messages = (parsed as { messages?: unknown }).messages;
338
- if (!messages || typeof messages !== "object" || Array.isArray(messages)) return {};
339
- const output: Record<string, MailboxMessageStatus> = {};
340
- for (const [id, status] of Object.entries(messages)) if (isMailboxStatus(status)) output[id] = status;
341
- return output;
342
- } catch {
343
- return {};
344
- }
345
- }
346
-
347
- function readGroupJoinMailbox(filePath: string, delivery: Record<string, MailboxMessageStatus>): RunUiGroupJoin[] {
348
- return tailJsonlLines(filePath, MAX_TAIL_LINES, (line) => {
349
- try {
350
- const parsed = JSON.parse(line) as unknown;
351
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
352
- const message = parsed as { id?: unknown; data?: unknown };
353
- const data = message.data && typeof message.data === "object" && !Array.isArray(message.data) ? message.data as Record<string, unknown> : undefined;
354
- if (typeof message.id !== "string" || data?.kind !== "group_join" || typeof data.requestId !== "string") return undefined;
355
- return { requestId: data.requestId, messageId: message.id, partial: data.partial === true, ack: delivery[message.id] === "acknowledged" ? "acknowledged" as const : "pending" as const };
356
- } catch {
357
- return undefined;
358
- }
359
- });
360
- }
361
-
362
- async function readGroupJoinMailboxAsync(filePath: string, delivery: Record<string, MailboxMessageStatus>): Promise<RunUiGroupJoin[]> {
363
- return tailJsonlLinesAsync(filePath, MAX_TAIL_LINES, (line) => {
364
- try {
365
- const parsed = JSON.parse(line) as unknown;
366
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
367
- const message = parsed as { id?: unknown; data?: unknown };
368
- const data = message.data && typeof message.data === "object" && !Array.isArray(message.data) ? message.data as Record<string, unknown> : undefined;
369
- if (typeof message.id !== "string" || data?.kind !== "group_join" || typeof data.requestId !== "string") return undefined;
370
- return { requestId: data.requestId, messageId: message.id, partial: data.partial === true, ack: delivery[message.id] === "acknowledged" ? "acknowledged" as const : "pending" as const };
371
- } catch {
372
- return undefined;
373
- }
374
- });
375
- }
376
-
377
- interface MailboxCount {
378
- count: number;
379
- approximate: boolean;
380
- }
381
-
382
- interface MailboxKindCount extends MailboxCount {
383
- steer: number;
384
- followUp: number;
385
- response: number;
386
- message: number;
387
- }
388
-
389
- function tailApproximate(filePath: string): boolean {
390
- try {
391
- return fs.statSync(filePath).size > MAX_TAIL_BYTES;
392
- } catch {
393
- return false;
394
- }
395
- }
396
-
397
- async function tailApproximateAsync(filePath: string): Promise<boolean> {
398
- try {
399
- return (await fs.promises.stat(filePath)).size > MAX_TAIL_BYTES;
400
- } catch {
401
- return false;
402
- }
403
- }
404
-
405
- function readMailboxCounts(filePath: string, delivery: Record<string, MailboxMessageStatus>): MailboxKindCount {
406
- const kindCounts = { steer: 0, followUp: 0, response: 0, message: 0 };
407
- const items = tailJsonlLines(filePath, MAX_TAIL_LINES, (line) => {
408
- try {
409
- const parsed = JSON.parse(line) as unknown;
410
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return 0;
411
- const msg = parsed as { id?: unknown; status?: unknown; kind?: unknown; data?: unknown };
412
- if (typeof msg.id !== "string" || !isMailboxStatus(msg.status)) return 0;
413
- if (msg.status !== "acknowledged" && delivery[msg.id] !== "acknowledged") {
414
- const kind = typeof msg.kind === "string" ? msg.kind : typeof (msg.data as Record<string, unknown>)?.kind === "string" ? (msg.data as Record<string, unknown>).kind as string : undefined;
415
- if (kind === "steer") kindCounts.steer++;
416
- else if (kind === "follow-up") kindCounts.followUp++;
417
- else if (kind === "response") kindCounts.response++;
418
- else kindCounts.message++;
419
- return 1;
420
- }
421
- return 0;
422
- } catch {
423
- return 0;
424
- }
425
- }) as number[];
426
- const count = items.reduce((sum, val) => sum + val, 0);
427
- return { count, approximate: tailApproximate(filePath), steer: kindCounts.steer, followUp: kindCounts.followUp, response: kindCounts.response, message: kindCounts.message };
428
- }
429
-
430
- async function readMailboxCountsAsync(filePath: string, delivery: Record<string, MailboxMessageStatus>): Promise<MailboxKindCount> {
431
- const kindCounts = { steer: 0, followUp: 0, response: 0, message: 0 };
432
- const items = await tailJsonlLinesAsync(filePath, MAX_TAIL_LINES, (line) => {
433
- try {
434
- const parsed = JSON.parse(line) as unknown;
435
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return 0;
436
- const msg = parsed as { id?: unknown; status?: unknown; kind?: unknown; data?: unknown };
437
- if (typeof msg.id !== "string" || !isMailboxStatus(msg.status)) return 0;
438
- if (msg.status !== "acknowledged" && delivery[msg.id] !== "acknowledged") {
439
- const kind = typeof msg.kind === "string" ? msg.kind : typeof (msg.data as Record<string, unknown>)?.kind === "string" ? (msg.data as Record<string, unknown>).kind as string : undefined;
440
- if (kind === "steer") kindCounts.steer++;
441
- else if (kind === "follow-up") kindCounts.followUp++;
442
- else if (kind === "response") kindCounts.response++;
443
- else kindCounts.message++;
444
- return 1;
445
- }
446
- return 0;
447
- } catch {
448
- return 0;
449
- }
450
- }) as number[];
451
- const count = items.reduce((sum, val) => sum + val, 0);
452
- return { count, approximate: await tailApproximateAsync(filePath), steer: kindCounts.steer, followUp: kindCounts.followUp, response: kindCounts.response, message: kindCounts.message };
453
- }
454
-
455
- function groupJoinsFrom(manifest: TeamRunManifest): RunUiGroupJoin[] {
456
- const root = path.join(manifest.stateRoot, "mailbox");
457
- const delivery = readDeliveryMessages(path.join(root, "delivery.json"));
458
- return readGroupJoinMailbox(path.join(root, "outbox.jsonl"), delivery).slice(-5);
459
- }
460
-
461
- async function groupJoinsFromAsync(manifest: TeamRunManifest): Promise<RunUiGroupJoin[]> {
462
- const root = path.join(manifest.stateRoot, "mailbox");
463
- const delivery = await readDeliveryMessagesAsync(path.join(root, "delivery.json"));
464
- return (await readGroupJoinMailboxAsync(path.join(root, "outbox.jsonl"), delivery)).slice(-5);
465
- }
466
-
467
- function mergeKindCounts(a: MailboxKindCount, b: MailboxKindCount): MailboxKindCount {
468
- return {
469
- count: a.count + b.count,
470
- approximate: a.approximate || b.approximate,
471
- steer: a.steer + b.steer,
472
- followUp: a.followUp + b.followUp,
473
- response: a.response + b.response,
474
- message: a.message + b.message,
475
- };
476
- }
477
-
478
- function mailboxFrom(manifest: TeamRunManifest, agents: CrewAgentRecord[]): RunUiMailbox {
479
- const root = path.join(manifest.stateRoot, "mailbox");
480
- const delivery = readDeliveryMessages(path.join(root, "delivery.json"));
481
- let inbox = readMailboxCounts(path.join(root, "inbox.jsonl"), delivery);
482
- let outbox = readMailboxCounts(path.join(root, "outbox.jsonl"), delivery);
483
- const tasksRoot = path.join(root, "tasks");
484
- try {
485
- for (const entry of fs.readdirSync(tasksRoot, { withFileTypes: true })) {
486
- if (!entry.isDirectory()) continue;
487
- const taskInbox = readMailboxCounts(path.join(tasksRoot, entry.name, "inbox.jsonl"), delivery);
488
- const taskOutbox = readMailboxCounts(path.join(tasksRoot, entry.name, "outbox.jsonl"), delivery);
489
- inbox = mergeKindCounts(inbox, taskInbox);
490
- outbox = mergeKindCounts(outbox, taskOutbox);
491
- }
492
- } catch {
493
- // No task mailboxes yet.
494
- }
495
- const attentionAgents = agents.filter((agent) => agent.progress?.activityState === "needs_attention").length;
496
- return {
497
- inboxUnread: inbox.count, outboxPending: outbox.count, needsAttention: inbox.count + attentionAgents, approximate: inbox.approximate || outbox.approximate,
498
- steerUnread: inbox.steer + outbox.steer, followUpUnread: inbox.followUp + outbox.followUp, responseUnread: inbox.response + outbox.response, messageUnread: inbox.message + outbox.message,
499
- };
500
- }
501
-
502
- async function mailboxFromAsync(manifest: TeamRunManifest, agents: CrewAgentRecord[]): Promise<RunUiMailbox> {
503
- const root = path.join(manifest.stateRoot, "mailbox");
504
- const delivery = await readDeliveryMessagesAsync(path.join(root, "delivery.json"));
505
- let inbox = await readMailboxCountsAsync(path.join(root, "inbox.jsonl"), delivery);
506
- let outbox = await readMailboxCountsAsync(path.join(root, "outbox.jsonl"), delivery);
507
- const tasksRoot = path.join(root, "tasks");
508
- try {
509
- for (const entry of await fs.promises.readdir(tasksRoot, { withFileTypes: true })) {
510
- if (!entry.isDirectory()) continue;
511
- const taskInbox = await readMailboxCountsAsync(path.join(tasksRoot, entry.name, "inbox.jsonl"), delivery);
512
- const taskOutbox = await readMailboxCountsAsync(path.join(tasksRoot, entry.name, "outbox.jsonl"), delivery);
513
- inbox = mergeKindCounts(inbox, taskInbox);
514
- outbox = mergeKindCounts(outbox, taskOutbox);
515
- }
516
- } catch {
517
- // No task mailboxes yet.
518
- }
519
- const attentionAgents = agents.filter((agent) => agent.progress?.activityState === "needs_attention").length;
520
- return {
521
- inboxUnread: inbox.count, outboxPending: outbox.count, needsAttention: inbox.count + attentionAgents, approximate: inbox.approximate || outbox.approximate,
522
- steerUnread: inbox.steer + outbox.steer, followUpUnread: inbox.followUp + outbox.followUp, responseUnread: inbox.response + outbox.response, messageUnread: inbox.message + outbox.message,
523
- };
524
- }
525
-
526
- function cancellationReasonFromEvents(events: TeamEvent[]): string | undefined {
527
- return [...events].reverse().find((event) => event.type === "run.cancelled" && typeof event.data?.reason === "string")?.data?.reason as string | undefined;
528
- }
529
-
530
- function signatureFor(input: Omit<RunUiSnapshot, "signature" | "fetchedAt">, stamps: SnapshotStamps): string {
531
- try {
532
- const digest = createHash("sha256");
533
- digest.update(JSON.stringify({
534
- run: [input.manifest.runId, input.manifest.status, input.manifest.updatedAt, input.manifest.artifacts.length],
535
- tasks: input.tasks.map((task) => [task.id, task.status, task.startedAt, task.finishedAt, task.agentProgress, task.usage]),
536
- agents: input.agents.map((agent) => [agent.id, agent.status, agent.startedAt, agent.completedAt, agent.toolUses, agent.progress, agent.usage, agent.model]),
537
- progress: input.progress,
538
- usage: input.usage,
539
- mailbox: input.mailbox,
540
- groupJoins: input.groupJoins,
541
- events: input.recentEvents.map((event) => [event.metadata?.seq, event.time, event.type, event.taskId, event.message, event.data?.reason]),
542
- cancellationReason: input.cancellationReason,
543
- output: input.recentOutputLines,
544
- stamps,
545
- }));
546
- return digest.digest("hex").slice(0, 16);
547
- } catch {
548
- // Circular reference or non-serializable data — fall back to timestamp.
549
- return String(Date.now());
550
- }
551
- }
552
-
553
- function stampsFor(manifest: TeamRunManifest, agents: CrewAgentRecord[]): SnapshotStamps {
554
- return {
555
- manifest: stampFile(path.join(manifest.stateRoot, "manifest.json")),
556
- tasks: stampFile(manifest.tasksPath),
557
- agents: stampFile(agentsPath(manifest)),
558
- events: stampFile(manifest.eventsPath),
559
- mailbox: mailboxStamp(manifest),
560
- output: outputStamp(manifest, agents),
561
- };
562
- }
563
-
564
- async function stampsForAsync(manifest: TeamRunManifest, agents: CrewAgentRecord[]): Promise<SnapshotStamps> {
565
- const [manifestStamp, tasksStamp, agentsStamp, eventsStamp, mailbox, output] = await Promise.all([
566
- stampFileAsync(path.join(manifest.stateRoot, "manifest.json")),
567
- stampFileAsync(manifest.tasksPath),
568
- stampFileAsync(agentsPath(manifest)),
569
- stampFileAsync(manifest.eventsPath),
570
- mailboxStampAsync(manifest),
571
- outputStampAsync(manifest, agents),
572
- ]);
573
- return { manifest: manifestStamp, tasks: tasksStamp, agents: agentsStamp, events: eventsStamp, mailbox, output };
574
- }
575
-
576
- export function createRunSnapshotCache(cwd: string, options: RunSnapshotCacheOptions = {}): RunSnapshotCache {
577
- const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
578
- const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
579
- const recentEventsLimit = options.recentEvents ?? DEFAULT_RECENT_EVENTS;
580
- const recentOutputLimit = options.recentOutputLines ?? DEFAULT_RECENT_OUTPUT_LINES;
581
- const entries = new Map<string, CacheEntry>();
582
-
583
- function touch(runId: string, entry: CacheEntry): RunUiSnapshot {
584
- entry.lastAccessMs = Date.now();
585
- if (entries.has(runId)) {
586
- entries.delete(runId);
587
- entries.set(runId, entry);
588
- }
589
- return entry.snapshot;
590
- }
591
-
592
- function evictIfNeeded(): void {
593
- while (entries.size > maxEntries) {
594
- const oldestInactive = [...entries.entries()].find(([, entry]) => !isActiveRunStatus(entry.snapshot.manifest.status));
595
- const key = oldestInactive?.[0] ?? entries.keys().next().value;
596
- if (!key) break;
597
- entries.delete(key);
598
- }
599
- }
600
-
601
- function build(runId: string, previous?: CacheEntry): CacheEntry {
602
- let loaded: ReturnType<typeof loadRunManifestById>;
603
- try {
604
- loaded = loadRunManifestById(cwd, runId);
605
- } catch {
606
- if (previous) return previous;
607
- throw new Error(`Run '${runId}' could not be parsed.`);
608
- }
609
- if (!loaded) {
610
- if (previous) return previous;
611
- throw new Error(`Run '${runId}' not found.`);
612
- }
613
- let tasks: TeamTaskState[];
614
- let agents: CrewAgentRecord[];
615
- try {
616
- tasks = readTasks(loaded.manifest.tasksPath);
617
- agents = readCrewAgents(loaded.manifest);
618
- } catch {
619
- if (previous) return previous;
620
- throw new Error(`Run '${runId}' could not be parsed.`);
621
- }
622
- const mailbox = mailboxFrom(loaded.manifest, agents);
623
- const groupJoins = groupJoinsFrom(loaded.manifest);
624
- const recentEvents = safeRecentEvents(loaded.manifest.eventsPath, recentEventsLimit);
625
- const base = {
626
- runId: loaded.manifest.runId,
627
- cwd: loaded.manifest.cwd,
628
- manifest: loaded.manifest,
629
- tasks,
630
- agents,
631
- progress: progressFromTasks(tasks),
632
- usage: usageFrom(tasks, agents),
633
- mailbox,
634
- groupJoins,
635
- cancellationReason: cancellationReasonFromEvents(recentEvents),
636
- recentEvents,
637
- recentOutputLines: recentOutputLines(loaded.manifest, agents, recentOutputLimit),
638
- };
639
- const stamps = stampsFor(loaded.manifest, agents);
640
- const snapshot: RunUiSnapshot = { ...base, fetchedAt: Date.now(), signature: signatureFor(base, stamps) };
641
- return { snapshot, stamps, loadedAtMs: snapshot.fetchedAt, lastAccessMs: snapshot.fetchedAt };
642
- }
643
-
644
- async function buildAsync(runId: string, previous?: CacheEntry): Promise<CacheEntry> {
645
- let loaded: Awaited<ReturnType<typeof loadRunManifestByIdAsync>>;
646
- try {
647
- loaded = await loadRunManifestByIdAsync(cwd, runId);
648
- } catch {
649
- if (previous) return previous;
650
- throw new Error(`Run '${runId}' could not be parsed.`);
651
- }
652
- if (!loaded) {
653
- if (previous) return previous;
654
- throw new Error(`Run '${runId}' not found.`);
655
- }
656
- let tasks: TeamTaskState[];
657
- let agents: CrewAgentRecord[];
658
- try {
659
- tasks = loaded.tasks;
660
- agents = await readCrewAgentsAsync(loaded.manifest);
661
- } catch {
662
- if (previous) return previous;
663
- throw new Error(`Run '${runId}' could not be parsed.`);
664
- }
665
- const [mailbox, groupJoins, recentEvents, recentOutput] = await Promise.all([
666
- mailboxFromAsync(loaded.manifest, agents),
667
- groupJoinsFromAsync(loaded.manifest),
668
- safeRecentEventsAsync(loaded.manifest.eventsPath, recentEventsLimit),
669
- recentOutputLinesAsync(loaded.manifest, agents, recentOutputLimit),
670
- ]);
671
- const base = {
672
- runId: loaded.manifest.runId,
673
- cwd: loaded.manifest.cwd,
674
- manifest: loaded.manifest,
675
- tasks,
676
- agents,
677
- progress: progressFromTasks(tasks),
678
- usage: usageFrom(tasks, agents),
679
- mailbox,
680
- groupJoins,
681
- cancellationReason: cancellationReasonFromEvents(recentEvents),
682
- recentEvents,
683
- recentOutputLines: recentOutput,
684
- };
685
- const stamps = await stampsForAsync(loaded.manifest, agents);
686
- const snapshot: RunUiSnapshot = { ...base, fetchedAt: Date.now(), signature: signatureFor(base, stamps) };
687
- return { snapshot, stamps, loadedAtMs: snapshot.fetchedAt, lastAccessMs: snapshot.fetchedAt };
688
- }
689
-
690
- function currentStamps(previous: CacheEntry): SnapshotStamps {
691
- const manifest = previous.snapshot.manifest;
692
- return {
693
- manifest: stampFile(path.join(manifest.stateRoot, "manifest.json")),
694
- tasks: stampFile(manifest.tasksPath),
695
- agents: stampFile(agentsPath(manifest)),
696
- events: stampFile(manifest.eventsPath),
697
- mailbox: mailboxStamp(manifest),
698
- output: outputStamp(previous.snapshot.manifest, previous.snapshot.agents),
699
- };
700
- }
701
-
702
- async function currentStampsAsync(previous: CacheEntry): Promise<SnapshotStamps> {
703
- return stampsForAsync(previous.snapshot.manifest, previous.snapshot.agents);
704
- }
705
-
706
- async function preloadStale(runId: string): Promise<RunUiSnapshot | undefined> {
707
- const previous = entries.get(runId);
708
- const now = Date.now();
709
- // Fresh enough? Return immediately
710
- if (previous && now - previous.loadedAtMs < ttlMs) {
711
- return touch(runId, previous);
712
- }
713
- // Check stamps async
714
- if (previous) {
715
- const stamps = await currentStampsAsync(previous);
716
- if (sameStamps(stamps, previous.stamps)) {
717
- previous.loadedAtMs = now;
718
- return touch(runId, previous);
719
- }
720
- }
721
- // Full async build
722
- const entry = await buildAsync(runId, previous);
723
- entries.set(runId, entry);
724
- evictIfNeeded();
725
- return entry.snapshot;
726
- }
727
-
728
- async function preloadAllStale(runIds: string[]): Promise<void> {
729
- const batchSize = 4;
730
- for (let i = 0; i < runIds.length; i += batchSize) {
731
- const batch = runIds.slice(i, i + batchSize);
732
- await Promise.all(batch.map((id) => preloadStale(id)));
733
- }
734
- }
735
-
736
- const unsubscribe = runEventBus.onAny((event) => {
737
- if (entries.has(event.runId)) {
738
- entries.delete(event.runId);
739
- }
740
- });
741
-
742
- return {
743
- get(runId: string): RunUiSnapshot | undefined {
744
- const entry = entries.get(runId);
745
- return entry ? touch(runId, entry) : undefined;
746
- },
747
- refresh(runId: string): RunUiSnapshot {
748
- const previous = entries.get(runId);
749
- const entry = build(runId, previous);
750
- entries.set(runId, entry);
751
- evictIfNeeded();
752
- return entry.snapshot;
753
- },
754
- refreshIfStale(runId: string): RunUiSnapshot {
755
- const previous = entries.get(runId);
756
- if (!previous) return this.refresh(runId);
757
- const now = Date.now();
758
- if (now - previous.loadedAtMs < ttlMs) return touch(runId, previous);
759
- const stamps = currentStamps(previous);
760
- if (sameStamps(stamps, previous.stamps)) return touch(runId, previous);
761
- return this.refresh(runId);
762
- },
763
- preloadStale,
764
- preloadAllStale,
765
- invalidate(runId?: string): void {
766
- if (runId) entries.delete(runId);
767
- else entries.clear();
768
- },
769
- snapshotsByKey(): Map<string, RunUiSnapshot> {
770
- return new Map([...entries.entries()].map(([key, entry]) => [key, entry.snapshot]));
771
- },
772
- dispose(): void {
773
- unsubscribe();
774
- entries.clear();
775
- },
776
- };
777
- }
1
+ import { createHash } from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { readCrewAgents, readCrewAgentsAsync, agentsPath, agentOutputPath } from "../runtime/crew-agent-records.ts";
5
+ import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
6
+ import { isActiveRunStatus } from "../runtime/process-status.ts";
7
+ import type { TeamEvent } from "../state/event-log.ts";
8
+ import type { MailboxMessageStatus } from "../state/mailbox.ts";
9
+ import { loadRunManifestById, loadRunManifestByIdAsync } from "../state/state-store.ts";
10
+ import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
11
+ import type { RunSnapshotCache as RunSnapshotCacheBase, RunUiGroupJoin, RunUiMailbox, RunUiProgress, RunUiSnapshot, RunUiUsage } from "./snapshot-types.ts";
12
+ import { runEventBus } from "./run-event-bus.ts";
13
+ import { sequencePath } from "../state/event-log.ts";
14
+
15
+ export interface RunSnapshotCache extends RunSnapshotCacheBase {
16
+ preloadStale(runId: string): Promise<RunUiSnapshot | undefined>;
17
+ preloadAllStale(runIds: string[]): Promise<void>;
18
+ }
19
+
20
+ const DEFAULT_TTL_MS = 1500;
21
+ const DEFAULT_MAX_ENTRIES = 24;
22
+ const DEFAULT_RECENT_EVENTS = 20;
23
+ const DEFAULT_RECENT_OUTPUT_LINES = 20;
24
+ const MAX_TAIL_BYTES = 32 * 1024;
25
+ /** Max JSONL lines to tail when reading growing files (events, mailbox). */
26
+ const MAX_TAIL_LINES = 500;
27
+
28
+ interface FileStamp {
29
+ mtimeMs: number;
30
+ size: number;
31
+ }
32
+
33
+ interface SnapshotStamps {
34
+ manifest: FileStamp;
35
+ tasks: FileStamp;
36
+ agents: FileStamp;
37
+ events: FileStamp;
38
+ mailbox: FileStamp;
39
+ }
40
+
41
+ interface CacheEntry {
42
+ snapshot: RunUiSnapshot;
43
+ stamps: SnapshotStamps;
44
+ loadedAtMs: number;
45
+ lastAccessMs: number;
46
+ }
47
+
48
+ export interface RunSnapshotCacheOptions {
49
+ ttlMs?: number;
50
+ maxEntries?: number;
51
+ recentEvents?: number;
52
+ recentOutputLines?: number;
53
+ }
54
+
55
+ function zeroStamp(): FileStamp {
56
+ return { mtimeMs: 0, size: 0 };
57
+ }
58
+
59
+ function stampFile(filePath: string | undefined): FileStamp {
60
+ if (!filePath) return zeroStamp();
61
+ try {
62
+ const stat = fs.statSync(filePath);
63
+ return { mtimeMs: stat.mtimeMs, size: stat.size };
64
+ } catch {
65
+ return zeroStamp();
66
+ }
67
+ }
68
+
69
+ async function stampFileAsync(filePath: string | undefined): Promise<FileStamp> {
70
+ if (!filePath) return zeroStamp();
71
+ try {
72
+ const stat = await fs.promises.stat(filePath);
73
+ return { mtimeMs: stat.mtimeMs, size: stat.size };
74
+ } catch {
75
+ return zeroStamp();
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Sprint-1 / 1.4 events stamp uses the `.seq` sidecar instead of stat-ing
81
+ * the JSONL itself. The sequence file is a few bytes long and the persisted
82
+ * counter monotonically increases even when the events log gets rotated and
83
+ * shrinks (rotation truncates `size` but keeps `seq` ascending). Encoding the
84
+ * counter into the size field keeps the FileStamp structure unchanged so
85
+ * `sameStamp` continues to work.
86
+ */
87
+ function eventsStamp(eventsPath: string): FileStamp {
88
+ try {
89
+ const raw = fs.readFileSync(sequencePath(eventsPath), "utf-8");
90
+ const seq = Number.parseInt(raw.trim(), 10);
91
+ if (Number.isFinite(seq) && seq >= 0) return { mtimeMs: 0, size: seq + 1 };
92
+ } catch { /* fall through to legacy stat */ }
93
+ return stampFile(eventsPath);
94
+ }
95
+
96
+ async function eventsStampAsync(eventsPath: string): Promise<FileStamp> {
97
+ try {
98
+ const raw = await fs.promises.readFile(sequencePath(eventsPath), "utf-8");
99
+ const seq = Number.parseInt(raw.trim(), 10);
100
+ if (Number.isFinite(seq) && seq >= 0) return { mtimeMs: 0, size: seq + 1 };
101
+ } catch { /* fall through to legacy stat */ }
102
+ return stampFileAsync(eventsPath);
103
+ }
104
+
105
+ function combineStamps(stamps: FileStamp[]): FileStamp {
106
+ return stamps.reduce((acc, stamp) => ({ mtimeMs: Math.max(acc.mtimeMs, stamp.mtimeMs), size: acc.size + stamp.size }), zeroStamp());
107
+ }
108
+
109
+ function mailboxStamp(manifest: TeamRunManifest): FileStamp {
110
+ const root = path.join(manifest.stateRoot, "mailbox");
111
+ const stamps: FileStamp[] = [
112
+ stampFile(path.join(root, "inbox.jsonl")),
113
+ stampFile(path.join(root, "outbox.jsonl")),
114
+ stampFile(path.join(root, "delivery.json")),
115
+ ];
116
+ const tasksRoot = path.join(root, "tasks");
117
+ try {
118
+ for (const entry of fs.readdirSync(tasksRoot, { withFileTypes: true })) {
119
+ if (!entry.isDirectory()) continue;
120
+ stamps.push(stampFile(path.join(tasksRoot, entry.name, "inbox.jsonl")));
121
+ stamps.push(stampFile(path.join(tasksRoot, entry.name, "outbox.jsonl")));
122
+ }
123
+ } catch {
124
+ // No task mailbox yet.
125
+ }
126
+ return combineStamps(stamps);
127
+ }
128
+
129
+ async function mailboxStampAsync(manifest: TeamRunManifest): Promise<FileStamp> {
130
+ const root = path.join(manifest.stateRoot, "mailbox");
131
+ const stamps: FileStamp[] = [
132
+ await stampFileAsync(path.join(root, "inbox.jsonl")),
133
+ await stampFileAsync(path.join(root, "outbox.jsonl")),
134
+ await stampFileAsync(path.join(root, "delivery.json")),
135
+ ];
136
+ const tasksRoot = path.join(root, "tasks");
137
+ try {
138
+ for (const entry of await fs.promises.readdir(tasksRoot, { withFileTypes: true })) {
139
+ if (!entry.isDirectory()) continue;
140
+ stamps.push(await stampFileAsync(path.join(tasksRoot, entry.name, "inbox.jsonl")));
141
+ stamps.push(await stampFileAsync(path.join(tasksRoot, entry.name, "outbox.jsonl")));
142
+ }
143
+ } catch {
144
+ // No task mailbox yet.
145
+ }
146
+ return combineStamps(stamps);
147
+ }
148
+
149
+ function safeAgentOutputPath(manifest: TeamRunManifest, agent: CrewAgentRecord): string | undefined {
150
+ try {
151
+ return agentOutputPath(manifest, agent.taskId);
152
+ } catch {
153
+ return undefined;
154
+ }
155
+ }
156
+
157
+ function outputStamp(manifest: TeamRunManifest, agents: CrewAgentRecord[]): FileStamp {
158
+ return combineStamps(agents.map((agent) => stampFile(safeAgentOutputPath(manifest, agent))));
159
+ }
160
+
161
+ async function outputStampAsync(manifest: TeamRunManifest, agents: CrewAgentRecord[]): Promise<FileStamp> {
162
+ return combineStamps(await Promise.all(agents.map((agent) => stampFileAsync(safeAgentOutputPath(manifest, agent)))));
163
+ }
164
+
165
+ function sameStamp(a: FileStamp, b: FileStamp): boolean {
166
+ return a.mtimeMs === b.mtimeMs && a.size === b.size;
167
+ }
168
+
169
+ function sameStamps(a: SnapshotStamps, b: SnapshotStamps): boolean {
170
+ return sameStamp(a.manifest, b.manifest)
171
+ && sameStamp(a.tasks, b.tasks)
172
+ && sameStamp(a.agents, b.agents)
173
+ && sameStamp(a.events, b.events)
174
+ && sameStamp(a.mailbox, b.mailbox);
175
+ }
176
+
177
+ function readTasks(tasksPath: string): TeamTaskState[] {
178
+ try {
179
+ const parsed = JSON.parse(fs.readFileSync(tasksPath, "utf-8")) as unknown;
180
+ return Array.isArray(parsed) ? (parsed as TeamTaskState[]) : [];
181
+ } catch {
182
+ throw new Error(`Failed to parse tasks at ${tasksPath}`);
183
+ }
184
+ }
185
+
186
+ /** Tail-read JSONL lines from a file, returning parsed objects (limited). */
187
+ function tailJsonlLines<T>(filePath: string, limit: number, parse: (line: string) => T | undefined): T[] {
188
+ if (limit <= 0) return [];
189
+ try {
190
+ const stat = fs.statSync(filePath);
191
+ const bytesToRead = Math.min(stat.size, MAX_TAIL_BYTES);
192
+ const fd = fs.openSync(filePath, "r");
193
+ try {
194
+ const buffer = Buffer.alloc(bytesToRead);
195
+ fs.readSync(fd, buffer, 0, bytesToRead, stat.size - bytesToRead);
196
+ const lines = buffer.toString("utf-8").split(/\r?\n/).filter(Boolean);
197
+ return lines.flatMap((line) => {
198
+ const item = parse(line);
199
+ return item ? [item] : [];
200
+ }).slice(-limit);
201
+ } finally {
202
+ fs.closeSync(fd);
203
+ }
204
+ } catch {
205
+ return [];
206
+ }
207
+ }
208
+
209
+ /** Async tail-read JSONL lines from a file, returning parsed objects (limited). */
210
+ async function tailJsonlLinesAsync<T>(filePath: string, limit: number, parse: (line: string) => T | undefined): Promise<T[]> {
211
+ if (limit <= 0) return [];
212
+ try {
213
+ const stat = await fs.promises.stat(filePath);
214
+ const bytesToRead = Math.min(stat.size, MAX_TAIL_BYTES);
215
+ const handle = await fs.promises.open(filePath, "r");
216
+ try {
217
+ const buffer = Buffer.alloc(bytesToRead);
218
+ await handle.read(buffer, 0, bytesToRead, stat.size - bytesToRead);
219
+ const lines = buffer.toString("utf-8").split(/\r?\n/).filter(Boolean);
220
+ return lines.flatMap((line) => {
221
+ const item = parse(line);
222
+ return item ? [item] : [];
223
+ }).slice(-limit);
224
+ } finally {
225
+ await handle.close();
226
+ }
227
+ } catch {
228
+ return [];
229
+ }
230
+ }
231
+
232
+ function safeRecentEvents(eventsPath: string, limit: number): TeamEvent[] {
233
+ return tailJsonlLines(eventsPath, limit, (line) => {
234
+ try {
235
+ const parsed = JSON.parse(line) as unknown;
236
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as TeamEvent) : undefined;
237
+ } catch {
238
+ return undefined;
239
+ }
240
+ });
241
+ }
242
+
243
+ async function safeRecentEventsAsync(eventsPath: string, limit: number): Promise<TeamEvent[]> {
244
+ return tailJsonlLinesAsync(eventsPath, limit, (line) => {
245
+ try {
246
+ const parsed = JSON.parse(line) as unknown;
247
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as TeamEvent) : undefined;
248
+ } catch {
249
+ return undefined;
250
+ }
251
+ });
252
+ }
253
+
254
+ function tailLines(filePath: string, limit: number): string[] {
255
+ if (limit <= 0) return [];
256
+ try {
257
+ const stat = fs.statSync(filePath);
258
+ const bytesToRead = Math.min(stat.size, MAX_TAIL_BYTES);
259
+ const fd = fs.openSync(filePath, "r");
260
+ try {
261
+ const buffer = Buffer.alloc(bytesToRead);
262
+ fs.readSync(fd, buffer, 0, bytesToRead, stat.size - bytesToRead);
263
+ return buffer.toString("utf-8").split(/\r?\n/).filter(Boolean).slice(-limit);
264
+ } finally {
265
+ fs.closeSync(fd);
266
+ }
267
+ } catch {
268
+ return [];
269
+ }
270
+ }
271
+
272
+ async function tailLinesAsync(filePath: string, limit: number): Promise<string[]> {
273
+ if (limit <= 0) return [];
274
+ try {
275
+ const stat = await fs.promises.stat(filePath);
276
+ const bytesToRead = Math.min(stat.size, MAX_TAIL_BYTES);
277
+ const handle = await fs.promises.open(filePath, "r");
278
+ try {
279
+ const buffer = Buffer.alloc(bytesToRead);
280
+ await handle.read(buffer, 0, bytesToRead, stat.size - bytesToRead);
281
+ return buffer.toString("utf-8").split(/\r?\n/).filter(Boolean).slice(-limit);
282
+ } finally {
283
+ await handle.close();
284
+ }
285
+ } catch {
286
+ return [];
287
+ }
288
+ }
289
+
290
+ function recentOutputLines(manifest: TeamRunManifest, agents: CrewAgentRecord[], limit: number): string[] {
291
+ const fromProgress = agents.flatMap((agent) => agent.progress?.recentOutput ?? []);
292
+ const fromFiles = agents.flatMap((agent) => {
293
+ const outputPath = safeAgentOutputPath(manifest, agent);
294
+ return outputPath ? tailLines(outputPath, limit) : [];
295
+ });
296
+ return [...fromProgress, ...fromFiles].map((line) => line.replace(/\s+/g, " ").trim()).filter(Boolean).slice(-limit);
297
+ }
298
+
299
+ async function recentOutputLinesAsync(manifest: TeamRunManifest, agents: CrewAgentRecord[], limit: number): Promise<string[]> {
300
+ const fromProgress = agents.flatMap((agent) => agent.progress?.recentOutput ?? []);
301
+ const fromFilesArrays = await Promise.all(agents.map((agent) => {
302
+ const outputPath = safeAgentOutputPath(manifest, agent);
303
+ return outputPath ? tailLinesAsync(outputPath, limit) : Promise.resolve([]);
304
+ }));
305
+ const fromFiles = fromFilesArrays.flat();
306
+ return [...fromProgress, ...fromFiles].map((line) => line.replace(/\s+/g, " ").trim()).filter(Boolean).slice(-limit);
307
+ }
308
+
309
+ function progressFromTasks(tasks: TeamTaskState[]): RunUiProgress {
310
+ const progress: RunUiProgress = { total: tasks.length, completed: 0, running: 0, failed: 0, queued: 0, waiting: 0, cancelled: 0, skipped: 0 };
311
+ for (const task of tasks) {
312
+ if (task.status === "completed") progress.completed += 1;
313
+ else if (task.status === "running") progress.running += 1;
314
+ else if (task.status === "failed") progress.failed += 1;
315
+ else if (task.status === "queued") progress.queued += 1;
316
+ else if (task.status === "waiting") progress.waiting = (progress.waiting ?? 0) + 1;
317
+ else if (task.status === "cancelled") progress.cancelled = (progress.cancelled ?? 0) + 1;
318
+ else if (task.status === "skipped") progress.skipped = (progress.skipped ?? 0) + 1;
319
+ }
320
+ return progress;
321
+ }
322
+
323
+ function usageFrom(tasks: TeamTaskState[], agents: CrewAgentRecord[]): RunUiUsage {
324
+ const taskUsage = tasks.reduce((acc, task) => {
325
+ acc.tokensIn += task.usage?.input ?? 0;
326
+ acc.tokensOut += task.usage?.output ?? 0;
327
+ acc.toolUses += task.agentProgress?.toolCount ?? 0;
328
+ return acc;
329
+ }, { tokensIn: 0, tokensOut: 0, toolUses: 0 });
330
+ if (taskUsage.tokensIn || taskUsage.tokensOut || taskUsage.toolUses) return taskUsage;
331
+ return agents.reduce((acc, agent) => {
332
+ acc.tokensIn += agent.usage?.input ?? 0;
333
+ acc.tokensOut += agent.usage?.output ?? agent.progress?.tokens ?? 0;
334
+ acc.toolUses += agent.toolUses ?? agent.progress?.toolCount ?? 0;
335
+ return acc;
336
+ }, { tokensIn: 0, tokensOut: 0, toolUses: 0 });
337
+ }
338
+
339
+ function isMailboxStatus(value: unknown): value is MailboxMessageStatus {
340
+ return value === "queued" || value === "delivered" || value === "acknowledged";
341
+ }
342
+
343
+ function readDeliveryMessages(filePath: string): Record<string, MailboxMessageStatus> {
344
+ try {
345
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8")) as unknown;
346
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
347
+ const messages = (parsed as { messages?: unknown }).messages;
348
+ if (!messages || typeof messages !== "object" || Array.isArray(messages)) return {};
349
+ const output: Record<string, MailboxMessageStatus> = {};
350
+ for (const [id, status] of Object.entries(messages)) if (isMailboxStatus(status)) output[id] = status;
351
+ return output;
352
+ } catch {
353
+ return {};
354
+ }
355
+ }
356
+
357
+ async function readDeliveryMessagesAsync(filePath: string): Promise<Record<string, MailboxMessageStatus>> {
358
+ try {
359
+ const content = await fs.promises.readFile(filePath, "utf-8");
360
+ const parsed = JSON.parse(content) as unknown;
361
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
362
+ const messages = (parsed as { messages?: unknown }).messages;
363
+ if (!messages || typeof messages !== "object" || Array.isArray(messages)) return {};
364
+ const output: Record<string, MailboxMessageStatus> = {};
365
+ for (const [id, status] of Object.entries(messages)) if (isMailboxStatus(status)) output[id] = status;
366
+ return output;
367
+ } catch {
368
+ return {};
369
+ }
370
+ }
371
+
372
+ function readGroupJoinMailbox(filePath: string, delivery: Record<string, MailboxMessageStatus>): RunUiGroupJoin[] {
373
+ return tailJsonlLines(filePath, MAX_TAIL_LINES, (line) => {
374
+ try {
375
+ const parsed = JSON.parse(line) as unknown;
376
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
377
+ const message = parsed as { id?: unknown; data?: unknown };
378
+ const data = message.data && typeof message.data === "object" && !Array.isArray(message.data) ? message.data as Record<string, unknown> : undefined;
379
+ if (typeof message.id !== "string" || data?.kind !== "group_join" || typeof data.requestId !== "string") return undefined;
380
+ return { requestId: data.requestId, messageId: message.id, partial: data.partial === true, ack: delivery[message.id] === "acknowledged" ? "acknowledged" as const : "pending" as const };
381
+ } catch {
382
+ return undefined;
383
+ }
384
+ });
385
+ }
386
+
387
+ async function readGroupJoinMailboxAsync(filePath: string, delivery: Record<string, MailboxMessageStatus>): Promise<RunUiGroupJoin[]> {
388
+ return tailJsonlLinesAsync(filePath, MAX_TAIL_LINES, (line) => {
389
+ try {
390
+ const parsed = JSON.parse(line) as unknown;
391
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
392
+ const message = parsed as { id?: unknown; data?: unknown };
393
+ const data = message.data && typeof message.data === "object" && !Array.isArray(message.data) ? message.data as Record<string, unknown> : undefined;
394
+ if (typeof message.id !== "string" || data?.kind !== "group_join" || typeof data.requestId !== "string") return undefined;
395
+ return { requestId: data.requestId, messageId: message.id, partial: data.partial === true, ack: delivery[message.id] === "acknowledged" ? "acknowledged" as const : "pending" as const };
396
+ } catch {
397
+ return undefined;
398
+ }
399
+ });
400
+ }
401
+
402
+ interface MailboxCount {
403
+ count: number;
404
+ approximate: boolean;
405
+ }
406
+
407
+ interface MailboxKindCount extends MailboxCount {
408
+ steer: number;
409
+ followUp: number;
410
+ response: number;
411
+ message: number;
412
+ }
413
+
414
+ function tailApproximate(filePath: string): boolean {
415
+ try {
416
+ return fs.statSync(filePath).size > MAX_TAIL_BYTES;
417
+ } catch {
418
+ return false;
419
+ }
420
+ }
421
+
422
+ async function tailApproximateAsync(filePath: string): Promise<boolean> {
423
+ try {
424
+ return (await fs.promises.stat(filePath)).size > MAX_TAIL_BYTES;
425
+ } catch {
426
+ return false;
427
+ }
428
+ }
429
+
430
+ function readMailboxCounts(filePath: string, delivery: Record<string, MailboxMessageStatus>): MailboxKindCount {
431
+ const kindCounts = { steer: 0, followUp: 0, response: 0, message: 0 };
432
+ const items = tailJsonlLines(filePath, MAX_TAIL_LINES, (line) => {
433
+ try {
434
+ const parsed = JSON.parse(line) as unknown;
435
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return 0;
436
+ const msg = parsed as { id?: unknown; status?: unknown; kind?: unknown; data?: unknown };
437
+ if (typeof msg.id !== "string" || !isMailboxStatus(msg.status)) return 0;
438
+ if (msg.status !== "acknowledged" && delivery[msg.id] !== "acknowledged") {
439
+ const kind = typeof msg.kind === "string" ? msg.kind : typeof (msg.data as Record<string, unknown>)?.kind === "string" ? (msg.data as Record<string, unknown>).kind as string : undefined;
440
+ if (kind === "steer") kindCounts.steer++;
441
+ else if (kind === "follow-up") kindCounts.followUp++;
442
+ else if (kind === "response") kindCounts.response++;
443
+ else kindCounts.message++;
444
+ return 1;
445
+ }
446
+ return 0;
447
+ } catch {
448
+ return 0;
449
+ }
450
+ }) as number[];
451
+ const count = items.reduce((sum, val) => sum + val, 0);
452
+ return { count, approximate: tailApproximate(filePath), steer: kindCounts.steer, followUp: kindCounts.followUp, response: kindCounts.response, message: kindCounts.message };
453
+ }
454
+
455
+ async function readMailboxCountsAsync(filePath: string, delivery: Record<string, MailboxMessageStatus>): Promise<MailboxKindCount> {
456
+ const kindCounts = { steer: 0, followUp: 0, response: 0, message: 0 };
457
+ const items = await tailJsonlLinesAsync(filePath, MAX_TAIL_LINES, (line) => {
458
+ try {
459
+ const parsed = JSON.parse(line) as unknown;
460
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return 0;
461
+ const msg = parsed as { id?: unknown; status?: unknown; kind?: unknown; data?: unknown };
462
+ if (typeof msg.id !== "string" || !isMailboxStatus(msg.status)) return 0;
463
+ if (msg.status !== "acknowledged" && delivery[msg.id] !== "acknowledged") {
464
+ const kind = typeof msg.kind === "string" ? msg.kind : typeof (msg.data as Record<string, unknown>)?.kind === "string" ? (msg.data as Record<string, unknown>).kind as string : undefined;
465
+ if (kind === "steer") kindCounts.steer++;
466
+ else if (kind === "follow-up") kindCounts.followUp++;
467
+ else if (kind === "response") kindCounts.response++;
468
+ else kindCounts.message++;
469
+ return 1;
470
+ }
471
+ return 0;
472
+ } catch {
473
+ return 0;
474
+ }
475
+ }) as number[];
476
+ const count = items.reduce((sum, val) => sum + val, 0);
477
+ return { count, approximate: await tailApproximateAsync(filePath), steer: kindCounts.steer, followUp: kindCounts.followUp, response: kindCounts.response, message: kindCounts.message };
478
+ }
479
+
480
+ function groupJoinsFrom(manifest: TeamRunManifest): RunUiGroupJoin[] {
481
+ const root = path.join(manifest.stateRoot, "mailbox");
482
+ const delivery = readDeliveryMessages(path.join(root, "delivery.json"));
483
+ return readGroupJoinMailbox(path.join(root, "outbox.jsonl"), delivery).slice(-5);
484
+ }
485
+
486
+ async function groupJoinsFromAsync(manifest: TeamRunManifest): Promise<RunUiGroupJoin[]> {
487
+ const root = path.join(manifest.stateRoot, "mailbox");
488
+ const delivery = await readDeliveryMessagesAsync(path.join(root, "delivery.json"));
489
+ return (await readGroupJoinMailboxAsync(path.join(root, "outbox.jsonl"), delivery)).slice(-5);
490
+ }
491
+
492
+ function mergeKindCounts(a: MailboxKindCount, b: MailboxKindCount): MailboxKindCount {
493
+ return {
494
+ count: a.count + b.count,
495
+ approximate: a.approximate || b.approximate,
496
+ steer: a.steer + b.steer,
497
+ followUp: a.followUp + b.followUp,
498
+ response: a.response + b.response,
499
+ message: a.message + b.message,
500
+ };
501
+ }
502
+
503
+ function mailboxFrom(manifest: TeamRunManifest, agents: CrewAgentRecord[]): RunUiMailbox {
504
+ const root = path.join(manifest.stateRoot, "mailbox");
505
+ const delivery = readDeliveryMessages(path.join(root, "delivery.json"));
506
+ let inbox = readMailboxCounts(path.join(root, "inbox.jsonl"), delivery);
507
+ let outbox = readMailboxCounts(path.join(root, "outbox.jsonl"), delivery);
508
+ const tasksRoot = path.join(root, "tasks");
509
+ try {
510
+ for (const entry of fs.readdirSync(tasksRoot, { withFileTypes: true })) {
511
+ if (!entry.isDirectory()) continue;
512
+ const taskInbox = readMailboxCounts(path.join(tasksRoot, entry.name, "inbox.jsonl"), delivery);
513
+ const taskOutbox = readMailboxCounts(path.join(tasksRoot, entry.name, "outbox.jsonl"), delivery);
514
+ inbox = mergeKindCounts(inbox, taskInbox);
515
+ outbox = mergeKindCounts(outbox, taskOutbox);
516
+ }
517
+ } catch {
518
+ // No task mailboxes yet.
519
+ }
520
+ const attentionAgents = agents.filter((agent) => agent.progress?.activityState === "needs_attention").length;
521
+ return {
522
+ inboxUnread: inbox.count, outboxPending: outbox.count, needsAttention: inbox.count + attentionAgents, approximate: inbox.approximate || outbox.approximate,
523
+ steerUnread: inbox.steer + outbox.steer, followUpUnread: inbox.followUp + outbox.followUp, responseUnread: inbox.response + outbox.response, messageUnread: inbox.message + outbox.message,
524
+ };
525
+ }
526
+
527
+ async function mailboxFromAsync(manifest: TeamRunManifest, agents: CrewAgentRecord[]): Promise<RunUiMailbox> {
528
+ const root = path.join(manifest.stateRoot, "mailbox");
529
+ const delivery = await readDeliveryMessagesAsync(path.join(root, "delivery.json"));
530
+ let inbox = await readMailboxCountsAsync(path.join(root, "inbox.jsonl"), delivery);
531
+ let outbox = await readMailboxCountsAsync(path.join(root, "outbox.jsonl"), delivery);
532
+ const tasksRoot = path.join(root, "tasks");
533
+ try {
534
+ for (const entry of await fs.promises.readdir(tasksRoot, { withFileTypes: true })) {
535
+ if (!entry.isDirectory()) continue;
536
+ const taskInbox = await readMailboxCountsAsync(path.join(tasksRoot, entry.name, "inbox.jsonl"), delivery);
537
+ const taskOutbox = await readMailboxCountsAsync(path.join(tasksRoot, entry.name, "outbox.jsonl"), delivery);
538
+ inbox = mergeKindCounts(inbox, taskInbox);
539
+ outbox = mergeKindCounts(outbox, taskOutbox);
540
+ }
541
+ } catch {
542
+ // No task mailboxes yet.
543
+ }
544
+ const attentionAgents = agents.filter((agent) => agent.progress?.activityState === "needs_attention").length;
545
+ return {
546
+ inboxUnread: inbox.count, outboxPending: outbox.count, needsAttention: inbox.count + attentionAgents, approximate: inbox.approximate || outbox.approximate,
547
+ steerUnread: inbox.steer + outbox.steer, followUpUnread: inbox.followUp + outbox.followUp, responseUnread: inbox.response + outbox.response, messageUnread: inbox.message + outbox.message,
548
+ };
549
+ }
550
+
551
+ function cancellationReasonFromEvents(events: TeamEvent[]): string | undefined {
552
+ return [...events].reverse().find((event) => event.type === "run.cancelled" && typeof event.data?.reason === "string")?.data?.reason as string | undefined;
553
+ }
554
+
555
+ function signatureFor(input: Omit<RunUiSnapshot, "signature" | "fetchedAt" | "sliceSignatures">, stamps: SnapshotStamps): string {
556
+ try {
557
+ const digest = createHash("sha256");
558
+ digest.update(JSON.stringify({
559
+ run: [input.manifest.runId, input.manifest.status, input.manifest.updatedAt, input.manifest.artifacts.length],
560
+ tasks: input.tasks.map((task) => [task.id, task.status, task.startedAt, task.finishedAt, task.agentProgress, task.usage]),
561
+ agents: input.agents.map((agent) => [agent.id, agent.status, agent.startedAt, agent.completedAt, agent.toolUses, agent.progress, agent.usage, agent.model]),
562
+ progress: input.progress,
563
+ usage: input.usage,
564
+ mailbox: input.mailbox,
565
+ groupJoins: input.groupJoins,
566
+ events: input.recentEvents.map((event) => [event.metadata?.seq, event.time, event.type, event.taskId, event.message, event.data?.reason]),
567
+ cancellationReason: input.cancellationReason,
568
+ output: input.recentOutputLines,
569
+ stamps,
570
+ }));
571
+ return digest.digest("hex").slice(0, 16);
572
+ } catch {
573
+ // Circular reference or non-serializable data fall back to timestamp.
574
+ return String(Date.now());
575
+ }
576
+ }
577
+
578
+ /**
579
+ * 1.6 / 1.7 compute one short hash per logical slice of the snapshot so
580
+ * dashboard panes / widget can short-circuit when their slice hasn't moved.
581
+ * The slice contents must mirror what `signatureFor` packs into each branch.
582
+ */
583
+ function sliceSignaturesFor(input: Omit<RunUiSnapshot, "signature" | "fetchedAt" | "sliceSignatures">): RunUiSnapshot["sliceSignatures"] {
584
+ const hash = (value: unknown): string => {
585
+ try {
586
+ return createHash("sha256").update(JSON.stringify(value)).digest("hex").slice(0, 12);
587
+ } catch {
588
+ return String(Date.now());
589
+ }
590
+ };
591
+ return {
592
+ tasks: hash(input.tasks.map((task) => [task.id, task.status, task.startedAt, task.finishedAt, task.agentProgress, task.usage])),
593
+ agents: hash(input.agents.map((agent) => [agent.id, agent.status, agent.startedAt, agent.completedAt, agent.toolUses, agent.progress, agent.usage, agent.model])),
594
+ mailbox: hash([input.mailbox, input.groupJoins]),
595
+ progress: hash([input.progress, input.usage, input.cancellationReason]),
596
+ events: hash(input.recentEvents.map((event) => [event.metadata?.seq, event.time, event.type, event.taskId, event.message, event.data?.reason])),
597
+ };
598
+ }
599
+
600
+ function stampsFor(manifest: TeamRunManifest, _agents: CrewAgentRecord[]): SnapshotStamps {
601
+ // 1.4: use events sequence file instead of stat-ing the events log directly.
602
+ // 1.5: drop per-agent output.log stamping; rely on event-bus invalidation
603
+ // (`crew.subagent.*` and stream events) and on agents.json mtime which
604
+ // updates whenever crew-agent-records.appendCrewAgentOutput touches the
605
+ // aggregate. Saves O(N) statSync per render tick.
606
+ return {
607
+ manifest: stampFile(path.join(manifest.stateRoot, "manifest.json")),
608
+ tasks: stampFile(manifest.tasksPath),
609
+ agents: stampFile(agentsPath(manifest)),
610
+ events: eventsStamp(manifest.eventsPath),
611
+ mailbox: mailboxStamp(manifest),
612
+ };
613
+ }
614
+
615
+ async function stampsForAsync(manifest: TeamRunManifest, _agents: CrewAgentRecord[]): Promise<SnapshotStamps> {
616
+ const [manifestStamp, tasksStamp, agentsStamp, eventsStampValue, mailbox] = await Promise.all([
617
+ stampFileAsync(path.join(manifest.stateRoot, "manifest.json")),
618
+ stampFileAsync(manifest.tasksPath),
619
+ stampFileAsync(agentsPath(manifest)),
620
+ eventsStampAsync(manifest.eventsPath),
621
+ mailboxStampAsync(manifest),
622
+ ]);
623
+ return { manifest: manifestStamp, tasks: tasksStamp, agents: agentsStamp, events: eventsStampValue, mailbox };
624
+ }
625
+
626
+ export function createRunSnapshotCache(cwd: string, options: RunSnapshotCacheOptions = {}): RunSnapshotCache {
627
+ const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
628
+ const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
629
+ const recentEventsLimit = options.recentEvents ?? DEFAULT_RECENT_EVENTS;
630
+ const recentOutputLimit = options.recentOutputLines ?? DEFAULT_RECENT_OUTPUT_LINES;
631
+ const entries = new Map<string, CacheEntry>();
632
+
633
+ function touch(runId: string, entry: CacheEntry): RunUiSnapshot {
634
+ entry.lastAccessMs = Date.now();
635
+ if (entries.has(runId)) {
636
+ entries.delete(runId);
637
+ entries.set(runId, entry);
638
+ }
639
+ return entry.snapshot;
640
+ }
641
+
642
+ function evictIfNeeded(): void {
643
+ while (entries.size > maxEntries) {
644
+ const oldestInactive = [...entries.entries()].find(([, entry]) => !isActiveRunStatus(entry.snapshot.manifest.status));
645
+ const key = oldestInactive?.[0] ?? entries.keys().next().value;
646
+ if (!key) break;
647
+ entries.delete(key);
648
+ }
649
+ }
650
+
651
+ function build(runId: string, previous?: CacheEntry): CacheEntry {
652
+ let loaded: ReturnType<typeof loadRunManifestById>;
653
+ try {
654
+ loaded = loadRunManifestById(cwd, runId);
655
+ } catch {
656
+ if (previous) return previous;
657
+ throw new Error(`Run '${runId}' could not be parsed.`);
658
+ }
659
+ if (!loaded) {
660
+ if (previous) return previous;
661
+ throw new Error(`Run '${runId}' not found.`);
662
+ }
663
+ let tasks: TeamTaskState[];
664
+ let agents: CrewAgentRecord[];
665
+ try {
666
+ tasks = readTasks(loaded.manifest.tasksPath);
667
+ agents = readCrewAgents(loaded.manifest);
668
+ } catch {
669
+ if (previous) return previous;
670
+ throw new Error(`Run '${runId}' could not be parsed.`);
671
+ }
672
+ const mailbox = mailboxFrom(loaded.manifest, agents);
673
+ const groupJoins = groupJoinsFrom(loaded.manifest);
674
+ const recentEvents = safeRecentEvents(loaded.manifest.eventsPath, recentEventsLimit);
675
+ const base = {
676
+ runId: loaded.manifest.runId,
677
+ cwd: loaded.manifest.cwd,
678
+ manifest: loaded.manifest,
679
+ tasks,
680
+ agents,
681
+ progress: progressFromTasks(tasks),
682
+ usage: usageFrom(tasks, agents),
683
+ mailbox,
684
+ groupJoins,
685
+ cancellationReason: cancellationReasonFromEvents(recentEvents),
686
+ recentEvents,
687
+ recentOutputLines: recentOutputLines(loaded.manifest, agents, recentOutputLimit),
688
+ };
689
+ const stamps = stampsFor(loaded.manifest, agents);
690
+ const snapshot: RunUiSnapshot = { ...base, fetchedAt: Date.now(), signature: signatureFor(base, stamps), sliceSignatures: sliceSignaturesFor(base) };
691
+ return { snapshot, stamps, loadedAtMs: snapshot.fetchedAt, lastAccessMs: snapshot.fetchedAt };
692
+ }
693
+
694
+ async function buildAsync(runId: string, previous?: CacheEntry): Promise<CacheEntry> {
695
+ let loaded: Awaited<ReturnType<typeof loadRunManifestByIdAsync>>;
696
+ try {
697
+ loaded = await loadRunManifestByIdAsync(cwd, runId);
698
+ } catch {
699
+ if (previous) return previous;
700
+ throw new Error(`Run '${runId}' could not be parsed.`);
701
+ }
702
+ if (!loaded) {
703
+ if (previous) return previous;
704
+ throw new Error(`Run '${runId}' not found.`);
705
+ }
706
+ let tasks: TeamTaskState[];
707
+ let agents: CrewAgentRecord[];
708
+ try {
709
+ tasks = loaded.tasks;
710
+ agents = await readCrewAgentsAsync(loaded.manifest);
711
+ } catch {
712
+ if (previous) return previous;
713
+ throw new Error(`Run '${runId}' could not be parsed.`);
714
+ }
715
+ const [mailbox, groupJoins, recentEvents, recentOutput] = await Promise.all([
716
+ mailboxFromAsync(loaded.manifest, agents),
717
+ groupJoinsFromAsync(loaded.manifest),
718
+ safeRecentEventsAsync(loaded.manifest.eventsPath, recentEventsLimit),
719
+ recentOutputLinesAsync(loaded.manifest, agents, recentOutputLimit),
720
+ ]);
721
+ const base = {
722
+ runId: loaded.manifest.runId,
723
+ cwd: loaded.manifest.cwd,
724
+ manifest: loaded.manifest,
725
+ tasks,
726
+ agents,
727
+ progress: progressFromTasks(tasks),
728
+ usage: usageFrom(tasks, agents),
729
+ mailbox,
730
+ groupJoins,
731
+ cancellationReason: cancellationReasonFromEvents(recentEvents),
732
+ recentEvents,
733
+ recentOutputLines: recentOutput,
734
+ };
735
+ const stamps = await stampsForAsync(loaded.manifest, agents);
736
+ const snapshot: RunUiSnapshot = { ...base, fetchedAt: Date.now(), signature: signatureFor(base, stamps), sliceSignatures: sliceSignaturesFor(base) };
737
+ return { snapshot, stamps, loadedAtMs: snapshot.fetchedAt, lastAccessMs: snapshot.fetchedAt };
738
+ }
739
+
740
+ function currentStamps(previous: CacheEntry): SnapshotStamps {
741
+ const manifest = previous.snapshot.manifest;
742
+ return {
743
+ manifest: stampFile(path.join(manifest.stateRoot, "manifest.json")),
744
+ tasks: stampFile(manifest.tasksPath),
745
+ agents: stampFile(agentsPath(manifest)),
746
+ events: eventsStamp(manifest.eventsPath),
747
+ mailbox: mailboxStamp(manifest),
748
+ };
749
+ }
750
+
751
+ async function currentStampsAsync(previous: CacheEntry): Promise<SnapshotStamps> {
752
+ return stampsForAsync(previous.snapshot.manifest, previous.snapshot.agents);
753
+ }
754
+
755
+ async function preloadStale(runId: string): Promise<RunUiSnapshot | undefined> {
756
+ const previous = entries.get(runId);
757
+ const now = Date.now();
758
+ // Fresh enough? Return immediately
759
+ if (previous && now - previous.loadedAtMs < ttlMs) {
760
+ return touch(runId, previous);
761
+ }
762
+ // Check stamps async
763
+ if (previous) {
764
+ const stamps = await currentStampsAsync(previous);
765
+ if (sameStamps(stamps, previous.stamps)) {
766
+ previous.loadedAtMs = now;
767
+ return touch(runId, previous);
768
+ }
769
+ }
770
+ // Full async build
771
+ const entry = await buildAsync(runId, previous);
772
+ entries.set(runId, entry);
773
+ evictIfNeeded();
774
+ return entry.snapshot;
775
+ }
776
+
777
+ async function preloadAllStale(runIds: string[]): Promise<void> {
778
+ const batchSize = 4;
779
+ for (let i = 0; i < runIds.length; i += batchSize) {
780
+ const batch = runIds.slice(i, i + batchSize);
781
+ await Promise.all(batch.map((id) => preloadStale(id)));
782
+ }
783
+ }
784
+
785
+ const unsubscribe = runEventBus.onAny((event) => {
786
+ if (entries.has(event.runId)) {
787
+ entries.delete(event.runId);
788
+ }
789
+ });
790
+
791
+ return {
792
+ get(runId: string): RunUiSnapshot | undefined {
793
+ const entry = entries.get(runId);
794
+ return entry ? touch(runId, entry) : undefined;
795
+ },
796
+ refresh(runId: string): RunUiSnapshot {
797
+ const previous = entries.get(runId);
798
+ const entry = build(runId, previous);
799
+ entries.set(runId, entry);
800
+ evictIfNeeded();
801
+ return entry.snapshot;
802
+ },
803
+ refreshIfStale(runId: string): RunUiSnapshot {
804
+ const previous = entries.get(runId);
805
+ if (!previous) return this.refresh(runId);
806
+ const now = Date.now();
807
+ if (now - previous.loadedAtMs < ttlMs) return touch(runId, previous);
808
+ const stamps = currentStamps(previous);
809
+ if (sameStamps(stamps, previous.stamps)) return touch(runId, previous);
810
+ return this.refresh(runId);
811
+ },
812
+ preloadStale,
813
+ preloadAllStale,
814
+ invalidate(runId?: string): void {
815
+ if (runId) entries.delete(runId);
816
+ else entries.clear();
817
+ },
818
+ snapshotsByKey(): Map<string, RunUiSnapshot> {
819
+ return new Map([...entries.entries()].map(([key, entry]) => [key, entry.snapshot]));
820
+ },
821
+ dispose(): void {
822
+ unsubscribe();
823
+ entries.clear();
824
+ },
825
+ };
826
+ }