@herbertgao/pi-subagents 0.17.1 → 0.18.1

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 (50) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +427 -120
  3. package/docs/rpc.md +184 -0
  4. package/docs/workflows.md +466 -0
  5. package/examples/agent-tool-description.md +6 -6
  6. package/examples/workflows/compose.js +52 -0
  7. package/examples/workflows/fan-out-audit.js +56 -0
  8. package/examples/workflows/gated-fix.js +60 -0
  9. package/examples/workflows/lib/count-child.js +30 -0
  10. package/examples/workflows/review-panel.js +68 -0
  11. package/examples/workflows/structured-findings.js +81 -0
  12. package/package.json +12 -10
  13. package/src/agent-file-toggle.ts +52 -12
  14. package/src/agent-manager.ts +837 -146
  15. package/src/agent-runner.ts +213 -39
  16. package/src/cross-extension-rpc.ts +73 -14
  17. package/src/custom-agents.ts +101 -47
  18. package/src/index.ts +2249 -914
  19. package/src/invocation-config.ts +13 -0
  20. package/src/mention-clone.ts +215 -0
  21. package/src/mention.ts +147 -0
  22. package/src/model-resolver.ts +9 -1
  23. package/src/nested-tools.ts +40 -26
  24. package/src/output-file.ts +18 -8
  25. package/src/prompts.ts +46 -9
  26. package/src/schedule.ts +21 -16
  27. package/src/settings.ts +137 -7
  28. package/src/structured-output.ts +136 -0
  29. package/src/types.ts +126 -8
  30. package/src/ui/agent-mention.ts +274 -0
  31. package/src/ui/agent-widget.ts +20 -5
  32. package/src/ui/conversation-viewer.ts +10 -4
  33. package/src/ui/fleet-list.ts +167 -22
  34. package/src/ui/workflow-card.ts +555 -0
  35. package/src/ui/workflow-dialog.ts +1304 -0
  36. package/src/ui/workflow-menu.ts +226 -0
  37. package/src/workflow/collisions.ts +122 -0
  38. package/src/workflow/entry.ts +47 -0
  39. package/src/workflow/host.ts +463 -0
  40. package/src/workflow/journal.ts +164 -0
  41. package/src/workflow/json-schema.ts +142 -0
  42. package/src/workflow/meta.ts +401 -0
  43. package/src/workflow/progress.ts +622 -0
  44. package/src/workflow/runtime.ts +1399 -0
  45. package/src/workflow/saved.ts +230 -0
  46. package/src/workflow/task.ts +333 -0
  47. package/src/workflow/tool-description.ts +200 -0
  48. package/src/workflow/worker-source.ts +781 -0
  49. package/src/worktree.ts +97 -95
  50. package/src/xml.ts +13 -0
@@ -0,0 +1,622 @@
1
+ /**
2
+ * progress.ts — the workflow progress model, ported from Claude Code.
3
+ *
4
+ * Progress is an **append-only event log**, not a tree. Agent entries are keyed
5
+ * by `index` and last-write-wins, so a running agent is updated by appending a
6
+ * fresh entry with the same index rather than mutating anything. Every view —
7
+ * the inline card, the workflows dialog, the fleet widget — derives its shape
8
+ * by collapsing that log. Keeping the log authoritative is what lets a batched
9
+ * update carry several agents' changes in one message from the worker.
10
+ *
11
+ * Two vocabularies, deliberately distinct:
12
+ * - entry `state` is only start | progress | done | error, with `skipped`,
13
+ * `blocked` and `cached` as separate booleans;
14
+ * - the display state adds queued, running, interrupted, skipped, blocked and
15
+ * failed, and is *derived* (see `displayState`).
16
+ * Mixing them up is the easiest way to get the rendering wrong, which is why
17
+ * the derivation lives here as one function rather than inline in each renderer.
18
+ *
19
+ * Everything in this file is pure and framework-free so the whole model is
20
+ * unit-testable without a terminal.
21
+ */
22
+
23
+ import type { WorkflowMeta, WorkflowPhaseMeta } from "./meta.js"
24
+
25
+ /** Raw entry lifecycle, as written by the runtime. */
26
+ export type WorkflowEntryState = "start" | "progress" | "done" | "error"
27
+
28
+ /** Derived per-agent state, as rendered. */
29
+ export type WorkflowDisplayState =
30
+ | "queued"
31
+ | "running"
32
+ | "done"
33
+ | "failed"
34
+ | "skipped"
35
+ | "blocked"
36
+ | "interrupted"
37
+
38
+ /** Why an agent is on a later attempt, shown next to its row. */
39
+ export type AttemptReason = "throttled" | "user-retry" | "stalled"
40
+
41
+ export interface WorkflowPhaseEntry {
42
+ type: "workflow_phase"
43
+ index: number
44
+ title: string
45
+ }
46
+
47
+ export interface WorkflowLogEntry {
48
+ type: "workflow_log"
49
+ message: string
50
+ }
51
+
52
+ export interface WorkflowAgentEntry {
53
+ type: "workflow_agent"
54
+ /** Stable identity. Re-emitting this index replaces the previous entry. */
55
+ index: number
56
+ label: string
57
+ /**
58
+ * Absent when the agent ran before any `phase()` call. That is the signal —
59
+ * not a default of 0 — that turns the whole run into one "Agents" group.
60
+ */
61
+ phaseIndex?: number
62
+ phaseTitle?: string
63
+ state: WorkflowEntryState
64
+ agentId?: string
65
+ /**
66
+ * The manager's `AgentRecord` id, once the child has one.
67
+ *
68
+ * Distinct from {@link agentId}, which is the run's own `wf-agent-N` handle
69
+ * and means nothing outside the runtime. This is what the inspector's `c`
70
+ * key opens a conversation viewer on, so it is reported the moment the
71
+ * manager issues it rather than with the effective model — a child that dies
72
+ * before its session resolves still has a conversation worth reading.
73
+ */
74
+ recordId?: string
75
+ agentType?: string
76
+ /**
77
+ * Short model label for tight rows, e.g. `haiku 4.5`.
78
+ *
79
+ * Seeded from what the script asked for and then REPLACED by what the child
80
+ * actually ran on, once its session exists to report one — the same
81
+ * effective-not-requested rule every other subagent surface follows (#168).
82
+ * An `agent()` that named no model therefore starts blank and fills in.
83
+ */
84
+ model?: string
85
+ /** Canonical `provider/model-id`, for the dialog, which has room for it. */
86
+ modelId?: string
87
+ /** The level actually in effect, once the child's session reports one. */
88
+ thinking?: string
89
+ /**
90
+ * What the call asked for, kept only when it did not get it — pi clamped the
91
+ * level, or an agent file's frontmatter outranked the option (#182). Rendered
92
+ * as `(asked max)` beside the effective value rather than silently replacing
93
+ * it.
94
+ */
95
+ requestedThinking?: string
96
+ requestedModel?: string
97
+ fallbackModel?: string
98
+ isolation?: "worktree"
99
+ error?: string
100
+ skipped?: boolean
101
+ blocked?: boolean
102
+ cached?: boolean
103
+ queuedAt?: number
104
+ startedAt?: number
105
+ lastProgressAt?: number
106
+ attempt?: number
107
+ lastAttemptReason?: AttemptReason
108
+ promptPreview?: string
109
+ resultPreview?: string
110
+ tokens?: number
111
+ toolCalls?: number
112
+ durationMs?: number
113
+ }
114
+
115
+ export type WorkflowEntry =
116
+ | WorkflowPhaseEntry
117
+ | WorkflowLogEntry
118
+ | WorkflowAgentEntry
119
+
120
+ /** Overall run status, mirroring the task record. */
121
+ export type WorkflowRunStatus =
122
+ | "running"
123
+ | "completed"
124
+ | "failed"
125
+ | "killed"
126
+ | "paused"
127
+
128
+ export interface CollapsedProgress {
129
+ agents: WorkflowAgentEntry[]
130
+ logs: string[]
131
+ phaseTitles: Map<number, string>
132
+ }
133
+
134
+ export interface PhaseGroup {
135
+ title: string
136
+ status: "not-started" | "running" | "done" | "failed"
137
+ agents: WorkflowAgentEntry[]
138
+ doneCount: number
139
+ totalCount: number
140
+ tokens: number
141
+ durationMs: number
142
+ }
143
+
144
+ export interface WorkflowStats {
145
+ done: number
146
+ failedCount: number
147
+ running: boolean
148
+ total: number
149
+ started: number
150
+ complete: boolean
151
+ }
152
+
153
+ /**
154
+ * Fold the event log into its latest state.
155
+ *
156
+ * Agent entries collapse by index (last write wins); logs accumulate in order;
157
+ * phase titles are a lookup for grouping.
158
+ */
159
+ export function collapse(
160
+ progress: readonly WorkflowEntry[],
161
+ ): CollapsedProgress {
162
+ const agents = new Map<number, WorkflowAgentEntry>()
163
+ const logs: string[] = []
164
+ const phaseTitles = new Map<number, string>()
165
+
166
+ for (const entry of progress) {
167
+ if (entry.type === "workflow_agent") agents.set(entry.index, entry)
168
+ else if (entry.type === "workflow_log") logs.push(entry.message)
169
+ else phaseTitles.set(entry.index, entry.title)
170
+ }
171
+
172
+ return {
173
+ agents: [...agents.values()].sort((a, b) => a.index - b.index),
174
+ logs,
175
+ phaseTitles,
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Derive what to render for one agent.
181
+ *
182
+ * `workflowActive` is false once the run has stopped: anything still mid-flight
183
+ * at that point was cut off rather than finished, hence "interrupted".
184
+ */
185
+ export function displayState(
186
+ entry: WorkflowAgentEntry,
187
+ workflowActive: boolean,
188
+ ): WorkflowDisplayState {
189
+ if (entry.state === "done") return "done"
190
+ if (entry.state === "error") {
191
+ if (entry.skipped) return "skipped"
192
+ if (entry.blocked) return "blocked"
193
+ return "failed"
194
+ }
195
+ if (!workflowActive) return "interrupted"
196
+ // Queued means accepted but never given a slot. An entry with no queuedAt at
197
+ // all predates the semaphore and is treated as running.
198
+ return entry.queuedAt != null && entry.startedAt == null
199
+ ? "queued"
200
+ : "running"
201
+ }
202
+
203
+ /** True while an entry is still expected to change. */
204
+ export function isLive(entry: WorkflowAgentEntry): boolean {
205
+ return entry.state === "start" || entry.state === "progress"
206
+ }
207
+
208
+ /** Bucket agents by phase. Returns null when no agent declared a phase. */
209
+ function groupByPhase(
210
+ agents: readonly WorkflowAgentEntry[],
211
+ phaseTitles: Map<number, string>,
212
+ ):
213
+ | { phaseIndex: number; title: string; agents: WorkflowAgentEntry[] }[]
214
+ | null {
215
+ if (!agents.some((a) => a.phaseIndex != null)) return null
216
+
217
+ const byPhase = new Map<
218
+ number,
219
+ { phaseIndex: number; title: string; agents: WorkflowAgentEntry[] }
220
+ >()
221
+ for (const agent of agents) {
222
+ const phaseIndex = agent.phaseIndex ?? 0
223
+ let group = byPhase.get(phaseIndex)
224
+ if (!group) {
225
+ group = {
226
+ phaseIndex,
227
+ title: phaseTitles.get(phaseIndex) ?? `Phase ${phaseIndex}`,
228
+ agents: [],
229
+ }
230
+ byPhase.set(phaseIndex, group)
231
+ }
232
+ group.agents.push(agent)
233
+ }
234
+ return [...byPhase.values()].sort((a, b) => a.phaseIndex - b.phaseIndex)
235
+ }
236
+
237
+ /** Roll a phase's agents up into the counts and totals its header shows. */
238
+ function summarize(group: {
239
+ title: string
240
+ agents: WorkflowAgentEntry[]
241
+ }): PhaseGroup {
242
+ let done = 0
243
+ let failed = 0
244
+ let tokens = 0
245
+ let minStart = Number.POSITIVE_INFINITY
246
+ let maxProgress = 0
247
+
248
+ for (const agent of group.agents) {
249
+ if (agent.state === "done") done++
250
+ else if (agent.state === "error") failed++
251
+ if (agent.tokens) tokens += agent.tokens
252
+ if (agent.startedAt != null) {
253
+ if (agent.startedAt < minStart) minStart = agent.startedAt
254
+ const last = agent.lastProgressAt ?? agent.startedAt
255
+ if (last > maxProgress) maxProgress = last
256
+ }
257
+ }
258
+
259
+ const total = group.agents.length
260
+ const finished = done + failed === total && total > 0
261
+ return {
262
+ title: group.title,
263
+ status: finished ? (failed > 0 ? "failed" : "done") : "running",
264
+ agents: group.agents,
265
+ doneCount: done,
266
+ totalCount: total,
267
+ tokens,
268
+ // Wall-clock across the phase, not the sum of its agents: they overlap.
269
+ durationMs:
270
+ minStart < Number.POSITIVE_INFINITY ? maxProgress - minStart : 0,
271
+ }
272
+ }
273
+
274
+ /** A phase declared in `meta` that has not produced any agent yet. */
275
+ function placeholder(title: string): PhaseGroup {
276
+ return {
277
+ title,
278
+ status: "not-started",
279
+ agents: [],
280
+ doneCount: 0,
281
+ totalCount: 0,
282
+ tokens: 0,
283
+ durationMs: 0,
284
+ }
285
+ }
286
+
287
+ const normalizeTitle = (title: string) => title.toLowerCase().trim()
288
+
289
+ /**
290
+ * Reconcile the phases declared in `meta` with the phases actually observed.
291
+ *
292
+ * Matching is fuzzy on purpose: a script may call `phase("Review")` against a
293
+ * declared `{ title: "Review changed files" }`, and Claude Code treats those as
294
+ * the same phase when either title is a prefix of the other. Each observed
295
+ * group is consumed at most once, declared-but-unseen phases render as
296
+ * not-started placeholders, and observed groups with no declaration are
297
+ * appended after — that is how an undeclared `phase()` "gets its own group".
298
+ */
299
+ function mergePhases(
300
+ declared: readonly WorkflowPhaseMeta[] | undefined,
301
+ observed: {
302
+ phaseIndex: number
303
+ title: string
304
+ agents: WorkflowAgentEntry[]
305
+ }[],
306
+ ): PhaseGroup[] {
307
+ const consumed = new Set<{
308
+ phaseIndex: number
309
+ title: string
310
+ agents: WorkflowAgentEntry[]
311
+ }>()
312
+ const merged: PhaseGroup[] = []
313
+
314
+ for (const phase of declared ?? []) {
315
+ const wanted = normalizeTitle(phase.title)
316
+ const match = observed.find((group) => {
317
+ if (consumed.has(group)) return false
318
+ const actual = normalizeTitle(group.title)
319
+ return (
320
+ actual === wanted ||
321
+ actual.startsWith(wanted) ||
322
+ wanted.startsWith(actual)
323
+ )
324
+ })
325
+ if (match) {
326
+ consumed.add(match)
327
+ merged.push(summarize(match))
328
+ } else {
329
+ merged.push(placeholder(phase.title))
330
+ }
331
+ }
332
+
333
+ for (const group of observed) {
334
+ if (!consumed.has(group)) merged.push(summarize(group))
335
+ }
336
+
337
+ return merged
338
+ }
339
+
340
+ /**
341
+ * Build the phase groups a renderer walks.
342
+ *
343
+ * When nothing declared or emitted a phase, every agent collapses into a single
344
+ * group titled "Agents" so the tree still has one level of structure.
345
+ */
346
+ export function buildPhaseGroups(
347
+ progress: readonly WorkflowEntry[],
348
+ declared?: readonly WorkflowPhaseMeta[],
349
+ ): PhaseGroup[] {
350
+ const { agents, phaseTitles } = collapse(progress)
351
+ const observed = groupByPhase(agents, phaseTitles) ?? []
352
+ const merged = mergePhases(declared, observed)
353
+ if (merged.length === 0 && agents.length > 0) {
354
+ return [summarize({ title: "Agents", agents })]
355
+ }
356
+ // A run that declared phases but produced un-phased agents would otherwise
357
+ // render placeholders and drop those agents from the tree entirely. Claude
358
+ // Code has the same hole; showing the work is worth the small divergence.
359
+ if (agents.length > 0 && !merged.some((group) => group.totalCount > 0)) {
360
+ return [...merged, summarize({ title: "Agents", agents })]
361
+ }
362
+ return merged
363
+ }
364
+
365
+ /**
366
+ * Aggregate counts for the header line.
367
+ *
368
+ * `agentCount` is the number the runtime has *scheduled*, which can exceed the
369
+ * number that has emitted an entry — a fan-out reports its size before its
370
+ * agents start, so the total does not visibly climb as they trickle in.
371
+ */
372
+ export function stats(
373
+ progress: readonly WorkflowEntry[],
374
+ agentCount = 0,
375
+ ): WorkflowStats {
376
+ let seen = 0
377
+ let done = 0
378
+ let failed = 0
379
+ let started = 0
380
+ let anyLive = false
381
+
382
+ for (const entry of progress) {
383
+ if (entry.type !== "workflow_agent") continue
384
+ seen++
385
+ if (entry.state === "done") {
386
+ done++
387
+ started++
388
+ } else if (entry.state === "error") {
389
+ failed++
390
+ started++
391
+ } else {
392
+ anyLive = true
393
+ // Counted as started unless it is provably still waiting for a slot.
394
+ if (entry.startedAt !== undefined || entry.queuedAt === undefined)
395
+ started++
396
+ }
397
+ }
398
+
399
+ const total = Math.max(agentCount, seen)
400
+ return {
401
+ done,
402
+ failedCount: failed,
403
+ running: anyLive,
404
+ total,
405
+ started,
406
+ // `!anyLive` is implied by the count test — `total >= seen`, and `seen` also
407
+ // counts live entries, so `done + failed >= total` can only hold when none
408
+ // are live. Kept for parity with Claude Code and as a guard should `total`
409
+ // ever stop deriving from `seen`; no test can reach it as written.
410
+ complete: !anyLive && seen > 0 && done + failed >= total,
411
+ }
412
+ }
413
+
414
+ /** Elapsed run time, excluding any time spent paused. */
415
+ export function elapsedMs(
416
+ task: { startTime: number; endTime?: number; totalPausedMs?: number },
417
+ now: number,
418
+ ): number {
419
+ return Math.max(
420
+ 0,
421
+ (task.endTime ?? now) - task.startTime - (task.totalPausedMs ?? 0),
422
+ )
423
+ }
424
+
425
+ const plural = (n: number, word: string) => (n === 1 ? word : `${word}s`)
426
+
427
+ /** `1m12s` / `9s` / `340ms`, matching how the rest of the extension reads. */
428
+ export function formatDuration(ms: number): string {
429
+ if (ms < 1000) return `${Math.max(0, Math.round(ms))}ms`
430
+ const totalSeconds = Math.round(ms / 1000)
431
+ const minutes = Math.floor(totalSeconds / 60)
432
+ const seconds = totalSeconds % 60
433
+ if (minutes === 0) return `${seconds}s`
434
+ return `${minutes}m${seconds.toString().padStart(2, "0")}s`
435
+ }
436
+
437
+ export interface WorkflowHeader {
438
+ name: string
439
+ subtext: string
440
+ stats: string
441
+ }
442
+
443
+ /**
444
+ * The one-line summary above the tree: `3/7 agents · 1m12s`, plus a terminal
445
+ * suffix once the run stops. Deliberately carries no phase count.
446
+ */
447
+ export function header(
448
+ task: {
449
+ status: WorkflowRunStatus
450
+ workflowName?: string
451
+ summary?: string
452
+ description?: string
453
+ startTime: number
454
+ endTime?: number
455
+ totalPausedMs?: number
456
+ },
457
+ meta: WorkflowMeta | undefined,
458
+ groups: readonly PhaseGroup[],
459
+ agentCount: number,
460
+ now: number,
461
+ ): WorkflowHeader {
462
+ const suffix =
463
+ task.status === "completed"
464
+ ? " · done"
465
+ : task.status === "killed"
466
+ ? " · stopped"
467
+ : task.status === "paused"
468
+ ? " · paused"
469
+ : task.status === "failed"
470
+ ? " · failed"
471
+ : ""
472
+
473
+ let doneAgents = 0
474
+ let totalAgents = 0
475
+ for (const group of groups) {
476
+ doneAgents += group.doneCount
477
+ totalAgents += group.totalCount
478
+ }
479
+ totalAgents = Math.max(agentCount, totalAgents, doneAgents)
480
+
481
+ return {
482
+ name:
483
+ task.workflowName ??
484
+ meta?.name ??
485
+ task.summary ??
486
+ task.description ??
487
+ "workflow",
488
+ subtext: meta?.description ?? task.description ?? task.summary ?? "",
489
+ stats: `${doneAgents}/${totalAgents} ${plural(totalAgents, "agent")} · ${formatDuration(elapsedMs(task, now))}${suffix}`,
490
+ }
491
+ }
492
+
493
+ /* ------------------------------------------------------------------------- *
494
+ * Size warning
495
+ * ------------------------------------------------------------------------- */
496
+
497
+ export const DEFAULT_AGENT_CAP = 25
498
+ export const DEFAULT_TOKEN_CAP = 1_500_000
499
+ /** Assumed spend per agent before any has reported, for the projection. */
500
+ export const ASSUMED_TOKENS_PER_AGENT = 70_000
501
+
502
+ export interface SizeWarning {
503
+ axis: "agents" | "tokens" | "both"
504
+ scheduledAgents: number
505
+ totalTokens: number
506
+ projectedTokens: number
507
+ agentCap: number
508
+ tokenCap: number
509
+ }
510
+
511
+ /**
512
+ * Warn when a run is about to get expensive.
513
+ *
514
+ * The projection matters more than the current total: a 200-agent fan-out is
515
+ * worth flagging at agent 3, not after it has already spent the budget.
516
+ */
517
+ export function sizeWarning(input: {
518
+ scheduledAgents: number
519
+ startedAgents: number
520
+ totalTokens: number
521
+ agentCap?: number
522
+ tokenCap?: number
523
+ }): SizeWarning | undefined {
524
+ const agentCap = input.agentCap ?? DEFAULT_AGENT_CAP
525
+ const tokenCap = input.tokenCap ?? DEFAULT_TOKEN_CAP
526
+ const perAgent =
527
+ input.startedAgents > 0
528
+ ? input.totalTokens / input.startedAgents
529
+ : ASSUMED_TOKENS_PER_AGENT
530
+ const projectedTokens = Math.max(
531
+ input.totalTokens,
532
+ Math.round(perAgent * input.scheduledAgents),
533
+ )
534
+
535
+ const overAgents = input.scheduledAgents > agentCap
536
+ const overTokens = input.totalTokens > tokenCap || projectedTokens > tokenCap
537
+ if (!overAgents && !overTokens) return undefined
538
+
539
+ return {
540
+ axis: overAgents && overTokens ? "both" : overAgents ? "agents" : "tokens",
541
+ scheduledAgents: input.scheduledAgents,
542
+ totalTokens: input.totalTokens,
543
+ projectedTokens,
544
+ agentCap,
545
+ tokenCap,
546
+ }
547
+ }
548
+
549
+ /* ------------------------------------------------------------------------- *
550
+ * Footer phase label
551
+ * ------------------------------------------------------------------------- */
552
+
553
+ /** Words whose gerund is irregular, or which read better left alone. */
554
+ const GERUND_OVERRIDES = new Map<string, string | null>([
555
+ ["commit", "committing"],
556
+ ["submit", "submitting"],
557
+ ["format", "formatting"],
558
+ ["setup", null],
559
+ ["cleanup", null],
560
+ ])
561
+
562
+ const VOWELS = "aeiou"
563
+ const GERUND_CANDIDATE = /^[A-Za-z]{3,12}$/
564
+
565
+ /**
566
+ * Render a phase title as an activity: `Scan` → `Scanning`.
567
+ *
568
+ * Only applied in the footer, where the line reads as "what is happening now".
569
+ * Anything that is not a plain short word is left untouched.
570
+ */
571
+ export function gerund(word: string): string {
572
+ if (!GERUND_CANDIDATE.test(word)) return word
573
+ const lower = word.toLowerCase()
574
+
575
+ const override = GERUND_OVERRIDES.get(lower)
576
+ if (override !== undefined)
577
+ return override === null ? word : word[0] + override.slice(1)
578
+
579
+ if (lower.endsWith("ing")) return word
580
+ if (lower.endsWith("ie")) return `${word.slice(0, -2)}ying`
581
+ if (lower.endsWith("e") && !lower.endsWith("ee") && !lower.endsWith("ye"))
582
+ return `${word.slice(0, -1)}ing`
583
+
584
+ // Short consonant-vowel-consonant words double the final consonant: run →
585
+ // running. `w`, `x` and `y` never double.
586
+ const last = lower.at(-1) ?? ""
587
+ if (
588
+ lower.length <= 4 &&
589
+ !VOWELS.includes(lower.at(-3) ?? "") &&
590
+ VOWELS.includes(lower.at(-2) ?? "") &&
591
+ !VOWELS.includes(last) &&
592
+ !"wxy".includes(last)
593
+ ) {
594
+ return `${word}${last}ing`
595
+ }
596
+ return `${word}ing`
597
+ }
598
+
599
+ /** Truncation width for a footer phase title. */
600
+ const FOOTER_TITLE_WIDTH = 16
601
+
602
+ const truncate = (text: string, width: number) =>
603
+ text.length <= width ? text : `${text.slice(0, Math.max(1, width - 1))}…`
604
+
605
+ /**
606
+ * The footer's "what is this run doing" label.
607
+ *
608
+ * One active phase shows its position; two concurrent phases are joined, since
609
+ * a barrier-free pipeline routinely has work in more than one at a time.
610
+ */
611
+ export function footerPhaseLabel(input: {
612
+ titles: readonly string[]
613
+ positionStart: number
614
+ totalPhases: number
615
+ }): string {
616
+ const titles = input.titles.map(gerund)
617
+ if (titles.length === 0) return ""
618
+ if (titles.length === 1) {
619
+ return `${truncate(titles[0], FOOTER_TITLE_WIDTH)} (${input.positionStart}/${input.totalPhases})`
620
+ }
621
+ return titles.map((t) => truncate(t, FOOTER_TITLE_WIDTH)).join(" & ")
622
+ }