@herbertgao/pi-subagents 0.17.0 → 0.18.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.
- package/CHANGELOG.md +12 -0
- package/README.md +427 -120
- package/docs/rpc.md +184 -0
- package/docs/workflows.md +466 -0
- package/examples/agent-tool-description.md +6 -6
- package/examples/workflows/compose.js +52 -0
- package/examples/workflows/fan-out-audit.js +56 -0
- package/examples/workflows/gated-fix.js +60 -0
- package/examples/workflows/lib/count-child.js +30 -0
- package/examples/workflows/review-panel.js +68 -0
- package/examples/workflows/structured-findings.js +81 -0
- package/package.json +12 -9
- package/src/agent-file-toggle.ts +52 -12
- package/src/agent-manager.ts +837 -146
- package/src/agent-runner.ts +213 -39
- package/src/cross-extension-rpc.ts +73 -14
- package/src/custom-agents.ts +101 -47
- package/src/index.ts +2249 -914
- package/src/invocation-config.ts +13 -0
- package/src/mention-clone.ts +215 -0
- package/src/mention.ts +147 -0
- package/src/model-resolver.ts +9 -1
- package/src/nested-tools.ts +40 -26
- package/src/output-file.ts +18 -8
- package/src/prompts.ts +46 -9
- package/src/schedule.ts +21 -16
- package/src/settings.ts +137 -7
- package/src/structured-output.ts +136 -0
- package/src/types.ts +126 -8
- package/src/ui/agent-mention.ts +274 -0
- package/src/ui/agent-widget.ts +20 -5
- package/src/ui/conversation-viewer.ts +14 -1
- package/src/ui/fleet-list.ts +167 -22
- package/src/ui/workflow-card.ts +555 -0
- package/src/ui/workflow-dialog.ts +1304 -0
- package/src/ui/workflow-menu.ts +226 -0
- package/src/workflow/collisions.ts +122 -0
- package/src/workflow/entry.ts +47 -0
- package/src/workflow/host.ts +463 -0
- package/src/workflow/journal.ts +164 -0
- package/src/workflow/json-schema.ts +142 -0
- package/src/workflow/meta.ts +401 -0
- package/src/workflow/progress.ts +622 -0
- package/src/workflow/runtime.ts +1399 -0
- package/src/workflow/saved.ts +230 -0
- package/src/workflow/task.ts +333 -0
- package/src/workflow/tool-description.ts +200 -0
- package/src/workflow/worker-source.ts +781 -0
- package/src/worktree.ts +97 -95
- 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
|
+
}
|