@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/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: ExtensionCommandContext): boolean {
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: ExtensionCommandContext,
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: ExtensionCommandContext,
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
+ }