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
@@ -3,11 +3,25 @@ import * as fs from "node:fs";
3
3
  export interface TranscriptCacheEntry {
4
4
  path: string;
5
5
  mtimeMs: number;
6
+ /**
7
+ * Byte offset of the END of the cached text. Equals `offset + raw.length`
8
+ * (normally the file size at read time; only smaller if a concurrent
9
+ * writer shrank the file mid-read, which forces a fresh read next time).
10
+ */
6
11
  size: number;
12
+ /**
13
+ * Byte offset of the START of the cached text. Zero for whole-file reads;
14
+ * positive once the tail cap front-trims, because the cached text then
15
+ * starts partway into the file — not at offset 0.
16
+ */
17
+ offset: number;
18
+ /** Undecoded bytes backing the cached text, spanning [offset, size). */
19
+ raw: Buffer;
7
20
  lines: string[];
8
21
  parsedAt: number;
9
22
  readCount: number;
10
23
  mode: "tail" | "full";
24
+ /** Bytes actually read from disk on the most recent read (delta for appends). */
11
25
  bytesRead: number;
12
26
  truncated: boolean;
13
27
  }
@@ -47,34 +61,100 @@ export function getTranscriptCacheEntry(path: string, options: TranscriptReadOpt
47
61
  return transcriptCache.get(cacheKey(path, normalized)) ?? transcriptCache.get(path);
48
62
  }
49
63
 
64
+ interface TranscriptReadResult {
65
+ raw: Buffer;
66
+ offset: number;
67
+ bytesRead: number;
68
+ truncated: boolean;
69
+ }
70
+
71
+ /**
72
+ * Fresh read: whole file, or the last `maxTailBytes` bytes with the leading
73
+ * partial line skipped. Operates on raw bytes so a later append can extend
74
+ * the buffer and decode exactly like a fresh read of the same byte range.
75
+ */
50
76
  function readTranscriptText(
51
77
  path: string,
52
78
  stat: fs.Stats,
53
79
  options: Required<Pick<TranscriptReadOptions, "full">> & {
54
80
  maxTailBytes: number;
55
81
  },
56
- ): { text: string; bytesRead: number; truncated: boolean } {
82
+ ): TranscriptReadResult {
57
83
  if (options.full || stat.size <= options.maxTailBytes) {
58
- return {
59
- text: fs.readFileSync(path, "utf-8"),
60
- bytesRead: stat.size,
61
- truncated: false,
62
- };
84
+ const raw = fs.readFileSync(path);
85
+ return { raw, offset: 0, bytesRead: raw.length, truncated: false };
63
86
  }
64
87
  const bytesToRead = Math.min(stat.size, options.maxTailBytes);
65
88
  const fd = fs.openSync(path, "r");
66
89
  try {
67
90
  const buffer = Buffer.alloc(bytesToRead);
68
91
  fs.readSync(fd, buffer, 0, bytesToRead, stat.size - bytesToRead);
69
- let text = buffer.toString("utf-8");
70
- const firstNewline = text.search(/\r?\n/);
71
- if (firstNewline >= 0) text = text.slice(firstNewline + (text[firstNewline] === "\r" && text[firstNewline + 1] === "\n" ? 2 : 1));
72
- return { text, bytesRead: bytesToRead, truncated: true };
92
+ // Skip through the first newline so the tail starts on a line boundary
93
+ // (0x0a is ASCII, so a byte scan matches the old decoded-text scan).
94
+ const firstNewline = buffer.indexOf(0x0a);
95
+ const start = firstNewline >= 0 ? firstNewline + 1 : 0;
96
+ return {
97
+ raw: buffer.subarray(start),
98
+ offset: stat.size - bytesToRead + start,
99
+ bytesRead: bytesToRead,
100
+ truncated: true,
101
+ };
73
102
  } finally {
74
103
  fs.closeSync(fd);
75
104
  }
76
105
  }
77
106
 
107
+ /**
108
+ * Incremental read for append-only growth: read only [previous.size, size) and
109
+ * extend the cached bytes, then re-apply the tail cap by trimming the FRONT.
110
+ * Returns null when the delta could not be read completely (concurrent
111
+ * shrink/rotation) so the caller falls back to a fresh read.
112
+ */
113
+ function appendTranscriptText(
114
+ path: string,
115
+ previous: TranscriptCacheEntry,
116
+ stat: fs.Stats,
117
+ options: Required<Pick<TranscriptReadOptions, "full">> & {
118
+ maxTailBytes: number;
119
+ },
120
+ ): TranscriptReadResult | null {
121
+ const deltaLength = stat.size - previous.size;
122
+ const fd = fs.openSync(path, "r");
123
+ let delta: Buffer;
124
+ try {
125
+ delta = Buffer.alloc(deltaLength);
126
+ let read = 0;
127
+ while (read < deltaLength) {
128
+ const n = fs.readSync(fd, delta, read, deltaLength - read, previous.size + read);
129
+ if (n <= 0) break;
130
+ read += n;
131
+ }
132
+ if (read < deltaLength) return null;
133
+ } finally {
134
+ fs.closeSync(fd);
135
+ }
136
+ let raw = Buffer.concat([previous.raw, delta], previous.raw.length + deltaLength);
137
+ let offset = previous.offset;
138
+ const endOffset = previous.size + deltaLength;
139
+ if (!options.full && endOffset - offset > options.maxTailBytes) {
140
+ // Keep the tail bounded: drop to the start of the new window, then
141
+ // through the first newline so the front stays a complete line.
142
+ const windowStart = endOffset - options.maxTailBytes;
143
+ const firstNewline = raw.indexOf(0x0a, windowStart - offset);
144
+ const drop = firstNewline >= 0 ? firstNewline + 1 : windowStart - offset;
145
+ // Copy (not subarray) so a huge append does not keep its pre-trim
146
+ // buffer alive through the retained view.
147
+ raw = Buffer.from(raw.subarray(drop));
148
+ offset += drop;
149
+ }
150
+ return {
151
+ raw,
152
+ offset,
153
+ bytesRead: deltaLength,
154
+ truncated: !options.full && offset > 0,
155
+ };
156
+ }
157
+
78
158
  export function readTranscriptLinesCached(
79
159
  path: string,
80
160
  parse: (text: string) => string[],
@@ -98,12 +178,20 @@ export function readTranscriptLinesCached(
98
178
  return previous.lines;
99
179
  }
100
180
  try {
101
- const read = readTranscriptText(path, stat, normalized);
102
- const lines = parse(read.text);
181
+ // Append-only growth (size grew, mtime not older): read only the new
182
+ // bytes and extend the cached range. Any shrink or backdated mtime
183
+ // falls through to a fresh read below.
184
+ const read =
185
+ previous && stat.size > previous.size && stat.mtimeMs >= previous.mtimeMs
186
+ ? (appendTranscriptText(path, previous, stat, normalized) ?? readTranscriptText(path, stat, normalized))
187
+ : readTranscriptText(path, stat, normalized);
188
+ const lines = parse(read.raw.toString("utf-8"));
103
189
  const entry: TranscriptCacheEntry = {
104
190
  path,
105
191
  mtimeMs: stat.mtimeMs,
106
- size: stat.size,
192
+ size: read.offset + read.raw.length,
193
+ offset: read.offset,
194
+ raw: read.raw,
107
195
  lines,
108
196
  parsedAt: now,
109
197
  readCount: (previous?.readCount ?? 0) + 1,
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
2
2
  import { agentOutputPath, readCrewAgents } from "../runtime/crew-agent-records.ts";
3
3
  import type { TeamRunManifest } from "../state/types.ts";
4
4
  import { resolveRealContainedPath } from "../utils/safe-paths.ts";
5
- import { pad, truncate, truncateToVisualLines } from "../utils/visual.ts";
5
+ import { pad, truncate, truncateToVisualLines, truncateToVisualLinesTail } from "../utils/visual.ts";
6
6
  import type { InteractiveComponent } from "./component.ts";
7
7
  import { renderDiff } from "./render-diff.ts";
8
8
  import { colorForStatus, iconForStatus, type RunStatus } from "./status-colors.ts";
@@ -234,17 +234,60 @@ interface ViewerState {
234
234
  autoScroll: boolean;
235
235
  lastHeight: number;
236
236
  scroll: number;
237
+ // PERF (2026-08-24): full-wrap cache for the scroll-up path. ViewerState is
238
+ // built per render, so these are per-render scratch: the tail branch is the
239
+ // hot path (bottom-pinned ~500ms re-render) and never needs the full wrap;
240
+ // renders that fall to the else branch pay one full wrap each — the same
241
+ // price the pre-optimization code paid on EVERY render.
242
+ fullVisual: string[] | null;
243
+ sourceLen: number;
237
244
  }
238
245
 
239
246
  function renderViewerBase(state: ViewerState, width: number, lines: string[], title: string, subtitle: string): string[] {
240
247
  const inner = Math.max(20, width - 4);
248
+ // PERF (2026-08-24): transcripts grow to thousands of lines; wrapping every
249
+ // grapheme of the whole tail to display 16 rows was the largest CPU sink in
250
+ // the UI. Bottom-pinned (autoScroll) renders use the tail window — O(visible
251
+ // rows); scrolling up pays one full wrap per render (what the old code paid
252
+ // on EVERY render) and can page through the whole transcript.
241
253
  const bodyText = lines.join("\n");
242
- const { visualLines, skippedCount } = truncateToVisualLines(bodyText, state.lastHeight, inner);
243
- const maxScroll = Math.max(0, visualLines.length - state.lastHeight);
254
+ let visualLines: string[];
255
+ let skippedCount: number;
256
+ let maxScroll: number;
257
+ let tailWindow = false;
258
+ if (state.autoScroll && (state.fullVisual === null || state.sourceLen !== lines.length)) {
259
+ const tail = truncateToVisualLinesTail(bodyText, state.lastHeight, inner);
260
+ visualLines = tail.visualLines;
261
+ skippedCount = tail.skippedCount;
262
+ maxScroll = skippedCount; // bottom-pinned: everything above the window
263
+ state.fullVisual = null;
264
+ state.sourceLen = lines.length;
265
+ tailWindow = true;
266
+ } else {
267
+ if (state.fullVisual === null || state.sourceLen !== lines.length) {
268
+ // MAX_SAFE_INTEGER is never a real allocation: truncateToVisualLines
269
+ // returns early while visualLines.length <= limit (always true), so
270
+ // slice(-limit) never runs — this is just "wrap everything".
271
+ const full = truncateToVisualLines(bodyText, Number.MAX_SAFE_INTEGER, inner);
272
+ state.fullVisual = full.visualLines;
273
+ state.sourceLen = lines.length;
274
+ }
275
+ visualLines = state.fullVisual!;
276
+ skippedCount = 0;
277
+ maxScroll = Math.max(0, visualLines.length - state.lastHeight);
278
+ }
244
279
  if (state.autoScroll) state.scroll = maxScroll;
245
280
  state.scroll = Math.min(state.scroll, maxScroll);
246
- const visible = visualLines.slice(state.scroll, state.scroll + state.lastHeight);
247
- const statusLine = `${visualLines.length} lines · ${visualLines.length ? Math.round(((state.scroll + visible.length) / visualLines.length) * 100) : 100}% · auto-scroll ${state.autoScroll ? "on" : "off"}`;
281
+ // The tail window IS the bottom-pinned view: state.scroll holds the offset
282
+ // into the FULL wrap (skippedCount lines above), so slicing the ≤ lastHeight
283
+ // window at it would render an empty body. Display the window directly.
284
+ const visible = tailWindow ? visualLines : visualLines.slice(state.scroll, state.scroll + state.lastHeight);
285
+ // Tail-branch total is a lower bound: skippedCount counts SOURCE lines and
286
+ // each may wrap to several visual lines. "≥" flags that (short transcripts
287
+ // and the full-wrap branch keep the exact count).
288
+ const totalLines = tailWindow ? skippedCount + visible.length : visualLines.length;
289
+ const totalLabel = tailWindow && skippedCount > 0 ? `≥ ${totalLines}` : `${totalLines}`;
290
+ const statusLine = `${totalLabel} lines · ${totalLines ? Math.round(((state.scroll + visible.length) / totalLines) * 100) : 100}% · auto-scroll ${state.autoScroll ? "on" : "off"}`;
248
291
  const fg = (color: Parameters<TranscriptTheme["fg"]>[0], text: string) => state.theme.fg(color, text);
249
292
  const row = (text: string) => `${fg("border", "│")} ${pad(truncate(text, inner), inner)} ${fg("border", "│")}`;
250
293
  const linesOut: string[] = [
@@ -267,6 +310,11 @@ export class DurableTextViewer implements Component {
267
310
  private scroll = 0;
268
311
  private lastHeight = 16;
269
312
  private autoScroll = true;
313
+ // PERF (2026-08-24): whether the PREVIOUS render was bottom-pinned. The
314
+ // tail branch tracks `scroll` in source-line units, which is not a valid
315
+ // index into the full wrap — the first manual render after a pinned one
316
+ // clamps to the exact visual bottom instead of slicing at that stale value.
317
+ private prevAutoScroll = true;
270
318
  private title: string;
271
319
  private subtitle: string;
272
320
  private lines: string[];
@@ -312,6 +360,7 @@ export class DurableTextViewer implements Component {
312
360
  } else if (data === "g" || data === "\u001b[H") {
313
361
  this.scroll = 0;
314
362
  this.autoScroll = false;
363
+ this.prevAutoScroll = false; // explicit top — do not pin to bottom
315
364
  } else if (data === "G" || data === "\u001b[F") {
316
365
  this.scroll = maxScroll;
317
366
  this.autoScroll = true;
@@ -321,18 +370,23 @@ export class DurableTextViewer implements Component {
321
370
  }
322
371
 
323
372
  render(width: number): string[] {
324
- return renderViewerBase(
325
- {
326
- theme: this.theme,
327
- autoScroll: this.autoScroll,
328
- lastHeight: this.lastHeight,
329
- scroll: this.scroll,
330
- },
331
- width,
332
- this.lines,
333
- this.title,
334
- this.subtitle,
335
- );
373
+ // Leaving the pinned state (k/PgUp/a): the stale source-unit scroll
374
+ // would slice the full wrap in the wrong place — clamp to its bottom.
375
+ if (!this.autoScroll && this.prevAutoScroll) this.scroll = Number.MAX_SAFE_INTEGER;
376
+ const state: ViewerState = {
377
+ theme: this.theme,
378
+ autoScroll: this.autoScroll,
379
+ lastHeight: this.lastHeight,
380
+ scroll: this.scroll,
381
+ fullVisual: null,
382
+ sourceLen: 0,
383
+ };
384
+ const rendered = renderViewerBase(state, width, this.lines, this.title, this.subtitle);
385
+ // Write the clamped/updated scroll back so handleInput's scroll math
386
+ // starts from the position the user actually saw.
387
+ this.scroll = state.scroll;
388
+ this.prevAutoScroll = this.autoScroll;
389
+ return rendered;
336
390
  }
337
391
  }
338
392
 
@@ -340,6 +394,8 @@ export class DurableTranscriptViewer implements Component {
340
394
  private scroll = 0;
341
395
  private lastHeight = 16;
342
396
  private autoScroll = true;
397
+ // See DurableTextViewer.prevAutoScroll — same pinned→manual transition guard.
398
+ private prevAutoScroll = true;
343
399
  private manifest: TeamRunManifest;
344
400
  private theme: TranscriptTheme;
345
401
  private done: (result: undefined) => void;
@@ -406,6 +462,7 @@ export class DurableTranscriptViewer implements Component {
406
462
  } else if (data === "g" || data === "\u001b[H") {
407
463
  this.scroll = 0;
408
464
  this.autoScroll = false;
465
+ this.prevAutoScroll = false; // explicit top — do not pin to bottom
409
466
  } else if (data === "G" || data === "\u001b[F") {
410
467
  this.scroll = maxScroll;
411
468
  this.autoScroll = true;
@@ -415,6 +472,7 @@ export class DurableTranscriptViewer implements Component {
415
472
  this.fullTranscript = !this.fullTranscript;
416
473
  this.scroll = 0;
417
474
  this.autoScroll = !this.fullTranscript;
475
+ this.prevAutoScroll = this.autoScroll; // explicit reset — no bottom pin
418
476
  // The full/tail toggle changes the read options, so refresh the cache
419
477
  // with a single explicit read rather than per keystroke.
420
478
  this.cached = this.readTranscript(this.manifest, this.taskId, {
@@ -432,17 +490,27 @@ export class DurableTranscriptViewer implements Component {
432
490
  // Keep the per-keypress cache in sync with the latest rendered content
433
491
  // (the read is TTL-cached at ~500ms, so this stays cheap on each tick).
434
492
  this.cached = data;
435
- return renderViewerBase(
436
- {
437
- theme: this.theme,
438
- autoScroll: this.autoScroll,
439
- lastHeight: this.lastHeight,
440
- scroll: this.scroll,
441
- },
493
+ // Leaving the pinned state (k/PgUp/a): the stale source-unit scroll
494
+ // would slice the full wrap in the wrong place — clamp to its bottom.
495
+ if (!this.autoScroll && this.prevAutoScroll) this.scroll = Number.MAX_SAFE_INTEGER;
496
+ const state: ViewerState = {
497
+ theme: this.theme,
498
+ autoScroll: this.autoScroll,
499
+ lastHeight: this.lastHeight,
500
+ scroll: this.scroll,
501
+ fullVisual: null,
502
+ sourceLen: 0,
503
+ };
504
+ const rendered = renderViewerBase(
505
+ state,
442
506
  width,
443
507
  data.lines,
444
508
  "pi-crew transcript",
445
509
  `${data.title} · ${data.truncated ? `tail ${Math.round(data.bytesRead / 1024)}KB/${Math.round(data.size / 1024)}KB` : `full ${Math.round(data.size / 1024)}KB`} · f ${this.fullTranscript ? "tail" : "full"}`,
446
510
  );
511
+ // Write the clamped/updated scroll back (see DurableTextViewer.render).
512
+ this.scroll = state.scroll;
513
+ this.prevAutoScroll = this.autoScroll;
514
+ return rendered;
447
515
  }
448
516
  }
@@ -177,6 +177,14 @@ class CrewWidgetComponent implements WidgetComponent {
177
177
  private cachedWidth = 0;
178
178
  private cachedLines: string[] = [];
179
179
  private cachedBaseLines: string[] = [];
180
+ /**
181
+ * PERF (2026-08-24): truncate at build time, re-truncate only on width
182
+ * change — cache hits returned a fresh array + ANSI-aware width measurement
183
+ * per line on every frame. `cachedLines` stays raw (unclipped) so a width
184
+ * change can re-clip without a full rebuild; -1 marks "not truncated yet".
185
+ */
186
+ private truncatedLines: string[] = [];
187
+ private lastTruncateWidth = -1;
180
188
  private cachedTheme: CrewTheme;
181
189
  private readonly tui: unknown;
182
190
  private readonly unsubscribeTheme: () => void;
@@ -274,10 +282,23 @@ class CrewWidgetComponent implements WidgetComponent {
274
282
  this.cacheSignature = "";
275
283
  this.cachedBaseLines = [];
276
284
  this.cachedLines = [];
285
+ this.truncatedLines = [];
286
+ this.lastTruncateWidth = -1;
277
287
  this.cachedBuildSignature = "";
278
288
  this.cachedBuildSignatureAt = 0;
279
289
  }
280
290
 
291
+ /**
292
+ * PERF (2026-08-24): clip `cachedLines` to `width` only when the width
293
+ * changed since the last clip — the raw lines are stored unclipped so a
294
+ * resize re-clips them without rebuilding the whole cache.
295
+ */
296
+ private ensureTruncated(width: number): void {
297
+ if (width === this.lastTruncateWidth) return;
298
+ this.truncatedLines = this.cachedLines.map((line) => truncate(line, width));
299
+ this.lastTruncateWidth = width;
300
+ }
301
+
281
302
  /** Poke the host TUI to repaint immediately (defensive: no-op if unavailable). */
282
303
  requestRepaint(): void {
283
304
  requestRenderTarget(this.tui);
@@ -326,12 +347,14 @@ class CrewWidgetComponent implements WidgetComponent {
326
347
  this.cachedWidth = width;
327
348
  this.cachedTheme = this.theme;
328
349
  this.cacheSignature = signature;
350
+ this.lastTruncateWidth = -1;
329
351
  }
330
352
  if (runs.length === 0) {
331
353
  this.invalidate();
332
354
  return [];
333
355
  }
334
- return this.cachedLines.map((line) => truncate(line, width));
356
+ this.ensureTruncated(width);
357
+ return this.truncatedLines;
335
358
  }
336
359
 
337
360
  // Panel cursor/pane state is part of the rendered output, so it belongs in
@@ -361,6 +384,7 @@ class CrewWidgetComponent implements WidgetComponent {
361
384
  this.cachedWidth = width;
362
385
  this.cachedTheme = this.theme;
363
386
  this.cacheSignature = signatureWithPanel;
387
+ this.lastTruncateWidth = -1;
364
388
  }
365
389
 
366
390
  if (runs.length === 0) {
@@ -373,11 +397,13 @@ class CrewWidgetComponent implements WidgetComponent {
373
397
  return [];
374
398
  }
375
399
 
400
+ this.ensureTruncated(width);
376
401
  if (!compactDock) {
377
402
  const updatedHeader = `${runningGlyph}${this.cachedBaseLines[0]?.slice(1) ?? ""}`;
378
403
  this.cachedLines[0] = truncate(colorWidgetLine(updatedHeader, 0, this.theme), width);
404
+ this.truncatedLines[0] = truncate(this.cachedLines[0], width);
379
405
  }
380
- return this.cachedLines.map((line) => truncate(line, width));
406
+ return this.truncatedLines;
381
407
  }
382
408
  }
383
409
 
@@ -441,10 +467,6 @@ export function updateCrewWidget(
441
467
  // the only place already holding the manifest/snapshot caches — so the row
442
468
  // projection is registered here instead of re-reading state on every keypress.
443
469
  setPanelRowsProvider(() => panelRowsFromRuns(activeWidgetRuns(ctx.cwd, manifestCache, snapshotCache, preloadedManifests, workspaceId)));
444
- const lines = buildWidgetLines(ctx.cwd, state.frame, maxLines, runs, state.notificationCount ?? 0, getRenderWidth(), {
445
- rowStyle,
446
- ...panelDisplayState(),
447
- });
448
470
  const placement = config?.widgetPlacement ?? DEFAULT_UI.widgetPlacement;
449
471
  // `bottom` is not a pi widget slot: the dock then renders inside the
450
472
  // crew-vibes footer (dock-footer registry). pi's slot calls always use a
@@ -453,7 +475,9 @@ export function updateCrewWidget(
453
475
  const dockInFooter = bottomMode && isFooterDockSinkActive();
454
476
  const piPlacement: "aboveEditor" | "belowEditor" = bottomMode ? "belowEditor" : placement;
455
477
 
456
- ctx.ui.setStatus(STATUS_KEY, lines.length ? statusSummary(runs) : undefined);
478
+ // PERF (2026-08-24): the persistent CrewWidgetComponent renders itself from
479
+ // state.frame; the built line set was only ever used for its .length.
480
+ ctx.ui.setStatus(STATUS_KEY, runs.length > 0 ? statusSummary(runs) : undefined);
457
481
 
458
482
  const shouldClearLegacy = state.legacyCleared !== true || state.lastPlacement !== placement;
459
483
  if (shouldClearLegacy) {
@@ -461,7 +485,7 @@ export function updateCrewWidget(
461
485
  state.legacyCleared = true;
462
486
  }
463
487
 
464
- if (!lines.length) {
488
+ if (runs.length === 0) {
465
489
  if (state.lastVisibility !== "hidden" || state.lastPlacement !== placement) {
466
490
  setExtensionWidget(ctx, WIDGET_KEY, undefined, { placement: piPlacement });
467
491
  setExtensionWidget(ctx, TASKS_WIDGET_KEY, undefined, { placement: "aboveEditor" });
@@ -4,6 +4,12 @@ export const ANSI_PATTERN = /\u001b\[[0-?]*[ -/]*[@-~]/g;
4
4
 
5
5
  const WIDTH_CACHE_LIMIT = 256;
6
6
  const widthCache = new Map<string, number>();
7
+ // PERF (2026-08-24): truncateToWidth/wrapHard measure ONE codepoint at a time;
8
+ // those 1-2 char keys thrashed the 256-entry LRU, evicting every long-string
9
+ // entry that could actually hit. Short keys get their own cache (bounded
10
+ // alphabet — hit rate ~100%) so the LRU serves only whole lines/segments.
11
+ const SHORT_WIDTH_CACHE_LIMIT = 1024;
12
+ const shortWidthCache = new Map<string, number>();
7
13
 
8
14
  // NOTE: width measurement is delegated to pi-tui's `visibleWidth` (see below).
9
15
  // pi-tui is the renderer that HARD-ABORTS the session if any line exceeds the
@@ -20,6 +26,17 @@ export function visibleWidth(value: string): number {
20
26
  // LRU cache on top (pi-tui caches too, but this avoids repeated calls for the
21
27
  // hot render path).
22
28
  if (value.length > 4096) return tuiVisibleWidth(value);
29
+ if (value.length <= 2) {
30
+ const s = shortWidthCache.get(value);
31
+ if (s !== undefined) return s;
32
+ const w = tuiVisibleWidth(value);
33
+ if (shortWidthCache.size >= SHORT_WIDTH_CACHE_LIMIT) {
34
+ const first = shortWidthCache.keys().next().value;
35
+ if (first !== undefined) shortWidthCache.delete(first);
36
+ }
37
+ shortWidthCache.set(value, w);
38
+ return w;
39
+ }
23
40
  const cached = widthCache.get(value);
24
41
  if (cached !== undefined) return cached;
25
42
  const length = tuiVisibleWidth(value);
@@ -33,6 +50,7 @@ export function visibleWidth(value: string): number {
33
50
 
34
51
  export function __test__clearVisibleWidthCache(): void {
35
52
  widthCache.clear();
53
+ shortWidthCache.clear();
36
54
  }
37
55
 
38
56
  export function __test__visibleWidthCacheSize(): number {
@@ -205,3 +223,28 @@ export function truncateToVisualLines(text: string, maxVisualLines: number, widt
205
223
  const truncated = visualLines.slice(-limit);
206
224
  return { visualLines: truncated, skippedCount: visualLines.length - limit };
207
225
  }
226
+
227
+ /**
228
+ * PERF (2026-08-24): tail-windowed twin of truncateToVisualLines. Wraps lines
229
+ * from the END backwards and stops as soon as `maxVisualLines` visual lines
230
+ * exist — O(visible window) instead of O(whole transcript). `skippedCount`
231
+ * counts skipped SOURCE lines (a lower bound on skipped visual lines; the
232
+ * caller's total display therefore reads "≥ N lines" when scrolled to bottom).
233
+ */
234
+ export function truncateToVisualLinesTail(text: string, maxVisualLines: number, width: number, paddingX = 0): VisualTruncateResult {
235
+ if (!text) return { visualLines: [], skippedCount: 0 };
236
+ const effectiveWidth = Math.max(1, width - paddingX * 2);
237
+ const limit = Math.max(1, maxVisualLines);
238
+ const sourceLines = text.split("\n");
239
+ const wrapped: string[][] = [];
240
+ let collected = 0;
241
+ let index = sourceLines.length - 1;
242
+ while (index >= 0 && collected < limit) {
243
+ const w = wrapHard(pad(sourceLines[index]!, Math.max(0, effectiveWidth)).trimEnd(), effectiveWidth);
244
+ wrapped.push(w);
245
+ collected += w.length;
246
+ index -= 1;
247
+ }
248
+ const visualLines = wrapped.reverse().flat().slice(-limit);
249
+ return { visualLines, skippedCount: index + 1 };
250
+ }
@@ -129,19 +129,59 @@ function sanitizeBranchPart(value: string): string {
129
129
  );
130
130
  }
131
131
 
132
+ // PERF (2026-08-24): the sync path (prepareTaskWorkspace — per task!) ran
133
+ // findGitRoot (rev-parse spawn) + assertCleanLeader (status --porcelain spawn)
134
+ // on every call, and the reuse path ran assertCleanLeader twice. Mirror the
135
+ // async caches below (_gitRootCache/_cleanLeaderCache): same repoRoot → same
136
+ // answer within a run.
137
+ //
138
+ // Mirrored invalidation semantics (from the async caches below):
139
+ // - gitRoot: plain Map, NO TTL — the async _gitRootCache is process-lifetime with
140
+ // a FIFO cap; a cwd's repo root cannot change without moving .git itself.
141
+ // - cleanLeader: ONLY clean verdicts are cached — the async _cleanLeaderCache is
142
+ // a Set of verified-clean repoRoots and a dirty verdict throws BEFORE caching,
143
+ // so every call while dirty re-probes (mirrored here). The async cache has NO
144
+ // TTL (a clean verdict is trusted for the whole process), so the 30s TTL below
145
+ // is strictly MORE conservative than the async path: a leader dirtied after a
146
+ // cached clean verdict can slip through for at most 30s here vs. forever on
147
+ // the async path. The reuse-path re-check in prepareTaskWorkspace deletes the
148
+ // entry first, mirroring `_cleanLeaderCache.delete(repoRoot)` in the async variant.
149
+ const SYNC_WT_CACHE_TTL_MS = 30_000;
150
+ const MAX_SYNC_GIT_ROOT_CACHE = 256;
151
+ const MAX_SYNC_CLEAN_LEADER_CACHE = 256;
152
+ const syncGitRootCache = new Map<string, string>();
153
+ const syncCleanLeaderCache = new Map<string, { clean: boolean; expiresAt: number }>();
154
+
132
155
  export function findGitRoot(cwd: string): string {
133
- return git(cwd, ["rev-parse", "--show-toplevel"]);
156
+ const cached = syncGitRootCache.get(cwd);
157
+ if (cached) return cached;
158
+ const root = git(cwd, ["rev-parse", "--show-toplevel"]);
159
+ syncGitRootCache.set(cwd, root);
160
+ if (syncGitRootCache.size > MAX_SYNC_GIT_ROOT_CACHE) {
161
+ const oldest = syncGitRootCache.keys().next().value;
162
+ if (oldest !== undefined) syncGitRootCache.delete(oldest);
163
+ }
164
+ return root;
134
165
  }
135
166
 
136
167
  export function assertCleanLeader(repoRoot: string): void {
168
+ const cached = syncCleanLeaderCache.get(repoRoot);
169
+ if (cached?.clean && Date.now() < cached.expiresAt) return;
137
170
  // H-7 follow-up: --untracked-files=no so pi-crew's own auto-created .gitignore
138
171
  // (and any other untracked files the user hasn't staged) doesn't block worktree mode.
139
172
  // The worktree contract is "no tracked changes" — untracked files are safe since
140
173
  // they're either in .gitignore or the user can decide later.
141
174
  const status = git(repoRoot, ["status", "--porcelain", "--untracked-files=no"]);
142
175
  if (status.trim()) {
176
+ // Dirty verdicts are never cached (async mirror): the user may commit/stash
177
+ // seconds later, so the next call must re-probe.
143
178
  throw new Error("Worktree mode requires a clean leader repository. Commit/stash changes or use workspaceMode: 'single'.");
144
179
  }
180
+ syncCleanLeaderCache.set(repoRoot, { clean: true, expiresAt: Date.now() + SYNC_WT_CACHE_TTL_MS });
181
+ if (syncCleanLeaderCache.size > MAX_SYNC_CLEAN_LEADER_CACHE) {
182
+ const oldest = syncCleanLeaderCache.keys().next().value;
183
+ if (oldest !== undefined) syncCleanLeaderCache.delete(oldest);
184
+ }
145
185
  }
146
186
 
147
187
  // --- Async versions ---
@@ -453,6 +493,25 @@ function pruneStaleWorktrees(repoRoot: string): void {
453
493
  }
454
494
  }
455
495
 
496
+ // PERF (2026-08-24): worktree prune is a repo-level write; running it per
497
+ // task in a 50-task run is 50 prunes. At most once per repoRoot per minute.
498
+ // (The async variant memoizes at-most-once per process — see P1-11 above —
499
+ // so a 60s window prunes strictly more often than the async path already does.)
500
+ // R5-L2 mirror: FIFO-cap the key set so the map cannot grow unbounded.
501
+ const PRUNE_MIN_INTERVAL_MS = 60_000;
502
+ const MAX_PRUNE_THROTTLE_KEYS = 128;
503
+ const lastPruneAt = new Map<string, number>();
504
+ function pruneStaleWorktreesThrottled(repoRoot: string): void {
505
+ const last = lastPruneAt.get(repoRoot) ?? 0;
506
+ if (Date.now() - last < PRUNE_MIN_INTERVAL_MS) return;
507
+ lastPruneAt.set(repoRoot, Date.now());
508
+ if (lastPruneAt.size > MAX_PRUNE_THROTTLE_KEYS) {
509
+ const oldest = lastPruneAt.keys().next().value;
510
+ if (oldest !== undefined) lastPruneAt.delete(oldest);
511
+ }
512
+ pruneStaleWorktrees(repoRoot); // original body — best-effort, failures swallowed
513
+ }
514
+
456
515
  async function branchExistsAsync(repoRoot: string, branch: string): Promise<{ local: boolean; remoteOnly: boolean }> {
457
516
  let local = false;
458
517
  try {
@@ -818,11 +877,13 @@ export function prepareTaskWorkspace(manifest: TeamRunManifest, task: TeamTaskSt
818
877
  if (mergedReused.length > 0) {
819
878
  overlaySeedPaths(repoRoot, worktreePath, mergedReused);
820
879
  }
821
- // Re-validate leader is still clean before reusing — leader state may have changed since first preparation
880
+ // Re-validate leader is still clean before reusing — leader state may have changed since first preparation.
881
+ // Mirrors prepareTaskWorkspaceAsync: delete the cached verdict so this re-check probes live state.
882
+ syncCleanLeaderCache.delete(repoRoot);
822
883
  assertCleanLeader(repoRoot);
823
884
  return { cwd: worktreePath, worktreePath, branch, reused: true };
824
885
  }
825
- pruneStaleWorktrees(repoRoot);
886
+ pruneStaleWorktreesThrottled(repoRoot);
826
887
  const exists = branchExists(repoRoot, branch);
827
888
  let worktreeCreated = false;
828
889
  try {
@@ -1141,7 +1202,7 @@ export function prepareAgentWorktree(manifest: TeamRunManifest, agentId: string)
1141
1202
  const stamp = `${Date.now()}-${randomBytes(4).toString("hex")}`;
1142
1203
  const worktreePath = path.join(worktreeRoot, `${sanitizedAgentId}-${stamp}`);
1143
1204
  const branch = `pi-crew/${sanitizedRunId}/${sanitizedAgentId}-${stamp}`;
1144
- pruneStaleWorktrees(repoRoot);
1205
+ pruneStaleWorktreesThrottled(repoRoot);
1145
1206
  git(repoRoot, ["worktree", "add", "-b", branch, worktreePath, "HEAD"]);
1146
1207
  const nodeModulesLinked =
1147
1208
  loadedConfig.config.worktree?.linkNodeModules === true ? linkNodeModulesIfPresent(repoRoot, worktreePath) : false;