@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,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
|
+
}
|
package/src/ui/agent-widget.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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(
|
|
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,8 +156,21 @@ function capResult(text: string): { text: string; elided: number } {
|
|
|
156
156
|
}
|
|
157
157
|
}
|
|
158
158
|
|
|
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
|
|
169
|
+
return `${value.toFixed(1).replace(/\.0$/, "")}${thousands ? "k" : "M"}`
|
|
170
|
+
}
|
|
171
|
+
|
|
159
172
|
function truncationNote(elided: number): string {
|
|
160
|
-
return `... (truncated, ${elided} more character${elided === 1 ? "" : "s"})`
|
|
173
|
+
return `... (truncated, ${humanCount(elided)} more character${elided === 1 ? "" : "s"})`
|
|
161
174
|
}
|
|
162
175
|
|
|
163
176
|
export class ConversationViewer implements Component {
|
package/src/ui/fleet-list.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
239
|
+
/** Re-register/refresh the below-editor widget; clears it when nothing remains. */
|
|
193
240
|
update(): void {
|
|
194
241
|
if (!this.ui) return
|
|
195
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
|
441
|
-
if (
|
|
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,
|
|
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
|
|
455
|
-
const visible = Math.min(MAX_AGENT_ROWS,
|
|
456
|
-
const
|
|
457
|
-
const start =
|
|
458
|
-
const hiddenBelow =
|
|
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
|
-
|
|
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,
|