@ferris1225/pi-subagents 4.2.5 → 4.2.8

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/dispatch.ts CHANGED
@@ -1,541 +1,615 @@
1
- /**
2
- * The `subagent` tool: dispatches the enabled agents (explorer, executor,
3
- * plus custom roles) as isolated pi
4
- * child processes, single or parallel. Owns the public dispatch contract and
5
- * per-run status tracking. Stable thread generations, final integration, and
6
- * completion ownership live in thread-lifecycle.ts.
7
- */
8
-
9
- import { StringEnum } from "@earendil-works/pi-ai";
10
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
- import { Text } from "@earendil-works/pi-tui";
12
- import { join, resolve } from "node:path";
13
- import { Type } from "typebox";
14
- import { discoverAgents, isWriteCapableAgent, resolveAgentTools, type AgentConfig } from "./agents.ts";
15
- import { loadConfig } from "./config.ts";
16
- import { formatCompletionBlock, formatUsage, queuedResult } from "./format.ts";
17
- import {
18
- formatTaskSummary,
19
- formatToolActivity,
20
- monitor,
21
- statusIcon,
22
- type RunView,
23
- type RunWaitReason,
24
- } from "./monitor.ts";
25
- import type { SubagentRuntime } from "./runtime.ts";
26
- import { persistThreadCheckpoint } from "./thread-lifecycle.ts";
27
- import {
28
- getProjectRoot,
29
- getResultOutput,
30
- isFailedResult,
31
- runSingleAgentWithMainFallback,
32
- type SingleResult,
33
- type SubagentDetails,
34
- type SubagentLiveEvent,
35
- } from "./spawn.ts";
36
- import {
37
- createBackgroundDispatcher,
38
- projectResultsRoot,
39
- resolveDispatchModelRoute,
40
- runInManagedRepositoryLane,
41
- withWorktreeSystemPrompt,
42
- type DispatchEnvironment,
43
- } from "./thread-lifecycle.ts";
44
- import type { IsolationMode } from "./worktree.ts";
45
-
46
- export { isWorktreeCapableAgent, runInManagedRepositoryLane } from "./thread-lifecycle.ts";
47
-
48
- const NON_BLANK_TASK_OPTIONS = { minLength: 1, pattern: "\\S" } as const;
49
-
50
- const ISOLATION_DESCRIPTION =
51
- "Filesystem isolation: shared uses the caller's working tree; worktree creates a detached temporary Git worktree (write-capable agents, including executor, only)";
52
-
53
- const IsolationSchema = Type.Optional(
54
- StringEnum(["shared", "worktree"] as const, { description: ISOLATION_DESCRIPTION }),
55
- );
56
-
57
- const WaitSchema = Type.Optional(
58
- Type.Boolean({
59
- description:
60
- "Block until every run started by this call settles, then return their results in-turn (each result still arrives as a completion message too). Only for one-shot (pi -p) sessions or a next step that needs these results within this turn.",
61
- }),
62
- );
63
-
64
- const TaskItem = Type.Object({
65
- agent: Type.String({ description: "Name of the agent to invoke" }),
66
- task: Type.String({
67
- ...NON_BLANK_TASK_OPTIONS,
68
- description: "Self-contained task to delegate (the agent has no memory of this conversation)",
69
- }),
70
- cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
71
- isolation: IsolationSchema,
72
- });
73
-
74
- const SubagentParams = Type.Object({
75
- agent: Type.Optional(Type.String({ description: "Name of the agent to invoke (single mode)" })),
76
- task: Type.Optional(
77
- Type.String({ ...NON_BLANK_TASK_OPTIONS, description: "Self-contained task to delegate (single mode)" }),
78
- ),
79
- tasks: Type.Optional(Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" })),
80
- cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })),
81
- isolation: IsolationSchema,
82
- wait: WaitSchema,
83
- });
84
-
85
- /** Roles that default to worktree isolation in parallel dispatches even when
86
- * the live catalog cannot be consulted (render-only call sites). Custom
87
- * write-capable agents join them via isWriteCapableAgent on the execute path. */
88
- const WORKTREE_DEFAULT_AGENTS = new Set(["executor"]);
89
-
90
- /** Resolve the default isolation for a dispatch. Precedence: an explicit
91
- * per-call request, then the role's own frontmatter declaration (`worktree`
92
- * honored for write-capable roles only; `shared` always), then the parallel
93
- * write default — parallel write-capable agents get a detached worktree
94
- * because shared writers serialize on the repository lane, so defaulting them
95
- * to shared would turn one parallel batch into a convoy that also parks
96
- * process slots. */
97
- export function defaultIsolationMode(
98
- mode: "single" | "parallel",
99
- agentName: string,
100
- requested?: IsolationMode,
101
- writeCapable = WORKTREE_DEFAULT_AGENTS.has(agentName),
102
- declared?: IsolationMode,
103
- ): IsolationMode {
104
- if (requested) return requested;
105
- if (declared === "shared") return "shared";
106
- if (declared === "worktree" && writeCapable) return "worktree";
107
- return mode === "parallel" && writeCapable ? "worktree" : "shared";
108
- }
109
-
110
- /** In-turn wait behind dispatch `wait: true` the escape hatch for one-shot
111
- * `pi -p` parents that exit at end of turn: hold the call until every run it
112
- * started settles, then hand back their result blocks. Interactive sessions
113
- * never take this path; their results arrive as completion wake-ups. No
114
- * timer: a waiter resolves the moment its run's result registers (children
115
- * are bounded by the idle watchdog), an already-parked run answers
116
- * immediately with its resume handle, and the turn's abort signal remains the
117
- * escape hatch. */
118
- export async function awaitRunResults(
119
- runtime: SubagentRuntime,
120
- runIds: number[],
121
- signal: AbortSignal | undefined,
122
- maxResultLines: number,
123
- fallbackCwd: string,
124
- ): Promise<string> {
125
- const waitForRun = (runId: number): Promise<{ result?: SingleResult; note?: string }> => {
126
- const already = runtime.settledRuns.get(runId);
127
- if (already) return Promise.resolve({ result: already });
128
- if (monitor.findRun(runId)?.status === "parked") {
129
- return Promise.resolve({ note: `run #${runId} is parked at a stable checkpoint; use subagent_control resume to continue it` });
130
- }
131
- return new Promise((resolve) => {
132
- let done = false;
133
- let unsub: (() => void) | undefined;
134
- const cleanup = (): void => {
135
- if (unsub) unsub();
136
- signal?.removeEventListener("abort", onAbort);
137
- const listeners = runtime.settledListeners.get(runId);
138
- if (listeners) {
139
- listeners.delete(onSettled);
140
- if (listeners.size === 0) runtime.settledListeners.delete(runId);
141
- }
142
- };
143
- const finish = (outcome: { result?: SingleResult; note?: string }): void => {
144
- if (done) return;
145
- done = true;
146
- cleanup();
147
- resolve(outcome);
148
- };
149
- const onSettled = (result: SingleResult): void => finish({ result });
150
- const onMonitor = (): void => {
151
- const current = runtime.settledRuns.get(runId);
152
- if (current) {
153
- finish({ result: current });
154
- return;
155
- }
156
- const live = monitor.findRun(runId);
157
- if (live?.status === "parked") {
158
- finish({ note: `run #${runId} was parked at a stable checkpoint; use subagent_control resume to continue it` });
159
- return;
160
- }
161
- if (!live) {
162
- // Removal is followed synchronously by registerRunResult in the
163
- // finishing task; re-check on the next tick so the result wins.
164
- setTimeout(() => {
165
- const late = runtime.settledRuns.get(runId);
166
- if (late) finish({ result: late });
167
- else finish({ note: `run #${runId} was removed before its result was recorded (cancelled or session ended)` });
168
- }, 0);
169
- }
170
- };
171
- const onAbort = (): void => finish({ note: "wait aborted" });
172
- let listeners = runtime.settledListeners.get(runId);
173
- if (!listeners) {
174
- listeners = new Set();
175
- runtime.settledListeners.set(runId, listeners);
176
- }
177
- listeners.add(onSettled);
178
- unsub = monitor.subscribe(onMonitor);
179
- if (signal?.aborted) onAbort();
180
- else signal?.addEventListener("abort", onAbort, { once: true });
181
- });
182
- };
183
- const outcomes = await Promise.all(runIds.map(waitForRun));
184
- return outcomes.map((outcome) =>
185
- outcome.result
186
- ? formatCompletionBlock(outcome.result, maxResultLines, { resultRoot: projectResultsRoot(runtime.configPath, outcome.result.projectCwd ?? fallbackCwd) })
187
- : (outcome.note ?? "(no outcome)"),
188
- ).join("\n\n");
189
- }
190
-
191
- export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime): void {
192
- // Latest dispatch environment. The dispatcher is created once per process so
193
- // restored threads can resume before any dispatch has run; each execute
194
- // refreshes the fallback context, config, and agent catalog it resolves.
195
- const environmentRef: { current: DispatchEnvironment | undefined } = { current: undefined };
196
-
197
- // Finished runs leave the active monitor immediately. Their final findings
198
- // are sent as a custom message that starts a follow-up turn.
199
- const finishRun = (
200
- runId: number,
201
- status: "done" | "failed",
202
- opts?: { silent?: boolean },
203
- ): void => {
204
- monitor.setStatus(runId, status); // stamps endedAt for the elapsed time
205
- const run = monitor.removeRun(runId);
206
- if (!run) return; // already finished — stay idempotent
207
- if (opts?.silent || !runtime.sessionActive) return;
208
- const icon = status === "done" ? "✓" : "✗";
209
- environmentRef.current?.ctx.ui.notify(`${icon} #${run.id} ${monitor.summarize(run)}`, status === "done" ? "info" : "error");
210
- };
211
-
212
- // Live sub-agent activity → concise one-line status ("thinking",
213
- // "read src/index.ts", ...), never a raw args blob. The live handler
214
- // only updates monitor state; the queue task owns terminal removal,
215
- // notification, and lifecycle decisions.
216
- const makeLiveHandler =
217
- (runId: number, generation?: number) =>
218
- (e: SubagentLiveEvent): void => {
219
- if (generation !== undefined && runtime.threads.get(runId)?.generation !== generation) return;
220
- switch (e.kind) {
221
- case "status":
222
- monitor.setStatus(runId, e.status);
223
- // A fresh running segment refreshes the durable checkpoint (session
224
- // path plus child pids) so a crash mid-generation still restores.
225
- if (e.status === "running") {
226
- const thread = runtime.threads.get(runId);
227
- if (thread?.sessionId && thread.sessionDir) {
228
- persistThreadCheckpoint(runtime, thread, "parked");
229
- }
230
- }
231
- break;
232
- case "model":
233
- monitor.setModel(runId, e.model, e.fallbackFrom);
234
- monitor.setThinking(runId, e.thinking);
235
- break;
236
- case "usage":
237
- monitor.setUsage(runId, e.usage, e.model);
238
- break;
239
- case "session": {
240
- runtime.retainSession({ sessionDir: e.sessionDir });
241
- const thread = runtime.threads.get(runId);
242
- if (thread && (generation === undefined || runtime.threads.get(runId)?.generation === generation)) {
243
- thread.sessionId = e.sessionId;
244
- thread.sessionDir = e.sessionDir;
245
- persistThreadCheckpoint(runtime, thread, "parked");
246
- }
247
- break;
248
- }
249
- case "tool_start":
250
- monitor.recordToolStart(runId, e.toolName, formatToolActivity(e.toolName, e.args));
251
- break;
252
- case "tool_end":
253
- monitor.recordToolEnd(runId, e.toolName, e.isError);
254
- break;
255
- case "thinking":
256
- monitor.setActivity(runId, "thinking");
257
- break;
258
- case "text":
259
- // A text delta is model output, not a filesystem write.
260
- monitor.setActivity(runId, "responding");
261
- break;
262
- }
263
- };
264
-
265
- const makeDetails =
266
- (mode: "single" | "parallel", background = false) =>
267
- (results: SingleResult[]): SubagentDetails => ({ mode, results, background });
268
-
269
- /** Pacing note appended to dispatch confirmations whenever runs are actually
270
- * waiting. Slot waits and repository-lane waits are stated separately with
271
- * the real capacity: a lane-serialized shared writer or a starting child
272
- * must never read as an exhausted pool, or the model stops dispatching while
273
- * slots are free. Empty when nothing is waiting. */
274
- const queuePacingNote = (): string => {
275
- const runs = monitor.getRuns();
276
- const queuedWith = (reason: RunWaitReason): number =>
277
- runs.filter((run) => run.status === "queued" && run.waitReason === reason).length;
278
- const slotWaiting = queuedWith("process-slot");
279
- const laneWaiting = queuedWith("repository-lane");
280
- if (slotWaiting === 0 && laneWaiting === 0) return "";
281
- const executing = runs.filter((run) =>
282
- run.status === "running" || run.status === "interrupting" || (run.status === "queued" && run.waitReason === "starting"),
283
- ).length;
284
- const capacity = runtime.backgroundQueue.capacity;
285
- const freeSlots = Math.max(0, capacity - runtime.backgroundQueue.activeCount);
286
- const parts = [`${executing} running`];
287
- if (slotWaiting > 0) {
288
- parts.push(`${slotWaiting} waiting for a free process slot (capacity ${capacity}); they start automatically as slots free`);
289
- }
290
- if (laneWaiting > 0) {
291
- parts.push(
292
- `${laneWaiting} shared-checkout writer${laneWaiting === 1 ? "" : "s"} waiting for the repository write lane — write serialization, not slot capacity` +
293
- (slotWaiting === 0 ? ` (${freeSlots} of ${capacity} slots free; parallel writers avoid the lane via worktree isolation)` : ""),
294
- );
295
- }
296
- return ` Pacing: ${parts.join(" · ")}. Keep dispatching independent units.`;
297
- };
298
-
299
- const startBackground = createBackgroundDispatcher({
300
- runtime,
301
- getEnvironment: () => {
302
- if (!environmentRef.current) {
303
- throw new Error("pi-subagents dispatch environment is not ready yet.");
304
- }
305
- return environmentRef.current;
306
- },
307
- finishRun,
308
- makeLiveHandler,
309
- makeDetails,
310
- });
311
- runtime.dispatcher = startBackground;
312
-
313
- pi.registerTool({
314
- name: "subagent",
315
- label: "Subagent",
316
- description: [
317
- "Dispatch enabled agents as isolated leaf Pi child processes: single {agent, task} or parallel {tasks: [...]}. Dispatching never blocks your turn — runs proceed in the background and each completion resumes you automatically; never poll or restate delivered results.",
318
- "Put every genuinely independent unit in one `tasks` array: there is no per-call cap, and runs beyond the machine's free process slots simply wait and start as slots free.",
319
- "Parallel write-capable agents default to a detached Git worktree so writers run concurrently; explicit `shared` keeps the caller's checkout and serializes same-repository writers. Worktree setup failure never silently falls back to shared.",
320
- "A configured child-model failure continues the retained session on the current main model.",
321
- ].join(" "),
322
- promptSnippet:
323
- "Dispatch isolated background agents for recon, implementation, cleanup, docs sync, or result merging; never blocks your turn, and completions wake you automatically.",
324
- parameters: SubagentParams,
325
-
326
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
327
- // Run ids are allocated below; restore raises the allocator above every
328
- // id a durable record still owns, so a dispatch racing it could hand a
329
- // fresh run the id of a parked thread and overwrite its record.
330
- await runtime.durableRestore;
331
- monitor.beginTurn();
332
- const config = await loadConfig(runtime.configPath);
333
-
334
- const discovery = discoverAgents(ctx.cwd, {
335
- scope: config.agentScope,
336
- enabledNames: config.enabledAgents,
337
- projectTrusted: ctx.isProjectTrusted?.() === true,
338
- });
339
- const agents = discovery.agents;
340
- // Refresh the dispatcher's fallback environment so control operations
341
- // (resume of restored threads) never run on a stale context.
342
- environmentRef.current = { ctx, config, agents };
343
-
344
- const hasTasks = (params.tasks?.length ?? 0) > 0;
345
- const hasSingle = Boolean(params.agent) && params.task !== undefined;
346
-
347
- const catalog = agents.map((a) => a.name).join(", ") || "none";
348
-
349
- if (Number(hasTasks) + Number(hasSingle) !== 1) {
350
- return {
351
- content: [
352
- {
353
- type: "text",
354
- text: `Invalid parameters. Provide exactly one mode: single {agent, task} or parallel {tasks: [...]}. Enabled agents: ${catalog}.`,
355
- },
356
- ],
357
- details: makeDetails("single")([]),
358
- };
359
- }
360
-
361
- if (hasTasks) {
362
- const blankTaskIndex = params.tasks?.findIndex(({ task }) => task.trim().length === 0) ?? -1;
363
- if (blankTaskIndex !== -1) {
364
- return {
365
- content: [
366
- {
367
- type: "text",
368
- text: `Invalid parameters. tasks[${blankTaskIndex}].task must contain at least one non-whitespace character. No background tasks were started. Enabled agents: ${catalog}.`,
369
- },
370
- ],
371
- details: makeDetails("parallel")([]),
372
- };
373
- }
374
- } else if (params.task?.trim().length === 0) {
375
- return {
376
- content: [
377
- {
378
- type: "text",
379
- text: `Invalid parameters. task must contain at least one non-whitespace character. Enabled agents: ${catalog}.`,
380
- },
381
- ],
382
- details: makeDetails("single")([]),
383
- };
384
- }
385
-
386
- // Sub-agents run detached from the foreground turn: the editor stays
387
- // available and completion messages later wake the main agent. The turn is
388
- // NOT terminated here — the model can keep dispatching independent units
389
- // or do its own work, and the background queue paces how many child
390
- // processes actually run at once, so no per-call task cap is enforced.
391
- if (params.tasks && params.tasks.length > 0) {
392
- const results: SingleResult[] = [];
393
- // Preserve caller order (and deterministic completion batching) while
394
- // preparing each isolated filesystem before its queue entry can start.
395
- for (const item of params.tasks) {
396
- const catalogAgent = agents.find((candidate) => candidate.name === item.agent);
397
- results.push(await startBackground(
398
- item.agent,
399
- item.task,
400
- item.cwd,
401
- defaultIsolationMode(
402
- "parallel",
403
- item.agent,
404
- item.isolation as IsolationMode | undefined,
405
- catalogAgent ? isWriteCapableAgent(catalogAgent) : undefined,
406
- catalogAgent?.isolation,
407
- ),
408
- ));
409
- }
410
- const startedRuns = results.filter((result) => result.exitCode === -1);
411
- const started = startedRuns.length;
412
- const startedRefs = startedRuns.map((result) =>
413
- result.runId === undefined ? result.agent : `#${result.runId} ${result.agent}`,
414
- );
415
- const failureLines = results.flatMap((result, index) => {
416
- if (result.exitCode === -1) return [];
417
- const reason = getResultOutput(result).trim() || "unknown startup failure";
418
- return [
419
- `- tasks[${index}] (${params.tasks![index]!.agent}) failed to start: ${reason.replace(/\n/g, "\n ")}`,
420
- ];
421
- });
422
- if (started === 0) {
423
- // Pi marks custom-tool failures only when execute throws; returning an
424
- // `isError` property is still a successful AgentToolResult.
425
- throw new Error(`No background subagents were started.\n${failureLines.join("\n")}`);
426
- }
427
- if (params.wait) {
428
- const startedIds = startedRuns
429
- .map((result) => result.runId)
430
- .filter((id): id is number => id !== undefined);
431
- const blocks = await awaitRunResults(runtime, startedIds, signal, config.maxResultLines, ctx.cwd);
432
- const text = [
433
- `Started ${started} subagent${started === 1 ? "" : "s"} (${startedRefs.join(", ")}) and waited in-turn.`,
434
- ...(failureLines.length > 0
435
- ? [`${failureLines.length} task${failureLines.length === 1 ? "" : "s"} failed before launch:`, ...failureLines]
436
- : []),
437
- "",
438
- blocks,
439
- ].join("\n");
440
- return {
441
- content: [{ type: "text", text }],
442
- details: makeDetails("parallel", true)(results),
443
- };
444
- }
445
- const text = [
446
- `Started ${started} background subagent${started === 1 ? "" : "s"}: ${startedRefs.join(", ")}. They run in the background and never block you — dispatch more independent units now or keep working; each result resumes you automatically when you are idle.`,
447
- ...(failureLines.length > 0
448
- ? [`${failureLines.length} task${failureLines.length === 1 ? "" : "s"} failed before launch:`, ...failureLines]
449
- : []),
450
- ].join("\n") + queuePacingNote();
451
- return {
452
- content: [{ type: "text", text }],
453
- details: makeDetails("parallel", true)(results),
454
- };
455
- }
456
-
457
- const singleCatalogAgent = agents.find((candidate) => candidate.name === params.agent);
458
- const result = await startBackground(
459
- params.agent as string,
460
- params.task as string,
461
- params.cwd,
462
- defaultIsolationMode(
463
- "single",
464
- params.agent as string,
465
- params.isolation as IsolationMode | undefined,
466
- singleCatalogAgent ? isWriteCapableAgent(singleCatalogAgent) : undefined,
467
- singleCatalogAgent?.isolation,
468
- ),
469
- );
470
- if (result.exitCode !== -1) {
471
- throw new Error(getResultOutput(result));
472
- }
473
- const runRef = result.runId === undefined ? result.agent : `#${result.runId} ${result.agent}`;
474
- if (params.wait && result.runId !== undefined) {
475
- const blocks = await awaitRunResults(runtime, [result.runId], signal, config.maxResultLines, ctx.cwd);
476
- return {
477
- content: [{ type: "text", text: `Started ${runRef} and waited in-turn.\n\n${blocks}` }],
478
- details: makeDetails("single", true)([result]),
479
- };
480
- }
481
- return {
482
- content: [{ type: "text", text: `Started ${runRef} in the background. It never blocks you — dispatch more independent units now or keep working; its result resumes you automatically when you are idle.${queuePacingNote()}` }],
483
- details: makeDetails("single", true)([result]),
484
- };
485
-
486
- },
487
-
488
- renderCall(args, theme) {
489
- if (args.tasks && args.tasks.length > 0) {
490
- let text = `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", `parallel (${args.tasks.length})`)}`;
491
- for (const t of args.tasks.slice(0, 4)) {
492
- const preview = formatTaskSummary(t.task, 48);
493
- const isolation = defaultIsolationMode("parallel", t.agent, t.isolation) === "worktree" ? " [worktree]" : "";
494
- text += `\n ${theme.fg("accent", t.agent)}${theme.fg("dim", isolation)} ${theme.fg("dim", preview)}`;
495
- }
496
- if (args.tasks.length > 4) text += `\n ${theme.fg("dim", `… +${args.tasks.length - 4} more`)}`;
497
- return new Text(text, 0, 0);
498
- }
499
- const task: string = args.task ?? "";
500
- const preview = formatTaskSummary(task, 60);
501
- const isolation = args.isolation === "worktree" ? " [worktree]" : "";
502
- return new Text(
503
- `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", args.agent ?? "?")}${theme.fg("dim", `${isolation}`)} ${theme.fg("dim", preview)}`,
504
- 0,
505
- 0,
506
- );
507
- },
508
-
509
- renderResult(result, _options, theme) {
510
- const details = result.details as SubagentDetails | undefined;
511
- if (!details || details.results.length === 0) return new Text(theme.fg("dim", "(no output)"), 0, 0);
512
-
513
- if (details.mode === "single") {
514
- const r = details.results[0];
515
- const pending = r.exitCode === -1;
516
- const icon = statusIcon(pending ? "running" : isFailedResult(r) ? "failed" : "done", theme);
517
- const usage = formatUsage(r.usage);
518
- const model = `${r.model ?? "?"}${r.modelFallbackFrom ? ` (main after ${r.modelFallbackFrom} failed)` : ""}`;
519
- const isolation = r.isolation === "worktree" ? ` · worktree ${r.integrationStatus ?? "active"}` : "";
520
- const runId = r.runId === undefined ? "" : `${theme.fg("dim", `#${r.runId}`)} `;
521
- const line = `${theme.fg("toolTitle", theme.bold("subagent "))}${icon} ${runId}${theme.fg("accent", r.agent)} ${theme.fg("dim", `· ${model}${r.thinking ? ` · thinking ${r.thinking}` : ""}${isolation}${pending ? " · background" : ""}${usage ? ` · ${usage}` : ""}`)}`;
522
- return new Text(line, 0, 0);
523
- }
524
-
525
- // Parallel mode: header + one compact line per agent
526
- const lines: string[] = [
527
- `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", `parallel (${details.results.length})`)}`,
528
- ];
529
- for (const r of details.results) {
530
- const pending = r.exitCode === -1;
531
- const icon = statusIcon(pending ? "running" : isFailedResult(r) ? "failed" : "done", theme);
532
- const usage = formatUsage(r.usage);
533
- const model = `${r.model ?? "?"}${r.modelFallbackFrom ? ` (main after ${r.modelFallbackFrom} failed)` : ""}`;
534
- const isolation = r.isolation === "worktree" ? ` · worktree ${r.integrationStatus ?? "active"}` : "";
535
- const runId = r.runId === undefined ? "" : `${theme.fg("dim", `#${r.runId}`)} `;
536
- lines.push(` ${icon} ${runId}${theme.fg("accent", r.agent)} ${theme.fg("dim", `· ${model}${r.thinking ? ` · thinking ${r.thinking}` : ""}${isolation}${pending ? " · background" : ""}${usage ? ` · ${usage}` : ""}`)}`);
537
- }
538
- return new Text(lines.join("\n"), 0, 0);
539
- },
540
- });
541
- }
1
+ /**
2
+ * The `subagent` tool: dispatches the enabled agents (explorer, executor,
3
+ * plus custom roles) as isolated pi
4
+ * child processes, single or parallel. Owns the public dispatch contract and
5
+ * per-run status tracking. Stable thread generations, final integration, and
6
+ * completion ownership live in thread-lifecycle.ts.
7
+ */
8
+
9
+ import { StringEnum, type Usage } from "@earendil-works/pi-ai";
10
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
+ import { Text } from "@earendil-works/pi-tui";
12
+ import { join, resolve } from "node:path";
13
+ import { Type } from "typebox";
14
+ import { discoverAgents, isWriteCapableAgent, resolveAgentTools, type AgentConfig } from "./agents.ts";
15
+ import { loadConfig, THINKING_LEVEL_VALUES, type ThinkingLevel } from "./config.ts";
16
+ import { formatCompletionBlock, formatUsage, queuedResult } from "./format.ts";
17
+ import {
18
+ formatTaskSummary,
19
+ formatToolActivity,
20
+ monitor,
21
+ statusIcon,
22
+ statusLabel,
23
+ sumUsage,
24
+ type RunView,
25
+ type RunWaitReason,
26
+ } from "./monitor.ts";
27
+ import type { SubagentRuntime } from "./runtime.ts";
28
+ import { persistThreadCheckpoint } from "./thread-lifecycle.ts";
29
+ import {
30
+ getProjectRoot,
31
+ getResultOutput,
32
+ isFailedResult,
33
+ runSingleAgentWithMainFallback,
34
+ type SingleResult,
35
+ type SubagentDetails,
36
+ type SubagentLiveEvent,
37
+ type UsageStats,
38
+ } from "./spawn.ts";
39
+ import {
40
+ createBackgroundDispatcher,
41
+ projectResultsRoot,
42
+ resolveDispatchModelRoute,
43
+ runInManagedRepositoryLane,
44
+ withWorktreeSystemPrompt,
45
+ type DispatchEnvironment,
46
+ } from "./thread-lifecycle.ts";
47
+ import type { IsolationMode } from "./worktree.ts";
48
+
49
+ export { isWorktreeCapableAgent, runInManagedRepositoryLane } from "./thread-lifecycle.ts";
50
+
51
+ const NON_BLANK_TASK_OPTIONS = { minLength: 1, pattern: "\\S" } as const;
52
+
53
+ const ISOLATION_DESCRIPTION =
54
+ "Filesystem isolation: shared uses the caller's working tree; worktree creates a detached temporary Git worktree (write-capable agents, including executor, only)";
55
+
56
+ const IsolationSchema = Type.Optional(
57
+ StringEnum(["shared", "worktree"] as const, { description: ISOLATION_DESCRIPTION }),
58
+ );
59
+
60
+ const ThinkingSchema = Type.Optional(
61
+ StringEnum(THINKING_LEVEL_VALUES, {
62
+ description:
63
+ "Optional reasoning strength for this task; omit to keep the agent's own level",
64
+ }),
65
+ );
66
+
67
+ const WaitSchema = Type.Optional(
68
+ Type.Boolean({
69
+ description:
70
+ "Block until every run started by this call settles, then return their results in-turn (each result still arrives as a completion message too). Only for one-shot (pi -p) sessions or a next step that needs these results within this turn.",
71
+ }),
72
+ );
73
+
74
+ const TaskItem = Type.Object({
75
+ agent: Type.String({ description: "Name of the agent to invoke" }),
76
+ task: Type.String({
77
+ ...NON_BLANK_TASK_OPTIONS,
78
+ description: "Self-contained task to delegate (the agent has no memory of this conversation)",
79
+ }),
80
+ cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
81
+ isolation: IsolationSchema,
82
+ thinking: ThinkingSchema,
83
+ });
84
+
85
+ const SubagentParams = Type.Object({
86
+ agent: Type.Optional(Type.String({ description: "Name of the agent to invoke (single mode)" })),
87
+ task: Type.Optional(
88
+ Type.String({ ...NON_BLANK_TASK_OPTIONS, description: "Self-contained task to delegate (single mode)" }),
89
+ ),
90
+ tasks: Type.Optional(Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" })),
91
+ cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })),
92
+ isolation: IsolationSchema,
93
+ thinking: ThinkingSchema,
94
+ wait: WaitSchema,
95
+ });
96
+
97
+ /** Roles that default to worktree isolation in parallel dispatches even when
98
+ * the live catalog cannot be consulted (render-only call sites). Custom
99
+ * write-capable agents join them via isWriteCapableAgent on the execute path. */
100
+ const WORKTREE_DEFAULT_AGENTS = new Set(["executor"]);
101
+
102
+ /** Resolve the default isolation for a dispatch. Precedence: an explicit
103
+ * per-call request, then the role's own frontmatter declaration (`worktree`
104
+ * honored for write-capable roles only; `shared` always), then the parallel
105
+ * write default parallel write-capable agents get a detached worktree
106
+ * because shared writers serialize on the repository lane, so defaulting them
107
+ * to shared would turn one parallel batch into a convoy that also parks
108
+ * process slots. */
109
+ export function defaultIsolationMode(
110
+ mode: "single" | "parallel",
111
+ agentName: string,
112
+ requested?: IsolationMode,
113
+ writeCapable = WORKTREE_DEFAULT_AGENTS.has(agentName),
114
+ declared?: IsolationMode,
115
+ ): IsolationMode {
116
+ if (requested) return requested;
117
+ if (declared === "shared") return "shared";
118
+ if (declared === "worktree" && writeCapable) return "worktree";
119
+ return mode === "parallel" && writeCapable ? "worktree" : "shared";
120
+ }
121
+
122
+ /** Map the child's own usage tally onto pi's tool-result `Usage`, so sub-agent
123
+ * token spend lands in the parent's footer, /session, and RPC session totals
124
+ * instead of being invisible. Only the total cost is known here: a child
125
+ * reports one cost number, not a per-bucket split. */
126
+ function toToolUsage(stats: UsageStats): Usage {
127
+ return {
128
+ input: stats.input,
129
+ output: stats.output,
130
+ cacheRead: stats.cacheRead,
131
+ cacheWrite: stats.cacheWrite,
132
+ totalTokens: stats.input + stats.output + stats.cacheRead + stats.cacheWrite,
133
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: stats.cost },
134
+ };
135
+ }
136
+
137
+ /** Usage of the runs awaited in-turn. Omitted entirely in the background path:
138
+ * those children have not finished when the tool returns, so any number there
139
+ * would be a fabrication. */
140
+ function toolUsage(runtime: SubagentRuntime, runIds: number[]): { usage?: Usage } {
141
+ const parts = runIds
142
+ .map((id) => runtime.settledRuns.get(id)?.usage)
143
+ .filter((usage): usage is UsageStats => usage !== undefined);
144
+ return parts.length > 0 ? { usage: toToolUsage(sumUsage(parts)) } : {};
145
+ }
146
+
147
+ /** In-turn wait behind dispatch `wait: true` — the escape hatch for one-shot
148
+ * `pi -p` parents that exit at end of turn: hold the call until every run it
149
+ * started settles, then hand back their result blocks. Interactive sessions
150
+ * never take this path; their results arrive as completion wake-ups. No
151
+ * timer: a waiter resolves the moment its run's result registers (children
152
+ * are bounded by the idle watchdog), an already-parked run answers
153
+ * immediately with its resume handle, and the turn's abort signal remains the
154
+ * escape hatch. */
155
+ export async function awaitRunResults(
156
+ runtime: SubagentRuntime,
157
+ runIds: number[],
158
+ signal: AbortSignal | undefined,
159
+ maxResultLines: number,
160
+ fallbackCwd: string,
161
+ onProgress?: (text: string) => void,
162
+ ): Promise<string> {
163
+ const waitForRun = (runId: number): Promise<{ result?: SingleResult; note?: string }> => {
164
+ const already = runtime.settledRuns.get(runId);
165
+ if (already) return Promise.resolve({ result: already });
166
+ if (monitor.findRun(runId)?.status === "parked") {
167
+ return Promise.resolve({ note: `run #${runId} is parked at a stable checkpoint; use subagent_control resume to continue it` });
168
+ }
169
+ return new Promise((resolve) => {
170
+ let done = false;
171
+ let unsub: (() => void) | undefined;
172
+ const cleanup = (): void => {
173
+ if (unsub) unsub();
174
+ signal?.removeEventListener("abort", onAbort);
175
+ const listeners = runtime.settledListeners.get(runId);
176
+ if (listeners) {
177
+ listeners.delete(onSettled);
178
+ if (listeners.size === 0) runtime.settledListeners.delete(runId);
179
+ }
180
+ };
181
+ const finish = (outcome: { result?: SingleResult; note?: string }): void => {
182
+ if (done) return;
183
+ done = true;
184
+ cleanup();
185
+ resolve(outcome);
186
+ };
187
+ const onSettled = (result: SingleResult): void => finish({ result });
188
+ const onMonitor = (): void => {
189
+ const current = runtime.settledRuns.get(runId);
190
+ if (current) {
191
+ finish({ result: current });
192
+ return;
193
+ }
194
+ const live = monitor.findRun(runId);
195
+ if (live?.status === "parked") {
196
+ finish({ note: `run #${runId} was parked at a stable checkpoint; use subagent_control resume to continue it` });
197
+ return;
198
+ }
199
+ if (!live) {
200
+ // Removal is followed synchronously by registerRunResult in the
201
+ // finishing task; re-check on the next tick so the result wins.
202
+ setTimeout(() => {
203
+ const late = runtime.settledRuns.get(runId);
204
+ if (late) finish({ result: late });
205
+ else finish({ note: `run #${runId} was removed before its result was recorded (cancelled or session ended)` });
206
+ }, 0);
207
+ }
208
+ };
209
+ const onAbort = (): void => finish({ note: "wait aborted" });
210
+ let listeners = runtime.settledListeners.get(runId);
211
+ if (!listeners) {
212
+ listeners = new Set();
213
+ runtime.settledListeners.set(runId, listeners);
214
+ }
215
+ listeners.add(onSettled);
216
+ unsub = monitor.subscribe(onMonitor);
217
+ if (signal?.aborted) onAbort();
218
+ else signal?.addEventListener("abort", onAbort, { once: true });
219
+ });
220
+ };
221
+ // One shared subscription drives the progress line: each waiter already
222
+ // subscribes for its own settlement, and the tool card wants a single
223
+ // rolled-up line rather than one per run.
224
+ let lastProgress: string | undefined;
225
+ const emitProgress = onProgress
226
+ ? (): void => {
227
+ const parts = runIds.map((id) => {
228
+ const settled = runtime.settledRuns.get(id);
229
+ if (settled) return `#${id} ${isFailedResult(settled) ? "failed" : "done"}`;
230
+ const live = monitor.findRun(id);
231
+ return live ? `#${id} ${statusLabel(live.status)}` : `#${id} …`;
232
+ });
233
+ const text = `Waiting in-turn on ${runIds.length} run${runIds.length === 1 ? "" : "s"} · ${parts.join(", ")}`;
234
+ // The monitor notifies on every usage and activity change; this line
235
+ // names only statuses, so most notifications leave it identical.
236
+ if (text === lastProgress) return;
237
+ lastProgress = text;
238
+ onProgress(text);
239
+ }
240
+ : undefined;
241
+ const progressUnsub = emitProgress ? monitor.subscribe(emitProgress) : undefined;
242
+ emitProgress?.();
243
+ try {
244
+ const outcomes = await Promise.all(runIds.map(waitForRun));
245
+ return outcomes.map((outcome) =>
246
+ outcome.result
247
+ ? formatCompletionBlock(outcome.result, maxResultLines, { resultRoot: projectResultsRoot(runtime.configPath, outcome.result.projectCwd ?? fallbackCwd) })
248
+ : (outcome.note ?? "(no outcome)"),
249
+ ).join("\n\n");
250
+ } finally {
251
+ progressUnsub?.();
252
+ }
253
+ }
254
+
255
+ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime): void {
256
+ // Latest dispatch environment. The dispatcher is created once per process so
257
+ // restored threads can resume before any dispatch has run; each execute
258
+ // refreshes the fallback context, config, and agent catalog it resolves.
259
+ const environmentRef: { current: DispatchEnvironment | undefined } = { current: undefined };
260
+
261
+ // Finished runs leave the active monitor immediately. Their final findings
262
+ // are sent as a custom message that starts a follow-up turn.
263
+ const finishRun = (
264
+ runId: number,
265
+ status: "done" | "failed",
266
+ opts?: { silent?: boolean },
267
+ ): void => {
268
+ monitor.setStatus(runId, status); // stamps endedAt for the elapsed time
269
+ const run = monitor.removeRun(runId);
270
+ if (!run) return; // already finished stay idempotent
271
+ if (opts?.silent || !runtime.sessionActive) return;
272
+ const icon = status === "done" ? "✓" : "✗";
273
+ environmentRef.current?.ctx.ui.notify(`${icon} #${run.id} ${monitor.summarize(run)}`, status === "done" ? "info" : "error");
274
+ };
275
+
276
+ // Live sub-agent activity concise one-line status ("thinking",
277
+ // "read src/index.ts", ...), never a raw args blob. The live handler
278
+ // only updates monitor state; the queue task owns terminal removal,
279
+ // notification, and lifecycle decisions.
280
+ const makeLiveHandler =
281
+ (runId: number, generation?: number) =>
282
+ (e: SubagentLiveEvent): void => {
283
+ if (generation !== undefined && runtime.threads.get(runId)?.generation !== generation) return;
284
+ switch (e.kind) {
285
+ case "status":
286
+ monitor.setStatus(runId, e.status);
287
+ // A fresh running segment refreshes the durable checkpoint (session
288
+ // path plus child pids) so a crash mid-generation still restores.
289
+ if (e.status === "running") {
290
+ const thread = runtime.threads.get(runId);
291
+ if (thread?.sessionId && thread.sessionDir) {
292
+ persistThreadCheckpoint(runtime, thread, "parked");
293
+ }
294
+ }
295
+ break;
296
+ case "model":
297
+ monitor.setModel(runId, e.model, e.fallbackFrom);
298
+ monitor.setThinking(runId, e.thinking);
299
+ break;
300
+ case "usage":
301
+ monitor.setUsage(runId, e.usage, e.model);
302
+ break;
303
+ case "session": {
304
+ runtime.retainSession({ sessionDir: e.sessionDir });
305
+ const thread = runtime.threads.get(runId);
306
+ if (thread && (generation === undefined || runtime.threads.get(runId)?.generation === generation)) {
307
+ thread.sessionId = e.sessionId;
308
+ thread.sessionDir = e.sessionDir;
309
+ persistThreadCheckpoint(runtime, thread, "parked");
310
+ }
311
+ break;
312
+ }
313
+ case "tool_start":
314
+ monitor.recordToolStart(runId, e.toolName, formatToolActivity(e.toolName, e.args));
315
+ break;
316
+ case "tool_end":
317
+ monitor.recordToolEnd(runId, e.toolName, e.isError);
318
+ break;
319
+ case "thinking":
320
+ monitor.setActivity(runId, "thinking");
321
+ break;
322
+ case "text":
323
+ // A text delta is model output, not a filesystem write.
324
+ monitor.setActivity(runId, "responding");
325
+ break;
326
+ }
327
+ };
328
+
329
+ const makeDetails =
330
+ (mode: "single" | "parallel", background = false) =>
331
+ (results: SingleResult[]): SubagentDetails => ({ mode, results, background });
332
+
333
+ /** Pacing note appended to dispatch confirmations whenever runs are actually
334
+ * waiting. Slot waits and repository-lane waits are stated separately with
335
+ * the real capacity: a lane-serialized shared writer or a starting child
336
+ * must never read as an exhausted pool, or the model stops dispatching while
337
+ * slots are free. Empty when nothing is waiting. */
338
+ const queuePacingNote = (): string => {
339
+ const runs = monitor.getRuns();
340
+ const queuedWith = (reason: RunWaitReason): number =>
341
+ runs.filter((run) => run.status === "queued" && run.waitReason === reason).length;
342
+ const slotWaiting = queuedWith("process-slot");
343
+ const laneWaiting = queuedWith("repository-lane");
344
+ if (slotWaiting === 0 && laneWaiting === 0) return "";
345
+ const executing = runs.filter((run) =>
346
+ run.status === "running" || run.status === "interrupting" || (run.status === "queued" && run.waitReason === "starting"),
347
+ ).length;
348
+ const capacity = runtime.backgroundQueue.capacity;
349
+ const freeSlots = Math.max(0, capacity - runtime.backgroundQueue.activeCount);
350
+ const parts = [`${executing} running`];
351
+ if (slotWaiting > 0) {
352
+ parts.push(`${slotWaiting} waiting for a free process slot (capacity ${capacity}); they start automatically as slots free`);
353
+ }
354
+ if (laneWaiting > 0) {
355
+ parts.push(
356
+ `${laneWaiting} shared-checkout writer${laneWaiting === 1 ? "" : "s"} waiting for the repository write lane — write serialization, not slot capacity` +
357
+ (slotWaiting === 0 ? ` (${freeSlots} of ${capacity} slots free; parallel writers avoid the lane via worktree isolation)` : ""),
358
+ );
359
+ }
360
+ return ` Pacing: ${parts.join(" · ")}. Keep dispatching independent units.`;
361
+ };
362
+
363
+ const startBackground = createBackgroundDispatcher({
364
+ runtime,
365
+ getEnvironment: () => {
366
+ if (!environmentRef.current) {
367
+ throw new Error("pi-subagents dispatch environment is not ready yet.");
368
+ }
369
+ return environmentRef.current;
370
+ },
371
+ finishRun,
372
+ makeLiveHandler,
373
+ makeDetails,
374
+ });
375
+ runtime.dispatcher = startBackground;
376
+
377
+ pi.registerTool({
378
+ name: "subagent",
379
+ label: "Subagent",
380
+ description: [
381
+ "Dispatch enabled agents as isolated leaf Pi child processes: single {agent, task} or parallel {tasks: [...]}. Dispatching never blocks your turn — runs proceed in the background and each completion resumes you automatically; never poll or restate delivered results.",
382
+ "Put every genuinely independent unit in one `tasks` array: there is no per-call cap, and runs beyond the machine's free process slots simply wait and start as slots free.",
383
+ "Parallel write-capable agents default to a detached Git worktree so writers run concurrently; explicit `shared` keeps the caller's checkout and serializes same-repository writers. Worktree setup failure never silently falls back to shared.",
384
+ "A configured child-model failure continues the retained session on the current main model.",
385
+ ].join(" "),
386
+ promptSnippet:
387
+ "Dispatch isolated background agents for recon, implementation, cleanup, docs sync, or result merging; never blocks your turn, and completions wake you automatically.",
388
+ parameters: SubagentParams,
389
+
390
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
391
+ // `wait: true` holds this call for minutes and would otherwise show a
392
+ // blank card; the background path returns at once and has nothing to
393
+ // stream. Frames carry the final details shape because renderResult
394
+ // falls back to "(no output)" without it.
395
+ const makeProgress = (details: SubagentDetails): ((text: string) => void) | undefined =>
396
+ onUpdate ? (text: string): void => onUpdate({ content: [{ type: "text", text }], details }) : undefined;
397
+ // Run ids are allocated below; restore raises the allocator above every
398
+ // id a durable record still owns, so a dispatch racing it could hand a
399
+ // fresh run the id of a parked thread and overwrite its record.
400
+ await runtime.durableRestore;
401
+ monitor.beginTurn();
402
+ const config = await loadConfig(runtime.configPath);
403
+
404
+ const discovery = discoverAgents(ctx.cwd, {
405
+ scope: config.agentScope,
406
+ enabledNames: config.enabledAgents,
407
+ projectTrusted: ctx.isProjectTrusted?.() === true,
408
+ });
409
+ const agents = discovery.agents;
410
+ // Refresh the dispatcher's fallback environment so control operations
411
+ // (resume of restored threads) never run on a stale context.
412
+ environmentRef.current = { ctx, config, agents };
413
+
414
+ const hasTasks = (params.tasks?.length ?? 0) > 0;
415
+ const hasSingle = Boolean(params.agent) && params.task !== undefined;
416
+
417
+ const catalog = agents.map((a) => a.name).join(", ") || "none";
418
+
419
+ if (Number(hasTasks) + Number(hasSingle) !== 1) {
420
+ return {
421
+ content: [
422
+ {
423
+ type: "text",
424
+ text: `Invalid parameters. Provide exactly one mode: single {agent, task} or parallel {tasks: [...]}. Enabled agents: ${catalog}.`,
425
+ },
426
+ ],
427
+ details: makeDetails("single")([]),
428
+ };
429
+ }
430
+
431
+ if (hasTasks) {
432
+ const blankTaskIndex = params.tasks?.findIndex(({ task }) => task.trim().length === 0) ?? -1;
433
+ if (blankTaskIndex !== -1) {
434
+ return {
435
+ content: [
436
+ {
437
+ type: "text",
438
+ text: `Invalid parameters. tasks[${blankTaskIndex}].task must contain at least one non-whitespace character. No background tasks were started. Enabled agents: ${catalog}.`,
439
+ },
440
+ ],
441
+ details: makeDetails("parallel")([]),
442
+ };
443
+ }
444
+ } else if (params.task?.trim().length === 0) {
445
+ return {
446
+ content: [
447
+ {
448
+ type: "text",
449
+ text: `Invalid parameters. task must contain at least one non-whitespace character. Enabled agents: ${catalog}.`,
450
+ },
451
+ ],
452
+ details: makeDetails("single")([]),
453
+ };
454
+ }
455
+
456
+ // Sub-agents run detached from the foreground turn: the editor stays
457
+ // available and completion messages later wake the main agent. The turn is
458
+ // NOT terminated here — the model can keep dispatching independent units
459
+ // or do its own work, and the background queue paces how many child
460
+ // processes actually run at once, so no per-call task cap is enforced.
461
+ if (params.tasks && params.tasks.length > 0) {
462
+ const results: SingleResult[] = [];
463
+ // Preserve caller order (and deterministic completion batching) while
464
+ // preparing each isolated filesystem before its queue entry can start.
465
+ for (const item of params.tasks) {
466
+ const catalogAgent = agents.find((candidate) => candidate.name === item.agent);
467
+ results.push(await startBackground(
468
+ item.agent,
469
+ item.task,
470
+ item.cwd,
471
+ defaultIsolationMode(
472
+ "parallel",
473
+ item.agent,
474
+ item.isolation as IsolationMode | undefined,
475
+ catalogAgent ? isWriteCapableAgent(catalogAgent) : undefined,
476
+ catalogAgent?.isolation,
477
+ ),
478
+ { thinking: item.thinking as ThinkingLevel | undefined },
479
+ ));
480
+ }
481
+ const startedRuns = results.filter((result) => result.exitCode === -1);
482
+ const started = startedRuns.length;
483
+ const startedRefs = startedRuns.map((result) =>
484
+ result.runId === undefined ? result.agent : `#${result.runId} ${result.agent}`,
485
+ );
486
+ const failureLines = results.flatMap((result, index) => {
487
+ if (result.exitCode === -1) return [];
488
+ const reason = getResultOutput(result).trim() || "unknown startup failure";
489
+ return [
490
+ `- tasks[${index}] (${params.tasks![index]!.agent}) failed to start: ${reason.replace(/\n/g, "\n ")}`,
491
+ ];
492
+ });
493
+ if (started === 0) {
494
+ // Pi marks custom-tool failures only when execute throws; returning an
495
+ // `isError` property is still a successful AgentToolResult.
496
+ throw new Error(`No background subagents were started.\n${failureLines.join("\n")}`);
497
+ }
498
+ if (params.wait) {
499
+ const startedIds = startedRuns
500
+ .map((result) => result.runId)
501
+ .filter((id): id is number => id !== undefined);
502
+ const blocks = await awaitRunResults(runtime, startedIds, signal, config.maxResultLines, ctx.cwd, makeProgress(makeDetails("parallel", true)(results)));
503
+ const text = [
504
+ `Started ${started} subagent${started === 1 ? "" : "s"} (${startedRefs.join(", ")}) and waited in-turn.`,
505
+ ...(failureLines.length > 0
506
+ ? [`${failureLines.length} task${failureLines.length === 1 ? "" : "s"} failed before launch:`, ...failureLines]
507
+ : []),
508
+ "",
509
+ blocks,
510
+ ].join("\n");
511
+ return {
512
+ content: [{ type: "text", text }],
513
+ details: makeDetails("parallel", true)(results),
514
+ ...toolUsage(runtime, startedIds),
515
+ };
516
+ }
517
+ const text = [
518
+ `Started ${started} background subagent${started === 1 ? "" : "s"}: ${startedRefs.join(", ")}. They run in the background and never block you — dispatch more independent units now or keep working; each result resumes you automatically when you are idle.`,
519
+ ...(failureLines.length > 0
520
+ ? [`${failureLines.length} task${failureLines.length === 1 ? "" : "s"} failed before launch:`, ...failureLines]
521
+ : []),
522
+ ].join("\n") + queuePacingNote();
523
+ return {
524
+ content: [{ type: "text", text }],
525
+ details: makeDetails("parallel", true)(results),
526
+ };
527
+ }
528
+
529
+ const singleCatalogAgent = agents.find((candidate) => candidate.name === params.agent);
530
+ const result = await startBackground(
531
+ params.agent as string,
532
+ params.task as string,
533
+ params.cwd,
534
+ defaultIsolationMode(
535
+ "single",
536
+ params.agent as string,
537
+ params.isolation as IsolationMode | undefined,
538
+ singleCatalogAgent ? isWriteCapableAgent(singleCatalogAgent) : undefined,
539
+ singleCatalogAgent?.isolation,
540
+ ),
541
+ { thinking: params.thinking as ThinkingLevel | undefined },
542
+ );
543
+ if (result.exitCode !== -1) {
544
+ throw new Error(getResultOutput(result));
545
+ }
546
+ const runRef = result.runId === undefined ? result.agent : `#${result.runId} ${result.agent}`;
547
+ if (params.wait && result.runId !== undefined) {
548
+ const blocks = await awaitRunResults(runtime, [result.runId], signal, config.maxResultLines, ctx.cwd, makeProgress(makeDetails("single", true)([result])));
549
+ return {
550
+ content: [{ type: "text", text: `Started ${runRef} and waited in-turn.\n\n${blocks}` }],
551
+ details: makeDetails("single", true)([result]),
552
+ ...toolUsage(runtime, [result.runId]),
553
+ };
554
+ }
555
+ return {
556
+ content: [{ type: "text", text: `Started ${runRef} in the background. It never blocks you — dispatch more independent units now or keep working; its result resumes you automatically when you are idle.${queuePacingNote()}` }],
557
+ details: makeDetails("single", true)([result]),
558
+ };
559
+
560
+ },
561
+
562
+ renderCall(args, theme) {
563
+ if (args.tasks && args.tasks.length > 0) {
564
+ let text = `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", `parallel (${args.tasks.length})`)}`;
565
+ for (const t of args.tasks.slice(0, 4)) {
566
+ const preview = formatTaskSummary(t.task, 48);
567
+ const isolation = defaultIsolationMode("parallel", t.agent, t.isolation) === "worktree" ? " [worktree]" : "";
568
+ text += `\n ${theme.fg("accent", t.agent)}${theme.fg("dim", isolation)} ${theme.fg("dim", preview)}`;
569
+ }
570
+ if (args.tasks.length > 4) text += `\n ${theme.fg("dim", `… +${args.tasks.length - 4} more`)}`;
571
+ return new Text(text, 0, 0);
572
+ }
573
+ const task: string = args.task ?? "";
574
+ const preview = formatTaskSummary(task, 60);
575
+ const isolation = args.isolation === "worktree" ? " [worktree]" : "";
576
+ return new Text(
577
+ `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", args.agent ?? "?")}${theme.fg("dim", `${isolation}`)} ${theme.fg("dim", preview)}`,
578
+ 0,
579
+ 0,
580
+ );
581
+ },
582
+
583
+ renderResult(result, _options, theme) {
584
+ const details = result.details as SubagentDetails | undefined;
585
+ if (!details || details.results.length === 0) return new Text(theme.fg("dim", "(no output)"), 0, 0);
586
+
587
+ if (details.mode === "single") {
588
+ const r = details.results[0];
589
+ const pending = r.exitCode === -1;
590
+ const icon = statusIcon(pending ? "running" : isFailedResult(r) ? "failed" : "done", theme);
591
+ const usage = formatUsage(r.usage);
592
+ const model = `${r.model ?? "?"}${r.modelFallbackFrom ? ` (main after ${r.modelFallbackFrom} failed)` : ""}`;
593
+ const isolation = r.isolation === "worktree" ? ` · worktree ${r.integrationStatus ?? "active"}` : "";
594
+ const runId = r.runId === undefined ? "" : `${theme.fg("dim", `#${r.runId}`)} `;
595
+ const line = `${theme.fg("toolTitle", theme.bold("subagent "))}${icon} ${runId}${theme.fg("accent", r.agent)} ${theme.fg("dim", `· ${model}${r.thinking ? ` · thinking ${r.thinking}` : ""}${isolation}${pending ? " · background" : ""}${usage ? ` · ${usage}` : ""}`)}`;
596
+ return new Text(line, 0, 0);
597
+ }
598
+
599
+ // Parallel mode: header + one compact line per agent
600
+ const lines: string[] = [
601
+ `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", `parallel (${details.results.length})`)}`,
602
+ ];
603
+ for (const r of details.results) {
604
+ const pending = r.exitCode === -1;
605
+ const icon = statusIcon(pending ? "running" : isFailedResult(r) ? "failed" : "done", theme);
606
+ const usage = formatUsage(r.usage);
607
+ const model = `${r.model ?? "?"}${r.modelFallbackFrom ? ` (main after ${r.modelFallbackFrom} failed)` : ""}`;
608
+ const isolation = r.isolation === "worktree" ? ` · worktree ${r.integrationStatus ?? "active"}` : "";
609
+ const runId = r.runId === undefined ? "" : `${theme.fg("dim", `#${r.runId}`)} `;
610
+ lines.push(` ${icon} ${runId}${theme.fg("accent", r.agent)} ${theme.fg("dim", `· ${model}${r.thinking ? ` · thinking ${r.thinking}` : ""}${isolation}${pending ? " · background" : ""}${usage ? ` · ${usage}` : ""}`)}`);
611
+ }
612
+ return new Text(lines.join("\n"), 0, 0);
613
+ },
614
+ });
615
+ }