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.
- package/CHANGELOG.md +249 -0
- package/dist/index.mjs +98 -307
- package/package.json +2 -1
- package/schema.json +11 -0
- package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +6 -2
- package/skills/real-test-pi-crew/SKILL.md +278 -79
- package/src/config/config-merge.ts +11 -1
- package/src/config/config-validation.ts +40 -1
- package/src/config/config.ts +28 -6
- package/src/config/defaults.ts +35 -10
- package/src/config/env-vars.ts +27 -2
- package/src/config/types.ts +36 -0
- package/src/extension/registration/lifecycle-handlers.ts +40 -9
- package/src/extension/registration/team-tool.ts +53 -5
- package/src/extension/team-tool/doctor.ts +364 -7
- package/src/extension/team-tool/handle-settings.ts +19 -0
- package/src/extension/team-tool/inspect.ts +10 -2
- package/src/extension/team-tool/status.ts +7 -0
- package/src/extension/team-tool.ts +35 -2
- package/src/hooks/registry.ts +59 -56
- package/src/prompt/inbox-poll.ts +90 -0
- package/src/prompt/message-tool.ts +166 -0
- package/src/prompt/prompt-runtime.ts +201 -18
- package/src/prompt/surface-worker.ts +720 -0
- package/src/prompt/worker-events-channel.ts +49 -3
- package/src/runtime/async-runner.ts +29 -1
- package/src/runtime/background-runner.ts +13 -7
- package/src/runtime/broker/broker-issuer.ts +27 -2
- package/src/runtime/broker/crew-broker-tokens.ts +56 -4
- package/src/runtime/broker/crew-broker.ts +261 -41
- package/src/runtime/child-pi/child-pi-spawn.ts +23 -9
- package/src/runtime/child-pi/child-pi-streams.ts +9 -1
- package/src/runtime/child-pi/child-pi.ts +353 -5
- package/src/runtime/crew-agent-records.ts +13 -1
- package/src/runtime/dispatch-batch.ts +12 -1
- package/src/runtime/event-log-tail-source.ts +374 -0
- package/src/runtime/finalize-run.ts +4 -0
- package/src/runtime/live-session/live-agent-manager.ts +34 -1
- package/src/runtime/live-session/live-control-realtime.ts +10 -0
- package/src/runtime/live-session/live-session-runtime.ts +47 -27
- package/src/runtime/manifest-cache.ts +128 -17
- package/src/runtime/model/pi-args.ts +54 -65
- package/src/runtime/output/sidechain-output.ts +61 -6
- package/src/runtime/process/proc-stat.ts +46 -0
- package/src/runtime/process/zombie-scanner.ts +32 -19
- package/src/runtime/spawn-policy.ts +27 -41
- package/src/runtime/surface/degrade.ts +776 -0
- package/src/runtime/surface/herdr-provider.ts +546 -0
- package/src/runtime/surface/launch-script.ts +172 -0
- package/src/runtime/surface/resolve-surface.ts +274 -0
- package/src/runtime/surface/surface-provider.ts +129 -0
- package/src/runtime/surface/surface-spawn.ts +475 -0
- package/src/runtime/surface/tmux-provider.ts +400 -0
- package/src/runtime/task-runner/child-executor.ts +47 -0
- package/src/runtime/task-runner/post-execution.ts +57 -2
- package/src/runtime/task-runner/prompt-builder.ts +1 -0
- package/src/runtime/task-runner/retrieval-orchestrator.ts +191 -56
- package/src/runtime/task-runner/state-helpers.ts +54 -30
- package/src/runtime/task-runner.ts +4 -2
- package/src/runtime/team-runner.ts +101 -0
- package/src/schema/config-schema.ts +24 -0
- package/src/state/atomic-write.ts +219 -40
- package/src/state/coordination/locks.ts +7 -5
- package/src/state/coordination/mailbox.ts +56 -10
- package/src/state/event-log/cursor.ts +413 -23
- package/src/state/event-log/event-log.ts +120 -113
- package/src/state/event-log/sequence-cache.ts +21 -3
- package/src/state/stores/state-store.ts +98 -6
- package/src/state/types.ts +51 -0
- package/src/ui/inline-panel/agent-pane.ts +3 -0
- package/src/ui/render-diff.ts +16 -8
- package/src/ui/run-dashboard.ts +87 -42
- package/src/ui/run-event-bus.ts +10 -1
- package/src/ui/run-snapshot-cache.ts +83 -35
- package/src/ui/transcript-cache.ts +101 -13
- package/src/ui/transcript-viewer.ts +92 -24
- package/src/ui/widget/index.ts +32 -8
- package/src/utils/visual.ts +43 -0
- 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
|
-
):
|
|
82
|
+
): TranscriptReadResult {
|
|
57
83
|
if (options.full || stat.size <= options.maxTailBytes) {
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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:
|
|
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
|
-
|
|
243
|
-
|
|
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
|
-
|
|
247
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
}
|
package/src/ui/widget/index.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
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" });
|
package/src/utils/visual.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|