@ferris1225/pi-subagents 4.1.18 → 4.1.21

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,647 +1,817 @@
1
- /**
2
- * The `subagent` tool: dispatches explorer/worker/cleaner/documenter/reviewer agents as isolated pi
3
- * child processes, single or parallel. Owns the public dispatch contract,
4
- * per-run status tracking, the managed worker/cleaner reviewer gate, and
5
- * internal step launching. 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 { formatUsage, queuedResult } from "./format.ts";
17
- import {
18
- buildFinalReviewBrief,
19
- buildReReviewBrief,
20
- buildReviewerFixBrief,
21
- MAX_REVIEW_FIX_ROUNDS,
22
- type ChainStep,
23
- type ManagedWorkflowOutcome,
24
- } from "./workflow.ts";
25
- import {
26
- formatTaskSummary,
27
- formatToolActivity,
28
- monitor,
29
- statusIcon,
30
- type RunChainMeta,
31
- type WorkflowStage,
32
- type WorkflowStageStatus,
33
- } from "./monitor.ts";
34
- import type { SubagentRuntime } from "./runtime.ts";
35
- import { persistThreadCheckpoint } from "./thread-lifecycle.ts";
36
- import {
37
- getProjectRoot,
38
- getResultOutput,
39
- isFailedResult,
40
- reviewVerdict,
41
- runSingleAgentWithMainFallback,
42
- type SingleResult,
43
- type SubagentDetails,
44
- type SubagentLiveEvent,
45
- } from "./spawn.ts";
46
- import {
47
- createBackgroundDispatcher,
48
- resolveDispatchModelRoute,
49
- runInManagedRepositoryLane,
50
- withWorktreeSystemPrompt,
51
- type DispatchEnvironment,
52
- type ManagedWorkflowRequest,
53
- } from "./thread-lifecycle.ts";
54
- import type { IsolationMode } from "./worktree.ts";
55
-
56
- export { isWorktreeCapableAgent, runInManagedRepositoryLane } from "./thread-lifecycle.ts";
57
-
58
- const NON_BLANK_TASK_OPTIONS = { minLength: 1, pattern: "\\S" } as const;
59
-
60
- const ISOLATION_DESCRIPTION =
61
- "Filesystem isolation: shared uses the caller's working tree; worktree creates a detached temporary Git worktree (write-capable agents, including worker, cleaner, and documenter, only)";
62
-
63
- const IsolationSchema = Type.Optional(
64
- StringEnum(["shared", "worktree"] as const, { description: ISOLATION_DESCRIPTION }),
65
- );
66
-
67
- const TaskItem = Type.Object({
68
- agent: Type.String({ description: "Name of the agent to invoke" }),
69
- task: Type.String({
70
- ...NON_BLANK_TASK_OPTIONS,
71
- description: "Self-contained task to delegate (the agent has no memory of this conversation)",
72
- }),
73
- cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
74
- isolation: IsolationSchema,
75
- });
76
-
77
- const SubagentParams = Type.Object({
78
- agent: Type.Optional(Type.String({ description: "Name of the agent to invoke (single mode)" })),
79
- task: Type.Optional(
80
- Type.String({ ...NON_BLANK_TASK_OPTIONS, description: "Self-contained task to delegate (single mode)" }),
81
- ),
82
- tasks: Type.Optional(Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" })),
83
- cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })),
84
- isolation: IsolationSchema,
85
- });
86
-
87
- /** Roles that default to worktree isolation in parallel dispatches even when
88
- * the live catalog cannot be consulted (render-only call sites). Custom
89
- * write-capable agents join them via isWriteCapableAgent on the execute path. */
90
- const WORKTREE_DEFAULT_AGENTS = new Set(["worker", "cleaner", "documenter"]);
91
-
92
- /** Resolve the default isolation for a dispatch. Parallel write-capable agents
93
- * get a detached worktree: shared writers serialize on the repository lane, so
94
- * defaulting them to shared would turn one parallel batch into a convoy that
95
- * also parks process slots. Explicit requests always win. */
96
- export function defaultIsolationMode(
97
- mode: "single" | "parallel",
98
- agentName: string,
99
- requested?: IsolationMode,
100
- writeCapable = WORKTREE_DEFAULT_AGENTS.has(agentName),
101
- ): IsolationMode {
102
- if (requested) return requested;
103
- return mode === "parallel" && writeCapable ? "worktree" : "shared";
104
- }
105
-
106
- function workflowStageStatus(result: SingleResult, relation?: string): WorkflowStageStatus {
107
- if (isFailedResult(result)) return "failed";
108
- if (relation === "review fix") return "done";
109
- if (result.agent !== "reviewer") return "done";
110
- const verdict = reviewVerdict(getResultOutput(result));
111
- if (verdict === "fail") return "changes";
112
- return verdict === "pass" ? "done" : "failed";
113
- }
114
-
115
- /** The runtime-granted write continuation of a failed gate: same reviewer
116
- * role, model, and retained session, but the read-only boundary is lifted for
117
- * this one stage so it applies its own fix instructions. One line only: the
118
- * full fix-stage contract is already in the retained session's prompt. */
119
- function withReviewerFixStageAgent(agent: AgentConfig): AgentConfig {
120
- return {
121
- ...agent,
122
- tools: undefined,
123
- systemPrompt: `${agent.systemPrompt.trimEnd()}\n\nRuntime workflow context: FIX STAGE — your gate just returned REVIEW_FAIL. Your read-only boundary is lifted for this stage only: apply your own fix instructions exactly as specified, verify, and report what changed; never edit during a review and never emit a verdict here.`,
124
- };
125
- }
126
-
127
- export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime): void {
128
- // Latest dispatch environment. The dispatcher is created once per process so
129
- // restored threads can resume before any dispatch has run; each execute
130
- // refreshes the fallback context, config, and agent catalog it resolves.
131
- const environmentRef: { current: DispatchEnvironment | undefined } = { current: undefined };
132
-
133
- // Finished runs leave the active monitor immediately. Their final findings
134
- // are sent as a custom message that starts a follow-up turn.
135
- const finishRun = (
136
- runId: number,
137
- status: "done" | "failed",
138
- opts?: { silent?: boolean },
139
- ): void => {
140
- monitor.setStatus(runId, status); // stamps endedAt for the elapsed time
141
- const run = monitor.removeRun(runId);
142
- if (!run) return; // already finished stay idempotent
143
- if (opts?.silent || !runtime.sessionActive) return;
144
- const icon = status === "done" ? "✓" : "✗";
145
- environmentRef.current?.ctx.ui.notify(`${icon} #${run.id} ${monitor.summarize(run)}`, status === "done" ? "info" : "error");
146
- };
147
-
148
- // Live sub-agent activity → concise one-line status ("thinking",
149
- // "read src/index.ts", ...), never a raw args blob. The live handler
150
- // only updates monitor state; the queue task / launchInWorkflow owns
151
- // terminal removal, notification, and downstream workflow decisions.
152
- const makeLiveHandler =
153
- (runId: number, generation?: number) =>
154
- (e: SubagentLiveEvent): void => {
155
- if (generation !== undefined && runtime.threads.get(runId)?.generation !== generation) return;
156
- switch (e.kind) {
157
- case "status":
158
- monitor.setStatus(runId, e.status);
159
- // A fresh running segment refreshes the durable checkpoint (session
160
- // path plus child pids) so a crash mid-generation still restores.
161
- if (e.status === "running") {
162
- const thread = runtime.threads.get(runId);
163
- if (thread?.sessionId && thread.sessionDir) {
164
- persistThreadCheckpoint(runtime, thread, "parked");
165
- }
166
- }
167
- break;
168
- case "model":
169
- monitor.setModel(runId, e.model, e.fallbackFrom);
170
- monitor.setThinking(runId, e.thinking);
171
- break;
172
- case "usage":
173
- monitor.setUsage(runId, e.usage, e.model);
174
- break;
175
- case "session": {
176
- runtime.retainSession({ sessionDir: e.sessionDir });
177
- const thread = runtime.threads.get(runId);
178
- if (thread && (generation === undefined || runtime.threads.get(runId)?.generation === generation)) {
179
- thread.sessionId = e.sessionId;
180
- thread.sessionDir = e.sessionDir;
181
- persistThreadCheckpoint(runtime, thread, "parked");
182
- }
183
- break;
184
- }
185
- case "tool_start":
186
- monitor.recordToolStart(runId, e.toolName, formatToolActivity(e.toolName, e.args));
187
- break;
188
- case "tool_end":
189
- monitor.recordToolEnd(runId, e.toolName, e.isError);
190
- break;
191
- case "thinking":
192
- monitor.setActivity(runId, "thinking");
193
- break;
194
- case "text":
195
- // A text delta is model output, not a filesystem write.
196
- monitor.setActivity(runId, "responding");
197
- break;
198
- }
199
- };
200
-
201
- const makeDetails =
202
- (mode: "single" | "parallel", background = false) =>
203
- (results: SingleResult[]): SubagentDetails => ({ mode, results, background });
204
-
205
- /** Pacing note appended to dispatch confirmations whenever runs are actually
206
- * waiting: states the real slot capacity so ordinary queueing is never
207
- * mistaken for a hard dispatch limit. Empty when everything is running. */
208
- const queuePacingNote = (): string => {
209
- const runs = monitor.getRuns();
210
- const waiting = runs.filter((run) => run.status === "queued").length;
211
- if (waiting === 0) return "";
212
- const running = runs.filter((run) => run.status === "running" || run.status === "interrupting").length;
213
- return ` Pacing: ${running} running · ${waiting} waiting for a free process slot (capacity ${runtime.backgroundQueue.capacity}); waiting runs start automatically as slots free — keep dispatching independent units.`;
214
- };
215
-
216
- /** Launch one workflow-internal child in a fresh model context. It sees the
217
- * parent's exact repository/worktree state and is registered by its own id,
218
- * but never enters top-level lifecycle policy or completion delivery.
219
- * `stage` continues a retained session (the reviewer fix stage) and/or
220
- * replaces the resolved agent (lifting the reviewer read-only boundary). */
221
- const launchInWorkflow = async (
222
- request: ManagedWorkflowRequest,
223
- agentName: string,
224
- task: string,
225
- meta: RunChainMeta,
226
- stage: {
227
- agentOverride?: AgentConfig;
228
- session?: { sessionId: string; sessionDir: string };
229
- } = {},
230
- ): Promise<{ runId: number; result: SingleResult }> => {
231
- const discoveredAgent = request.agents.find((candidate) => candidate.name === agentName);
232
- if (!discoveredAgent) {
233
- throw new Error(`Managed workflow requires enabled agent "${agentName}", but discovery did not provide it.`);
234
- }
235
- const boundaryAgent = stage.agentOverride ?? discoveredAgent;
236
- const resolveLiveAgentTools = (candidate: AgentConfig): AgentConfig =>
237
- resolveAgentTools({ ...candidate, tools: boundaryAgent.tools }, runtime.getActiveTools());
238
- const agent = resolveLiveAgentTools(boundaryAgent);
239
- // Workflow policy (agents) stays fixed for the chain, but model/thinking
240
- // routes are re-read per stage so config edits apply to stages that have
241
- // not launched yet.
242
- const stageConfig = await loadConfig(runtime.configPath).catch(() => request.config);
243
- const resolvedRoute = resolveDispatchModelRoute(agent, stageConfig, request.ctx);
244
- const route = request.isolation === "worktree"
245
- ? { ...resolvedRoute, agent: withWorktreeSystemPrompt(resolvedRoute.agent) }
246
- : resolvedRoute;
247
- const thinkingLevel = route.thinkingLevel;
248
- const runId = monitor.addRun(agent.name, task, route.agent.model, thinkingLevel, {
249
- ...meta,
250
- isolation: request.isolation,
251
- ...(request.worktreeId ? { worktreeId: request.worktreeId } : {}),
252
- });
253
- const onLive = makeLiveHandler(runId);
254
- const projectRoot = getProjectRoot(runtime.configPath, request.executionCwd);
255
- try {
256
- const result = await runSingleAgentWithMainFallback(
257
- {
258
- defaultCwd: request.executionCwd,
259
- cwd: request.executionCwd,
260
- agent: route.agent,
261
- resolveAgentForAttempt: resolveLiveAgentTools,
262
- agentName,
263
- task,
264
- thinkingLevel,
265
- thinkingLevelForModel: route.thinkingLevelForModel,
266
- signal: request.signal,
267
- onLive,
268
- makeDetails: makeDetails("single", true),
269
- idleTimeoutMs: stageConfig.idleTimeoutSec * 1000,
270
- sessionRoot: join(projectRoot, "sessions"),
271
- scratchRoot: join(projectRoot, "tmp"),
272
- ...(stage.session
273
- ? { sessionId: stage.session.sessionId, sessionDir: stage.session.sessionDir, stdinText: task }
274
- : {}),
275
- },
276
- route.mainFallbackRef,
277
- );
278
- result.runId = runId;
279
- result.projectCwd = request.projectCwd;
280
- result.isolation = request.isolation;
281
- runtime.retainSession(result);
282
- monitor.setModel(runId, result.model, result.modelFallbackFrom);
283
- monitor.setThinking(runId, result.thinking);
284
- finishRun(runId, isFailedResult(result) ? "failed" : "done", { silent: true });
285
- runtime.registerRunResult(runId, result);
286
- return { runId, result };
287
- } catch (error) {
288
- finishRun(runId, "failed", { silent: true });
289
- const errorMessage = error instanceof Error ? error.message : String(error);
290
- const crashed: SingleResult = {
291
- ...queuedResult(route.agent, task, thinkingLevel),
292
- runId,
293
- projectCwd: request.projectCwd,
294
- isolation: request.isolation,
295
- exitCode: 1,
296
- stderr: errorMessage,
297
- stopReason: request.signal.aborted ? "aborted" : "error",
298
- errorMessage,
299
- dispatchFailed: true,
300
- };
301
- runtime.registerRunResult(runId, crashed);
302
- return { runId, result: crashed };
303
- }
304
- };
305
-
306
- /** Drop any in-flight internal row. Normal internal settlement already
307
- * removes rows; this is a cancellation/crash guard. */
308
- const removeWorkflowGroup = (groupId: string): void => {
309
- for (const run of [...monitor.getRuns()]) {
310
- if (run.groupId === groupId) monitor.removeRun(run.id);
311
- }
312
- };
313
-
314
- /** Run every downstream role inline under the parent generation's queue
315
- * controller. That gives park/stop/shutdown one lifecycle owner and keeps
316
- * isolated worktrees unintegrated until the final reviewer settles. */
317
- const runManagedWorkflow = async (
318
- request: ManagedWorkflowRequest,
319
- ): Promise<ManagedWorkflowOutcome> => {
320
- const initialStepRunId = monitor.reserveRunId();
321
- const initialStepResult: SingleResult = {
322
- ...request.initialResult,
323
- runId: initialStepRunId,
324
- };
325
- runtime.registerRunResult(initialStepRunId, initialStepResult);
326
- const steps: ChainStep[] = [{
327
- runId: initialStepRunId,
328
- result: initialStepResult,
329
- relation: request.plan.initialRelation,
330
- }];
331
- const enabled = (name: string): boolean =>
332
- request.agents.some((candidate) => candidate.name === name);
333
- const canContinue = (): boolean => runtime.sessionActive && !request.signal.aborted;
334
-
335
- // Keep a live parent-owned projection because settled internal rows are
336
- // intentionally removed. Only real/currently planned stages enter it.
337
- const initialStageRelation = initialStepResult.agent === "worker"
338
- ? "implement"
339
- : initialStepResult.agent === "cleaner"
340
- ? "cleanup"
341
- : "review";
342
- const workflowStages: WorkflowStage[] = [{
343
- agent: initialStepResult.agent,
344
- relation: initialStageRelation,
345
- status: workflowStageStatus(initialStepResult),
346
- }];
347
- let reviewStage: WorkflowStage | undefined;
348
- if (enabled("reviewer")) {
349
- reviewStage = { agent: "reviewer", relation: "review", status: "pending" };
350
- workflowStages.push(reviewStage);
351
- }
352
- const publishWorkflowStages = (): void => {
353
- monitor.setWorkflowStages(request.parentRunId, workflowStages);
354
- };
355
- publishWorkflowStages();
356
-
357
- const launchStep = async (
358
- agentName: string,
359
- task: string,
360
- relation: string,
361
- stage: WorkflowStage,
362
- stageOptions: {
363
- agentOverride?: AgentConfig;
364
- session?: { sessionId: string; sessionDir: string };
365
- } = {},
366
- ): Promise<SingleResult> => {
367
- if (!enabled(agentName)) {
368
- throw new Error(`Managed workflow cannot launch disabled or missing agent "${agentName}".`);
369
- }
370
- stage.status = "active";
371
- publishWorkflowStages();
372
- try {
373
- const step = await launchInWorkflow(request, agentName, task, {
374
- groupId: request.groupId,
375
- relationLabel: relation,
376
- parentRunId: request.parentRunId,
377
- }, stageOptions);
378
- stage.status = workflowStageStatus(step.result, relation);
379
- publishWorkflowStages();
380
- request.rememberLatest(step.result);
381
- steps.push({ ...step, relation });
382
- return step.result;
383
- } catch (error) {
384
- stage.status = "failed";
385
- publishWorkflowStages();
386
- throw error;
387
- }
388
- };
389
-
390
- try {
391
- // Park/stop/shutdown may win after the top-level child settles but
392
- // before this continuation starts. Preserve that stable checkpoint and
393
- // never create an already-aborted downstream child.
394
- if (!canContinue()) return { steps };
395
- if (reviewStage) {
396
- const discoveredReviewer = request.agents.find((candidate) => candidate.name === "reviewer")!;
397
- let gateReview = await launchStep(
398
- "reviewer",
399
- buildFinalReviewBrief(initialStepResult),
400
- "final review",
401
- reviewStage,
402
- );
403
- // The failing gate owns its fixes: the same retained
404
- // reviewer session applies its own fix instructions with write access,
405
- // then a converging re-review verifies the fixes. The cap only stops
406
- // pathological burn and hands the still-failing gate to the main agent.
407
- for (let round = 1; round <= MAX_REVIEW_FIX_ROUNDS; round++) {
408
- const gateSession = gateReview.sessionId && gateReview.sessionDir
409
- ? { sessionId: gateReview.sessionId, sessionDir: gateReview.sessionDir }
410
- : undefined;
411
- if (reviewVerdict(getResultOutput(gateReview)) !== "fail" || !gateSession || !canContinue()) break;
412
- const fixStage: WorkflowStage = { agent: "reviewer", relation: "review fix", status: "pending" };
413
- workflowStages.push(fixStage);
414
- publishWorkflowStages();
415
- const fixResult = await launchStep(
416
- "reviewer",
417
- buildReviewerFixBrief(getResultOutput(gateReview)),
418
- "review fix",
419
- fixStage,
420
- { agentOverride: withReviewerFixStageAgent(discoveredReviewer), session: gateSession },
421
- );
422
- if (isFailedResult(fixResult) || !canContinue()) break;
423
- const reReviewStage: WorkflowStage = { agent: "reviewer", relation: "review", status: "pending" };
424
- workflowStages.push(reReviewStage);
425
- publishWorkflowStages();
426
- gateReview = await launchStep(
427
- "reviewer",
428
- buildReReviewBrief(fixResult, round),
429
- round === 1 ? "re-review" : `re-review ${round}`,
430
- reReviewStage,
431
- );
432
- }
433
- }
434
- return { steps };
435
- } finally {
436
- removeWorkflowGroup(request.groupId);
437
- }
438
- };
439
-
440
- const startBackground = createBackgroundDispatcher({
441
- runtime,
442
- getEnvironment: () => {
443
- if (!environmentRef.current) {
444
- throw new Error("pi-subagents dispatch environment is not ready yet.");
445
- }
446
- return environmentRef.current;
447
- },
448
- finishRun,
449
- makeLiveHandler,
450
- makeDetails,
451
- runManagedWorkflow,
452
- });
453
- runtime.dispatcher = startBackground;
454
-
455
- pi.registerTool({
456
- name: "subagent",
457
- label: "Subagent",
458
- description: [
459
- "Dispatch enabled agents as isolated leaf Pi child processes, singly or in parallel. Dispatching never blocks your turn — runs proceed in the background and each completion resumes you automatically; never poll or restate delivered results.",
460
- "Put every genuinely independent unit in one `tasks` array (no per-call cap). Process slots scale with the machine; when all slots are busy the extra runs simply wait and start automatically as slots free — waiting is pacing, never a rejection or a limit on how much you may dispatch.",
461
- "Every parallel write-capable agent (worker, cleaner, documenter, custom writers) defaults to a detached Git worktree, so writers run concurrently; shared mode serializes same-repository writers. Explicit `shared` keeps the caller's checkout; setup failure never silently falls back to shared.",
462
- "Successful worker/cleaner runs get one automatic reviewer gate; a failing gate is fixed by the reviewer itself in a write-enabled continuation of the same session and re-reviewed in bounded, converging rounds. A REVIEW_FAIL from a gate you dispatched directly returns its findings to you — fix them inline or via a briefed worker without waiting for the user.",
463
- "A configured child-model failure continues the retained session on the current main model. Resume a parked or settled thread with subagent_control by run id; use subagent_stop for destructive cancellation.",
464
- ].join(" "),
465
- promptSnippet:
466
- "Dispatch isolated background agents for recon, implementation, cleanup, docs, or review; never blocks your turn, and REVIEW_FAIL findings return to you to fix.",
467
- parameters: SubagentParams,
468
-
469
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
470
- monitor.beginTurn();
471
- const config = await loadConfig(runtime.configPath);
472
-
473
- const discovery = discoverAgents(ctx.cwd, {
474
- scope: config.agentScope,
475
- enabledNames: config.enabledAgents,
476
- projectTrusted: ctx.isProjectTrusted?.() === true,
477
- });
478
- const agents = discovery.agents;
479
- // Refresh the dispatcher's fallback environment so control operations
480
- // (resume of restored threads) never run on a stale context.
481
- environmentRef.current = { ctx, config, agents };
482
-
483
- const hasTasks = (params.tasks?.length ?? 0) > 0;
484
- const hasSingle = Boolean(params.agent) && params.task !== undefined;
485
-
486
- const catalog = agents.map((a) => a.name).join(", ") || "none";
487
-
488
- if (Number(hasTasks) + Number(hasSingle) !== 1) {
489
- return {
490
- content: [
491
- {
492
- type: "text",
493
- text: `Invalid parameters. Provide exactly one mode: single {agent, task} or parallel {tasks: [...]}. Enabled agents: ${catalog}.`,
494
- },
495
- ],
496
- details: makeDetails("single")([]),
497
- };
498
- }
499
-
500
- if (hasTasks) {
501
- const blankTaskIndex = params.tasks?.findIndex(({ task }) => task.trim().length === 0) ?? -1;
502
- if (blankTaskIndex !== -1) {
503
- return {
504
- content: [
505
- {
506
- type: "text",
507
- text: `Invalid parameters. tasks[${blankTaskIndex}].task must contain at least one non-whitespace character. No background tasks were started. Enabled agents: ${catalog}.`,
508
- },
509
- ],
510
- details: makeDetails("parallel")([]),
511
- };
512
- }
513
- } else if (params.task?.trim().length === 0) {
514
- return {
515
- content: [
516
- {
517
- type: "text",
518
- text: `Invalid parameters. task must contain at least one non-whitespace character. Enabled agents: ${catalog}.`,
519
- },
520
- ],
521
- details: makeDetails("single")([]),
522
- };
523
- }
524
-
525
- // Sub-agents run detached from the foreground turn: the editor stays
526
- // available and completion messages later wake the main agent. The turn is
527
- // NOT terminated here — the model can keep dispatching independent units
528
- // or do its own work, and the background queue paces how many child
529
- // processes actually run at once, so no per-call task cap is enforced.
530
- if (params.tasks && params.tasks.length > 0) {
531
- const results: SingleResult[] = [];
532
- // Preserve caller order (and deterministic completion batching) while
533
- // preparing each isolated filesystem before its queue entry can start.
534
- for (const item of params.tasks) {
535
- const catalogAgent = agents.find((candidate) => candidate.name === item.agent);
536
- results.push(await startBackground(
537
- item.agent,
538
- item.task,
539
- item.cwd,
540
- defaultIsolationMode(
541
- "parallel",
542
- item.agent,
543
- item.isolation as IsolationMode | undefined,
544
- catalogAgent ? isWriteCapableAgent(catalogAgent) : undefined,
545
- ),
546
- ));
547
- }
548
- const startedRuns = results.filter((result) => result.exitCode === -1);
549
- const started = startedRuns.length;
550
- const startedRefs = startedRuns.map((result) =>
551
- result.runId === undefined ? result.agent : `#${result.runId} ${result.agent}`,
552
- );
553
- const failureLines = results.flatMap((result, index) => {
554
- if (result.exitCode === -1) return [];
555
- const reason = getResultOutput(result).trim() || "unknown startup failure";
556
- return [
557
- `- tasks[${index}] (${params.tasks![index]!.agent}) failed to start: ${reason.replace(/\n/g, "\n ")}`,
558
- ];
559
- });
560
- if (started === 0) {
561
- // Pi marks custom-tool failures only when execute throws; returning an
562
- // `isError` property is still a successful AgentToolResult.
563
- throw new Error(`No background subagents were started.\n${failureLines.join("\n")}`);
564
- }
565
- const text = [
566
- `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.`,
567
- ...(failureLines.length > 0
568
- ? [`${failureLines.length} task${failureLines.length === 1 ? "" : "s"} failed before launch:`, ...failureLines]
569
- : []),
570
- ].join("\n") + queuePacingNote();
571
- return {
572
- content: [{ type: "text", text }],
573
- details: makeDetails("parallel", true)(results),
574
- };
575
- }
576
-
577
- const result = await startBackground(
578
- params.agent as string,
579
- params.task as string,
580
- params.cwd,
581
- defaultIsolationMode("single", params.agent as string, params.isolation as IsolationMode | undefined),
582
- );
583
- if (result.exitCode !== -1) {
584
- throw new Error(getResultOutput(result));
585
- }
586
- const runRef = result.runId === undefined ? result.agent : `#${result.runId} ${result.agent}`;
587
- return {
588
- 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()}` }],
589
- details: makeDetails("single", true)([result]),
590
- };
591
-
592
- },
593
-
594
- renderCall(args, theme) {
595
- if (args.tasks && args.tasks.length > 0) {
596
- let text = `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", `parallel (${args.tasks.length})`)}`;
597
- for (const t of args.tasks.slice(0, 4)) {
598
- const preview = formatTaskSummary(t.task, 48);
599
- const isolation = defaultIsolationMode("parallel", t.agent, t.isolation) === "worktree" ? " [worktree]" : "";
600
- text += `\n ${theme.fg("accent", t.agent)}${theme.fg("dim", isolation)} ${theme.fg("dim", preview)}`;
601
- }
602
- if (args.tasks.length > 4) text += `\n ${theme.fg("dim", `… +${args.tasks.length - 4} more`)}`;
603
- return new Text(text, 0, 0);
604
- }
605
- const task: string = args.task ?? "";
606
- const preview = formatTaskSummary(task, 60);
607
- const isolation = args.isolation === "worktree" ? " [worktree]" : "";
608
- return new Text(
609
- `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", args.agent ?? "?")}${theme.fg("dim", isolation)} ${theme.fg("dim", preview)}`,
610
- 0,
611
- 0,
612
- );
613
- },
614
-
615
- renderResult(result, _options, theme) {
616
- const details = result.details as SubagentDetails | undefined;
617
- if (!details || details.results.length === 0) return new Text(theme.fg("dim", "(no output)"), 0, 0);
618
-
619
- if (details.mode === "single") {
620
- const r = details.results[0];
621
- const pending = r.exitCode === -1;
622
- const icon = statusIcon(pending ? "running" : isFailedResult(r) ? "failed" : "done", theme);
623
- const usage = formatUsage(r.usage);
624
- const model = `${r.model ?? "?"}${r.modelFallbackFrom ? ` (main after ${r.modelFallbackFrom} failed)` : ""}`;
625
- const isolation = r.isolation === "worktree" ? ` · worktree ${r.integrationStatus ?? "active"}` : "";
626
- const runId = r.runId === undefined ? "" : `${theme.fg("dim", `#${r.runId}`)} `;
627
- 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}` : ""}`)}`;
628
- return new Text(line, 0, 0);
629
- }
630
-
631
- // Parallel mode: header + one compact line per agent
632
- const lines: string[] = [
633
- `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", `parallel (${details.results.length})`)}`,
634
- ];
635
- for (const r of details.results) {
636
- const pending = r.exitCode === -1;
637
- const icon = statusIcon(pending ? "running" : isFailedResult(r) ? "failed" : "done", theme);
638
- const usage = formatUsage(r.usage);
639
- const model = `${r.model ?? "?"}${r.modelFallbackFrom ? ` (main after ${r.modelFallbackFrom} failed)` : ""}`;
640
- const isolation = r.isolation === "worktree" ? ` · worktree ${r.integrationStatus ?? "active"}` : "";
641
- const runId = r.runId === undefined ? "" : `${theme.fg("dim", `#${r.runId}`)} `;
642
- lines.push(` ${icon} ${runId}${theme.fg("accent", r.agent)} ${theme.fg("dim", `· ${model}${r.thinking ? ` · thinking ${r.thinking}` : ""}${isolation}${pending ? " · background" : ""}${usage ? ` · ${usage}` : ""}`)}`);
643
- }
644
- return new Text(lines.join("\n"), 0, 0);
645
- },
646
- });
647
- }
1
+ /**
2
+ * The `subagent` tool: dispatches the enabled agents (explorer, worker, cleaner,
3
+ * documenter, synthesizer, reviewer, plus custom roles) as isolated pi
4
+ * child processes, single or parallel. Owns the public dispatch contract,
5
+ * per-run status tracking, the managed worker/cleaner reviewer gate, and
6
+ * internal step launching. Stable thread generations, final integration, and
7
+ * completion ownership live in thread-lifecycle.ts.
8
+ */
9
+
10
+ import { StringEnum } from "@earendil-works/pi-ai";
11
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
+ import { Text } from "@earendil-works/pi-tui";
13
+ import { join, resolve } from "node:path";
14
+ import { Type } from "typebox";
15
+ import { discoverAgents, isWriteCapableAgent, resolveAgentTools, type AgentConfig } from "./agents.ts";
16
+ import { loadConfig } from "./config.ts";
17
+ import { formatCompletionBlock, formatUsage, queuedResult } from "./format.ts";
18
+ import {
19
+ buildFinalReviewBrief,
20
+ buildReReviewBrief,
21
+ buildReviewerFixBrief,
22
+ MAX_REVIEW_FIX_ROUNDS,
23
+ type ChainStep,
24
+ type ManagedWorkflowOutcome,
25
+ type ReviewMode,
26
+ } from "./workflow.ts";
27
+ import {
28
+ formatTaskSummary,
29
+ formatToolActivity,
30
+ monitor,
31
+ statusIcon,
32
+ type RunChainMeta,
33
+ type RunWaitReason,
34
+ type WorkflowStage,
35
+ type WorkflowStageStatus,
36
+ } from "./monitor.ts";
37
+ import type { SubagentRuntime } from "./runtime.ts";
38
+ import { persistThreadCheckpoint } from "./thread-lifecycle.ts";
39
+ import {
40
+ getProjectRoot,
41
+ getResultOutput,
42
+ isFailedResult,
43
+ reviewVerdict,
44
+ runSingleAgentWithMainFallback,
45
+ type SingleResult,
46
+ type SubagentDetails,
47
+ type SubagentLiveEvent,
48
+ } from "./spawn.ts";
49
+ import {
50
+ createBackgroundDispatcher,
51
+ projectResultsRoot,
52
+ resolveDispatchModelRoute,
53
+ runInManagedRepositoryLane,
54
+ withWorktreeSystemPrompt,
55
+ type DispatchEnvironment,
56
+ type ManagedWorkflowRequest,
57
+ } from "./thread-lifecycle.ts";
58
+ import type { IsolationMode } from "./worktree.ts";
59
+
60
+ export { isWorktreeCapableAgent, runInManagedRepositoryLane } from "./thread-lifecycle.ts";
61
+
62
+ const NON_BLANK_TASK_OPTIONS = { minLength: 1, pattern: "\\S" } as const;
63
+
64
+ const ISOLATION_DESCRIPTION =
65
+ "Filesystem isolation: shared uses the caller's working tree; worktree creates a detached temporary Git worktree (write-capable agents, including worker, cleaner, and documenter, only)";
66
+
67
+ const IsolationSchema = Type.Optional(
68
+ StringEnum(["shared", "worktree"] as const, { description: ISOLATION_DESCRIPTION }),
69
+ );
70
+
71
+ const WaitSchema = Type.Optional(
72
+ Type.Boolean({
73
+ description:
74
+ "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.",
75
+ }),
76
+ );
77
+
78
+ const REVIEW_DESCRIPTION =
79
+ "Gate intensity for a worker/cleaner task: \"gate\" (default) runs one automatic reviewer after success; \"none\" skips it for mechanical, low-risk edits (typos, comments, doc strings, config value tweaks) that you verify yourself. Keep the default whenever behavior can change.";
80
+
81
+ const ReviewSchema = Type.Optional(
82
+ StringEnum(["gate", "none"] as const, { description: REVIEW_DESCRIPTION }),
83
+ );
84
+
85
+ const TaskItem = Type.Object({
86
+ agent: Type.String({ description: "Name of the agent to invoke" }),
87
+ task: Type.String({
88
+ ...NON_BLANK_TASK_OPTIONS,
89
+ description: "Self-contained task to delegate (the agent has no memory of this conversation)",
90
+ }),
91
+ cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
92
+ isolation: IsolationSchema,
93
+ review: ReviewSchema,
94
+ });
95
+
96
+ const SubagentParams = Type.Object({
97
+ agent: Type.Optional(Type.String({ description: "Name of the agent to invoke (single mode)" })),
98
+ task: Type.Optional(
99
+ Type.String({ ...NON_BLANK_TASK_OPTIONS, description: "Self-contained task to delegate (single mode)" }),
100
+ ),
101
+ tasks: Type.Optional(Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" })),
102
+ cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })),
103
+ isolation: IsolationSchema,
104
+ review: ReviewSchema,
105
+ wait: WaitSchema,
106
+ });
107
+
108
+ /** Roles that default to worktree isolation in parallel dispatches even when
109
+ * the live catalog cannot be consulted (render-only call sites). Custom
110
+ * write-capable agents join them via isWriteCapableAgent on the execute path. */
111
+ const WORKTREE_DEFAULT_AGENTS = new Set(["worker", "cleaner", "documenter"]);
112
+
113
+ /** Resolve the default isolation for a dispatch. Precedence: an explicit
114
+ * per-call request, then the role's own frontmatter declaration (`worktree`
115
+ * honored for write-capable roles only; `shared` always), then the parallel
116
+ * write default parallel write-capable agents get a detached worktree
117
+ * because shared writers serialize on the repository lane, so defaulting them
118
+ * to shared would turn one parallel batch into a convoy that also parks
119
+ * process slots. */
120
+ export function defaultIsolationMode(
121
+ mode: "single" | "parallel",
122
+ agentName: string,
123
+ requested?: IsolationMode,
124
+ writeCapable = WORKTREE_DEFAULT_AGENTS.has(agentName),
125
+ declared?: IsolationMode,
126
+ ): IsolationMode {
127
+ if (requested) return requested;
128
+ if (declared === "shared") return "shared";
129
+ if (declared === "worktree" && writeCapable) return "worktree";
130
+ return mode === "parallel" && writeCapable ? "worktree" : "shared";
131
+ }
132
+
133
+ function workflowStageStatus(result: SingleResult, relation?: string): WorkflowStageStatus {
134
+ if (isFailedResult(result)) return "failed";
135
+ if (relation === "review fix") return "done";
136
+ if (result.agent !== "reviewer") return "done";
137
+ const verdict = reviewVerdict(getResultOutput(result));
138
+ if (verdict === "fail") return "changes";
139
+ return verdict === "pass" ? "done" : "failed";
140
+ }
141
+
142
+ /** The runtime-granted write continuation of a failed gate: same reviewer
143
+ * role, model, and retained session, but the read-only boundary is lifted for
144
+ * this one stage so it applies its own fix instructions. One line only: the
145
+ * full fix-stage contract is already in the retained session's prompt. */
146
+ function withReviewerFixStageAgent(agent: AgentConfig): AgentConfig {
147
+ return {
148
+ ...agent,
149
+ tools: undefined,
150
+ systemPrompt: `${agent.systemPrompt.trimEnd()}\n\nRuntime workflow context: FIX STAGE — your gate just returned REVIEW_FAIL. Your read-only boundary is lifted for this stage only: apply your own fix instructions exactly as specified, verify, and report what changed; never edit during a review and never emit a verdict here.`,
151
+ };
152
+ }
153
+
154
+ /** In-turn wait behind dispatch `wait: true` the escape hatch for one-shot
155
+ * `pi -p` parents that exit at end of turn: hold the call until every run it
156
+ * started settles, then hand back their result blocks. Interactive sessions
157
+ * never take this path; their results arrive as completion wake-ups. No
158
+ * timer: a waiter resolves the moment its run's result registers (children
159
+ * are bounded by the idle watchdog), an already-parked run answers
160
+ * immediately with its resume handle, and the turn's abort signal remains the
161
+ * escape hatch. */
162
+ export async function awaitRunResults(
163
+ runtime: SubagentRuntime,
164
+ runIds: number[],
165
+ signal: AbortSignal | undefined,
166
+ maxResultLines: number,
167
+ fallbackCwd: string,
168
+ ): Promise<string> {
169
+ const waitForRun = (runId: number): Promise<{ result?: SingleResult; note?: string }> => {
170
+ const already = runtime.settledRuns.get(runId);
171
+ if (already) return Promise.resolve({ result: already });
172
+ if (monitor.findRun(runId)?.status === "parked") {
173
+ return Promise.resolve({ note: `run #${runId} is parked at a stable checkpoint; use subagent_control resume to continue it` });
174
+ }
175
+ return new Promise((resolve) => {
176
+ let done = false;
177
+ let unsub: (() => void) | undefined;
178
+ const cleanup = (): void => {
179
+ if (unsub) unsub();
180
+ signal?.removeEventListener("abort", onAbort);
181
+ const listeners = runtime.settledListeners.get(runId);
182
+ if (listeners) {
183
+ listeners.delete(onSettled);
184
+ if (listeners.size === 0) runtime.settledListeners.delete(runId);
185
+ }
186
+ };
187
+ const finish = (outcome: { result?: SingleResult; note?: string }): void => {
188
+ if (done) return;
189
+ done = true;
190
+ cleanup();
191
+ resolve(outcome);
192
+ };
193
+ const onSettled = (result: SingleResult): void => finish({ result });
194
+ const onMonitor = (): void => {
195
+ const current = runtime.settledRuns.get(runId);
196
+ if (current) {
197
+ finish({ result: current });
198
+ return;
199
+ }
200
+ const live = monitor.findRun(runId);
201
+ if (live?.status === "parked") {
202
+ finish({ note: `run #${runId} was parked at a stable checkpoint; use subagent_control resume to continue it` });
203
+ return;
204
+ }
205
+ if (!live) {
206
+ // Removal is followed synchronously by registerRunResult in the
207
+ // finishing task; re-check on the next tick so the result wins.
208
+ setTimeout(() => {
209
+ const late = runtime.settledRuns.get(runId);
210
+ if (late) finish({ result: late });
211
+ else finish({ note: `run #${runId} was removed before its result was recorded (cancelled or session ended)` });
212
+ }, 0);
213
+ }
214
+ };
215
+ const onAbort = (): void => finish({ note: "wait aborted" });
216
+ let listeners = runtime.settledListeners.get(runId);
217
+ if (!listeners) {
218
+ listeners = new Set();
219
+ runtime.settledListeners.set(runId, listeners);
220
+ }
221
+ listeners.add(onSettled);
222
+ unsub = monitor.subscribe(onMonitor);
223
+ if (signal?.aborted) onAbort();
224
+ else signal?.addEventListener("abort", onAbort, { once: true });
225
+ });
226
+ };
227
+ const outcomes = await Promise.all(runIds.map(waitForRun));
228
+ return outcomes.map((outcome) =>
229
+ outcome.result
230
+ ? formatCompletionBlock(outcome.result, maxResultLines, { resultRoot: projectResultsRoot(runtime.configPath, outcome.result.projectCwd ?? fallbackCwd) })
231
+ : (outcome.note ?? "(no outcome)"),
232
+ ).join("\n\n");
233
+ }
234
+
235
+ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime): void {
236
+ // Latest dispatch environment. The dispatcher is created once per process so
237
+ // restored threads can resume before any dispatch has run; each execute
238
+ // refreshes the fallback context, config, and agent catalog it resolves.
239
+ const environmentRef: { current: DispatchEnvironment | undefined } = { current: undefined };
240
+
241
+ // Finished runs leave the active monitor immediately. Their final findings
242
+ // are sent as a custom message that starts a follow-up turn.
243
+ const finishRun = (
244
+ runId: number,
245
+ status: "done" | "failed",
246
+ opts?: { silent?: boolean },
247
+ ): void => {
248
+ monitor.setStatus(runId, status); // stamps endedAt for the elapsed time
249
+ const run = monitor.removeRun(runId);
250
+ if (!run) return; // already finished — stay idempotent
251
+ if (opts?.silent || !runtime.sessionActive) return;
252
+ const icon = status === "done" ? "✓" : "✗";
253
+ environmentRef.current?.ctx.ui.notify(`${icon} #${run.id} ${monitor.summarize(run)}`, status === "done" ? "info" : "error");
254
+ };
255
+
256
+ // Live sub-agent activity → concise one-line status ("thinking",
257
+ // "read src/index.ts", ...), never a raw args blob. The live handler
258
+ // only updates monitor state; the queue task / launchInWorkflow owns
259
+ // terminal removal, notification, and downstream workflow decisions.
260
+ const makeLiveHandler =
261
+ (runId: number, generation?: number) =>
262
+ (e: SubagentLiveEvent): void => {
263
+ if (generation !== undefined && runtime.threads.get(runId)?.generation !== generation) return;
264
+ switch (e.kind) {
265
+ case "status":
266
+ monitor.setStatus(runId, e.status);
267
+ // A fresh running segment refreshes the durable checkpoint (session
268
+ // path plus child pids) so a crash mid-generation still restores.
269
+ if (e.status === "running") {
270
+ const thread = runtime.threads.get(runId);
271
+ if (thread?.sessionId && thread.sessionDir) {
272
+ persistThreadCheckpoint(runtime, thread, "parked");
273
+ }
274
+ }
275
+ break;
276
+ case "model":
277
+ monitor.setModel(runId, e.model, e.fallbackFrom);
278
+ monitor.setThinking(runId, e.thinking);
279
+ break;
280
+ case "usage":
281
+ monitor.setUsage(runId, e.usage, e.model);
282
+ break;
283
+ case "session": {
284
+ runtime.retainSession({ sessionDir: e.sessionDir });
285
+ const thread = runtime.threads.get(runId);
286
+ if (thread && (generation === undefined || runtime.threads.get(runId)?.generation === generation)) {
287
+ thread.sessionId = e.sessionId;
288
+ thread.sessionDir = e.sessionDir;
289
+ persistThreadCheckpoint(runtime, thread, "parked");
290
+ }
291
+ break;
292
+ }
293
+ case "tool_start":
294
+ monitor.recordToolStart(runId, e.toolName, formatToolActivity(e.toolName, e.args));
295
+ break;
296
+ case "tool_end":
297
+ monitor.recordToolEnd(runId, e.toolName, e.isError);
298
+ break;
299
+ case "thinking":
300
+ monitor.setActivity(runId, "thinking");
301
+ break;
302
+ case "text":
303
+ // A text delta is model output, not a filesystem write.
304
+ monitor.setActivity(runId, "responding");
305
+ break;
306
+ }
307
+ };
308
+
309
+ const makeDetails =
310
+ (mode: "single" | "parallel", background = false) =>
311
+ (results: SingleResult[]): SubagentDetails => ({ mode, results, background });
312
+
313
+ /** Pacing note appended to dispatch confirmations whenever runs are actually
314
+ * waiting. Slot waits and repository-lane waits are stated separately with
315
+ * the real capacity: a lane-serialized shared writer or a starting child
316
+ * must never read as an exhausted pool, or the model stops dispatching while
317
+ * slots are free. Empty when nothing is waiting. */
318
+ const queuePacingNote = (): string => {
319
+ const runs = monitor.getRuns();
320
+ const queuedWith = (reason: RunWaitReason): number =>
321
+ runs.filter((run) => run.status === "queued" && run.waitReason === reason).length;
322
+ const slotWaiting = queuedWith("process-slot");
323
+ const laneWaiting = queuedWith("repository-lane");
324
+ if (slotWaiting === 0 && laneWaiting === 0) return "";
325
+ const executing = runs.filter((run) =>
326
+ run.status === "running" || run.status === "interrupting" || (run.status === "queued" && run.waitReason === "starting"),
327
+ ).length;
328
+ const capacity = runtime.backgroundQueue.capacity;
329
+ const freeSlots = Math.max(0, capacity - runtime.backgroundQueue.activeCount);
330
+ const parts = [`${executing} running`];
331
+ if (slotWaiting > 0) {
332
+ parts.push(`${slotWaiting} waiting for a free process slot (capacity ${capacity}); they start automatically as slots free`);
333
+ }
334
+ if (laneWaiting > 0) {
335
+ parts.push(
336
+ `${laneWaiting} shared-checkout writer${laneWaiting === 1 ? "" : "s"} waiting for the repository write lane — write serialization, not slot capacity` +
337
+ (slotWaiting === 0 ? ` (${freeSlots} of ${capacity} slots free; parallel writers avoid the lane via worktree isolation)` : ""),
338
+ );
339
+ }
340
+ return ` Pacing: ${parts.join(" · ")}. Keep dispatching independent units.`;
341
+ };
342
+
343
+ /** Launch one workflow-internal child in a fresh model context. It sees the
344
+ * parent's exact repository/worktree state and is registered by its own id,
345
+ * but never enters top-level lifecycle policy or completion delivery.
346
+ * `stage` continues a retained session (the reviewer fix stage) and/or
347
+ * replaces the resolved agent (lifting the reviewer read-only boundary). */
348
+ const launchInWorkflow = async (
349
+ request: ManagedWorkflowRequest,
350
+ agentName: string,
351
+ task: string,
352
+ meta: RunChainMeta,
353
+ stage: {
354
+ agentOverride?: AgentConfig;
355
+ session?: { sessionId: string; sessionDir: string };
356
+ } = {},
357
+ ): Promise<{ runId: number; result: SingleResult }> => {
358
+ const discoveredAgent = request.agents.find((candidate) => candidate.name === agentName);
359
+ if (!discoveredAgent) {
360
+ throw new Error(`Managed workflow requires enabled agent "${agentName}", but discovery did not provide it.`);
361
+ }
362
+ const boundaryAgent = stage.agentOverride ?? discoveredAgent;
363
+ const resolveLiveAgentTools = (candidate: AgentConfig): AgentConfig =>
364
+ resolveAgentTools({ ...candidate, tools: boundaryAgent.tools }, runtime.getActiveTools());
365
+ const agent = resolveLiveAgentTools(boundaryAgent);
366
+ // Workflow policy (agents) stays fixed for the chain, but model/thinking
367
+ // routes are re-read per stage so config edits apply to stages that have
368
+ // not launched yet.
369
+ const stageConfig = await loadConfig(runtime.configPath).catch(() => request.config);
370
+ const resolvedRoute = resolveDispatchModelRoute(agent, stageConfig, request.ctx);
371
+ const route = request.isolation === "worktree"
372
+ ? { ...resolvedRoute, agent: withWorktreeSystemPrompt(resolvedRoute.agent) }
373
+ : resolvedRoute;
374
+ const thinkingLevel = route.thinkingLevel;
375
+ const runId = monitor.addRun(agent.name, task, route.agent.model, thinkingLevel, {
376
+ ...meta,
377
+ isolation: request.isolation,
378
+ ...(request.worktreeId ? { worktreeId: request.worktreeId } : {}),
379
+ // Workflow-internal children spawn immediately: they never enter the
380
+ // process queue, so they must never be reported as slot-waiting.
381
+ waitReason: "starting",
382
+ });
383
+ const onLive = makeLiveHandler(runId);
384
+ const projectRoot = getProjectRoot(runtime.configPath, request.executionCwd);
385
+ try {
386
+ const result = await runSingleAgentWithMainFallback(
387
+ {
388
+ defaultCwd: request.executionCwd,
389
+ cwd: request.executionCwd,
390
+ agent: route.agent,
391
+ resolveAgentForAttempt: resolveLiveAgentTools,
392
+ agentName,
393
+ task,
394
+ thinkingLevel,
395
+ thinkingLevelForModel: route.thinkingLevelForModel,
396
+ signal: request.signal,
397
+ onLive,
398
+ makeDetails: makeDetails("single", true),
399
+ idleTimeoutMs: stageConfig.idleTimeoutSec * 1000,
400
+ sessionRoot: join(projectRoot, "sessions"),
401
+ scratchRoot: join(projectRoot, "tmp"),
402
+ ...(stage.session
403
+ ? { sessionId: stage.session.sessionId, sessionDir: stage.session.sessionDir, stdinText: task }
404
+ : {}),
405
+ },
406
+ route.mainFallbackRef,
407
+ );
408
+ result.runId = runId;
409
+ result.projectCwd = request.projectCwd;
410
+ result.isolation = request.isolation;
411
+ runtime.retainSession(result);
412
+ monitor.setModel(runId, result.model, result.modelFallbackFrom);
413
+ monitor.setThinking(runId, result.thinking);
414
+ finishRun(runId, isFailedResult(result) ? "failed" : "done", { silent: true });
415
+ runtime.registerRunResult(runId, result);
416
+ return { runId, result };
417
+ } catch (error) {
418
+ finishRun(runId, "failed", { silent: true });
419
+ const errorMessage = error instanceof Error ? error.message : String(error);
420
+ const crashed: SingleResult = {
421
+ ...queuedResult(route.agent, task, thinkingLevel),
422
+ runId,
423
+ projectCwd: request.projectCwd,
424
+ isolation: request.isolation,
425
+ exitCode: 1,
426
+ stderr: errorMessage,
427
+ stopReason: request.signal.aborted ? "aborted" : "error",
428
+ errorMessage,
429
+ dispatchFailed: true,
430
+ };
431
+ runtime.registerRunResult(runId, crashed);
432
+ return { runId, result: crashed };
433
+ }
434
+ };
435
+
436
+ /** Drop any in-flight internal row. Normal internal settlement already
437
+ * removes rows; this is a cancellation/crash guard. */
438
+ const removeWorkflowGroup = (groupId: string): void => {
439
+ for (const run of [...monitor.getRuns()]) {
440
+ if (run.groupId === groupId) monitor.removeRun(run.id);
441
+ }
442
+ };
443
+
444
+ /** Run every downstream role inline under the parent generation's queue
445
+ * controller. That gives park/stop/shutdown one lifecycle owner and keeps
446
+ * isolated worktrees unintegrated until the final reviewer settles. */
447
+ const runManagedWorkflow = async (
448
+ request: ManagedWorkflowRequest,
449
+ ): Promise<ManagedWorkflowOutcome> => {
450
+ const initialStepRunId = monitor.reserveRunId();
451
+ const initialStepResult: SingleResult = {
452
+ ...request.initialResult,
453
+ runId: initialStepRunId,
454
+ };
455
+ runtime.registerRunResult(initialStepRunId, initialStepResult);
456
+ const steps: ChainStep[] = [{
457
+ runId: initialStepRunId,
458
+ result: initialStepResult,
459
+ relation: request.plan.initialRelation,
460
+ }];
461
+ const enabled = (name: string): boolean =>
462
+ request.agents.some((candidate) => candidate.name === name);
463
+ const canContinue = (): boolean => runtime.sessionActive && !request.signal.aborted;
464
+
465
+ // Keep a live parent-owned projection because settled internal rows are
466
+ // intentionally removed. Only real/currently planned stages enter it.
467
+ const initialStageRelation = initialStepResult.agent === "worker"
468
+ ? "implement"
469
+ : initialStepResult.agent === "cleaner"
470
+ ? "cleanup"
471
+ : "review";
472
+ const workflowStages: WorkflowStage[] = [{
473
+ agent: initialStepResult.agent,
474
+ relation: initialStageRelation,
475
+ status: workflowStageStatus(initialStepResult),
476
+ }];
477
+ let reviewStage: WorkflowStage | undefined;
478
+ if (enabled("reviewer")) {
479
+ reviewStage = { agent: "reviewer", relation: "review", status: "pending" };
480
+ workflowStages.push(reviewStage);
481
+ }
482
+ const publishWorkflowStages = (): void => {
483
+ monitor.setWorkflowStages(request.parentRunId, workflowStages);
484
+ };
485
+ publishWorkflowStages();
486
+
487
+ const launchStep = async (
488
+ agentName: string,
489
+ task: string,
490
+ relation: string,
491
+ stage: WorkflowStage,
492
+ stageOptions: {
493
+ agentOverride?: AgentConfig;
494
+ session?: { sessionId: string; sessionDir: string };
495
+ } = {},
496
+ ): Promise<SingleResult> => {
497
+ if (!enabled(agentName)) {
498
+ throw new Error(`Managed workflow cannot launch disabled or missing agent "${agentName}".`);
499
+ }
500
+ stage.status = "active";
501
+ publishWorkflowStages();
502
+ try {
503
+ const step = await launchInWorkflow(request, agentName, task, {
504
+ groupId: request.groupId,
505
+ relationLabel: relation,
506
+ parentRunId: request.parentRunId,
507
+ }, stageOptions);
508
+ stage.status = workflowStageStatus(step.result, relation);
509
+ publishWorkflowStages();
510
+ request.rememberLatest(step.result);
511
+ steps.push({ ...step, relation });
512
+ return step.result;
513
+ } catch (error) {
514
+ stage.status = "failed";
515
+ publishWorkflowStages();
516
+ throw error;
517
+ }
518
+ };
519
+
520
+ try {
521
+ // Park/stop/shutdown may win after the top-level child settles but
522
+ // before this continuation starts. Preserve that stable checkpoint and
523
+ // never create an already-aborted downstream child.
524
+ if (!canContinue()) return { steps };
525
+ if (reviewStage) {
526
+ const discoveredReviewer = request.agents.find((candidate) => candidate.name === "reviewer")!;
527
+ let gateReview = await launchStep(
528
+ "reviewer",
529
+ buildFinalReviewBrief(initialStepResult),
530
+ "final review",
531
+ reviewStage,
532
+ );
533
+ // The failing gate owns its fixes: the same retained
534
+ // reviewer session applies its own fix instructions with write access,
535
+ // then a converging re-review verifies the fixes. The cap only stops
536
+ // pathological burn and hands the still-failing gate to the main agent.
537
+ for (let round = 1; round <= MAX_REVIEW_FIX_ROUNDS; round++) {
538
+ const gateSession = gateReview.sessionId && gateReview.sessionDir
539
+ ? { sessionId: gateReview.sessionId, sessionDir: gateReview.sessionDir }
540
+ : undefined;
541
+ if (reviewVerdict(getResultOutput(gateReview)) !== "fail" || !gateSession || !canContinue()) break;
542
+ const fixStage: WorkflowStage = { agent: "reviewer", relation: "review fix", status: "pending" };
543
+ workflowStages.push(fixStage);
544
+ publishWorkflowStages();
545
+ const fixResult = await launchStep(
546
+ "reviewer",
547
+ buildReviewerFixBrief(getResultOutput(gateReview)),
548
+ "review fix",
549
+ fixStage,
550
+ { agentOverride: withReviewerFixStageAgent(discoveredReviewer), session: gateSession },
551
+ );
552
+ if (isFailedResult(fixResult) || !canContinue()) break;
553
+ const reReviewStage: WorkflowStage = { agent: "reviewer", relation: "review", status: "pending" };
554
+ workflowStages.push(reReviewStage);
555
+ publishWorkflowStages();
556
+ gateReview = await launchStep(
557
+ "reviewer",
558
+ buildReReviewBrief(fixResult, round),
559
+ round === 1 ? "re-review" : `re-review ${round}`,
560
+ reReviewStage,
561
+ );
562
+ }
563
+ }
564
+ return { steps };
565
+ } finally {
566
+ removeWorkflowGroup(request.groupId);
567
+ }
568
+ };
569
+
570
+ const startBackground = createBackgroundDispatcher({
571
+ runtime,
572
+ getEnvironment: () => {
573
+ if (!environmentRef.current) {
574
+ throw new Error("pi-subagents dispatch environment is not ready yet.");
575
+ }
576
+ return environmentRef.current;
577
+ },
578
+ finishRun,
579
+ makeLiveHandler,
580
+ makeDetails,
581
+ runManagedWorkflow,
582
+ });
583
+ runtime.dispatcher = startBackground;
584
+
585
+ pi.registerTool({
586
+ name: "subagent",
587
+ label: "Subagent",
588
+ description: [
589
+ "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.",
590
+ "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.",
591
+ "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.",
592
+ "Successful worker/cleaner runs get one automatic reviewer gate; pass review: \"none\" on a worker/cleaner task to skip it for mechanical, low-risk edits you verify yourself. A REVIEW_FAIL from a gate you dispatched directly returns its findings to you. A configured child-model failure continues the retained session on the current main model.",
593
+ ].join(" "),
594
+ promptSnippet:
595
+ "Dispatch isolated background agents for recon, implementation, cleanup, docs, or review; never blocks your turn, and REVIEW_FAIL findings return to you to fix.",
596
+ parameters: SubagentParams,
597
+
598
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
599
+ // Run ids are allocated below; restore raises the allocator above every
600
+ // id a durable record still owns, so a dispatch racing it could hand a
601
+ // fresh run the id of a parked thread and overwrite its record.
602
+ await runtime.durableRestore;
603
+ monitor.beginTurn();
604
+ const config = await loadConfig(runtime.configPath);
605
+
606
+ const discovery = discoverAgents(ctx.cwd, {
607
+ scope: config.agentScope,
608
+ enabledNames: config.enabledAgents,
609
+ projectTrusted: ctx.isProjectTrusted?.() === true,
610
+ });
611
+ const agents = discovery.agents;
612
+ // Refresh the dispatcher's fallback environment so control operations
613
+ // (resume of restored threads) never run on a stale context.
614
+ environmentRef.current = { ctx, config, agents };
615
+
616
+ const hasTasks = (params.tasks?.length ?? 0) > 0;
617
+ const hasSingle = Boolean(params.agent) && params.task !== undefined;
618
+
619
+ const catalog = agents.map((a) => a.name).join(", ") || "none";
620
+
621
+ if (Number(hasTasks) + Number(hasSingle) !== 1) {
622
+ return {
623
+ content: [
624
+ {
625
+ type: "text",
626
+ text: `Invalid parameters. Provide exactly one mode: single {agent, task} or parallel {tasks: [...]}. Enabled agents: ${catalog}.`,
627
+ },
628
+ ],
629
+ details: makeDetails("single")([]),
630
+ };
631
+ }
632
+
633
+ if (hasTasks) {
634
+ const blankTaskIndex = params.tasks?.findIndex(({ task }) => task.trim().length === 0) ?? -1;
635
+ if (blankTaskIndex !== -1) {
636
+ return {
637
+ content: [
638
+ {
639
+ type: "text",
640
+ text: `Invalid parameters. tasks[${blankTaskIndex}].task must contain at least one non-whitespace character. No background tasks were started. Enabled agents: ${catalog}.`,
641
+ },
642
+ ],
643
+ details: makeDetails("parallel")([]),
644
+ };
645
+ }
646
+ } else if (params.task?.trim().length === 0) {
647
+ return {
648
+ content: [
649
+ {
650
+ type: "text",
651
+ text: `Invalid parameters. task must contain at least one non-whitespace character. Enabled agents: ${catalog}.`,
652
+ },
653
+ ],
654
+ details: makeDetails("single")([]),
655
+ };
656
+ }
657
+
658
+ // Sub-agents run detached from the foreground turn: the editor stays
659
+ // available and completion messages later wake the main agent. The turn is
660
+ // NOT terminated here — the model can keep dispatching independent units
661
+ // or do its own work, and the background queue paces how many child
662
+ // processes actually run at once, so no per-call task cap is enforced.
663
+ if (params.tasks && params.tasks.length > 0) {
664
+ const results: SingleResult[] = [];
665
+ // Preserve caller order (and deterministic completion batching) while
666
+ // preparing each isolated filesystem before its queue entry can start.
667
+ for (const item of params.tasks) {
668
+ const catalogAgent = agents.find((candidate) => candidate.name === item.agent);
669
+ results.push(await startBackground(
670
+ item.agent,
671
+ item.task,
672
+ item.cwd,
673
+ defaultIsolationMode(
674
+ "parallel",
675
+ item.agent,
676
+ item.isolation as IsolationMode | undefined,
677
+ catalogAgent ? isWriteCapableAgent(catalogAgent) : undefined,
678
+ catalogAgent?.isolation,
679
+ ),
680
+ { review: item.review as ReviewMode | undefined },
681
+ ));
682
+ }
683
+ const startedRuns = results.filter((result) => result.exitCode === -1);
684
+ const started = startedRuns.length;
685
+ const startedRefs = startedRuns.map((result) =>
686
+ result.runId === undefined ? result.agent : `#${result.runId} ${result.agent}`,
687
+ );
688
+ const failureLines = results.flatMap((result, index) => {
689
+ if (result.exitCode === -1) return [];
690
+ const reason = getResultOutput(result).trim() || "unknown startup failure";
691
+ return [
692
+ `- tasks[${index}] (${params.tasks![index]!.agent}) failed to start: ${reason.replace(/\n/g, "\n ")}`,
693
+ ];
694
+ });
695
+ if (started === 0) {
696
+ // Pi marks custom-tool failures only when execute throws; returning an
697
+ // `isError` property is still a successful AgentToolResult.
698
+ throw new Error(`No background subagents were started.\n${failureLines.join("\n")}`);
699
+ }
700
+ if (params.wait) {
701
+ const startedIds = startedRuns
702
+ .map((result) => result.runId)
703
+ .filter((id): id is number => id !== undefined);
704
+ const blocks = await awaitRunResults(runtime, startedIds, signal, config.maxResultLines, ctx.cwd);
705
+ const text = [
706
+ `Started ${started} subagent${started === 1 ? "" : "s"} (${startedRefs.join(", ")}) and waited in-turn.`,
707
+ ...(failureLines.length > 0
708
+ ? [`${failureLines.length} task${failureLines.length === 1 ? "" : "s"} failed before launch:`, ...failureLines]
709
+ : []),
710
+ "",
711
+ blocks,
712
+ ].join("\n");
713
+ return {
714
+ content: [{ type: "text", text }],
715
+ details: makeDetails("parallel", true)(results),
716
+ };
717
+ }
718
+ const text = [
719
+ `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.`,
720
+ ...(failureLines.length > 0
721
+ ? [`${failureLines.length} task${failureLines.length === 1 ? "" : "s"} failed before launch:`, ...failureLines]
722
+ : []),
723
+ ].join("\n") + queuePacingNote();
724
+ return {
725
+ content: [{ type: "text", text }],
726
+ details: makeDetails("parallel", true)(results),
727
+ };
728
+ }
729
+
730
+ const singleCatalogAgent = agents.find((candidate) => candidate.name === params.agent);
731
+ const result = await startBackground(
732
+ params.agent as string,
733
+ params.task as string,
734
+ params.cwd,
735
+ defaultIsolationMode(
736
+ "single",
737
+ params.agent as string,
738
+ params.isolation as IsolationMode | undefined,
739
+ singleCatalogAgent ? isWriteCapableAgent(singleCatalogAgent) : undefined,
740
+ singleCatalogAgent?.isolation,
741
+ ),
742
+ { review: params.review as ReviewMode | undefined },
743
+ );
744
+ if (result.exitCode !== -1) {
745
+ throw new Error(getResultOutput(result));
746
+ }
747
+ const runRef = result.runId === undefined ? result.agent : `#${result.runId} ${result.agent}`;
748
+ if (params.wait && result.runId !== undefined) {
749
+ const blocks = await awaitRunResults(runtime, [result.runId], signal, config.maxResultLines, ctx.cwd);
750
+ return {
751
+ content: [{ type: "text", text: `Started ${runRef} and waited in-turn.\n\n${blocks}` }],
752
+ details: makeDetails("single", true)([result]),
753
+ };
754
+ }
755
+ return {
756
+ 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()}` }],
757
+ details: makeDetails("single", true)([result]),
758
+ };
759
+
760
+ },
761
+
762
+ renderCall(args, theme) {
763
+ if (args.tasks && args.tasks.length > 0) {
764
+ let text = `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", `parallel (${args.tasks.length})`)}`;
765
+ for (const t of args.tasks.slice(0, 4)) {
766
+ const preview = formatTaskSummary(t.task, 48);
767
+ const isolation = defaultIsolationMode("parallel", t.agent, t.isolation) === "worktree" ? " [worktree]" : "";
768
+ const gate = t.review === "none" ? " [no gate]" : "";
769
+ text += `\n ${theme.fg("accent", t.agent)}${theme.fg("dim", `${isolation}${gate}`)} ${theme.fg("dim", preview)}`;
770
+ }
771
+ if (args.tasks.length > 4) text += `\n ${theme.fg("dim", `… +${args.tasks.length - 4} more`)}`;
772
+ return new Text(text, 0, 0);
773
+ }
774
+ const task: string = args.task ?? "";
775
+ const preview = formatTaskSummary(task, 60);
776
+ const isolation = args.isolation === "worktree" ? " [worktree]" : "";
777
+ const gate = args.review === "none" ? " [no gate]" : "";
778
+ return new Text(
779
+ `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", args.agent ?? "?")}${theme.fg("dim", `${isolation}${gate}`)} ${theme.fg("dim", preview)}`,
780
+ 0,
781
+ 0,
782
+ );
783
+ },
784
+
785
+ renderResult(result, _options, theme) {
786
+ const details = result.details as SubagentDetails | undefined;
787
+ if (!details || details.results.length === 0) return new Text(theme.fg("dim", "(no output)"), 0, 0);
788
+
789
+ if (details.mode === "single") {
790
+ const r = details.results[0];
791
+ const pending = r.exitCode === -1;
792
+ const icon = statusIcon(pending ? "running" : isFailedResult(r) ? "failed" : "done", theme);
793
+ const usage = formatUsage(r.usage);
794
+ const model = `${r.model ?? "?"}${r.modelFallbackFrom ? ` (main after ${r.modelFallbackFrom} failed)` : ""}`;
795
+ const isolation = r.isolation === "worktree" ? ` · worktree ${r.integrationStatus ?? "active"}` : "";
796
+ const runId = r.runId === undefined ? "" : `${theme.fg("dim", `#${r.runId}`)} `;
797
+ 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}` : ""}`)}`;
798
+ return new Text(line, 0, 0);
799
+ }
800
+
801
+ // Parallel mode: header + one compact line per agent
802
+ const lines: string[] = [
803
+ `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", `parallel (${details.results.length})`)}`,
804
+ ];
805
+ for (const r of details.results) {
806
+ const pending = r.exitCode === -1;
807
+ const icon = statusIcon(pending ? "running" : isFailedResult(r) ? "failed" : "done", theme);
808
+ const usage = formatUsage(r.usage);
809
+ const model = `${r.model ?? "?"}${r.modelFallbackFrom ? ` (main after ${r.modelFallbackFrom} failed)` : ""}`;
810
+ const isolation = r.isolation === "worktree" ? ` · worktree ${r.integrationStatus ?? "active"}` : "";
811
+ const runId = r.runId === undefined ? "" : `${theme.fg("dim", `#${r.runId}`)} `;
812
+ lines.push(` ${icon} ${runId}${theme.fg("accent", r.agent)} ${theme.fg("dim", `· ${model}${r.thinking ? ` · thinking ${r.thinking}` : ""}${isolation}${pending ? " · background" : ""}${usage ? ` · ${usage}` : ""}`)}`);
813
+ }
814
+ return new Text(lines.join("\n"), 0, 0);
815
+ },
816
+ });
817
+ }