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.
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +249 -0
- package/README.md +5 -1
- package/dist/index.mjs +10844 -7250
- package/docs/architecture.md +4 -4
- package/docs/commands-reference.md +3 -0
- package/docs/publishing.md +15 -3
- package/install.mjs +90 -39
- package/package.json +9 -3
- package/schema.json +11 -0
- package/scripts/README.md +4 -3
- package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +7 -2
- package/skills/real-test-pi-crew/SKILL.md +428 -82
- 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/migration-validator.ts +113 -0
- package/src/config/types.ts +36 -0
- package/src/extension/cross-extension-rpc.ts +3 -7
- package/src/extension/register.ts +13 -0
- package/src/extension/registration/lifecycle-handlers.ts +40 -9
- package/src/extension/registration/observability.ts +3 -7
- package/src/extension/registration/subagent-tools.ts +3 -7
- package/src/extension/registration/team-tool.ts +56 -12
- package/src/extension/registration/ui.ts +3 -8
- package/src/extension/registration/viewers.ts +3 -10
- package/src/extension/team-manager-command.ts +3 -7
- package/src/extension/team-tool/api/agent-control.ts +17 -10
- package/src/extension/team-tool/api/heartbeat.ts +4 -3
- package/src/extension/team-tool/api/mailbox.ts +33 -20
- package/src/extension/team-tool/api/plan-approval.ts +5 -5
- package/src/extension/team-tool/api/task-claims.ts +8 -7
- package/src/extension/team-tool/cancel.ts +6 -0
- package/src/extension/team-tool/doctor.ts +364 -7
- package/src/extension/team-tool/handle-settings.ts +23 -1
- package/src/extension/team-tool/inspect.ts +10 -2
- package/src/extension/team-tool/run.ts +3 -7
- package/src/extension/team-tool/status.ts +12 -0
- package/src/extension/team-tool.ts +41 -16
- package/src/hooks/registry.ts +62 -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/scratchpad-lifecycle.ts +3 -3
- 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 +43 -42
- 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 +334 -443
- package/src/runtime/broker/delegate/delegate-event.ts +37 -0
- package/src/runtime/broker/mailbox-observer/mailbox-fanout.ts +59 -0
- package/src/runtime/broker/protocol/connection-state.ts +103 -0
- package/src/runtime/broker/protocol/events-replay.ts +68 -0
- package/src/runtime/broker/protocol/manifest-loader.ts +20 -0
- package/src/runtime/broker/protocol/msg-inbox.ts +69 -0
- package/src/runtime/broker/protocol/request-parsers.ts +175 -0
- package/src/runtime/broker/protocol/wait-auth.ts +46 -0
- 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 +368 -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 +19 -7
- package/src/runtime/foreground-control.ts +19 -6
- package/src/runtime/goal-workflow/dynamic-workflow-context.ts +6 -0
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +3 -0
- package/src/runtime/goal-workflow/goal-loop-runner.ts +29 -27
- package/src/runtime/goal-workflow/goal-state-store.ts +3 -0
- package/src/runtime/heartbeat/heartbeat-watcher.ts +3 -3
- 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 +59 -65
- package/src/runtime/output/sidechain-output.ts +61 -6
- package/src/runtime/plan-replan.ts +3 -0
- 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/stale-reconciler.ts +28 -3
- package/src/runtime/supervisor-contact.ts +3 -0
- 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 +80 -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 +104 -3
- 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/ownership-map.ts +5 -4
- package/src/state/stores/plan-store.ts +12 -0
- package/src/state/stores/state-store.ts +103 -6
- package/src/state/types.ts +51 -0
- package/src/ui/inline-panel/agent-pane.ts +3 -0
- package/src/ui/powerbar-publisher.ts +3 -7
- package/src/ui/render-diff.ts +16 -8
- package/src/ui/run-action-dispatcher.ts +7 -10
- 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/settings-overlay.ts +4 -1
- 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
|
@@ -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
|
-
/**
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
.
|
|
207
|
-
|
|
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
|
-
|
|
410
|
-
|
|
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 =
|
|
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:
|
|
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(
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
869
|
-
|
|
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
|
|
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))
|
|
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))
|
|
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
|
-
|
|
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
|
-
):
|
|
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
|
}
|