pi-workflow-engine 0.13.0 → 1.1.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 (98) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +152 -416
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +1 -1
  3. package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +17 -14
  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 +12 -13
  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 +131 -117
  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-workflow-tool.ts +3 -0
  16. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +33 -86
  17. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +1 -5
  18. package/.pi/extensions/pi-workflow-engine/src/classify.ts +107 -0
  19. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +25 -80
  20. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +16 -23
  21. package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +1 -4
  22. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +58 -117
  23. package/.pi/extensions/pi-workflow-engine/src/engine.ts +41 -80
  24. package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +2 -10
  25. package/.pi/extensions/pi-workflow-engine/src/git.ts +55 -0
  26. package/.pi/extensions/pi-workflow-engine/src/guards.ts +8 -0
  27. package/.pi/extensions/pi-workflow-engine/src/host-tools.ts +76 -0
  28. package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +7 -19
  29. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +171 -106
  30. package/.pi/extensions/pi-workflow-engine/src/journal.ts +0 -12
  31. package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +37 -8
  32. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +15 -66
  33. package/.pi/extensions/pi-workflow-engine/src/model-ref.ts +19 -0
  34. package/.pi/extensions/pi-workflow-engine/src/options.ts +97 -16
  35. package/.pi/extensions/pi-workflow-engine/src/perf.ts +43 -72
  36. package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +2 -2
  37. package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +14 -4
  38. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +2 -0
  39. package/.pi/extensions/pi-workflow-engine/src/progress.ts +79 -80
  40. package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +28 -20
  41. package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +2 -7
  42. package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +0 -1
  43. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +29 -31
  44. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +75 -139
  45. package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +14 -31
  46. package/.pi/extensions/pi-workflow-engine/src/review/patch-validation.ts +3 -4
  47. package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +1 -1
  48. package/.pi/extensions/pi-workflow-engine/src/review/review-diff-lines.ts +54 -0
  49. package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +7 -9
  50. package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +51 -117
  51. package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +1 -1
  52. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +8 -13
  53. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +3 -7
  54. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +158 -241
  55. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +28 -38
  56. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +35 -44
  57. package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +13 -14
  58. package/.pi/extensions/pi-workflow-engine/src/text.ts +29 -0
  59. package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +17 -4
  60. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +16 -28
  61. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +34 -14
  62. package/.pi/extensions/pi-workflow-engine/src/types.ts +30 -0
  63. package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +193 -0
  64. package/.pi/extensions/pi-workflow-engine/src/ui/research-result-renderer.ts +177 -0
  65. package/.pi/extensions/pi-workflow-engine/src/ui/result-layout.ts +209 -0
  66. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +17 -44
  67. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +703 -235
  68. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-picker.ts +48 -0
  69. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +239 -129
  70. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +461 -0
  71. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +91 -0
  72. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +316 -98
  73. package/.pi/extensions/pi-workflow-engine/src/usage.ts +41 -34
  74. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +162 -22
  75. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +19 -24
  76. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +17 -37
  77. package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +79 -0
  78. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +4 -14
  79. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +77 -63
  80. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +42 -29
  81. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +15 -14
  82. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +27 -31
  83. package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +1 -5
  84. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +3 -6
  85. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +48 -110
  86. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +64 -93
  87. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +25 -67
  88. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +19 -53
  89. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +21 -49
  90. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +74 -66
  91. package/README.md +1 -1
  92. package/USAGE.md +79 -20
  93. package/package.json +1 -2
  94. package/skills/workflow-code-review-actions/SKILL.md +12 -23
  95. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +0 -81
  96. package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +0 -24
  97. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +0 -52
  98. 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,7 @@
1
+ import type { Usage } from "@earendil-works/pi-ai";
2
+ import { isFiniteNumber, isRecord } from "./guards.ts";
3
+ import { formatCount } from "./text.ts";
4
+
1
5
  export interface WorkflowUsageCost {
2
6
  readonly input: number;
3
7
  readonly output: number;
@@ -78,9 +82,14 @@ export class WorkflowUsageRecorder implements WorkflowUsageSink {
78
82
  const parsed = parseAssistantUsageMessage(message);
79
83
  return parsed ? [parsed] : [];
80
84
  });
81
- if (assistantMessages.length === 0) return;
85
+ // Tools that call models themselves, such as codemode running a classifier, report it on their result.
86
+ const toolUsage = input.messages.flatMap((message) => {
87
+ const parsed = parseToolResultUsage(message);
88
+ return parsed ? [parsed] : [];
89
+ });
90
+ if (assistantMessages.length === 0 && toolUsage.length === 0) return;
82
91
 
83
- const usage = sumTotals(assistantMessages.map((message) => message.usage));
92
+ const usage = sumTotals([...assistantMessages.map((message) => message.usage), ...toolUsage]);
84
93
  const latestMetadata = assistantMessages.findLast((message) => message.provider !== undefined || message.model !== undefined);
85
94
  this.agents.push({
86
95
  label: input.label,
@@ -94,10 +103,8 @@ export class WorkflowUsageRecorder implements WorkflowUsageSink {
94
103
  }
95
104
 
96
105
  snapshot(): WorkflowUsageSnapshot {
97
- const agents = this.agents.map((agent) => ({
98
- ...agent,
99
- usage: cloneTotals(agent.usage),
100
- }));
106
+ // Recorded entries are never mutated; copying the array keeps each snapshot point-in-time.
107
+ const agents = [...this.agents];
101
108
  return {
102
109
  agents,
103
110
  totals: sumTotals(agents.map((agent) => agent.usage)),
@@ -118,7 +125,7 @@ export function isWorkflowUsageSnapshot(value: unknown): value is WorkflowUsageS
118
125
  if (!isRecord(value)) return false;
119
126
  if (!Array.isArray(value.agents) || !value.agents.every(isWorkflowAgentUsage)) return false;
120
127
  if (!isWorkflowUsageTotals(value.totals)) return false;
121
- return finiteNumber(value.assistantMessages) !== undefined;
128
+ return isFiniteNumber(value.assistantMessages);
122
129
  }
123
130
 
124
131
  export function hasWorkflowUsage(snapshot: unknown): snapshot is WorkflowUsageSnapshot {
@@ -126,6 +133,13 @@ export function hasWorkflowUsage(snapshot: unknown): snapshot is WorkflowUsageSn
126
133
  return snapshot.totals.totalTokens > 0 || snapshot.totals.cost.total > 0;
127
134
  }
128
135
 
136
+ /** The run's subagent totals as pi's `Usage`, so a tool result adds them to the session's cost. */
137
+ export function workflowUsageForPi(snapshot: unknown): Usage | undefined {
138
+ if (!hasWorkflowUsage(snapshot)) return undefined;
139
+ const { input, output, cacheRead, cacheWrite, totalTokens, cost } = snapshot.totals;
140
+ return { input, output, cacheRead, cacheWrite, totalTokens, cost: { ...cost } };
141
+ }
142
+
129
143
  export function formatWorkflowUsageLine(snapshot: unknown): string | undefined {
130
144
  if (!hasWorkflowUsage(snapshot)) return undefined;
131
145
  const parts = formatWorkflowUsageComponents(snapshot.totals);
@@ -144,9 +158,9 @@ function formatWorkflowUsageComponents(totals: WorkflowUsageTotals): string[] {
144
158
  appendUsageComponent(parts, "output", totals.output, totals.coverage.output);
145
159
 
146
160
  const knownComponents = totals.input + totals.output + totals.cacheRead + totals.cacheWrite;
147
- if (parts.length === 0 && totals.totalTokens > 0) parts.push(`tokens ${formatUsageCount(totals.totalTokens)}`);
161
+ if (parts.length === 0 && totals.totalTokens > 0) parts.push(`tokens ${formatCount(totals.totalTokens)}`);
148
162
  else if (!hasCompleteCoverage(totals.coverage) && totals.totalTokens !== knownComponents) {
149
- parts.push(`total ${formatUsageCount(totals.totalTokens)}`);
163
+ parts.push(`total ${formatCount(totals.totalTokens)}`);
150
164
  }
151
165
  return parts;
152
166
  }
@@ -158,7 +172,7 @@ function appendUsageComponent(
158
172
  coverage: WorkflowUsageCoverage,
159
173
  ): void {
160
174
  if (value <= 0 || coverage === "none") return;
161
- const count = formatUsageCount(value);
175
+ const count = formatCount(value);
162
176
  parts.push(`${label} ${coverage === "partial" ? `≥${count}` : count}`);
163
177
  }
164
178
 
@@ -168,17 +182,17 @@ function isWorkflowAgentUsage(value: unknown): value is WorkflowAgentUsage {
168
182
  if (value.phase !== undefined && typeof value.phase !== "string") return false;
169
183
  if (value.provider !== undefined && typeof value.provider !== "string") return false;
170
184
  if (value.model !== undefined && typeof value.model !== "string") return false;
171
- if (finiteNumber(value.assistantMessages) === undefined) return false;
185
+ if (!isFiniteNumber(value.assistantMessages)) return false;
172
186
  return isWorkflowUsageTotals(value.usage);
173
187
  }
174
188
 
175
189
  function isWorkflowUsageTotals(value: unknown): value is WorkflowUsageTotals {
176
190
  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;
191
+ if (!isFiniteNumber(value.input)) return false;
192
+ if (!isFiniteNumber(value.output)) return false;
193
+ if (!isFiniteNumber(value.cacheRead)) return false;
194
+ if (!isFiniteNumber(value.cacheWrite)) return false;
195
+ if (!isFiniteNumber(value.totalTokens)) return false;
182
196
  if (!isWorkflowUsageCoverage(value.coverage)) return false;
183
197
  return isWorkflowUsageCost(value.cost);
184
198
  }
@@ -196,11 +210,11 @@ function isWorkflowUsageCoverage(value: unknown): value is WorkflowUsageComponen
196
210
  function isWorkflowUsageCost(value: unknown): value is WorkflowUsageCost {
197
211
  if (!isRecord(value)) return false;
198
212
  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
213
+ isFiniteNumber(value.input) &&
214
+ isFiniteNumber(value.output) &&
215
+ isFiniteNumber(value.cacheRead) &&
216
+ isFiniteNumber(value.cacheWrite) &&
217
+ isFiniteNumber(value.total)
204
218
  );
205
219
  }
206
220
 
@@ -216,6 +230,11 @@ function parseAssistantUsageMessage(message: unknown): AssistantUsageMessage | u
216
230
  };
217
231
  }
218
232
 
233
+ function parseToolResultUsage(message: unknown): WorkflowUsageTotals | undefined {
234
+ if (!isRecord(message) || message.role !== "toolResult") return undefined;
235
+ return parseUsageTotals(message.usage);
236
+ }
237
+
219
238
  function parseUsageTotals(value: unknown): WorkflowUsageTotals | undefined {
220
239
  if (!isRecord(value)) return undefined;
221
240
  const input = finiteNumber(value.input);
@@ -321,18 +340,6 @@ function isCoverageValue(value: unknown): value is WorkflowUsageCoverage {
321
340
  return value === "none" || value === "partial" || value === "complete";
322
341
  }
323
342
 
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
343
  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;
344
+ return isFiniteNumber(value) ? value : undefined;
338
345
  }