@herbertgao/pi-subagents 0.17.1 → 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.
Files changed (50) hide show
  1. package/CHANGELOG.md +6 -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 +11 -9
  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,274 @@
1
+ /**
2
+ * agent-mention.ts — what `@` can address, and the suggestions pi renders for it.
3
+ *
4
+ * A subagent is addressable whether or not it is currently running: a live
5
+ * record is messaged or resumed, an evicted one whose session is still on disk
6
+ * is reopened, and an agent *type* with no instance at all is started. That is
7
+ * the point of the handle — `@explore` means the Explore agent, not "the
8
+ * Explore process that happens to exist right now" — so the roster below unions
9
+ * all three, and the dispatcher and the popup read the same list.
10
+ *
11
+ * Rows are per *agent*, not per handle. An agent given a `name` holds two names
12
+ * (its alias and its type-derived handle) and both resolve, but it lists once,
13
+ * under the alias, with its type moved into the description so the row still
14
+ * says what it is.
15
+ *
16
+ * pi's `CombinedAutocompleteProvider` already owns `@`, where it means "attach a
17
+ * file". Extensions can wrap it (`ctx.ui.addAutocompleteProvider`), so this
18
+ * provider adds the `@` tokens that name an agent and delegates everything else
19
+ * — including all of `applyCompletion`, whose `@`-branch already inserts
20
+ * `item.value` plus a trailing space, which is exactly what a handle needs.
21
+ *
22
+ * Matching mirrors Claude Code: case-insensitive prefix, not fuzzy. What it does
23
+ * NOT mirror is Claude Code dropping files whenever an agent matches. Here `@` is
24
+ * pi's file picker first, and the handles are additive, so a token matching both
25
+ * lists both — agents first. Suppressing on any match sounds narrow and is not:
26
+ * an empty token prefix-matches every handle, so a bare `@` — the gesture people
27
+ * use to browse files — would offer no files at all, and a single letter
28
+ * beginning any handle would do the same.
29
+ *
30
+ * Both halves ship under ONE `prefix`, which is sound because wherever BOTH sides
31
+ * produce rows they measured the same span. pi's `extractAtPrefix` takes the
32
+ * token after the last of `{space, tab, ", ', =}` and keeps it only if it starts
33
+ * with `@`; `MENTION_TRIGGER` matches `@[\w-]*` at the cursor, after start-of-line
34
+ * or `[\s。、?!]`. Where those two disagree, exactly one side answers and there
35
+ * is nothing to merge: `@src/index.ts` and `@"my file` are pi's alone (no handle
36
+ * matches), `=@ex` is pi's alone (`=` is a delimiter to pi, not a boundary to us),
37
+ * and `。@ex` is ours alone (the reverse). A merged response therefore never
38
+ * carries a prefix from one side and an item from the other.
39
+ *
40
+ * Offering never-started types is a deliberate step beyond Claude Code, whose
41
+ * registry holds only live tasks, so an agent you had not launched yet was
42
+ * unaddressable.
43
+ */
44
+
45
+ import type {
46
+ AutocompleteItem,
47
+ AutocompleteProvider,
48
+ AutocompleteSuggestions,
49
+ } from "@earendil-works/pi-tui"
50
+ import type { AgentManager } from "../agent-manager.js"
51
+ import { handleBase, MENTION_TRIGGER } from "../mention.js"
52
+ import type { AgentRecord, AgentTombstone } from "../types.js"
53
+
54
+ /**
55
+ * One thing `@` can address, and what sending to it will do. `typeLabel` is the
56
+ * agent's `display_name`, resolved by the caller: this module stays independent
57
+ * of the type registry, but the popup must agree with FleetView and the widget,
58
+ * which both render the label rather than the raw type.
59
+ */
60
+ export type MentionTarget =
61
+ | { kind: "record"; handle: string; record: AgentRecord; typeLabel: string }
62
+ | {
63
+ kind: "tombstone"
64
+ handle: string
65
+ entry: AgentTombstone
66
+ typeLabel: string
67
+ }
68
+ | { kind: "type"; handle: string; type: string; description: string }
69
+
70
+ /** The registry facts the roster needs, so it stays independent of agent-types. */
71
+ export type TypeInfo = { name: string; description: string }
72
+
73
+ /**
74
+ * Everything `@` can reach, in the order the popup lists it: steerable agents
75
+ * first, then the other live ones earliest-launched, then agent types with no
76
+ * live instance. A type whose handle a record already holds is omitted — that
77
+ * name addresses the existing agent, which is what makes `@explore` mean
78
+ * "message the one that's running" and only otherwise "start one".
79
+ */
80
+ export function mentionRoster(
81
+ manager: AgentManager,
82
+ types: readonly TypeInfo[],
83
+ // Identity by default: a caller with no registry to consult gets the raw
84
+ // type, which is also what `getConfig` falls back to when no label is set.
85
+ displayNameOf: (type: string) => string = (type) => type,
86
+ ): MentionTarget[] {
87
+ const live = (r: AgentRecord) =>
88
+ r.status === "running" || r.status === "queued"
89
+ const records = manager
90
+ .listAgents()
91
+ .filter((r) => r.handle !== undefined && r.parentAgentId === undefined)
92
+ .sort(
93
+ (a, b) => Number(live(b)) - Number(live(a)) || a.startedAt - b.startedAt,
94
+ )
95
+
96
+ const taken = new Set<string>()
97
+ const targets: MentionTarget[] = []
98
+
99
+ // One row per agent, not per handle. An aliased agent lists under its alias
100
+ // only — both names resolve, but showing two rows for one agent reads as two
101
+ // agents. The type handle stays addressable whether or not it is listed.
102
+ for (const record of records) {
103
+ const handle = record.alias ?? record.handle!
104
+ taken.add(handle.toLowerCase())
105
+ if (record.handle) taken.add(record.handle.toLowerCase())
106
+ targets.push({
107
+ kind: "record",
108
+ handle,
109
+ record,
110
+ typeLabel: displayNameOf(record.type),
111
+ })
112
+ }
113
+
114
+ // Then agents that are gone but whose conversation can be reopened. After the
115
+ // live ones: a running agent is the likelier target, and this keeps the
116
+ // ordering "what exists now, then what can be brought back, then what can be
117
+ // started".
118
+ for (const entry of manager.listTombstones()) {
119
+ const handle = entry.alias ?? entry.handle
120
+ if (taken.has(handle.toLowerCase())) continue
121
+ taken.add(handle.toLowerCase())
122
+ taken.add(entry.handle.toLowerCase())
123
+ targets.push({
124
+ kind: "tombstone",
125
+ handle,
126
+ entry,
127
+ typeLabel: displayNameOf(entry.type),
128
+ })
129
+ }
130
+
131
+ for (const type of types) {
132
+ const handle = handleBase(type.name)
133
+ if (taken.has(handle)) continue
134
+ taken.add(handle)
135
+ targets.push({
136
+ kind: "type",
137
+ handle,
138
+ type: type.name,
139
+ description: type.description,
140
+ })
141
+ }
142
+ return targets
143
+ }
144
+
145
+ export function createMentionProvider(
146
+ current: AutocompleteProvider,
147
+ roster: () => MentionTarget[],
148
+ isEnabled: () => boolean,
149
+ ): AutocompleteProvider {
150
+ // One warning per provider, not per keystroke: `getSuggestions` runs on every
151
+ // character typed after `@`, so an unguarded log would bury the terminal in
152
+ // the time it takes to finish a word.
153
+ let warnedInnerFailure = false
154
+ return {
155
+ // Only `@` — the contract is "characters that should naturally trigger
156
+ // THIS provider", and pi unions each wrapper's own set onto the outermost
157
+ // one itself (interactive-mode.js:432), so re-declaring the wrapped
158
+ // provider's characters here would both misreport us and duplicate that.
159
+ triggerCharacters: ["@"],
160
+
161
+ async getSuggestions(
162
+ lines,
163
+ cursorLine,
164
+ cursorCol,
165
+ options,
166
+ ): Promise<AutocompleteSuggestions | null> {
167
+ const mine = isEnabled()
168
+ ? mentionItems(roster(), lines[cursorLine] ?? "", cursorCol)
169
+ : null
170
+ // Asked unconditionally: pi owns `@` and must keep answering for it even
171
+ // when a handle matches too. That is the same work vanilla pi does on any
172
+ // `@` keystroke — a capped `fd` search, or nothing at all when the host
173
+ // configured no `fd` path — but we now do it on tokens we used to answer
174
+ // alone, so it must not be able to take the popup down with it. The
175
+ // wrapped provider is not always pi's: another extension may sit inside
176
+ // us, and before this it was never called for a token naming an agent.
177
+ // try/catch, not `.catch()`: a provider that throws SYNCHRONOUSLY never
178
+ // returns the promise a `.catch()` would attach to, and the throw escapes
179
+ // this method as a rejection — which pi does not handle either
180
+ // (components/editor.js:1892 awaits with no catch of its own).
181
+ let theirs: AutocompleteSuggestions | null = null
182
+ try {
183
+ theirs = await current.getSuggestions(
184
+ lines,
185
+ cursorLine,
186
+ cursorCol,
187
+ options,
188
+ )
189
+ } catch (err) {
190
+ // Safe to treat as "no files": pi discards any response whose request is
191
+ // no longer current, so an aborted search that surfaces as a rejection
192
+ // cannot leave a stale popup behind (`isAutocompleteRequestCurrent`).
193
+ // Warned rather than swallowed outright — the failure is invisible in
194
+ // the popup, and the same `console.warn` channel already carries this
195
+ // extension's other non-fatal failures.
196
+ if (!warnedInnerFailure) {
197
+ warnedInnerFailure = true
198
+ console.warn(
199
+ "[pi-subagents] the autocomplete provider below us failed; showing agent rows only:",
200
+ err,
201
+ )
202
+ }
203
+ theirs = null
204
+ }
205
+ if (!mine) return theirs
206
+ if (!theirs) return mine
207
+ // Agents first: there are a handful of them against pi's 20 file rows, and
208
+ // a handle buried under fuzzy path matches is a handle nobody finds. The
209
+ // prefix is ours by the span argument in the header — identical to pi's
210
+ // whenever both sides have something to say.
211
+ return { items: [...mine.items, ...theirs.items], prefix: mine.prefix }
212
+ },
213
+
214
+ applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
215
+ return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix)
216
+ },
217
+
218
+ shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
219
+ return (
220
+ current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ??
221
+ true
222
+ )
223
+ },
224
+ }
225
+ }
226
+
227
+ /** Suggestions for the `@…` token under the cursor, or null when it names no agent. */
228
+ function mentionItems(
229
+ roster: MentionTarget[],
230
+ line: string,
231
+ cursorCol: number,
232
+ ): AutocompleteSuggestions | null {
233
+ const match = MENTION_TRIGGER.exec(line.slice(0, cursorCol))
234
+ if (!match) return null
235
+
236
+ const typed = match[2].toLowerCase()
237
+ const items: AutocompleteItem[] = []
238
+ for (const target of roster) {
239
+ if (!target.handle.toLowerCase().startsWith(typed)) continue
240
+ items.push({
241
+ value: `@${target.handle}`,
242
+ label: `@${target.handle}`,
243
+ description: describeTarget(target),
244
+ })
245
+ }
246
+ return items.length > 0 ? { items, prefix: `@${match[2]}` } : null
247
+ }
248
+
249
+ /** Name the action that will actually happen, so the list never mispromises. */
250
+ function describeTarget(target: MentionTarget): string {
251
+ if (target.kind === "type")
252
+ return `start agent · ${summarize(target.description)}`
253
+ if (target.kind === "tombstone") {
254
+ // No status: the record is gone, and "completed" would imply one is still
255
+ // being tracked. The type carries the identity the handle may not.
256
+ return `resume · ${target.typeLabel} · ${target.entry.description}`
257
+ }
258
+ const { status, description, alias } = target.record
259
+ const action =
260
+ status === "running" || status === "queued" ? "send message" : "resume"
261
+ // A row listed under its alias has lost the type its handle would have shown,
262
+ // so name it — `@auth-audit` alone says nothing about what the agent is.
263
+ // A type-derived row already reads as its type and would just repeat itself.
264
+ const identity = alias ? `${target.typeLabel} · ` : ""
265
+ return `${action} · ${identity}${status} · ${description}`
266
+ }
267
+
268
+ /** First sentence of an agent description, clipped — these run to paragraphs. */
269
+ function summarize(description: string): string {
270
+ const first = (description.match(/^.*?[.!?](?=\s|$)/s)?.[0] ?? description)
271
+ .replace(/\s+/g, " ")
272
+ .trim()
273
+ return first.length > 60 ? `${first.slice(0, 59).trimEnd()}…` : first
274
+ }
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { truncateToWidth } from "@earendil-works/pi-tui"
9
9
  import { renderAgentName } from "../agent-color.js"
10
- import type { AgentManager } from "../agent-manager.js"
10
+ import { type AgentManager, isTopLevelAgent } from "../agent-manager.js"
11
11
  import { getConfig } from "../agent-types.js"
12
12
  import type { AgentInvocation, SubagentType, WidgetMode } from "../types.js"
13
13
  import {
@@ -99,7 +99,7 @@ export interface AgentDetails {
99
99
  activity?: string
100
100
  /** Current spinner frame index (for animated running indicator). */
101
101
  spinnerFrame?: number
102
- /** Short model name if different from parent (e.g. "haiku", "sonnet"). */
102
+ /** Short label for the model the run used, e.g. "haiku 4.5". */
103
103
  modelName?: string
104
104
  /** Notable config tags (e.g. ["thinking: high", "isolated"]). */
105
105
  tags?: string[]
@@ -223,7 +223,13 @@ export function getPromptModeLabel(type: SubagentType): string | undefined {
223
223
  return config.promptMode === "append" ? "twin" : undefined
224
224
  }
225
225
 
226
- /** Mode label is not included — callers add it where they want it. */
226
+ /**
227
+ * Mode label is not included — callers add it where they want it.
228
+ *
229
+ * Both model forms come back so each surface can pick by width; the
230
+ * "(asked X)" annotation is applied here rather than by callers, so a value the
231
+ * spawn did not honor cannot be rendered as though it had been (#182).
232
+ */
227
233
  export function buildInvocationTags(invocation: AgentInvocation | undefined): {
228
234
  modelName?: string
229
235
  modelId?: string
@@ -330,7 +336,13 @@ export class AgentWidget {
330
336
  * supplies the user's `showCost` setting.
331
337
  */
332
338
  private showCost: () => boolean = () => false,
333
- /** Whether running rows show the model and thinking level. */
339
+ /**
340
+ * Read live at render time, like `mode`. Whether running agents name the
341
+ * model driving them and the thinking level it is running at. Defaults to
342
+ * off — the extension supplies the user's `showModel` setting — because the
343
+ * row is already dense and the same pair is on the tool result and in the
344
+ * conversation viewer unconditionally.
345
+ */
334
346
  private showModel: () => boolean = () => false,
335
347
  ) {}
336
348
 
@@ -346,7 +358,7 @@ export class AgentWidget {
346
358
  * - `all`: every agent.
347
359
  */
348
360
  private widgetAgents() {
349
- const all = this.manager.listAgents().filter((a) => !a.parentAgentId)
361
+ const all = this.manager.listAgents().filter(isTopLevelAgent)
350
362
  switch (this.mode()) {
351
363
  case "off":
352
364
  return []
@@ -542,6 +554,9 @@ export class AgentWidget {
542
554
 
543
555
  const parts: string[] = []
544
556
  if (this.showModel()) {
557
+ // Leading, and paired: a thinking level means nothing without the model
558
+ // it applies to. The tag is taken from buildInvocationTags rather than
559
+ // rebuilt so the "(asked X)" annotation survives.
545
560
  const { modelName, tags } = buildInvocationTags(a.invocation)
546
561
  if (modelName) parts.push(modelName)
547
562
  const thinkingTag = tags.find((tag) => tag.startsWith("thinking: "))
@@ -156,10 +156,16 @@ function capResult(text: string): { text: string; elided: number } {
156
156
  }
157
157
  }
158
158
 
159
- function humanCount(count: number): string {
160
- if (count < 1_000) return `${count}`
161
- const thousands = count < 999_950
162
- const value = thousands ? count / 1_000 : count / 1_000_000
159
+ /**
160
+ * `999` · `1.5k` · `8.4M` — a magnitude cue, not an exact count, past 1000.
161
+ *
162
+ * The bracket is chosen against the *rounded* value, so 999,999 reads `1M`
163
+ * rather than the `1000.0k` a naive `< 1e6` test produces.
164
+ */
165
+ function humanCount(n: number): string {
166
+ if (n < 1_000) return `${n}`
167
+ const thousands = n < 999_950
168
+ const value = thousands ? n / 1_000 : n / 1_000_000
163
169
  return `${value.toFixed(1).replace(/\.0$/, "")}${thousands ? "k" : "M"}`
164
170
  }
165
171
 
@@ -20,8 +20,8 @@ import {
20
20
  visibleWidth,
21
21
  } from "@earendil-works/pi-tui"
22
22
  import { hasAgentBadge, renderAgentName } from "../agent-color.js"
23
- import type { AgentManager } from "../agent-manager.js"
24
- import type { AgentRecord } from "../types.js"
23
+ import { type AgentManager, isTopLevelAgent } from "../agent-manager.js"
24
+ import type { AgentRecord, ViewerMarkdownMode } from "../types.js"
25
25
  import { getLifetimeCost, getLifetimeTotal } from "../usage.js"
26
26
  import { type AgentActivity, formatCost, type Theme } from "./agent-widget.js"
27
27
  import {
@@ -78,9 +78,30 @@ export type FleetUICtx = {
78
78
  ): Promise<T>
79
79
  }
80
80
 
81
+ /**
82
+ * A workflow run, as the fleet list needs to see it.
83
+ *
84
+ * Narrow on purpose: the list knows nothing about `WorkflowTask`, the runtime
85
+ * or the dialog, so it stays as testable as it was when it only held agents.
86
+ * The extension maps its tasks into this shape and injects an opener.
87
+ */
88
+ export interface FleetWorkflow {
89
+ id: string
90
+ /** The `meta.name` of the run, or its id when the script named nothing. */
91
+ name: string
92
+ status: "running" | "completed" | "failed" | "killed" | "paused"
93
+ doneCount: number
94
+ totalCount: number
95
+ startedAt: number
96
+ /** Set once the run settles, which is what freezes its clock. */
97
+ completedAt?: number
98
+ tokens: number
99
+ }
100
+
81
101
  type MainEntry = { kind: "main" }
82
102
  type AgentEntry = { kind: "agent"; record: AgentRecord }
83
- type FleetEntry = MainEntry | AgentEntry
103
+ type WorkflowEntry = { kind: "workflow"; workflow: FleetWorkflow }
104
+ type FleetEntry = MainEntry | WorkflowEntry | AgentEntry
84
105
 
85
106
  /** `11s` — integer seconds, no decimal/suffix (matches Claude Code, unlike formatMs). */
86
107
  export function formatFleetElapsed(ms: number): string {
@@ -124,6 +145,17 @@ export class FleetList {
124
145
  /** Set while a conversation overlay is open; calling it closes the overlay. */
125
146
  private viewerClose: (() => void) | undefined
126
147
  private viewingAgentId: string | undefined
148
+ /** Injected by the extension; absent until workflows are wired (or at all). */
149
+ private workflowSource: (() => readonly FleetWorkflow[]) | undefined
150
+ private openWorkflow: ((id: string) => Promise<void> | void) | undefined
151
+ /**
152
+ * Set while the workflow inspector is up.
153
+ *
154
+ * It does the two jobs `viewerClose` does for an agent's overlay — keep the
155
+ * list out of the dialog's keys, and remember which row to come back to —
156
+ * minus the close handle, because that overlay belongs to the extension.
157
+ */
158
+ private viewingWorkflowId: string | undefined
127
159
 
128
160
  constructor(
129
161
  private manager: AgentManager,
@@ -134,6 +166,18 @@ export class FleetList {
134
166
  * `showCost` setting.
135
167
  */
136
168
  private showCost: () => boolean = () => false,
169
+ /**
170
+ * The user's `viewerMarkdown` setting, for a conversation overlay opened
171
+ * from here. Read live rather than captured, because the viewer's `m` key
172
+ * changes it while the overlay is up. Omitted → the viewer's own default.
173
+ */
174
+ private viewerMarkdown?: () => ViewerMarkdownMode,
175
+ /**
176
+ * Persist a mode chosen with `m` in that overlay, so the key means the same
177
+ * thing here as it does from `/agents` — one setting, not one per entry
178
+ * point. Omitted → `m` still cycles, viewer-locally.
179
+ */
180
+ private onViewerMarkdown?: (mode: ViewerMarkdownMode) => void,
137
181
  ) {}
138
182
 
139
183
  // ---- Lifecycle ----
@@ -180,6 +224,9 @@ export class FleetList {
180
224
  this.viewerClose = undefined
181
225
  }
182
226
  this.viewingAgentId = undefined
227
+ // No handle to close the workflow inspector with, but the list is going
228
+ // away — leaving the id set would keep it swallowing input forever.
229
+ this.viewingWorkflowId = undefined
183
230
  if (this.ui && this.widgetRegistered)
184
231
  this.ui.setWidget(FLEET_KEY, undefined)
185
232
  this.widgetRegistered = false
@@ -189,12 +236,16 @@ export class FleetList {
189
236
  this.ui = undefined
190
237
  }
191
238
 
192
- /** Re-register/refresh the below-editor widget; clears it when no agents remain. */
239
+ /** Re-register/refresh the below-editor widget; clears it when nothing remains. */
193
240
  update(): void {
194
241
  if (!this.ui) return
195
- const hasAgents = this.enabled && this.agentRecords().length > 0
242
+ // A run with no agents of its own left in the list is still worth a row —
243
+ // it is the thing the user opens to see what its children did. Read off the
244
+ // roster for the same reason activation does: two counts of "is there
245
+ // anything here" drifted apart once before.
246
+ const hasRows = this.enabled && this.roster().length > 1
196
247
 
197
- if (!hasAgents) {
248
+ if (!hasRows) {
198
249
  if (this.widgetRegistered) {
199
250
  this.ui.setWidget(FLEET_KEY, undefined)
200
251
  this.widgetRegistered = false
@@ -249,7 +300,7 @@ export class FleetList {
249
300
  .listAgents()
250
301
  .filter(
251
302
  (a) =>
252
- !a.parentAgentId &&
303
+ isTopLevelAgent(a) &&
253
304
  a.session &&
254
305
  (a.status === "running" ||
255
306
  a.status === "queued" ||
@@ -260,9 +311,49 @@ export class FleetList {
260
311
  .sort((a, b) => a.startedAt - b.startedAt)
261
312
  }
262
313
 
314
+ /**
315
+ * Wire workflow runs into the list.
316
+ *
317
+ * Injected rather than constructed here because the fleet list predates
318
+ * workflows and must keep working without them — a session with the feature
319
+ * switched off never calls this, and the roster is agents-only exactly as
320
+ * before.
321
+ */
322
+ setWorkflowSource(
323
+ source: () => readonly FleetWorkflow[],
324
+ open: (id: string) => Promise<void> | void,
325
+ ): void {
326
+ this.workflowSource = source
327
+ this.openWorkflow = open
328
+ }
329
+
330
+ /** Live runs, plus recently settled ones — the same linger the agents get. */
331
+ private workflows(): FleetWorkflow[] {
332
+ if (!this.workflowSource) return []
333
+ const now = Date.now()
334
+ return [...this.workflowSource()]
335
+ .filter(
336
+ (run) =>
337
+ run.status === "running" ||
338
+ run.status === "paused" ||
339
+ (run.completedAt != null &&
340
+ now - run.completedAt < FINISHED_LINGER_MS),
341
+ )
342
+ .sort((a, b) => a.startedAt - b.startedAt)
343
+ }
344
+
345
+ /**
346
+ * Runs sit above the agents rather than interleaved by start time: a run owns
347
+ * most of the agents under it, so listing the container first is what makes
348
+ * the list read as a hierarchy rather than a shuffle.
349
+ */
263
350
  private roster(): FleetEntry[] {
264
351
  return [
265
352
  { kind: "main" },
353
+ ...this.workflows().map((workflow) => ({
354
+ kind: "workflow" as const,
355
+ workflow,
356
+ })),
266
357
  ...this.agentRecords().map((record) => ({
267
358
  kind: "agent" as const,
268
359
  record,
@@ -285,8 +376,10 @@ export class FleetList {
285
376
  // emits both, and matchesKey matches either) — act on press only, or every
286
377
  // tap would move/fire twice. Repeats still pass through for held-key nav.
287
378
  if (isKeyRelease(data)) return undefined
288
- // While an overlay is open, let it own all input.
289
- if (this.viewerClose) return undefined
379
+ // While an overlay is open, let it own all input. Checked before the focus
380
+ // test below, which would otherwise read the dialog holding the keyboard as
381
+ // "the user left the list" and reset the selection out from under it.
382
+ if (this.viewerClose || this.viewingWorkflowId) return undefined
290
383
  // Input listeners fire BEFORE the focused component, and dialogs
291
384
  // (ctx.ui.select/confirm/input, pi's own menus) swap the prompt editor out
292
385
  // while getEditorText() still reads the detached — empty — editor. So when
@@ -299,9 +392,12 @@ export class FleetList {
299
392
  if (!this.active) {
300
393
  // Activate: ↓ or ← at an empty prompt moves focus into the list.
301
394
  const isActivator = matchesKey(data, "down") || matchesKey(data, "left")
395
+ // Gated on the roster, not the agents: a session whose only row is a
396
+ // workflow run still has somewhere to go, and requiring an agent would
397
+ // render the row but refuse to move into it.
302
398
  if (
303
399
  isActivator &&
304
- this.agentRecords().length > 0 &&
400
+ this.roster().length > 1 &&
305
401
  this.ui.getEditorText() === ""
306
402
  ) {
307
403
  this.active = true
@@ -369,6 +465,17 @@ export class FleetList {
369
465
  this.deactivate()
370
466
  return
371
467
  }
468
+ if (entry.kind === "workflow") {
469
+ // The extension owns this overlay and closes it, so there is no
470
+ // `viewerClose` to hold — but the list still has to know one is up, and
471
+ // still has to put the cursor back on the run when it comes down.
472
+ this.viewingWorkflowId = entry.workflow.id
473
+ void Promise.resolve(this.openWorkflow?.(entry.workflow.id)).then(
474
+ () => this.clearViewer(),
475
+ () => this.clearViewer(),
476
+ )
477
+ return
478
+ }
372
479
  const record = entry.record
373
480
  if (!this.ui) return
374
481
  if (!record.session) {
@@ -400,6 +507,8 @@ export class FleetList {
400
507
  keybindings,
401
508
  (message: string) => this.manager.steer(record.id, message),
402
509
  this.showCost(),
510
+ this.viewerMarkdown,
511
+ this.onViewerMarkdown,
403
512
  )
404
513
  },
405
514
  {
@@ -423,25 +532,31 @@ export class FleetList {
423
532
  // still feels natural if the list reordered (an earlier agent finished)
424
533
  // while the overlay was open. If that agent is gone, leave the index for
425
534
  // update()'s clamp to settle.
426
- if (this.viewingAgentId) {
427
- const idx = this.roster().findIndex(
428
- (e) => e.kind === "agent" && e.record.id === this.viewingAgentId,
535
+ const viewed = this.viewingAgentId ?? this.viewingWorkflowId
536
+ if (viewed !== undefined) {
537
+ const idx = this.roster().findIndex((e) =>
538
+ e.kind === "agent"
539
+ ? e.record.id === viewed
540
+ : e.kind === "workflow"
541
+ ? e.workflow.id === viewed
542
+ : false,
429
543
  )
430
544
  if (idx >= 0) this.selectedIndex = idx
431
545
  }
432
546
  this.viewerClose = undefined
433
547
  this.viewingAgentId = undefined
548
+ this.viewingWorkflowId = undefined
434
549
  this.update()
435
550
  }
436
551
 
437
552
  // ---- Rendering ----
438
553
 
439
554
  private renderBar(width: number, theme: Theme): string[] {
440
- const agents = this.roster().slice(1) as AgentEntry[]
441
- if (agents.length === 0) return []
555
+ const rows = this.roster().slice(1) as (WorkflowEntry | AgentEntry)[]
556
+ if (rows.length === 0) return []
442
557
  // Clamp locally so a render between a roster shrink and the next update()
443
558
  // (e.g. on terminal resize) never loses the selection marker.
444
- const sel = Math.min(this.selectedIndex, agents.length)
559
+ const sel = Math.min(this.selectedIndex, rows.length)
445
560
 
446
561
  const hint = this.active
447
562
  ? "↑↓ select · enter view · esc back"
@@ -451,17 +566,20 @@ export class FleetList {
451
566
  lines.push("")
452
567
  lines.push(truncateToWidth(` ${this.bullet(0, sel, theme)} main`, width))
453
568
 
454
- // Window the agent rows so the selected one stays visible.
455
- const visible = Math.min(MAX_AGENT_ROWS, agents.length)
456
- const selAgent = Math.max(0, sel - 1)
457
- const start = selAgent < visible ? 0 : selAgent - visible + 1
458
- const hiddenBelow = agents.length - (start + visible)
569
+ // Window the rows so the selected one stays visible.
570
+ const visible = Math.min(MAX_AGENT_ROWS, rows.length)
571
+ const selRow = Math.max(0, sel - 1)
572
+ const start = selRow < visible ? 0 : selRow - visible + 1
573
+ const hiddenBelow = rows.length - (start + visible)
459
574
 
460
575
  if (start > 0)
461
576
  lines.push(rightAlign("", theme.fg("dim", `↑ ${start} more`), width))
462
577
  for (let a = start; a < start + visible; a++) {
578
+ const row = rows[a]
463
579
  lines.push(
464
- this.renderAgentRow(a + 1, sel, agents[a].record, width, theme),
580
+ row.kind === "workflow"
581
+ ? this.renderWorkflowRow(a + 1, sel, row.workflow, width, theme)
582
+ : this.renderAgentRow(a + 1, sel, row.record, width, theme),
465
583
  )
466
584
  }
467
585
  if (hiddenBelow > 0)
@@ -476,6 +594,33 @@ export class FleetList {
476
594
  return rosterIndex === sel ? theme.fg("accent", "●") : theme.fg("dim", "○")
477
595
  }
478
596
 
597
+ /**
598
+ * A run's row. Shaped like an agent's — bullet, kind, name, stats flush right
599
+ * — so the two read as one list, with the agent count where an agent has its
600
+ * description and the same elapsed/token tail.
601
+ */
602
+ private renderWorkflowRow(
603
+ rosterIndex: number,
604
+ sel: number,
605
+ workflow: FleetWorkflow,
606
+ width: number,
607
+ theme: Theme,
608
+ ): string {
609
+ const selected = rosterIndex === sel
610
+ const kind = theme.fg(selected ? "text" : "muted", "workflow")
611
+ const name = selected ? theme.fg("text", workflow.name) : workflow.name
612
+ const left = ` ${this.bullet(rosterIndex, sel, theme)} ${kind} ${name}`
613
+ // Frozen once the run settles, exactly as an agent's clock is.
614
+ const elapsed = (workflow.completedAt ?? Date.now()) - workflow.startedAt
615
+ const agents = `${workflow.doneCount}/${workflow.totalCount} agent${workflow.totalCount === 1 ? "" : "s"}`
616
+ const stats = `${agents} · ${formatFleetElapsed(elapsed)} · ${formatFleetTokens(workflow.tokens)}`
617
+ return rightAlign(
618
+ left,
619
+ selected ? theme.fg("text", stats) : theme.fg("dim", stats),
620
+ width,
621
+ )
622
+ }
623
+
479
624
  private renderAgentRow(
480
625
  rosterIndex: number,
481
626
  sel: number,