@ferris1225/pi-subagents 0.28.0 → 0.31.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/README.md +270 -597
- package/agents/explore.md +1 -0
- package/agents/reviewer.md +1 -0
- package/agents/worker.md +1 -1
- package/package.json +1 -1
- package/src/completion.ts +28 -0
- package/src/config.ts +25 -0
- package/src/dispatch.ts +845 -0
- package/src/format.ts +149 -0
- package/src/index.ts +90 -1386
- package/src/models.ts +13 -0
- package/src/monitor.ts +27 -0
- package/src/prompt.ts +10 -0
- package/src/runtime.ts +145 -0
- package/src/setup.ts +50 -0
- package/src/spawn.ts +189 -68
- package/src/tools.ts +409 -0
- package/src/ui.ts +8 -3
- package/src/widget.ts +182 -0
package/src/tools.ts
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lookup tools around the subagent runtime: subagent_wait (in-turn result
|
|
3
|
+
* lookup, non-blocking by default), subagent_status (overview / full result by
|
|
4
|
+
* id), and subagent_stop (cancel active runs).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
9
|
+
import { Type } from "typebox";
|
|
10
|
+
import { loadConfig } from "./config.ts";
|
|
11
|
+
import { emptyUsage, formatCompletionBlock, formatUsage, matchRunIds } from "./format.ts";
|
|
12
|
+
import {
|
|
13
|
+
formatElapsed,
|
|
14
|
+
formatUsageCompact,
|
|
15
|
+
monitor,
|
|
16
|
+
runLabel,
|
|
17
|
+
statusLabel,
|
|
18
|
+
} from "./monitor.ts";
|
|
19
|
+
import type { SubagentRuntime } from "./runtime.ts";
|
|
20
|
+
import { isFailedResult, type SingleResult } from "./spawn.ts";
|
|
21
|
+
|
|
22
|
+
/** In-turn result lookup. Dispatch already ended the turn and results arrive as
|
|
23
|
+
* wake-up messages, so the default must NOT block: a settled run returns its
|
|
24
|
+
* result immediately, a still-active run returns a "still running — end your
|
|
25
|
+
* turn" note and the model finishes (the completion then wakes it). Blocking
|
|
26
|
+
* is opt-in via an explicit timeoutMs — a long default would hold the turn
|
|
27
|
+
* hostage for nothing, since the result arrives on its own either way. */
|
|
28
|
+
const SUBAGENT_WAIT_DEFAULT_TIMEOUT_MS = 0;
|
|
29
|
+
|
|
30
|
+
function renderFirstLine(result: { content?: unknown }, label: string, theme: any): Text {
|
|
31
|
+
const parts = (result.content ?? []) as Array<{ type: string; text?: string }>;
|
|
32
|
+
const text = parts
|
|
33
|
+
.map((part) => (typeof part.text === "string" ? part.text : ""))
|
|
34
|
+
.join(" ")
|
|
35
|
+
.trim();
|
|
36
|
+
const firstLine = text.split("\n").find((line) => line.trim()) ?? "(no output)";
|
|
37
|
+
return new Text(`${theme.fg("toolTitle", theme.bold(label))}${theme.fg("dim", firstLine.slice(0, 60))}`, 0, 0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime): void {
|
|
41
|
+
const SubagentWaitParams = Type.Object({
|
|
42
|
+
id: Type.Optional(
|
|
43
|
+
Type.String({
|
|
44
|
+
description: "Run id or prefix shown in the subagent widget (#id). Omit to wait for all active runs in this session.",
|
|
45
|
+
}),
|
|
46
|
+
),
|
|
47
|
+
timeoutMs: Type.Optional(
|
|
48
|
+
Type.Number({
|
|
49
|
+
description: `Block for up to this many milliseconds and report the still-running runs. Default ${SUBAGENT_WAIT_DEFAULT_TIMEOUT_MS}: no blocking — settled runs return their result immediately, active runs return a note telling the model to end its turn.`,
|
|
50
|
+
}),
|
|
51
|
+
),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
pi.registerTool({
|
|
55
|
+
name: "subagent_wait",
|
|
56
|
+
label: "Subagent Wait",
|
|
57
|
+
description: [
|
|
58
|
+
"Look up background sub-agent run(s) and return their results.",
|
|
59
|
+
"PREFER NOT CALLING THIS: dispatching already ended your turn and results arrive as a message that wakes you automatically.",
|
|
60
|
+
"By default it does NOT block: a settled run returns its result immediately; a still-active run returns a 'still running — end your turn' note.",
|
|
61
|
+
"Pass an explicit timeoutMs ONLY when you must stay in the turn and need the result right now (sequential dependent steps).",
|
|
62
|
+
"NEVER sleep, poll, or wait with bash to get a sub-agent result: end the turn, or call this tool.",
|
|
63
|
+
"The same result is also delivered as a completion message that resumes the main agent, so you may see it twice (once here, once as a wake-up) — that is expected, not a duplicate.",
|
|
64
|
+
].join(" "),
|
|
65
|
+
promptSnippet: "Look up a background subagent result in-turn (id: run id from the widget; omit for all). Non-blocking by default; pass timeoutMs to block.",
|
|
66
|
+
promptGuidelines: [
|
|
67
|
+
"Do NOT call subagent_wait to hold the turn: results arrive as wake-up messages automatically. The default call is a non-blocking lookup — settled results return immediately, active runs return a note telling you to end your turn.",
|
|
68
|
+
"Pass an explicit timeoutMs only when you must keep the turn AND the next step depends on the result right now — e.g. the user asked you to wait for it.",
|
|
69
|
+
"Never use bash sleep/timeout/polling to wait for a sub-agent — it blocks the turn and delays result delivery.",
|
|
70
|
+
"If subagent_wait times out, end the turn and wait for the wake-up message, or call it again with a longer timeoutMs.",
|
|
71
|
+
],
|
|
72
|
+
parameters: SubagentWaitParams,
|
|
73
|
+
|
|
74
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
75
|
+
const config = await loadConfig(runtime.configPath);
|
|
76
|
+
// A non-finite or negative timeout would produce a nonsensical note
|
|
77
|
+
// ("timed out after Infinitys") or an instant "timeout" that was never
|
|
78
|
+
// asked for; fall back to the default. Zero is honored as an immediate
|
|
79
|
+
// give-up (clamped to 1ms below).
|
|
80
|
+
const timeoutMs =
|
|
81
|
+
typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs) && params.timeoutMs >= 0
|
|
82
|
+
? params.timeoutMs
|
|
83
|
+
: SUBAGENT_WAIT_DEFAULT_TIMEOUT_MS;
|
|
84
|
+
const isActive = (run: { status: string; retained?: boolean }): boolean =>
|
|
85
|
+
run.status === "queued" || run.status === "running" || run.retained === true;
|
|
86
|
+
|
|
87
|
+
const requested = params.id?.trim();
|
|
88
|
+
// A run that already settled resolves immediately with its result.
|
|
89
|
+
if (requested) {
|
|
90
|
+
const settledIds = matchRunIds([...runtime.settledRuns.keys()], requested);
|
|
91
|
+
if (settledIds.length > 0) {
|
|
92
|
+
return {
|
|
93
|
+
content: [
|
|
94
|
+
{ type: "text", text: settledIds.map((id) => formatCompletionBlock(runtime.settledRuns.get(id)!, config.maxResultLines, ctx.cwd)).join("\n\n") },
|
|
95
|
+
],
|
|
96
|
+
details: {},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const activeRuns = monitor.getRuns().filter(isActive);
|
|
102
|
+
const targetIds = requested ? matchRunIds(activeRuns.map((run) => run.id), requested) : activeRuns.map((run) => run.id);
|
|
103
|
+
const targets = activeRuns.filter((run) => targetIds.includes(run.id));
|
|
104
|
+
if (targets.length === 0) {
|
|
105
|
+
const activeList = activeRuns.map((run) => `#${run.id} ${run.agent}`).join(", ");
|
|
106
|
+
return {
|
|
107
|
+
content: [
|
|
108
|
+
{
|
|
109
|
+
type: "text",
|
|
110
|
+
text: requested
|
|
111
|
+
? `No active subagent run matches "${requested}".${activeList ? ` Active runs: ${activeList}.` : ""}`
|
|
112
|
+
: `No active subagent runs${activeList ? ` (active: ${activeList})` : " right now"}.`,
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
details: {},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const waitForRun = (runId: number): Promise<{ result?: SingleResult; note?: string }> => {
|
|
120
|
+
const already = runtime.settledRuns.get(runId);
|
|
121
|
+
if (already) return Promise.resolve({ result: already });
|
|
122
|
+
return new Promise((resolve) => {
|
|
123
|
+
let done = false;
|
|
124
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
125
|
+
let unsub: (() => void) | undefined;
|
|
126
|
+
const cleanup = (): void => {
|
|
127
|
+
if (timer) clearTimeout(timer);
|
|
128
|
+
if (unsub) unsub();
|
|
129
|
+
signal?.removeEventListener("abort", onAbort);
|
|
130
|
+
const listeners = runtime.settledListeners.get(runId);
|
|
131
|
+
if (listeners) {
|
|
132
|
+
listeners.delete(onSettled);
|
|
133
|
+
if (listeners.size === 0) runtime.settledListeners.delete(runId);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
const finish = (outcome: { result?: SingleResult; note?: string }): void => {
|
|
137
|
+
if (done) return;
|
|
138
|
+
done = true;
|
|
139
|
+
cleanup();
|
|
140
|
+
resolve(outcome);
|
|
141
|
+
};
|
|
142
|
+
const onSettled = (result: SingleResult): void => finish({ result });
|
|
143
|
+
const onMonitor = (): void => {
|
|
144
|
+
const current = runtime.settledRuns.get(runId);
|
|
145
|
+
if (current) {
|
|
146
|
+
finish({ result: current });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (!monitor.findRun(runId)) {
|
|
150
|
+
// Removal is followed synchronously by registerRunResult in the
|
|
151
|
+
// finishing task; re-check on the next tick so the result wins.
|
|
152
|
+
setTimeout(() => {
|
|
153
|
+
const late = runtime.settledRuns.get(runId);
|
|
154
|
+
if (late) finish({ result: late });
|
|
155
|
+
else finish({ note: `run #${runId} was removed before its result was recorded (cancelled or session ended)` });
|
|
156
|
+
}, 0);
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
const onAbort = (): void => finish({ note: "wait aborted" });
|
|
160
|
+
let listeners = runtime.settledListeners.get(runId);
|
|
161
|
+
if (!listeners) {
|
|
162
|
+
listeners = new Set();
|
|
163
|
+
runtime.settledListeners.set(runId, listeners);
|
|
164
|
+
}
|
|
165
|
+
listeners.add(onSettled);
|
|
166
|
+
unsub = monitor.subscribe(onMonitor);
|
|
167
|
+
timer = setTimeout(
|
|
168
|
+
() =>
|
|
169
|
+
finish({
|
|
170
|
+
note:
|
|
171
|
+
timeoutMs === 0
|
|
172
|
+
? `run #${runId} is still active — end your turn: the result will wake you (or call subagent_wait again with an explicit timeoutMs to block)`
|
|
173
|
+
: `wait timed out after ${Math.round(timeoutMs / 1000)}s — run #${runId} is still active; call subagent_wait again or end the turn (the result will wake you when ready)`,
|
|
174
|
+
}),
|
|
175
|
+
Math.max(1, timeoutMs),
|
|
176
|
+
);
|
|
177
|
+
if (signal?.aborted) onAbort();
|
|
178
|
+
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
179
|
+
});
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const outcomes = await Promise.all(targets.map((run) => waitForRun(run.id)));
|
|
183
|
+
const blocks = outcomes.map((outcome) =>
|
|
184
|
+
outcome.result ? formatCompletionBlock(outcome.result, config.maxResultLines, ctx.cwd) : (outcome.note ?? "(no outcome)"),
|
|
185
|
+
);
|
|
186
|
+
return { content: [{ type: "text", text: blocks.join("\n\n") }], details: {} };
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
renderCall(args, theme) {
|
|
190
|
+
const target = args.id ? `#${args.id}` : "all";
|
|
191
|
+
return new Text(`${theme.fg("toolTitle", theme.bold("subagent_wait "))}${theme.fg("accent", target)}`, 0, 0);
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
renderResult(result, _options, theme) {
|
|
195
|
+
return renderFirstLine(result, "subagent_wait ", theme);
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// Status overview: what is running right now and what finished this session,
|
|
200
|
+
// with per-run details (id, agent, model, usage, elapsed, activity) so the
|
|
201
|
+
// main agent can decide whether to wait, stop, or re-dispatch. Learned from
|
|
202
|
+
// nicobailon/pi-subagents ({action:"status"} + status files): inspect before
|
|
203
|
+
// you act, and report run ids when handing off.
|
|
204
|
+
const SubagentStatusParams = Type.Object({
|
|
205
|
+
id: Type.Optional(
|
|
206
|
+
Type.String({
|
|
207
|
+
description: "Run id or prefix to show the full result for (must already be finished; use subagent_wait to block on an active run).",
|
|
208
|
+
}),
|
|
209
|
+
),
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
pi.registerTool({
|
|
213
|
+
name: "subagent_status",
|
|
214
|
+
label: "Subagent Status",
|
|
215
|
+
description: [
|
|
216
|
+
"List active background sub-agent runs (id, agent, model, usage, elapsed, current activity) and recently finished results.",
|
|
217
|
+
"Pass id to read the full result of a finished run; pass no id for the overview.",
|
|
218
|
+
"Use it to decide whether to subagent_wait, subagent_stop, or re-dispatch — never to poll: results arrive by themselves.",
|
|
219
|
+
].join(" "),
|
|
220
|
+
promptSnippet: "Inspect background subagents: active runs, finished results, full result by id.",
|
|
221
|
+
promptGuidelines: [
|
|
222
|
+
"Call subagent_status to see what is running and what already finished; the widget shows the same live state.",
|
|
223
|
+
"Never poll subagent_status in a loop to wait for a run: end the turn (you will be woken) or call subagent_wait.",
|
|
224
|
+
"A finished run's id stays available for the session; its full result is one subagent_status call away.",
|
|
225
|
+
],
|
|
226
|
+
parameters: SubagentStatusParams,
|
|
227
|
+
|
|
228
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
229
|
+
const config = await loadConfig(runtime.configPath);
|
|
230
|
+
const requested = params.id?.trim();
|
|
231
|
+
|
|
232
|
+
if (requested) {
|
|
233
|
+
const settledIds = matchRunIds([...runtime.settledRuns.keys()], requested);
|
|
234
|
+
if (settledIds.length > 0) {
|
|
235
|
+
return {
|
|
236
|
+
content: [
|
|
237
|
+
{ type: "text", text: settledIds.map((id) => formatCompletionBlock(runtime.settledRuns.get(id)!, config.maxResultLines, ctx.cwd)).join("\n\n") },
|
|
238
|
+
],
|
|
239
|
+
details: {},
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
const runs = monitor.getRuns();
|
|
243
|
+
const activeId = matchRunIds(runs.map((run) => run.id), requested)[0];
|
|
244
|
+
const active = activeId === undefined ? undefined : runs.find((run) => run.id === activeId);
|
|
245
|
+
if (active) {
|
|
246
|
+
return {
|
|
247
|
+
content: [
|
|
248
|
+
{
|
|
249
|
+
type: "text",
|
|
250
|
+
text: `Run #${active.id} ${active.agent} is still active (${active.activity ?? statusLabel(active.status)}). Use subagent_wait to block for its result, or subagent_stop to cancel it.`,
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
details: {},
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
return { content: [{ type: "text", text: `No subagent run matches "${requested}".` }], details: {} };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const now = Date.now();
|
|
260
|
+
const activeRuns = monitor.getRuns().filter(
|
|
261
|
+
(run) => run.status === "queued" || run.status === "running" || run.retained,
|
|
262
|
+
);
|
|
263
|
+
const activeLines = activeRuns.map((run) => {
|
|
264
|
+
const parts = [
|
|
265
|
+
`#${run.id} ${run.agent}`,
|
|
266
|
+
run.label,
|
|
267
|
+
run.model ?? "?",
|
|
268
|
+
formatUsageCompact(run.usage),
|
|
269
|
+
formatElapsed(run, now),
|
|
270
|
+
].filter(Boolean);
|
|
271
|
+
return `- ${parts.join(" · ")} · ${run.activity ?? statusLabel(run.status)}`;
|
|
272
|
+
});
|
|
273
|
+
const completed = [...runtime.settledRuns.entries()].slice(-5);
|
|
274
|
+
const completedLines = completed.map(([id, result]) => {
|
|
275
|
+
const usage = formatUsage(result.usage);
|
|
276
|
+
const label = runLabel(result.task);
|
|
277
|
+
return `- #${id} ${result.agent}${label ? ` · ${label}` : ""} · ${isFailedResult(result) ? "failed" : "completed"}${usage ? ` · ${usage}` : ""}`;
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const sections: string[] = [];
|
|
281
|
+
sections.push(`### Active subagent runs (${activeRuns.length})`);
|
|
282
|
+
sections.push(activeLines.length > 0 ? activeLines.join("\n") : "(none)");
|
|
283
|
+
sections.push(`### Finished this session (${runtime.settledRuns.size})`);
|
|
284
|
+
sections.push(completedLines.length > 0 ? completedLines.join("\n") : "(none)");
|
|
285
|
+
sections.push("Pass a run id to subagent_status for the full result, or subagent_wait to block for an active run.");
|
|
286
|
+
return { content: [{ type: "text", text: sections.join("\n\n") }], details: {} };
|
|
287
|
+
},
|
|
288
|
+
|
|
289
|
+
renderCall(args, theme) {
|
|
290
|
+
return new Text(
|
|
291
|
+
`${theme.fg("toolTitle", theme.bold("subagent_status "))}${theme.fg("accent", args.id ? `#${args.id}` : "overview")}`,
|
|
292
|
+
0,
|
|
293
|
+
0,
|
|
294
|
+
);
|
|
295
|
+
},
|
|
296
|
+
|
|
297
|
+
renderResult(result, _options, theme) {
|
|
298
|
+
return renderFirstLine(result, "subagent_status ", theme);
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// Cancel one or more active runs: aborts the queue controller, which
|
|
303
|
+
// terminates the child and delivers an aborted result (with whatever partial
|
|
304
|
+
// output it produced) so the main agent always knows the run stopped.
|
|
305
|
+
const SubagentStopParams = Type.Object({
|
|
306
|
+
id: Type.Optional(
|
|
307
|
+
Type.String({
|
|
308
|
+
description: "Run id or prefix to stop (see the widget or subagent_status).",
|
|
309
|
+
}),
|
|
310
|
+
),
|
|
311
|
+
all: Type.Optional(Type.Boolean({ description: "Stop every active run (default false)." })),
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
pi.registerTool({
|
|
315
|
+
name: "subagent_stop",
|
|
316
|
+
label: "Subagent Stop",
|
|
317
|
+
description: [
|
|
318
|
+
"Cancel one or more active background sub-agent runs: the child process is terminated and an aborted result (with partial output) is delivered.",
|
|
319
|
+
"Pass id (run id or prefix) to stop one run, or all: true to stop every active run.",
|
|
320
|
+
].join(" "),
|
|
321
|
+
promptSnippet: "Stop a running background subagent (id from the widget/subagent_status; or all: true).",
|
|
322
|
+
promptGuidelines: [
|
|
323
|
+
"Stop a run when its task is obsolete, stuck, or superseded — do not leave it burning tokens.",
|
|
324
|
+
"A stopped run reports as failed with 'aborted' and its partial output, so the next step knows it did not complete.",
|
|
325
|
+
],
|
|
326
|
+
parameters: SubagentStopParams,
|
|
327
|
+
|
|
328
|
+
async execute(_toolCallId, params, _signal, _onUpdate) {
|
|
329
|
+
const targets =
|
|
330
|
+
params.all === true
|
|
331
|
+
? [...runtime.runControllers.keys()]
|
|
332
|
+
: params.id !== undefined && params.id.trim() !== ""
|
|
333
|
+
? matchRunIds([...runtime.runControllers.keys()], params.id!.trim())
|
|
334
|
+
: [];
|
|
335
|
+
|
|
336
|
+
if (targets.length === 0) {
|
|
337
|
+
const activeList = [...runtime.runControllers.keys()].map((id) => `#${id}`).join(", ");
|
|
338
|
+
return {
|
|
339
|
+
content: [
|
|
340
|
+
{
|
|
341
|
+
type: "text",
|
|
342
|
+
text:
|
|
343
|
+
params.all === true
|
|
344
|
+
? "No active subagent runs to stop."
|
|
345
|
+
: `No active subagent run matches "${params.id}".${activeList ? ` Active runs: ${activeList}.` : ""}`,
|
|
346
|
+
},
|
|
347
|
+
],
|
|
348
|
+
details: {},
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const stopped: string[] = [];
|
|
353
|
+
for (const runId of targets) {
|
|
354
|
+
const run = monitor.findRun(runId);
|
|
355
|
+
if (!run) {
|
|
356
|
+
runtime.runControllers.delete(runId);
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
// Abort before registering the synthetic result: abort() only marks the
|
|
360
|
+
// queue entry (drain delivers the cancellation callback later), so the
|
|
361
|
+
// has() re-check right after it distinguishes an entry that never ran
|
|
362
|
+
// from one whose task already started under a stale "queued" status —
|
|
363
|
+
// a started task owns its own (real, partial-output) result.
|
|
364
|
+
const controller = runtime.runControllers.get(runId);
|
|
365
|
+
controller?.abort();
|
|
366
|
+
// A queued run never reaches the child-spawn code path, so its abort
|
|
367
|
+
// goes through the queue's cancelled callback with no result object;
|
|
368
|
+
// register a synthetic aborted result so subagent_wait resolves.
|
|
369
|
+
if (run.status === "queued" && runtime.runControllers.has(runId)) {
|
|
370
|
+
runtime.registerRunResult(runId, {
|
|
371
|
+
agent: run.agent,
|
|
372
|
+
agentSource: "builtin",
|
|
373
|
+
task: run.task,
|
|
374
|
+
exitCode: 1,
|
|
375
|
+
messages: [],
|
|
376
|
+
stderr: "Stopped by subagent_stop before the run started.",
|
|
377
|
+
usage: emptyUsage(),
|
|
378
|
+
model: run.model,
|
|
379
|
+
thinking: run.thinking,
|
|
380
|
+
stopReason: "aborted",
|
|
381
|
+
errorMessage: "Stopped by subagent_stop before the run started.",
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
stopped.push(`#${runId} ${run.agent}${run.status === "queued" ? " (queued)" : ""}`);
|
|
385
|
+
}
|
|
386
|
+
return {
|
|
387
|
+
content: [
|
|
388
|
+
{
|
|
389
|
+
type: "text",
|
|
390
|
+
text: `Stopped ${stopped.length} run${stopped.length === 1 ? "" : "s"}: ${stopped.join(", ")}. An aborted result (with partial output) is delivered.`,
|
|
391
|
+
},
|
|
392
|
+
],
|
|
393
|
+
details: {},
|
|
394
|
+
};
|
|
395
|
+
},
|
|
396
|
+
|
|
397
|
+
renderCall(args, theme) {
|
|
398
|
+
return new Text(
|
|
399
|
+
`${theme.fg("toolTitle", theme.bold("subagent_stop "))}${theme.fg("accent", args.all === true ? "all" : args.id ? `#${args.id}` : "?")}`,
|
|
400
|
+
0,
|
|
401
|
+
0,
|
|
402
|
+
);
|
|
403
|
+
},
|
|
404
|
+
|
|
405
|
+
renderResult(result, _options, theme) {
|
|
406
|
+
return renderFirstLine(result, "subagent_stop ", theme);
|
|
407
|
+
},
|
|
408
|
+
});
|
|
409
|
+
}
|
package/src/ui.ts
CHANGED
|
@@ -24,6 +24,11 @@ import {
|
|
|
24
24
|
} from "@earendil-works/pi-tui";
|
|
25
25
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
26
26
|
|
|
27
|
+
/** The slice of the extension context the pickers need (mode + ui), so both
|
|
28
|
+
* command handlers (ExtensionCommandContext) and tool execute handlers
|
|
29
|
+
* (ExtensionContext) can use them. */
|
|
30
|
+
export type PickerContext = Pick<ExtensionCommandContext, "mode" | "ui">;
|
|
31
|
+
|
|
27
32
|
/** Rows shown at once; longer lists are reached with PageUp/PageDown. */
|
|
28
33
|
export const PAGE_SIZE = 8;
|
|
29
34
|
|
|
@@ -185,7 +190,7 @@ function makeStyles(theme: { fg: (color: any, text: string) => string; bold: (te
|
|
|
185
190
|
};
|
|
186
191
|
}
|
|
187
192
|
|
|
188
|
-
function requireTui(ctx:
|
|
193
|
+
function requireTui(ctx: PickerContext): boolean {
|
|
189
194
|
if (ctx.mode !== "tui") {
|
|
190
195
|
ctx.ui.notify("/subagents-setup requires Pi's interactive TUI.", "error");
|
|
191
196
|
return false;
|
|
@@ -195,7 +200,7 @@ function requireTui(ctx: ExtensionCommandContext): boolean {
|
|
|
195
200
|
|
|
196
201
|
/** Single-select with fuzzy filter + paging. Resolves undefined on Esc. */
|
|
197
202
|
export function promptSelectOne(
|
|
198
|
-
ctx:
|
|
203
|
+
ctx: PickerContext,
|
|
199
204
|
title: string,
|
|
200
205
|
hint: string,
|
|
201
206
|
items: SelectItem[],
|
|
@@ -213,7 +218,7 @@ export function promptSelectOne(
|
|
|
213
218
|
|
|
214
219
|
/** Multi-select with fuzzy filter + paging. Resolves undefined on Esc. */
|
|
215
220
|
export function promptSelectMany(
|
|
216
|
-
ctx:
|
|
221
|
+
ctx: PickerContext,
|
|
217
222
|
title: string,
|
|
218
223
|
hint: string,
|
|
219
224
|
items: SelectItem[],
|
package/src/widget.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session_start wiring: the persistent status widget above the editor, plus
|
|
3
|
+
* one-time feature announcements (a new configurable option is surfaced to the
|
|
4
|
+
* user once after an update; the marker persists in `announcedFeatures`).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { stat } from "node:fs/promises";
|
|
8
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
10
|
+
import { loadConfig, saveConfig } from "./config.ts";
|
|
11
|
+
import {
|
|
12
|
+
activityStateLabel,
|
|
13
|
+
compactLine,
|
|
14
|
+
deriveActivityState,
|
|
15
|
+
formatElapsed,
|
|
16
|
+
formatUsageCompact,
|
|
17
|
+
monitor,
|
|
18
|
+
statusIcon,
|
|
19
|
+
statusLabel,
|
|
20
|
+
} from "./monitor.ts";
|
|
21
|
+
import type { SubagentRuntime } from "./runtime.ts";
|
|
22
|
+
|
|
23
|
+
/** Features whose one-time announcement is still pending (keyed by config
|
|
24
|
+
* `announcedFeatures` entry). When the feature's precondition is unmet and the
|
|
25
|
+
* marker is absent, the user is told about it exactly once. */
|
|
26
|
+
const ANNOUNCEMENTS: Array<{
|
|
27
|
+
key: string;
|
|
28
|
+
condition: (config: Awaited<ReturnType<typeof loadConfig>>) => boolean;
|
|
29
|
+
message: string;
|
|
30
|
+
}> = [
|
|
31
|
+
{
|
|
32
|
+
key: "visionModel",
|
|
33
|
+
condition: (config) => config.visionModel === undefined,
|
|
34
|
+
message:
|
|
35
|
+
"pi-subagents: new — a vision-capable model can now handle image tasks (screenshots, mockups, designs). Run /subagents-setup to configure it; until set, vision tasks use the main session's current model.",
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One-time feature announcements: when an update introduces a new configurable
|
|
41
|
+
* feature, tell the user once (the marker persists in announcedFeatures) so they
|
|
42
|
+
* know it exists — e.g. the vision model, which is unset by default. Only runs
|
|
43
|
+
* when a config file already exists: on a fresh install there is nothing to
|
|
44
|
+
* announce (and writing the file here would make /subagents-setup skip its
|
|
45
|
+
* first-time wizard). A failed announcement must never break session startup.
|
|
46
|
+
*/
|
|
47
|
+
async function announceNewFeatures(
|
|
48
|
+
ctx: { ui: { notify: (message: string, kind: "info" | "warning" | "error") => void } },
|
|
49
|
+
runtime: SubagentRuntime,
|
|
50
|
+
): Promise<void> {
|
|
51
|
+
try {
|
|
52
|
+
let configExists = true;
|
|
53
|
+
try {
|
|
54
|
+
await stat(runtime.configPath);
|
|
55
|
+
} catch {
|
|
56
|
+
configExists = false;
|
|
57
|
+
}
|
|
58
|
+
if (!configExists) return;
|
|
59
|
+
|
|
60
|
+
const config = await loadConfig(runtime.configPath);
|
|
61
|
+
const pending = ANNOUNCEMENTS.filter(
|
|
62
|
+
(announcement) =>
|
|
63
|
+
announcement.condition(config) && !config.announcedFeatures.includes(announcement.key),
|
|
64
|
+
);
|
|
65
|
+
if (pending.length === 0) return;
|
|
66
|
+
await saveConfig(
|
|
67
|
+
{
|
|
68
|
+
...config,
|
|
69
|
+
announcedFeatures: [...config.announcedFeatures, ...pending.map((a) => a.key)],
|
|
70
|
+
},
|
|
71
|
+
runtime.configPath,
|
|
72
|
+
);
|
|
73
|
+
for (const announcement of pending) {
|
|
74
|
+
ctx.ui.notify(announcement.message, "info");
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
/* announcement failures are non-fatal */
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function registerWidget(pi: ExtensionAPI, runtime: SubagentRuntime): void {
|
|
82
|
+
pi.on("session_start", async (_e, ctx) => {
|
|
83
|
+
if (ctx.mode !== "tui") return;
|
|
84
|
+
await announceNewFeatures(ctx, runtime);
|
|
85
|
+
|
|
86
|
+
ctx.ui.setWidget(
|
|
87
|
+
"pi-subagents",
|
|
88
|
+
(tui, theme) => {
|
|
89
|
+
const unsub = monitor.subscribe(() => tui.requestRender());
|
|
90
|
+
// Tick once a second so elapsed time stays live while runs are active.
|
|
91
|
+
const timer = setInterval(() => {
|
|
92
|
+
if (monitor.getRuns().some((r) => r.status === "queued" || r.status === "running")) {
|
|
93
|
+
tui.requestRender();
|
|
94
|
+
}
|
|
95
|
+
}, 1000);
|
|
96
|
+
return {
|
|
97
|
+
render(width: number): string[] {
|
|
98
|
+
const runs = monitor.getRuns();
|
|
99
|
+
if (runs.length === 0) return [];
|
|
100
|
+
const now = Date.now();
|
|
101
|
+
const lines: string[] = [];
|
|
102
|
+
// Tree layout: each top-level agent is a root whose title/activity hang
|
|
103
|
+
// off it as branches; auto-fix chain runs (groupId) become child nodes
|
|
104
|
+
// under their parent root, with a "│" continuation while more siblings
|
|
105
|
+
// follow. Blank lines separate agent blocks so parallel runs don't blur
|
|
106
|
+
// into one wall of text.
|
|
107
|
+
const dim = (t: string): string => theme.fg("dim", t);
|
|
108
|
+
for (let idx = 0; idx < runs.length; idx++) {
|
|
109
|
+
const r = runs[idx];
|
|
110
|
+
const isChain = Boolean(r.groupId);
|
|
111
|
+
const chainContinues = isChain && runs[idx + 1]?.groupId === r.groupId;
|
|
112
|
+
const activity =
|
|
113
|
+
r.activity && (r.status === "running" || r.status === "queued") ? r.activity : undefined;
|
|
114
|
+
const hasActivity = activity !== undefined;
|
|
115
|
+
const icon = statusIcon(r.status, theme);
|
|
116
|
+
// Chain-internal runs (auto-fix worker/reviewer) are child nodes under
|
|
117
|
+
// their parent reviewer. Their relationLabel ("fix round 1") is more
|
|
118
|
+
// distinguishing than the repeated worker/reviewer name.
|
|
119
|
+
const name = isChain ? (r.relationLabel ?? r.agent) : r.agent;
|
|
120
|
+
// Two lines per run: the header row (icon, run id, agent name) and the
|
|
121
|
+
// live activity branch below. The task summary is deliberately not
|
|
122
|
+
// shown — the task lives in the tool result, and the agent name plus
|
|
123
|
+
// what it is doing right now is enough to tell runs apart. The header
|
|
124
|
+
// stays exactly as it was (accent name, dim stats), matching the
|
|
125
|
+
// referenced sub-agent widgets (tintinweb): the running indicator
|
|
126
|
+
// uses the accent color, everything else is quiet.
|
|
127
|
+
if (!isChain && lines.length > 0) lines.push("");
|
|
128
|
+
const nodeBranch = isChain ? (chainContinues ? "├─ " : "└─ ") : "";
|
|
129
|
+
// Content label (task-derived) trails the agent name so concurrent
|
|
130
|
+
// same-agent runs read as what they do, not just their run id. Chain
|
|
131
|
+
// nodes already carry a distinguishing relationLabel.
|
|
132
|
+
const labelPart = !isChain && r.label ? ` ${dim(`· ${r.label}`)}` : "";
|
|
133
|
+
const left = `${dim(nodeBranch)}${icon} ${dim(`#${r.id}`)} ${isChain ? name : theme.fg("accent", theme.bold(name))}${labelPart}`;
|
|
134
|
+
|
|
135
|
+
// Right side: full model ref (provider/model), token usage (in/out +
|
|
136
|
+
// cache read/write), tool count, elapsed, and the soft activity-state
|
|
137
|
+
// annotation (idle / long-running). Trailing the header with a single
|
|
138
|
+
// " · " chain keeps the row compact (no center gap); compactLine
|
|
139
|
+
// clips on overflow, never the right side on its own.
|
|
140
|
+
const model = r.model ?? "?";
|
|
141
|
+
const usage = formatUsageCompact(r.usage);
|
|
142
|
+
const tools = r.toolCount ? `${r.toolCount} tool${r.toolCount === 1 ? "" : "s"}` : "";
|
|
143
|
+
const elapsed = formatElapsed(r, now);
|
|
144
|
+
// The round outcome summary leads the metadata so a finished chain
|
|
145
|
+
// row reads as what it did ("fail · src/index.ts · render()",
|
|
146
|
+
// "pass", "src/index.ts · tests/monitor.test.ts").
|
|
147
|
+
const metaParts = [r.summary, model, usage, tools, elapsed].filter(Boolean);
|
|
148
|
+
// Running is conveyed by the icon + elapsed; spell out the label only for
|
|
149
|
+
// the other states (ready / done / stopped) so they are unambiguous.
|
|
150
|
+
if (r.status !== "running") metaParts.push(statusLabel(r.status));
|
|
151
|
+
const state = deriveActivityState(r, now);
|
|
152
|
+
if (state) metaParts.push(activityStateLabel(state));
|
|
153
|
+
if (r.annotation) metaParts.push(r.annotation);
|
|
154
|
+
// Metadata trails the header in dim — quiet, never competing with the
|
|
155
|
+
// accent agent name (the same restraint the referenced widgets use).
|
|
156
|
+
// Trailing with a single " · " chain keeps the row compact (no center
|
|
157
|
+
// gap); compactLine clips on overflow, never the right side on its own.
|
|
158
|
+
const right = metaParts.length ? dim(` · ${metaParts.join(" · ")}`) : "";
|
|
159
|
+
lines.push(compactLine(left, right, width));
|
|
160
|
+
|
|
161
|
+
// Current activity ("read src/index.ts", "bash npm test") is the only
|
|
162
|
+
// branch: gray, so it never competes with the agent name or pi's own
|
|
163
|
+
// UI. Chain nodes that still have siblings carry a "│" continuation
|
|
164
|
+
// down to the last one.
|
|
165
|
+
if (hasActivity) {
|
|
166
|
+
const continuation = isChain ? (chainContinues ? "│ " : " ") : "";
|
|
167
|
+
lines.push(truncateToWidth(`${continuation}${dim("└─ ")}${dim(activity)}`, width));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return lines;
|
|
171
|
+
},
|
|
172
|
+
invalidate() {},
|
|
173
|
+
dispose() {
|
|
174
|
+
unsub();
|
|
175
|
+
clearInterval(timer);
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
},
|
|
179
|
+
{ placement: "aboveEditor" },
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
}
|