@ferris1225/pi-subagents 0.27.0 → 0.29.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/models.ts CHANGED
@@ -36,6 +36,19 @@ export function availableModelRefs(ctx: ModelContext): string[] {
36
36
  return [currentRef, ...refs.filter((ref) => ref !== currentRef)];
37
37
  }
38
38
 
39
+ /**
40
+ * Resolve the model for a `vision: true` dispatch. The configured vision model
41
+ * wins when it is usable by the current session; otherwise the task falls back
42
+ * to the main window's current model (the documented behavior when the vision
43
+ * model is unset). Returns undefined only when neither exists — callers then
44
+ * keep the agent's own model as the last resort.
45
+ */
46
+ export function resolveVisionModelRef(ctx: ModelContext, visionModel?: string): string | undefined {
47
+ const configured = visionModel?.trim();
48
+ if (configured && availableModelRefs(ctx).includes(configured)) return configured;
49
+ return ctx.model ? modelRef(ctx.model) : undefined;
50
+ }
51
+
39
52
  /** Replace unavailable persisted overrides with a model usable by the main session. */
40
53
  export function repairUnavailableModelOverrides(
41
54
  ctx: ModelContext,
package/src/prompt.ts CHANGED
@@ -41,9 +41,13 @@ It immediately ends the current main-agent turn so the user can keep working. Wh
41
41
  finishes, its result is sent back as a message that automatically resumes the main agent;
42
42
  if the main agent is busy, the result waits as a follow-up.
43
43
 
44
- NEVER run sleep, wait, or polling commands (e.g. Start-Sleep, sleep, timeout) to wait for
45
- a sub-agent the turn already ended and the main agent is auto-resumed when results arrive.
46
- Manual waiting blocks the turn, delays result delivery, and wastes the user's time.
44
+ NEVER run sleep, wait, or polling commands (e.g. Start-Sleep, sleep, timeout), and do NOT
45
+ call subagent_wait to hold the turn — dispatching already ended it, and results arrive as
46
+ messages that resume the main agent automatically (even mid-turn). Ending your turn is the
47
+ default and the only correct way to wait; subagent_wait blocks the turn so the user cannot
48
+ give you other work meanwhile. It is non-blocking by default: settled results return
49
+ immediately, active runs return a "still running — end your turn" note. Pass an explicit
50
+ timeoutMs only when you must stay in the turn (e.g. the user asked you to wait).
47
51
 
48
52
  Available agents:
49
53
  ${catalog}
@@ -58,6 +62,10 @@ ${hasMultiple ? "- Run INDEPENDENT tasks in parallel: one subagent call with a `
58
62
  - Treat delegated agents as leaf workers: do not ask a sub-agent to dispatch another sub-agent; child processes do not have this tool.
59
63
  - Trust but verify: a sub-agent's summary describes intent, not outcome. Check the actual changes/results before reporting work done.
60
64
 
65
+ Vision tasks:
66
+ - Judge whether a delegated task may require viewing images (frontend screenshots, mockups, design files, visual regression comparisons). If it might, pass \`vision: true\` in the subagent call and give the sub-agent the exact image paths — it reads them with its read tool.
67
+ - \`vision: true\` runs the sub-agent on the vision-capable model configured in /subagents-setup; when none is configured it falls back to the main session's current model. Do not skip the flag because the agent's default model looks fast/cheap — a non-vision model cannot see the images.
68
+
61
69
  Review & verification:
62
70
  - Never report an unrun check as passed; report it as unavailable or as a pre-existing failure.
63
71
  ${hasReviewer ? "- For non-trivial diffs, run one fresh read-only `reviewer` sub-agent before reporting done. Fix only concrete blockers and re-review at most once.\n- Use multi-model cross-review only when explicitly requested or for genuinely high-risk changes (security, unsafe/FFI, persistence-migration, concurrency). Reviewers are read-only; only the main agent edits.\n" : ""}- Commit or push only when explicitly requested, applicable checks pass, and no accepted blockers remain.`;
package/src/runtime.ts ADDED
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Shared per-session runtime state for pi-subagents.
3
+ *
4
+ * The extension registers several tools (subagent, subagent_wait/status/stop) and
5
+ * a widget that all talk to one set of live structures: the background queue, the
6
+ * completion batcher, abort controllers per run, and the settled-results store.
7
+ * `createRuntime` builds those once per extension load and hands the same object
8
+ * to every registration site, so state stays in one place without globals.
9
+ */
10
+
11
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
+ import { BackgroundTaskQueue } from "./background.ts";
13
+ import {
14
+ completionGroupTriggersTurn,
15
+ createCompletionBatcher,
16
+ formatCompletionMessage,
17
+ type CompletionBatcher,
18
+ type CompletionMessageItem,
19
+ } from "./completion.ts";
20
+ import { loadConfigSync } from "./config.ts";
21
+ import { monitor } from "./monitor.ts";
22
+ import type { SingleResult } from "./spawn.ts";
23
+
24
+ export interface SubagentRuntime {
25
+ configPath: string;
26
+ backgroundQueue: BackgroundTaskQueue;
27
+ /** False after session_shutdown; guards delivery and queue work. */
28
+ sessionActive: boolean;
29
+ /** Deliver a batch of completion messages to the main window, waking it only
30
+ * when the batch needs a turn. */
31
+ sendCompletionGroup: (items: CompletionMessageItem[]) => void;
32
+ completionBatcher: CompletionBatcher<CompletionMessageItem>;
33
+ /** Abort controllers per active run, so subagent_stop can cancel a run in-turn. */
34
+ runControllers: Map<number, AbortController>;
35
+ /** Final results keyed by run id, so subagent_wait can hand the model the
36
+ * actual result in-turn instead of it sleeping/polling for a wake-up message. */
37
+ settledRuns: Map<number, SingleResult>;
38
+ settledListeners: Map<number, Set<(result: SingleResult) => void>>;
39
+ registerRunResult: (runId: number, result: SingleResult) => void;
40
+ /** Flip sessionActive off and release all session-scoped resources. */
41
+ shutdown: () => void;
42
+ }
43
+
44
+ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRuntime {
45
+ // Init-time decisions need the config synchronously; the full (migrating)
46
+ // async load runs per tool call.
47
+ const initialConfig = loadConfigSync(configPath);
48
+ const backgroundQueue = new BackgroundTaskQueue(initialConfig.maxConcurrency);
49
+
50
+ const runtime: SubagentRuntime = {
51
+ configPath,
52
+ backgroundQueue,
53
+ sessionActive: true,
54
+ sendCompletionGroup: (items) => {
55
+ if (!runtime.sessionActive || items.length === 0) return;
56
+ const message = {
57
+ customType: "subagent-result",
58
+ content: formatCompletionMessage(items),
59
+ display: true,
60
+ };
61
+ if (completionGroupTriggersTurn(items)) {
62
+ // steer: the result is injected after the current tool call even mid-turn,
63
+ // or starts a new turn when idle. followUp would sit in the queue until the
64
+ // whole turn ends — a main agent waiting for the result (sleep/poll) would
65
+ // never see it delivered, which is exactly the "returned but never woken"
66
+ // failure mode.
67
+ pi.sendMessage(message, { deliverAs: "steer", triggerTurn: true });
68
+ } else {
69
+ // No-wake delivery: nextTurn rides along with the next user turn and can
70
+ // never start a continuation by itself. followUp would auto-continue
71
+ // whenever pi is already streaming, defeating the opt-out.
72
+ pi.sendMessage(message, { deliverAs: "nextTurn" });
73
+ }
74
+ },
75
+ completionBatcher: undefined as unknown as CompletionBatcher<CompletionMessageItem>,
76
+ runControllers: new Map<number, AbortController>(),
77
+ settledRuns: new Map<number, SingleResult>(),
78
+ settledListeners: new Map<number, Set<(result: SingleResult) => void>>(),
79
+ registerRunResult: (runId, result) => {
80
+ runtime.settledRuns.set(runId, result);
81
+ const listeners = runtime.settledListeners.get(runId);
82
+ if (listeners) {
83
+ runtime.settledListeners.delete(runId);
84
+ for (const listener of listeners) {
85
+ try {
86
+ listener(result);
87
+ } catch {
88
+ /* listener errors must never break settling */
89
+ }
90
+ }
91
+ }
92
+ },
93
+ shutdown: () => {
94
+ runtime.sessionActive = false;
95
+ runtime.completionBatcher.dispose();
96
+ runtime.backgroundQueue.cancelAll();
97
+ runtime.settledRuns.clear();
98
+ runtime.settledListeners.clear();
99
+ runtime.runControllers.clear();
100
+ // Clear the monitor so stale runs from this session never leak into the
101
+ // next one (the module-level singleton survives across sessions).
102
+ monitor.clear();
103
+ },
104
+ };
105
+
106
+ runtime.completionBatcher = createCompletionBatcher<CompletionMessageItem>({
107
+ emit: runtime.sendCompletionGroup,
108
+ });
109
+ return runtime;
110
+ }
package/src/setup.ts CHANGED
@@ -103,6 +103,31 @@ async function pickAgentModel(
103
103
  );
104
104
  }
105
105
 
106
+ /** Vision model pick for image tasks (screenshots/mockups); the inherit option
107
+ * leaves it unset, so vision-flagged dispatches fall back to the main session's
108
+ * current model. */
109
+ async function pickVisionModel(
110
+ ctx: ExtensionCommandContext,
111
+ currentRef: string | undefined,
112
+ refs: readonly string[],
113
+ ): Promise<string | typeof INHERIT | undefined> {
114
+ const items = [
115
+ {
116
+ value: INHERIT,
117
+ label: currentRef
118
+ ? `(not set — vision tasks fall back to the main session's model; drop "${currentRef}")`
119
+ : "(not set — vision tasks fall back to the main session's current model)",
120
+ },
121
+ ...refs.map((ref) => ({ value: ref, label: ref === currentRef ? `${ref} (current)` : ref })),
122
+ ];
123
+ return promptSelectOne(
124
+ ctx,
125
+ "Vision-capable model for image tasks (screenshots, mockups, designs)?",
126
+ "Type to filter • ↑/↓ • PgUp/PgDn • Enter selects • Esc cancels setup",
127
+ items,
128
+ );
129
+ }
130
+
106
131
  async function pickAgentModelsAndStrength(
107
132
  ctx: ExtensionCommandContext,
108
133
  enabledAgents: readonly string[],
@@ -337,6 +362,18 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
337
362
  const picked = await pickAgentModelsAndStrength(ctx, enabled, base.agentModels, base.agentThinkingLevels, thinkingLevel, defaults);
338
363
  if (picked === undefined) return notifyCancelled(ctx);
339
364
 
365
+ let nextVisionModel: string | undefined;
366
+ // No models available: keep the vision model unset (vision tasks then fall
367
+ // back to the main session's model) instead of showing a one-option picker.
368
+ const refs = availableModelRefs(ctx);
369
+ if (refs.length === 0) {
370
+ ctx.ui.notify("No Pi models are currently available; vision model left unset.", "warning");
371
+ } else {
372
+ const visionModel = await pickVisionModel(ctx, base.visionModel, refs);
373
+ if (visionModel === undefined) return notifyCancelled(ctx);
374
+ if (visionModel !== INHERIT) nextVisionModel = visionModel;
375
+ }
376
+
340
377
  const injection = await pickInjection(ctx, base.proactiveInjection);
341
378
  if (injection === undefined) return notifyCancelled(ctx);
342
379
 
@@ -382,7 +419,9 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
382
419
  maxConcurrency,
383
420
  maxFixRounds,
384
421
  idleTimeoutSec,
422
+ announcedFeatures: base.announcedFeatures,
385
423
  };
424
+ if (nextVisionModel !== undefined) next.visionModel = nextVisionModel;
386
425
  await saveConfig(next, configPath);
387
426
  ctx.ui.notify(`pi-subagents configured. Saved to ${configPath}`, "info");
388
427
  }
@@ -391,6 +430,7 @@ async function runMenu(ctx: ExtensionCommandContext, configPath: string, config:
391
430
  const choice = await ctx.ui.select("pi-subagents is already configured. What would you like to change?", [
392
431
  "Enable/disable agents",
393
432
  "Configure an agent (model + thinking)",
433
+ "Change vision model (image tasks)",
394
434
  "Toggle proactive injection",
395
435
  "Change agent scope",
396
436
  "Change max concurrent sub-agents",
@@ -440,6 +480,16 @@ async function runMenu(ctx: ExtensionCommandContext, configPath: string, config:
440
480
  const injection = await pickInjection(ctx, config.proactiveInjection);
441
481
  if (injection === undefined) return notifyCancelled(ctx);
442
482
  next.proactiveInjection = injection;
483
+ } else if (choice.startsWith("Change vision")) {
484
+ const refs = availableModelRefs(ctx);
485
+ if (refs.length === 0) {
486
+ ctx.ui.notify("No Pi models are currently available; vision model left unchanged.", "warning");
487
+ return;
488
+ }
489
+ const visionModel = await pickVisionModel(ctx, config.visionModel, refs);
490
+ if (visionModel === undefined) return notifyCancelled(ctx);
491
+ if (visionModel === INHERIT) delete next.visionModel;
492
+ else next.visionModel = visionModel;
443
493
  } else if (choice.startsWith("Change agent scope")) {
444
494
  const scope = await pickScope(ctx, config.agentScope);
445
495
  if (scope === undefined) return notifyCancelled(ctx);
package/src/tools.ts ADDED
@@ -0,0 +1,406 @@
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
+ statusLabel,
17
+ } from "./monitor.ts";
18
+ import type { SubagentRuntime } from "./runtime.ts";
19
+ import { isFailedResult, type SingleResult } from "./spawn.ts";
20
+
21
+ /** In-turn result lookup. Dispatch already ended the turn and results arrive as
22
+ * wake-up messages, so the default must NOT block: a settled run returns its
23
+ * result immediately, a still-active run returns a "still running — end your
24
+ * turn" note and the model finishes (the completion then wakes it). Blocking
25
+ * is opt-in via an explicit timeoutMs — a long default would hold the turn
26
+ * hostage for nothing, since the result arrives on its own either way. */
27
+ const SUBAGENT_WAIT_DEFAULT_TIMEOUT_MS = 0;
28
+
29
+ function renderFirstLine(result: { content?: unknown }, label: string, theme: any): Text {
30
+ const parts = (result.content ?? []) as Array<{ type: string; text?: string }>;
31
+ const text = parts
32
+ .map((part) => (typeof part.text === "string" ? part.text : ""))
33
+ .join(" ")
34
+ .trim();
35
+ const firstLine = text.split("\n").find((line) => line.trim()) ?? "(no output)";
36
+ return new Text(`${theme.fg("toolTitle", theme.bold(label))}${theme.fg("dim", firstLine.slice(0, 60))}`, 0, 0);
37
+ }
38
+
39
+ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime): void {
40
+ const SubagentWaitParams = Type.Object({
41
+ id: Type.Optional(
42
+ Type.String({
43
+ description: "Run id or prefix shown in the subagent widget (#id). Omit to wait for all active runs in this session.",
44
+ }),
45
+ ),
46
+ timeoutMs: Type.Optional(
47
+ Type.Number({
48
+ 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.`,
49
+ }),
50
+ ),
51
+ });
52
+
53
+ pi.registerTool({
54
+ name: "subagent_wait",
55
+ label: "Subagent Wait",
56
+ description: [
57
+ "Look up background sub-agent run(s) and return their results.",
58
+ "PREFER NOT CALLING THIS: dispatching already ended your turn and results arrive as a message that wakes you automatically.",
59
+ "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.",
60
+ "Pass an explicit timeoutMs ONLY when you must stay in the turn and need the result right now (sequential dependent steps).",
61
+ "NEVER sleep, poll, or wait with bash to get a sub-agent result: end the turn, or call this tool.",
62
+ "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.",
63
+ ].join(" "),
64
+ 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.",
65
+ promptGuidelines: [
66
+ "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.",
67
+ "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.",
68
+ "Never use bash sleep/timeout/polling to wait for a sub-agent — it blocks the turn and delays result delivery.",
69
+ "If subagent_wait times out, end the turn and wait for the wake-up message, or call it again with a longer timeoutMs.",
70
+ ],
71
+ parameters: SubagentWaitParams,
72
+
73
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
74
+ const config = await loadConfig(runtime.configPath);
75
+ // A non-finite or negative timeout would produce a nonsensical note
76
+ // ("timed out after Infinitys") or an instant "timeout" that was never
77
+ // asked for; fall back to the default. Zero is honored as an immediate
78
+ // give-up (clamped to 1ms below).
79
+ const timeoutMs =
80
+ typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs) && params.timeoutMs >= 0
81
+ ? params.timeoutMs
82
+ : SUBAGENT_WAIT_DEFAULT_TIMEOUT_MS;
83
+ const isActive = (run: { status: string; retained?: boolean }): boolean =>
84
+ run.status === "queued" || run.status === "running" || run.retained === true;
85
+
86
+ const requested = params.id?.trim();
87
+ // A run that already settled resolves immediately with its result.
88
+ if (requested) {
89
+ const settledIds = matchRunIds([...runtime.settledRuns.keys()], requested);
90
+ if (settledIds.length > 0) {
91
+ return {
92
+ content: [
93
+ { type: "text", text: settledIds.map((id) => formatCompletionBlock(runtime.settledRuns.get(id)!, config.maxResultLines, ctx.cwd)).join("\n\n") },
94
+ ],
95
+ details: {},
96
+ };
97
+ }
98
+ }
99
+
100
+ const activeRuns = monitor.getRuns().filter(isActive);
101
+ const targetIds = requested ? matchRunIds(activeRuns.map((run) => run.id), requested) : activeRuns.map((run) => run.id);
102
+ const targets = activeRuns.filter((run) => targetIds.includes(run.id));
103
+ if (targets.length === 0) {
104
+ const activeList = activeRuns.map((run) => `#${run.id} ${run.agent}`).join(", ");
105
+ return {
106
+ content: [
107
+ {
108
+ type: "text",
109
+ text: requested
110
+ ? `No active subagent run matches "${requested}".${activeList ? ` Active runs: ${activeList}.` : ""}`
111
+ : `No active subagent runs${activeList ? ` (active: ${activeList})` : " right now"}.`,
112
+ },
113
+ ],
114
+ details: {},
115
+ };
116
+ }
117
+
118
+ const waitForRun = (runId: number): Promise<{ result?: SingleResult; note?: string }> => {
119
+ const already = runtime.settledRuns.get(runId);
120
+ if (already) return Promise.resolve({ result: already });
121
+ return new Promise((resolve) => {
122
+ let done = false;
123
+ let timer: ReturnType<typeof setTimeout> | undefined;
124
+ let unsub: (() => void) | undefined;
125
+ const cleanup = (): void => {
126
+ if (timer) clearTimeout(timer);
127
+ if (unsub) unsub();
128
+ signal?.removeEventListener("abort", onAbort);
129
+ const listeners = runtime.settledListeners.get(runId);
130
+ if (listeners) {
131
+ listeners.delete(onSettled);
132
+ if (listeners.size === 0) runtime.settledListeners.delete(runId);
133
+ }
134
+ };
135
+ const finish = (outcome: { result?: SingleResult; note?: string }): void => {
136
+ if (done) return;
137
+ done = true;
138
+ cleanup();
139
+ resolve(outcome);
140
+ };
141
+ const onSettled = (result: SingleResult): void => finish({ result });
142
+ const onMonitor = (): void => {
143
+ const current = runtime.settledRuns.get(runId);
144
+ if (current) {
145
+ finish({ result: current });
146
+ return;
147
+ }
148
+ if (!monitor.findRun(runId)) {
149
+ // Removal is followed synchronously by registerRunResult in the
150
+ // finishing task; re-check on the next tick so the result wins.
151
+ setTimeout(() => {
152
+ const late = runtime.settledRuns.get(runId);
153
+ if (late) finish({ result: late });
154
+ else finish({ note: `run #${runId} was removed before its result was recorded (cancelled or session ended)` });
155
+ }, 0);
156
+ }
157
+ };
158
+ const onAbort = (): void => finish({ note: "wait aborted" });
159
+ let listeners = runtime.settledListeners.get(runId);
160
+ if (!listeners) {
161
+ listeners = new Set();
162
+ runtime.settledListeners.set(runId, listeners);
163
+ }
164
+ listeners.add(onSettled);
165
+ unsub = monitor.subscribe(onMonitor);
166
+ timer = setTimeout(
167
+ () =>
168
+ finish({
169
+ note:
170
+ timeoutMs === 0
171
+ ? `run #${runId} is still active — end your turn: the result will wake you (or call subagent_wait again with an explicit timeoutMs to block)`
172
+ : `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)`,
173
+ }),
174
+ Math.max(1, timeoutMs),
175
+ );
176
+ if (signal?.aborted) onAbort();
177
+ else signal?.addEventListener("abort", onAbort, { once: true });
178
+ });
179
+ };
180
+
181
+ const outcomes = await Promise.all(targets.map((run) => waitForRun(run.id)));
182
+ const blocks = outcomes.map((outcome) =>
183
+ outcome.result ? formatCompletionBlock(outcome.result, config.maxResultLines, ctx.cwd) : (outcome.note ?? "(no outcome)"),
184
+ );
185
+ return { content: [{ type: "text", text: blocks.join("\n\n") }], details: {} };
186
+ },
187
+
188
+ renderCall(args, theme) {
189
+ const target = args.id ? `#${args.id}` : "all";
190
+ return new Text(`${theme.fg("toolTitle", theme.bold("subagent_wait "))}${theme.fg("accent", target)}`, 0, 0);
191
+ },
192
+
193
+ renderResult(result, _options, theme) {
194
+ return renderFirstLine(result, "subagent_wait ", theme);
195
+ },
196
+ });
197
+
198
+ // Status overview: what is running right now and what finished this session,
199
+ // with per-run details (id, agent, model, usage, elapsed, activity) so the
200
+ // main agent can decide whether to wait, stop, or re-dispatch. Learned from
201
+ // nicobailon/pi-subagents ({action:"status"} + status files): inspect before
202
+ // you act, and report run ids when handing off.
203
+ const SubagentStatusParams = Type.Object({
204
+ id: Type.Optional(
205
+ Type.String({
206
+ description: "Run id or prefix to show the full result for (must already be finished; use subagent_wait to block on an active run).",
207
+ }),
208
+ ),
209
+ });
210
+
211
+ pi.registerTool({
212
+ name: "subagent_status",
213
+ label: "Subagent Status",
214
+ description: [
215
+ "List active background sub-agent runs (id, agent, model, usage, elapsed, current activity) and recently finished results.",
216
+ "Pass id to read the full result of a finished run; pass no id for the overview.",
217
+ "Use it to decide whether to subagent_wait, subagent_stop, or re-dispatch — never to poll: results arrive by themselves.",
218
+ ].join(" "),
219
+ promptSnippet: "Inspect background subagents: active runs, finished results, full result by id.",
220
+ promptGuidelines: [
221
+ "Call subagent_status to see what is running and what already finished; the widget shows the same live state.",
222
+ "Never poll subagent_status in a loop to wait for a run: end the turn (you will be woken) or call subagent_wait.",
223
+ "A finished run's id stays available for the session; its full result is one subagent_status call away.",
224
+ ],
225
+ parameters: SubagentStatusParams,
226
+
227
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
228
+ const config = await loadConfig(runtime.configPath);
229
+ const requested = params.id?.trim();
230
+
231
+ if (requested) {
232
+ const settledIds = matchRunIds([...runtime.settledRuns.keys()], requested);
233
+ if (settledIds.length > 0) {
234
+ return {
235
+ content: [
236
+ { type: "text", text: settledIds.map((id) => formatCompletionBlock(runtime.settledRuns.get(id)!, config.maxResultLines, ctx.cwd)).join("\n\n") },
237
+ ],
238
+ details: {},
239
+ };
240
+ }
241
+ const runs = monitor.getRuns();
242
+ const activeId = matchRunIds(runs.map((run) => run.id), requested)[0];
243
+ const active = activeId === undefined ? undefined : runs.find((run) => run.id === activeId);
244
+ if (active) {
245
+ return {
246
+ content: [
247
+ {
248
+ type: "text",
249
+ 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.`,
250
+ },
251
+ ],
252
+ details: {},
253
+ };
254
+ }
255
+ return { content: [{ type: "text", text: `No subagent run matches "${requested}".` }], details: {} };
256
+ }
257
+
258
+ const now = Date.now();
259
+ const activeRuns = monitor.getRuns().filter(
260
+ (run) => run.status === "queued" || run.status === "running" || run.retained,
261
+ );
262
+ const activeLines = activeRuns.map((run) => {
263
+ const parts = [
264
+ `#${run.id} ${run.agent}`,
265
+ run.model ?? "?",
266
+ formatUsageCompact(run.usage),
267
+ formatElapsed(run, now),
268
+ ].filter(Boolean);
269
+ return `- ${parts.join(" · ")} · ${run.activity ?? statusLabel(run.status)}`;
270
+ });
271
+ const completed = [...runtime.settledRuns.entries()].slice(-5);
272
+ const completedLines = completed.map(([id, result]) => {
273
+ const usage = formatUsage(result.usage);
274
+ return `- #${id} ${result.agent} · ${isFailedResult(result) ? "failed" : "completed"}${usage ? ` · ${usage}` : ""}`;
275
+ });
276
+
277
+ const sections: string[] = [];
278
+ sections.push(`### Active subagent runs (${activeRuns.length})`);
279
+ sections.push(activeLines.length > 0 ? activeLines.join("\n") : "(none)");
280
+ sections.push(`### Finished this session (${runtime.settledRuns.size})`);
281
+ sections.push(completedLines.length > 0 ? completedLines.join("\n") : "(none)");
282
+ sections.push("Pass a run id to subagent_status for the full result, or subagent_wait to block for an active run.");
283
+ return { content: [{ type: "text", text: sections.join("\n\n") }], details: {} };
284
+ },
285
+
286
+ renderCall(args, theme) {
287
+ return new Text(
288
+ `${theme.fg("toolTitle", theme.bold("subagent_status "))}${theme.fg("accent", args.id ? `#${args.id}` : "overview")}`,
289
+ 0,
290
+ 0,
291
+ );
292
+ },
293
+
294
+ renderResult(result, _options, theme) {
295
+ return renderFirstLine(result, "subagent_status ", theme);
296
+ },
297
+ });
298
+
299
+ // Cancel one or more active runs: aborts the queue controller, which
300
+ // terminates the child and delivers an aborted result (with whatever partial
301
+ // output it produced) so the main agent always knows the run stopped.
302
+ const SubagentStopParams = Type.Object({
303
+ id: Type.Optional(
304
+ Type.String({
305
+ description: "Run id or prefix to stop (see the widget or subagent_status).",
306
+ }),
307
+ ),
308
+ all: Type.Optional(Type.Boolean({ description: "Stop every active run (default false)." })),
309
+ });
310
+
311
+ pi.registerTool({
312
+ name: "subagent_stop",
313
+ label: "Subagent Stop",
314
+ description: [
315
+ "Cancel one or more active background sub-agent runs: the child process is terminated and an aborted result (with partial output) is delivered.",
316
+ "Pass id (run id or prefix) to stop one run, or all: true to stop every active run.",
317
+ ].join(" "),
318
+ promptSnippet: "Stop a running background subagent (id from the widget/subagent_status; or all: true).",
319
+ promptGuidelines: [
320
+ "Stop a run when its task is obsolete, stuck, or superseded — do not leave it burning tokens.",
321
+ "A stopped run reports as failed with 'aborted' and its partial output, so the next step knows it did not complete.",
322
+ ],
323
+ parameters: SubagentStopParams,
324
+
325
+ async execute(_toolCallId, params, _signal, _onUpdate) {
326
+ const targets =
327
+ params.all === true
328
+ ? [...runtime.runControllers.keys()]
329
+ : params.id !== undefined && params.id.trim() !== ""
330
+ ? matchRunIds([...runtime.runControllers.keys()], params.id!.trim())
331
+ : [];
332
+
333
+ if (targets.length === 0) {
334
+ const activeList = [...runtime.runControllers.keys()].map((id) => `#${id}`).join(", ");
335
+ return {
336
+ content: [
337
+ {
338
+ type: "text",
339
+ text:
340
+ params.all === true
341
+ ? "No active subagent runs to stop."
342
+ : `No active subagent run matches "${params.id}".${activeList ? ` Active runs: ${activeList}.` : ""}`,
343
+ },
344
+ ],
345
+ details: {},
346
+ };
347
+ }
348
+
349
+ const stopped: string[] = [];
350
+ for (const runId of targets) {
351
+ const run = monitor.findRun(runId);
352
+ if (!run) {
353
+ runtime.runControllers.delete(runId);
354
+ continue;
355
+ }
356
+ // Abort before registering the synthetic result: abort() only marks the
357
+ // queue entry (drain delivers the cancellation callback later), so the
358
+ // has() re-check right after it distinguishes an entry that never ran
359
+ // from one whose task already started under a stale "queued" status —
360
+ // a started task owns its own (real, partial-output) result.
361
+ const controller = runtime.runControllers.get(runId);
362
+ controller?.abort();
363
+ // A queued run never reaches the child-spawn code path, so its abort
364
+ // goes through the queue's cancelled callback with no result object;
365
+ // register a synthetic aborted result so subagent_wait resolves.
366
+ if (run.status === "queued" && runtime.runControllers.has(runId)) {
367
+ runtime.registerRunResult(runId, {
368
+ agent: run.agent,
369
+ agentSource: "builtin",
370
+ task: run.task,
371
+ exitCode: 1,
372
+ messages: [],
373
+ stderr: "Stopped by subagent_stop before the run started.",
374
+ usage: emptyUsage(),
375
+ model: run.model,
376
+ thinking: run.thinking,
377
+ stopReason: "aborted",
378
+ errorMessage: "Stopped by subagent_stop before the run started.",
379
+ });
380
+ }
381
+ stopped.push(`#${runId} ${run.agent}${run.status === "queued" ? " (queued)" : ""}`);
382
+ }
383
+ return {
384
+ content: [
385
+ {
386
+ type: "text",
387
+ text: `Stopped ${stopped.length} run${stopped.length === 1 ? "" : "s"}: ${stopped.join(", ")}. An aborted result (with partial output) is delivered.`,
388
+ },
389
+ ],
390
+ details: {},
391
+ };
392
+ },
393
+
394
+ renderCall(args, theme) {
395
+ return new Text(
396
+ `${theme.fg("toolTitle", theme.bold("subagent_stop "))}${theme.fg("accent", args.all === true ? "all" : args.id ? `#${args.id}` : "?")}`,
397
+ 0,
398
+ 0,
399
+ );
400
+ },
401
+
402
+ renderResult(result, _options, theme) {
403
+ return renderFirstLine(result, "subagent_stop ", theme);
404
+ },
405
+ });
406
+ }