pi-crew 0.10.2 → 0.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +249 -0
  3. package/README.md +5 -1
  4. package/dist/index.mjs +10844 -7250
  5. package/docs/architecture.md +4 -4
  6. package/docs/commands-reference.md +3 -0
  7. package/docs/publishing.md +15 -3
  8. package/install.mjs +90 -39
  9. package/package.json +9 -3
  10. package/schema.json +11 -0
  11. package/scripts/README.md +4 -3
  12. package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +7 -2
  13. package/skills/real-test-pi-crew/SKILL.md +428 -82
  14. package/src/config/config-merge.ts +11 -1
  15. package/src/config/config-validation.ts +40 -1
  16. package/src/config/config.ts +28 -6
  17. package/src/config/defaults.ts +35 -10
  18. package/src/config/env-vars.ts +27 -2
  19. package/src/config/migration-validator.ts +113 -0
  20. package/src/config/types.ts +36 -0
  21. package/src/extension/cross-extension-rpc.ts +3 -7
  22. package/src/extension/register.ts +13 -0
  23. package/src/extension/registration/lifecycle-handlers.ts +40 -9
  24. package/src/extension/registration/observability.ts +3 -7
  25. package/src/extension/registration/subagent-tools.ts +3 -7
  26. package/src/extension/registration/team-tool.ts +56 -12
  27. package/src/extension/registration/ui.ts +3 -8
  28. package/src/extension/registration/viewers.ts +3 -10
  29. package/src/extension/team-manager-command.ts +3 -7
  30. package/src/extension/team-tool/api/agent-control.ts +17 -10
  31. package/src/extension/team-tool/api/heartbeat.ts +4 -3
  32. package/src/extension/team-tool/api/mailbox.ts +33 -20
  33. package/src/extension/team-tool/api/plan-approval.ts +5 -5
  34. package/src/extension/team-tool/api/task-claims.ts +8 -7
  35. package/src/extension/team-tool/cancel.ts +6 -0
  36. package/src/extension/team-tool/doctor.ts +364 -7
  37. package/src/extension/team-tool/handle-settings.ts +23 -1
  38. package/src/extension/team-tool/inspect.ts +10 -2
  39. package/src/extension/team-tool/run.ts +3 -7
  40. package/src/extension/team-tool/status.ts +12 -0
  41. package/src/extension/team-tool.ts +41 -16
  42. package/src/hooks/registry.ts +62 -56
  43. package/src/prompt/inbox-poll.ts +90 -0
  44. package/src/prompt/message-tool.ts +166 -0
  45. package/src/prompt/prompt-runtime.ts +201 -18
  46. package/src/prompt/scratchpad-lifecycle.ts +3 -3
  47. package/src/prompt/surface-worker.ts +720 -0
  48. package/src/prompt/worker-events-channel.ts +49 -3
  49. package/src/runtime/async-runner.ts +29 -1
  50. package/src/runtime/background-runner.ts +43 -42
  51. package/src/runtime/broker/broker-issuer.ts +27 -2
  52. package/src/runtime/broker/crew-broker-tokens.ts +56 -4
  53. package/src/runtime/broker/crew-broker.ts +334 -443
  54. package/src/runtime/broker/delegate/delegate-event.ts +37 -0
  55. package/src/runtime/broker/mailbox-observer/mailbox-fanout.ts +59 -0
  56. package/src/runtime/broker/protocol/connection-state.ts +103 -0
  57. package/src/runtime/broker/protocol/events-replay.ts +68 -0
  58. package/src/runtime/broker/protocol/manifest-loader.ts +20 -0
  59. package/src/runtime/broker/protocol/msg-inbox.ts +69 -0
  60. package/src/runtime/broker/protocol/request-parsers.ts +175 -0
  61. package/src/runtime/broker/protocol/wait-auth.ts +46 -0
  62. package/src/runtime/child-pi/child-pi-spawn.ts +23 -9
  63. package/src/runtime/child-pi/child-pi-streams.ts +9 -1
  64. package/src/runtime/child-pi/child-pi.ts +368 -5
  65. package/src/runtime/crew-agent-records.ts +13 -1
  66. package/src/runtime/dispatch-batch.ts +12 -1
  67. package/src/runtime/event-log-tail-source.ts +374 -0
  68. package/src/runtime/finalize-run.ts +19 -7
  69. package/src/runtime/foreground-control.ts +19 -6
  70. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +6 -0
  71. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +3 -0
  72. package/src/runtime/goal-workflow/goal-loop-runner.ts +29 -27
  73. package/src/runtime/goal-workflow/goal-state-store.ts +3 -0
  74. package/src/runtime/heartbeat/heartbeat-watcher.ts +3 -3
  75. package/src/runtime/live-session/live-agent-manager.ts +34 -1
  76. package/src/runtime/live-session/live-control-realtime.ts +10 -0
  77. package/src/runtime/live-session/live-session-runtime.ts +47 -27
  78. package/src/runtime/manifest-cache.ts +128 -17
  79. package/src/runtime/model/pi-args.ts +59 -65
  80. package/src/runtime/output/sidechain-output.ts +61 -6
  81. package/src/runtime/plan-replan.ts +3 -0
  82. package/src/runtime/process/proc-stat.ts +46 -0
  83. package/src/runtime/process/zombie-scanner.ts +32 -19
  84. package/src/runtime/spawn-policy.ts +27 -41
  85. package/src/runtime/stale-reconciler.ts +28 -3
  86. package/src/runtime/supervisor-contact.ts +3 -0
  87. package/src/runtime/surface/degrade.ts +776 -0
  88. package/src/runtime/surface/herdr-provider.ts +546 -0
  89. package/src/runtime/surface/launch-script.ts +172 -0
  90. package/src/runtime/surface/resolve-surface.ts +274 -0
  91. package/src/runtime/surface/surface-provider.ts +129 -0
  92. package/src/runtime/surface/surface-spawn.ts +475 -0
  93. package/src/runtime/surface/tmux-provider.ts +400 -0
  94. package/src/runtime/task-runner/child-executor.ts +80 -0
  95. package/src/runtime/task-runner/post-execution.ts +57 -2
  96. package/src/runtime/task-runner/prompt-builder.ts +1 -0
  97. package/src/runtime/task-runner/retrieval-orchestrator.ts +191 -56
  98. package/src/runtime/task-runner/state-helpers.ts +54 -30
  99. package/src/runtime/task-runner.ts +4 -2
  100. package/src/runtime/team-runner.ts +104 -3
  101. package/src/schema/config-schema.ts +24 -0
  102. package/src/state/atomic-write.ts +219 -40
  103. package/src/state/coordination/locks.ts +7 -5
  104. package/src/state/coordination/mailbox.ts +56 -10
  105. package/src/state/event-log/cursor.ts +413 -23
  106. package/src/state/event-log/event-log.ts +120 -113
  107. package/src/state/event-log/sequence-cache.ts +21 -3
  108. package/src/state/stores/ownership-map.ts +5 -4
  109. package/src/state/stores/plan-store.ts +12 -0
  110. package/src/state/stores/state-store.ts +103 -6
  111. package/src/state/types.ts +51 -0
  112. package/src/ui/inline-panel/agent-pane.ts +3 -0
  113. package/src/ui/powerbar-publisher.ts +3 -7
  114. package/src/ui/render-diff.ts +16 -8
  115. package/src/ui/run-action-dispatcher.ts +7 -10
  116. package/src/ui/run-dashboard.ts +87 -42
  117. package/src/ui/run-event-bus.ts +10 -1
  118. package/src/ui/run-snapshot-cache.ts +83 -35
  119. package/src/ui/settings-overlay.ts +4 -1
  120. package/src/ui/transcript-cache.ts +101 -13
  121. package/src/ui/transcript-viewer.ts +92 -24
  122. package/src/ui/widget/index.ts +32 -8
  123. package/src/utils/visual.ts +43 -0
  124. package/src/worktree/worktree-manager.ts +65 -4
@@ -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 {
@@ -345,7 +345,10 @@ const EFFECTIVE_DEFAULTS: Record<string, unknown> = {
345
345
  "ui.dashboardPlacement": "center",
346
346
  "ui.dashboardWidth": 72,
347
347
  "ui.autoOpenDashboard": false,
348
- "ui.widgetPlacement": "aboveEditor",
348
+ // G17 sync (2026-09-10 review): canonical DEFAULT_UI.widgetPlacement =
349
+ // "bottom" (defaults.ts / install.mjs / project-init) — was drifted
350
+ // "aboveEditor" in this duplicated EFFECTIVE_DEFAULTS map.
351
+ "ui.widgetPlacement": "bottom",
349
352
  "autonomous.enabled": true,
350
353
  "autonomous.injectPolicy": true,
351
354
  "autonomous.preferAsyncForLongTasks": false,
@@ -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
  }