pi-workflow-engine 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +256 -0
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +64 -0
  3. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +238 -0
  4. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +28 -0
  5. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +171 -0
  6. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +152 -0
  7. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +114 -0
  8. package/{src → .pi/extensions/pi-workflow-engine/src}/engine.ts +36 -8
  9. package/.pi/extensions/pi-workflow-engine/src/options.ts +46 -0
  10. package/.pi/extensions/pi-workflow-engine/src/perf.ts +139 -0
  11. package/{src → .pi/extensions/pi-workflow-engine/src}/progress.ts +82 -35
  12. package/{src → .pi/extensions/pi-workflow-engine/src}/types.ts +12 -4
  13. package/{src → .pi/extensions/pi-workflow-engine/src}/ui/workflow-format.ts +59 -6
  14. package/{src → .pi/extensions/pi-workflow-engine/src}/ui/workflow-inspector.ts +8 -6
  15. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +198 -0
  16. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +182 -0
  17. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +263 -0
  18. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +24 -0
  19. package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/code-review.ts +89 -145
  20. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +244 -0
  21. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +188 -0
  22. package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/ping.ts +1 -0
  23. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +181 -0
  24. package/.pi/settings.json +3 -0
  25. package/README.md +163 -63
  26. package/assets/preview-candidate-core.png +0 -0
  27. package/assets/preview-candidate-relic-emblem.png +0 -0
  28. package/assets/preview-candidate-robot-squad.png +0 -0
  29. package/assets/preview.png +0 -0
  30. package/package.json +32 -6
  31. package/src/agent-runner.ts +0 -122
  32. package/src/concurrency.ts +0 -55
  33. package/src/discovery.ts +0 -54
  34. package/src/index.ts +0 -141
  35. package/src/ui/workflow-result-renderer.ts +0 -149
  36. package/src/ui/workflow-widget.ts +0 -143
  37. package/src/workflows.ts +0 -14
@@ -0,0 +1,139 @@
1
+ import { performance } from "node:perf_hooks";
2
+
3
+ export type PerfTags = Record<string, string | number>;
4
+
5
+ export interface PerfSample {
6
+ readonly name: string;
7
+ readonly durationMs?: number;
8
+ readonly value?: number;
9
+ readonly tags?: PerfTags;
10
+ }
11
+
12
+ export interface PerfAggregate {
13
+ readonly name: string;
14
+ readonly count: number;
15
+ readonly total: number;
16
+ readonly min: number;
17
+ readonly max: number;
18
+ readonly mean: number;
19
+ readonly p50: number;
20
+ readonly p95: number;
21
+ }
22
+
23
+ export interface PerfSnapshot {
24
+ readonly enabled: boolean;
25
+ readonly startedAt: number;
26
+ readonly samples: readonly PerfSample[];
27
+ readonly aggregates: readonly PerfAggregate[];
28
+ }
29
+
30
+ export interface PerfSink {
31
+ time<T>(name: string, fn: () => Promise<T>, tags?: PerfTags): Promise<T>;
32
+ timeSync<T>(name: string, fn: () => T, tags?: PerfTags): T;
33
+ observe(name: string, value: number, tags?: PerfTags): void;
34
+ counter(name: string, delta?: number, tags?: PerfTags): void;
35
+ snapshot(): PerfSnapshot;
36
+ }
37
+
38
+ export class PerfRecorder implements PerfSink {
39
+ private readonly samples: PerfSample[] = [];
40
+
41
+ constructor(private readonly startedAt = Date.now()) {}
42
+
43
+ async time<T>(name: string, fn: () => Promise<T>, tags?: PerfTags): Promise<T> {
44
+ const start = performance.now();
45
+ try {
46
+ return await fn();
47
+ } finally {
48
+ this.observeDuration(name, performance.now() - start, tags);
49
+ }
50
+ }
51
+
52
+ timeSync<T>(name: string, fn: () => T, tags?: PerfTags): T {
53
+ const start = performance.now();
54
+ try {
55
+ return fn();
56
+ } finally {
57
+ this.observeDuration(name, performance.now() - start, tags);
58
+ }
59
+ }
60
+
61
+ observe(name: string, value: number, tags?: PerfTags): void {
62
+ if (!Number.isFinite(value)) return;
63
+ this.samples.push({ name, value, tags });
64
+ }
65
+
66
+ counter(name: string, delta = 1, tags?: PerfTags): void {
67
+ this.observe(name, delta, tags);
68
+ }
69
+
70
+ snapshot(): PerfSnapshot {
71
+ return {
72
+ enabled: true,
73
+ startedAt: this.startedAt,
74
+ samples: this.samples.map((sample) => ({ ...sample, tags: sample.tags ? { ...sample.tags } : undefined })),
75
+ aggregates: aggregateSamples(this.samples),
76
+ };
77
+ }
78
+
79
+ private observeDuration(name: string, durationMs: number, tags?: PerfTags): void {
80
+ if (!Number.isFinite(durationMs)) return;
81
+ this.samples.push({ name, durationMs, tags });
82
+ }
83
+ }
84
+
85
+ export class NoopPerfRecorder implements PerfSink {
86
+ constructor(private readonly startedAt = Date.now()) {}
87
+
88
+ async time<T>(_name: string, fn: () => Promise<T>, _tags?: PerfTags): Promise<T> {
89
+ return await fn();
90
+ }
91
+
92
+ timeSync<T>(_name: string, fn: () => T, _tags?: PerfTags): T {
93
+ return fn();
94
+ }
95
+
96
+ observe(_name: string, _value: number, _tags?: PerfTags): void {}
97
+
98
+ counter(_name: string, _delta?: number, _tags?: PerfTags): void {}
99
+
100
+ snapshot(): PerfSnapshot {
101
+ return { enabled: false, startedAt: this.startedAt, samples: [], aggregates: [] };
102
+ }
103
+ }
104
+
105
+ export function createPerfRecorder(enabled: boolean, startedAt?: number): PerfSink {
106
+ return enabled ? new PerfRecorder(startedAt) : new NoopPerfRecorder(startedAt);
107
+ }
108
+
109
+ function aggregateSamples(samples: readonly PerfSample[]): PerfAggregate[] {
110
+ const byName = new Map<string, number[]>();
111
+ for (const sample of samples) {
112
+ const value = sample.durationMs ?? sample.value;
113
+ if (value === undefined || !Number.isFinite(value)) continue;
114
+ const values = byName.get(sample.name) ?? [];
115
+ values.push(value);
116
+ byName.set(sample.name, values);
117
+ }
118
+
119
+ return [...byName.entries()].map(([name, rawValues]) => {
120
+ const values = [...rawValues].sort((a, b) => a - b);
121
+ const total = values.reduce((sum, value) => sum + value, 0);
122
+ return {
123
+ name,
124
+ count: values.length,
125
+ total,
126
+ min: values[0] ?? 0,
127
+ max: values[values.length - 1] ?? 0,
128
+ mean: values.length === 0 ? 0 : total / values.length,
129
+ p50: percentile(values, 0.5),
130
+ p95: percentile(values, 0.95),
131
+ };
132
+ });
133
+ }
134
+
135
+ function percentile(sortedValues: readonly number[], p: number): number {
136
+ if (sortedValues.length === 0) return 0;
137
+ const index = Math.min(sortedValues.length - 1, Math.max(0, Math.ceil(sortedValues.length * p) - 1));
138
+ return sortedValues[index] ?? 0;
139
+ }
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { TUI } from "@earendil-works/pi-tui";
3
3
  import type { WorkflowLaneItemStatus, WorkflowProgressEvent } from "./types.ts";
4
- import { statusText } from "./ui/workflow-format.ts";
4
+ import { statusTextFromCounts, type WorkflowStatusCounts } from "./ui/workflow-format.ts";
5
5
  import { createWorkflowWidget, type WorkflowWidget } from "./ui/workflow-widget.ts";
6
6
 
7
7
  export type AgentRowStatus = "queued" | "running" | "done" | "failed";
@@ -46,6 +46,7 @@ export interface WorkflowProgressSnapshot {
46
46
  readonly counters: readonly WorkflowCounterSnapshot[];
47
47
  readonly summary: readonly [string, string | number][];
48
48
  readonly lanes: readonly [string, readonly WorkflowLaneItemSnapshot[]][];
49
+ readonly laneOverflow: readonly [string, number][];
49
50
  readonly logs: readonly string[];
50
51
  }
51
52
 
@@ -81,6 +82,7 @@ interface WorkflowLaneItem {
81
82
  }
82
83
 
83
84
  const LOG_LIMIT = 24;
85
+ export const DEFAULT_LANE_ITEM_LIMIT = 200;
84
86
 
85
87
  /**
86
88
  * Tracks live workflow state for widgets, footer/status text, result renderers,
@@ -92,7 +94,11 @@ export class ProgressTracker {
92
94
  private readonly counters = new Map<string, WorkflowCounter>();
93
95
  private readonly summary = new Map<string, string | number>();
94
96
  private readonly lanes = new Map<string, WorkflowLaneItem[]>();
97
+ private readonly laneOverflow = new Map<string, number>();
98
+ private readonly rowsById = new Map<number, AgentRow>();
99
+ private readonly agentCounts: Record<AgentRowStatus, number> = { queued: 0, running: 0, done: 0, failed: 0 };
95
100
  private readonly startedAt = Date.now();
101
+ private readonly laneItemLimit = laneItemLimitFromEnv();
96
102
  private doneAt: number | undefined;
97
103
  private currentPhase = "Workflow";
98
104
  private nextAgentId = 1;
@@ -101,6 +107,7 @@ export class ProgressTracker {
101
107
  private tui: Pick<TUI, "requestRender"> | undefined;
102
108
  private widgetInterval: ReturnType<typeof setInterval> | undefined;
103
109
  private lastStatusText: string | undefined;
110
+ private renderQueued = false;
104
111
 
105
112
  constructor(
106
113
  private readonly ctx: ExtensionContext,
@@ -156,6 +163,7 @@ export class ProgressTracker {
156
163
  details: event.details,
157
164
  createdAt: Date.now(),
158
165
  });
166
+ this.pruneLane(event.lane, lane);
159
167
  this.lanes.set(event.lane, lane);
160
168
  break;
161
169
  }
@@ -168,7 +176,10 @@ export class ProgressTracker {
168
176
 
169
177
  agentQueued(phase: string | undefined, label: string): number {
170
178
  const id = this.nextAgentId++;
171
- this.ensurePhase(phase ?? this.currentPhase).agents.push({ label, id, status: "queued", toolUses: 0 });
179
+ const row = { label, id, status: "queued" as const, toolUses: 0 };
180
+ this.ensurePhase(phase ?? this.currentPhase).agents.push(row);
181
+ this.rowsById.set(id, row);
182
+ this.agentCounts.queued++;
172
183
  this.render();
173
184
  return id;
174
185
  }
@@ -176,17 +187,20 @@ export class ProgressTracker {
176
187
  agentStart(phase: string | undefined, label: string, id?: number): void {
177
188
  const row = id === undefined ? undefined : this.findRowById(id);
178
189
  if (row) {
179
- row.status = "running";
190
+ this.transitionAgentStatus(row, "running");
180
191
  row.startedAt = Date.now();
181
192
  row.error = undefined;
182
193
  } else {
183
- this.ensurePhase(phase ?? this.currentPhase).agents.push({
194
+ const nextRow = {
184
195
  label,
185
196
  id: this.nextAgentId++,
186
- status: "running",
197
+ status: "running" as const,
187
198
  startedAt: Date.now(),
188
199
  toolUses: 0,
189
- });
200
+ };
201
+ this.ensurePhase(phase ?? this.currentPhase).agents.push(nextRow);
202
+ this.rowsById.set(nextRow.id, nextRow);
203
+ this.agentCounts.running++;
190
204
  }
191
205
  this.render();
192
206
  }
@@ -203,7 +217,7 @@ export class ProgressTracker {
203
217
  agentDone(label: string, id?: number): void {
204
218
  const row = this.findRow(label, id);
205
219
  if (row && row.status !== "failed") {
206
- row.status = "done";
220
+ this.transitionAgentStatus(row, "done");
207
221
  row.doneAt = Date.now();
208
222
  }
209
223
  this.render();
@@ -212,7 +226,7 @@ export class ProgressTracker {
212
226
  agentFailed(label: string, error: unknown, id?: number): void {
213
227
  const row = this.findRow(label, id);
214
228
  if (row) {
215
- row.status = "failed";
229
+ this.transitionAgentStatus(row, "failed");
216
230
  row.doneAt = Date.now();
217
231
  row.error = errorMessage(error);
218
232
  }
@@ -232,10 +246,40 @@ export class ProgressTracker {
232
246
  counters: [...this.counters.values()].map((counter) => ({ ...counter })),
233
247
  summary: [...this.summary.entries()],
234
248
  lanes: [...this.lanes.entries()].map(([lane, items]) => [lane, items.map((item) => ({ ...item }))]),
249
+ laneOverflow: [...this.laneOverflow.entries()],
235
250
  logs: [...this.logs],
236
251
  };
237
252
  }
238
253
 
254
+ statusCounts(): WorkflowStatusCounts {
255
+ return this.statusCountsSnapshot();
256
+ }
257
+
258
+ private statusCountsSnapshot(): WorkflowStatusCounts {
259
+ return {
260
+ queued: this.agentCounts.queued,
261
+ running: this.agentCounts.running,
262
+ done: this.agentCounts.done,
263
+ failed: this.agentCounts.failed,
264
+ total: this.agentCounts.queued + this.agentCounts.running + this.agentCounts.done + this.agentCounts.failed,
265
+ };
266
+ }
267
+
268
+ private transitionAgentStatus(row: AgentRow, nextStatus: AgentRowStatus): void {
269
+ if (row.status === nextStatus) return;
270
+ this.agentCounts[row.status]--;
271
+ row.status = nextStatus;
272
+ this.agentCounts[nextStatus]++;
273
+ }
274
+
275
+ private pruneLane(laneName: string, lane: WorkflowLaneItem[]): void {
276
+ if (this.laneItemLimit <= 0) return;
277
+ while (lane.length > this.laneItemLimit) {
278
+ lane.shift();
279
+ this.laneOverflow.set(laneName, (this.laneOverflow.get(laneName) ?? 0) + 1);
280
+ }
281
+ }
282
+
239
283
  private findRow(label: string, id?: number): AgentRow | undefined {
240
284
  if (id !== undefined) return this.findRowById(id);
241
285
  for (let i = this.phases.length - 1; i >= 0; i--) {
@@ -245,48 +289,44 @@ export class ProgressTracker {
245
289
  if (running) return running;
246
290
  }
247
291
  for (let i = this.phases.length - 1; i >= 0; i--) {
248
- const any = this.phases[i].agents.find((agent) => agent.label === label);
249
- if (any) return any;
292
+ const matching = this.phases[i].agents.find((agent) => agent.label === label);
293
+ if (matching) return matching;
250
294
  }
251
295
  return undefined;
252
296
  }
253
297
 
254
298
  private findRowById(id: number): AgentRow | undefined {
255
- for (const phase of this.phases) {
256
- const row = phase.agents.find((agent) => agent.id === id);
257
- if (row) return row;
258
- }
259
- return undefined;
260
- }
261
-
262
- private lines(): string[] {
263
- const out: string[] = [`⚙ ${this.title}`];
264
- for (const phase of this.phases) {
265
- if (phase.agents.length === 0 && phase.title === "Workflow") continue;
266
- out.push(` ${phase.title}`);
267
- for (const agent of phase.agents) {
268
- const icon = agent.status === "done" ? "✓" : agent.status === "failed" ? "✗" : agent.status === "queued" ? "○" : "⏳";
269
- const tool = agent.status === "running" && agent.lastTool ? ` · ${agent.lastTool}` : "";
270
- const error = agent.status === "failed" && agent.error ? ` · ${agent.error}` : "";
271
- out.push(` ${icon} ${agent.label}${tool}${error}`);
272
- }
273
- }
274
- const counterLine = [...this.counters.values()].map((counter) => `${counter.label}: ${counter.value}`).join(" · ");
275
- if (counterLine) out.push(` ${counterLine}`);
276
- for (const line of this.logs.slice(-6)) out.push(` · ${line}`);
277
- return out;
299
+ return this.rowsById.get(id);
278
300
  }
279
301
 
280
302
  private render(): void {
281
303
  if (!this.ctx.hasUI) return;
282
304
  this.ensureWidget();
283
305
  this.widget?.invalidate();
284
- this.tui?.requestRender();
306
+ this.requestRenderSoon();
285
307
  this.publishStatus();
286
308
  }
287
309
 
310
+ private requestRenderSoon(): void {
311
+ if (this.renderQueued) return;
312
+ this.renderQueued = true;
313
+ queueMicrotask(() => {
314
+ this.renderQueued = false;
315
+ this.tui?.requestRender();
316
+ });
317
+ }
318
+
288
319
  private publishStatus(): void {
289
- const next = statusText(this.snapshot(), this.ctx.ui.theme);
320
+ const next = statusTextFromCounts(
321
+ {
322
+ title: this.title,
323
+ doneAt: this.doneAt,
324
+ currentPhase: this.currentPhase,
325
+ counters: [...this.counters.values()].map((counter) => ({ ...counter })),
326
+ },
327
+ this.statusCountsSnapshot(),
328
+ this.ctx.ui.theme,
329
+ );
290
330
  if (next === this.lastStatusText) return;
291
331
  this.ctx.ui.setStatus("workflow", next);
292
332
  this.lastStatusText = next;
@@ -332,12 +372,19 @@ export class ProgressTracker {
332
372
  this.ctx.ui.setWidget("workflow", undefined);
333
373
  this.ctx.ui.setStatus("workflow", status);
334
374
  this.lastStatusText = status;
375
+ this.renderQueued = false;
335
376
  this.widgetRegistered = false;
336
377
  this.widget = undefined;
337
378
  this.tui = undefined;
338
379
  }
339
380
  }
340
381
 
382
+ function laneItemLimitFromEnv(): number {
383
+ const parsed = Number(process.env.PI_WORKFLOW_LANE_ITEM_LIMIT ?? "");
384
+ if (!Number.isFinite(parsed) || parsed < 1) return DEFAULT_LANE_ITEM_LIMIT;
385
+ return Math.trunc(parsed);
386
+ }
387
+
341
388
  function errorMessage(error: unknown): string {
342
389
  return error instanceof Error ? error.message : String(error);
343
390
  }
@@ -1,5 +1,7 @@
1
1
  import type { Static, TSchema } from "typebox";
2
2
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
3
+ import type { Pipeline } from "./concurrency.ts";
4
+ import type { PerfSink, PerfSnapshot } from "./perf.ts";
3
5
 
4
6
  /** Metadata every workflow module must export. */
5
7
  export interface WorkflowMeta {
@@ -18,6 +20,15 @@ export interface WorkflowRunStats {
18
20
 
19
21
  export interface WorkflowRunOptions {
20
22
  inspect?: boolean;
23
+ perf?: boolean;
24
+ concurrency?: number;
25
+ parallelSubmissionLimit?: number;
26
+ /** Additional abort signal to compose with the host context signal. */
27
+ signal?: AbortSignal;
28
+ /** Internal recorder override for command/tool invocation timing. */
29
+ perfRecorder?: PerfSink;
30
+ /** Called with the final performance snapshot when perf is enabled. */
31
+ onPerfSnapshot?: (snapshot: PerfSnapshot) => void;
21
32
  }
22
33
 
23
34
  /** Options for a single `agent()` call. */
@@ -63,10 +74,7 @@ export interface WorkflowApi {
63
74
  /** Run every thunk concurrently and wait for all (a barrier). */
64
75
  parallel<T>(thunks: Array<() => Promise<T>>): Promise<T[]>;
65
76
  /** Run each item through all stages independently — no barrier between stages. */
66
- pipeline(
67
- items: unknown[],
68
- ...stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown>>
69
- ): Promise<unknown[]>;
77
+ pipeline: Pipeline;
70
78
  /** Start a new phase; subsequent agents group under it in the progress tree. */
71
79
  phase(title: string): void;
72
80
  /** Emit a progress line. */
@@ -1,6 +1,6 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { truncateToWidth } from "@earendil-works/pi-tui";
3
- import type { WorkflowProgressSnapshot } from "../progress.ts";
3
+ import type { AgentRowSnapshot, WorkflowProgressSnapshot } from "../progress.ts";
4
4
  import type { WorkflowLaneItemStatus } from "../types.ts";
5
5
 
6
6
  export type WorkflowDisplayStatus = WorkflowLaneItemStatus | "queued" | "done" | "failed";
@@ -58,24 +58,77 @@ export function truncateDisplay(text: string, width: number): string {
58
58
  return truncateToWidth(text, width);
59
59
  }
60
60
 
61
+ export interface AgentDetailOptions {
62
+ now?: number;
63
+ includeQueuedStatus?: boolean;
64
+ }
65
+
66
+ export function agentDetailParts(agent: AgentRowSnapshot, now?: number): string[];
67
+ export function agentDetailParts(agent: AgentRowSnapshot, options?: AgentDetailOptions): string[];
68
+ export function agentDetailParts(agent: AgentRowSnapshot, optionsOrNow: AgentDetailOptions | number = {}): string[] {
69
+ const options = typeof optionsOrNow === "number" ? { now: optionsOrNow } : optionsOrNow;
70
+ const now = options.now ?? Date.now();
71
+ const includeQueuedStatus = options.includeQueuedStatus ?? true;
72
+ const parts: string[] = [];
73
+ if (agent.toolUses > 0) parts.push(`${agent.toolUses} tool${agent.toolUses === 1 ? "" : "s"}`);
74
+ if (agent.lastTool) parts.push(agent.lastTool);
75
+ if (agent.startedAt !== undefined) parts.push(formatDuration((agent.doneAt ?? now) - agent.startedAt));
76
+ else if (includeQueuedStatus && agent.status === "queued") parts.push("queued");
77
+ if (agent.status === "failed" && agent.error) parts.push(agent.error);
78
+ return parts;
79
+ }
80
+
81
+ export function agentLabelColor(agent: AgentRowSnapshot): WorkflowThemeColor {
82
+ return agent.status === "running" ? "text" : "muted";
83
+ }
84
+
85
+ export interface WorkflowStatusCounts {
86
+ readonly queued: number;
87
+ readonly running: number;
88
+ readonly done: number;
89
+ readonly failed: number;
90
+ readonly total: number;
91
+ }
92
+
93
+ export interface WorkflowStatusSource {
94
+ readonly title: string;
95
+ readonly doneAt?: number;
96
+ readonly currentPhase: string;
97
+ readonly counters: readonly { readonly key: string; readonly label: string; readonly value: number }[];
98
+ }
99
+
61
100
  export function statusText(snapshot: WorkflowProgressSnapshot, theme: Theme): string | undefined {
101
+ const counts = countSnapshotAgents(snapshot);
102
+ return statusTextFromCounts(snapshot, counts, theme);
103
+ }
104
+
105
+ export function statusTextFromCounts(snapshot: WorkflowStatusSource, counts: WorkflowStatusCounts, theme: Theme): string | undefined {
62
106
  if (snapshot.doneAt !== undefined) return undefined;
63
107
 
64
- const agents = snapshot.phases.flatMap((phase) => phase.agents);
65
- const complete = agents.filter((agent) => agent.status === "done" || agent.status === "failed").length;
66
- const active = agents.filter((agent) => agent.status === "running" || agent.status === "queued").length;
67
- const total = agents.length;
108
+ const complete = counts.done + counts.failed;
109
+ const active = counts.running + counts.queued;
68
110
  const kept = snapshot.counters.find((counter) => counter.key === "kept" || counter.label.toLowerCase() === "kept");
69
111
  const displayName = snapshot.title === "code-review" ? "review" : snapshot.title;
70
112
 
71
113
  const parts = [theme.fg("accent", displayName), theme.fg("muted", snapshot.currentPhase)];
72
- if (total > 0) parts.push(theme.fg("muted", `${complete}/${total}`));
114
+ if (counts.total > 0) parts.push(theme.fg("muted", `${complete}/${counts.total}`));
73
115
  if (kept) parts.push(theme.fg("success", `${formatCount(kept.value)} kept`));
74
116
  else if (active > 0) parts.push(theme.fg("muted", `${active} active`));
75
117
 
76
118
  return parts.join(theme.fg("dim", " · "));
77
119
  }
78
120
 
121
+ function countSnapshotAgents(snapshot: WorkflowProgressSnapshot): WorkflowStatusCounts {
122
+ const counts = { queued: 0, running: 0, done: 0, failed: 0, total: 0 };
123
+ for (const phase of snapshot.phases) {
124
+ for (const agent of phase.agents) {
125
+ counts[agent.status]++;
126
+ counts.total++;
127
+ }
128
+ }
129
+ return counts;
130
+ }
131
+
79
132
  function formatCompact(value: number): string {
80
133
  if (value >= 100) return `${Math.round(value)}`;
81
134
  const rounded = Math.round(value * 10) / 10;
@@ -1,7 +1,7 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { matchesKey, type TUI, visibleWidth } from "@earendil-works/pi-tui";
3
3
  import type { AgentRowSnapshot, WorkflowLaneItemSnapshot, WorkflowProgressSnapshot } from "../progress.ts";
4
- import { formatCount, formatDuration, statusIcon, truncateDisplay } from "./workflow-format.ts";
4
+ import { agentDetailParts, formatCount, formatDuration, statusIcon, truncateDisplay } from "./workflow-format.ts";
5
5
 
6
6
  type Section = "Overview" | "Agents" | "Findings" | "Logs";
7
7
 
@@ -195,10 +195,9 @@ export class WorkflowInspector {
195
195
  }
196
196
 
197
197
  private agentLine(agent: AgentRowSnapshot, selected: boolean): string {
198
- const elapsed = agent.startedAt === undefined ? "queued" : formatDuration((agent.doneAt ?? Date.now()) - agent.startedAt);
199
- const details = [`${agent.toolUses} tools`, elapsed];
200
- if (agent.lastTool) details.unshift(agent.lastTool);
201
- const text = `${statusIcon(agent.status, this.theme)} ${agent.label} ${this.theme.fg("dim", `· ${details.join(" · ")}`)}`;
198
+ const details = agentDetailParts(agent);
199
+ const suffix = details.length > 0 ? ` ${this.theme.fg("dim", `· ${details.join(" · ")}`)}` : "";
200
+ const text = `${statusIcon(agent.status, this.theme)} ${agent.label}${suffix}`;
202
201
  return selected ? this.theme.bg("selectedBg", text) : text;
203
202
  }
204
203
 
@@ -212,8 +211,11 @@ export class WorkflowInspector {
212
211
  private findingLines(snapshot: WorkflowProgressSnapshot, _width: number): InspectorLine[] {
213
212
  const lines: InspectorLine[] = [];
214
213
  let selectableIndex = -1;
214
+ const overflowByLane = new Map(snapshot.laneOverflow);
215
215
  for (const [lane, items] of snapshot.lanes) {
216
- lines.push({ text: this.theme.fg("dim", `${lane} (${items.length})`) });
216
+ const hidden = overflowByLane.get(lane) ?? 0;
217
+ const countText = hidden > 0 ? `${items.length} shown, ${hidden} hidden` : `${items.length}`;
218
+ lines.push({ text: this.theme.fg("dim", `${lane} (${countText})`) });
217
219
  items.forEach((item, index) => {
218
220
  selectableIndex++;
219
221
  const key = `finding:${lane}:${index}:${item.createdAt}`;