pi-crew 0.10.2 → 0.10.3

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 (79) hide show
  1. package/CHANGELOG.md +249 -0
  2. package/dist/index.mjs +98 -307
  3. package/package.json +2 -1
  4. package/schema.json +11 -0
  5. package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +6 -2
  6. package/skills/real-test-pi-crew/SKILL.md +278 -79
  7. package/src/config/config-merge.ts +11 -1
  8. package/src/config/config-validation.ts +40 -1
  9. package/src/config/config.ts +28 -6
  10. package/src/config/defaults.ts +35 -10
  11. package/src/config/env-vars.ts +27 -2
  12. package/src/config/types.ts +36 -0
  13. package/src/extension/registration/lifecycle-handlers.ts +40 -9
  14. package/src/extension/registration/team-tool.ts +53 -5
  15. package/src/extension/team-tool/doctor.ts +364 -7
  16. package/src/extension/team-tool/handle-settings.ts +19 -0
  17. package/src/extension/team-tool/inspect.ts +10 -2
  18. package/src/extension/team-tool/status.ts +7 -0
  19. package/src/extension/team-tool.ts +35 -2
  20. package/src/hooks/registry.ts +59 -56
  21. package/src/prompt/inbox-poll.ts +90 -0
  22. package/src/prompt/message-tool.ts +166 -0
  23. package/src/prompt/prompt-runtime.ts +201 -18
  24. package/src/prompt/surface-worker.ts +720 -0
  25. package/src/prompt/worker-events-channel.ts +49 -3
  26. package/src/runtime/async-runner.ts +29 -1
  27. package/src/runtime/background-runner.ts +13 -7
  28. package/src/runtime/broker/broker-issuer.ts +27 -2
  29. package/src/runtime/broker/crew-broker-tokens.ts +56 -4
  30. package/src/runtime/broker/crew-broker.ts +261 -41
  31. package/src/runtime/child-pi/child-pi-spawn.ts +23 -9
  32. package/src/runtime/child-pi/child-pi-streams.ts +9 -1
  33. package/src/runtime/child-pi/child-pi.ts +353 -5
  34. package/src/runtime/crew-agent-records.ts +13 -1
  35. package/src/runtime/dispatch-batch.ts +12 -1
  36. package/src/runtime/event-log-tail-source.ts +374 -0
  37. package/src/runtime/finalize-run.ts +4 -0
  38. package/src/runtime/live-session/live-agent-manager.ts +34 -1
  39. package/src/runtime/live-session/live-control-realtime.ts +10 -0
  40. package/src/runtime/live-session/live-session-runtime.ts +47 -27
  41. package/src/runtime/manifest-cache.ts +128 -17
  42. package/src/runtime/model/pi-args.ts +54 -65
  43. package/src/runtime/output/sidechain-output.ts +61 -6
  44. package/src/runtime/process/proc-stat.ts +46 -0
  45. package/src/runtime/process/zombie-scanner.ts +32 -19
  46. package/src/runtime/spawn-policy.ts +27 -41
  47. package/src/runtime/surface/degrade.ts +776 -0
  48. package/src/runtime/surface/herdr-provider.ts +546 -0
  49. package/src/runtime/surface/launch-script.ts +172 -0
  50. package/src/runtime/surface/resolve-surface.ts +274 -0
  51. package/src/runtime/surface/surface-provider.ts +129 -0
  52. package/src/runtime/surface/surface-spawn.ts +475 -0
  53. package/src/runtime/surface/tmux-provider.ts +400 -0
  54. package/src/runtime/task-runner/child-executor.ts +47 -0
  55. package/src/runtime/task-runner/post-execution.ts +57 -2
  56. package/src/runtime/task-runner/prompt-builder.ts +1 -0
  57. package/src/runtime/task-runner/retrieval-orchestrator.ts +191 -56
  58. package/src/runtime/task-runner/state-helpers.ts +54 -30
  59. package/src/runtime/task-runner.ts +4 -2
  60. package/src/runtime/team-runner.ts +101 -0
  61. package/src/schema/config-schema.ts +24 -0
  62. package/src/state/atomic-write.ts +219 -40
  63. package/src/state/coordination/locks.ts +7 -5
  64. package/src/state/coordination/mailbox.ts +56 -10
  65. package/src/state/event-log/cursor.ts +413 -23
  66. package/src/state/event-log/event-log.ts +120 -113
  67. package/src/state/event-log/sequence-cache.ts +21 -3
  68. package/src/state/stores/state-store.ts +98 -6
  69. package/src/state/types.ts +51 -0
  70. package/src/ui/inline-panel/agent-pane.ts +3 -0
  71. package/src/ui/render-diff.ts +16 -8
  72. package/src/ui/run-dashboard.ts +87 -42
  73. package/src/ui/run-event-bus.ts +10 -1
  74. package/src/ui/run-snapshot-cache.ts +83 -35
  75. package/src/ui/transcript-cache.ts +101 -13
  76. package/src/ui/transcript-viewer.ts +92 -24
  77. package/src/ui/widget/index.ts +32 -8
  78. package/src/utils/visual.ts +43 -0
  79. package/src/worktree/worktree-manager.ts +65 -4
@@ -36,10 +36,10 @@ const WORD_DIFF_MIN_SIM = 0.15;
36
36
  * characters relative to the longer of the two strings, using word-level
37
37
  * diff to identify the common (unchanged) parts. Returns a value in [0, 1].
38
38
  */
39
- function computeSimilarity(oldContent: string, newContent: string): number {
40
- const wordDiff = Diff.diffWords(oldContent, newContent);
39
+ function computeSimilarity(oldContent: string, newContent: string, wordDiff?: Diff.Change[]): number {
40
+ const parts = wordDiff ?? Diff.diffWords(oldContent, newContent);
41
41
  let commonChars = 0;
42
- for (const part of wordDiff) {
42
+ for (const part of parts) {
43
43
  if (!part.removed && !part.added) {
44
44
  commonChars += part.value.length;
45
45
  }
@@ -49,14 +49,19 @@ function computeSimilarity(oldContent: string, newContent: string): number {
49
49
  return commonChars / maxLen;
50
50
  }
51
51
 
52
- function renderIntraLineDiff(theme: CrewTheme, oldContent: string, newContent: string): { removedLine: string; addedLine: string } {
53
- const wordDiff = Diff.diffWords(oldContent, newContent);
52
+ function renderIntraLineDiff(
53
+ theme: CrewTheme,
54
+ oldContent: string,
55
+ newContent: string,
56
+ wordDiff?: Diff.Change[],
57
+ ): { removedLine: string; addedLine: string } {
58
+ const parts = wordDiff ?? Diff.diffWords(oldContent, newContent);
54
59
  let removedLine = "";
55
60
  let addedLine = "";
56
61
  let isFirstRemoved = true;
57
62
  let isFirstAdded = true;
58
63
 
59
- for (const part of wordDiff) {
64
+ for (const part of parts) {
60
65
  if (part.removed) {
61
66
  let value = part.value;
62
67
  if (isFirstRemoved) {
@@ -130,9 +135,12 @@ export function renderDiff(diffText: string, options: RenderDiffOptions = {}): s
130
135
  if (removedLines.length === 1 && addedLines.length === 1) {
131
136
  const oldContent = replaceTabs(removedLines[0]!.content);
132
137
  const newContent = replaceTabs(addedLines[0]!.content);
133
- const similarity = computeSimilarity(oldContent, newContent);
138
+ // PERF (2026-08-24): diffWords is the expensive call and was run
139
+ // TWICE on identical inputs (similarity + render). One pass, shared.
140
+ const wordDiff = Diff.diffWords(oldContent, newContent);
141
+ const similarity = computeSimilarity(oldContent, newContent, wordDiff);
134
142
  if (similarity >= WORD_DIFF_MIN_SIM) {
135
- const { removedLine, addedLine } = renderIntraLineDiff(theme, oldContent, newContent);
143
+ const { removedLine, addedLine } = renderIntraLineDiff(theme, oldContent, newContent, wordDiff);
136
144
  result.push(theme.fg("toolDiffRemoved", `-${removedLines[0]!.lineNum} ${removedLine}`));
137
145
  result.push(theme.fg("toolDiffAdded", `+${addedLines[0]!.lineNum} ${addedLine}`));
138
146
  } else {
@@ -162,11 +162,11 @@ function formatAge(iso: string | undefined): string | undefined {
162
162
  return `${Math.floor(ms / 3_600_000)}h`;
163
163
  }
164
164
 
165
- function readProgressPreview(run: TeamRunManifest, maxLines = 5, snapshotCache?: RunSnapshotCache): string[] {
165
+ function readProgressPreview(run: TeamRunManifest, maxLines = 5, snapshotCache?: RunSnapshotCache, resolve?: SnapshotResolver): string[] {
166
166
  // P0-6: prefer the snapshot's `recentOutputLines` (no disk I/O) over reading the
167
167
  // progress artifact on every render. The progress artifact content is captured
168
168
  // into the snapshot's recent events / output pipeline upstream.
169
- const snapshot = snapshotFor(run, snapshotCache);
169
+ const snapshot = resolve ? resolve(run) : snapshotFor(run, snapshotCache);
170
170
  if (snapshot?.recentOutputLines?.length) {
171
171
  return ["Progress:", ...snapshot.recentOutputLines.slice(0, maxLines)];
172
172
  }
@@ -206,8 +206,20 @@ function snapshotFor(run: TeamRunManifest, snapshotCache?: RunSnapshotCache): Ru
206
206
  }
207
207
  }
208
208
 
209
- function readRunTasks(run: TeamRunManifest, snapshotCache?: RunSnapshotCache): TeamTaskState[] {
210
- const snapshot = snapshotFor(run, snapshotCache);
209
+ /**
210
+ * PERF (2026-08-24, task 20) — per-frame snapshot resolver. The render path
211
+ * used to resolve each run's snapshot 6-8 times per frame (refreshRuns,
212
+ * buildSignature, groupedRuns, per-row, selected-run), and every
213
+ * `snapshotFor → refreshIfStale` call can stat several files per run when its
214
+ * own TTL expires. `renderUnsafe` now builds a frame-local Map keyed by runId
215
+ * and threads this resolver through every render-path helper. Helpers keep
216
+ * their plain direct-resolution behavior when no resolver is passed
217
+ * (keypress handlers and other callers outside a render frame).
218
+ */
219
+ type SnapshotResolver = (run: TeamRunManifest) => RunUiSnapshot | undefined;
220
+
221
+ function readRunTasks(run: TeamRunManifest, snapshotCache?: RunSnapshotCache, resolve?: SnapshotResolver): TeamTaskState[] {
222
+ const snapshot = resolve ? resolve(run) : snapshotFor(run, snapshotCache);
211
223
  if (snapshot) return snapshot.tasks;
212
224
  // P0-6: when a snapshot cache is provided but hasn't populated yet, return
213
225
  // empty (the render loop falls back to the empty pane placeholder) instead
@@ -266,15 +278,15 @@ function agentPreviewLine(agent: CrewAgentRecord, task: TeamTaskState | undefine
266
278
  );
267
279
  }
268
280
 
269
- function readAgentPreview(run: TeamRunManifest, maxLines = 5, options: RunDashboardOptions = {}): string[] {
281
+ function readAgentPreview(run: TeamRunManifest, maxLines = 5, options: RunDashboardOptions = {}, resolve?: SnapshotResolver): string[] {
270
282
  try {
271
- const snapshot = snapshotFor(run, options.snapshotCache);
283
+ const snapshot = resolve ? resolve(run) : snapshotFor(run, options.snapshotCache);
272
284
  // P0-6: when a snapshot cache is provided but hasn't populated yet, return
273
285
  // the empty-pane placeholder instead of calling `readCrewAgents` (disk I/O)
274
286
  // on every render tick. Legacy callers (no cache) keep the disk-read path
275
287
  // so existing unit tests continue to assert against concrete agent data.
276
288
  const agents = snapshot?.agents ?? (options.snapshotCache ? [] : readCrewAgents(run));
277
- const tasks = snapshot?.tasks ?? readRunTasks(run, options.snapshotCache);
289
+ const tasks = snapshot?.tasks ?? readRunTasks(run, options.snapshotCache, resolve);
278
290
  if (!agents.length) return ["Agents: (none)"];
279
291
  const totals = tasks.reduce(
280
292
  (acc, task) => {
@@ -305,8 +317,8 @@ function readAgentPreview(run: TeamRunManifest, maxLines = 5, options: RunDashbo
305
317
  }
306
318
  }
307
319
 
308
- function agentsFor(run: TeamRunManifest, snapshotCache?: RunSnapshotCache): CrewAgentRecord[] {
309
- const snapshot = snapshotFor(run, snapshotCache);
320
+ function agentsFor(run: TeamRunManifest, snapshotCache?: RunSnapshotCache, resolve?: SnapshotResolver): CrewAgentRecord[] {
321
+ const snapshot = resolve ? resolve(run) : snapshotFor(run, snapshotCache);
310
322
  if (snapshot) return snapshot.agents;
311
323
  // P0-6: when a snapshot cache is provided but hasn't populated yet, return
312
324
  // empty (callers handle the empty-state placeholder) instead of calling
@@ -319,8 +331,14 @@ function agentsFor(run: TeamRunManifest, snapshotCache?: RunSnapshotCache): Crew
319
331
  }
320
332
  }
321
333
 
322
- function runLabel(run: TeamRunManifest, selected: boolean, snapshotCache?: RunSnapshotCache, maxW?: number): string {
323
- const agents = agentsFor(run, snapshotCache);
334
+ function runLabel(
335
+ run: TeamRunManifest,
336
+ selected: boolean,
337
+ snapshotCache?: RunSnapshotCache,
338
+ maxW?: number,
339
+ resolve?: SnapshotResolver,
340
+ ): string {
341
+ const agents = agentsFor(run, snapshotCache, resolve);
324
342
  const stale = isLikelyOrphanedActiveRun(run, agents);
325
343
  const running = agents.find((agent) => agent.status === "running");
326
344
  const queued = agents.find((agent) => agent.status === "queued");
@@ -366,11 +384,11 @@ interface ResolvedRun {
366
384
  status: RunStatus;
367
385
  }
368
386
 
369
- function resolveRuns(runs: TeamRunManifest[], snapshotCache?: RunSnapshotCache): Map<string, ResolvedRun> {
387
+ function resolveRuns(runs: TeamRunManifest[], snapshotCache?: RunSnapshotCache, resolve?: SnapshotResolver): Map<string, ResolvedRun> {
370
388
  const map = new Map<string, ResolvedRun>();
371
389
  for (const run of runs) {
372
- const snapshot = snapshotFor(run, snapshotCache);
373
- const agents = snapshot?.agents ?? agentsFor(run, snapshotCache);
390
+ const snapshot = resolve ? resolve(run) : snapshotFor(run, snapshotCache);
391
+ const agents = snapshot?.agents ?? agentsFor(run, snapshotCache, resolve);
374
392
  const displayRun = snapshot?.manifest ?? run;
375
393
  const status: RunStatus = isLikelyOrphanedActiveRun(displayRun, agents) ? "stale" : (displayRun.status as RunStatus);
376
394
  map.set(run.runId, { manifest: run, snapshot, agents, status });
@@ -378,8 +396,12 @@ function resolveRuns(runs: TeamRunManifest[], snapshotCache?: RunSnapshotCache):
378
396
  return map;
379
397
  }
380
398
 
381
- function groupedRuns(runs: TeamRunManifest[], snapshotCache?: RunSnapshotCache): Array<{ label: string; run?: TeamRunManifest }> {
382
- const resolved = resolveRuns(runs, snapshotCache);
399
+ function groupedRuns(
400
+ runs: TeamRunManifest[],
401
+ snapshotCache?: RunSnapshotCache,
402
+ resolve?: SnapshotResolver,
403
+ ): Array<{ label: string; run?: TeamRunManifest }> {
404
+ const resolved = resolveRuns(runs, snapshotCache, resolve);
383
405
  const rows: Array<{ label: string; run?: TeamRunManifest }> = [];
384
406
  const active = runs.filter((run) =>
385
407
  isDisplayActiveRun(resolved.get(run.runId)?.snapshot?.manifest ?? run, resolved.get(run.runId)?.agents ?? []),
@@ -392,8 +414,13 @@ function groupedRuns(runs: TeamRunManifest[], snapshotCache?: RunSnapshotCache):
392
414
  return rows;
393
415
  }
394
416
 
395
- function selectedRunFromGrouped(runs: TeamRunManifest[], selected: number, snapshotCache?: RunSnapshotCache): TeamRunManifest | undefined {
396
- return groupedRuns(runs, snapshotCache).filter((row) => row.run)[selected]?.run;
417
+ function selectedRunFromGrouped(
418
+ runs: TeamRunManifest[],
419
+ selected: number,
420
+ snapshotCache?: RunSnapshotCache,
421
+ resolve?: SnapshotResolver,
422
+ ): TeamRunManifest | undefined {
423
+ return groupedRuns(runs, snapshotCache, resolve).filter((row) => row.run)[selected]?.run;
397
424
  }
398
425
 
399
426
  function countByStatus(runs: TeamRunManifest[], snapshotCache?: RunSnapshotCache): string {
@@ -520,9 +547,9 @@ export class RunDashboard implements DashboardComponent {
520
547
  return Math.max(12, Math.min(36, rows - 2));
521
548
  }
522
549
 
523
- private refreshRuns(): void {
550
+ private refreshRuns(resolve?: SnapshotResolver): void {
524
551
  if (!this.options.runProvider) return;
525
- const selectedRunId = this.selectedRunId();
552
+ const selectedRunId = this.selectedRunId(resolve);
526
553
  const next = this.options.runProvider();
527
554
  // P3 (#8): re-apply the workspaceId filter on EVERY refresh, not just
528
555
  // the constructor. Without this, runs from other sessions leak back in
@@ -532,7 +559,7 @@ export class RunDashboard implements DashboardComponent {
532
559
  ? unfiltered.filter((run) => !run.ownerSessionId || run.ownerSessionId === this.options.workspaceId)
533
560
  : unfiltered;
534
561
  if (selectedRunId) {
535
- const nextIndex = groupedRuns(this.runs, this.options.snapshotCache)
562
+ const nextIndex = groupedRuns(this.runs, this.options.snapshotCache, resolve)
536
563
  .filter((row) => row.run)
537
564
  .findIndex((row) => row.run?.runId === selectedRunId);
538
565
  if (nextIndex >= 0) this.selected = nextIndex;
@@ -564,7 +591,7 @@ export class RunDashboard implements DashboardComponent {
564
591
  }
565
592
  }
566
593
 
567
- private buildSignature(): string {
594
+ private buildSignature(resolve?: SnapshotResolver): string {
568
595
  // 1.10 (UI-P1-2) — short-TTL cache so we don't re-read every run's
569
596
  // snapshot on every render tick. `snapshotFor → refreshIfStale` can
570
597
  // stat multiple files per run when its own TTL expires, and the
@@ -579,9 +606,9 @@ export class RunDashboard implements DashboardComponent {
579
606
  let hasRunning = false;
580
607
  const statuses = this.runs
581
608
  .map((run) => {
582
- const snapshot = snapshotFor(run, this.options.snapshotCache);
609
+ const snapshot = resolve ? resolve(run) : snapshotFor(run, this.options.snapshotCache);
583
610
  const displayRun = snapshot?.manifest ?? run;
584
- const agents = snapshot?.agents ?? agentsFor(run, this.options.snapshotCache);
611
+ const agents = snapshot?.agents ?? agentsFor(run, this.options.snapshotCache, resolve);
585
612
  const stale = isLikelyOrphanedActiveRun(displayRun, agents);
586
613
  const status: RunStatus = stale ? "stale" : (displayRun.status as RunStatus);
587
614
  if (status === "running" || agents.some((agent) => agent.status === "running")) hasRunning = true;
@@ -611,8 +638,8 @@ export class RunDashboard implements DashboardComponent {
611
638
  this.schedulerHandle?.dispose();
612
639
  }
613
640
 
614
- private selectedRunId(): string | undefined {
615
- return selectedRunFromGrouped(this.runs, this.selected, this.options.snapshotCache)?.runId;
641
+ private selectedRunId(resolve?: SnapshotResolver): string | undefined {
642
+ return selectedRunFromGrouped(this.runs, this.selected, this.options.snapshotCache, resolve)?.runId;
616
643
  }
617
644
 
618
645
  render(width: number): string[] {
@@ -625,13 +652,25 @@ export class RunDashboard implements DashboardComponent {
625
652
  }
626
653
 
627
654
  private renderUnsafe(width: number): string[] {
628
- this.refreshRuns();
629
- const signature = this.buildSignature();
655
+ // PERF (2026-08-24): snapshot resolution stat'd 7-8 files per run 6-8
656
+ // times per frame. Resolve once per frame into a local map and thread
657
+ // it through every render-path consumer below.
658
+ const frameSnapshots = new Map<string, RunUiSnapshot | undefined>();
659
+ const snapshotOnce: SnapshotResolver = (run) => {
660
+ if (!frameSnapshots.has(run.runId)) frameSnapshots.set(run.runId, snapshotFor(run, this.options.snapshotCache));
661
+ return frameSnapshots.get(run.runId);
662
+ };
663
+ this.refreshRuns(snapshotOnce);
664
+ const signature = this.buildSignature(snapshotOnce);
630
665
  if (signature !== this.cachedVersion || this.cachedWidth !== width) {
631
666
  const innerWidth = Math.max(20, width - 4);
632
667
  const borderWidth = Math.min(innerWidth, Math.max(0, width - 2));
633
668
  const fg = (color: Parameters<CrewTheme["fg"]>[0], text: string) => this.theme.fg(color, text);
634
- const borderFill = (count: number) => new DynamicCrewBorder(this.theme).render(count)[0];
669
+ // PERF (2026-08-24): DynamicCrewBorder caches the rendered fill per
670
+ // instance — a new instance per line defeated it. One per render
671
+ // pass; every border()/sep() call below reuses the cached line.
672
+ const crewBorder = new DynamicCrewBorder(this.theme);
673
+ const borderFill = (count: number) => crewBorder.render(count)[0];
635
674
  const border = (left: string, right: string) => `${fg("border", left)}${borderFill(borderWidth)}${fg("border", right)}`;
636
675
  const row = (text: string) => `│ ${pad(truncate(text, innerWidth - 1), innerWidth - 1)}│`;
637
676
  const sep = () => border("├", "┤");
@@ -655,7 +694,7 @@ export class RunDashboard implements DashboardComponent {
655
694
  lines.push(row(fg("dim", "Start one: team action='run' · r reload · Esc close")));
656
695
  } else {
657
696
  // L-1: windowed run list so the selection can never scroll off-screen.
658
- const allGrouped = groupedRuns(this.runs, this.options.snapshotCache);
697
+ const allGrouped = groupedRuns(this.runs, this.options.snapshotCache, snapshotOnce);
659
698
  const selectable = allGrouped.filter((rowItem) => rowItem.run);
660
699
  const selectableCount = selectable.length;
661
700
  if (this.selected > selectableCount - 1) this.selected = Math.max(0, selectableCount - 1);
@@ -668,11 +707,11 @@ export class RunDashboard implements DashboardComponent {
668
707
  continue;
669
708
  }
670
709
  const idx = selectable.findIndex((c) => c.run?.runId === rowItem.run?.runId);
671
- const snap = snapshotFor(rowItem.run, this.options.snapshotCache);
710
+ const snap = snapshotOnce(rowItem.run);
672
711
  const run = snap?.manifest ?? rowItem.run;
673
- const agents = snap?.agents ?? agentsFor(rowItem.run, this.options.snapshotCache);
712
+ const agents = snap?.agents ?? agentsFor(rowItem.run, this.options.snapshotCache, snapshotOnce);
674
713
  const status: RunStatus = isLikelyOrphanedActiveRun(run, agents) ? "stale" : (run.status as RunStatus);
675
- const label = runLabel(run, idx === this.selected, this.options.snapshotCache, innerWidth - 2);
714
+ const label = runLabel(run, idx === this.selected, this.options.snapshotCache, innerWidth - 2, snapshotOnce);
676
715
  lines.push(row(applyStatusColor(this.theme, status, label)));
677
716
  }
678
717
  } else {
@@ -682,25 +721,28 @@ export class RunDashboard implements DashboardComponent {
682
721
  for (let gi = this.runScrollOffset; gi < Math.min(this.runScrollOffset + win.slots, selectableCount); gi++) {
683
722
  const rowItem = selectable[gi];
684
723
  if (!rowItem?.run) continue;
685
- const snap = snapshotFor(rowItem.run, this.options.snapshotCache);
724
+ const snap = snapshotOnce(rowItem.run);
686
725
  const run = snap?.manifest ?? rowItem.run;
687
- const agents = snap?.agents ?? agentsFor(rowItem.run, this.options.snapshotCache);
726
+ const agents = snap?.agents ?? agentsFor(rowItem.run, this.options.snapshotCache, snapshotOnce);
688
727
  const status: RunStatus = isLikelyOrphanedActiveRun(run, agents) ? "stale" : (run.status as RunStatus);
689
- const label = runLabel(run, gi === this.selected, this.options.snapshotCache, innerWidth - 2);
728
+ const label = runLabel(run, gi === this.selected, this.options.snapshotCache, innerWidth - 2, snapshotOnce);
690
729
  lines.push(row(applyStatusColor(this.theme, status, label)));
691
730
  }
692
731
  if (win.hasBottom)
693
732
  lines.push(row(fg("dim", `↓ ${selectableCount - (this.runScrollOffset + win.slots)} more below`)));
694
733
  }
695
734
 
696
- // Selected run detail — compact
697
- const selectedRun = selectedRunFromGrouped(this.runs, this.selected, this.options.snapshotCache);
735
+ // Selected run detail — compact. PERF (2026-08-24): reuse the
736
+ // `selectable` rows already derived from the single groupedRuns()
737
+ // computation above instead of recomputing grouping a third time
738
+ // (`selectedRunFromGrouped` is exactly `selectable[selected].run`).
739
+ const selectedRun = selectable[Math.min(this.selected, selectable.length - 1)]?.run;
698
740
  if (selectedRun) {
699
- const snap = snapshotFor(selectedRun, this.options.snapshotCache);
741
+ const snap = snapshotOnce(selectedRun);
700
742
  const r = snap?.manifest ?? selectedRun;
701
- const agents = snap?.agents ?? agentsFor(selectedRun, this.options.snapshotCache);
743
+ const agents = snap?.agents ?? agentsFor(selectedRun, this.options.snapshotCache, snapshotOnce);
702
744
  const statusStr: RunStatus = isLikelyOrphanedActiveRun(r, agents) ? "stale" : (r.status as RunStatus);
703
- const selectedTasks = snap?.tasks ?? readRunTasks(r, this.options.snapshotCache);
745
+ const selectedTasks = snap?.tasks ?? readRunTasks(r, this.options.snapshotCache, snapshotOnce);
704
746
  lines.push(sep());
705
747
  lines.push(row(`${fg("accent", "▸")} ${truncate(sanitizeLine(r.goal), innerWidth - 6)}`));
706
748
  // L-2: surface the failure/cancellation reason inline for terminal runs.
@@ -741,7 +783,10 @@ export class RunDashboard implements DashboardComponent {
741
783
  : this.activePane === "plan"
742
784
  ? safeRenderPane("plan", () => renderPlanPane(snap, { diff: this.planDiff }))
743
785
  : safeRenderPane("transcript", () => renderTranscriptPane(snap))
744
- : [...readAgentPreview(r, 4, this.options), ...readProgressPreview(r, 2, this.options.snapshotCache)];
786
+ : [
787
+ ...readAgentPreview(r, 4, this.options, snapshotOnce),
788
+ ...readProgressPreview(r, 2, this.options.snapshotCache, snapshotOnce),
789
+ ];
745
790
  const filteredPane = paneLines.filter((l) => l && !l.includes("(none)") && l.trim() !== "");
746
791
  if (filteredPane.length > 0) {
747
792
  lines.push(row(fg("dim", `── ${this.activePane} ──`)));
@@ -359,7 +359,16 @@ export function teamEventToRunEventType(event: TeamEvent): RunEventType | undefi
359
359
  if (type === "run.blocked") return "run_blocked";
360
360
  if (type === "run.running") return "run_started";
361
361
  if (type === "run.cancelled") return "run_cancelled";
362
- if (type === "task.progress" || type === "mailbox.message_queued" || type === "mailbox.message_delivered") return "mailbox_updated";
362
+ if (
363
+ type === "task.progress" ||
364
+ type === "mailbox.message_queued" ||
365
+ type === "mailbox.message_delivered" ||
366
+ // Task 5b (§15.2 wake): broker-side worker→parent wake signal. Maps to
367
+ // the existing mailbox_updated/run:state channel so sidebar+widget
368
+ // refresh through the render scheduler without new UI code.
369
+ type === "worker.message"
370
+ )
371
+ return "mailbox_updated";
363
372
  if (type === "run.effectiveness" || type === "task.attention") return "effectiveness_changed";
364
373
  return undefined;
365
374
  }
@@ -25,6 +25,8 @@ import type {
25
25
  export interface RunSnapshotCache extends RunSnapshotCacheBase {
26
26
  preloadStale(runId: string): Promise<RunUiSnapshot | undefined>;
27
27
  preloadAllStale(runIds: string[]): Promise<void>;
28
+ /** Task 17 (perf/review-2026-08-24): watcher-facing coalesced async refresh. */
29
+ scheduleRefresh(runId: string): void;
28
30
  }
29
31
 
30
32
  /** WP-7 (R7): the plans slice + Plan pane load only when this flag is set —
@@ -191,9 +193,18 @@ function sameStamps(a: SnapshotStamps, b: SnapshotStamps): boolean {
191
193
  );
192
194
  }
193
195
 
194
- /** Tail-read JSONL lines from a file, returning parsed objects (limited). */
195
- function tailJsonlLines<T>(filePath: string, limit: number, parse: (line: string) => T | undefined): T[] {
196
- if (limit <= 0) return [];
196
+ /** Raw tail-window of a file: split lines plus whether the window clipped content. */
197
+ interface TailContent {
198
+ lines: string[];
199
+ approximate: boolean;
200
+ }
201
+
202
+ /**
203
+ * PERF (2026-08-24): single tail read of a file, shareable across consumers.
204
+ * `approximate` mirrors the old tailApproximate() stat (size > MAX_TAIL_BYTES)
205
+ * so callers keep reporting clipped mailboxes without re-statting.
206
+ */
207
+ function readTailContent(filePath: string): TailContent {
197
208
  try {
198
209
  const stat = fs.statSync(filePath);
199
210
  const bytesToRead = Math.min(stat.size, MAX_TAIL_BYTES);
@@ -201,21 +212,35 @@ function tailJsonlLines<T>(filePath: string, limit: number, parse: (line: string
201
212
  try {
202
213
  const buffer = Buffer.alloc(bytesToRead);
203
214
  fs.readSync(fd, buffer, 0, bytesToRead, stat.size - bytesToRead);
204
- const lines = buffer.toString("utf-8").split(/\r?\n/).filter(Boolean);
205
- return lines
206
- .flatMap((line) => {
207
- const item = parse(line);
208
- return item ? [item] : [];
209
- })
210
- .slice(-limit);
215
+ return {
216
+ lines: buffer.toString("utf-8").split(/\r?\n/).filter(Boolean),
217
+ approximate: stat.size > MAX_TAIL_BYTES,
218
+ };
211
219
  } finally {
212
220
  fs.closeSync(fd);
213
221
  }
214
222
  } catch {
215
- return [];
223
+ return { lines: [], approximate: false };
216
224
  }
217
225
  }
218
226
 
227
+ /** Parse pre-read tail lines, keeping the last `limit` parseable items. */
228
+ function parseTailLines<T>(lines: string[], limit: number, parse: (line: string) => T | undefined): T[] {
229
+ if (limit <= 0) return [];
230
+ return lines
231
+ .flatMap((line) => {
232
+ const item = parse(line);
233
+ return item ? [item] : [];
234
+ })
235
+ .slice(-limit);
236
+ }
237
+
238
+ /** Tail-read JSONL lines from a file, returning parsed objects (limited). */
239
+ function tailJsonlLines<T>(filePath: string, limit: number, parse: (line: string) => T | undefined): T[] {
240
+ if (limit <= 0) return [];
241
+ return parseTailLines(readTailContent(filePath).lines, limit, parse);
242
+ }
243
+
219
244
  /** Async tail-read JSONL lines from a file, returning parsed objects (limited). */
220
245
  async function tailJsonlLinesAsync<T>(filePath: string, limit: number, parse: (line: string) => T | undefined): Promise<T[]> {
221
246
  if (limit <= 0) return [];
@@ -406,8 +431,9 @@ async function readDeliveryMessagesAsync(filePath: string): Promise<Record<strin
406
431
  }
407
432
  }
408
433
 
409
- function readGroupJoinMailbox(filePath: string, delivery: Record<string, MailboxMessageStatus>): RunUiGroupJoin[] {
410
- return tailJsonlLines(filePath, MAX_TAIL_LINES, (line) => {
434
+ /** Parse pre-read outbox lines into group-join records (ack status from `delivery`). */
435
+ function parseGroupJoinLines(lines: string[], delivery: Record<string, MailboxMessageStatus>): RunUiGroupJoin[] {
436
+ return parseTailLines(lines, MAX_TAIL_LINES, (line) => {
411
437
  try {
412
438
  const parsed = JSON.parse(line) as unknown;
413
439
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
@@ -464,14 +490,6 @@ interface MailboxKindCount extends MailboxCount {
464
490
  message: number;
465
491
  }
466
492
 
467
- function tailApproximate(filePath: string): boolean {
468
- try {
469
- return fs.statSync(filePath).size > MAX_TAIL_BYTES;
470
- } catch {
471
- return false;
472
- }
473
- }
474
-
475
493
  async function tailApproximateAsync(filePath: string): Promise<boolean> {
476
494
  try {
477
495
  return (await fs.promises.stat(filePath)).size > MAX_TAIL_BYTES;
@@ -481,8 +499,13 @@ async function tailApproximateAsync(filePath: string): Promise<boolean> {
481
499
  }
482
500
 
483
501
  function readMailboxCounts(filePath: string, delivery: Record<string, MailboxMessageStatus>): MailboxKindCount {
502
+ return mailboxCountsFrom(readTailContent(filePath), delivery);
503
+ }
504
+
505
+ /** Count unread/pending by kind from a pre-read tail window (shared outbox read). */
506
+ function mailboxCountsFrom(tail: TailContent, delivery: Record<string, MailboxMessageStatus>): MailboxKindCount {
484
507
  const kindCounts = { steer: 0, followUp: 0, response: 0, message: 0 };
485
- const items = tailJsonlLines(filePath, MAX_TAIL_LINES, (line) => {
508
+ const items = parseTailLines(tail.lines, MAX_TAIL_LINES, (line) => {
486
509
  try {
487
510
  const parsed = JSON.parse(line) as unknown;
488
511
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return 0;
@@ -514,7 +537,7 @@ function readMailboxCounts(filePath: string, delivery: Record<string, MailboxMes
514
537
  const count = items.reduce((sum, val) => sum + val, 0);
515
538
  return {
516
539
  count,
517
- approximate: tailApproximate(filePath),
540
+ approximate: tail.approximate,
518
541
  steer: kindCounts.steer,
519
542
  followUp: kindCounts.followUp,
520
543
  response: kindCounts.response,
@@ -564,10 +587,12 @@ async function readMailboxCountsAsync(filePath: string, delivery: Record<string,
564
587
  };
565
588
  }
566
589
 
567
- function groupJoinsFrom(manifest: TeamRunManifest): RunUiGroupJoin[] {
568
- const root = path.join(manifest.stateRoot, "mailbox");
569
- const delivery = readDeliveryMessages(path.join(root, "delivery.json"));
570
- return readGroupJoinMailbox(path.join(root, "outbox.jsonl"), delivery).slice(-5);
590
+ function groupJoinsFrom(
591
+ manifest: TeamRunManifest,
592
+ delivery: Record<string, MailboxMessageStatus>,
593
+ outboxTail: TailContent,
594
+ ): RunUiGroupJoin[] {
595
+ return parseGroupJoinLines(outboxTail.lines, delivery).slice(-5);
571
596
  }
572
597
 
573
598
  async function groupJoinsFromAsync(manifest: TeamRunManifest): Promise<RunUiGroupJoin[]> {
@@ -587,11 +612,15 @@ function mergeKindCounts(a: MailboxKindCount, b: MailboxKindCount): MailboxKindC
587
612
  };
588
613
  }
589
614
 
590
- function mailboxFrom(manifest: TeamRunManifest, agents: CrewAgentRecord[]): RunUiMailbox {
615
+ function mailboxFrom(
616
+ manifest: TeamRunManifest,
617
+ agents: CrewAgentRecord[],
618
+ delivery: Record<string, MailboxMessageStatus>,
619
+ outboxTail: TailContent,
620
+ ): RunUiMailbox {
591
621
  const root = path.join(manifest.stateRoot, "mailbox");
592
- const delivery = readDeliveryMessages(path.join(root, "delivery.json"));
593
622
  let inbox = readMailboxCounts(path.join(root, "inbox.jsonl"), delivery);
594
- let outbox = readMailboxCounts(path.join(root, "outbox.jsonl"), delivery);
623
+ let outbox = mailboxCountsFrom(outboxTail, delivery);
595
624
  const tasksRoot = path.join(root, "tasks");
596
625
  try {
597
626
  for (const entry of fs.readdirSync(tasksRoot, {
@@ -865,8 +894,14 @@ export function createRunSnapshotCache(cwd: string, options: RunSnapshotCacheOpt
865
894
  if (previous) return previous;
866
895
  throw new Error(`Run '${runId}' could not be parsed.`);
867
896
  }
868
- const mailbox = mailboxFrom(loaded.manifest, agents);
869
- const groupJoins = groupJoinsFrom(loaded.manifest);
897
+ // PERF (2026-08-24): mailboxFrom and groupJoinsFrom each parsed
898
+ // delivery.json and tailed outbox.jsonl — read both once here and
899
+ // thread the results into both consumers.
900
+ const mailboxRoot = path.join(loaded.manifest.stateRoot, "mailbox");
901
+ const delivery = readDeliveryMessages(path.join(mailboxRoot, "delivery.json"));
902
+ const outboxTail = readTailContent(path.join(mailboxRoot, "outbox.jsonl"));
903
+ const mailbox = mailboxFrom(loaded.manifest, agents, delivery, outboxTail);
904
+ const groupJoins = groupJoinsFrom(loaded.manifest, delivery, outboxTail);
870
905
  const recentEvents = safeRecentEvents(loaded.manifest.eventsPath, recentEventsLimit);
871
906
  const base = {
872
907
  runId: loaded.manifest.runId,
@@ -1048,7 +1083,7 @@ export function createRunSnapshotCache(cwd: string, options: RunSnapshotCacheOpt
1048
1083
  }
1049
1084
  const pendingRefreshes = new Map<string, ReturnType<typeof setTimeout>>();
1050
1085
  const INVAL_COALESCE_MS = 80;
1051
- const scheduleRefresh = (runId: string): void => {
1086
+ const scheduleCoalescedRefresh = (runId: string): void => {
1052
1087
  const existing = pendingRefreshes.get(runId);
1053
1088
  if (existing) clearTimeout(existing);
1054
1089
  const timer = setTimeout(() => {
@@ -1062,10 +1097,10 @@ export function createRunSnapshotCache(cwd: string, options: RunSnapshotCacheOpt
1062
1097
  pendingRefreshes.set(runId, timer);
1063
1098
  };
1064
1099
  const unsubState = runEventBus.onChannel("run:state", (event) => {
1065
- if (entries.has(event.runId)) scheduleRefresh(event.runId);
1100
+ if (entries.has(event.runId)) scheduleCoalescedRefresh(event.runId);
1066
1101
  });
1067
1102
  const unsubLifecycle = runEventBus.onChannel("worker:lifecycle", (event) => {
1068
- if (entries.has(event.runId)) scheduleRefresh(event.runId);
1103
+ if (entries.has(event.runId)) scheduleCoalescedRefresh(event.runId);
1069
1104
  });
1070
1105
  const unsubscribe = () => {
1071
1106
  unsubState();
@@ -1085,6 +1120,19 @@ export function createRunSnapshotCache(cwd: string, options: RunSnapshotCacheOpt
1085
1120
  refreshIfStale(runId: string): RunUiSnapshot {
1086
1121
  return localRefreshIfStale(runId);
1087
1122
  },
1123
+ /**
1124
+ * PERF (2026-08-24): watcher-facing refresh. The fs.watch path used to
1125
+ * call the SYNC refresh() directly on every file event — a full
1126
+ * snapshot rebuild (manifest+tasks parse, agents.json, mailbox readdir,
1127
+ * per-agent tail reads, 2x stringify+sha256) many times per second,
1128
+ * blocking the UI event loop. This routes through the same 80ms
1129
+ * coalesced → async (preloadStale) pipeline the run event bus uses.
1130
+ * FLICKER FIX semantics preserved: buildAsync re-sets the entry in
1131
+ * place; nothing is deleted.
1132
+ */
1133
+ scheduleRefresh(runId: string): void {
1134
+ scheduleCoalescedRefresh(runId);
1135
+ },
1088
1136
  preloadStale,
1089
1137
  preloadAllStale,
1090
1138
  invalidate(runId?: string): void {