pi-crew 0.9.41 → 0.9.44

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 (170) hide show
  1. package/CHANGELOG.md +253 -0
  2. package/README.md +14 -0
  3. package/dist/build-meta.json +6450 -12419
  4. package/dist/index.mjs +57998 -68417
  5. package/dist/index.mjs.map +4 -4
  6. package/docs/migration/atomic-write-v2-migration.md +2 -0
  7. package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +2 -0
  8. package/package.json +4 -2
  9. package/scripts/README.md +6 -0
  10. package/scripts/build-bundle.mjs +2 -1
  11. package/scripts/check-all-skills.ts +3 -3
  12. package/scripts/dev-runner.mjs +106 -0
  13. package/scripts/postinstall.mjs +6 -2
  14. package/scripts/verify-skill.ts +2 -2
  15. package/src/agents/agent-config.ts +1 -1
  16. package/src/config/config.ts +20 -1
  17. package/src/config/drift-detector.ts +97 -15
  18. package/src/config/resilient-parser.ts +4 -4
  19. package/src/config/types.ts +0 -2
  20. package/src/extension/crew-cleanup.ts +17 -3
  21. package/src/extension/crew-vibes/config.ts +1 -1
  22. package/src/extension/crew-vibes/font-detect.ts +16 -3
  23. package/src/extension/crew-vibes/index.ts +1 -1
  24. package/src/extension/cross-extension-rpc.ts +26 -9
  25. package/src/extension/management.ts +6 -5
  26. package/src/extension/register.ts +58 -1573
  27. package/src/extension/registration/command-registration.ts +57 -0
  28. package/src/extension/registration/commands.ts +7 -2
  29. package/src/extension/registration/context-builder.ts +131 -0
  30. package/src/extension/registration/crash-recovery-cache.ts +54 -0
  31. package/src/extension/registration/foreground-run-controller.ts +238 -0
  32. package/src/extension/registration/hook-registration.ts +119 -0
  33. package/src/extension/registration/lazy-configurers.ts +124 -0
  34. package/src/extension/registration/lifecycle-handlers.ts +731 -0
  35. package/src/extension/registration/observability.ts +33 -4
  36. package/src/extension/registration/registration-types.ts +149 -0
  37. package/src/extension/registration/runtime-cleanup.ts +230 -0
  38. package/src/extension/registration/subagent-manager-setup.ts +211 -0
  39. package/src/extension/registration/tool-registration.ts +50 -0
  40. package/src/extension/registration/wire-cross-extension.ts +37 -0
  41. package/src/extension/rpc-hmac.ts +21 -1
  42. package/src/extension/run-import.ts +3 -3
  43. package/src/extension/team-tool/auto-summarize.ts +4 -4
  44. package/src/extension/team-tool/cancel.ts +5 -5
  45. package/src/extension/team-tool/chain-executor.ts +5 -4
  46. package/src/extension/team-tool/context.ts +11 -1
  47. package/src/extension/team-tool/doctor.ts +2 -1
  48. package/src/extension/team-tool/goal-wrap.ts +2 -2
  49. package/src/extension/team-tool/goal.ts +1 -1
  50. package/src/extension/team-tool/handle-schedule.ts +25 -36
  51. package/src/extension/team-tool/orchestrate.ts +0 -1
  52. package/src/extension/team-tool/parallel-dispatch.ts +5 -0
  53. package/src/extension/team-tool/run.ts +220 -214
  54. package/src/extension/team-tool/workflow-manage.ts +1 -1
  55. package/src/extension/team-tool.ts +69 -16
  56. package/src/hooks/registry.ts +7 -2
  57. package/src/observability/correlation.ts +4 -3
  58. package/src/observability/event-bus.ts +10 -1
  59. package/src/observability/event-to-metric.ts +8 -7
  60. package/src/observability/exporters/adapter.ts +6 -9
  61. package/src/observability/exporters/otlp-exporter.ts +81 -0
  62. package/src/observability/metric-retention.ts +4 -0
  63. package/src/observability/metric-sink.ts +14 -4
  64. package/src/prompt/prompt-runtime.ts +15 -0
  65. package/src/runtime/adaptive-plan.ts +1 -1
  66. package/src/runtime/async-runner.ts +13 -4
  67. package/src/runtime/background-runner.ts +32 -23
  68. package/src/runtime/capability-inventory.ts +1 -1
  69. package/src/runtime/chain-runner.ts +5 -5
  70. package/src/runtime/checkpoint.ts +6 -16
  71. package/src/runtime/child-pi-constants.ts +42 -0
  72. package/src/runtime/child-pi-kill.ts +180 -0
  73. package/src/runtime/child-pi-spawn.ts +234 -0
  74. package/src/runtime/child-pi-steering.ts +128 -0
  75. package/src/runtime/child-pi-streams.ts +296 -0
  76. package/src/runtime/child-pi-transcript.ts +169 -0
  77. package/src/runtime/child-pi.ts +80 -822
  78. package/src/runtime/coalesce-tasks.ts +1 -1
  79. package/src/runtime/compact-stages/tail-capture-stage.ts +1 -1
  80. package/src/runtime/crash-recovery.ts +63 -47
  81. package/src/runtime/delivery-coordinator.ts +1 -1
  82. package/src/runtime/deterministic-ast.ts +2 -2
  83. package/src/runtime/diagnostic-export.ts +2 -1
  84. package/src/runtime/dwf-state-store.ts +5 -0
  85. package/src/runtime/dynamic-workflow-context.ts +78 -18
  86. package/src/runtime/dynamic-workflow-runner.ts +18 -2
  87. package/src/runtime/foreground-control.ts +3 -2
  88. package/src/runtime/goal-evaluator.ts +59 -27
  89. package/src/runtime/goal-loop-runner.ts +46 -13
  90. package/src/runtime/goal-state-store.ts +1 -1
  91. package/src/runtime/handoff-manager.ts +0 -2
  92. package/src/runtime/intercom-bridge.ts +0 -2
  93. package/src/runtime/live-agent-manager.ts +30 -5
  94. package/src/runtime/live-extension-bridge.ts +0 -2
  95. package/src/runtime/live-session-runtime.ts +65 -57
  96. package/src/runtime/manifest-cache.ts +59 -0
  97. package/src/runtime/mcp-proxy.ts +1 -1
  98. package/src/runtime/orphan-worker-registry.ts +3 -3
  99. package/src/runtime/output-validator.ts +1 -1
  100. package/src/runtime/path-overlap.ts +1 -1
  101. package/src/runtime/pi-args.ts +3 -3
  102. package/src/runtime/pi-json-output.ts +4 -3
  103. package/src/runtime/pi-spawn.ts +7 -1
  104. package/src/runtime/pipeline-runner.ts +1 -4
  105. package/src/runtime/post-checks.ts +0 -1
  106. package/src/runtime/retry-runner.ts +1 -1
  107. package/src/runtime/run-coalesced-task-group.ts +18 -23
  108. package/src/runtime/scheduler.ts +2 -0
  109. package/src/runtime/session-resources.ts +1 -1
  110. package/src/runtime/settings-store.ts +31 -1
  111. package/src/runtime/skill-effectiveness.ts +2 -2
  112. package/src/runtime/stale-reconciler.ts +23 -53
  113. package/src/runtime/subagent-manager.ts +19 -3
  114. package/src/runtime/task-output-context.ts +2 -1
  115. package/src/runtime/task-runner/prompt-builder.ts +3 -3
  116. package/src/runtime/task-runner/run-projection.ts +3 -1
  117. package/src/runtime/task-runner/state-helpers.ts +13 -1
  118. package/src/runtime/task-runner.ts +31 -6
  119. package/src/runtime/team-runner.ts +8 -2
  120. package/src/runtime/verification-gates.ts +54 -5
  121. package/src/schema/config-schema.ts +19 -1
  122. package/src/schema/team-tool-schema.ts +2 -0
  123. package/src/skills/discover-skills.ts +25 -15
  124. package/src/state/active-run-registry.ts +0 -1
  125. package/src/state/atomic-write.ts +37 -11
  126. package/src/state/blob-store.ts +3 -9
  127. package/src/state/contracts.ts +5 -1
  128. package/src/state/crew-init.ts +3 -2
  129. package/src/state/decision-ledger.ts +2 -2
  130. package/src/state/event-log-rotation.ts +1 -1
  131. package/src/state/event-log.ts +4 -3
  132. package/src/state/gitignore-manager.ts +2 -1
  133. package/src/state/health-store.ts +5 -2
  134. package/src/state/instinct-store.ts +11 -2
  135. package/src/state/locks.ts +132 -49
  136. package/src/state/mailbox.ts +3 -4
  137. package/src/state/observation-store.ts +1 -13
  138. package/src/state/run-cache.ts +12 -12
  139. package/src/state/run-graph.ts +2 -1
  140. package/src/state/run-metrics.ts +1 -1
  141. package/src/state/schedule.ts +7 -1
  142. package/src/state/state-store.ts +9 -3
  143. package/src/state/tiered-eval.ts +1 -1
  144. package/src/state/worker-atomic-writer.ts +1 -0
  145. package/src/ui/dynamic-border.ts +13 -3
  146. package/src/ui/live-conversation-overlay.ts +7 -27
  147. package/src/ui/live-duration.ts +2 -2
  148. package/src/ui/live-run-sidebar.ts +56 -32
  149. package/src/ui/mascot.ts +19 -13
  150. package/src/ui/powerbar-publisher.ts +244 -178
  151. package/src/ui/render-coalescer.ts +44 -3
  152. package/src/ui/render-diff.ts +2 -2
  153. package/src/ui/run-dashboard.ts +122 -21
  154. package/src/ui/run-event-bus.ts +66 -0
  155. package/src/ui/run-snapshot-cache.ts +84 -81
  156. package/src/ui/settings-overlay.ts +3 -3
  157. package/src/ui/status-colors.ts +24 -0
  158. package/src/ui/tool-progress-formatter.ts +1 -1
  159. package/src/ui/tool-render.ts +3 -3
  160. package/src/ui/transcript-cache.ts +5 -10
  161. package/src/ui/transcript-entries.ts +68 -72
  162. package/src/ui/transcript-viewer.ts +54 -14
  163. package/src/ui/widget/index.ts +26 -15
  164. package/src/ui/widget/widget-model.ts +18 -9
  165. package/src/ui/widget/widget-renderer.ts +2 -5
  166. package/src/utils/fingerprint.ts +1 -1
  167. package/src/utils/paths.ts +1 -1
  168. package/src/utils/project-detector.ts +1 -1
  169. package/src/worktree/worktree-manager.ts +47 -19
  170. package/src/state/atomic-write-v2.ts +0 -108
@@ -3,26 +3,67 @@
3
3
  * Prevents UI flicker when many events arrive in quick succession.
4
4
  * Inspired by oh-my-pi's PROGRESS_COALESCE_MS (150ms) pattern.
5
5
  */
6
+ import { logInternalError } from "../utils/internal-error.ts";
7
+
8
+ export interface RenderCoalescerOptions {
9
+ /**
10
+ * Invoked once per coalesce window with the number of `request()` calls
11
+ * that were collapsed into the most recent scheduled render. Defaults to
12
+ * a debug-scoped `logInternalError` so dropped bursts surface in logs
13
+ * (gated behind PI_TEAMS_DEBUG) without callers having to wire one up.
14
+ */
15
+ onDrop?: (droppedCount: number) => void;
16
+ }
17
+
6
18
  export class RenderCoalescer {
7
19
  #pending = false;
8
20
  #timerId: ReturnType<typeof setTimeout> | null = null;
9
21
  #callback: () => void;
10
22
  #intervalMs: number;
23
+ #onDrop: (droppedCount: number) => void;
24
+ #dropped = 0;
11
25
 
12
- constructor(callback: () => void, intervalMs = 32) {
26
+ constructor(callback: () => void, intervalMs = 32, options: RenderCoalescerOptions = {}) {
13
27
  this.#callback = callback;
14
28
  this.#intervalMs = intervalMs;
29
+ this.#onDrop =
30
+ options.onDrop ??
31
+ ((count) => {
32
+ logInternalError(
33
+ "render-coalescer",
34
+ new Error(`coalesced ${count} dropped request(s) into a single render`),
35
+ undefined,
36
+ "debug",
37
+ );
38
+ });
15
39
  }
16
40
 
17
41
  /** Request a render. Will be coalesced with other requests within the interval. */
18
42
  request(): void {
19
- if (this.#pending) return;
43
+ if (this.#pending) {
44
+ // A render is already scheduled — coalesce this request and bump
45
+ // the drop counter so the scheduled flush can report how many
46
+ // bursts were collapsed (useful for diagnosing UI lag).
47
+ this.#dropped += 1;
48
+ return;
49
+ }
20
50
  this.#pending = true;
21
- this.#timerId = setTimeout(() => {
51
+ const timer = setTimeout(() => {
22
52
  this.#pending = false;
23
53
  this.#timerId = null;
54
+ const dropped = this.#dropped;
55
+ this.#dropped = 0;
56
+ if (dropped > 0) {
57
+ try {
58
+ this.#onDrop(dropped);
59
+ } catch {
60
+ /* drop callback errors are non-fatal */
61
+ }
62
+ }
24
63
  this.#callback();
25
64
  }, this.#intervalMs);
65
+ timer.unref();
66
+ this.#timerId = timer;
26
67
  }
27
68
 
28
69
  /** Force an immediate render, bypassing coalescing. */
@@ -108,7 +108,7 @@ export function renderDiff(diffText: string, options: RenderDiffOptions = {}): s
108
108
  const removedLines: DiffLineContent[] = [];
109
109
  while (i < lines.length) {
110
110
  const nextParsed = parseDiffLine(lines[i] ?? "");
111
- if (!nextParsed || nextParsed.prefix !== "-") break;
111
+ if (nextParsed?.prefix !== "-") break;
112
112
  removedLines.push({
113
113
  lineNum: nextParsed.lineNum,
114
114
  content: nextParsed.content,
@@ -119,7 +119,7 @@ export function renderDiff(diffText: string, options: RenderDiffOptions = {}): s
119
119
  const addedLines: DiffLineContent[] = [];
120
120
  while (i < lines.length) {
121
121
  const nextParsed = parseDiffLine(lines[i] ?? "");
122
- if (!nextParsed || nextParsed.prefix !== "+") break;
122
+ if (nextParsed?.prefix !== "+") break;
123
123
  addedLines.push({
124
124
  lineNum: nextParsed.lineNum,
125
125
  content: nextParsed.content,
@@ -21,7 +21,8 @@ import { DynamicCrewBorder } from "./dynamic-border.ts";
21
21
  import { dashboardActionForKey } from "./keybinding-map.ts";
22
22
  import { Box, Text } from "./layout-primitives.ts";
23
23
  import { HelpOverlay } from "./overlays/help-overlay.ts";
24
- import { runEventBus } from "./run-event-bus.ts";
24
+ import { RenderScheduler } from "./render-scheduler.ts";
25
+ import { runEventBusAsRenderScheduler } from "./run-event-bus.ts";
25
26
  import type { RunSnapshotCache, RunUiSnapshot } from "./snapshot-types.ts";
26
27
  import { spinnerBucket, spinnerFrame } from "./spinner.ts";
27
28
  import { applyStatusColor, colorizeStatusGlyphs, iconForStatus, type RunStatus } from "./status-colors.ts";
@@ -105,6 +106,17 @@ const TASK_READ_TTL_MS = 1000;
105
106
  /** Max run rows rendered in the dashboard run-list block (L-1 window budget). */
106
107
  const RUN_LIST_MAX = 8;
107
108
 
109
+ /**
110
+ * 1.10 (UI-P1-2) — short TTL for the buildSignature cache. `buildSignature`
111
+ * iterates every run and calls `snapshotFor → refreshIfStale`, which can
112
+ * stat multiple files per run when the TTL expires. Rendering fires several
113
+ * times per second (spinner + event-bus), so we cache the computed signature
114
+ * for 100ms: enough to coalesce one render burst without holding the cache
115
+ * across genuinely new data (>100ms idle means a fresh signature will be
116
+ * computed on the next render anyway).
117
+ */
118
+ const SIGNATURE_CACHE_TTL_MS = 100;
119
+
108
120
  /**
109
121
  * F-4 — a live run's snapshot is considered "possibly stale" once its
110
122
  * `fetchedAt` is this old. A progressing run rebuilds on every stamp change,
@@ -155,7 +167,17 @@ function renderLines(lines: string[], width: number): string[] {
155
167
  return box.render(width);
156
168
  }
157
169
 
158
- function readProgressPreview(run: TeamRunManifest, maxLines = 5): string[] {
170
+ function readProgressPreview(run: TeamRunManifest, maxLines = 5, snapshotCache?: RunSnapshotCache): string[] {
171
+ // P0-6: prefer the snapshot's `recentOutputLines` (no disk I/O) over reading the
172
+ // progress artifact on every render. The progress artifact content is captured
173
+ // into the snapshot's recent events / output pipeline upstream.
174
+ const snapshot = snapshotFor(run, snapshotCache);
175
+ if (snapshot?.recentOutputLines?.length) {
176
+ return ["Progress:", ...snapshot.recentOutputLines.slice(0, maxLines)];
177
+ }
178
+ if (snapshotCache) return ["Progress: (loading…)"];
179
+ // Legacy fallback: tests/dev paths without a snapshot cache still read the
180
+ // progress artifact directly so existing assertions keep working.
159
181
  const progress = [...run.artifacts].reverse().find((artifact) => artifact.kind === "progress");
160
182
  if (!progress) return ["Progress: (none)"];
161
183
  try {
@@ -192,6 +214,10 @@ function snapshotFor(run: TeamRunManifest, snapshotCache?: RunSnapshotCache): Ru
192
214
  function readRunTasks(run: TeamRunManifest, snapshotCache?: RunSnapshotCache): TeamTaskState[] {
193
215
  const snapshot = snapshotFor(run, snapshotCache);
194
216
  if (snapshot) return snapshot.tasks;
217
+ // P0-6: when a snapshot cache is provided but hasn't populated yet, return
218
+ // empty (the render loop falls back to the empty pane placeholder) instead
219
+ // of reading the tasks.json file synchronously every render tick.
220
+ if (snapshotCache) return [];
195
221
  const parse = () => {
196
222
  if (!fs.existsSync(run.tasksPath)) return [];
197
223
  const parsed = JSON.parse(fs.readFileSync(run.tasksPath, "utf-8"));
@@ -248,7 +274,11 @@ function agentPreviewLine(agent: CrewAgentRecord, task: TeamTaskState | undefine
248
274
  function readAgentPreview(run: TeamRunManifest, maxLines = 5, options: RunDashboardOptions = {}): string[] {
249
275
  try {
250
276
  const snapshot = snapshotFor(run, options.snapshotCache);
251
- const agents = snapshot?.agents ?? readCrewAgents(run);
277
+ // P0-6: when a snapshot cache is provided but hasn't populated yet, return
278
+ // the empty-pane placeholder instead of calling `readCrewAgents` (disk I/O)
279
+ // on every render tick. Legacy callers (no cache) keep the disk-read path
280
+ // so existing unit tests continue to assert against concrete agent data.
281
+ const agents = snapshot?.agents ?? (options.snapshotCache ? [] : readCrewAgents(run));
252
282
  const tasks = snapshot?.tasks ?? readRunTasks(run, options.snapshotCache);
253
283
  if (!agents.length) return ["Agents: (none)"];
254
284
  const totals = tasks.reduce(
@@ -283,6 +313,10 @@ function readAgentPreview(run: TeamRunManifest, maxLines = 5, options: RunDashbo
283
313
  function agentsFor(run: TeamRunManifest, snapshotCache?: RunSnapshotCache): CrewAgentRecord[] {
284
314
  const snapshot = snapshotFor(run, snapshotCache);
285
315
  if (snapshot) return snapshot.agents;
316
+ // P0-6: when a snapshot cache is provided but hasn't populated yet, return
317
+ // empty (callers handle the empty-state placeholder) instead of calling
318
+ // `readCrewAgents` synchronously on every render tick.
319
+ if (snapshotCache) return [];
286
320
  try {
287
321
  return readCrewAgents(run);
288
322
  } catch {
@@ -387,8 +421,12 @@ export class RunDashboard implements DashboardComponent {
387
421
  private cachedWidth = 0;
388
422
  private cachedVersion = "";
389
423
  private cachedLines: string[] = [];
424
+ /** 1.10 (UI-P1-2) — short-TTL cache for `buildSignature()` so per-run
425
+ * `snapshotFor → refreshIfStale` calls don't fire on every render tick. */
426
+ private cachedSignatureAt = 0;
427
+ private cachedSignature = "";
390
428
  private readonly unsubscribeTheme: () => void;
391
- private readonly unsubscribeEventBus: () => void;
429
+ private readonly renderScheduler: RenderScheduler | undefined;
392
430
 
393
431
  constructor(
394
432
  runs: TeamRunManifest[],
@@ -405,17 +443,61 @@ export class RunDashboard implements DashboardComponent {
405
443
  this.done = done;
406
444
  this.theme = asCrewTheme(theme);
407
445
  this.options = options;
408
- this.unsubscribeTheme = subscribeThemeChange(theme, () => this.invalidateAndRender());
409
- this.unsubscribeEventBus = (() => {
410
- const unsub1 = runEventBus.onChannel("run:state", () => this.invalidateAndRender());
411
- const unsub2 = runEventBus.onChannel("worker:lifecycle", () => this.invalidateAndRender());
412
- const unsub3 = runEventBus.onChannel("ui:invalidate", () => this.invalidateAndRender());
413
- return () => {
414
- unsub1();
415
- unsub2();
416
- unsub3();
417
- };
418
- })();
446
+ // 1.10 (UI-P1-1): route run:state / worker:lifecycle / ui:invalidate
447
+ // through a RenderScheduler (debounce + fallback) instead of registering
448
+ // 3 raw runEventBus subscriptions that each schedule an immediate
449
+ // invalidate+requestRender. With 3 overlays subscribing independently,
450
+ // a single event triggered up to 9 callbacks and ~150 invalidates/sec.
451
+ // The scheduler keeps the per-channel subscriptions but collapses them
452
+ // into one debounced render + one coalesced invalidate per runId.
453
+ //
454
+ // FIND-07: scheduler must be constructed BEFORE the theme subscription
455
+ // and the input-handler paths so they can route their invalidate+render
456
+ // calls through `this.scheduleRender()` (debounced + coalesced) instead
457
+ // of bypassing it with direct `invalidateAndRender()` calls. Each direct
458
+ // call would otherwise skip the scheduler's debounce window and force
459
+ // a synchronous repaint, recreating the burst-storm that this scheduler
460
+ // was introduced to eliminate.
461
+ const renderTick = (): void => {
462
+ this.invalidateAndRender();
463
+ };
464
+ this.renderScheduler = new RenderScheduler(
465
+ runEventBusAsRenderScheduler(["run:state", "worker:lifecycle", "ui:invalidate"]),
466
+ renderTick,
467
+ {
468
+ debounceMs: 75,
469
+ fallbackMs: 750,
470
+ events: ["run:state", "worker:lifecycle", "ui:invalidate"],
471
+ onInvalidate: () => {
472
+ // Drop any cached signature — data underneath may have
473
+ // changed so the next buildSignature() needs to recompute.
474
+ this.cachedSignature = "";
475
+ this.cachedSignatureAt = 0;
476
+ },
477
+ },
478
+ );
479
+ // FIND-07: route theme changes through the scheduler so they coalesce
480
+ // with run:state / worker:lifecycle / ui:invalidate bursts instead of
481
+ // each firing their own immediate invalidate+requestRender.
482
+ this.unsubscribeTheme = subscribeThemeChange(theme, () => this.scheduleRender());
483
+ }
484
+
485
+ /**
486
+ * FIND-07: route an external invalidation through the RenderScheduler.
487
+ *
488
+ * Use this for any caller-driven invalidation (theme change, input
489
+ * handler action) instead of calling `invalidateAndRender()` directly.
490
+ * The scheduler debounces + coalesces these so a theme change arriving
491
+ * in the middle of a run:state burst doesn't force a separate synchronous
492
+ * repaint. Falls back to a direct render if the scheduler was not created
493
+ * (defensive — currently always created in the constructor).
494
+ */
495
+ private scheduleRender(): void {
496
+ if (this.renderScheduler) {
497
+ this.renderScheduler.schedule();
498
+ return;
499
+ }
500
+ this.invalidateAndRender();
419
501
  }
420
502
 
421
503
  /**
@@ -489,6 +571,17 @@ export class RunDashboard implements DashboardComponent {
489
571
  }
490
572
 
491
573
  private buildSignature(): string {
574
+ // 1.10 (UI-P1-2) — short-TTL cache so we don't re-read every run's
575
+ // snapshot on every render tick. `snapshotFor → refreshIfStale` can
576
+ // stat multiple files per run when its own TTL expires, and the
577
+ // dashboard's render is called several times per second (spinner +
578
+ // event-bus). Cache the full computed signature for ~100ms: long
579
+ // enough to coalesce one render burst, short enough that idle pauses
580
+ // still pick up new data promptly.
581
+ const now = Date.now();
582
+ if (this.cachedSignature && now - this.cachedSignatureAt < SIGNATURE_CACHE_TTL_MS) {
583
+ return this.cachedSignature;
584
+ }
492
585
  let hasRunning = false;
493
586
  const statuses = this.runs
494
587
  .map((run) => {
@@ -503,17 +596,25 @@ export class RunDashboard implements DashboardComponent {
503
596
  .join("|");
504
597
  const metricsSig =
505
598
  this.activePane === "metrics" ? `:metrics=${this.options.registry?.snapshot().length ?? 0}:${spinnerBucket()}` : "";
506
- return `${this.selected}:${this.showHelp ? 1 : 0}:${this.showFullProgress ? 1 : 0}:${this.activePane}:${statuses}${hasRunning ? `:spin=${spinnerBucket()}` : ""}${metricsSig}`;
599
+ const sig = `${this.selected}:${this.showHelp ? 1 : 0}:${this.showFullProgress ? 1 : 0}:${this.activePane}:${statuses}${hasRunning ? `:spin=${spinnerBucket()}` : ""}${metricsSig}`;
600
+ this.cachedSignature = sig;
601
+ this.cachedSignatureAt = now;
602
+ return sig;
507
603
  }
508
604
 
509
605
  invalidate(): void {
510
606
  this.cachedVersion = "";
511
607
  this.cachedLines = [];
608
+ // 1.10 (UI-P1-2): also drop the short-TTL signature cache so the next
609
+ // render can't return a stale snapshot-derived signature that was
610
+ // computed before the invalidating event arrived.
611
+ this.cachedSignature = "";
612
+ this.cachedSignatureAt = 0;
512
613
  }
513
614
 
514
615
  dispose(): void {
515
616
  this.unsubscribeTheme();
516
- this.unsubscribeEventBus();
617
+ this.renderScheduler?.dispose();
517
618
  }
518
619
 
519
620
  private selectedRunId(): string | undefined {
@@ -644,7 +745,7 @@ export class RunDashboard implements DashboardComponent {
644
745
  }),
645
746
  )
646
747
  : safeRenderPane("transcript", () => renderTranscriptPane(snap))
647
- : [...readAgentPreview(r, 4, this.options), ...readProgressPreview(r, 2)];
748
+ : [...readAgentPreview(r, 4, this.options), ...readProgressPreview(r, 2, this.options.snapshotCache)];
648
749
  const filteredPane = paneLines.filter((l) => l && !l.includes("(none)") && l.trim() !== "");
649
750
  if (filteredPane.length > 0) {
650
751
  lines.push(row(fg("dim", `── ${this.activePane} ──`)));
@@ -719,12 +820,12 @@ export class RunDashboard implements DashboardComponent {
719
820
  // (including Esc) just dismisses it first instead of acting.
720
821
  if (action === "help") {
721
822
  this.showHelp = !this.showHelp;
722
- this.invalidateAndRender();
823
+ this.scheduleRender();
723
824
  return;
724
825
  }
725
826
  if (this.showHelp) {
726
827
  this.showHelp = false;
727
- this.invalidateAndRender();
828
+ this.scheduleRender();
728
829
  return;
729
830
  }
730
831
  const selectedRunId = this.selectedRunId();
@@ -786,7 +887,7 @@ export class RunDashboard implements DashboardComponent {
786
887
  }
787
888
  if (action) {
788
889
  lastActivePane = this.activePane;
789
- this.invalidateAndRender();
890
+ this.scheduleRender();
790
891
  }
791
892
  }
792
893
  }
@@ -1,5 +1,6 @@
1
1
  import type { TeamEvent } from "../state/event-log.ts";
2
2
  import { readEventsCursor } from "../state/event-log.ts";
3
+ import type { RenderSchedulerEventBus } from "./render-scheduler.ts";
3
4
 
4
5
  export type RunEventType =
5
6
  | "task_started"
@@ -80,6 +81,14 @@ class RunEventBus {
80
81
  #globalListeners = new Set<RunEventCallback>();
81
82
  #channelListeners = new Map<EventChannel, Set<RunEventCallback>>();
82
83
  #channelRunListeners = new Map<string, Map<EventChannel, Set<RunEventCallback>>>();
84
+ // 1.10 (FIND-13): microtask-batched listener fan-out. Events emitted in
85
+ // the same synchronous tick are coalesced into a single microtask flush
86
+ // instead of one synchronous fan-out per emit. Reduces listener dispatch
87
+ // overhead when many events arrive back-to-back (e.g. from a single
88
+ // appendEvent chain that emits several derived events).
89
+ #emitQueue: RunEventPayload[] = [];
90
+ #flushScheduled = false;
91
+ #disposed = false;
83
92
 
84
93
  on(runId: string, callback: RunEventCallback): () => void {
85
94
  const listeners = this.#listeners.get(runId) ?? new Set();
@@ -202,12 +211,41 @@ class RunEventBus {
202
211
  }
203
212
 
204
213
  emit(event: RunEventPayload): void {
214
+ if (this.#disposed) return;
205
215
  // Auto-classify channel if not already set.
206
216
  // M2: Use local variable for routing, but also set on event
207
217
  // for subscriber API contract (listeners read event.channel).
208
218
  if (!event.channel) {
209
219
  (event as { channel?: EventChannel }).channel = classifyEventChannel(event.type);
210
220
  }
221
+ // 1.10 (FIND-13): queue and defer fan-out to a microtask so multiple
222
+ // emits in the same synchronous tick collapse into one listener-pass
223
+ // each (one per emit, all in the same microtask).
224
+ this.#emitQueue.push(event);
225
+ if (this.#flushScheduled) return;
226
+ this.#flushScheduled = true;
227
+ queueMicrotask(() => {
228
+ this.#flushScheduled = false;
229
+ if (this.#disposed) {
230
+ this.#emitQueue.length = 0;
231
+ return;
232
+ }
233
+ // Swap the queue before dispatching so a listener that re-emits
234
+ // enqueues into a fresh queue (scheduled by its own emit() call)
235
+ // instead of stomping the current iteration.
236
+ const queue = this.#emitQueue;
237
+ this.#emitQueue = [];
238
+ for (const queued of queue) this.#dispatchSync(queued);
239
+ });
240
+ }
241
+
242
+ /**
243
+ * Synchronous per-event listener fan-out. Called from the microtask flush
244
+ * scheduled by emit(); not part of the public API. Mirrors the original
245
+ * (pre-batching) emit body so a single emit still visits every listener
246
+ * registered for the event's runId / channel / global set.
247
+ */
248
+ #dispatchSync(event: RunEventPayload): void {
211
249
  const channel = event.channel!;
212
250
 
213
251
  // Existing: runId-specific listeners
@@ -262,10 +300,13 @@ class RunEventBus {
262
300
 
263
301
  /** Dispose all subscriptions including channel-based ones. */
264
302
  dispose(): void {
303
+ this.#disposed = true;
265
304
  this.#listeners.clear();
266
305
  this.#globalListeners.clear();
267
306
  this.#channelListeners.clear();
268
307
  this.#channelRunListeners.clear();
308
+ this.#emitQueue.length = 0;
309
+ this.#flushScheduled = false;
269
310
  }
270
311
 
271
312
  listenerCount(runId?: string): number {
@@ -283,6 +324,31 @@ class RunEventBus {
283
324
  /** Global singleton run event bus for UI-first event delivery. */
284
325
  export const runEventBus = new RunEventBus();
285
326
 
327
+ /**
328
+ * 1.10 (UI-P1-1): adapt `runEventBus.onChannel` to the
329
+ * `RenderSchedulerEventBus` interface so overlays can route their
330
+ * `run:state` / `worker:lifecycle` / `ui:invalidate` subscriptions through
331
+ * a `RenderScheduler` (debounce + fallback + per-runId coalescing) instead of
332
+ * calling `runEventBus.onChannel(...)` directly three times. Without the
333
+ * scheduler, a single event triggers up to 9 callbacks across the 3 overlays
334
+ * (dashboard + sidebar + widget) and bursts into ~150 invalidate+requestRender
335
+ * /sec under load.
336
+ *
337
+ * Returns a fresh adapter whose `on(event, handler)` is a thin shim over
338
+ * `runEventBus.onChannel`. The channels list is closed-over so we can do a
339
+ * defensive membership check without re-reading the channel set on every
340
+ * subscription.
341
+ */
342
+ export function runEventBusAsRenderScheduler(channels: readonly EventChannel[]): RenderSchedulerEventBus {
343
+ const allowed = new Set<EventChannel>(channels);
344
+ return {
345
+ on: (event: string, handler: (payload: unknown) => void): (() => void) | void => {
346
+ if (!allowed.has(event as EventChannel)) return undefined;
347
+ return runEventBus.onChannel(event as EventChannel, handler);
348
+ },
349
+ };
350
+ }
351
+
286
352
  /** Derive a RunEventType from a TeamEvent. */
287
353
  export function teamEventToRunEventType(event: TeamEvent): RunEventType | undefined {
288
354
  const type = event.type;
@@ -126,46 +126,33 @@ function combineStamps(stamps: FileStamp[]): FileStamp {
126
126
 
127
127
  function mailboxStamp(manifest: TeamRunManifest): FileStamp {
128
128
  const root = path.join(manifest.stateRoot, "mailbox");
129
- const stamps: FileStamp[] = [
129
+ const tasksRoot = path.join(root, "tasks");
130
+ // 1.10 (UI-P1-3): replace the O(tasks) per-task inbox/outbox stat loop
131
+ // with a single stat on the tasks dir. The dir mtime changes whenever a
132
+ // task mailbox subdir is created or removed (the dominant cost when a run
133
+ // has many tasks), and per-file content changes are still caught by the
134
+ // top-level inbox/outbox/delivery stats below. The event-bus
135
+ // (`crew.mailbox.*`) also drives invalidation, so the stamp is the
136
+ // fallback poll, not the only source of truth.
137
+ return combineStamps([
130
138
  stampFile(path.join(root, "inbox.jsonl")),
131
139
  stampFile(path.join(root, "outbox.jsonl")),
132
140
  stampFile(path.join(root, "delivery.json")),
133
- ];
134
- const tasksRoot = path.join(root, "tasks");
135
- try {
136
- for (const entry of fs.readdirSync(tasksRoot, {
137
- withFileTypes: true,
138
- })) {
139
- if (!entry.isDirectory()) continue;
140
- stamps.push(stampFile(path.join(tasksRoot, entry.name, "inbox.jsonl")));
141
- stamps.push(stampFile(path.join(tasksRoot, entry.name, "outbox.jsonl")));
142
- }
143
- } catch {
144
- // No task mailbox yet.
145
- }
146
- return combineStamps(stamps);
141
+ stampFile(tasksRoot),
142
+ ]);
147
143
  }
148
144
 
149
145
  async function mailboxStampAsync(manifest: TeamRunManifest): Promise<FileStamp> {
150
146
  const root = path.join(manifest.stateRoot, "mailbox");
151
- const stamps: FileStamp[] = [
147
+ const tasksRoot = path.join(root, "tasks");
148
+ // 1.10 (UI-P1-3): see sync `mailboxStamp` above — single tasksRoot stat
149
+ // replaces the O(tasks) per-task loop.
150
+ return combineStamps([
152
151
  await stampFileAsync(path.join(root, "inbox.jsonl")),
153
152
  await stampFileAsync(path.join(root, "outbox.jsonl")),
154
153
  await stampFileAsync(path.join(root, "delivery.json")),
155
- ];
156
- const tasksRoot = path.join(root, "tasks");
157
- try {
158
- for (const entry of await fs.promises.readdir(tasksRoot, {
159
- withFileTypes: true,
160
- })) {
161
- if (!entry.isDirectory()) continue;
162
- stamps.push(await stampFileAsync(path.join(tasksRoot, entry.name, "inbox.jsonl")));
163
- stamps.push(await stampFileAsync(path.join(tasksRoot, entry.name, "outbox.jsonl")));
164
- }
165
- } catch {
166
- // No task mailbox yet.
167
- }
168
- return combineStamps(stamps);
154
+ await stampFileAsync(tasksRoot),
155
+ ]);
169
156
  }
170
157
 
171
158
  function safeAgentOutputPath(manifest: TeamRunManifest, agent: CrewAgentRecord): string | undefined {
@@ -673,54 +660,20 @@ function cancellationReasonFromEvents(events: TeamEvent[]): string | undefined {
673
660
  | undefined;
674
661
  }
675
662
 
676
- function signatureFor(input: Omit<RunUiSnapshot, "signature" | "fetchedAt" | "sliceSignatures">, stamps: SnapshotStamps): string {
677
- try {
678
- const digest = createHash("sha256");
679
- digest.update(
680
- JSON.stringify({
681
- run: [input.manifest.runId, input.manifest.status, input.manifest.updatedAt, input.manifest.artifacts.length],
682
- tasks: input.tasks.map((task) => [task.id, task.status, task.startedAt, task.finishedAt, task.agentProgress, task.usage]),
683
- agents: input.agents.map((agent) => [
684
- agent.id,
685
- agent.status,
686
- agent.startedAt,
687
- agent.completedAt,
688
- agent.toolUses,
689
- agent.progress,
690
- agent.usage,
691
- agent.model,
692
- ]),
693
- progress: input.progress,
694
- usage: input.usage,
695
- mailbox: input.mailbox,
696
- groupJoins: input.groupJoins,
697
- events: input.recentEvents.map((event) => [
698
- event.metadata?.seq,
699
- event.time,
700
- event.type,
701
- event.taskId,
702
- event.message,
703
- event.data?.reason,
704
- ]),
705
- cancellationReason: input.cancellationReason,
706
- dwfPhaseState: input.dwfPhaseState,
707
- output: input.recentOutputLines,
708
- stamps,
709
- }),
710
- );
711
- return digest.digest("hex").slice(0, 16);
712
- } catch {
713
- // Circular reference or non-serializable data — fall back to timestamp.
714
- return String(Date.now());
715
- }
716
- }
663
+ type SliceSignatures = NonNullable<RunUiSnapshot["sliceSignatures"]>;
717
664
 
718
665
  /**
719
- * 1.6 / 1.7 — compute one short hash per logical slice of the snapshot so
720
- * dashboard panes / widget can short-circuit when their slice hasn't moved.
721
- * The slice contents must mirror what `signatureFor` packs into each branch.
666
+ * FIND-08: compute the per-slice short hashes once per build and reuse the
667
+ * result for both the full-snapshot `signature` and the per-pane
668
+ * `sliceSignatures`. Previously, `signatureFor` and `sliceSignaturesFor` each
669
+ * called `JSON.stringify` + `createHash("sha256")` on the tasks, agents, and
670
+ * recentEvents projections independently — twice the work for the slices the
671
+ * two signatures share. The per-slice hashes are embedded into the full
672
+ * signature so the full-snapshot signature still changes whenever any slice
673
+ * changes (collision risk: 1 in 16^16 per build, well within tolerance for
674
+ * cache-invalidation use).
722
675
  */
723
- function sliceSignaturesFor(input: Omit<RunUiSnapshot, "signature" | "fetchedAt" | "sliceSignatures">): RunUiSnapshot["sliceSignatures"] {
676
+ function computeSliceSignatures(input: Omit<RunUiSnapshot, "signature" | "fetchedAt" | "sliceSignatures">): SliceSignatures {
724
677
  const hash = (value: unknown): string => {
725
678
  try {
726
679
  return createHash("sha256").update(JSON.stringify(value)).digest("hex").slice(0, 12);
@@ -757,6 +710,45 @@ function sliceSignaturesFor(input: Omit<RunUiSnapshot, "signature" | "fetchedAt"
757
710
  };
758
711
  }
759
712
 
713
+ function signatureFor(
714
+ input: Omit<RunUiSnapshot, "signature" | "fetchedAt" | "sliceSignatures">,
715
+ stamps: SnapshotStamps,
716
+ sliceSignatures: SliceSignatures,
717
+ ): string {
718
+ try {
719
+ const digest = createHash("sha256");
720
+ digest.update(
721
+ JSON.stringify({
722
+ run: [input.manifest.runId, input.manifest.status, input.manifest.updatedAt, input.manifest.artifacts.length],
723
+ tasks: sliceSignatures.tasks,
724
+ agents: sliceSignatures.agents,
725
+ progress: input.progress,
726
+ usage: input.usage,
727
+ mailbox: input.mailbox,
728
+ groupJoins: input.groupJoins,
729
+ events: sliceSignatures.events,
730
+ cancellationReason: input.cancellationReason,
731
+ dwfPhaseState: input.dwfPhaseState,
732
+ output: input.recentOutputLines,
733
+ stamps,
734
+ }),
735
+ );
736
+ return digest.digest("hex").slice(0, 16);
737
+ } catch {
738
+ // Circular reference or non-serializable data — fall back to timestamp.
739
+ return String(Date.now());
740
+ }
741
+ }
742
+
743
+ /**
744
+ * 1.6 / 1.7 — compute one short hash per logical slice of the snapshot so
745
+ * dashboard panes / widget can short-circuit when their slice hasn't moved.
746
+ * The slice contents must mirror what `signatureFor` packs into each branch.
747
+ */
748
+ function sliceSignaturesFor(sliceSignatures: SliceSignatures): SliceSignatures {
749
+ return sliceSignatures;
750
+ }
751
+
760
752
  function stampsFor(manifest: TeamRunManifest, _agents: CrewAgentRecord[]): SnapshotStamps {
761
753
  // 1.4: use events sequence file instead of stat-ing the events log directly.
762
754
  // 1.5: drop per-agent output.log stamping; rely on event-bus invalidation
@@ -807,8 +799,17 @@ export function createRunSnapshotCache(cwd: string, options: RunSnapshotCacheOpt
807
799
 
808
800
  function evictIfNeeded(): void {
809
801
  while (entries.size > maxEntries) {
810
- const oldestInactive = [...entries.entries()].find(([, entry]) => !isActiveRunStatus(entry.snapshot.manifest.status));
811
- const key = oldestInactive?.[0] ?? entries.keys().next().value;
802
+ // Map iteration respects insertion order, so the first inactive entry
803
+ // we see IS the oldest inactive entry — no Array.from(...).sort()
804
+ // allocation, no copy of the entries map.
805
+ let key: string | undefined;
806
+ for (const [k, entry] of entries) {
807
+ if (!isActiveRunStatus(entry.snapshot.manifest.status)) {
808
+ key = k;
809
+ break;
810
+ }
811
+ }
812
+ if (!key) key = entries.keys().next().value;
812
813
  if (!key) break;
813
814
  entries.delete(key);
814
815
  }
@@ -854,11 +855,12 @@ export function createRunSnapshotCache(cwd: string, options: RunSnapshotCacheOpt
854
855
  recentOutputLines: recentOutputLines(loaded.manifest, agents, recentOutputLimit),
855
856
  };
856
857
  const stamps = stampsFor(loaded.manifest, agents);
858
+ const sliceSignatures = computeSliceSignatures(base);
857
859
  const snapshot: RunUiSnapshot = {
858
860
  ...base,
859
861
  fetchedAt: Date.now(),
860
- signature: signatureFor(base, stamps),
861
- sliceSignatures: sliceSignaturesFor(base),
862
+ signature: signatureFor(base, stamps, sliceSignatures),
863
+ sliceSignatures,
862
864
  };
863
865
  return {
864
866
  snapshot,
@@ -911,11 +913,12 @@ export function createRunSnapshotCache(cwd: string, options: RunSnapshotCacheOpt
911
913
  recentOutputLines: recentOutput,
912
914
  };
913
915
  const stamps = await stampsForAsync(loaded.manifest, agents);
916
+ const sliceSignatures = computeSliceSignatures(base);
914
917
  const snapshot: RunUiSnapshot = {
915
918
  ...base,
916
919
  fetchedAt: Date.now(),
917
- signature: signatureFor(base, stamps),
918
- sliceSignatures: sliceSignaturesFor(base),
920
+ signature: signatureFor(base, stamps, sliceSignatures),
921
+ sliceSignatures,
919
922
  };
920
923
  return {
921
924
  snapshot,