pi-crew 0.2.3 → 0.2.5

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 (348) hide show
  1. package/AGENTS.md +57 -32
  2. package/CHANGELOG.md +466 -448
  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 -592
  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-review-round4-2026-05-13.md +107 -0
  25. package/docs/implementation-plan-top3.md +333 -0
  26. package/docs/live-mailbox-runtime.md +36 -36
  27. package/docs/next-upgrade-roadmap.md +808 -808
  28. package/docs/oh-my-pi-research.md +509 -0
  29. package/docs/perf/baseline-2026-05.md +113 -0
  30. package/docs/perf/final-report-2026-05.md +206 -0
  31. package/docs/perf/sprint-1-report.md +71 -0
  32. package/docs/perf/sprint-2-report.md +81 -0
  33. package/docs/perf/sprint-2.5-report.md +53 -0
  34. package/docs/perf/sprint-3-report.md +36 -0
  35. package/docs/perf/sprint-4-report.md +47 -0
  36. package/docs/perf/sprint-5-report.md +51 -0
  37. package/docs/perf/sprint-6-report.md +94 -0
  38. package/docs/perf/sprint-7-report.md +74 -0
  39. package/docs/perf/upgrade-plan-2026-05.md +147 -0
  40. package/docs/pi-subagents3-deep-analysis.md +508 -0
  41. package/docs/product/README.md +31 -0
  42. package/docs/product/platform.md +27 -0
  43. package/docs/product/runtime-safety.md +37 -0
  44. package/docs/product/team-run.md +39 -0
  45. package/docs/product/team-tool.md +37 -0
  46. package/docs/publishing.md +65 -65
  47. package/docs/resource-formats.md +134 -134
  48. package/docs/runtime-analysis-child-vs-live.md +171 -0
  49. package/docs/runtime-flow.md +148 -148
  50. package/docs/runtime-migration-in-process-analysis.md +250 -0
  51. package/docs/stories/README.md +30 -0
  52. package/docs/stories/backlog.md +36 -0
  53. package/docs/templates/decision.md +27 -0
  54. package/docs/templates/story.md +44 -0
  55. package/docs/templates/validation-report.md +32 -0
  56. package/docs/usage.md +238 -238
  57. package/index.ts +7 -6
  58. package/install.mjs +65 -65
  59. package/package.json +107 -100
  60. package/schema.json +222 -222
  61. package/skills/child-pi-spawning/SKILL.md +213 -0
  62. package/skills/context-artifact-hygiene/SKILL.md +32 -0
  63. package/skills/event-log-tracing/SKILL.md +299 -0
  64. package/skills/git-master/SKILL.md +225 -24
  65. package/skills/live-agent-lifecycle/SKILL.md +192 -0
  66. package/skills/mailbox-interactive/SKILL.md +300 -19
  67. package/skills/model-routing-context/SKILL.md +94 -0
  68. package/skills/multi-perspective-review/SKILL.md +88 -0
  69. package/skills/read-only-explorer/SKILL.md +250 -26
  70. package/skills/safe-bash/SKILL.md +307 -21
  71. package/skills/verification-before-done/SKILL.md +11 -2
  72. package/skills/widget-rendering/SKILL.md +258 -0
  73. package/skills/workspace-isolation/SKILL.md +202 -0
  74. package/skills/worktree-isolation/SKILL.md +202 -18
  75. package/src/adapters/claude-adapter.ts +25 -25
  76. package/src/adapters/codex-adapter.ts +21 -21
  77. package/src/adapters/cursor-adapter.ts +17 -17
  78. package/src/adapters/export-util.ts +137 -137
  79. package/src/adapters/index.ts +15 -15
  80. package/src/adapters/registry.ts +18 -18
  81. package/src/adapters/types.ts +23 -23
  82. package/src/agents/agent-config.ts +38 -38
  83. package/src/agents/agent-serializer.ts +38 -38
  84. package/src/agents/discover-agents.ts +121 -118
  85. package/src/config/config.ts +740 -858
  86. package/src/config/defaults.ts +96 -96
  87. package/src/config/drift-detector.ts +211 -211
  88. package/src/config/markers.ts +327 -327
  89. package/src/config/resilient-parser.ts +109 -108
  90. package/src/config/suggestions.ts +74 -74
  91. package/src/config/types.ts +199 -0
  92. package/src/extension/async-notifier.ts +123 -89
  93. package/src/extension/autonomous-policy.ts +169 -169
  94. package/src/extension/cross-extension-rpc.ts +104 -104
  95. package/src/extension/help.ts +47 -47
  96. package/src/extension/import-index.ts +69 -69
  97. package/src/extension/management.ts +395 -382
  98. package/src/extension/notification-router.ts +116 -116
  99. package/src/extension/notification-sink.ts +51 -51
  100. package/src/extension/project-init.ts +168 -168
  101. package/src/extension/register.ts +859 -668
  102. package/src/extension/registration/artifact-cleanup.ts +15 -15
  103. package/src/extension/registration/command-utils.ts +54 -54
  104. package/src/extension/registration/commands.ts +559 -452
  105. package/src/extension/registration/compaction-guard.ts +125 -125
  106. package/src/extension/registration/subagent-helpers.ts +102 -102
  107. package/src/extension/registration/subagent-tools.ts +220 -159
  108. package/src/extension/registration/team-tool.ts +159 -99
  109. package/src/extension/registration/viewers.ts +29 -0
  110. package/src/extension/result-watcher.ts +128 -128
  111. package/src/extension/run-bundle-schema.ts +89 -89
  112. package/src/extension/run-export.ts +73 -73
  113. package/src/extension/run-import.ts +84 -84
  114. package/src/extension/run-index.ts +94 -94
  115. package/src/extension/run-maintenance.ts +142 -142
  116. package/src/extension/session-summary.ts +8 -8
  117. package/src/extension/team-manager-command.ts +96 -96
  118. package/src/extension/team-recommendation.ts +188 -188
  119. package/src/extension/team-tool/api.ts +5 -2
  120. package/src/extension/team-tool/cancel.ts +224 -209
  121. package/src/extension/team-tool/config-patch.ts +36 -36
  122. package/src/extension/team-tool/context.ts +60 -60
  123. package/src/extension/team-tool/doctor.ts +242 -242
  124. package/src/extension/team-tool/handle-settings.ts +421 -195
  125. package/src/extension/team-tool/inspect.ts +41 -41
  126. package/src/extension/team-tool/lifecycle-actions.ts +139 -139
  127. package/src/extension/team-tool/parallel-dispatch.ts +156 -156
  128. package/src/extension/team-tool/plan.ts +19 -19
  129. package/src/extension/team-tool/respond.ts +112 -111
  130. package/src/extension/team-tool/run.ts +246 -229
  131. package/src/extension/team-tool/status.ts +110 -110
  132. package/src/extension/team-tool-types.ts +13 -13
  133. package/src/extension/team-tool.ts +344 -344
  134. package/src/extension/tool-result.ts +16 -16
  135. package/src/extension/validate-resources.ts +77 -77
  136. package/src/hooks/registry.ts +61 -61
  137. package/src/hooks/types.ts +40 -40
  138. package/src/i18n.ts +184 -184
  139. package/src/observability/correlation.ts +35 -35
  140. package/src/observability/event-to-metric.ts +68 -68
  141. package/src/observability/exporters/adapter.ts +30 -30
  142. package/src/observability/exporters/otlp-exporter.ts +106 -92
  143. package/src/observability/exporters/prometheus-exporter.ts +54 -54
  144. package/src/observability/metric-registry.ts +87 -87
  145. package/src/observability/metric-retention.ts +54 -54
  146. package/src/observability/metric-sink.ts +81 -56
  147. package/src/observability/metrics-primitives.ts +167 -167
  148. package/src/prompt/prompt-runtime.ts +72 -72
  149. package/src/runtime/adaptive-plan.ts +338 -0
  150. package/src/runtime/agent-control.ts +169 -169
  151. package/src/runtime/agent-memory.ts +72 -72
  152. package/src/runtime/agent-observability.ts +114 -114
  153. package/src/runtime/async-marker.ts +26 -26
  154. package/src/runtime/async-runner.ts +153 -153
  155. package/src/runtime/attention-events.ts +28 -28
  156. package/src/runtime/auto-resume.ts +100 -100
  157. package/src/runtime/background-runner.ts +122 -89
  158. package/src/runtime/cancellation.ts +61 -61
  159. package/src/runtime/capability-inventory.ts +116 -116
  160. package/src/runtime/child-pi-pool.ts +68 -0
  161. package/src/runtime/child-pi.ts +541 -461
  162. package/src/runtime/code-summary.ts +247 -247
  163. package/src/runtime/compaction-summary.ts +271 -271
  164. package/src/runtime/concurrency.ts +58 -58
  165. package/src/runtime/crash-recovery.ts +317 -301
  166. package/src/runtime/crew-agent-records.ts +379 -281
  167. package/src/runtime/crew-agent-runtime.ts +60 -60
  168. package/src/runtime/cross-extension-rpc.ts +72 -0
  169. package/src/runtime/custom-tools/irc-tool.ts +201 -201
  170. package/src/runtime/custom-tools/submit-result-tool.ts +90 -90
  171. package/src/runtime/deadletter.ts +47 -47
  172. package/src/runtime/delivery-coordinator.ts +176 -176
  173. package/src/runtime/delta-conflict.ts +360 -360
  174. package/src/runtime/diagnostic-export.ts +102 -102
  175. package/src/runtime/direct-run.ts +35 -35
  176. package/src/runtime/effectiveness.ts +82 -81
  177. package/src/runtime/errors/crew-errors.ts +166 -0
  178. package/src/runtime/event-stream-bridge.ts +92 -92
  179. package/src/runtime/foreground-control.ts +82 -82
  180. package/src/runtime/green-contract.ts +46 -46
  181. package/src/runtime/group-join.ts +234 -106
  182. package/src/runtime/heartbeat-watcher.ts +145 -124
  183. package/src/runtime/iteration-hooks.ts +267 -267
  184. package/src/runtime/live-agent-control.ts +88 -88
  185. package/src/runtime/live-agent-manager.ts +377 -179
  186. package/src/runtime/live-control-realtime.ts +36 -36
  187. package/src/runtime/live-session-runtime.ts +676 -600
  188. package/src/runtime/loop-gates.ts +129 -129
  189. package/src/runtime/manifest-cache.ts +263 -263
  190. package/src/runtime/mcp-proxy.ts +113 -113
  191. package/src/runtime/metric-parser.ts +40 -40
  192. package/src/runtime/model-fallback.ts +282 -274
  193. package/src/runtime/model-resolver.ts +118 -0
  194. package/src/runtime/output-validator.ts +187 -187
  195. package/src/runtime/overflow-recovery.ts +175 -175
  196. package/src/runtime/parallel-research.ts +44 -44
  197. package/src/runtime/parallel-utils.ts +156 -156
  198. package/src/runtime/parent-guard.ts +80 -80
  199. package/src/runtime/phase-progress.ts +217 -217
  200. package/src/runtime/pi-args.ts +165 -165
  201. package/src/runtime/pi-json-output.ts +111 -111
  202. package/src/runtime/pi-spawn.ts +167 -167
  203. package/src/runtime/policy-engine.ts +79 -79
  204. package/src/runtime/post-checks.ts +125 -125
  205. package/src/runtime/post-exit-stdio-guard.ts +86 -86
  206. package/src/runtime/process-status.ts +97 -73
  207. package/src/runtime/progress-event-coalescer.ts +43 -43
  208. package/src/runtime/recovery-recipes.ts +74 -74
  209. package/src/runtime/retry-executor.ts +81 -81
  210. package/src/runtime/role-permission.ts +39 -39
  211. package/src/runtime/run-tracker.ts +99 -0
  212. package/src/runtime/runtime-policy.ts +21 -0
  213. package/src/runtime/runtime-resolver.ts +94 -91
  214. package/src/runtime/scheduler.ts +294 -0
  215. package/src/runtime/semaphore.ts +131 -131
  216. package/src/runtime/sensitive-paths.ts +92 -92
  217. package/src/runtime/session-usage.ts +79 -79
  218. package/src/runtime/settings-store.ts +103 -0
  219. package/src/runtime/sidechain-output.ts +29 -29
  220. package/src/runtime/skill-instructions.ts +222 -222
  221. package/src/runtime/stale-reconciler.ts +198 -189
  222. package/src/runtime/streaming-output.ts +47 -0
  223. package/src/runtime/subagent-manager.ts +404 -400
  224. package/src/runtime/subprocess-tool-registry.ts +67 -67
  225. package/src/runtime/task-display.ts +38 -38
  226. package/src/runtime/task-graph-scheduler.ts +122 -122
  227. package/src/runtime/task-graph.ts +207 -207
  228. package/src/runtime/task-output-context.ts +177 -177
  229. package/src/runtime/task-packet.ts +93 -93
  230. package/src/runtime/task-quality.ts +207 -207
  231. package/src/runtime/task-runner/capabilities.ts +78 -78
  232. package/src/runtime/task-runner/live-executor.ts +131 -113
  233. package/src/runtime/task-runner/progress.ts +119 -119
  234. package/src/runtime/task-runner/prompt-builder.ts +139 -139
  235. package/src/runtime/task-runner/prompt-pipeline.ts +64 -64
  236. package/src/runtime/task-runner/result-utils.ts +14 -14
  237. package/src/runtime/task-runner/run-projection.ts +103 -103
  238. package/src/runtime/task-runner/state-helpers.ts +22 -22
  239. package/src/runtime/task-runner.ts +469 -459
  240. package/src/runtime/team-runner.ts +693 -945
  241. package/src/runtime/usage-tracker.ts +71 -0
  242. package/src/runtime/worker-heartbeat.ts +21 -21
  243. package/src/runtime/worker-startup.ts +57 -57
  244. package/src/runtime/workflow-state.ts +187 -187
  245. package/src/runtime/yield-handler.ts +190 -190
  246. package/src/schema/config-schema.ts +172 -168
  247. package/src/schema/team-tool-schema.ts +126 -126
  248. package/src/schema/validation-types.ts +151 -148
  249. package/src/skills/discover-skills.ts +67 -67
  250. package/src/skills/skill-templates.ts +374 -374
  251. package/src/state/active-run-registry.ts +227 -191
  252. package/src/state/artifact-store.ts +130 -129
  253. package/src/state/atomic-write.ts +262 -195
  254. package/src/state/blob-store.ts +116 -116
  255. package/src/state/contracts.ts +111 -111
  256. package/src/state/event-log-rotation.ts +161 -158
  257. package/src/state/event-log.ts +383 -303
  258. package/src/state/event-reconstructor.ts +217 -217
  259. package/src/state/jsonl-writer.ts +82 -82
  260. package/src/state/locks.ts +146 -146
  261. package/src/state/mailbox.ts +446 -405
  262. package/src/state/state-store.ts +364 -351
  263. package/src/state/task-claims.ts +44 -44
  264. package/src/state/types.ts +285 -285
  265. package/src/state/usage.ts +29 -29
  266. package/src/subagents/async-entry.ts +1 -1
  267. package/src/subagents/index.ts +3 -3
  268. package/src/subagents/live/control.ts +1 -1
  269. package/src/subagents/live/manager.ts +1 -1
  270. package/src/subagents/live/realtime.ts +1 -1
  271. package/src/subagents/live/session-runtime.ts +1 -1
  272. package/src/subagents/manager.ts +1 -1
  273. package/src/subagents/spawn.ts +1 -1
  274. package/src/teams/discover-teams.ts +116 -116
  275. package/src/teams/team-config.ts +27 -27
  276. package/src/teams/team-serializer.ts +38 -38
  277. package/src/types/diff.d.ts +18 -18
  278. package/src/ui/agent-management-overlay.ts +144 -144
  279. package/src/ui/crew-widget.ts +487 -370
  280. package/src/ui/dashboard-panes/agents-pane.ts +109 -28
  281. package/src/ui/dashboard-panes/cancellation-pane.ts +42 -42
  282. package/src/ui/dashboard-panes/capability-pane.ts +59 -59
  283. package/src/ui/dashboard-panes/health-pane.ts +30 -30
  284. package/src/ui/dashboard-panes/mailbox-pane.ts +35 -35
  285. package/src/ui/dashboard-panes/progress-pane.ts +30 -30
  286. package/src/ui/dashboard-panes/transcript-pane.ts +10 -10
  287. package/src/ui/heartbeat-aggregator.ts +63 -63
  288. package/src/ui/keybinding-map.ts +97 -94
  289. package/src/ui/live-conversation-overlay.ts +152 -0
  290. package/src/ui/live-run-sidebar.ts +180 -180
  291. package/src/ui/mascot.ts +442 -442
  292. package/src/ui/overlays/agent-picker-overlay.ts +57 -57
  293. package/src/ui/overlays/confirm-overlay.ts +58 -58
  294. package/src/ui/overlays/mailbox-compose-overlay.ts +144 -144
  295. package/src/ui/overlays/mailbox-compose-preview.ts +63 -63
  296. package/src/ui/overlays/mailbox-detail-overlay.ts +122 -122
  297. package/src/ui/pi-ui-compat.ts +57 -57
  298. package/src/ui/powerbar-publisher.ts +221 -197
  299. package/src/ui/render-scheduler.ts +216 -143
  300. package/src/ui/run-action-dispatcher.ts +118 -118
  301. package/src/ui/run-dashboard.ts +526 -464
  302. package/src/ui/run-event-bus.ts +208 -208
  303. package/src/ui/run-snapshot-cache.ts +826 -777
  304. package/src/ui/settings-overlay.ts +721 -0
  305. package/src/ui/snapshot-types.ts +86 -70
  306. package/src/ui/theme-adapter.ts +190 -190
  307. package/src/ui/tool-progress-formatter.ts +89 -0
  308. package/src/ui/transcript-cache.ts +94 -94
  309. package/src/ui/transcript-viewer.ts +335 -335
  310. package/src/utils/conflict-detect.ts +662 -0
  311. package/src/utils/file-coalescer.ts +86 -86
  312. package/src/utils/frontmatter.ts +68 -68
  313. package/src/utils/fs-watch.ts +88 -31
  314. package/src/utils/gh-protocol.ts +479 -0
  315. package/src/utils/ids.ts +17 -17
  316. package/src/utils/incremental-reader.ts +104 -104
  317. package/src/utils/internal-error.ts +6 -6
  318. package/src/utils/names.ts +27 -27
  319. package/src/utils/paths.ts +102 -63
  320. package/src/utils/redaction.ts +44 -44
  321. package/src/utils/safe-paths.ts +47 -47
  322. package/src/utils/scan-cache.ts +136 -136
  323. package/src/utils/sse-parser.ts +134 -134
  324. package/src/utils/task-name-generator.ts +337 -337
  325. package/src/utils/timings.ts +33 -33
  326. package/src/utils/visual.ts +243 -198
  327. package/src/workflows/discover-workflows.ts +139 -139
  328. package/src/workflows/validate-workflow.ts +40 -40
  329. package/src/workflows/workflow-config.ts +26 -26
  330. package/src/workflows/workflow-serializer.ts +32 -32
  331. package/src/worktree/branch-freshness.ts +45 -45
  332. package/src/worktree/cleanup.ts +75 -75
  333. package/src/worktree/worktree-manager.ts +188 -188
  334. package/teams/default.team.md +12 -12
  335. package/teams/fast-fix.team.md +11 -11
  336. package/teams/implementation.team.md +18 -18
  337. package/teams/parallel-research.team.md +14 -14
  338. package/teams/research.team.md +11 -11
  339. package/teams/review.team.md +12 -12
  340. package/tsconfig.json +19 -19
  341. package/workflows/default.workflow.md +30 -30
  342. package/workflows/fast-fix.workflow.md +23 -23
  343. package/workflows/implementation.workflow.md +43 -43
  344. package/workflows/parallel-research.workflow.md +46 -46
  345. package/workflows/research.workflow.md +22 -22
  346. package/workflows/review.workflow.md +30 -30
  347. package/skills/task-packet/SKILL.md +0 -28
  348. 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
+ }