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
package/src/ui/render-diff.ts
CHANGED
|
@@ -36,10 +36,10 @@ const WORD_DIFF_MIN_SIM = 0.15;
|
|
|
36
36
|
* characters relative to the longer of the two strings, using word-level
|
|
37
37
|
* diff to identify the common (unchanged) parts. Returns a value in [0, 1].
|
|
38
38
|
*/
|
|
39
|
-
function computeSimilarity(oldContent: string, newContent: string): number {
|
|
40
|
-
const
|
|
39
|
+
function computeSimilarity(oldContent: string, newContent: string, wordDiff?: Diff.Change[]): number {
|
|
40
|
+
const parts = wordDiff ?? Diff.diffWords(oldContent, newContent);
|
|
41
41
|
let commonChars = 0;
|
|
42
|
-
for (const part of
|
|
42
|
+
for (const part of parts) {
|
|
43
43
|
if (!part.removed && !part.added) {
|
|
44
44
|
commonChars += part.value.length;
|
|
45
45
|
}
|
|
@@ -49,14 +49,19 @@ function computeSimilarity(oldContent: string, newContent: string): number {
|
|
|
49
49
|
return commonChars / maxLen;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
function renderIntraLineDiff(
|
|
53
|
-
|
|
52
|
+
function renderIntraLineDiff(
|
|
53
|
+
theme: CrewTheme,
|
|
54
|
+
oldContent: string,
|
|
55
|
+
newContent: string,
|
|
56
|
+
wordDiff?: Diff.Change[],
|
|
57
|
+
): { removedLine: string; addedLine: string } {
|
|
58
|
+
const parts = wordDiff ?? Diff.diffWords(oldContent, newContent);
|
|
54
59
|
let removedLine = "";
|
|
55
60
|
let addedLine = "";
|
|
56
61
|
let isFirstRemoved = true;
|
|
57
62
|
let isFirstAdded = true;
|
|
58
63
|
|
|
59
|
-
for (const part of
|
|
64
|
+
for (const part of parts) {
|
|
60
65
|
if (part.removed) {
|
|
61
66
|
let value = part.value;
|
|
62
67
|
if (isFirstRemoved) {
|
|
@@ -130,9 +135,12 @@ export function renderDiff(diffText: string, options: RenderDiffOptions = {}): s
|
|
|
130
135
|
if (removedLines.length === 1 && addedLines.length === 1) {
|
|
131
136
|
const oldContent = replaceTabs(removedLines[0]!.content);
|
|
132
137
|
const newContent = replaceTabs(addedLines[0]!.content);
|
|
133
|
-
|
|
138
|
+
// PERF (2026-08-24): diffWords is the expensive call and was run
|
|
139
|
+
// TWICE on identical inputs (similarity + render). One pass, shared.
|
|
140
|
+
const wordDiff = Diff.diffWords(oldContent, newContent);
|
|
141
|
+
const similarity = computeSimilarity(oldContent, newContent, wordDiff);
|
|
134
142
|
if (similarity >= WORD_DIFF_MIN_SIM) {
|
|
135
|
-
const { removedLine, addedLine } = renderIntraLineDiff(theme, oldContent, newContent);
|
|
143
|
+
const { removedLine, addedLine } = renderIntraLineDiff(theme, oldContent, newContent, wordDiff);
|
|
136
144
|
result.push(theme.fg("toolDiffRemoved", `-${removedLines[0]!.lineNum} ${removedLine}`));
|
|
137
145
|
result.push(theme.fg("toolDiffAdded", `+${addedLines[0]!.lineNum} ${addedLine}`));
|
|
138
146
|
} else {
|
package/src/ui/run-dashboard.ts
CHANGED
|
@@ -162,11 +162,11 @@ function formatAge(iso: string | undefined): string | undefined {
|
|
|
162
162
|
return `${Math.floor(ms / 3_600_000)}h`;
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
-
function readProgressPreview(run: TeamRunManifest, maxLines = 5, snapshotCache?: RunSnapshotCache): string[] {
|
|
165
|
+
function readProgressPreview(run: TeamRunManifest, maxLines = 5, snapshotCache?: RunSnapshotCache, resolve?: SnapshotResolver): string[] {
|
|
166
166
|
// P0-6: prefer the snapshot's `recentOutputLines` (no disk I/O) over reading the
|
|
167
167
|
// progress artifact on every render. The progress artifact content is captured
|
|
168
168
|
// into the snapshot's recent events / output pipeline upstream.
|
|
169
|
-
const snapshot = snapshotFor(run, snapshotCache);
|
|
169
|
+
const snapshot = resolve ? resolve(run) : snapshotFor(run, snapshotCache);
|
|
170
170
|
if (snapshot?.recentOutputLines?.length) {
|
|
171
171
|
return ["Progress:", ...snapshot.recentOutputLines.slice(0, maxLines)];
|
|
172
172
|
}
|
|
@@ -206,8 +206,20 @@ function snapshotFor(run: TeamRunManifest, snapshotCache?: RunSnapshotCache): Ru
|
|
|
206
206
|
}
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
-
|
|
210
|
-
|
|
209
|
+
/**
|
|
210
|
+
* PERF (2026-08-24, task 20) — per-frame snapshot resolver. The render path
|
|
211
|
+
* used to resolve each run's snapshot 6-8 times per frame (refreshRuns,
|
|
212
|
+
* buildSignature, groupedRuns, per-row, selected-run), and every
|
|
213
|
+
* `snapshotFor → refreshIfStale` call can stat several files per run when its
|
|
214
|
+
* own TTL expires. `renderUnsafe` now builds a frame-local Map keyed by runId
|
|
215
|
+
* and threads this resolver through every render-path helper. Helpers keep
|
|
216
|
+
* their plain direct-resolution behavior when no resolver is passed
|
|
217
|
+
* (keypress handlers and other callers outside a render frame).
|
|
218
|
+
*/
|
|
219
|
+
type SnapshotResolver = (run: TeamRunManifest) => RunUiSnapshot | undefined;
|
|
220
|
+
|
|
221
|
+
function readRunTasks(run: TeamRunManifest, snapshotCache?: RunSnapshotCache, resolve?: SnapshotResolver): TeamTaskState[] {
|
|
222
|
+
const snapshot = resolve ? resolve(run) : snapshotFor(run, snapshotCache);
|
|
211
223
|
if (snapshot) return snapshot.tasks;
|
|
212
224
|
// P0-6: when a snapshot cache is provided but hasn't populated yet, return
|
|
213
225
|
// empty (the render loop falls back to the empty pane placeholder) instead
|
|
@@ -266,15 +278,15 @@ function agentPreviewLine(agent: CrewAgentRecord, task: TeamTaskState | undefine
|
|
|
266
278
|
);
|
|
267
279
|
}
|
|
268
280
|
|
|
269
|
-
function readAgentPreview(run: TeamRunManifest, maxLines = 5, options: RunDashboardOptions = {}): string[] {
|
|
281
|
+
function readAgentPreview(run: TeamRunManifest, maxLines = 5, options: RunDashboardOptions = {}, resolve?: SnapshotResolver): string[] {
|
|
270
282
|
try {
|
|
271
|
-
const snapshot = snapshotFor(run, options.snapshotCache);
|
|
283
|
+
const snapshot = resolve ? resolve(run) : snapshotFor(run, options.snapshotCache);
|
|
272
284
|
// P0-6: when a snapshot cache is provided but hasn't populated yet, return
|
|
273
285
|
// the empty-pane placeholder instead of calling `readCrewAgents` (disk I/O)
|
|
274
286
|
// on every render tick. Legacy callers (no cache) keep the disk-read path
|
|
275
287
|
// so existing unit tests continue to assert against concrete agent data.
|
|
276
288
|
const agents = snapshot?.agents ?? (options.snapshotCache ? [] : readCrewAgents(run));
|
|
277
|
-
const tasks = snapshot?.tasks ?? readRunTasks(run, options.snapshotCache);
|
|
289
|
+
const tasks = snapshot?.tasks ?? readRunTasks(run, options.snapshotCache, resolve);
|
|
278
290
|
if (!agents.length) return ["Agents: (none)"];
|
|
279
291
|
const totals = tasks.reduce(
|
|
280
292
|
(acc, task) => {
|
|
@@ -305,8 +317,8 @@ function readAgentPreview(run: TeamRunManifest, maxLines = 5, options: RunDashbo
|
|
|
305
317
|
}
|
|
306
318
|
}
|
|
307
319
|
|
|
308
|
-
function agentsFor(run: TeamRunManifest, snapshotCache?: RunSnapshotCache): CrewAgentRecord[] {
|
|
309
|
-
const snapshot = snapshotFor(run, snapshotCache);
|
|
320
|
+
function agentsFor(run: TeamRunManifest, snapshotCache?: RunSnapshotCache, resolve?: SnapshotResolver): CrewAgentRecord[] {
|
|
321
|
+
const snapshot = resolve ? resolve(run) : snapshotFor(run, snapshotCache);
|
|
310
322
|
if (snapshot) return snapshot.agents;
|
|
311
323
|
// P0-6: when a snapshot cache is provided but hasn't populated yet, return
|
|
312
324
|
// empty (callers handle the empty-state placeholder) instead of calling
|
|
@@ -319,8 +331,14 @@ function agentsFor(run: TeamRunManifest, snapshotCache?: RunSnapshotCache): Crew
|
|
|
319
331
|
}
|
|
320
332
|
}
|
|
321
333
|
|
|
322
|
-
function runLabel(
|
|
323
|
-
|
|
334
|
+
function runLabel(
|
|
335
|
+
run: TeamRunManifest,
|
|
336
|
+
selected: boolean,
|
|
337
|
+
snapshotCache?: RunSnapshotCache,
|
|
338
|
+
maxW?: number,
|
|
339
|
+
resolve?: SnapshotResolver,
|
|
340
|
+
): string {
|
|
341
|
+
const agents = agentsFor(run, snapshotCache, resolve);
|
|
324
342
|
const stale = isLikelyOrphanedActiveRun(run, agents);
|
|
325
343
|
const running = agents.find((agent) => agent.status === "running");
|
|
326
344
|
const queued = agents.find((agent) => agent.status === "queued");
|
|
@@ -366,11 +384,11 @@ interface ResolvedRun {
|
|
|
366
384
|
status: RunStatus;
|
|
367
385
|
}
|
|
368
386
|
|
|
369
|
-
function resolveRuns(runs: TeamRunManifest[], snapshotCache?: RunSnapshotCache): Map<string, ResolvedRun> {
|
|
387
|
+
function resolveRuns(runs: TeamRunManifest[], snapshotCache?: RunSnapshotCache, resolve?: SnapshotResolver): Map<string, ResolvedRun> {
|
|
370
388
|
const map = new Map<string, ResolvedRun>();
|
|
371
389
|
for (const run of runs) {
|
|
372
|
-
const snapshot = snapshotFor(run, snapshotCache);
|
|
373
|
-
const agents = snapshot?.agents ?? agentsFor(run, snapshotCache);
|
|
390
|
+
const snapshot = resolve ? resolve(run) : snapshotFor(run, snapshotCache);
|
|
391
|
+
const agents = snapshot?.agents ?? agentsFor(run, snapshotCache, resolve);
|
|
374
392
|
const displayRun = snapshot?.manifest ?? run;
|
|
375
393
|
const status: RunStatus = isLikelyOrphanedActiveRun(displayRun, agents) ? "stale" : (displayRun.status as RunStatus);
|
|
376
394
|
map.set(run.runId, { manifest: run, snapshot, agents, status });
|
|
@@ -378,8 +396,12 @@ function resolveRuns(runs: TeamRunManifest[], snapshotCache?: RunSnapshotCache):
|
|
|
378
396
|
return map;
|
|
379
397
|
}
|
|
380
398
|
|
|
381
|
-
function groupedRuns(
|
|
382
|
-
|
|
399
|
+
function groupedRuns(
|
|
400
|
+
runs: TeamRunManifest[],
|
|
401
|
+
snapshotCache?: RunSnapshotCache,
|
|
402
|
+
resolve?: SnapshotResolver,
|
|
403
|
+
): Array<{ label: string; run?: TeamRunManifest }> {
|
|
404
|
+
const resolved = resolveRuns(runs, snapshotCache, resolve);
|
|
383
405
|
const rows: Array<{ label: string; run?: TeamRunManifest }> = [];
|
|
384
406
|
const active = runs.filter((run) =>
|
|
385
407
|
isDisplayActiveRun(resolved.get(run.runId)?.snapshot?.manifest ?? run, resolved.get(run.runId)?.agents ?? []),
|
|
@@ -392,8 +414,13 @@ function groupedRuns(runs: TeamRunManifest[], snapshotCache?: RunSnapshotCache):
|
|
|
392
414
|
return rows;
|
|
393
415
|
}
|
|
394
416
|
|
|
395
|
-
function selectedRunFromGrouped(
|
|
396
|
-
|
|
417
|
+
function selectedRunFromGrouped(
|
|
418
|
+
runs: TeamRunManifest[],
|
|
419
|
+
selected: number,
|
|
420
|
+
snapshotCache?: RunSnapshotCache,
|
|
421
|
+
resolve?: SnapshotResolver,
|
|
422
|
+
): TeamRunManifest | undefined {
|
|
423
|
+
return groupedRuns(runs, snapshotCache, resolve).filter((row) => row.run)[selected]?.run;
|
|
397
424
|
}
|
|
398
425
|
|
|
399
426
|
function countByStatus(runs: TeamRunManifest[], snapshotCache?: RunSnapshotCache): string {
|
|
@@ -520,9 +547,9 @@ export class RunDashboard implements DashboardComponent {
|
|
|
520
547
|
return Math.max(12, Math.min(36, rows - 2));
|
|
521
548
|
}
|
|
522
549
|
|
|
523
|
-
private refreshRuns(): void {
|
|
550
|
+
private refreshRuns(resolve?: SnapshotResolver): void {
|
|
524
551
|
if (!this.options.runProvider) return;
|
|
525
|
-
const selectedRunId = this.selectedRunId();
|
|
552
|
+
const selectedRunId = this.selectedRunId(resolve);
|
|
526
553
|
const next = this.options.runProvider();
|
|
527
554
|
// P3 (#8): re-apply the workspaceId filter on EVERY refresh, not just
|
|
528
555
|
// the constructor. Without this, runs from other sessions leak back in
|
|
@@ -532,7 +559,7 @@ export class RunDashboard implements DashboardComponent {
|
|
|
532
559
|
? unfiltered.filter((run) => !run.ownerSessionId || run.ownerSessionId === this.options.workspaceId)
|
|
533
560
|
: unfiltered;
|
|
534
561
|
if (selectedRunId) {
|
|
535
|
-
const nextIndex = groupedRuns(this.runs, this.options.snapshotCache)
|
|
562
|
+
const nextIndex = groupedRuns(this.runs, this.options.snapshotCache, resolve)
|
|
536
563
|
.filter((row) => row.run)
|
|
537
564
|
.findIndex((row) => row.run?.runId === selectedRunId);
|
|
538
565
|
if (nextIndex >= 0) this.selected = nextIndex;
|
|
@@ -564,7 +591,7 @@ export class RunDashboard implements DashboardComponent {
|
|
|
564
591
|
}
|
|
565
592
|
}
|
|
566
593
|
|
|
567
|
-
private buildSignature(): string {
|
|
594
|
+
private buildSignature(resolve?: SnapshotResolver): string {
|
|
568
595
|
// 1.10 (UI-P1-2) — short-TTL cache so we don't re-read every run's
|
|
569
596
|
// snapshot on every render tick. `snapshotFor → refreshIfStale` can
|
|
570
597
|
// stat multiple files per run when its own TTL expires, and the
|
|
@@ -579,9 +606,9 @@ export class RunDashboard implements DashboardComponent {
|
|
|
579
606
|
let hasRunning = false;
|
|
580
607
|
const statuses = this.runs
|
|
581
608
|
.map((run) => {
|
|
582
|
-
const snapshot = snapshotFor(run, this.options.snapshotCache);
|
|
609
|
+
const snapshot = resolve ? resolve(run) : snapshotFor(run, this.options.snapshotCache);
|
|
583
610
|
const displayRun = snapshot?.manifest ?? run;
|
|
584
|
-
const agents = snapshot?.agents ?? agentsFor(run, this.options.snapshotCache);
|
|
611
|
+
const agents = snapshot?.agents ?? agentsFor(run, this.options.snapshotCache, resolve);
|
|
585
612
|
const stale = isLikelyOrphanedActiveRun(displayRun, agents);
|
|
586
613
|
const status: RunStatus = stale ? "stale" : (displayRun.status as RunStatus);
|
|
587
614
|
if (status === "running" || agents.some((agent) => agent.status === "running")) hasRunning = true;
|
|
@@ -611,8 +638,8 @@ export class RunDashboard implements DashboardComponent {
|
|
|
611
638
|
this.schedulerHandle?.dispose();
|
|
612
639
|
}
|
|
613
640
|
|
|
614
|
-
private selectedRunId(): string | undefined {
|
|
615
|
-
return selectedRunFromGrouped(this.runs, this.selected, this.options.snapshotCache)?.runId;
|
|
641
|
+
private selectedRunId(resolve?: SnapshotResolver): string | undefined {
|
|
642
|
+
return selectedRunFromGrouped(this.runs, this.selected, this.options.snapshotCache, resolve)?.runId;
|
|
616
643
|
}
|
|
617
644
|
|
|
618
645
|
render(width: number): string[] {
|
|
@@ -625,13 +652,25 @@ export class RunDashboard implements DashboardComponent {
|
|
|
625
652
|
}
|
|
626
653
|
|
|
627
654
|
private renderUnsafe(width: number): string[] {
|
|
628
|
-
|
|
629
|
-
|
|
655
|
+
// PERF (2026-08-24): snapshot resolution stat'd 7-8 files per run 6-8
|
|
656
|
+
// times per frame. Resolve once per frame into a local map and thread
|
|
657
|
+
// it through every render-path consumer below.
|
|
658
|
+
const frameSnapshots = new Map<string, RunUiSnapshot | undefined>();
|
|
659
|
+
const snapshotOnce: SnapshotResolver = (run) => {
|
|
660
|
+
if (!frameSnapshots.has(run.runId)) frameSnapshots.set(run.runId, snapshotFor(run, this.options.snapshotCache));
|
|
661
|
+
return frameSnapshots.get(run.runId);
|
|
662
|
+
};
|
|
663
|
+
this.refreshRuns(snapshotOnce);
|
|
664
|
+
const signature = this.buildSignature(snapshotOnce);
|
|
630
665
|
if (signature !== this.cachedVersion || this.cachedWidth !== width) {
|
|
631
666
|
const innerWidth = Math.max(20, width - 4);
|
|
632
667
|
const borderWidth = Math.min(innerWidth, Math.max(0, width - 2));
|
|
633
668
|
const fg = (color: Parameters<CrewTheme["fg"]>[0], text: string) => this.theme.fg(color, text);
|
|
634
|
-
|
|
669
|
+
// PERF (2026-08-24): DynamicCrewBorder caches the rendered fill per
|
|
670
|
+
// instance — a new instance per line defeated it. One per render
|
|
671
|
+
// pass; every border()/sep() call below reuses the cached line.
|
|
672
|
+
const crewBorder = new DynamicCrewBorder(this.theme);
|
|
673
|
+
const borderFill = (count: number) => crewBorder.render(count)[0];
|
|
635
674
|
const border = (left: string, right: string) => `${fg("border", left)}${borderFill(borderWidth)}${fg("border", right)}`;
|
|
636
675
|
const row = (text: string) => `│ ${pad(truncate(text, innerWidth - 1), innerWidth - 1)}│`;
|
|
637
676
|
const sep = () => border("├", "┤");
|
|
@@ -655,7 +694,7 @@ export class RunDashboard implements DashboardComponent {
|
|
|
655
694
|
lines.push(row(fg("dim", "Start one: team action='run' · r reload · Esc close")));
|
|
656
695
|
} else {
|
|
657
696
|
// L-1: windowed run list so the selection can never scroll off-screen.
|
|
658
|
-
const allGrouped = groupedRuns(this.runs, this.options.snapshotCache);
|
|
697
|
+
const allGrouped = groupedRuns(this.runs, this.options.snapshotCache, snapshotOnce);
|
|
659
698
|
const selectable = allGrouped.filter((rowItem) => rowItem.run);
|
|
660
699
|
const selectableCount = selectable.length;
|
|
661
700
|
if (this.selected > selectableCount - 1) this.selected = Math.max(0, selectableCount - 1);
|
|
@@ -668,11 +707,11 @@ export class RunDashboard implements DashboardComponent {
|
|
|
668
707
|
continue;
|
|
669
708
|
}
|
|
670
709
|
const idx = selectable.findIndex((c) => c.run?.runId === rowItem.run?.runId);
|
|
671
|
-
const snap =
|
|
710
|
+
const snap = snapshotOnce(rowItem.run);
|
|
672
711
|
const run = snap?.manifest ?? rowItem.run;
|
|
673
|
-
const agents = snap?.agents ?? agentsFor(rowItem.run, this.options.snapshotCache);
|
|
712
|
+
const agents = snap?.agents ?? agentsFor(rowItem.run, this.options.snapshotCache, snapshotOnce);
|
|
674
713
|
const status: RunStatus = isLikelyOrphanedActiveRun(run, agents) ? "stale" : (run.status as RunStatus);
|
|
675
|
-
const label = runLabel(run, idx === this.selected, this.options.snapshotCache, innerWidth - 2);
|
|
714
|
+
const label = runLabel(run, idx === this.selected, this.options.snapshotCache, innerWidth - 2, snapshotOnce);
|
|
676
715
|
lines.push(row(applyStatusColor(this.theme, status, label)));
|
|
677
716
|
}
|
|
678
717
|
} else {
|
|
@@ -682,25 +721,28 @@ export class RunDashboard implements DashboardComponent {
|
|
|
682
721
|
for (let gi = this.runScrollOffset; gi < Math.min(this.runScrollOffset + win.slots, selectableCount); gi++) {
|
|
683
722
|
const rowItem = selectable[gi];
|
|
684
723
|
if (!rowItem?.run) continue;
|
|
685
|
-
const snap =
|
|
724
|
+
const snap = snapshotOnce(rowItem.run);
|
|
686
725
|
const run = snap?.manifest ?? rowItem.run;
|
|
687
|
-
const agents = snap?.agents ?? agentsFor(rowItem.run, this.options.snapshotCache);
|
|
726
|
+
const agents = snap?.agents ?? agentsFor(rowItem.run, this.options.snapshotCache, snapshotOnce);
|
|
688
727
|
const status: RunStatus = isLikelyOrphanedActiveRun(run, agents) ? "stale" : (run.status as RunStatus);
|
|
689
|
-
const label = runLabel(run, gi === this.selected, this.options.snapshotCache, innerWidth - 2);
|
|
728
|
+
const label = runLabel(run, gi === this.selected, this.options.snapshotCache, innerWidth - 2, snapshotOnce);
|
|
690
729
|
lines.push(row(applyStatusColor(this.theme, status, label)));
|
|
691
730
|
}
|
|
692
731
|
if (win.hasBottom)
|
|
693
732
|
lines.push(row(fg("dim", `↓ ${selectableCount - (this.runScrollOffset + win.slots)} more below`)));
|
|
694
733
|
}
|
|
695
734
|
|
|
696
|
-
// Selected run detail — compact
|
|
697
|
-
|
|
735
|
+
// Selected run detail — compact. PERF (2026-08-24): reuse the
|
|
736
|
+
// `selectable` rows already derived from the single groupedRuns()
|
|
737
|
+
// computation above instead of recomputing grouping a third time
|
|
738
|
+
// (`selectedRunFromGrouped` is exactly `selectable[selected].run`).
|
|
739
|
+
const selectedRun = selectable[Math.min(this.selected, selectable.length - 1)]?.run;
|
|
698
740
|
if (selectedRun) {
|
|
699
|
-
const snap =
|
|
741
|
+
const snap = snapshotOnce(selectedRun);
|
|
700
742
|
const r = snap?.manifest ?? selectedRun;
|
|
701
|
-
const agents = snap?.agents ?? agentsFor(selectedRun, this.options.snapshotCache);
|
|
743
|
+
const agents = snap?.agents ?? agentsFor(selectedRun, this.options.snapshotCache, snapshotOnce);
|
|
702
744
|
const statusStr: RunStatus = isLikelyOrphanedActiveRun(r, agents) ? "stale" : (r.status as RunStatus);
|
|
703
|
-
const selectedTasks = snap?.tasks ?? readRunTasks(r, this.options.snapshotCache);
|
|
745
|
+
const selectedTasks = snap?.tasks ?? readRunTasks(r, this.options.snapshotCache, snapshotOnce);
|
|
704
746
|
lines.push(sep());
|
|
705
747
|
lines.push(row(`${fg("accent", "▸")} ${truncate(sanitizeLine(r.goal), innerWidth - 6)}`));
|
|
706
748
|
// L-2: surface the failure/cancellation reason inline for terminal runs.
|
|
@@ -741,7 +783,10 @@ export class RunDashboard implements DashboardComponent {
|
|
|
741
783
|
: this.activePane === "plan"
|
|
742
784
|
? safeRenderPane("plan", () => renderPlanPane(snap, { diff: this.planDiff }))
|
|
743
785
|
: safeRenderPane("transcript", () => renderTranscriptPane(snap))
|
|
744
|
-
: [
|
|
786
|
+
: [
|
|
787
|
+
...readAgentPreview(r, 4, this.options, snapshotOnce),
|
|
788
|
+
...readProgressPreview(r, 2, this.options.snapshotCache, snapshotOnce),
|
|
789
|
+
];
|
|
745
790
|
const filteredPane = paneLines.filter((l) => l && !l.includes("(none)") && l.trim() !== "");
|
|
746
791
|
if (filteredPane.length > 0) {
|
|
747
792
|
lines.push(row(fg("dim", `── ${this.activePane} ──`)));
|
package/src/ui/run-event-bus.ts
CHANGED
|
@@ -359,7 +359,16 @@ export function teamEventToRunEventType(event: TeamEvent): RunEventType | undefi
|
|
|
359
359
|
if (type === "run.blocked") return "run_blocked";
|
|
360
360
|
if (type === "run.running") return "run_started";
|
|
361
361
|
if (type === "run.cancelled") return "run_cancelled";
|
|
362
|
-
if (
|
|
362
|
+
if (
|
|
363
|
+
type === "task.progress" ||
|
|
364
|
+
type === "mailbox.message_queued" ||
|
|
365
|
+
type === "mailbox.message_delivered" ||
|
|
366
|
+
// Task 5b (§15.2 wake): broker-side worker→parent wake signal. Maps to
|
|
367
|
+
// the existing mailbox_updated/run:state channel so sidebar+widget
|
|
368
|
+
// refresh through the render scheduler without new UI code.
|
|
369
|
+
type === "worker.message"
|
|
370
|
+
)
|
|
371
|
+
return "mailbox_updated";
|
|
363
372
|
if (type === "run.effectiveness" || type === "task.attention") return "effectiveness_changed";
|
|
364
373
|
return undefined;
|
|
365
374
|
}
|
|
@@ -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 {
|