pi-workflow-engine 0.13.0 → 1.0.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 (92) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +106 -391
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +1 -1
  3. package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +8 -8
  4. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +7 -1
  5. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +48 -62
  6. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +46 -67
  7. package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +14 -37
  8. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +5 -11
  9. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +15 -21
  10. package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +13 -43
  11. package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +17 -5
  12. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +93 -100
  13. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +11 -43
  14. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +4 -18
  15. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +33 -86
  16. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +1 -5
  17. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +25 -80
  18. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +16 -23
  19. package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +1 -4
  20. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +39 -98
  21. package/.pi/extensions/pi-workflow-engine/src/engine.ts +37 -80
  22. package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +2 -10
  23. package/.pi/extensions/pi-workflow-engine/src/git.ts +55 -0
  24. package/.pi/extensions/pi-workflow-engine/src/guards.ts +8 -0
  25. package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +7 -19
  26. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +171 -106
  27. package/.pi/extensions/pi-workflow-engine/src/journal.ts +0 -12
  28. package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +37 -8
  29. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +15 -66
  30. package/.pi/extensions/pi-workflow-engine/src/options.ts +97 -16
  31. package/.pi/extensions/pi-workflow-engine/src/perf.ts +43 -72
  32. package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +14 -4
  33. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +2 -0
  34. package/.pi/extensions/pi-workflow-engine/src/progress.ts +79 -80
  35. package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +28 -20
  36. package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +2 -7
  37. package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +0 -1
  38. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +27 -31
  39. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +75 -139
  40. package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +14 -31
  41. package/.pi/extensions/pi-workflow-engine/src/review/patch-validation.ts +3 -4
  42. package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +1 -1
  43. package/.pi/extensions/pi-workflow-engine/src/review/review-diff-lines.ts +54 -0
  44. package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +7 -9
  45. package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +51 -117
  46. package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +1 -1
  47. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +8 -13
  48. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +3 -7
  49. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +158 -241
  50. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +28 -38
  51. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +35 -44
  52. package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +13 -14
  53. package/.pi/extensions/pi-workflow-engine/src/text.ts +29 -0
  54. package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +2 -1
  55. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +16 -28
  56. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +34 -14
  57. package/.pi/extensions/pi-workflow-engine/src/types.ts +6 -0
  58. package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +193 -0
  59. package/.pi/extensions/pi-workflow-engine/src/ui/research-result-renderer.ts +177 -0
  60. package/.pi/extensions/pi-workflow-engine/src/ui/result-layout.ts +209 -0
  61. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +17 -44
  62. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +703 -235
  63. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-picker.ts +48 -0
  64. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +239 -129
  65. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +461 -0
  66. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +91 -0
  67. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +316 -98
  68. package/.pi/extensions/pi-workflow-engine/src/usage.ts +21 -32
  69. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +56 -19
  70. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +19 -24
  71. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +17 -37
  72. package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +79 -0
  73. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +4 -14
  74. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +77 -63
  75. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +42 -29
  76. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +15 -14
  77. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +27 -31
  78. package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +1 -5
  79. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +3 -6
  80. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +48 -110
  81. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +64 -93
  82. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +25 -67
  83. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +19 -53
  84. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +21 -49
  85. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +74 -66
  86. package/USAGE.md +31 -17
  87. package/package.json +1 -2
  88. package/skills/workflow-code-review-actions/SKILL.md +12 -23
  89. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +0 -81
  90. package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +0 -24
  91. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +0 -52
  92. package/.pi/settings.json +0 -3
@@ -1,127 +1,345 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
2
3
  import type { AgentRowSnapshot, PhaseSnapshot, WorkflowProgressSnapshot } from "../progress-types.ts";
3
- import { formatWorkflowUsageLine } from "../usage.ts";
4
- import { agentDetailParts, agentLabelColor, formatCount, formatDuration, statusIcon } from "./workflow-format.ts";
4
+ import { formatCount } from "../text.ts";
5
+ import { compactUsage, dot, fit, GLYPH, joinParts, keyHints, progressBar, spread, type ThemeColor } from "./kit.ts";
6
+ import { countAgents, formatDuration, formatElapsed, type WorkflowStatusCounts } from "./workflow-format.ts";
5
7
 
8
+ /** pi caps string widgets at this many rows; the component keeps to the same budget. */
6
9
  const MAX_WIDGET_LINES = 10;
10
+ /** Width used for surfaces that only accept pre-rendered strings (RPC). */
11
+ export const STRING_WIDGET_WIDTH = 100;
7
12
 
8
- export function renderWorkflowWidgetLines(
9
- snapshot: WorkflowProgressSnapshot,
10
- theme: Theme,
11
- ): string[] {
13
+ const INSPECTOR_COMMAND = "/workflow:inspector";
14
+ const MAX_BAR_WIDTH = 24;
15
+ const MIN_BAR_WIDTH = 8;
16
+ const MAX_PHASE_COLUMN = 18;
17
+ const MAX_LABEL_COLUMN = 26;
18
+ const FAILURE_LOG = /\b(fail(ed|ure|ing)?|error)\b/i;
19
+
20
+ let inspectorShortcut: string | null = null;
21
+
22
+ /** The widget footer names the configured inspector shortcut; registration sets it once from the shortcut config. */
23
+ export function setWorkflowInspectorShortcut(shortcut: string | null): void {
24
+ inspectorShortcut = shortcut;
25
+ }
26
+
27
+ function workflowInspectorHint(): string {
28
+ return inspectorShortcut ?? INSPECTOR_COMMAND;
29
+ }
30
+
31
+ /**
32
+ * The live run widget, every line fitted to `width`:
33
+ * header (name, bar, finished/total, elapsed, usage) → phases in chronological order, the active
34
+ * phase expanded into agent rows → upcoming declared phases → counters with the inspector hint.
35
+ */
36
+ export function renderWorkflowWidget(snapshot: WorkflowProgressSnapshot, width: number, theme: Theme): string[] {
37
+ const now = Date.now();
38
+ const inner = Math.max(1, width - 2);
12
39
  const counts = countAgents(snapshot.phases);
13
- const active = counts.running + counts.queued;
14
- const elapsed = formatDuration((snapshot.doneAt ?? Date.now()) - snapshot.startedAt);
15
- const activity = active > 0 ? theme.fg("accent", "●") : theme.fg("success", "✓");
16
-
17
- const headingParts = [`${counts.done}/${counts.total} done`, elapsed];
18
- if (counts.running > 0) headingParts.unshift(`${counts.running} running`);
19
- if (counts.queued > 0) headingParts.unshift(`${counts.queued} queued`);
20
- if (counts.failed > 0) headingParts.unshift(theme.fg("error", `${counts.failed} failed`));
21
-
22
- const lines: string[] = [
23
- `${activity} ${theme.bold(snapshot.title)} ${theme.fg("dim", "·")} ${theme.fg("muted", snapshot.currentPhase)} ${theme.fg("dim", "·")} ${headingParts.join(` ${theme.fg("dim", "·")} `)}`,
24
- ];
25
-
26
- const footer = footerLine(snapshot, theme);
27
- const bodyBudget = Math.max(0, MAX_WIDGET_LINES - lines.length - (footer ? 1 : 0));
28
- const body = visibleBodyLines(snapshot.phases, bodyBudget, theme);
29
- const reserveHiddenLine = body.hidden > 0 && bodyBudget > 0;
30
- const visibleBodyLinesToRender = reserveHiddenLine ? body.lines.slice(0, Math.max(0, bodyBudget - 1)) : body.lines;
31
- const hidden = body.hidden + (body.lines.length - visibleBodyLinesToRender.length);
32
-
33
- lines.push(...visibleBodyLinesToRender);
34
- if (hidden > 0 && lines.length < MAX_WIDGET_LINES) {
35
- lines.push(`${theme.fg("dim", "└─")} ${theme.fg("dim", `+${hidden} more`)}`);
40
+ const phases = classifyPhases(snapshot);
41
+ const failureLog = failureLogLine(snapshot, theme);
42
+ const footer = footerLine(snapshot, workflowInspectorHint(), inner, theme);
43
+ const upcoming = upcomingLine(snapshot, theme);
44
+
45
+ const bodyBudget = MAX_WIDGET_LINES - 2 - (failureLog ? 1 : 0);
46
+ const body = phaseBody(phases, upcoming, bodyBudget, inner, now, theme);
47
+ const lines = [headerLine(snapshot, counts, inner, now, theme), ...body];
48
+ if (failureLog) lines.push(failureLog);
49
+ lines.push(footer);
50
+ return lines.map((line) => ` ${truncateToWidth(line, inner, "…")}`);
51
+ }
52
+
53
+ /** A TUI widget that re-renders its lines at the terminal's current width on every frame. */
54
+ export class WidthAwareWidget implements Component {
55
+ constructor(private readonly lines: (width: number) => string[]) {}
56
+
57
+ render(width: number): string[] {
58
+ return this.lines(width);
36
59
  }
37
- if (footer && lines.length < MAX_WIDGET_LINES) lines.push(footer);
38
60
 
39
- return lines.slice(0, MAX_WIDGET_LINES);
61
+ invalidate(): void {}
62
+ }
63
+
64
+ /** `◆ background · code-review 56b57550 · Find 6/10 · 12s`: one line for a run the user sent to the background. */
65
+ export function renderBackgroundWorkflowLine(snapshot: WorkflowProgressSnapshot, width: number, theme: Theme): string[] {
66
+ const counts = countAgents(snapshot.phases);
67
+ const progress = counts.total > 0 ? `${snapshot.currentPhase} ${counts.done + counts.failed}/${counts.total}` : snapshot.currentPhase;
68
+ const line = joinParts([
69
+ `${theme.fg("accent", GLYPH.workflow)} ${theme.fg("muted", "background")}`,
70
+ `${theme.bold(snapshot.title)} ${theme.fg("dim", snapshot.runId.slice(0, 8))}`,
71
+ theme.fg("muted", progress),
72
+ counts.failed > 0 ? theme.fg("error", `${counts.failed} failed`) : undefined,
73
+ theme.fg("dim", formatElapsed(Date.now() - snapshot.startedAt)),
74
+ ], theme);
75
+ return [` ${spread(line, keyHints([[workflowInspectorHint(), "inspect"]], theme), Math.max(1, width - 2))}`];
76
+ }
77
+
78
+ interface ClassifiedPhases {
79
+ /** Every shown phase in chronological order; finished and active phases interleave when pipelines overlap. */
80
+ readonly ordered: readonly PhaseSnapshot[];
81
+ /** The current phase plus any phase that still has running or queued agents. */
82
+ readonly active: readonly PhaseSnapshot[];
83
+ }
84
+
85
+ function classifyPhases(snapshot: WorkflowProgressSnapshot): ClassifiedPhases {
86
+ // The implicit "Workflow" bucket only matters when agents ran before any phase() call.
87
+ const ordered = snapshot.phases.filter((phase) => phase.agents.length > 0 || phase.title !== "Workflow");
88
+ const active = ordered.filter((phase) =>
89
+ phase.title === snapshot.currentPhase || phase.agents.some((agent) => agent.status === "running" || agent.status === "queued"));
90
+ return { ordered, active };
91
+ }
92
+
93
+ function headerLine(snapshot: WorkflowProgressSnapshot, counts: WorkflowStatusCounts, width: number, now: number, theme: Theme): string {
94
+ const name = `${theme.fg("accent", GLYPH.workflow)} ${theme.bold(snapshot.title)}`;
95
+ const finished = counts.done + counts.failed;
96
+ const stats = joinParts([
97
+ counts.total > 0 ? theme.fg("text", `${finished}/${counts.total}`) : undefined,
98
+ theme.fg("muted", formatElapsed(now - snapshot.startedAt)),
99
+ compactUsage(snapshot.usage, theme),
100
+ ], theme);
101
+ const barWidth = Math.min(MAX_BAR_WIDTH, width - visibleWidth(name) - visibleWidth(stats) - 4);
102
+ if (counts.total === 0 || barWidth < MIN_BAR_WIDTH) return `${name} ${stats}`;
103
+ return `${name} ${progressBar(finished, counts.total, barWidth, theme)} ${stats}`;
40
104
  }
41
105
 
42
- interface AgentCounts {
43
- queued: number;
44
- running: number;
45
- done: number;
46
- failed: number;
47
- total: number;
106
+ interface AgentRowPlan {
107
+ readonly phase: PhaseSnapshot;
108
+ readonly shown: readonly AgentRowSnapshot[];
109
+ readonly hidden: number;
48
110
  }
49
111
 
50
- function countAgents(phases: readonly PhaseSnapshot[]): AgentCounts {
51
- const counts: AgentCounts = { queued: 0, running: 0, done: 0, failed: 0, total: 0 };
52
- for (const phase of phases) {
53
- for (const agent of phase.agents) {
54
- counts[agent.status]++;
55
- counts.total++;
112
+ function phaseBody(
113
+ phases: ClassifiedPhases,
114
+ upcoming: string | undefined,
115
+ budget: number,
116
+ width: number,
117
+ now: number,
118
+ theme: Theme,
119
+ ): string[] {
120
+ let ordered: readonly (PhaseSnapshot | "collapsed")[] = phases.ordered;
121
+ const candidates = phases.active.reduce((total, phase) => total + phase.agents.length, 0);
122
+ const wantedRows = Math.min(candidates, 3);
123
+ const fixedLines = () => ordered.length + (upcoming ? 1 : 0);
124
+ // Keep agent rows visible on long workflows: fold everything finished before the first active phase into one line.
125
+ if (budget - fixedLines() < wantedRows) {
126
+ const firstActive = phases.ordered.findIndex((phase) => phases.active.includes(phase));
127
+ const foldUntil = firstActive === -1 ? phases.ordered.length : firstActive;
128
+ if (foldUntil > 1) ordered = ["collapsed", ...phases.ordered.slice(foldUntil)];
129
+ }
130
+ const showUpcoming = upcoming !== undefined && budget - ordered.length >= 1;
131
+ const rowBudget = Math.max(0, budget - ordered.length - (showUpcoming ? 1 : 0));
132
+ const plans = planAgentRows(phases.active, rowBudget);
133
+
134
+ const visiblePhases = ordered.filter((entry): entry is PhaseSnapshot => entry !== "collapsed");
135
+ const titleWidth = Math.min(MAX_PHASE_COLUMN, Math.max(0, ...visiblePhases.map((phase) => visibleWidth(phase.title))));
136
+ const labelWidth = agentLabelWidth(plans, width);
137
+ const folded = phases.ordered.slice(0, phases.ordered.length - visiblePhases.length);
138
+
139
+ const lines: string[] = [];
140
+ for (const entry of ordered) {
141
+ if (entry === "collapsed") {
142
+ lines.push(foldedPhasesLine(folded, theme));
143
+ continue;
56
144
  }
145
+ const plan = plans.get(entry);
146
+ if (!plan) {
147
+ lines.push(finishedPhaseLine(entry, titleWidth, theme));
148
+ continue;
149
+ }
150
+ lines.push(activePhaseLine(plan, titleWidth, theme));
151
+ for (const agent of plan.shown) lines.push(agentLine(agent, entry, labelWidth, width, now, theme));
57
152
  }
58
- return counts;
153
+ if (showUpcoming && upcoming) lines.push(upcoming);
154
+ return lines.slice(0, budget);
59
155
  }
60
156
 
61
- function visibleBodyLines(phases: readonly PhaseSnapshot[], budget: number, theme: Theme): { lines: string[]; hidden: number } {
62
- const lines: string[] = [];
63
- let totalRows = 0;
64
- const visit = (phase: PhaseSnapshot): void => {
65
- if (phase.agents.length === 0 && phase.title === "Workflow") return;
66
- const counts = countPhaseAgents(phase);
67
- totalRows++;
68
- if (lines.length < budget) lines.push(phaseLine(phase.title, counts, theme));
69
- appendAgentGroup(phase.agents, (agent) => agent.status === "running" || agent.status === "queued", lines, budget, theme, () => totalRows++);
70
- appendAgentGroup(phase.agents, (agent) => agent.status === "failed", lines, budget, theme, () => totalRows++);
71
- appendAgentGroup(phase.agents, (agent) => agent.status === "done", lines, budget, theme, () => totalRows++);
157
+ /**
158
+ * Spread the row budget over active phases by importance — a reserved share for failures so they always
159
+ * show, then running, remaining failures, done, queued — while each phase lists its chosen rows as
160
+ * running, failed, done, queued.
161
+ */
162
+ function planAgentRows(active: readonly PhaseSnapshot[], budget: number): Map<PhaseSnapshot, AgentRowPlan> {
163
+ const byStatus = (status: AgentRowSnapshot["status"]) =>
164
+ active.flatMap((phase) => phase.agents.filter((agent) => agent.status === status));
165
+ const failed = byStatus("failed");
166
+ const chosen = new Set<AgentRowSnapshot>();
167
+ let remaining = budget;
168
+ const take = (agents: readonly AgentRowSnapshot[], limit = remaining) => {
169
+ for (const agent of agents) {
170
+ if (remaining <= 0 || limit <= 0) return;
171
+ if (chosen.has(agent)) continue;
172
+ chosen.add(agent);
173
+ remaining--;
174
+ limit--;
175
+ }
72
176
  };
177
+ take(failed, failed.length > 0 ? Math.max(1, Math.floor(budget / 3)) : 0);
178
+ take(byStatus("running"));
179
+ take(failed);
180
+ take(byStatus("done"));
181
+ take(byStatus("queued"));
73
182
 
74
- for (const phase of phases) {
75
- if (phase.agents.some((agent) => agent.status === "running" || agent.status === "queued")) visit(phase);
76
- }
77
- for (const phase of phases) {
78
- if (!phase.agents.some((agent) => agent.status === "running" || agent.status === "queued")) visit(phase);
183
+ const rank = { running: 0, failed: 1, done: 2, queued: 3 } as const;
184
+ const plans = new Map<PhaseSnapshot, AgentRowPlan>();
185
+ for (const phase of active) {
186
+ const shown = phase.agents.filter((agent) => chosen.has(agent)).sort((a, b) => rank[a.status] - rank[b.status]);
187
+ plans.set(phase, { phase, shown, hidden: phase.agents.length - shown.length });
79
188
  }
189
+ return plans;
190
+ }
80
191
 
81
- return { lines, hidden: Math.max(0, totalRows - lines.length) };
192
+ function agentLabelWidth(plans: ReadonlyMap<PhaseSnapshot, AgentRowPlan>, width: number): number {
193
+ const labels = [...plans.values()].flatMap((plan) => plan.shown.map((agent) => visibleWidth(agentLabel(agent, plan.phase))));
194
+ return Math.min(MAX_LABEL_COLUMN, Math.floor(width * 0.4), Math.max(0, ...labels));
82
195
  }
83
196
 
84
- function appendAgentGroup(
85
- agents: readonly AgentRowSnapshot[],
86
- include: (agent: AgentRowSnapshot) => boolean,
87
- lines: string[],
88
- budget: number,
89
- theme: Theme,
90
- countRow: () => void,
91
- ): void {
92
- for (const agent of agents) {
93
- if (!include(agent)) continue;
94
- countRow();
95
- if (lines.length < budget) lines.push(agentLine(agent, theme));
197
+ function finishedPhaseLine(phase: PhaseSnapshot, titleWidth: number, theme: Theme): string {
198
+ const counts = countAgents([phase]);
199
+ const glyph = counts.failed === 0
200
+ ? theme.fg("success", GLYPH.done)
201
+ : counts.failed === counts.total ? theme.fg("error", GLYPH.failed) : theme.fg("warning", GLYPH.warning);
202
+ const span = phaseSpan(phase.agents);
203
+ const detail = joinParts([
204
+ counts.total > 0 ? theme.fg("muted", plural(counts.total, "agent")) : undefined,
205
+ span === undefined ? undefined : theme.fg("muted", shortDuration(span)),
206
+ counts.failed > 0 ? theme.fg("error", `${counts.failed} failed`) : undefined,
207
+ ], theme);
208
+ return ` ${glyph} ${theme.fg("muted", fit(phase.title, titleWidth))} ${detail}`.trimEnd();
209
+ }
210
+
211
+ function foldedPhasesLine(phases: readonly PhaseSnapshot[], theme: Theme): string {
212
+ const counts = countAgents(phases);
213
+ const glyph = counts.failed > 0 ? theme.fg("warning", GLYPH.warning) : theme.fg("success", GLYPH.done);
214
+ const titles = phases.map((phase) => phase.title).join(" · ");
215
+ const detail = joinParts([
216
+ theme.fg("muted", plural(counts.total, "agent")),
217
+ counts.failed > 0 ? theme.fg("error", `${counts.failed} failed`) : undefined,
218
+ ], theme);
219
+ return ` ${glyph} ${theme.fg("muted", titles)} ${detail}`;
220
+ }
221
+
222
+ function activePhaseLine(plan: AgentRowPlan, titleWidth: number, theme: Theme): string {
223
+ const counts = countAgents([plan.phase]);
224
+ const detail = joinParts([
225
+ counts.running > 0 ? theme.fg("accent", `${counts.running} running`) : undefined,
226
+ counts.queued > 0 ? theme.fg("muted", `${counts.queued} queued`) : undefined,
227
+ counts.done > 0 ? theme.fg("muted", `${counts.done} done`) : undefined,
228
+ counts.failed > 0 ? theme.fg("error", `${counts.failed} failed`) : undefined,
229
+ plan.hidden > 0 ? theme.fg("dim", `+${plan.hidden} more`) : undefined,
230
+ ], theme);
231
+ return ` ${theme.fg("accent", GLYPH.running)} ${theme.bold(fit(plan.phase.title, titleWidth))} ${detail}`.trimEnd();
232
+ }
233
+
234
+ function agentLine(agent: AgentRowSnapshot, phase: PhaseSnapshot, labelWidth: number, width: number, now: number, theme: Theme): string {
235
+ const glyph = agentGlyph(agent, theme);
236
+ const label = fit(agentLabel(agent, phase), labelWidth);
237
+ const head = ` ${glyph} ${theme.fg(agent.status === "running" || agent.status === "failed" ? "text" : "muted", label)} `;
238
+ return head + truncateToWidth(agentDetail(agent, now, theme), Math.max(0, width - visibleWidth(head)), "…");
239
+ }
240
+
241
+ function agentGlyph(agent: AgentRowSnapshot, theme: Theme): string {
242
+ switch (agent.status) {
243
+ case "running":
244
+ return theme.fg("accent", GLYPH.running);
245
+ case "failed":
246
+ return theme.fg("error", GLYPH.failed);
247
+ case "done":
248
+ return theme.fg("success", GLYPH.done);
249
+ case "queued":
250
+ return theme.fg("dim", GLYPH.queued);
251
+ }
252
+ }
253
+
254
+ /** Running: what it is doing now; done: how much it did; failed: why. */
255
+ function agentDetail(agent: AgentRowSnapshot, now: number, theme: Theme): string {
256
+ const elapsed = agent.startedAt === undefined ? undefined : (agent.doneAt ?? now) - agent.startedAt;
257
+ switch (agent.status) {
258
+ case "running":
259
+ return joinParts([agent.lastTool && theme.fg("muted", agent.lastTool), elapsed !== undefined && theme.fg("dim", formatElapsed(elapsed))], theme);
260
+ case "done":
261
+ return joinParts([
262
+ agent.toolUses > 0 && theme.fg("dim", plural(agent.toolUses, "tool")),
263
+ elapsed !== undefined && theme.fg("dim", shortDuration(elapsed)),
264
+ ], theme);
265
+ case "failed":
266
+ return theme.fg("error", oneLine(agent.error ?? "failed"));
267
+ case "queued":
268
+ return theme.fg("dim", "queued");
96
269
  }
97
270
  }
98
271
 
99
- function countPhaseAgents(phase: PhaseSnapshot): AgentCounts {
100
- const counts: AgentCounts = { queued: 0, running: 0, done: 0, failed: 0, total: phase.agents.length };
101
- for (const agent of phase.agents) counts[agent.status]++;
102
- return counts;
272
+ /** `find:logic-bugs` under the Find phase reads as `logic-bugs`; the phase line already says Find. */
273
+ function agentLabel(agent: AgentRowSnapshot, phase: PhaseSnapshot): string {
274
+ const phaseName = phase.title.split(" ▸ ").pop()?.toLowerCase() ?? "";
275
+ const prefix = `${phaseName}:`;
276
+ return phaseName && agent.label.toLowerCase().startsWith(prefix) && agent.label.length > prefix.length
277
+ ? agent.label.slice(prefix.length)
278
+ : agent.label;
279
+ }
280
+
281
+ /** Declared `meta.phases` not reached yet, after the last one the run has entered. */
282
+ function upcomingLine(snapshot: WorkflowProgressSnapshot, theme: Theme): string | undefined {
283
+ const planned = snapshot.plannedPhases ?? [];
284
+ const seen = new Set(snapshot.phases.map((phase) => phase.title));
285
+ const lastSeen = planned.reduce((last, title, index) => (seen.has(title) ? index : last), -1);
286
+ const upcoming = planned.slice(lastSeen + 1).filter((title) => !seen.has(title));
287
+ if (upcoming.length === 0) return undefined;
288
+ return ` ${theme.fg("dim", GLYPH.queued)} ${theme.fg("dim", upcoming.join(" · "))}`;
103
289
  }
104
290
 
105
- function phaseLine(title: string, counts: AgentCounts, theme: Theme): string {
291
+ /**
292
+ * The latest log when it reports a failure the phase and agent lines do not already count, such as a
293
+ * provider retry or an observer error. Failed agents are reported by their rows and phase totals.
294
+ */
295
+ function failureLogLine(snapshot: WorkflowProgressSnapshot, theme: Theme): string | undefined {
296
+ const latest = snapshot.logs.at(-1);
297
+ if (!latest || !FAILURE_LOG.test(latest)) return undefined;
298
+ const failedLabels = snapshot.phases.flatMap((phase) => phase.agents.filter((agent) => agent.status === "failed").map((agent) => agent.label));
299
+ if (failedLabels.some((label) => latest.startsWith(label))) return undefined;
300
+ return ` ${theme.fg("warning", `${GLYPH.warning} ${oneLine(latest)}`)}`;
301
+ }
302
+
303
+ function footerLine(snapshot: WorkflowProgressSnapshot, inspectorHint: string, width: number, theme: Theme): string {
304
+ const hint = keyHints([[inspectorHint, "inspect"]], theme);
305
+ const counters = counterText(snapshot, theme);
306
+ return counters ? spread(` ${counters}`, hint, width) : spread("", hint, width);
307
+ }
308
+
309
+ /** `3 files · 3 candidates · 2 confirmed`: zeros hidden, verdict labels lower-cased, duplicates dropped. */
310
+ function counterText(snapshot: WorkflowProgressSnapshot, theme: Theme): string | undefined {
311
+ const seen = new Set<string>();
106
312
  const parts: string[] = [];
107
- if (counts.running > 0) parts.push(`${counts.running} running`);
108
- if (counts.done > 0) parts.push(`${counts.done} done`);
109
- if (counts.failed > 0) parts.push(`${counts.failed} failed`);
110
- return `${theme.fg("dim", "├─")} ${theme.fg(counts.running > 0 ? "accent" : "muted", title)}${parts.length ? ` ${theme.fg("dim", `(${parts.join(" · ")})`)}` : ""}`;
111
- }
112
-
113
- function agentLine(agent: AgentRowSnapshot, theme: Theme): string {
114
- const detailParts = agentDetailParts(agent, { includeQueuedStatus: false });
115
- const activity = detailParts.length > 0 ? ` ${theme.fg("dim", `· ${detailParts.join(" · ")}`)}` : "";
116
- return `${theme.fg("dim", "│ ")} ${statusIcon(agent.status, theme)} ${theme.fg(agentLabelColor(agent), agent.label)}${activity}`;
117
- }
118
-
119
- function footerLine(snapshot: WorkflowProgressSnapshot, theme: Theme): string | undefined {
120
- const usage = formatWorkflowUsageLine(snapshot.usage);
121
- const counters = snapshot.counters.slice(0, 4).map((counter) => `${counter.label} ${formatCount(counter.value)}`);
122
- const latestLog = snapshot.logs[snapshot.logs.length - 1];
123
- const parts = usage ? [usage, ...counters] : counters;
124
- if (latestLog) parts.push(latestLog);
125
- if (parts.length === 0) return undefined;
126
- return `${theme.fg("dim", "└─")} ${theme.fg("dim", parts.join(" · "))}`;
313
+ for (const counter of snapshot.counters) {
314
+ const label = counter.label.toLowerCase().replaceAll("_", " ");
315
+ if (counter.value === 0 || seen.has(label)) continue;
316
+ seen.add(label);
317
+ parts.push(`${theme.fg("text", formatCount(counter.value))} ${theme.fg(counterColor(counter.key), label)}`);
318
+ }
319
+ return parts.length > 0 ? parts.join(dot(theme)) : undefined;
320
+ }
321
+
322
+ function counterColor(key: string): ThemeColor {
323
+ return key === "kept" ? "success" : "muted";
324
+ }
325
+
326
+ function phaseSpan(agents: readonly AgentRowSnapshot[]): number | undefined {
327
+ const starts = agents.flatMap((agent) => (agent.startedAt === undefined ? [] : [agent.startedAt]));
328
+ const ends = agents.flatMap((agent) => (agent.doneAt === undefined ? [] : [agent.doneAt]));
329
+ if (starts.length === 0 || ends.length === 0) return undefined;
330
+ return Math.max(...ends) - Math.min(...starts);
331
+ }
332
+
333
+ /** `0.6s`, `4.2s`, then `12s` / `1m 4s` for finished spans. */
334
+ function shortDuration(ms: number): string {
335
+ if (ms < 10_000) return `${(Math.max(0, ms) / 1_000).toFixed(1)}s`;
336
+ return formatDuration(ms);
337
+ }
338
+
339
+ function plural(count: number, noun: string): string {
340
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
341
+ }
342
+
343
+ function oneLine(text: string): string {
344
+ return text.replace(/\s+/g, " ").trim();
127
345
  }
@@ -1,3 +1,6 @@
1
+ import { isFiniteNumber, isRecord } from "./guards.ts";
2
+ import { formatCount } from "./text.ts";
3
+
1
4
  export interface WorkflowUsageCost {
2
5
  readonly input: number;
3
6
  readonly output: number;
@@ -94,10 +97,8 @@ export class WorkflowUsageRecorder implements WorkflowUsageSink {
94
97
  }
95
98
 
96
99
  snapshot(): WorkflowUsageSnapshot {
97
- const agents = this.agents.map((agent) => ({
98
- ...agent,
99
- usage: cloneTotals(agent.usage),
100
- }));
100
+ // Recorded entries are never mutated; copying the array keeps each snapshot point-in-time.
101
+ const agents = [...this.agents];
101
102
  return {
102
103
  agents,
103
104
  totals: sumTotals(agents.map((agent) => agent.usage)),
@@ -118,7 +119,7 @@ export function isWorkflowUsageSnapshot(value: unknown): value is WorkflowUsageS
118
119
  if (!isRecord(value)) return false;
119
120
  if (!Array.isArray(value.agents) || !value.agents.every(isWorkflowAgentUsage)) return false;
120
121
  if (!isWorkflowUsageTotals(value.totals)) return false;
121
- return finiteNumber(value.assistantMessages) !== undefined;
122
+ return isFiniteNumber(value.assistantMessages);
122
123
  }
123
124
 
124
125
  export function hasWorkflowUsage(snapshot: unknown): snapshot is WorkflowUsageSnapshot {
@@ -144,9 +145,9 @@ function formatWorkflowUsageComponents(totals: WorkflowUsageTotals): string[] {
144
145
  appendUsageComponent(parts, "output", totals.output, totals.coverage.output);
145
146
 
146
147
  const knownComponents = totals.input + totals.output + totals.cacheRead + totals.cacheWrite;
147
- if (parts.length === 0 && totals.totalTokens > 0) parts.push(`tokens ${formatUsageCount(totals.totalTokens)}`);
148
+ if (parts.length === 0 && totals.totalTokens > 0) parts.push(`tokens ${formatCount(totals.totalTokens)}`);
148
149
  else if (!hasCompleteCoverage(totals.coverage) && totals.totalTokens !== knownComponents) {
149
- parts.push(`total ${formatUsageCount(totals.totalTokens)}`);
150
+ parts.push(`total ${formatCount(totals.totalTokens)}`);
150
151
  }
151
152
  return parts;
152
153
  }
@@ -158,7 +159,7 @@ function appendUsageComponent(
158
159
  coverage: WorkflowUsageCoverage,
159
160
  ): void {
160
161
  if (value <= 0 || coverage === "none") return;
161
- const count = formatUsageCount(value);
162
+ const count = formatCount(value);
162
163
  parts.push(`${label} ${coverage === "partial" ? `≥${count}` : count}`);
163
164
  }
164
165
 
@@ -168,17 +169,17 @@ function isWorkflowAgentUsage(value: unknown): value is WorkflowAgentUsage {
168
169
  if (value.phase !== undefined && typeof value.phase !== "string") return false;
169
170
  if (value.provider !== undefined && typeof value.provider !== "string") return false;
170
171
  if (value.model !== undefined && typeof value.model !== "string") return false;
171
- if (finiteNumber(value.assistantMessages) === undefined) return false;
172
+ if (!isFiniteNumber(value.assistantMessages)) return false;
172
173
  return isWorkflowUsageTotals(value.usage);
173
174
  }
174
175
 
175
176
  function isWorkflowUsageTotals(value: unknown): value is WorkflowUsageTotals {
176
177
  if (!isRecord(value)) return false;
177
- if (finiteNumber(value.input) === undefined) return false;
178
- if (finiteNumber(value.output) === undefined) return false;
179
- if (finiteNumber(value.cacheRead) === undefined) return false;
180
- if (finiteNumber(value.cacheWrite) === undefined) return false;
181
- if (finiteNumber(value.totalTokens) === undefined) return false;
178
+ if (!isFiniteNumber(value.input)) return false;
179
+ if (!isFiniteNumber(value.output)) return false;
180
+ if (!isFiniteNumber(value.cacheRead)) return false;
181
+ if (!isFiniteNumber(value.cacheWrite)) return false;
182
+ if (!isFiniteNumber(value.totalTokens)) return false;
182
183
  if (!isWorkflowUsageCoverage(value.coverage)) return false;
183
184
  return isWorkflowUsageCost(value.cost);
184
185
  }
@@ -196,11 +197,11 @@ function isWorkflowUsageCoverage(value: unknown): value is WorkflowUsageComponen
196
197
  function isWorkflowUsageCost(value: unknown): value is WorkflowUsageCost {
197
198
  if (!isRecord(value)) return false;
198
199
  return (
199
- finiteNumber(value.input) !== undefined &&
200
- finiteNumber(value.output) !== undefined &&
201
- finiteNumber(value.cacheRead) !== undefined &&
202
- finiteNumber(value.cacheWrite) !== undefined &&
203
- finiteNumber(value.total) !== undefined
200
+ isFiniteNumber(value.input) &&
201
+ isFiniteNumber(value.output) &&
202
+ isFiniteNumber(value.cacheRead) &&
203
+ isFiniteNumber(value.cacheWrite) &&
204
+ isFiniteNumber(value.total)
204
205
  );
205
206
  }
206
207
 
@@ -321,18 +322,6 @@ function isCoverageValue(value: unknown): value is WorkflowUsageCoverage {
321
322
  return value === "none" || value === "partial" || value === "complete";
322
323
  }
323
324
 
324
- function formatUsageCount(count: number): string {
325
- if (count < 1000) return count.toString();
326
- if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
327
- if (count < 1000000) return `${Math.round(count / 1000)}k`;
328
- if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
329
- return `${Math.round(count / 1000000)}M`;
330
- }
331
-
332
325
  function finiteNumber(value: unknown): number | undefined {
333
- return typeof value === "number" && Number.isFinite(value) ? value : undefined;
334
- }
335
-
336
- function isRecord(value: unknown): value is Record<string, unknown> {
337
- return typeof value === "object" && value !== null;
326
+ return isFiniteNumber(value) ? value : undefined;
338
327
  }