@ferris1225/pi-subagents 0.32.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/dispatch.ts CHANGED
@@ -1,1878 +1,1833 @@
1
- /**
2
- * The `subagent` tool: dispatches explore/worker/reviewer agents as isolated pi
3
- * child processes, single or parallel. Owns the dispatch pipeline: config load,
4
- * per-agent model-pool resolution, per-run widget tracking, the auto-fix chain
5
- * (REVIEW_FAIL → worker → re-review), and completion delivery.
6
- *
7
- * Vision: a task flagged `vision: true` uses the configured vision model as an
8
- * explicit primary, then the agent's configured backup and the current
9
- * main-window model. Stale refs remain in the pool and fail normally at runtime.
10
- */
11
-
12
- import { StringEnum } from "@earendil-works/pi-ai";
13
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
14
- import { Text } from "@earendil-works/pi-tui";
15
- import { existsSync } from "node:fs";
16
- import { realpath, rm } from "node:fs/promises";
17
- import { resolve } from "node:path";
18
- import { Type } from "typebox";
19
- import { discoverAgents, type AgentConfig } from "./agents.ts";
20
- import {
21
- completionTriggersTurn,
22
- type CompletionMessageItem,
23
- } from "./completion.ts";
24
- import { loadConfig, type SubagentsConfig } from "./config.ts";
25
- import {
26
- dispatchFailedResult,
27
- failedStartResult,
28
- formatCompletionBlock,
29
- formatUsage,
30
- modelLevelTakeoverNote,
31
- queuedResult,
32
- } from "./format.ts";
33
- import {
34
- buildFixTaskBrief,
35
- buildReReviewBrief,
36
- formatChainSummary,
37
- shouldTriggerFixLoop,
38
- summarizeChainResult,
39
- type ChainStep,
40
- } from "./fixloop.ts";
41
- import { currentModelRef, resolveAgentModelPool } from "./models.ts";
42
- import {
43
- formatTaskSummary,
44
- formatToolActivity,
45
- monitor,
46
- statusIcon,
47
- type RunChainMeta,
48
- } from "./monitor.ts";
49
- import type { SubagentRuntime, SubagentThread, ThreadState } from "./runtime.ts";
50
- import { persistRecoveryRecords, recoveryRecordFromFinalization } from "./recovery.ts";
51
- import { forkRetainedSession } from "./session-fork.ts";
52
- import {
53
- buildFallbackResumeReason,
54
- buildResumePrompt,
55
- RpcRunControl,
56
- getResultOutput,
57
- isFailedResult,
58
- isModelLevelFailure,
59
- reviewVerdict,
60
- runSingleAgentWithModelFallback,
61
- type SingleResult,
62
- type SubagentDetails,
63
- type SubagentLiveEvent,
64
- type SubagentRecordEvent,
65
- } from "./spawn.ts";
66
- import { inspectorStore, summarizeToolArgs } from "./trajectory.ts";
67
- import {
68
- createWorktreeIsolation,
69
- resolveWorktreeTarget,
70
- type IsolationMode,
71
- type WorktreeFinalization,
72
- type WorktreeIsolation,
73
- } from "./worktree.ts";
74
-
75
- const NON_BLANK_TASK_OPTIONS = { minLength: 1, pattern: "\\S" } as const;
76
- export const FORK_CONTINUATION_PROMPT =
77
- "Continue from the retained context above. Review the prior work, then take the most useful next step toward completing the existing objective without repeating completed work.";
78
- export const WORKTREE_ISOLATION_INSTRUCTIONS =
79
- "You are running in a temporary detached Git worktree. Work only in the current cwd; do not create another worktree or manually copy/apply changes to the original checkout. The parent dispatcher will integrate your tracked, deleted, and untracked changes when this thread finally settles.";
80
-
81
- export function buildWorktreeTaskPrompt(task: string): string {
82
- return `${WORKTREE_ISOLATION_INSTRUCTIONS}\n\nTask: ${task}`;
83
- }
84
-
85
- function withWorktreeSystemPrompt(agent: AgentConfig): AgentConfig {
86
- return {
87
- ...agent,
88
- systemPrompt: `${agent.systemPrompt.trimEnd()}\n\n${WORKTREE_ISOLATION_INSTRUCTIONS}`.trim(),
89
- };
90
- }
91
-
92
- interface DispatchEnvironment {
93
- ctx: ExtensionContext;
94
- config: SubagentsConfig;
95
- agents: AgentConfig[];
96
- sessionRef?: string;
97
- }
98
-
99
- const VISION_DESCRIPTION =
100
- "Set true when the task may require viewing images (screenshots, mockups, designs) — the configured vision model becomes primary, followed by the agent backup and current main-window model";
101
-
102
- const ISOLATION_DESCRIPTION =
103
- "Filesystem isolation: shared uses the caller's working tree; worktree creates a detached temporary Git worktree (write-capable agents only)";
104
-
105
- const IsolationSchema = Type.Optional(
106
- StringEnum(["shared", "worktree"] as const, { description: ISOLATION_DESCRIPTION }),
107
- );
108
-
109
- const TaskItem = Type.Object({
110
- agent: Type.String({ description: "Name of the agent to invoke" }),
111
- task: Type.String({
112
- ...NON_BLANK_TASK_OPTIONS,
113
- description: "Self-contained task to delegate (the agent has no memory of this conversation)",
114
- }),
115
- cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
116
- vision: Type.Optional(Type.Boolean({ description: VISION_DESCRIPTION })),
117
- isolation: IsolationSchema,
118
- });
119
-
120
- const SubagentParams = Type.Object({
121
- agent: Type.Optional(Type.String({ description: "Name of the agent to invoke (single mode)" })),
122
- task: Type.Optional(
123
- Type.String({ ...NON_BLANK_TASK_OPTIONS, description: "Self-contained task to delegate (single mode)" }),
124
- ),
125
- tasks: Type.Optional(Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" })),
126
- cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })),
127
- vision: Type.Optional(Type.Boolean({ description: VISION_DESCRIPTION })),
128
- isolation: IsolationSchema,
129
- });
130
-
131
- export function defaultIsolationMode(mode: "single" | "parallel", agentName: string, requested?: IsolationMode): IsolationMode {
132
- if (requested) return requested;
133
- return mode === "parallel" && agentName === "worker" ? "worktree" : "shared";
134
- }
135
-
136
- export function isWorktreeCapableAgent(agent: AgentConfig): boolean {
137
- if (agent.name === "explore" || agent.name === "reviewer") return false;
138
- if (agent.name === "worker") return true;
139
- if (!agent.tools) return true;
140
- return agent.tools.includes("edit") || agent.tools.includes("write");
141
- }
142
-
143
- const autoFixRootTails = new Map<string, Promise<void>>();
144
-
145
- async function canonicalAutoFixRoot(cwd: string): Promise<string> {
146
- try {
147
- return (await resolveWorktreeTarget(cwd)).originalRoot;
148
- } catch {
149
- try {
150
- return await realpath(resolve(cwd));
151
- } catch {
152
- return resolve(cwd);
153
- }
154
- }
155
- }
156
-
157
- /** Keep the complete worker→review loop exclusive for one canonical repository.
158
- * Child processes have independent file-mutation queues, so queue concurrency
159
- * alone cannot make shared-checkout edits safe. */
160
- function serializeAutoFixChain(
161
- cwd: string,
162
- task: (signal: AbortSignal) => Promise<void>,
163
- ): (signal: AbortSignal) => Promise<void> {
164
- return async (signal) => {
165
- if (signal.aborted) return;
166
- const root = await canonicalAutoFixRoot(cwd);
167
- const key = process.platform === "win32" ? root.toLowerCase() : root;
168
- const previous = autoFixRootTails.get(key) ?? Promise.resolve();
169
- let release!: () => void;
170
- const gate = new Promise<void>((resolveGate) => {
171
- release = resolveGate;
172
- });
173
- const tail = previous.catch(() => undefined).then(() => gate);
174
- autoFixRootTails.set(key, tail);
175
- await previous.catch(() => undefined);
176
- try {
177
- if (!signal.aborted) await task(signal);
178
- } finally {
179
- release();
180
- if (autoFixRootTails.get(key) === tail) autoFixRootTails.delete(key);
181
- }
182
- };
183
- }
184
-
185
- function resolveDispatchModelPool(
186
- agent: AgentConfig,
187
- config: SubagentsConfig,
188
- mainRef: string | undefined,
189
- vision: boolean,
190
- ): { agent: AgentConfig; fallbackModelRefs: string[] } {
191
- const pool = resolveAgentModelPool({
192
- primaryRef: vision ? config.visionModel : config.agentModels[agent.name],
193
- backupRef: config.agentBackupModels[agent.name],
194
- mainRef,
195
- declaredDefaultRef: agent.model,
196
- });
197
- return {
198
- agent: { ...agent, model: pool.primaryRef },
199
- fallbackModelRefs: pool.fallbackModelRefs,
200
- };
201
- }
202
-
203
- export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime): void {
204
- pi.registerTool({
205
- name: "subagent",
206
- label: "Subagent",
207
- description: [
208
- "Delegate a discrete, self-contained task to a specialized sub-agent running in an ISOLATED context window.",
209
- "Agents: explore (read-only codebase recon), worker (implement/fix/refactor/test, full tools), reviewer (adversarial pre-commit review, read-only).",
210
- "Modes: single ({agent, task}) or parallel ({tasks: [{agent, task}, ...]}).",
211
- "Isolation: single tasks default to shared; parallel worker tasks default to detached Git worktrees unless isolation: shared is explicit. explore/reviewer cannot use worktree isolation.",
212
- "Use subagent_control to steer, retarget, park, resume, or fork a thread by its stable run id.",
213
- "It starts agents in the background and immediately returns control to the main window; completion messages automatically wake the main agent to continue.",
214
- "Each agent has no memory of this conversation brief it fully (goal, exact paths, constraints, expected output).",
215
- "Results arrive as wake-up messages automatically you do NOT need to wait. If you must get a result in-turn, subagent_wait is a non-blocking lookup by default (pass timeoutMs to block).",
216
- "Vision: set vision: true when the task may require viewing images (screenshots, mockups, design files — e.g. frontend work) — the configured vision model is primary, followed by that agent's backup and the current main-window model.",
217
- ].join(" "),
218
- promptSnippet:
219
- "Start background subagents: explore (read-only search), worker (implement), reviewer (adversarial review); completion automatically resumes the main agent. Simple tasks: use direct tools, not subagents.",
220
- promptGuidelines: [
221
- "Delegate only when an isolated context genuinely pays: broad exploration, a self-contained implementation, or a review gate. Handle simple lookups and one-line edits inline with direct tools — never spawn a sub-agent for them.",
222
- "Use subagent with agent 'explore' for broad or open-ended code search before large changes; a targeted 'where is X' is a direct grep/read.",
223
- "Use subagent with agent 'worker' for a self-contained implementation task worth a separate context; it plans internally.",
224
- "Use subagent with agent 'reviewer' for a fresh read-only review before reporting work done or committing.",
225
- "subagent launches work in the background and ends the current turn; when a result arrives, the main agent is automatically resumed with it.",
226
- "Run independent tasks in parallel by passing a tasks array to subagent; parallel worker items default to isolation: worktree so their edits are integrated independently. Pass isolation: shared only when workers intentionally need the caller's live uncommitted tree.",
227
- "Use isolation: worktree only for worker/write-capable agents and only inside a Git repository with a committed HEAD; setup or integration failures never silently fall back to shared.",
228
- "NEVER sleep or poll, and do NOT call subagent_wait to hold the turn subagent ends the turn immediately and the result arrives as a message that wakes you automatically (even mid-turn). Ending your turn is the default and the only correct way to wait.",
229
- "If you must keep the turn for a result, call subagent_wait with an explicit timeoutMs (non-blocking by default)never bash sleep/timeout to wait for a sub-agent.",
230
- "When a delegated task may require viewing images (frontend screenshots, mockups, design comparisons), pass vision: true and give the sub-agent the exact image paths — it reads them with its read tool. The configured vision model becomes primary; model-level failures continue through the agent's backup pool and current main-window model.",
231
- "When a sub-agent result arrives it is already shown to the user — do NOT restate, paraphrase, or summarize it; reply with only your own conclusion or next action (often just one line), since duplicating the result wastes tokens for nothing.",
232
- ],
233
- parameters: SubagentParams,
234
-
235
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
236
- monitor.beginTurn();
237
- const config = await loadConfig(runtime.configPath);
238
- // Pick up concurrency changes from /subagents-setup without a restart.
239
- runtime.backgroundQueue.setConcurrency(config.maxConcurrency);
240
-
241
- // Finished runs leave the widget immediately. Their final findings are sent
242
- // back as a custom message that automatically starts a follow-up turn.
243
- const finishRun = (
244
- runId: number,
245
- status: "done" | "failed",
246
- opts?: { silent?: boolean; retain?: boolean },
247
- ): void => {
248
- monitor.setStatus(runId, status); // stamps endedAt for the elapsed time
249
- const run = opts?.retain ? monitor.findRun(runId) : monitor.removeRun(runId);
250
- if (!run) return; // already finished — stay idempotent
251
- if (opts?.retain) monitor.setRetained(runId, true);
252
- if (opts?.silent || !runtime.sessionActive) return;
253
- const icon = status === "done" ? "" : "";
254
- ctx.ui.notify(`${icon} ${monitor.summarize(run)}`, status === "done" ? "info" : "error");
255
- };
256
-
257
- // Live sub-agent activity concise one-line status ("thinking",
258
- // "read src/index.ts", ...), never a raw args blob. In parallel, every
259
- // live event is appended to the thread's append-only trajectory (status,
260
- // model-candidate changes, usage, tool starts/ends with a redacted
261
- // args summary) so /subagents-inspect can replay what happened. The live
262
- // handler only updates widget status; finishing (removeRun + notify) is
263
- // owned by the queue task / launchInLoop. That keeps a startup retry —
264
- // which fires a transient "failed" status before relaunching from
265
- // ripping the row out early, and lets the queue task decide between
266
- // delivering a reviewer's result and starting an auto-fix chain.
267
- const makeLiveHandler =
268
- (runId: number, threadId?: number, generation?: number) =>
269
- (e: SubagentLiveEvent): void => {
270
- if (generation !== undefined && runtime.threads.get(runId)?.generation !== generation) return;
271
- switch (e.kind) {
272
- case "status":
273
- // Only update the widget status here. Finishing (removeRun + notify) is
274
- // owned by the queue task / launchInLoop so that a startup retry which
275
- // fires a transient "failed" status before relaunching the child never
276
- // rips the row out from under the retry or emits a premature "✗" toast.
277
- monitor.setStatus(runId, e.status);
278
- break;
279
- case "model":
280
- monitor.setModel(runId, e.model, e.fallbackFrom);
281
- break;
282
- case "usage":
283
- monitor.setUsage(runId, e.usage, e.model);
284
- break;
285
- case "tool_start":
286
- monitor.recordToolStart(runId, e.toolName, formatToolActivity(e.toolName, e.args));
287
- break;
288
- case "tool_end":
289
- monitor.recordToolEnd(runId, e.toolName, e.isError);
290
- break;
291
- case "thinking":
292
- monitor.setActivity(runId, "thinking");
293
- break;
294
- case "text":
295
- // A text delta is model output, not a filesystem write.
296
- monitor.setActivity(runId, "responding");
297
- break;
298
- }
299
- if (threadId !== undefined) {
300
- const trajectory = inspectorStore.get(threadId).trajectory;
301
- switch (e.kind) {
302
- case "status":
303
- trajectory.append({ kind: "status", status: e.status });
304
- break;
305
- case "model":
306
- trajectory.append({ kind: "candidate", model: e.model, fallbackFrom: e.fallbackFrom });
307
- break;
308
- case "usage":
309
- trajectory.append({ kind: "usage", usage: { ...e.usage }, model: e.model });
310
- break;
311
- case "tool_start":
312
- trajectory.append({
313
- kind: "tool_start",
314
- tool: e.toolName,
315
- toolCallId: e.toolCallId,
316
- summary: summarizeToolArgs(e.args),
317
- });
318
- break;
319
- case "tool_end":
320
- trajectory.append({ kind: "tool_end", tool: e.toolName, toolCallId: e.toolCallId, isError: e.isError });
321
- break;
322
- // Text/thinking deltas arrive via onRecord below.
323
- }
324
- }
325
- };
326
-
327
- /** Raw streamed output (text/thinking deltas) → the thread's bounded
328
- * transcript buffer; dropped on restart, never carried across generations. */
329
- const makeRecordHandler =
330
- (threadId: number, generation?: number) =>
331
- (e: SubagentRecordEvent): void => {
332
- if (generation !== undefined && runtime.threads.get(threadId)?.generation !== generation) return;
333
- const transcript = inspectorStore.get(threadId).transcript;
334
- if (e.kind === "thinking") transcript.appendThinking(e.delta);
335
- else transcript.appendText(e.delta);
336
- };
337
- const discovery = discoverAgents(ctx.cwd, {
338
- scope: config.agentScope,
339
- enabledNames: config.enabledAgents,
340
- projectTrusted: ctx.isProjectTrusted?.() === true,
341
- });
342
- const sessionRef = currentModelRef(ctx);
343
- const agents = discovery.agents;
344
-
345
- const hasTasks = (params.tasks?.length ?? 0) > 0;
346
- const hasSingle = Boolean(params.agent) && params.task !== undefined;
347
-
348
- const makeDetails =
349
- (mode: "single" | "parallel", background = false) =>
350
- (results: SingleResult[]): SubagentDetails => ({ mode, results, background });
351
-
352
- const catalog = agents.map((a) => a.name).join(", ") || "none";
353
-
354
- if (Number(hasTasks) + Number(hasSingle) !== 1) {
355
- return {
356
- content: [
357
- {
358
- type: "text",
359
- text: `Invalid parameters. Provide exactly one mode: single {agent, task} or parallel {tasks: [...]}. Enabled agents: ${catalog}.`,
360
- },
361
- ],
362
- details: makeDetails("single")([]),
363
- };
364
- }
365
-
366
- if (hasTasks) {
367
- const blankTaskIndex = params.tasks?.findIndex(({ task }) => task.trim().length === 0) ?? -1;
368
- if (blankTaskIndex !== -1) {
369
- return {
370
- content: [
371
- {
372
- type: "text",
373
- text: `Invalid parameters. tasks[${blankTaskIndex}].task must contain at least one non-whitespace character. No background tasks were started. Enabled agents: ${catalog}.`,
374
- },
375
- ],
376
- details: makeDetails("parallel")([]),
377
- };
378
- }
379
- } else if (params.task?.trim().length === 0) {
380
- return {
381
- content: [
382
- {
383
- type: "text",
384
- text: `Invalid parameters. task must contain at least one non-whitespace character. Enabled agents: ${catalog}.`,
385
- },
386
- ],
387
- details: makeDetails("single")([]),
388
- };
389
- }
390
-
391
- /**
392
- * Dispatch one agent inside an auto-fix chain: tracked in the widget with a
393
- * groupId/relationLabel, but NOT delivered through the completion flow the
394
- * chain owner assembles and delivers the whole group at the end.
395
- */
396
- const launchInLoop = async (
397
- agentName: string,
398
- task: string,
399
- executionCwd: string,
400
- signal: AbortSignal,
401
- meta: RunChainMeta,
402
- vision = false,
403
- ): Promise<{ runId?: number; result: SingleResult }> => {
404
- const agent = agents.find((candidate) => candidate.name === agentName);
405
- if (!agent) return { result: failedStartResult(agentName, task, `Unknown agent: "${agentName}".`) };
406
- // Vision chains keep the vision override as each round's primary while
407
- // retaining that worker/reviewer's own configured backup pool.
408
- const pool = resolveDispatchModelPool(agent, config, sessionRef, vision);
409
- const thinkingLevel = config.agentThinkingLevels[agent.name] ?? agent.thinking ?? config.thinkingLevel;
410
- const runId = monitor.addRun(agent.name, task, pool.agent.model, thinkingLevel, meta);
411
- // Chain rounds are real threads: they get their own trajectory so the
412
- // inspector can show each fix/re-review round's full story.
413
- const chainState = inspectorStore.get(runId);
414
- chainState.retainFrom({ agent: agent.name, task, status: "queued", model: pool.agent.model, thinking: thinkingLevel });
415
- chainState.trajectory.append({
416
- kind: "dispatch",
417
- agent: agent.name,
418
- task,
419
- model: pool.agent.model,
420
- thinking: thinkingLevel,
421
- pool: pool.fallbackModelRefs,
422
- vision,
423
- isolation: "shared",
424
- originalCwd: executionCwd,
425
- isolationCwd: executionCwd,
426
- });
427
- const onLive = makeLiveHandler(runId, runId);
428
- const onRecord = makeRecordHandler(runId);
429
- try {
430
- const result = await runSingleAgentWithModelFallback(
431
- {
432
- defaultCwd: executionCwd,
433
- cwd: executionCwd,
434
- agent: pool.agent,
435
- agentName,
436
- task,
437
- thinkingLevel,
438
- signal,
439
- onLive,
440
- onRecord,
441
- makeDetails: makeDetails("single", true),
442
- idleTimeoutMs: config.idleTimeoutSec * 1000,
443
- },
444
- pool.fallbackModelRefs,
445
- );
446
- result.runId = runId;
447
- result.isolation = "shared";
448
- result.originalCwd = executionCwd;
449
- result.isolationCwd = executionCwd;
450
- runtime.retainSession(result);
451
- monitor.setModel(runId, result.model, result.modelFallbackFrom);
452
- chainState.trajectory.append({
453
- kind: "settled",
454
- status: isFailedResult(result) ? "failed" : "done",
455
- model: result.model,
456
- });
457
- // Keep the finished round visible in the widget while the chain is
458
- // still running, with a one-line summary of what it did; the whole
459
- // group is dropped when the chain resolves (see removeChainGroup).
460
- monitor.setSummary(runId, summarizeChainResult(result));
461
- finishRun(runId, isFailedResult(result) ? "failed" : "done", { retain: true });
462
- const retainedRun = monitor.findRun(runId);
463
- if (retainedRun) chainState.retainFrom(retainedRun);
464
- runtime.registerRunResult(runId, result);
465
- return { runId, result };
466
- } catch (error) {
467
- finishRun(runId, "failed", { retain: true });
468
- chainState.trajectory.append({ kind: "settled", status: "failed", model: pool.agent.model });
469
- const retainedRun = monitor.findRun(runId);
470
- if (retainedRun) chainState.retainFrom(retainedRun);
471
- const errorMessage = error instanceof Error ? error.message : String(error);
472
- const crashed: SingleResult = {
473
- ...queuedResult(pool.agent, task, thinkingLevel),
474
- runId,
475
- isolation: "shared",
476
- originalCwd: executionCwd,
477
- isolationCwd: executionCwd,
478
- exitCode: 1,
479
- stderr: errorMessage,
480
- stopReason: signal.aborted ? "aborted" : "error",
481
- errorMessage,
482
- dispatchFailed: true,
483
- };
484
- runtime.registerRunResult(runId, crashed);
485
- return { runId, result: crashed };
486
- }
487
- };
488
-
489
- /**
490
- * Run the auto-fix chain in the background: worker (briefed with the review's
491
- * findings) reviewer re-review, up to maxFixRounds times. The main agent is
492
- * not woken mid-loop; the full chain is delivered as one group at the end.
493
- * Failures short-circuit: a crashed worker skips its re-review and delivers.
494
- * The triggering reviewer's run stays visible in the widget (annotated) until
495
- * the chain resolves, so the ↳ rows have an obvious parent.
496
- */
497
- /** Drop every widget row belonging to an auto-fix chain; the retained
498
- * parent row is removed separately (it does not carry the groupId). */
499
- const removeChainGroup = (groupId: string): void => {
500
- for (const run of [...monitor.getRuns()]) {
501
- if (run.groupId === groupId) monitor.removeRun(run.id);
502
- }
503
- };
504
-
505
- const startFixLoop = (
506
- initialReviewerResult: SingleResult,
507
- parentGroupId: string,
508
- parentRunId: number,
509
- executionCwd: string,
510
- vision = false,
511
- ): void => {
512
- const parentThreadAtStart = runtime.threads.get(parentRunId);
513
- if (!parentThreadAtStart) return;
514
- const parentGeneration = parentThreadAtStart.generation;
515
- const parentControl = parentThreadAtStart.control;
516
- let fixController: AbortController | undefined;
517
- const ownsParent = (): boolean => {
518
- const current = runtime.threads.get(parentRunId);
519
- return fixController !== undefined &&
520
- current === parentThreadAtStart &&
521
- current.generation === parentGeneration &&
522
- current.control === parentControl &&
523
- current.queueController === fixController &&
524
- runtime.runControllers.get(parentRunId) === fixController;
525
- };
526
- const clearOwnedController = (): void => {
527
- if (!fixController) return;
528
- if (runtime.runControllers.get(parentRunId) === fixController) {
529
- runtime.runControllers.delete(parentRunId);
530
- }
531
- const current = runtime.threads.get(parentRunId);
532
- if (current === parentThreadAtStart && current.queueController === fixController) {
533
- current.queueController = undefined;
534
- }
535
- };
536
- fixController = runtime.backgroundQueue.enqueue(
537
- serializeAutoFixChain(executionCwd, async (signal) => {
538
- const chain: ChainStep[] = [
539
- { runId: parentRunId, result: initialReviewerResult, relation: "initial review" },
540
- ];
541
- let lastReviewer = initialReviewerResult;
542
- for (let round = 1; round <= config.maxFixRounds; round++) {
543
- if (!runtime.sessionActive) break;
544
- const fixBrief = buildFixTaskBrief(lastReviewer, round, config.maxFixRounds);
545
- const workerStep = await launchInLoop("worker", fixBrief, executionCwd, signal, {
546
- groupId: parentGroupId,
547
- relationLabel: `fix round ${round}`,
548
- }, vision);
549
- // Preserve the newest sub-step before checking chain ownership. A
550
- // destructive stop invalidates ownsParent() while this child is
551
- // aborting, and its partial output must become the parent's stopped
552
- // result instead of falling back to the old triggering review.
553
- if (
554
- runtime.threads.get(parentRunId) === parentThreadAtStart &&
555
- parentThreadAtStart.generation === parentGeneration
556
- ) {
557
- parentThreadAtStart.lastResult = workerStep.result;
558
- parentThreadAtStart.agentName = workerStep.result.agent;
559
- parentThreadAtStart.task = workerStep.result.task;
560
- parentThreadAtStart.sessionId = workerStep.result.sessionId;
561
- parentThreadAtStart.sessionDir = workerStep.result.sessionDir;
562
- runtime.retainSession(workerStep.result);
563
- }
564
- if (!ownsParent()) return;
565
- chain.push({ ...workerStep, relation: `fix round ${round}` });
566
- if (!runtime.sessionActive || isFailedResult(workerStep.result)) break;
567
- const reReviewBrief = buildReReviewBrief(lastReviewer, round);
568
- const reviewStep = await launchInLoop("reviewer", reReviewBrief, executionCwd, signal, {
569
- groupId: parentGroupId,
570
- relationLabel: `re-review round ${round}`,
571
- }, vision);
572
- if (
573
- runtime.threads.get(parentRunId) === parentThreadAtStart &&
574
- parentThreadAtStart.generation === parentGeneration
575
- ) {
576
- parentThreadAtStart.lastResult = reviewStep.result;
577
- parentThreadAtStart.agentName = reviewStep.result.agent;
578
- parentThreadAtStart.task = reviewStep.result.task;
579
- parentThreadAtStart.sessionId = reviewStep.result.sessionId;
580
- parentThreadAtStart.sessionDir = reviewStep.result.sessionDir;
581
- runtime.retainSession(reviewStep.result);
582
- }
583
- if (!ownsParent()) return;
584
- chain.push({ ...reviewStep, relation: `re-review round ${round}` });
585
- lastReviewer = reviewStep.result;
586
- // A crashed re-review must stop the chain like a crashed worker: its
587
- // output (if any) is not a verdict, and feeding it to the next fix
588
- // round would brief the worker from garbage.
589
- if (!runtime.sessionActive || isFailedResult(reviewStep.result)) break;
590
- if (reviewVerdict(getResultOutput(reviewStep.result)) === "pass") break;
591
- }
592
- // Every parent mutation is guarded by the exact generation, control, and
593
- // queue controller that started this chain. A parked/resumed generation or
594
- // destructive stop must make this old orchestration a no-op.
595
- if (!ownsParent()) return;
596
- const controlledParent = parentThreadAtStart;
597
- if (controlledParent.retired || controlledParent.state === "stopped") {
598
- clearOwnedController();
599
- removeChainGroup(parentGroupId);
600
- return;
601
- }
602
- // Parking an auto-fix chain aborts its in-flight child but preserves the
603
- // parent's retained checkpoint and suppresses an aborted chain delivery.
604
- if (controlledParent.state === "parked") {
605
- clearOwnedController();
606
- removeChainGroup(parentGroupId);
607
- monitor.setRetained(parentRunId, false);
608
- monitor.setStatus(parentRunId, "parked");
609
- return;
610
- }
611
- // The chain is done (success, exhaustion, or abort): drop the retained
612
- // parent row and its retained round rows, then deliver one condensed
613
- // summary. Register the parent's final state (the last chain result)
614
- // before removal so subagent_wait can resolve it.
615
- const last = chain[chain.length - 1];
616
- runtime.registerRunResult(parentRunId, last.result);
617
- removeChainGroup(parentGroupId);
618
- monitor.removeRun(parentRunId);
619
- runtime.retainSession(last.result);
620
- const parentThread = parentThreadAtStart;
621
- parentThread.agentName = last.result.agent;
622
- parentThread.task = last.result.task;
623
- parentThread.sessionId = last.result.sessionId;
624
- parentThread.sessionDir = last.result.sessionDir;
625
- parentThread.state = isFailedResult(last.result) ? "failed" : "completed";
626
- // The chain outcome settles the parent thread's trajectory: the
627
- // last chain step is its final state.
628
- const parentInspection = inspectorStore.get(parentRunId);
629
- parentInspection.trajectory.append({
630
- kind: "settled",
631
- status: parentThread.state === "failed" ? "failed" : "done",
632
- model: last.result.model,
633
- });
634
- parentInspection.retainFrom({
635
- agent: last.result.agent,
636
- task: last.result.task,
637
- model: last.result.model,
638
- status: parentThread.state === "failed" ? "failed" : "done",
639
- usage: last.result.usage,
640
- });
641
- if (!runtime.sessionActive) {
642
- clearOwnedController();
643
- return;
644
- }
645
- // One compact message instead of every round's raw output: the summary
646
- // lines cover each step (verdict + what changed/found), and the final
647
- // step's full report is appended only when its detail is actionable
648
- // (a FAIL verdict, a crash, or a model-level failure the main agent
649
- // must take over). Everything else stays one `subagent_status #id`
650
- // call away.
651
- let block = formatChainSummary(chain);
652
- if (isFailedResult(last.result) && isModelLevelFailure(last.result)) {
653
- block = `${block}\n\n${formatCompletionBlock(last.result, config.maxResultLines, executionCwd)}\n\n${modelLevelTakeoverNote(last.result, { runId: parentRunId })}`;
654
- } else if (isFailedResult(last.result) || reviewVerdict(getResultOutput(last.result)) === "fail") {
655
- block = `${block}\n\n${formatCompletionBlock(last.result, config.maxResultLines, executionCwd)}`;
656
- }
657
- runtime.sendCompletionGroup([
658
- {
659
- agent: `auto-fix chain (${last.result.agent})`,
660
- block,
661
- triggerTurn: true,
662
- },
663
- ]);
664
- runtime.completionBatcher.flush();
665
- clearOwnedController();
666
- }),
667
- () => {
668
- if (!ownsParent()) return;
669
- const controlledParent = parentThreadAtStart;
670
- clearOwnedController();
671
- removeChainGroup(parentGroupId);
672
- if (controlledParent.state === "parked") {
673
- monitor.setRetained(parentRunId, false);
674
- monitor.setStatus(parentRunId, "parked");
675
- return;
676
- }
677
- if (!controlledParent.retired) monitor.removeRun(parentRunId);
678
- },
679
- (error) => {
680
- // A crash inside the chain orchestration (failed runs are caught by
681
- // launchInLoop and delivered as part of the chain) must not vanish, but
682
- // an obsolete generation/controller must never publish it.
683
- if (!ownsParent()) return;
684
- if (parentThreadAtStart.retired || parentThreadAtStart.state === "stopped") {
685
- clearOwnedController();
686
- removeChainGroup(parentGroupId);
687
- return;
688
- }
689
- runtime.registerRunResult(parentRunId, initialReviewerResult);
690
- removeChainGroup(parentGroupId);
691
- monitor.removeRun(parentRunId);
692
- if (!runtime.sessionActive) {
693
- clearOwnedController();
694
- return;
695
- }
696
- const errorMessage = error instanceof Error ? error.message : String(error);
697
- try {
698
- ctx.ui.notify(`✗ auto-fix chain dispatch failed: ${errorMessage}`, "error");
699
- // Keep the triggering review's findings: the chain crashed before any
700
- // fix round ran, and the main agent needs the review to act on it.
701
- runtime.sendCompletionGroup([
702
- {
703
- agent: initialReviewerResult.agent,
704
- block: `${formatCompletionBlock(initialReviewerResult, config.maxResultLines, executionCwd)}\n\nAuto-fix chain crashed before completion: ${errorMessage}. The planned fix rounds did not run; the review above is the triggering reviewer's full output.`,
705
- triggerTurn: true,
706
- },
707
- ]);
708
- runtime.completionBatcher.flush();
709
- } catch {
710
- /* a second delivery failure must not throw through the queue */
711
- } finally {
712
- clearOwnedController();
713
- }
714
- },
715
- );
716
- runtime.runControllers.set(parentRunId, fixController);
717
- parentThreadAtStart.queueController = fixController;
718
- const priorCompletion = parentThreadAtStart.generationCompletion;
719
- parentThreadAtStart.generationCompletion = Promise.all([
720
- priorCompletion,
721
- runtime.backgroundQueue.waitForTask(fixController),
722
- ]).then(() => undefined);
723
- };
724
-
725
- interface SessionSeed {
726
- sessionId?: string;
727
- sessionDir?: string;
728
- prompt?: string;
729
- worktree?: WorktreeIsolation;
730
- forkedFromRunId?: number;
731
- forkObjective?: string;
732
- modelPool?: string[];
733
- thinkingLevel?: SubagentThread["thinkingLevel"];
734
- }
735
-
736
- interface ResumeReservation {
737
- version: number;
738
- generation: number;
739
- sessionId?: string;
740
- sessionDir?: string;
741
- }
742
-
743
- const ownsResumeReservation = (
744
- thread: SubagentThread,
745
- reservation: ResumeReservation,
746
- ): boolean =>
747
- runtime.sessionActive &&
748
- runtime.threads.get(thread.id) === thread &&
749
- !thread.retired &&
750
- thread.lifecycleOperation === "resume" &&
751
- thread.lifecycleVersion === reservation.version &&
752
- thread.generation === reservation.generation &&
753
- thread.sessionId === reservation.sessionId &&
754
- thread.sessionDir === reservation.sessionDir;
755
-
756
- const beginPreflight = (): (() => void) => {
757
- let resolvePreflight!: () => void;
758
- const preflight = new Promise<void>((resolve) => {
759
- resolvePreflight = resolve;
760
- });
761
- runtime.preflightOperations.add(preflight);
762
- return () => {
763
- runtime.preflightOperations.delete(preflight);
764
- resolvePreflight();
765
- };
766
- };
767
-
768
- const startBackground = async (
769
- agentName: string,
770
- task: string,
771
- cwd: string | undefined,
772
- vision = false,
773
- isolation: IsolationMode = "shared",
774
- existingThread?: SubagentThread,
775
- newObjectiveOnResume = false,
776
- environment?: DispatchEnvironment,
777
- seed?: SessionSeed,
778
- resumeReservation?: ResumeReservation,
779
- ): Promise<SingleResult> => {
780
- if (!runtime.sessionActive) {
781
- return failedStartResult(agentName, task, "Parent session shut down before this subagent generation could start.");
782
- }
783
- if (existingThread && (!resumeReservation || !ownsResumeReservation(existingThread, resumeReservation))) {
784
- return failedStartResult(agentName, task, `Run #${existingThread.id} changed while resume was preparing; no new generation was started.`);
785
- }
786
- const runCtx = environment?.ctx ?? ctx;
787
- const runConfig = environment?.config ?? config;
788
- const runAgents = environment?.agents ?? agents;
789
- const runSessionRef = environment?.sessionRef ?? sessionRef;
790
- const agent = runAgents.find((candidate) => candidate.name === agentName);
791
- if (!agent) return failedStartResult(agentName, task, `Unknown agent: "${agentName}".`);
792
- if (isolation === "worktree" && !isWorktreeCapableAgent(agent)) {
793
- return {
794
- ...failedStartResult(agentName, task, `Agent "${agentName}" is read-only; worktree isolation is available only to worker/write-capable agents.`),
795
- isolation,
796
- };
797
- }
798
-
799
- const originalCwd = resolve(cwd ?? runCtx.cwd);
800
- const previousWorktree = existingThread?.worktree;
801
- let worktree = seed?.worktree ?? previousWorktree;
802
- if (isolation === "worktree") {
803
- if (worktree && worktree.state !== "active") {
804
- return {
805
- ...failedStartResult(agentName, task, `Run #${existingThread?.id ?? "?"} has no active continuation worktree.`),
806
- isolation,
807
- originalCwd,
808
- integrationStatus: worktree.state === "finalizing" ? "pending" : worktree.state,
809
- };
810
- }
811
- if (!worktree) {
812
- try {
813
- worktree = await createWorktreeIsolation(originalCwd);
814
- } catch (error) {
815
- return {
816
- ...failedStartResult(agentName, task, error instanceof Error ? error.message : String(error)),
817
- isolation,
818
- originalCwd,
819
- };
820
- }
821
- }
822
- }
823
- const executionCwd = worktree?.cwd ?? originalCwd;
824
- const resolvedPool = resolveDispatchModelPool(agent, runConfig, runSessionRef, vision);
825
- const inheritedPool = seed?.modelPool?.filter((ref) => ref.trim().length > 0) ?? [];
826
- const rawPool = inheritedPool.length > 0
827
- ? {
828
- agent: { ...agent, model: inheritedPool[0] },
829
- fallbackModelRefs: inheritedPool.slice(1),
830
- }
831
- : resolvedPool;
832
- // Isolation is a persistent system-level invariant, not a one-shot task
833
- // prefix: queued retargets, live retargets, resumes, and model fallbacks
834
- // all keep the same worktree boundary.
835
- const pool = isolation === "worktree"
836
- ? { ...rawPool, agent: withWorktreeSystemPrompt(rawPool.agent) }
837
- : rawPool;
838
- const thinkingLevel = seed?.thinkingLevel ?? runConfig.agentThinkingLevels[agent.name] ?? agent.thinking ?? runConfig.thinkingLevel;
839
- const modelPool = [pool.agent.model, ...pool.fallbackModelRefs].filter((ref): ref is string => Boolean(ref));
840
- const priorTask = existingThread?.task;
841
- const priorSessionId = seed?.sessionId ?? existingThread?.sessionId;
842
- const priorSessionDir = seed?.sessionDir ?? existingThread?.sessionDir;
843
- if (existingThread && resumeReservation && !ownsResumeReservation(existingThread, resumeReservation)) {
844
- return failedStartResult(agentName, task, `Run #${existingThread.id} changed while resume was preparing; no new generation was started.`);
845
- }
846
- const runId = existingThread?.id ?? monitor.addRun(agent.name, task, pool.agent.model, thinkingLevel, {
847
- isolation,
848
- ...(seed?.forkedFromRunId !== undefined ? { forkedFromRunId: seed.forkedFromRunId } : {}),
849
- });
850
- const generation = (existingThread?.generation ?? 0) + 1;
851
- const pending: SingleResult = {
852
- ...queuedResult(pool.agent, task, thinkingLevel),
853
- runId,
854
- isolation,
855
- originalCwd,
856
- isolationCwd: executionCwd,
857
- ...(isolation === "worktree" ? { integrationStatus: "pending" as const } : {}),
858
- ...(seed?.sessionId && seed.sessionDir
859
- ? { sessionId: seed.sessionId, sessionDir: seed.sessionDir, resumed: true }
860
- : {}),
861
- ...(seed?.forkedFromRunId !== undefined ? { forkedFromRunId: seed.forkedFromRunId } : {}),
862
- };
863
- if (existingThread) {
864
- monitor.restartRun(runId, agent.name, task, pool.agent.model, thinkingLevel, isolation);
865
- runtime.settledRuns.delete(runId);
866
- }
867
-
868
- let thread!: SubagentThread;
869
- const control = new RpcRunControl(task, generation, (phase) => {
870
- if (runtime.threads.get(runId)?.generation !== generation || phase === "settled") return;
871
- // Orchestration transitions are part of the trajectory (retrying
872
- // retry event, park/stop → terminal control events).
873
- const trajectory = inspectorStore.get(runId).trajectory;
874
- if (phase === "retrying") trajectory.append({ kind: "retry", reason: "retrying" });
875
- else if (phase === "parked") trajectory.append({ kind: "park" });
876
- else if (phase === "stopped") trajectory.append({ kind: "stop", reason: control.getStopMessage() });
877
- const state: ThreadState =
878
- phase === "queued" || phase === "starting"
879
- ? "queued"
880
- : phase === "steering"
881
- ? "steering"
882
- : phase === "interrupting"
883
- ? "interrupting"
884
- : phase === "parked"
885
- ? "parked"
886
- : phase === "stopped"
887
- ? "stopped"
888
- : "running";
889
- thread.state = state;
890
- if (state === "queued") monitor.setStatus(runId, "queued");
891
- else if (state === "steering") monitor.setStatus(runId, "steering");
892
- else if (state === "interrupting") monitor.setStatus(runId, "interrupting");
893
- else if (state === "parked") monitor.setStatus(runId, "parked");
894
- else if (state === "running") monitor.setStatus(runId, "running");
895
- });
896
-
897
- // Inspector projection for this thread id: on restart the append-only
898
- // event history is kept (bumped generation), while the bounded
899
- // transcript starts fresh for the new generation.
900
- const inspectState = inspectorStore.get(runId);
901
- if (existingThread) {
902
- inspectState.trajectory.restart();
903
- inspectState.transcript.clear();
904
- inspectState.trajectory.append({
905
- kind: "resume",
906
- objective: newObjectiveOnResume ? task : undefined,
907
- });
908
- }
909
- inspectState.retainFrom({ agent: agent.name, task, status: "queued", model: pool.agent.model, thinking: thinkingLevel });
910
- if (seed?.forkedFromRunId !== undefined) {
911
- inspectState.trajectory.append({
912
- kind: "fork",
913
- sourceRunId: seed.forkedFromRunId,
914
- childRunId: runId,
915
- objective: seed.forkObjective,
916
- });
917
- }
918
- inspectState.trajectory.append({
919
- kind: "dispatch",
920
- agent: agent.name,
921
- task,
922
- model: pool.agent.model,
923
- thinking: thinkingLevel,
924
- pool: pool.fallbackModelRefs,
925
- vision,
926
- resumed: existingThread !== undefined || seed !== undefined,
927
- isolation,
928
- originalCwd,
929
- isolationCwd: executionCwd,
930
- });
931
- if (worktree && worktree !== previousWorktree) {
932
- inspectState.trajectory.append({
933
- kind: "worktree",
934
- status: "created",
935
- originalCwd,
936
- isolationCwd: executionCwd,
937
- worktreePath: worktree.worktreePath,
938
- });
939
- }
940
-
941
- if (existingThread) {
942
- thread = existingThread;
943
- thread.generation = generation;
944
- thread.agentName = agent.name;
945
- thread.task = task;
946
- thread.cwd = originalCwd;
947
- thread.executionCwd = executionCwd;
948
- thread.vision = vision;
949
- thread.modelPool = modelPool;
950
- thread.thinkingLevel = thinkingLevel;
951
- thread.isolation = isolation;
952
- thread.worktree = worktree;
953
- thread.state = "queued";
954
- thread.control = control;
955
- // A newly admitted generation owns no output yet. Keeping the prior
956
- // generation here would make a queued stop publish stale task,
957
- // transcript, and session metadata as this generation's partial.
958
- thread.lastResult = undefined;
959
- if (seed?.sessionId && seed.sessionDir) {
960
- thread.sessionId = seed.sessionId;
961
- thread.sessionDir = seed.sessionDir;
962
- }
963
- thread.retireOnSettle = false;
964
- thread.isolationFailureNotified = false;
965
- } else {
966
- thread = {
967
- id: runId,
968
- generation,
969
- agentName: agent.name,
970
- task,
971
- cwd: originalCwd,
972
- executionCwd,
973
- vision,
974
- modelPool,
975
- thinkingLevel,
976
- isolation,
977
- worktree,
978
- state: "queued",
979
- control,
980
- generationCompletion: Promise.resolve(),
981
- lifecycleVersion: 0,
982
- sessionId: seed?.sessionId,
983
- sessionDir: seed?.sessionDir,
984
- forkedFromRunId: seed?.forkedFromRunId,
985
- forkChildRunIds: [],
986
- park: async () => {
987
- throw new Error("Thread park was not initialized.");
988
- },
989
- resume: async () => failedStartResult(agent.name, task, "Thread resume was not initialized."),
990
- fork: async () => failedStartResult(agent.name, task, "Thread fork was not initialized."),
991
- finalizeIsolation: async () => undefined,
992
- };
993
- runtime.threads.set(runId, thread);
994
- }
995
- thread.notifyIsolationFailure = (finalization) => {
996
- const paths = [finalization.worktreePath, finalization.patchPath].filter(Boolean).join(" · ");
997
- runCtx.ui.notify(
998
- `✗ worker worktree ${finalization.integrated ? "cleanup" : "integration"} failed${paths ? ` · retained ${paths}` : ""}: ${finalization.error ?? "unknown Git integration error"}`,
999
- "error",
1000
- );
1001
- };
1002
- thread.finalizeIsolation = async (
1003
- expectedGeneration: number,
1004
- result?: SingleResult,
1005
- ): Promise<WorktreeFinalization | undefined> => {
1006
- if (thread.isolation !== "worktree" || !thread.worktree) return undefined;
1007
- if (thread.generation !== expectedGeneration) return undefined;
1008
- const finalization = await thread.worktree.finalize();
1009
- monitor.setIsolation(runId, "worktree", finalization.status);
1010
- inspectState.trajectory.append({
1011
- kind: "worktree",
1012
- status: finalization.status,
1013
- originalCwd: thread.cwd,
1014
- isolationCwd: thread.executionCwd,
1015
- worktreePath: finalization.worktreePath,
1016
- patchPath: finalization.patchPath,
1017
- integrated: finalization.integrated,
1018
- error: finalization.error,
1019
- });
1020
- if (result) {
1021
- result.runId = runId;
1022
- result.isolation = "worktree";
1023
- result.originalCwd = thread.cwd;
1024
- result.isolationCwd = thread.executionCwd;
1025
- result.integrationStatus = finalization.status;
1026
- result.integrationApplied = finalization.integrated;
1027
- result.integrationError = finalization.error;
1028
- result.integrationWorktreePath = finalization.worktreePath;
1029
- result.integrationPatchPath = finalization.patchPath;
1030
- result.forkedFromRunId = thread.forkedFromRunId;
1031
- result.forkChildRunIds = [...thread.forkChildRunIds];
1032
- if (finalization.status === "retained") {
1033
- const retained = [
1034
- finalization.worktreePath ? `worktree ${finalization.worktreePath}` : undefined,
1035
- finalization.patchPath ? `patch ${finalization.patchPath}` : undefined,
1036
- ].filter(Boolean).join(", ");
1037
- const integrationMessage = finalization.integrated
1038
- ? `Worktree changes were applied, but cleanup failed${retained ? `; retained ${retained}` : ""}: ${finalization.error ?? "unknown Git cleanup error"}`
1039
- : `Worktree integration failed${retained ? `; retained ${retained}` : ""}: ${finalization.error ?? "unknown Git integration error"}`;
1040
- result.exitCode = 1;
1041
- result.stopReason = "error";
1042
- result.errorMessage = result.errorMessage
1043
- ? `${result.errorMessage}\n${integrationMessage}`
1044
- : integrationMessage;
1045
- result.stderr = result.stderr ? `${result.stderr.trimEnd()}\n${integrationMessage}` : integrationMessage;
1046
- }
1047
- }
1048
- if (finalization.status === "retained") {
1049
- runtime.retainWorktreeArtifacts(finalization);
1050
- if (!thread.isolationFailureNotified) {
1051
- thread.isolationFailureNotified = true;
1052
- try {
1053
- thread.notifyIsolationFailure?.(finalization);
1054
- } catch {
1055
- /* notification failures do not hide retained artifacts */
1056
- }
1057
- }
1058
- }
1059
- return finalization;
1060
- };
1061
-
1062
- const cleanupTrackedSessionDir = async (sessionDir: string, action: string): Promise<void> => {
1063
- try {
1064
- await rm(sessionDir, { recursive: true, force: true });
1065
- runtime.sessionDirs.delete(sessionDir);
1066
- } catch (error) {
1067
- // Keep ownership so shutdown can retry; losing the path here leaks a
1068
- // cloned session containing retained model context on Windows locks.
1069
- try {
1070
- runCtx.ui.notify(
1071
- `✗ ${action}; retained ${sessionDir} for shutdown cleanup: ${error instanceof Error ? error.message : String(error)}`,
1072
- "error",
1073
- );
1074
- } catch {
1075
- /* cleanup ownership remains tracked even if the UI is unavailable */
1076
- }
1077
- }
1078
- };
1079
-
1080
- const discardUnusedWorktree = async (candidate: WorktreeIsolation | undefined): Promise<void> => {
1081
- if (!candidate) return;
1082
- try {
1083
- if (candidate.discard) {
1084
- await candidate.discard();
1085
- return;
1086
- }
1087
- // Compatibility for externally supplied/test handles. Production handles
1088
- // expose discard(), so this fallback never integrates a seeded worktree.
1089
- if (candidate.state === "active") await candidate.finalize();
1090
- } catch (error) {
1091
- const retainedPath = existsSync(candidate.worktreePath)
1092
- ? candidate.worktreePath
1093
- : existsSync(candidate.tempDir)
1094
- ? candidate.tempDir
1095
- : undefined;
1096
- const finalization: WorktreeFinalization = {
1097
- status: "retained",
1098
- integrated: false,
1099
- hadChanges: false,
1100
- ...(retainedPath ? { worktreePath: retainedPath } : {}),
1101
- ...(existsSync(candidate.patchPath) ? { patchPath: candidate.patchPath } : {}),
1102
- error: `Discarding unused continuation failed: ${error instanceof Error ? error.message : String(error)}`,
1103
- };
1104
- runtime.retainWorktreeArtifacts(finalization);
1105
- await persistRecoveryRecords(runtime.configPath, [
1106
- recoveryRecordFromFinalization(runId, finalization),
1107
- ]).catch(() => undefined);
1108
- try {
1109
- thread.notifyIsolationFailure?.(finalization);
1110
- } catch {
1111
- /* parent UI may already be shutting down */
1112
- }
1113
- }
1114
- };
1115
-
1116
- const createContinuationWorktree = async (
1117
- source: WorktreeIsolation,
1118
- seedIsIntegrated: boolean,
1119
- ): Promise<WorktreeIsolation> => {
1120
- if (source.state === "finalizing") {
1121
- throw new Error(`Run #${runId}'s worktree is still finalizing.`);
1122
- }
1123
- const seedCheckpoint = await source.snapshotCheckpoint();
1124
- return createWorktreeIsolation(thread.cwd, {
1125
- seedCheckpoint,
1126
- seedIsIntegrated,
1127
- });
1128
- };
1129
-
1130
- thread.park = async (): Promise<"queued" | "active"> => {
1131
- if (thread.retired) throw new Error(`Run #${runId} was retired by subagent_stop.`);
1132
- if (thread.lifecycleOperation) throw new Error(`Run #${runId} is already handling ${thread.lifecycleOperation}.`);
1133
- if (thread.state === "parked") return "active";
1134
- const phase = thread.control.getPhase();
1135
- const queued = thread.state === "queued" && phase === "queued";
1136
- if (
1137
- !queued &&
1138
- ((phase === "settled" && thread.state !== "running") ||
1139
- !["starting", "running", "steering", "interrupting", "retrying", "settled"].includes(phase))
1140
- ) {
1141
- throw new Error(`Run #${runId} is ${thread.state}; only active work can be parked.`);
1142
- }
1143
-
1144
- const version = ++thread.lifecycleVersion;
1145
- const generation = thread.generation;
1146
- const completion = thread.generationCompletion;
1147
- const controller = thread.queueController;
1148
- thread.lifecycleOperation = "park";
1149
- try {
1150
- if (queued) {
1151
- thread.control.parkPending();
1152
- runtime.backgroundQueue.cancel(controller);
1153
- } else {
1154
- await thread.control.park();
1155
- // Auto-fix orchestration has no live RPC attempt once its parent
1156
- // review settled, so cancel its queue owner explicitly.
1157
- if (phase === "settled") runtime.backgroundQueue.cancel(controller);
1158
- }
1159
- await completion;
1160
- if (
1161
- thread.generation !== generation ||
1162
- thread.lifecycleVersion !== version ||
1163
- thread.lifecycleOperation !== "park"
1164
- ) {
1165
- throw new Error(`Run #${runId} changed while parking.`);
1166
- }
1167
- thread.state = "parked";
1168
- thread.queueController = undefined;
1169
- runtime.runControllers.delete(runId);
1170
- monitor.setStatus(runId, "parked");
1171
- return queued ? "queued" : "active";
1172
- } finally {
1173
- if (thread.lifecycleVersion === version && thread.lifecycleOperation === "park") {
1174
- thread.lifecycleOperation = undefined;
1175
- }
1176
- }
1177
- };
1178
-
1179
- thread.resume = async (objective?: string, resumeCtx?: ExtensionContext): Promise<SingleResult> => {
1180
- const requestedObjective = objective?.trim();
1181
- if (!runtime.sessionActive || runtime.threads.get(runId) !== thread) {
1182
- return failedStartResult(thread.agentName, thread.task, `Run #${runId} belongs to a parent session that has shut down.`);
1183
- }
1184
- if (objective !== undefined && !requestedObjective) {
1185
- return failedStartResult(thread.agentName, thread.task, "resume objective must be non-blank when provided.");
1186
- }
1187
- if (thread.retired) return failedStartResult(thread.agentName, thread.task, `Run #${runId} was retired by subagent_stop.`);
1188
- if (thread.lifecycleOperation) {
1189
- return failedStartResult(thread.agentName, thread.task, `Run #${runId} is already ${thread.lifecycleOperation === "resume" ? "resuming" : "being forked"}.`);
1190
- }
1191
- if (!["parked", "completed", "failed"].includes(thread.state)) {
1192
- return failedStartResult(thread.agentName, thread.task, `Run #${runId} is ${thread.state}; it must be parked or settled before resume.`);
1193
- }
1194
-
1195
- // Lifecycle CAS: claim synchronously before the first await, then cancel
1196
- // and fully quiesce any superseded queue/process before cloning or
1197
- // reusing its session. A second resume/fork sees this claim immediately.
1198
- const previousState = thread.state;
1199
- const previousSessionId = thread.sessionId;
1200
- const previousSessionDir = thread.sessionDir;
1201
- const previousExecutionCwd = thread.executionCwd;
1202
- const reservation: ResumeReservation = {
1203
- version: ++thread.lifecycleVersion,
1204
- generation: thread.generation,
1205
- sessionId: previousSessionId,
1206
- sessionDir: previousSessionDir,
1207
- };
1208
- thread.lifecycleOperation = "resume";
1209
- thread.state = "resuming";
1210
- const finishPreflight = beginPreflight();
1211
- const supersededController = thread.queueController;
1212
- runtime.backgroundQueue.cancel(supersededController);
1213
- runtime.runControllers.delete(runId);
1214
-
1215
- let continuationWorktree: WorktreeIsolation | undefined;
1216
- let clonedSession: Awaited<ReturnType<typeof forkRetainedSession>> | undefined;
1217
- try {
1218
- await thread.generationCompletion;
1219
- if (!ownsResumeReservation(thread, reservation)) {
1220
- return failedStartResult(
1221
- thread.agentName,
1222
- thread.task,
1223
- thread.retired
1224
- ? `Run #${runId} was retired by subagent_stop; no new generation was started.`
1225
- : `Run #${runId} changed while resume was preparing; no new generation was started.`,
1226
- );
1227
- }
1228
- thread.state = "resuming";
1229
- const currentCtx = resumeCtx ?? runCtx;
1230
- let seed: SessionSeed | undefined;
1231
- if (thread.isolation === "worktree" && thread.worktree?.state !== "active") {
1232
- if (!thread.worktree) throw new Error(`Run #${runId} has no isolated worktree checkpoint.`);
1233
- const seedAlreadyIntegrated =
1234
- thread.worktree.state === "integrated" ||
1235
- thread.worktree.state === "no_changes" ||
1236
- thread.lastResult?.integrationApplied === true;
1237
- continuationWorktree = await createContinuationWorktree(thread.worktree, seedAlreadyIntegrated);
1238
- if (!ownsResumeReservation(thread, reservation)) {
1239
- throw new Error(`Run #${runId} changed while its continuation worktree was being created.`);
1240
- }
1241
- seed = { worktree: continuationWorktree };
1242
- if (previousSessionId && previousSessionDir) {
1243
- clonedSession = await forkRetainedSession({
1244
- cwd: previousExecutionCwd,
1245
- targetCwd: continuationWorktree.cwd,
1246
- sessionDir: previousSessionDir,
1247
- sessionId: previousSessionId,
1248
- });
1249
- runtime.sessionDirs.add(clonedSession.sessionDir);
1250
- if (!ownsResumeReservation(thread, reservation)) {
1251
- throw new Error(`Run #${runId} changed while its retained session was being cloned.`);
1252
- }
1253
- seed.sessionId = clonedSession.sessionId;
1254
- seed.sessionDir = clonedSession.sessionDir;
1255
- }
1256
- }
1257
-
1258
- const currentConfig = await loadConfig(runtime.configPath);
1259
- if (!ownsResumeReservation(thread, reservation)) {
1260
- throw new Error(`Run #${runId} changed while resume configuration was loading.`);
1261
- }
1262
- runtime.backgroundQueue.setConcurrency(currentConfig.maxConcurrency);
1263
- const currentAgents = discoverAgents(currentCtx.cwd, {
1264
- scope: currentConfig.agentScope,
1265
- enabledNames: currentConfig.enabledAgents,
1266
- projectTrusted: currentCtx.isProjectTrusted?.() === true,
1267
- }).agents;
1268
- const nextTask = requestedObjective ?? thread.task;
1269
- const pending = await startBackground(
1270
- thread.agentName,
1271
- nextTask,
1272
- thread.cwd,
1273
- thread.vision,
1274
- thread.isolation,
1275
- thread,
1276
- objective !== undefined,
1277
- {
1278
- ctx: currentCtx,
1279
- config: currentConfig,
1280
- agents: currentAgents,
1281
- sessionRef: currentModelRef(currentCtx),
1282
- },
1283
- seed,
1284
- reservation,
1285
- );
1286
- if (pending.exitCode !== -1) {
1287
- if (clonedSession) {
1288
- await cleanupTrackedSessionDir(
1289
- clonedSession.sessionDir,
1290
- `Could not discard failed resume session clone for run #${runId}`,
1291
- );
1292
- }
1293
- await discardUnusedWorktree(continuationWorktree);
1294
- if (ownsResumeReservation(thread, reservation)) thread.state = previousState;
1295
- return pending;
1296
- }
1297
-
1298
- // The cloned branch replaces the removed-worktree session for this
1299
- // logical id. Keep an undeletable old dir in runtime cleanup if needed.
1300
- if (clonedSession && previousSessionDir && previousSessionDir !== clonedSession.sessionDir) {
1301
- try {
1302
- await rm(previousSessionDir, { recursive: true, force: true });
1303
- runtime.sessionDirs.delete(previousSessionDir);
1304
- } catch {
1305
- /* shutdown retries cleanup of the old retained branch */
1306
- }
1307
- }
1308
- return pending;
1309
- } catch (error) {
1310
- if (clonedSession) {
1311
- await cleanupTrackedSessionDir(
1312
- clonedSession.sessionDir,
1313
- `Could not discard interrupted resume session clone for run #${runId}`,
1314
- );
1315
- }
1316
- await discardUnusedWorktree(continuationWorktree);
1317
- if (ownsResumeReservation(thread, reservation)) {
1318
- thread.state = previousState;
1319
- thread.sessionId = previousSessionId;
1320
- thread.sessionDir = previousSessionDir;
1321
- thread.executionCwd = previousExecutionCwd;
1322
- }
1323
- return failedStartResult(
1324
- thread.agentName,
1325
- requestedObjective ?? thread.task,
1326
- `Could not resume run #${runId}: ${error instanceof Error ? error.message : String(error)}`,
1327
- );
1328
- } finally {
1329
- finishPreflight();
1330
- if (
1331
- thread.lifecycleOperation === "resume" &&
1332
- thread.lifecycleVersion === reservation.version
1333
- ) {
1334
- thread.lifecycleOperation = undefined;
1335
- }
1336
- }
1337
- };
1338
-
1339
- thread.fork = async (objective?: string, forkCtx?: ExtensionContext): Promise<SingleResult> => {
1340
- const forkObjective = objective?.trim();
1341
- if (!runtime.sessionActive || runtime.threads.get(runId) !== thread) {
1342
- return failedStartResult(thread.agentName, thread.task, `Run #${runId} belongs to a parent session that has shut down.`);
1343
- }
1344
- if (objective !== undefined && !forkObjective) {
1345
- return failedStartResult(thread.agentName, thread.task, "fork objective must be non-blank when provided.");
1346
- }
1347
- if (thread.retired || thread.state === "stopped") {
1348
- return failedStartResult(thread.agentName, thread.task, `Run #${runId} was retired by subagent_stop and cannot be forked.`);
1349
- }
1350
- if (thread.lifecycleOperation) {
1351
- return failedStartResult(thread.agentName, thread.task, `Run #${runId} is already ${thread.lifecycleOperation === "resume" ? "resuming" : "being forked"}.`);
1352
- }
1353
- if (thread.state === "queued" && !thread.sessionId) {
1354
- return failedStartResult(thread.agentName, thread.task, `Run #${runId} is queued and has no retained session to fork.`);
1355
- }
1356
- if (["queued", "running", "steering", "interrupting"].includes(thread.state)) {
1357
- return failedStartResult(thread.agentName, thread.task, `Run #${runId} is active; park it first with subagent_control { action: "park", id: ${runId} }, then fork the stable session.`);
1358
- }
1359
- if (!["parked", "completed", "failed"].includes(thread.state)) {
1360
- return failedStartResult(thread.agentName, thread.task, `Run #${runId} is ${thread.state} and has no forkable retained checkpoint.`);
1361
- }
1362
- if (!thread.sessionId || !thread.sessionDir) {
1363
- return failedStartResult(thread.agentName, thread.task, `Run #${runId} has no retained session to fork (it may have been parked before starting).`);
1364
- }
1365
- if (thread.isolation === "worktree") {
1366
- const worktreeState = thread.worktree?.state;
1367
- const seedIntegrated =
1368
- worktreeState === "integrated" ||
1369
- worktreeState === "no_changes" ||
1370
- thread.lastResult?.integrationApplied === true;
1371
- if (!seedIntegrated) {
1372
- return failedStartResult(
1373
- thread.agentName,
1374
- thread.task,
1375
- `Run #${runId}'s isolated checkpoint has not been integrated. Resume and settle it before forking so its seed is applied exactly once.`,
1376
- );
1377
- }
1378
- }
1379
-
1380
- // Same lifecycle CAS as resume: a concurrent resume/fork cannot consume
1381
- // or clone this session while the branch copy is in progress.
1382
- const forkVersion = ++thread.lifecycleVersion;
1383
- const forkGeneration = thread.generation;
1384
- const forkSessionId = thread.sessionId;
1385
- const forkSessionDir = thread.sessionDir;
1386
- const ownsFork = (): boolean =>
1387
- runtime.sessionActive &&
1388
- runtime.threads.get(runId) === thread &&
1389
- !thread.retired &&
1390
- thread.lifecycleOperation === "fork" &&
1391
- thread.lifecycleVersion === forkVersion &&
1392
- thread.generation === forkGeneration &&
1393
- thread.sessionId === forkSessionId &&
1394
- thread.sessionDir === forkSessionDir;
1395
- thread.lifecycleOperation = "fork";
1396
- const finishPreflight = beginPreflight();
1397
- let childWorktree: WorktreeIsolation | undefined;
1398
- let forkedSession: Awaited<ReturnType<typeof forkRetainedSession>> | undefined;
1399
- try {
1400
- await thread.generationCompletion;
1401
- if (!ownsFork()) {
1402
- return failedStartResult(thread.agentName, thread.task, `Run #${runId} changed while fork was preparing; no child was started.`);
1403
- }
1404
- const currentCtx = forkCtx ?? runCtx;
1405
- if (thread.isolation === "worktree") {
1406
- if (!thread.worktree) throw new Error(`Run #${runId} has no isolated worktree checkpoint.`);
1407
- const seedAlreadyIntegrated =
1408
- thread.worktree.state === "integrated" ||
1409
- thread.worktree.state === "no_changes" ||
1410
- thread.lastResult?.integrationApplied === true;
1411
- childWorktree = await createContinuationWorktree(thread.worktree, seedAlreadyIntegrated);
1412
- if (!ownsFork()) throw new Error(`Run #${runId} changed while its fork worktree was being created.`);
1413
- }
1414
- forkedSession = await forkRetainedSession({
1415
- cwd: thread.executionCwd,
1416
- targetCwd: childWorktree?.cwd ?? thread.cwd,
1417
- sessionDir: thread.sessionDir,
1418
- sessionId: thread.sessionId,
1419
- });
1420
- runtime.sessionDirs.add(forkedSession.sessionDir);
1421
- if (!ownsFork()) throw new Error(`Run #${runId} changed while its retained session was being forked.`);
1422
- const currentConfig = await loadConfig(runtime.configPath);
1423
- if (!ownsFork()) throw new Error(`Run #${runId} changed while fork configuration was loading.`);
1424
- runtime.backgroundQueue.setConcurrency(currentConfig.maxConcurrency);
1425
- const currentAgents = discoverAgents(currentCtx.cwd, {
1426
- scope: currentConfig.agentScope,
1427
- enabledNames: currentConfig.enabledAgents,
1428
- projectTrusted: currentCtx.isProjectTrusted?.() === true,
1429
- }).agents;
1430
- if (!ownsFork()) throw new Error(`Run #${runId} changed while fork was preparing; no child was started.`);
1431
- const childTask = forkObjective ?? thread.task;
1432
- const child = await startBackground(
1433
- thread.agentName,
1434
- childTask,
1435
- thread.cwd,
1436
- thread.vision,
1437
- thread.isolation,
1438
- undefined,
1439
- false,
1440
- {
1441
- ctx: currentCtx,
1442
- config: currentConfig,
1443
- agents: currentAgents,
1444
- sessionRef: currentModelRef(currentCtx),
1445
- },
1446
- {
1447
- sessionId: forkedSession.sessionId,
1448
- sessionDir: forkedSession.sessionDir,
1449
- prompt: forkObjective ?? FORK_CONTINUATION_PROMPT,
1450
- worktree: childWorktree,
1451
- forkedFromRunId: runId,
1452
- forkObjective,
1453
- modelPool: [...thread.modelPool],
1454
- thinkingLevel: thread.thinkingLevel,
1455
- },
1456
- );
1457
- if (child.exitCode !== -1 || child.runId === undefined) {
1458
- await cleanupTrackedSessionDir(
1459
- forkedSession.sessionDir,
1460
- `Could not discard failed fork session clone for run #${runId}`,
1461
- );
1462
- await discardUnusedWorktree(childWorktree);
1463
- return child;
1464
- }
1465
-
1466
- // Once the independent child is enqueued it remains valid even if the
1467
- // source is retired; just skip source-side relationship mutation.
1468
- if (!ownsFork()) return child;
1469
- const childRunId = child.runId;
1470
- if (!thread.forkChildRunIds.includes(childRunId)) thread.forkChildRunIds.push(childRunId);
1471
- const childThread = runtime.threads.get(childRunId);
1472
- if (childThread) childThread.forkedFromRunId = runId;
1473
- monitor.setForkRelation(runId, childRunId);
1474
- inspectorStore.get(runId).trajectory.append({
1475
- kind: "fork",
1476
- sourceRunId: runId,
1477
- childRunId,
1478
- objective: forkObjective,
1479
- });
1480
- const sourceResult = runtime.settledRuns.get(runId) ?? thread.lastResult;
1481
- if (sourceResult) sourceResult.forkChildRunIds = [...thread.forkChildRunIds];
1482
- return child;
1483
- } catch (error) {
1484
- if (forkedSession) {
1485
- await cleanupTrackedSessionDir(
1486
- forkedSession.sessionDir,
1487
- `Could not discard interrupted fork session clone for run #${runId}`,
1488
- );
1489
- }
1490
- await discardUnusedWorktree(childWorktree);
1491
- return failedStartResult(
1492
- thread.agentName,
1493
- forkObjective ?? thread.task,
1494
- `Could not fork retained session for run #${runId}: ${error instanceof Error ? error.message : String(error)}`,
1495
- );
1496
- } finally {
1497
- finishPreflight();
1498
- if (thread.lifecycleVersion === forkVersion && thread.lifecycleOperation === "fork") {
1499
- thread.lifecycleOperation = undefined;
1500
- }
1501
- }
1502
- };
1503
-
1504
- const onLive = makeLiveHandler(runId, runId, generation);
1505
- const onRecord = makeRecordHandler(runId, generation);
1506
- const queueController = runtime.backgroundQueue.enqueue(
1507
- async (backgroundSignal) => {
1508
- if (runtime.threads.get(runId)?.generation !== generation) return;
1509
- let result: SingleResult;
1510
- try {
1511
- result = await runSingleAgentWithModelFallback(
1512
- {
1513
- defaultCwd: executionCwd,
1514
- agent: pool.agent,
1515
- agentName,
1516
- task,
1517
- cwd: executionCwd,
1518
- thinkingLevel,
1519
- signal: backgroundSignal,
1520
- onLive,
1521
- onRecord,
1522
- control,
1523
- makeDetails: makeDetails("single", true),
1524
- idleTimeoutMs: runConfig.idleTimeoutSec * 1000,
1525
- ...(priorSessionId && priorSessionDir
1526
- ? {
1527
- sessionId: priorSessionId,
1528
- sessionDir: priorSessionDir,
1529
- stdinText: seed?.prompt ?? (newObjectiveOnResume
1530
- ? task
1531
- : buildResumePrompt(priorTask ?? task, buildFallbackResumeReason())),
1532
- }
1533
- : {}),
1534
- },
1535
- pool.fallbackModelRefs,
1536
- );
1537
- } catch (error) {
1538
- const errorMessage = error instanceof Error ? error.message : String(error);
1539
- result = {
1540
- ...pending,
1541
- task: control.getObjective(),
1542
- exitCode: 1,
1543
- stderr: errorMessage,
1544
- stopReason: backgroundSignal.aborted ? "aborted" : "error",
1545
- errorMessage,
1546
- dispatchFailed: true,
1547
- };
1548
- }
1549
-
1550
- // A stale process/generation may finish after a park/resume race. It owns
1551
- // no monitor mutation, result registration, or completion delivery.
1552
- if (runtime.threads.get(runId)?.generation !== generation) return;
1553
- result.runId = runId;
1554
- result.isolation = isolation;
1555
- result.originalCwd = originalCwd;
1556
- result.isolationCwd = executionCwd;
1557
- result.forkedFromRunId = thread.forkedFromRunId;
1558
- result.forkChildRunIds = [...thread.forkChildRunIds];
1559
- thread.queueController = undefined;
1560
- runtime.runControllers.delete(runId);
1561
- thread.task = result.task;
1562
- thread.sessionId = result.sessionId;
1563
- thread.sessionDir = result.sessionDir;
1564
- thread.lastResult = result;
1565
- runtime.retainSession(result);
1566
- monitor.setModel(runId, result.model, result.modelFallbackFrom);
1567
-
1568
- // Destructive stop owns publication once it has synchronously claimed
1569
- // the lifecycle. Leave the partial result/session on the thread; the
1570
- // stop path waits for this queue task, finalizes isolation, and emits
1571
- // exactly one aborted result.
1572
- if (thread.lifecycleOperation === "stop") return;
1573
-
1574
- if (result.parked) {
1575
- thread.state = "parked";
1576
- monitor.setStatus(runId, "parked");
1577
- const parkedRun = monitor.findRun(runId);
1578
- if (parkedRun) inspectState.retainFrom({ ...parkedRun, task: result.task, usage: result.usage });
1579
- runtime.settledRuns.delete(runId);
1580
- return;
1581
- }
1582
-
1583
- if (thread.retireOnSettle) runtime.retireThreadSession(thread);
1584
- const wantsFixLoop = shouldTriggerFixLoop(result, runConfig);
1585
- if (wantsFixLoop && isolation === "shared" && runtime.sessionActive) {
1586
- thread.state = "running";
1587
- finishRun(runId, "done", { silent: true, retain: true });
1588
- monitor.setAnnotation(runId, "auto-fix chain running");
1589
- startFixLoop(result, `fix-${runId}`, runId, thread.executionCwd, vision);
1590
- return;
1591
- }
1592
- // Claim terminal settlement synchronously before the first slow await.
1593
- // Park therefore either wins while RPC is still active, or is rejected
1594
- // once settlement owns the generation. Destructive stop may supersede
1595
- // this reservation; publication is revalidated after Git finalization.
1596
- const settlementVersion = ++thread.lifecycleVersion;
1597
- thread.lifecycleOperation = "settle";
1598
- const ownsSettlement = (): boolean =>
1599
- runtime.threads.get(runId) === thread &&
1600
- thread.generation === generation &&
1601
- thread.lifecycleVersion === settlementVersion &&
1602
- thread.lifecycleOperation === "settle" &&
1603
- !thread.retired;
1604
- try {
1605
- // Worktree isolation is rejected for reviewers, the only role that can
1606
- // trigger auto-fix. Keep that invariant explicit: an isolated result is
1607
- // finalized once here and can never start a chain that would integrate
1608
- // the same worktree early.
1609
- await thread.finalizeIsolation(generation, result);
1610
- if (!ownsSettlement()) return;
1611
-
1612
- const failed = isFailedResult(result);
1613
- thread.state = failed ? "failed" : "completed";
1614
- // Stamp the terminal monitor state before projecting it. This gives every
1615
- // path a fixed endedAt even when the row is removed immediately.
1616
- monitor.setStatus(runId, failed ? "failed" : "done");
1617
- inspectState.trajectory.append({
1618
- kind: "settled",
1619
- status: failed ? "failed" : "done",
1620
- model: result.model,
1621
- isolation,
1622
- ...(result.integrationStatus && result.integrationStatus !== "pending"
1623
- ? { integrationStatus: result.integrationStatus }
1624
- : {}),
1625
- });
1626
- const terminalRun = monitor.findRun(runId);
1627
- inspectState.retainFrom(terminalRun
1628
- ? { ...terminalRun, task: result.task, model: result.model ?? terminalRun.model, usage: result.usage }
1629
- : {
1630
- agent: pool.agent.name,
1631
- task: result.task,
1632
- model: result.model,
1633
- thinking: thinkingLevel,
1634
- status: failed ? "failed" : "done",
1635
- endedAt: inspectState.trajectory.summary().endedAt,
1636
- usage: result.usage,
1637
- });
1638
- if (!runtime.sessionActive || !ownsSettlement()) return;
1639
-
1640
- const modelLevel = failed && isModelLevelFailure(result);
1641
- const dispatchFailed = result.dispatchFailed === true;
1642
- finishRun(runId, failed ? "failed" : "done", modelLevel || dispatchFailed ? { silent: true } : undefined);
1643
- runtime.registerRunResult(runId, result);
1644
- const completion: CompletionMessageItem = {
1645
- agent: result.agent,
1646
- block: modelLevel
1647
- ? `${formatCompletionBlock(result, runConfig.maxResultLines, runCtx.cwd)}\n\n${modelLevelTakeoverNote(result, { runId })}`
1648
- : formatCompletionBlock(result, runConfig.maxResultLines, runCtx.cwd),
1649
- triggerTurn: completionTriggersTurn(result, runConfig.notifyOnReviewPass),
1650
- };
1651
- if (modelLevel) {
1652
- runCtx.ui.notify(`✗ ${result.agent} dispatch failed: model unavailable or broken — task handed to the main window`, "error");
1653
- } else if (dispatchFailed) {
1654
- runCtx.ui.notify(`✗ ${result.agent} dispatch failed: ${result.errorMessage ?? "dispatch crashed"}`, "error");
1655
- }
1656
- if (failed) {
1657
- runtime.sendCompletionGroup([completion]);
1658
- runtime.completionBatcher.flush();
1659
- } else {
1660
- runtime.completionBatcher.push(completion);
1661
- }
1662
- } finally {
1663
- if (ownsSettlement()) thread.lifecycleOperation = undefined;
1664
- }
1665
- },
1666
- () => {
1667
- if (runtime.threads.get(runId)?.generation !== generation) return;
1668
- // Queued park/stop owns publication and may still be finalizing an
1669
- // isolated worktree. Do not expose a terminal monitor/trajectory state
1670
- // before that owner records the checkpoint or aborted result.
1671
- if (thread.lifecycleOperation === "park" || thread.lifecycleOperation === "stop") return;
1672
- runtime.runControllers.delete(runId);
1673
- thread.queueController = undefined;
1674
- if (thread.state === "parked") {
1675
- monitor.setStatus(runId, "parked");
1676
- return;
1677
- }
1678
- thread.state = "stopped";
1679
- monitor.setStatus(runId, "failed");
1680
- inspectState.trajectory.append({ kind: "settled", status: "stopped", model: monitor.findRun(runId)?.model, isolation });
1681
- const stoppedRun = monitor.findRun(runId);
1682
- if (stoppedRun) inspectState.retainFrom(stoppedRun);
1683
- if (!runtime.sessionActive) {
1684
- monitor.removeRun(runId);
1685
- return;
1686
- }
1687
- finishRun(runId, "failed");
1688
- },
1689
- async (error) => {
1690
- if (runtime.threads.get(runId)?.generation !== generation) return;
1691
- // Queue-level crashes use the same settlement reservation as ordinary
1692
- // results. A concurrent destructive stop may supersede it while slow
1693
- // worktree finalization is running, in which case stop publishes once.
1694
- if (thread.lifecycleOperation === "stop") return;
1695
- const settlementVersion = ++thread.lifecycleVersion;
1696
- thread.lifecycleOperation = "settle";
1697
- const ownsSettlement = (): boolean =>
1698
- runtime.threads.get(runId) === thread &&
1699
- thread.generation === generation &&
1700
- thread.lifecycleVersion === settlementVersion &&
1701
- thread.lifecycleOperation === "settle" &&
1702
- !thread.retired;
1703
- try {
1704
- const crashed: SingleResult = {
1705
- ...dispatchFailedResult(pool.agent, control.getObjective(), error, thinkingLevel),
1706
- runId,
1707
- isolation,
1708
- originalCwd,
1709
- isolationCwd: executionCwd,
1710
- forkedFromRunId: thread.forkedFromRunId,
1711
- };
1712
- await thread.finalizeIsolation(generation, crashed);
1713
- if (!ownsSettlement()) return;
1714
- thread.state = "failed";
1715
- monitor.setStatus(runId, "failed");
1716
- inspectState.trajectory.append({
1717
- kind: "settled",
1718
- status: "failed",
1719
- model: crashed.model,
1720
- isolation,
1721
- ...(crashed.integrationStatus && crashed.integrationStatus !== "pending"
1722
- ? { integrationStatus: crashed.integrationStatus }
1723
- : {}),
1724
- });
1725
- const crashedRun = monitor.findRun(runId);
1726
- if (crashedRun) inspectState.retainFrom({ ...crashedRun, usage: crashed.usage });
1727
- finishRun(runId, "failed", { silent: true });
1728
- runtime.registerRunResult(runId, crashed);
1729
- runtime.runControllers.delete(runId);
1730
- thread.queueController = undefined;
1731
- if (!runtime.sessionActive || !ownsSettlement()) return;
1732
- try {
1733
- runCtx.ui.notify(`✗ ${agent.name} dispatch failed: ${crashed.errorMessage}`, "error");
1734
- runtime.sendCompletionGroup([
1735
- {
1736
- agent: agent.name,
1737
- block: formatCompletionBlock(crashed, runConfig.maxResultLines, runCtx.cwd),
1738
- triggerTurn: true,
1739
- },
1740
- ]);
1741
- runtime.completionBatcher.flush();
1742
- } catch {
1743
- /* a second delivery failure must not throw through the queue */
1744
- }
1745
- } finally {
1746
- if (ownsSettlement()) thread.lifecycleOperation = undefined;
1747
- }
1748
- },
1749
- );
1750
- thread.queueController = queueController;
1751
- thread.generationCompletion = runtime.backgroundQueue.waitForTask(queueController);
1752
- runtime.runControllers.set(runId, queueController);
1753
- return pending;
1754
- };
1755
-
1756
- // Sub-agents intentionally detach from the foreground turn. This makes the
1757
- // editor available immediately; completion messages later wake the main agent.
1758
- if (params.tasks && params.tasks.length > 0) {
1759
- if (params.tasks.length > config.maxConcurrency) {
1760
- return {
1761
- content: [
1762
- {
1763
- type: "text",
1764
- text: `Too many parallel tasks (${params.tasks.length}). Max is ${config.maxConcurrency} (configurable via /subagents-setup).`,
1765
- },
1766
- ],
1767
- details: makeDetails("parallel", true)([]),
1768
- };
1769
- }
1770
-
1771
- const results: SingleResult[] = [];
1772
- // Preserve caller order (and deterministic completion batching) while
1773
- // preparing each isolated filesystem before its queue entry can start.
1774
- for (const item of params.tasks) {
1775
- results.push(await startBackground(
1776
- item.agent,
1777
- item.task,
1778
- item.cwd,
1779
- item.vision === true,
1780
- defaultIsolationMode("parallel", item.agent, item.isolation as IsolationMode | undefined),
1781
- ));
1782
- }
1783
- const started = results.filter((result) => result.exitCode === -1).length;
1784
- const failureLines = results.flatMap((result, index) => {
1785
- if (result.exitCode === -1) return [];
1786
- const reason = getResultOutput(result).trim() || "unknown startup failure";
1787
- return [
1788
- `- tasks[${index}] (${params.tasks![index]!.agent}) failed to start: ${reason.replace(/\n/g, "\n ")}`,
1789
- ];
1790
- });
1791
- if (started === 0) {
1792
- // Pi marks custom-tool failures only when execute throws; returning an
1793
- // `isError` property is still a successful AgentToolResult.
1794
- throw new Error(`No background subagents were started.\n${failureLines.join("\n")}`);
1795
- }
1796
- const text = [
1797
- `Started ${started} background subagent${started === 1 ? "" : "s"}. Results will automatically resume the main agent when ready.`,
1798
- ...(failureLines.length > 0
1799
- ? [`${failureLines.length} task${failureLines.length === 1 ? "" : "s"} failed before launch:`, ...failureLines]
1800
- : []),
1801
- ].join("\n");
1802
- return {
1803
- content: [{ type: "text", text }],
1804
- details: makeDetails("parallel", true)(results),
1805
- terminate: true,
1806
- };
1807
- }
1808
-
1809
- const result = await startBackground(
1810
- params.agent as string,
1811
- params.task as string,
1812
- params.cwd,
1813
- params.vision === true,
1814
- defaultIsolationMode("single", params.agent as string, params.isolation as IsolationMode | undefined),
1815
- );
1816
- if (result.exitCode !== -1) {
1817
- throw new Error(getResultOutput(result));
1818
- }
1819
- return {
1820
- content: [{ type: "text", text: `Started ${result.agent} in the background. Its result will automatically resume the main agent when ready.` }],
1821
- details: makeDetails("single", true)([result]),
1822
- terminate: true,
1823
- };
1824
-
1825
- },
1826
-
1827
- renderCall(args, theme) {
1828
- if (args.tasks && args.tasks.length > 0) {
1829
- let text = `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", `parallel (${args.tasks.length})`)}`;
1830
- for (const t of args.tasks.slice(0, 4)) {
1831
- const preview = formatTaskSummary(t.task, 48);
1832
- const isolation = defaultIsolationMode("parallel", t.agent, t.isolation) === "worktree" ? " [worktree]" : "";
1833
- text += `\n ${theme.fg("accent", t.agent)}${theme.fg("dim", isolation)} ${theme.fg("dim", preview)}`;
1834
- }
1835
- if (args.tasks.length > 4) text += `\n ${theme.fg("dim", `… +${args.tasks.length - 4} more`)}`;
1836
- return new Text(text, 0, 0);
1837
- }
1838
- const task: string = args.task ?? "";
1839
- const preview = formatTaskSummary(task, 60);
1840
- const isolation = args.isolation === "worktree" ? " [worktree]" : "";
1841
- return new Text(
1842
- `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", args.agent ?? "?")}${theme.fg("dim", isolation)} ${theme.fg("dim", preview)}`,
1843
- 0,
1844
- 0,
1845
- );
1846
- },
1847
-
1848
- renderResult(result, _options, theme) {
1849
- const details = result.details as SubagentDetails | undefined;
1850
- if (!details || details.results.length === 0) return new Text(theme.fg("dim", "(no output)"), 0, 0);
1851
-
1852
- if (details.mode === "single") {
1853
- const r = details.results[0];
1854
- const pending = r.exitCode === -1;
1855
- const icon = statusIcon(pending ? "running" : isFailedResult(r) ? "failed" : "done", theme);
1856
- const usage = formatUsage(r.usage);
1857
- const model = `${r.model ?? "?"}${r.modelFallbackFrom ? ` (pool fallback from ${r.modelFallbackFrom})` : ""}`;
1858
- const isolation = r.isolation === "worktree" ? ` · worktree ${r.integrationStatus ?? "active"}` : "";
1859
- const line = `${theme.fg("toolTitle", theme.bold("subagent "))}${icon} ${theme.fg("accent", r.agent)} ${theme.fg("dim", `· ${model}${r.thinking ? ` · thinking ${r.thinking}` : ""}${isolation}${pending ? " · background" : ""}${usage ? ` · ${usage}` : ""}`)}`;
1860
- return new Text(line, 0, 0);
1861
- }
1862
-
1863
- // Parallel mode: header + one compact line per agent
1864
- const lines: string[] = [
1865
- `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", `parallel (${details.results.length})`)}`,
1866
- ];
1867
- for (const r of details.results) {
1868
- const pending = r.exitCode === -1;
1869
- const icon = statusIcon(pending ? "running" : isFailedResult(r) ? "failed" : "done", theme);
1870
- const usage = formatUsage(r.usage);
1871
- const model = `${r.model ?? "?"}${r.modelFallbackFrom ? ` (pool fallback from ${r.modelFallbackFrom})` : ""}`;
1872
- const isolation = r.isolation === "worktree" ? ` · worktree ${r.integrationStatus ?? "active"}` : "";
1873
- lines.push(` ${icon} ${theme.fg("accent", r.agent)} ${theme.fg("dim", `· ${model}${r.thinking ? ` · thinking ${r.thinking}` : ""}${isolation}${pending ? " · background" : ""}${usage ? ` · ${usage}` : ""}`)}`);
1874
- }
1875
- return new Text(lines.join("\n"), 0, 0);
1876
- },
1877
- });
1878
- }
1
+ /**
2
+ * The `subagent` tool: dispatches explore/worker/reviewer agents as isolated pi
3
+ * child processes, single or parallel. Owns the dispatch pipeline: config load,
4
+ * per-agent model-pool resolution, per-run status tracking, the auto-fix chain
5
+ * (REVIEW_FAIL → worker → re-review), and completion delivery.
6
+ *
7
+ * Vision: a task flagged `vision: true` uses the configured vision model as an
8
+ * explicit primary, then the agent's configured backup and the current
9
+ * main-window model. Stale refs remain in the pool and fail normally at runtime.
10
+ */
11
+
12
+ import { StringEnum } from "@earendil-works/pi-ai";
13
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
14
+ import { Text } from "@earendil-works/pi-tui";
15
+ import { existsSync } from "node:fs";
16
+ import { realpath, rm } from "node:fs/promises";
17
+ import { resolve } from "node:path";
18
+ import { Type } from "typebox";
19
+ import { discoverAgents, type AgentConfig } from "./agents.ts";
20
+ import {
21
+ completionTriggersTurn,
22
+ type CompletionMessageItem,
23
+ } from "./completion.ts";
24
+ import { loadConfig, type SubagentsConfig } from "./config.ts";
25
+ import {
26
+ dispatchFailedResult,
27
+ failedStartResult,
28
+ formatCompletionBlock,
29
+ formatUsage,
30
+ modelLevelTakeoverNote,
31
+ queuedResult,
32
+ } from "./format.ts";
33
+ import {
34
+ buildFixTaskBrief,
35
+ buildReReviewBrief,
36
+ formatChainSummary,
37
+ shouldTriggerFixLoop,
38
+ summarizeChainResult,
39
+ type ChainStep,
40
+ } from "./fixloop.ts";
41
+ import { currentModelRef, resolveAgentModelPool } from "./models.ts";
42
+ import {
43
+ formatTaskSummary,
44
+ formatToolActivity,
45
+ monitor,
46
+ statusIcon,
47
+ type RunChainMeta,
48
+ } from "./monitor.ts";
49
+ import type { SubagentRuntime, SubagentThread, ThreadState } from "./runtime.ts";
50
+ import { persistRecoveryRecords, recoveryRecordFromFinalization } from "./recovery.ts";
51
+ import { forkRetainedSession } from "./session-fork.ts";
52
+ import {
53
+ buildFallbackResumeReason,
54
+ buildResumePrompt,
55
+ RpcRunControl,
56
+ getResultOutput,
57
+ isFailedResult,
58
+ isModelLevelFailure,
59
+ reviewVerdict,
60
+ runSingleAgentWithModelFallback,
61
+ type SingleResult,
62
+ type SubagentDetails,
63
+ type SubagentLiveEvent,
64
+ } from "./spawn.ts";
65
+ import { trajectoryStore, summarizeToolArgs } from "./trajectory.ts";
66
+ import {
67
+ createWorktreeIsolation,
68
+ resolveWorktreeTarget,
69
+ type IsolationMode,
70
+ type WorktreeFinalization,
71
+ type WorktreeIsolation,
72
+ } from "./worktree.ts";
73
+
74
+ const NON_BLANK_TASK_OPTIONS = { minLength: 1, pattern: "\\S" } as const;
75
+ export const FORK_CONTINUATION_PROMPT =
76
+ "Continue from the retained context above. Review the prior work, then take the most useful next step toward completing the existing objective without repeating completed work.";
77
+ export const WORKTREE_ISOLATION_INSTRUCTIONS =
78
+ "You are running in a temporary detached Git worktree. Work only in the current cwd; do not create another worktree or manually copy/apply changes to the original checkout. The parent dispatcher will integrate your tracked, deleted, and untracked changes when this thread finally settles.";
79
+
80
+ export function buildWorktreeTaskPrompt(task: string): string {
81
+ return `${WORKTREE_ISOLATION_INSTRUCTIONS}\n\nTask: ${task}`;
82
+ }
83
+
84
+ function withWorktreeSystemPrompt(agent: AgentConfig): AgentConfig {
85
+ return {
86
+ ...agent,
87
+ systemPrompt: `${agent.systemPrompt.trimEnd()}\n\n${WORKTREE_ISOLATION_INSTRUCTIONS}`.trim(),
88
+ };
89
+ }
90
+
91
+ interface DispatchEnvironment {
92
+ ctx: ExtensionContext;
93
+ config: SubagentsConfig;
94
+ agents: AgentConfig[];
95
+ sessionRef?: string;
96
+ }
97
+
98
+ const VISION_DESCRIPTION =
99
+ "Set true when the task may require viewing images (screenshots, mockups, designs) — the configured vision model becomes primary, followed by the agent backup and current main-window model";
100
+
101
+ const ISOLATION_DESCRIPTION =
102
+ "Filesystem isolation: shared uses the caller's working tree; worktree creates a detached temporary Git worktree (write-capable agents only)";
103
+
104
+ const IsolationSchema = Type.Optional(
105
+ StringEnum(["shared", "worktree"] as const, { description: ISOLATION_DESCRIPTION }),
106
+ );
107
+
108
+ const TaskItem = Type.Object({
109
+ agent: Type.String({ description: "Name of the agent to invoke" }),
110
+ task: Type.String({
111
+ ...NON_BLANK_TASK_OPTIONS,
112
+ description: "Self-contained task to delegate (the agent has no memory of this conversation)",
113
+ }),
114
+ cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
115
+ vision: Type.Optional(Type.Boolean({ description: VISION_DESCRIPTION })),
116
+ isolation: IsolationSchema,
117
+ });
118
+
119
+ const SubagentParams = Type.Object({
120
+ agent: Type.Optional(Type.String({ description: "Name of the agent to invoke (single mode)" })),
121
+ task: Type.Optional(
122
+ Type.String({ ...NON_BLANK_TASK_OPTIONS, description: "Self-contained task to delegate (single mode)" }),
123
+ ),
124
+ tasks: Type.Optional(Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" })),
125
+ cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })),
126
+ vision: Type.Optional(Type.Boolean({ description: VISION_DESCRIPTION })),
127
+ isolation: IsolationSchema,
128
+ });
129
+
130
+ export function defaultIsolationMode(mode: "single" | "parallel", agentName: string, requested?: IsolationMode): IsolationMode {
131
+ if (requested) return requested;
132
+ return mode === "parallel" && agentName === "worker" ? "worktree" : "shared";
133
+ }
134
+
135
+ export function isWorktreeCapableAgent(agent: AgentConfig): boolean {
136
+ if (agent.name === "explore" || agent.name === "reviewer") return false;
137
+ if (agent.name === "worker") return true;
138
+ if (!agent.tools) return true;
139
+ return agent.tools.includes("edit") || agent.tools.includes("write");
140
+ }
141
+
142
+ const autoFixRootTails = new Map<string, Promise<void>>();
143
+
144
+ async function canonicalAutoFixRoot(cwd: string): Promise<string> {
145
+ try {
146
+ return (await resolveWorktreeTarget(cwd)).originalRoot;
147
+ } catch {
148
+ try {
149
+ return await realpath(resolve(cwd));
150
+ } catch {
151
+ return resolve(cwd);
152
+ }
153
+ }
154
+ }
155
+
156
+ /** Keep the complete worker→review loop exclusive for one canonical repository.
157
+ * Child processes have independent file-mutation queues, so queue concurrency
158
+ * alone cannot make shared-checkout edits safe. */
159
+ function serializeAutoFixChain(
160
+ cwd: string,
161
+ task: (signal: AbortSignal) => Promise<void>,
162
+ ): (signal: AbortSignal) => Promise<void> {
163
+ return async (signal) => {
164
+ if (signal.aborted) return;
165
+ const root = await canonicalAutoFixRoot(cwd);
166
+ const key = process.platform === "win32" ? root.toLowerCase() : root;
167
+ const previous = autoFixRootTails.get(key) ?? Promise.resolve();
168
+ let release!: () => void;
169
+ const gate = new Promise<void>((resolveGate) => {
170
+ release = resolveGate;
171
+ });
172
+ const tail = previous.catch(() => undefined).then(() => gate);
173
+ autoFixRootTails.set(key, tail);
174
+ await previous.catch(() => undefined);
175
+ try {
176
+ if (!signal.aborted) await task(signal);
177
+ } finally {
178
+ release();
179
+ if (autoFixRootTails.get(key) === tail) autoFixRootTails.delete(key);
180
+ }
181
+ };
182
+ }
183
+
184
+ function resolveDispatchModelPool(
185
+ agent: AgentConfig,
186
+ config: SubagentsConfig,
187
+ mainRef: string | undefined,
188
+ vision: boolean,
189
+ ): { agent: AgentConfig; fallbackModelRefs: string[] } {
190
+ const pool = resolveAgentModelPool({
191
+ primaryRef: vision ? config.visionModel : config.agentModels[agent.name],
192
+ backupRef: config.agentBackupModels[agent.name],
193
+ mainRef,
194
+ declaredDefaultRef: agent.model,
195
+ });
196
+ return {
197
+ agent: { ...agent, model: pool.primaryRef },
198
+ fallbackModelRefs: pool.fallbackModelRefs,
199
+ };
200
+ }
201
+
202
+ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime): void {
203
+ pi.registerTool({
204
+ name: "subagent",
205
+ label: "Subagent",
206
+ description: [
207
+ "Delegate a discrete, self-contained task to a specialized sub-agent running in an ISOLATED context window.",
208
+ "Agents: explore (read-only codebase recon), worker (implement/fix/refactor/test, full tools), reviewer (adversarial pre-commit review, read-only).",
209
+ "Modes: single ({agent, task}) or parallel ({tasks: [{agent, task}, ...]}).",
210
+ "Isolation: single tasks default to shared; parallel worker tasks default to detached Git worktrees unless isolation: shared is explicit. explore/reviewer cannot use worktree isolation.",
211
+ "Use subagent_control to steer, retarget, park, resume, or fork a thread by its stable run id.",
212
+ "It starts agents in the background and immediately returns control to the main window; completion messages automatically wake the main agent to continue.",
213
+ "Each agent has no memory of this conversation brief it fully (goal, exact paths, constraints, expected output).",
214
+ "Results arrive as wake-up messages automatically you do NOT need to wait. If you must get a result in-turn, subagent_wait is a non-blocking lookup by default (pass timeoutMs to block).",
215
+ "Vision: set vision: true when the task may require viewing images (screenshots, mockups, design files — e.g. frontend work) the configured vision model is primary, followed by that agent's backup and the current main-window model.",
216
+ ].join(" "),
217
+ promptSnippet:
218
+ "Start background subagents: explore (read-only search), worker (implement), reviewer (adversarial review); completion automatically resumes the main agent. Simple tasks: use direct tools, not subagents.",
219
+ promptGuidelines: [
220
+ "Delegate only when an isolated context genuinely pays: broad exploration, a self-contained implementation, or a review gate. Handle simple lookups and one-line edits inline with direct tools — never spawn a sub-agent for them.",
221
+ "Use subagent with agent 'explore' for broad or open-ended code search before large changes; a targeted 'where is X' is a direct grep/read.",
222
+ "Use subagent with agent 'worker' for a self-contained implementation task worth a separate context; it plans internally.",
223
+ "Use subagent with agent 'reviewer' for a fresh read-only review before reporting work done or committing.",
224
+ "subagent launches work in the background and ends the current turn; when a result arrives, the main agent is automatically resumed with it.",
225
+ "Run independent tasks in parallel by passing a tasks array to subagent; parallel worker items default to isolation: worktree so their edits are integrated independently. Pass isolation: shared only when workers intentionally need the caller's live uncommitted tree.",
226
+ "Use isolation: worktree only for worker/write-capable agents and only inside a Git repository with a committed HEAD; setup or integration failures never silently fall back to shared.",
227
+ "NEVER sleep or poll, and do NOT call subagent_wait to hold the turn — subagent ends the turn immediately and the result arrives as a message that wakes you automatically (even mid-turn). Ending your turn is the default and the only correct way to wait.",
228
+ "If you must keep the turn for a result, call subagent_wait with an explicit timeoutMs (non-blocking by default) never bash sleep/timeout to wait for a sub-agent.",
229
+ "When a delegated task may require viewing images (frontend screenshots, mockups, design comparisons), pass vision: true and give the sub-agent the exact image paths it reads them with its read tool. The configured vision model becomes primary; model-level failures continue through the agent's backup pool and current main-window model.",
230
+ "When a sub-agent result arrives it is already shown to the user do NOT restate, paraphrase, or summarize it; reply with only your own conclusion or next action (often just one line), since duplicating the result wastes tokens for nothing.",
231
+ ],
232
+ parameters: SubagentParams,
233
+
234
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
235
+ monitor.beginTurn();
236
+ const config = await loadConfig(runtime.configPath);
237
+ // Pick up concurrency changes from /subagents-setup without a restart.
238
+ runtime.backgroundQueue.setConcurrency(config.maxConcurrency);
239
+
240
+ // Finished runs leave the active monitor immediately. Their final findings
241
+ // are sent as a custom message that starts a follow-up turn.
242
+ const finishRun = (
243
+ runId: number,
244
+ status: "done" | "failed",
245
+ opts?: { silent?: boolean; retain?: boolean },
246
+ ): void => {
247
+ monitor.setStatus(runId, status); // stamps endedAt for the elapsed time
248
+ const run = opts?.retain ? monitor.findRun(runId) : monitor.removeRun(runId);
249
+ if (!run) return; // already finished stay idempotent
250
+ if (opts?.retain) monitor.setRetained(runId, true);
251
+ if (opts?.silent || !runtime.sessionActive) return;
252
+ const icon = status === "done" ? "✓" : "✗";
253
+ ctx.ui.notify(`${icon} ${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. In parallel, every
258
+ // live event is appended to the thread's append-only trajectory (status,
259
+ // model-candidate changes, usage, tool starts/ends with a redacted
260
+ // args summary). The live handler only updates monitor state; finishing
261
+ // (removeRun + notify) is
262
+ // owned by the queue task / launchInLoop. That keeps a startup retry —
263
+ // which fires a transient "failed" status before relaunching from
264
+ // ripping the row out early, and lets the queue task decide between
265
+ // delivering a reviewer's result and starting an auto-fix chain.
266
+ const makeLiveHandler =
267
+ (runId: number, threadId?: number, generation?: number) =>
268
+ (e: SubagentLiveEvent): void => {
269
+ if (generation !== undefined && runtime.threads.get(runId)?.generation !== generation) return;
270
+ switch (e.kind) {
271
+ case "status":
272
+ // Only update monitor status here. Finishing (removeRun + notify) is
273
+ // owned by the queue task / launchInLoop so that a startup retry — which
274
+ // fires a transient "failed" status before relaunching the childnever
275
+ // rips the row out from under the retry or emits a premature "✗" toast.
276
+ monitor.setStatus(runId, e.status);
277
+ break;
278
+ case "model":
279
+ monitor.setModel(runId, e.model, e.fallbackFrom);
280
+ break;
281
+ case "usage":
282
+ monitor.setUsage(runId, e.usage, e.model);
283
+ break;
284
+ case "tool_start":
285
+ monitor.recordToolStart(runId, e.toolName, formatToolActivity(e.toolName, e.args));
286
+ break;
287
+ case "tool_end":
288
+ monitor.recordToolEnd(runId, e.toolName, e.isError);
289
+ break;
290
+ case "thinking":
291
+ monitor.setActivity(runId, "thinking");
292
+ break;
293
+ case "text":
294
+ // A text delta is model output, not a filesystem write.
295
+ monitor.setActivity(runId, "responding");
296
+ break;
297
+ }
298
+ if (threadId !== undefined) {
299
+ const trajectory = trajectoryStore.get(threadId).trajectory;
300
+ switch (e.kind) {
301
+ case "status":
302
+ trajectory.append({ kind: "status", status: e.status });
303
+ break;
304
+ case "model":
305
+ trajectory.append({ kind: "candidate", model: e.model, fallbackFrom: e.fallbackFrom });
306
+ break;
307
+ case "usage":
308
+ trajectory.append({ kind: "usage", usage: { ...e.usage }, model: e.model });
309
+ break;
310
+ case "tool_start":
311
+ trajectory.append({
312
+ kind: "tool_start",
313
+ tool: e.toolName,
314
+ toolCallId: e.toolCallId,
315
+ summary: summarizeToolArgs(e.args),
316
+ });
317
+ break;
318
+ case "tool_end":
319
+ trajectory.append({ kind: "tool_end", tool: e.toolName, toolCallId: e.toolCallId, isError: e.isError });
320
+ break;
321
+ }
322
+ }
323
+ };
324
+
325
+ const discovery = discoverAgents(ctx.cwd, {
326
+ scope: config.agentScope,
327
+ enabledNames: config.enabledAgents,
328
+ projectTrusted: ctx.isProjectTrusted?.() === true,
329
+ });
330
+ const sessionRef = currentModelRef(ctx);
331
+ const agents = discovery.agents;
332
+
333
+ const hasTasks = (params.tasks?.length ?? 0) > 0;
334
+ const hasSingle = Boolean(params.agent) && params.task !== undefined;
335
+
336
+ const makeDetails =
337
+ (mode: "single" | "parallel", background = false) =>
338
+ (results: SingleResult[]): SubagentDetails => ({ mode, results, background });
339
+
340
+ const catalog = agents.map((a) => a.name).join(", ") || "none";
341
+
342
+ if (Number(hasTasks) + Number(hasSingle) !== 1) {
343
+ return {
344
+ content: [
345
+ {
346
+ type: "text",
347
+ text: `Invalid parameters. Provide exactly one mode: single {agent, task} or parallel {tasks: [...]}. Enabled agents: ${catalog}.`,
348
+ },
349
+ ],
350
+ details: makeDetails("single")([]),
351
+ };
352
+ }
353
+
354
+ if (hasTasks) {
355
+ const blankTaskIndex = params.tasks?.findIndex(({ task }) => task.trim().length === 0) ?? -1;
356
+ if (blankTaskIndex !== -1) {
357
+ return {
358
+ content: [
359
+ {
360
+ type: "text",
361
+ text: `Invalid parameters. tasks[${blankTaskIndex}].task must contain at least one non-whitespace character. No background tasks were started. Enabled agents: ${catalog}.`,
362
+ },
363
+ ],
364
+ details: makeDetails("parallel")([]),
365
+ };
366
+ }
367
+ } else if (params.task?.trim().length === 0) {
368
+ return {
369
+ content: [
370
+ {
371
+ type: "text",
372
+ text: `Invalid parameters. task must contain at least one non-whitespace character. Enabled agents: ${catalog}.`,
373
+ },
374
+ ],
375
+ details: makeDetails("single")([]),
376
+ };
377
+ }
378
+
379
+ /**
380
+ * Dispatch one agent inside an auto-fix chain: tracked in monitor state with a
381
+ * groupId/relationLabel, but NOT delivered through the completion flow — the
382
+ * chain owner assembles and delivers the whole group at the end.
383
+ */
384
+ const launchInLoop = async (
385
+ agentName: string,
386
+ task: string,
387
+ executionCwd: string,
388
+ signal: AbortSignal,
389
+ meta: RunChainMeta,
390
+ vision = false,
391
+ ): Promise<{ runId?: number; result: SingleResult }> => {
392
+ const agent = agents.find((candidate) => candidate.name === agentName);
393
+ if (!agent) return { result: failedStartResult(agentName, task, `Unknown agent: "${agentName}".`) };
394
+ // Vision chains keep the vision override as each round's primary while
395
+ // retaining that worker/reviewer's own configured backup pool.
396
+ const pool = resolveDispatchModelPool(agent, config, sessionRef, vision);
397
+ const thinkingLevel = config.agentThinkingLevels[agent.name] ?? agent.thinking ?? config.thinkingLevel;
398
+ const runId = monitor.addRun(agent.name, task, pool.agent.model, thinkingLevel, meta);
399
+ // Chain rounds keep their own lifecycle trajectory.
400
+ const chainState = trajectoryStore.get(runId);
401
+ chainState.trajectory.append({
402
+ kind: "dispatch",
403
+ agent: agent.name,
404
+ task,
405
+ model: pool.agent.model,
406
+ thinking: thinkingLevel,
407
+ pool: pool.fallbackModelRefs,
408
+ vision,
409
+ isolation: "shared",
410
+ originalCwd: executionCwd,
411
+ isolationCwd: executionCwd,
412
+ });
413
+ const onLive = makeLiveHandler(runId, runId);
414
+ try {
415
+ const result = await runSingleAgentWithModelFallback(
416
+ {
417
+ defaultCwd: executionCwd,
418
+ cwd: executionCwd,
419
+ agent: pool.agent,
420
+ agentName,
421
+ task,
422
+ thinkingLevel,
423
+ signal,
424
+ onLive,
425
+ makeDetails: makeDetails("single", true),
426
+ idleTimeoutMs: config.idleTimeoutSec * 1000,
427
+ },
428
+ pool.fallbackModelRefs,
429
+ );
430
+ result.runId = runId;
431
+ result.isolation = "shared";
432
+ result.originalCwd = executionCwd;
433
+ result.isolationCwd = executionCwd;
434
+ runtime.retainSession(result);
435
+ monitor.setModel(runId, result.model, result.modelFallbackFrom);
436
+ chainState.trajectory.append({
437
+ kind: "settled",
438
+ status: isFailedResult(result) ? "failed" : "done",
439
+ model: result.model,
440
+ });
441
+ // Keep the finished round in status state while the chain is
442
+ // still running, with a one-line summary of what it did; the whole
443
+ // group is dropped when the chain resolves (see removeChainGroup).
444
+ monitor.setSummary(runId, summarizeChainResult(result));
445
+ finishRun(runId, isFailedResult(result) ? "failed" : "done", { retain: true });
446
+ runtime.registerRunResult(runId, result);
447
+ return { runId, result };
448
+ } catch (error) {
449
+ finishRun(runId, "failed", { retain: true });
450
+ chainState.trajectory.append({ kind: "settled", status: "failed", model: pool.agent.model });
451
+ const errorMessage = error instanceof Error ? error.message : String(error);
452
+ const crashed: SingleResult = {
453
+ ...queuedResult(pool.agent, task, thinkingLevel),
454
+ runId,
455
+ isolation: "shared",
456
+ originalCwd: executionCwd,
457
+ isolationCwd: executionCwd,
458
+ exitCode: 1,
459
+ stderr: errorMessage,
460
+ stopReason: signal.aborted ? "aborted" : "error",
461
+ errorMessage,
462
+ dispatchFailed: true,
463
+ };
464
+ runtime.registerRunResult(runId, crashed);
465
+ return { runId, result: crashed };
466
+ }
467
+ };
468
+
469
+ /**
470
+ * Run the auto-fix chain in the background: worker (briefed with the review's
471
+ * findings) reviewer re-review, up to maxFixRounds times. The main agent is
472
+ * not woken mid-loop; the full chain is delivered as one group at the end.
473
+ * Failures short-circuit: a crashed worker skips its re-review and delivers.
474
+ * The triggering reviewer stays in monitor state until the chain resolves.
475
+ */
476
+ /** Drop every monitor row belonging to an auto-fix chain; the retained
477
+ * parent is removed separately (it does not carry the groupId). */
478
+ const removeChainGroup = (groupId: string): void => {
479
+ for (const run of [...monitor.getRuns()]) {
480
+ if (run.groupId === groupId) monitor.removeRun(run.id);
481
+ }
482
+ };
483
+
484
+ const startFixLoop = (
485
+ initialReviewerResult: SingleResult,
486
+ parentGroupId: string,
487
+ parentRunId: number,
488
+ executionCwd: string,
489
+ vision = false,
490
+ ): void => {
491
+ const parentThreadAtStart = runtime.threads.get(parentRunId);
492
+ if (!parentThreadAtStart) return;
493
+ const parentGeneration = parentThreadAtStart.generation;
494
+ const parentControl = parentThreadAtStart.control;
495
+ let fixController: AbortController | undefined;
496
+ const ownsParent = (): boolean => {
497
+ const current = runtime.threads.get(parentRunId);
498
+ return fixController !== undefined &&
499
+ current === parentThreadAtStart &&
500
+ current.generation === parentGeneration &&
501
+ current.control === parentControl &&
502
+ current.queueController === fixController &&
503
+ runtime.runControllers.get(parentRunId) === fixController;
504
+ };
505
+ const clearOwnedController = (): void => {
506
+ if (!fixController) return;
507
+ if (runtime.runControllers.get(parentRunId) === fixController) {
508
+ runtime.runControllers.delete(parentRunId);
509
+ }
510
+ const current = runtime.threads.get(parentRunId);
511
+ if (current === parentThreadAtStart && current.queueController === fixController) {
512
+ current.queueController = undefined;
513
+ }
514
+ };
515
+ fixController = runtime.backgroundQueue.enqueue(
516
+ serializeAutoFixChain(executionCwd, async (signal) => {
517
+ const chain: ChainStep[] = [
518
+ { runId: parentRunId, result: initialReviewerResult, relation: "initial review" },
519
+ ];
520
+ let lastReviewer = initialReviewerResult;
521
+ for (let round = 1; round <= config.maxFixRounds; round++) {
522
+ if (!runtime.sessionActive) break;
523
+ const fixBrief = buildFixTaskBrief(lastReviewer, round, config.maxFixRounds);
524
+ const workerStep = await launchInLoop("worker", fixBrief, executionCwd, signal, {
525
+ groupId: parentGroupId,
526
+ relationLabel: `fix round ${round}`,
527
+ }, vision);
528
+ // Preserve the newest sub-step before checking chain ownership. A
529
+ // destructive stop invalidates ownsParent() while this child is
530
+ // aborting, and its partial output must become the parent's stopped
531
+ // result instead of falling back to the old triggering review.
532
+ if (
533
+ runtime.threads.get(parentRunId) === parentThreadAtStart &&
534
+ parentThreadAtStart.generation === parentGeneration
535
+ ) {
536
+ parentThreadAtStart.lastResult = workerStep.result;
537
+ parentThreadAtStart.agentName = workerStep.result.agent;
538
+ parentThreadAtStart.task = workerStep.result.task;
539
+ parentThreadAtStart.sessionId = workerStep.result.sessionId;
540
+ parentThreadAtStart.sessionDir = workerStep.result.sessionDir;
541
+ runtime.retainSession(workerStep.result);
542
+ }
543
+ if (!ownsParent()) return;
544
+ chain.push({ ...workerStep, relation: `fix round ${round}` });
545
+ if (!runtime.sessionActive || isFailedResult(workerStep.result)) break;
546
+ const reReviewBrief = buildReReviewBrief(lastReviewer, round);
547
+ const reviewStep = await launchInLoop("reviewer", reReviewBrief, executionCwd, signal, {
548
+ groupId: parentGroupId,
549
+ relationLabel: `re-review round ${round}`,
550
+ }, vision);
551
+ if (
552
+ runtime.threads.get(parentRunId) === parentThreadAtStart &&
553
+ parentThreadAtStart.generation === parentGeneration
554
+ ) {
555
+ parentThreadAtStart.lastResult = reviewStep.result;
556
+ parentThreadAtStart.agentName = reviewStep.result.agent;
557
+ parentThreadAtStart.task = reviewStep.result.task;
558
+ parentThreadAtStart.sessionId = reviewStep.result.sessionId;
559
+ parentThreadAtStart.sessionDir = reviewStep.result.sessionDir;
560
+ runtime.retainSession(reviewStep.result);
561
+ }
562
+ if (!ownsParent()) return;
563
+ chain.push({ ...reviewStep, relation: `re-review round ${round}` });
564
+ lastReviewer = reviewStep.result;
565
+ // A crashed re-review must stop the chain like a crashed worker: its
566
+ // output (if any) is not a verdict, and feeding it to the next fix
567
+ // round would brief the worker from garbage.
568
+ if (!runtime.sessionActive || isFailedResult(reviewStep.result)) break;
569
+ if (reviewVerdict(getResultOutput(reviewStep.result)) === "pass") break;
570
+ }
571
+ // Every parent mutation is guarded by the exact generation, control, and
572
+ // queue controller that started this chain. A parked/resumed generation or
573
+ // destructive stop must make this old orchestration a no-op.
574
+ if (!ownsParent()) return;
575
+ const controlledParent = parentThreadAtStart;
576
+ if (controlledParent.retired || controlledParent.state === "stopped") {
577
+ clearOwnedController();
578
+ removeChainGroup(parentGroupId);
579
+ return;
580
+ }
581
+ // Parking an auto-fix chain aborts its in-flight child but preserves the
582
+ // parent's retained checkpoint and suppresses an aborted chain delivery.
583
+ if (controlledParent.state === "parked") {
584
+ clearOwnedController();
585
+ removeChainGroup(parentGroupId);
586
+ monitor.setRetained(parentRunId, false);
587
+ monitor.setStatus(parentRunId, "parked");
588
+ return;
589
+ }
590
+ // The chain is done (success, exhaustion, or abort): drop the retained
591
+ // parent row and its retained round rows, then deliver one condensed
592
+ // summary. Register the parent's final state (the last chain result)
593
+ // before removal so subagent_wait can resolve it.
594
+ const last = chain[chain.length - 1];
595
+ runtime.registerRunResult(parentRunId, last.result);
596
+ removeChainGroup(parentGroupId);
597
+ monitor.removeRun(parentRunId);
598
+ runtime.retainSession(last.result);
599
+ const parentThread = parentThreadAtStart;
600
+ parentThread.agentName = last.result.agent;
601
+ parentThread.task = last.result.task;
602
+ parentThread.sessionId = last.result.sessionId;
603
+ parentThread.sessionDir = last.result.sessionDir;
604
+ parentThread.state = isFailedResult(last.result) ? "failed" : "completed";
605
+ // The chain outcome settles the parent thread's trajectory: the
606
+ // last chain step is its final state.
607
+ const parentTrajectory = trajectoryStore.get(parentRunId);
608
+ parentTrajectory.trajectory.append({
609
+ kind: "settled",
610
+ status: parentThread.state === "failed" ? "failed" : "done",
611
+ model: last.result.model,
612
+ });
613
+ if (!runtime.sessionActive) {
614
+ clearOwnedController();
615
+ return;
616
+ }
617
+ // One compact message instead of every round's raw output: the summary
618
+ // lines cover each step (verdict + what changed/found), and the final
619
+ // step's full report is appended only when its detail is actionable
620
+ // (a FAIL verdict, a crash, or a model-level failure the main agent
621
+ // must take over). Everything else stays one `subagent_status #id`
622
+ // call away.
623
+ let block = formatChainSummary(chain);
624
+ if (isFailedResult(last.result) && isModelLevelFailure(last.result)) {
625
+ block = `${block}\n\n${formatCompletionBlock(last.result, config.maxResultLines, executionCwd)}\n\n${modelLevelTakeoverNote(last.result, { runId: parentRunId })}`;
626
+ } else if (isFailedResult(last.result) || reviewVerdict(getResultOutput(last.result)) === "fail") {
627
+ block = `${block}\n\n${formatCompletionBlock(last.result, config.maxResultLines, executionCwd)}`;
628
+ }
629
+ runtime.sendCompletionGroup([
630
+ {
631
+ agent: `auto-fix chain (${last.result.agent})`,
632
+ block,
633
+ triggerTurn: true,
634
+ },
635
+ ]);
636
+ runtime.completionBatcher.flush();
637
+ clearOwnedController();
638
+ }),
639
+ () => {
640
+ if (!ownsParent()) return;
641
+ const controlledParent = parentThreadAtStart;
642
+ clearOwnedController();
643
+ removeChainGroup(parentGroupId);
644
+ if (controlledParent.state === "parked") {
645
+ monitor.setRetained(parentRunId, false);
646
+ monitor.setStatus(parentRunId, "parked");
647
+ return;
648
+ }
649
+ if (!controlledParent.retired) monitor.removeRun(parentRunId);
650
+ },
651
+ (error) => {
652
+ // A crash inside the chain orchestration (failed runs are caught by
653
+ // launchInLoop and delivered as part of the chain) must not vanish, but
654
+ // an obsolete generation/controller must never publish it.
655
+ if (!ownsParent()) return;
656
+ if (parentThreadAtStart.retired || parentThreadAtStart.state === "stopped") {
657
+ clearOwnedController();
658
+ removeChainGroup(parentGroupId);
659
+ return;
660
+ }
661
+ runtime.registerRunResult(parentRunId, initialReviewerResult);
662
+ removeChainGroup(parentGroupId);
663
+ monitor.removeRun(parentRunId);
664
+ if (!runtime.sessionActive) {
665
+ clearOwnedController();
666
+ return;
667
+ }
668
+ const errorMessage = error instanceof Error ? error.message : String(error);
669
+ try {
670
+ ctx.ui.notify(`✗ auto-fix chain dispatch failed: ${errorMessage}`, "error");
671
+ // Keep the triggering review's findings: the chain crashed before any
672
+ // fix round ran, and the main agent needs the review to act on it.
673
+ runtime.sendCompletionGroup([
674
+ {
675
+ agent: initialReviewerResult.agent,
676
+ block: `${formatCompletionBlock(initialReviewerResult, config.maxResultLines, executionCwd)}\n\nAuto-fix chain crashed before completion: ${errorMessage}. The planned fix rounds did not run; the review above is the triggering reviewer's full output.`,
677
+ triggerTurn: true,
678
+ },
679
+ ]);
680
+ runtime.completionBatcher.flush();
681
+ } catch {
682
+ /* a second delivery failure must not throw through the queue */
683
+ } finally {
684
+ clearOwnedController();
685
+ }
686
+ },
687
+ );
688
+ runtime.runControllers.set(parentRunId, fixController);
689
+ parentThreadAtStart.queueController = fixController;
690
+ const priorCompletion = parentThreadAtStart.generationCompletion;
691
+ parentThreadAtStart.generationCompletion = Promise.all([
692
+ priorCompletion,
693
+ runtime.backgroundQueue.waitForTask(fixController),
694
+ ]).then(() => undefined);
695
+ };
696
+
697
+ interface SessionSeed {
698
+ sessionId?: string;
699
+ sessionDir?: string;
700
+ prompt?: string;
701
+ worktree?: WorktreeIsolation;
702
+ forkedFromRunId?: number;
703
+ forkObjective?: string;
704
+ modelPool?: string[];
705
+ thinkingLevel?: SubagentThread["thinkingLevel"];
706
+ }
707
+
708
+ interface ResumeReservation {
709
+ version: number;
710
+ generation: number;
711
+ sessionId?: string;
712
+ sessionDir?: string;
713
+ }
714
+
715
+ const ownsResumeReservation = (
716
+ thread: SubagentThread,
717
+ reservation: ResumeReservation,
718
+ ): boolean =>
719
+ runtime.sessionActive &&
720
+ runtime.threads.get(thread.id) === thread &&
721
+ !thread.retired &&
722
+ thread.lifecycleOperation === "resume" &&
723
+ thread.lifecycleVersion === reservation.version &&
724
+ thread.generation === reservation.generation &&
725
+ thread.sessionId === reservation.sessionId &&
726
+ thread.sessionDir === reservation.sessionDir;
727
+
728
+ const beginPreflight = (): (() => void) => {
729
+ let resolvePreflight!: () => void;
730
+ const preflight = new Promise<void>((resolve) => {
731
+ resolvePreflight = resolve;
732
+ });
733
+ runtime.preflightOperations.add(preflight);
734
+ return () => {
735
+ runtime.preflightOperations.delete(preflight);
736
+ resolvePreflight();
737
+ };
738
+ };
739
+
740
+ const startBackground = async (
741
+ agentName: string,
742
+ task: string,
743
+ cwd: string | undefined,
744
+ vision = false,
745
+ isolation: IsolationMode = "shared",
746
+ existingThread?: SubagentThread,
747
+ newObjectiveOnResume = false,
748
+ environment?: DispatchEnvironment,
749
+ seed?: SessionSeed,
750
+ resumeReservation?: ResumeReservation,
751
+ ): Promise<SingleResult> => {
752
+ if (!runtime.sessionActive) {
753
+ return failedStartResult(agentName, task, "Parent session shut down before this subagent generation could start.");
754
+ }
755
+ if (existingThread && (!resumeReservation || !ownsResumeReservation(existingThread, resumeReservation))) {
756
+ return failedStartResult(agentName, task, `Run #${existingThread.id} changed while resume was preparing; no new generation was started.`);
757
+ }
758
+ const runCtx = environment?.ctx ?? ctx;
759
+ const runConfig = environment?.config ?? config;
760
+ const runAgents = environment?.agents ?? agents;
761
+ const runSessionRef = environment?.sessionRef ?? sessionRef;
762
+ const agent = runAgents.find((candidate) => candidate.name === agentName);
763
+ if (!agent) return failedStartResult(agentName, task, `Unknown agent: "${agentName}".`);
764
+ if (isolation === "worktree" && !isWorktreeCapableAgent(agent)) {
765
+ return {
766
+ ...failedStartResult(agentName, task, `Agent "${agentName}" is read-only; worktree isolation is available only to worker/write-capable agents.`),
767
+ isolation,
768
+ };
769
+ }
770
+
771
+ const originalCwd = resolve(cwd ?? runCtx.cwd);
772
+ const previousWorktree = existingThread?.worktree;
773
+ let worktree = seed?.worktree ?? previousWorktree;
774
+ if (isolation === "worktree") {
775
+ if (worktree && worktree.state !== "active") {
776
+ return {
777
+ ...failedStartResult(agentName, task, `Run #${existingThread?.id ?? "?"} has no active continuation worktree.`),
778
+ isolation,
779
+ originalCwd,
780
+ integrationStatus: worktree.state === "finalizing" ? "pending" : worktree.state,
781
+ };
782
+ }
783
+ if (!worktree) {
784
+ try {
785
+ worktree = await createWorktreeIsolation(originalCwd);
786
+ } catch (error) {
787
+ return {
788
+ ...failedStartResult(agentName, task, error instanceof Error ? error.message : String(error)),
789
+ isolation,
790
+ originalCwd,
791
+ };
792
+ }
793
+ }
794
+ }
795
+ const executionCwd = worktree?.cwd ?? originalCwd;
796
+ const resolvedPool = resolveDispatchModelPool(agent, runConfig, runSessionRef, vision);
797
+ const inheritedPool = seed?.modelPool?.filter((ref) => ref.trim().length > 0) ?? [];
798
+ const rawPool = inheritedPool.length > 0
799
+ ? {
800
+ agent: { ...agent, model: inheritedPool[0] },
801
+ fallbackModelRefs: inheritedPool.slice(1),
802
+ }
803
+ : resolvedPool;
804
+ // Isolation is a persistent system-level invariant, not a one-shot task
805
+ // prefix: queued retargets, live retargets, resumes, and model fallbacks
806
+ // all keep the same worktree boundary.
807
+ const pool = isolation === "worktree"
808
+ ? { ...rawPool, agent: withWorktreeSystemPrompt(rawPool.agent) }
809
+ : rawPool;
810
+ const thinkingLevel = seed?.thinkingLevel ?? runConfig.agentThinkingLevels[agent.name] ?? agent.thinking ?? runConfig.thinkingLevel;
811
+ const modelPool = [pool.agent.model, ...pool.fallbackModelRefs].filter((ref): ref is string => Boolean(ref));
812
+ const priorTask = existingThread?.task;
813
+ const priorSessionId = seed?.sessionId ?? existingThread?.sessionId;
814
+ const priorSessionDir = seed?.sessionDir ?? existingThread?.sessionDir;
815
+ if (existingThread && resumeReservation && !ownsResumeReservation(existingThread, resumeReservation)) {
816
+ return failedStartResult(agentName, task, `Run #${existingThread.id} changed while resume was preparing; no new generation was started.`);
817
+ }
818
+ const runId = existingThread?.id ?? monitor.addRun(agent.name, task, pool.agent.model, thinkingLevel, {
819
+ isolation,
820
+ ...(seed?.forkedFromRunId !== undefined ? { forkedFromRunId: seed.forkedFromRunId } : {}),
821
+ });
822
+ const generation = (existingThread?.generation ?? 0) + 1;
823
+ const pending: SingleResult = {
824
+ ...queuedResult(pool.agent, task, thinkingLevel),
825
+ runId,
826
+ isolation,
827
+ originalCwd,
828
+ isolationCwd: executionCwd,
829
+ ...(isolation === "worktree" ? { integrationStatus: "pending" as const } : {}),
830
+ ...(seed?.sessionId && seed.sessionDir
831
+ ? { sessionId: seed.sessionId, sessionDir: seed.sessionDir, resumed: true }
832
+ : {}),
833
+ ...(seed?.forkedFromRunId !== undefined ? { forkedFromRunId: seed.forkedFromRunId } : {}),
834
+ };
835
+ if (existingThread) {
836
+ monitor.restartRun(runId, agent.name, task, pool.agent.model, thinkingLevel, isolation);
837
+ runtime.settledRuns.delete(runId);
838
+ }
839
+
840
+ let thread!: SubagentThread;
841
+ const control = new RpcRunControl(task, generation, (phase) => {
842
+ if (runtime.threads.get(runId)?.generation !== generation || phase === "settled") return;
843
+ // Orchestration transitions are part of the trajectory (retrying
844
+ // retry event, park/stop terminal control events).
845
+ const trajectory = trajectoryStore.get(runId).trajectory;
846
+ if (phase === "retrying") trajectory.append({ kind: "retry", reason: "retrying" });
847
+ else if (phase === "parked") trajectory.append({ kind: "park" });
848
+ else if (phase === "stopped") trajectory.append({ kind: "stop", reason: control.getStopMessage() });
849
+ const state: ThreadState =
850
+ phase === "queued" || phase === "starting"
851
+ ? "queued"
852
+ : phase === "steering"
853
+ ? "steering"
854
+ : phase === "interrupting"
855
+ ? "interrupting"
856
+ : phase === "parked"
857
+ ? "parked"
858
+ : phase === "stopped"
859
+ ? "stopped"
860
+ : "running";
861
+ thread.state = state;
862
+ if (state === "queued") monitor.setStatus(runId, "queued");
863
+ else if (state === "steering") monitor.setStatus(runId, "steering");
864
+ else if (state === "interrupting") monitor.setStatus(runId, "interrupting");
865
+ else if (state === "parked") monitor.setStatus(runId, "parked");
866
+ else if (state === "running") monitor.setStatus(runId, "running");
867
+ });
868
+
869
+ // Restart bumps the generation while preserving append-only history.
870
+ const trajectoryState = trajectoryStore.get(runId);
871
+ if (existingThread) {
872
+ trajectoryState.trajectory.restart();
873
+ trajectoryState.trajectory.append({
874
+ kind: "resume",
875
+ objective: newObjectiveOnResume ? task : undefined,
876
+ });
877
+ }
878
+ if (seed?.forkedFromRunId !== undefined) {
879
+ trajectoryState.trajectory.append({
880
+ kind: "fork",
881
+ sourceRunId: seed.forkedFromRunId,
882
+ childRunId: runId,
883
+ objective: seed.forkObjective,
884
+ });
885
+ }
886
+ trajectoryState.trajectory.append({
887
+ kind: "dispatch",
888
+ agent: agent.name,
889
+ task,
890
+ model: pool.agent.model,
891
+ thinking: thinkingLevel,
892
+ pool: pool.fallbackModelRefs,
893
+ vision,
894
+ resumed: existingThread !== undefined || seed !== undefined,
895
+ isolation,
896
+ originalCwd,
897
+ isolationCwd: executionCwd,
898
+ });
899
+ if (worktree && worktree !== previousWorktree) {
900
+ trajectoryState.trajectory.append({
901
+ kind: "worktree",
902
+ status: "created",
903
+ originalCwd,
904
+ isolationCwd: executionCwd,
905
+ worktreePath: worktree.worktreePath,
906
+ });
907
+ }
908
+
909
+ if (existingThread) {
910
+ thread = existingThread;
911
+ thread.generation = generation;
912
+ thread.agentName = agent.name;
913
+ thread.task = task;
914
+ thread.cwd = originalCwd;
915
+ thread.executionCwd = executionCwd;
916
+ thread.vision = vision;
917
+ thread.modelPool = modelPool;
918
+ thread.thinkingLevel = thinkingLevel;
919
+ thread.isolation = isolation;
920
+ thread.worktree = worktree;
921
+ thread.state = "queued";
922
+ thread.control = control;
923
+ // A newly admitted generation owns no output yet. Keeping the prior
924
+ // generation here would make a queued stop publish stale task,
925
+ // session metadata as this generation's partial.
926
+ thread.lastResult = undefined;
927
+ if (seed?.sessionId && seed.sessionDir) {
928
+ thread.sessionId = seed.sessionId;
929
+ thread.sessionDir = seed.sessionDir;
930
+ }
931
+ thread.retireOnSettle = false;
932
+ thread.isolationFailureNotified = false;
933
+ } else {
934
+ thread = {
935
+ id: runId,
936
+ generation,
937
+ agentName: agent.name,
938
+ task,
939
+ cwd: originalCwd,
940
+ executionCwd,
941
+ vision,
942
+ modelPool,
943
+ thinkingLevel,
944
+ isolation,
945
+ worktree,
946
+ state: "queued",
947
+ control,
948
+ generationCompletion: Promise.resolve(),
949
+ lifecycleVersion: 0,
950
+ sessionId: seed?.sessionId,
951
+ sessionDir: seed?.sessionDir,
952
+ forkedFromRunId: seed?.forkedFromRunId,
953
+ forkChildRunIds: [],
954
+ park: async () => {
955
+ throw new Error("Thread park was not initialized.");
956
+ },
957
+ resume: async () => failedStartResult(agent.name, task, "Thread resume was not initialized."),
958
+ fork: async () => failedStartResult(agent.name, task, "Thread fork was not initialized."),
959
+ finalizeIsolation: async () => undefined,
960
+ };
961
+ runtime.threads.set(runId, thread);
962
+ }
963
+ thread.notifyIsolationFailure = (finalization) => {
964
+ const paths = [finalization.worktreePath, finalization.patchPath].filter(Boolean).join(" · ");
965
+ runCtx.ui.notify(
966
+ `✗ worker worktree ${finalization.integrated ? "cleanup" : "integration"} failed${paths ? ` · retained ${paths}` : ""}: ${finalization.error ?? "unknown Git integration error"}`,
967
+ "error",
968
+ );
969
+ };
970
+ thread.finalizeIsolation = async (
971
+ expectedGeneration: number,
972
+ result?: SingleResult,
973
+ ): Promise<WorktreeFinalization | undefined> => {
974
+ if (thread.isolation !== "worktree" || !thread.worktree) return undefined;
975
+ if (thread.generation !== expectedGeneration) return undefined;
976
+ const finalization = await thread.worktree.finalize();
977
+ monitor.setIsolation(runId, "worktree", finalization.status);
978
+ trajectoryState.trajectory.append({
979
+ kind: "worktree",
980
+ status: finalization.status,
981
+ originalCwd: thread.cwd,
982
+ isolationCwd: thread.executionCwd,
983
+ worktreePath: finalization.worktreePath,
984
+ patchPath: finalization.patchPath,
985
+ integrated: finalization.integrated,
986
+ error: finalization.error,
987
+ });
988
+ if (result) {
989
+ result.runId = runId;
990
+ result.isolation = "worktree";
991
+ result.originalCwd = thread.cwd;
992
+ result.isolationCwd = thread.executionCwd;
993
+ result.integrationStatus = finalization.status;
994
+ result.integrationApplied = finalization.integrated;
995
+ result.integrationError = finalization.error;
996
+ result.integrationWorktreePath = finalization.worktreePath;
997
+ result.integrationPatchPath = finalization.patchPath;
998
+ result.forkedFromRunId = thread.forkedFromRunId;
999
+ result.forkChildRunIds = [...thread.forkChildRunIds];
1000
+ if (finalization.status === "retained") {
1001
+ const retained = [
1002
+ finalization.worktreePath ? `worktree ${finalization.worktreePath}` : undefined,
1003
+ finalization.patchPath ? `patch ${finalization.patchPath}` : undefined,
1004
+ ].filter(Boolean).join(", ");
1005
+ const integrationMessage = finalization.integrated
1006
+ ? `Worktree changes were applied, but cleanup failed${retained ? `; retained ${retained}` : ""}: ${finalization.error ?? "unknown Git cleanup error"}`
1007
+ : `Worktree integration failed${retained ? `; retained ${retained}` : ""}: ${finalization.error ?? "unknown Git integration error"}`;
1008
+ result.exitCode = 1;
1009
+ result.stopReason = "error";
1010
+ result.errorMessage = result.errorMessage
1011
+ ? `${result.errorMessage}\n${integrationMessage}`
1012
+ : integrationMessage;
1013
+ result.stderr = result.stderr ? `${result.stderr.trimEnd()}\n${integrationMessage}` : integrationMessage;
1014
+ }
1015
+ }
1016
+ if (finalization.status === "retained") {
1017
+ runtime.retainWorktreeArtifacts(finalization);
1018
+ if (!thread.isolationFailureNotified) {
1019
+ thread.isolationFailureNotified = true;
1020
+ try {
1021
+ thread.notifyIsolationFailure?.(finalization);
1022
+ } catch {
1023
+ /* notification failures do not hide retained artifacts */
1024
+ }
1025
+ }
1026
+ }
1027
+ return finalization;
1028
+ };
1029
+
1030
+ const cleanupTrackedSessionDir = async (sessionDir: string, action: string): Promise<void> => {
1031
+ try {
1032
+ await rm(sessionDir, { recursive: true, force: true });
1033
+ runtime.sessionDirs.delete(sessionDir);
1034
+ } catch (error) {
1035
+ // Keep ownership so shutdown can retry; losing the path here leaks a
1036
+ // cloned session containing retained model context on Windows locks.
1037
+ try {
1038
+ runCtx.ui.notify(
1039
+ `✗ ${action}; retained ${sessionDir} for shutdown cleanup: ${error instanceof Error ? error.message : String(error)}`,
1040
+ "error",
1041
+ );
1042
+ } catch {
1043
+ /* cleanup ownership remains tracked even if the UI is unavailable */
1044
+ }
1045
+ }
1046
+ };
1047
+
1048
+ const discardUnusedWorktree = async (candidate: WorktreeIsolation | undefined): Promise<void> => {
1049
+ if (!candidate) return;
1050
+ try {
1051
+ if (candidate.discard) {
1052
+ await candidate.discard();
1053
+ return;
1054
+ }
1055
+ // Compatibility for externally supplied/test handles. Production handles
1056
+ // expose discard(), so this fallback never integrates a seeded worktree.
1057
+ if (candidate.state === "active") await candidate.finalize();
1058
+ } catch (error) {
1059
+ const retainedPath = existsSync(candidate.worktreePath)
1060
+ ? candidate.worktreePath
1061
+ : existsSync(candidate.tempDir)
1062
+ ? candidate.tempDir
1063
+ : undefined;
1064
+ const finalization: WorktreeFinalization = {
1065
+ status: "retained",
1066
+ integrated: false,
1067
+ hadChanges: false,
1068
+ ...(retainedPath ? { worktreePath: retainedPath } : {}),
1069
+ ...(existsSync(candidate.patchPath) ? { patchPath: candidate.patchPath } : {}),
1070
+ error: `Discarding unused continuation failed: ${error instanceof Error ? error.message : String(error)}`,
1071
+ };
1072
+ runtime.retainWorktreeArtifacts(finalization);
1073
+ await persistRecoveryRecords(runtime.configPath, [
1074
+ recoveryRecordFromFinalization(runId, finalization),
1075
+ ]).catch(() => undefined);
1076
+ try {
1077
+ thread.notifyIsolationFailure?.(finalization);
1078
+ } catch {
1079
+ /* parent UI may already be shutting down */
1080
+ }
1081
+ }
1082
+ };
1083
+
1084
+ const createContinuationWorktree = async (
1085
+ source: WorktreeIsolation,
1086
+ seedIsIntegrated: boolean,
1087
+ ): Promise<WorktreeIsolation> => {
1088
+ if (source.state === "finalizing") {
1089
+ throw new Error(`Run #${runId}'s worktree is still finalizing.`);
1090
+ }
1091
+ const seedCheckpoint = await source.snapshotCheckpoint();
1092
+ return createWorktreeIsolation(thread.cwd, {
1093
+ seedCheckpoint,
1094
+ seedIsIntegrated,
1095
+ });
1096
+ };
1097
+
1098
+ thread.park = async (): Promise<"queued" | "active"> => {
1099
+ if (thread.retired) throw new Error(`Run #${runId} was retired by subagent_stop.`);
1100
+ if (thread.lifecycleOperation) throw new Error(`Run #${runId} is already handling ${thread.lifecycleOperation}.`);
1101
+ if (thread.state === "parked") return "active";
1102
+ const phase = thread.control.getPhase();
1103
+ const queued = thread.state === "queued" && phase === "queued";
1104
+ if (
1105
+ !queued &&
1106
+ ((phase === "settled" && thread.state !== "running") ||
1107
+ !["starting", "running", "steering", "interrupting", "retrying", "settled"].includes(phase))
1108
+ ) {
1109
+ throw new Error(`Run #${runId} is ${thread.state}; only active work can be parked.`);
1110
+ }
1111
+
1112
+ const version = ++thread.lifecycleVersion;
1113
+ const generation = thread.generation;
1114
+ const completion = thread.generationCompletion;
1115
+ const controller = thread.queueController;
1116
+ thread.lifecycleOperation = "park";
1117
+ try {
1118
+ if (queued) {
1119
+ thread.control.parkPending();
1120
+ runtime.backgroundQueue.cancel(controller);
1121
+ } else {
1122
+ await thread.control.park();
1123
+ // Auto-fix orchestration has no live RPC attempt once its parent
1124
+ // review settled, so cancel its queue owner explicitly.
1125
+ if (phase === "settled") runtime.backgroundQueue.cancel(controller);
1126
+ }
1127
+ await completion;
1128
+ if (
1129
+ thread.generation !== generation ||
1130
+ thread.lifecycleVersion !== version ||
1131
+ thread.lifecycleOperation !== "park"
1132
+ ) {
1133
+ throw new Error(`Run #${runId} changed while parking.`);
1134
+ }
1135
+ thread.state = "parked";
1136
+ thread.queueController = undefined;
1137
+ runtime.runControllers.delete(runId);
1138
+ monitor.setStatus(runId, "parked");
1139
+ return queued ? "queued" : "active";
1140
+ } finally {
1141
+ if (thread.lifecycleVersion === version && thread.lifecycleOperation === "park") {
1142
+ thread.lifecycleOperation = undefined;
1143
+ }
1144
+ }
1145
+ };
1146
+
1147
+ thread.resume = async (objective?: string, resumeCtx?: ExtensionContext): Promise<SingleResult> => {
1148
+ const requestedObjective = objective?.trim();
1149
+ if (!runtime.sessionActive || runtime.threads.get(runId) !== thread) {
1150
+ return failedStartResult(thread.agentName, thread.task, `Run #${runId} belongs to a parent session that has shut down.`);
1151
+ }
1152
+ if (objective !== undefined && !requestedObjective) {
1153
+ return failedStartResult(thread.agentName, thread.task, "resume objective must be non-blank when provided.");
1154
+ }
1155
+ if (thread.retired) return failedStartResult(thread.agentName, thread.task, `Run #${runId} was retired by subagent_stop.`);
1156
+ if (thread.lifecycleOperation) {
1157
+ return failedStartResult(thread.agentName, thread.task, `Run #${runId} is already ${thread.lifecycleOperation === "resume" ? "resuming" : "being forked"}.`);
1158
+ }
1159
+ if (!["parked", "completed", "failed"].includes(thread.state)) {
1160
+ return failedStartResult(thread.agentName, thread.task, `Run #${runId} is ${thread.state}; it must be parked or settled before resume.`);
1161
+ }
1162
+
1163
+ // Lifecycle CAS: claim synchronously before the first await, then cancel
1164
+ // and fully quiesce any superseded queue/process before cloning or
1165
+ // reusing its session. A second resume/fork sees this claim immediately.
1166
+ const previousState = thread.state;
1167
+ const previousSessionId = thread.sessionId;
1168
+ const previousSessionDir = thread.sessionDir;
1169
+ const previousExecutionCwd = thread.executionCwd;
1170
+ const reservation: ResumeReservation = {
1171
+ version: ++thread.lifecycleVersion,
1172
+ generation: thread.generation,
1173
+ sessionId: previousSessionId,
1174
+ sessionDir: previousSessionDir,
1175
+ };
1176
+ thread.lifecycleOperation = "resume";
1177
+ thread.state = "resuming";
1178
+ const finishPreflight = beginPreflight();
1179
+ const supersededController = thread.queueController;
1180
+ runtime.backgroundQueue.cancel(supersededController);
1181
+ runtime.runControllers.delete(runId);
1182
+
1183
+ let continuationWorktree: WorktreeIsolation | undefined;
1184
+ let clonedSession: Awaited<ReturnType<typeof forkRetainedSession>> | undefined;
1185
+ try {
1186
+ await thread.generationCompletion;
1187
+ if (!ownsResumeReservation(thread, reservation)) {
1188
+ return failedStartResult(
1189
+ thread.agentName,
1190
+ thread.task,
1191
+ thread.retired
1192
+ ? `Run #${runId} was retired by subagent_stop; no new generation was started.`
1193
+ : `Run #${runId} changed while resume was preparing; no new generation was started.`,
1194
+ );
1195
+ }
1196
+ thread.state = "resuming";
1197
+ const currentCtx = resumeCtx ?? runCtx;
1198
+ let seed: SessionSeed | undefined;
1199
+ if (thread.isolation === "worktree" && thread.worktree?.state !== "active") {
1200
+ if (!thread.worktree) throw new Error(`Run #${runId} has no isolated worktree checkpoint.`);
1201
+ const seedAlreadyIntegrated =
1202
+ thread.worktree.state === "integrated" ||
1203
+ thread.worktree.state === "no_changes" ||
1204
+ thread.lastResult?.integrationApplied === true;
1205
+ continuationWorktree = await createContinuationWorktree(thread.worktree, seedAlreadyIntegrated);
1206
+ if (!ownsResumeReservation(thread, reservation)) {
1207
+ throw new Error(`Run #${runId} changed while its continuation worktree was being created.`);
1208
+ }
1209
+ seed = { worktree: continuationWorktree };
1210
+ if (previousSessionId && previousSessionDir) {
1211
+ clonedSession = await forkRetainedSession({
1212
+ cwd: previousExecutionCwd,
1213
+ targetCwd: continuationWorktree.cwd,
1214
+ sessionDir: previousSessionDir,
1215
+ sessionId: previousSessionId,
1216
+ });
1217
+ runtime.sessionDirs.add(clonedSession.sessionDir);
1218
+ if (!ownsResumeReservation(thread, reservation)) {
1219
+ throw new Error(`Run #${runId} changed while its retained session was being cloned.`);
1220
+ }
1221
+ seed.sessionId = clonedSession.sessionId;
1222
+ seed.sessionDir = clonedSession.sessionDir;
1223
+ }
1224
+ }
1225
+
1226
+ const currentConfig = await loadConfig(runtime.configPath);
1227
+ if (!ownsResumeReservation(thread, reservation)) {
1228
+ throw new Error(`Run #${runId} changed while resume configuration was loading.`);
1229
+ }
1230
+ runtime.backgroundQueue.setConcurrency(currentConfig.maxConcurrency);
1231
+ const currentAgents = discoverAgents(currentCtx.cwd, {
1232
+ scope: currentConfig.agentScope,
1233
+ enabledNames: currentConfig.enabledAgents,
1234
+ projectTrusted: currentCtx.isProjectTrusted?.() === true,
1235
+ }).agents;
1236
+ const nextTask = requestedObjective ?? thread.task;
1237
+ const pending = await startBackground(
1238
+ thread.agentName,
1239
+ nextTask,
1240
+ thread.cwd,
1241
+ thread.vision,
1242
+ thread.isolation,
1243
+ thread,
1244
+ objective !== undefined,
1245
+ {
1246
+ ctx: currentCtx,
1247
+ config: currentConfig,
1248
+ agents: currentAgents,
1249
+ sessionRef: currentModelRef(currentCtx),
1250
+ },
1251
+ seed,
1252
+ reservation,
1253
+ );
1254
+ if (pending.exitCode !== -1) {
1255
+ if (clonedSession) {
1256
+ await cleanupTrackedSessionDir(
1257
+ clonedSession.sessionDir,
1258
+ `Could not discard failed resume session clone for run #${runId}`,
1259
+ );
1260
+ }
1261
+ await discardUnusedWorktree(continuationWorktree);
1262
+ if (ownsResumeReservation(thread, reservation)) thread.state = previousState;
1263
+ return pending;
1264
+ }
1265
+
1266
+ // The cloned branch replaces the removed-worktree session for this
1267
+ // logical id. Keep an undeletable old dir in runtime cleanup if needed.
1268
+ if (clonedSession && previousSessionDir && previousSessionDir !== clonedSession.sessionDir) {
1269
+ try {
1270
+ await rm(previousSessionDir, { recursive: true, force: true });
1271
+ runtime.sessionDirs.delete(previousSessionDir);
1272
+ } catch {
1273
+ /* shutdown retries cleanup of the old retained branch */
1274
+ }
1275
+ }
1276
+ return pending;
1277
+ } catch (error) {
1278
+ if (clonedSession) {
1279
+ await cleanupTrackedSessionDir(
1280
+ clonedSession.sessionDir,
1281
+ `Could not discard interrupted resume session clone for run #${runId}`,
1282
+ );
1283
+ }
1284
+ await discardUnusedWorktree(continuationWorktree);
1285
+ if (ownsResumeReservation(thread, reservation)) {
1286
+ thread.state = previousState;
1287
+ thread.sessionId = previousSessionId;
1288
+ thread.sessionDir = previousSessionDir;
1289
+ thread.executionCwd = previousExecutionCwd;
1290
+ }
1291
+ return failedStartResult(
1292
+ thread.agentName,
1293
+ requestedObjective ?? thread.task,
1294
+ `Could not resume run #${runId}: ${error instanceof Error ? error.message : String(error)}`,
1295
+ );
1296
+ } finally {
1297
+ finishPreflight();
1298
+ if (
1299
+ thread.lifecycleOperation === "resume" &&
1300
+ thread.lifecycleVersion === reservation.version
1301
+ ) {
1302
+ thread.lifecycleOperation = undefined;
1303
+ }
1304
+ }
1305
+ };
1306
+
1307
+ thread.fork = async (objective?: string, forkCtx?: ExtensionContext): Promise<SingleResult> => {
1308
+ const forkObjective = objective?.trim();
1309
+ if (!runtime.sessionActive || runtime.threads.get(runId) !== thread) {
1310
+ return failedStartResult(thread.agentName, thread.task, `Run #${runId} belongs to a parent session that has shut down.`);
1311
+ }
1312
+ if (objective !== undefined && !forkObjective) {
1313
+ return failedStartResult(thread.agentName, thread.task, "fork objective must be non-blank when provided.");
1314
+ }
1315
+ if (thread.retired || thread.state === "stopped") {
1316
+ return failedStartResult(thread.agentName, thread.task, `Run #${runId} was retired by subagent_stop and cannot be forked.`);
1317
+ }
1318
+ if (thread.lifecycleOperation) {
1319
+ return failedStartResult(thread.agentName, thread.task, `Run #${runId} is already ${thread.lifecycleOperation === "resume" ? "resuming" : "being forked"}.`);
1320
+ }
1321
+ if (thread.state === "queued" && !thread.sessionId) {
1322
+ return failedStartResult(thread.agentName, thread.task, `Run #${runId} is queued and has no retained session to fork.`);
1323
+ }
1324
+ if (["queued", "running", "steering", "interrupting"].includes(thread.state)) {
1325
+ return failedStartResult(thread.agentName, thread.task, `Run #${runId} is active; park it first with subagent_control { action: "park", id: ${runId} }, then fork the stable session.`);
1326
+ }
1327
+ if (!["parked", "completed", "failed"].includes(thread.state)) {
1328
+ return failedStartResult(thread.agentName, thread.task, `Run #${runId} is ${thread.state} and has no forkable retained checkpoint.`);
1329
+ }
1330
+ if (!thread.sessionId || !thread.sessionDir) {
1331
+ return failedStartResult(thread.agentName, thread.task, `Run #${runId} has no retained session to fork (it may have been parked before starting).`);
1332
+ }
1333
+ if (thread.isolation === "worktree") {
1334
+ const worktreeState = thread.worktree?.state;
1335
+ const seedIntegrated =
1336
+ worktreeState === "integrated" ||
1337
+ worktreeState === "no_changes" ||
1338
+ thread.lastResult?.integrationApplied === true;
1339
+ if (!seedIntegrated) {
1340
+ return failedStartResult(
1341
+ thread.agentName,
1342
+ thread.task,
1343
+ `Run #${runId}'s isolated checkpoint has not been integrated. Resume and settle it before forking so its seed is applied exactly once.`,
1344
+ );
1345
+ }
1346
+ }
1347
+
1348
+ // Same lifecycle CAS as resume: a concurrent resume/fork cannot consume
1349
+ // or clone this session while the branch copy is in progress.
1350
+ const forkVersion = ++thread.lifecycleVersion;
1351
+ const forkGeneration = thread.generation;
1352
+ const forkSessionId = thread.sessionId;
1353
+ const forkSessionDir = thread.sessionDir;
1354
+ const ownsFork = (): boolean =>
1355
+ runtime.sessionActive &&
1356
+ runtime.threads.get(runId) === thread &&
1357
+ !thread.retired &&
1358
+ thread.lifecycleOperation === "fork" &&
1359
+ thread.lifecycleVersion === forkVersion &&
1360
+ thread.generation === forkGeneration &&
1361
+ thread.sessionId === forkSessionId &&
1362
+ thread.sessionDir === forkSessionDir;
1363
+ thread.lifecycleOperation = "fork";
1364
+ const finishPreflight = beginPreflight();
1365
+ let childWorktree: WorktreeIsolation | undefined;
1366
+ let forkedSession: Awaited<ReturnType<typeof forkRetainedSession>> | undefined;
1367
+ try {
1368
+ await thread.generationCompletion;
1369
+ if (!ownsFork()) {
1370
+ return failedStartResult(thread.agentName, thread.task, `Run #${runId} changed while fork was preparing; no child was started.`);
1371
+ }
1372
+ const currentCtx = forkCtx ?? runCtx;
1373
+ if (thread.isolation === "worktree") {
1374
+ if (!thread.worktree) throw new Error(`Run #${runId} has no isolated worktree checkpoint.`);
1375
+ const seedAlreadyIntegrated =
1376
+ thread.worktree.state === "integrated" ||
1377
+ thread.worktree.state === "no_changes" ||
1378
+ thread.lastResult?.integrationApplied === true;
1379
+ childWorktree = await createContinuationWorktree(thread.worktree, seedAlreadyIntegrated);
1380
+ if (!ownsFork()) throw new Error(`Run #${runId} changed while its fork worktree was being created.`);
1381
+ }
1382
+ forkedSession = await forkRetainedSession({
1383
+ cwd: thread.executionCwd,
1384
+ targetCwd: childWorktree?.cwd ?? thread.cwd,
1385
+ sessionDir: thread.sessionDir,
1386
+ sessionId: thread.sessionId,
1387
+ });
1388
+ runtime.sessionDirs.add(forkedSession.sessionDir);
1389
+ if (!ownsFork()) throw new Error(`Run #${runId} changed while its retained session was being forked.`);
1390
+ const currentConfig = await loadConfig(runtime.configPath);
1391
+ if (!ownsFork()) throw new Error(`Run #${runId} changed while fork configuration was loading.`);
1392
+ runtime.backgroundQueue.setConcurrency(currentConfig.maxConcurrency);
1393
+ const currentAgents = discoverAgents(currentCtx.cwd, {
1394
+ scope: currentConfig.agentScope,
1395
+ enabledNames: currentConfig.enabledAgents,
1396
+ projectTrusted: currentCtx.isProjectTrusted?.() === true,
1397
+ }).agents;
1398
+ if (!ownsFork()) throw new Error(`Run #${runId} changed while fork was preparing; no child was started.`);
1399
+ const childTask = forkObjective ?? thread.task;
1400
+ const child = await startBackground(
1401
+ thread.agentName,
1402
+ childTask,
1403
+ thread.cwd,
1404
+ thread.vision,
1405
+ thread.isolation,
1406
+ undefined,
1407
+ false,
1408
+ {
1409
+ ctx: currentCtx,
1410
+ config: currentConfig,
1411
+ agents: currentAgents,
1412
+ sessionRef: currentModelRef(currentCtx),
1413
+ },
1414
+ {
1415
+ sessionId: forkedSession.sessionId,
1416
+ sessionDir: forkedSession.sessionDir,
1417
+ prompt: forkObjective ?? FORK_CONTINUATION_PROMPT,
1418
+ worktree: childWorktree,
1419
+ forkedFromRunId: runId,
1420
+ forkObjective,
1421
+ modelPool: [...thread.modelPool],
1422
+ thinkingLevel: thread.thinkingLevel,
1423
+ },
1424
+ );
1425
+ if (child.exitCode !== -1 || child.runId === undefined) {
1426
+ await cleanupTrackedSessionDir(
1427
+ forkedSession.sessionDir,
1428
+ `Could not discard failed fork session clone for run #${runId}`,
1429
+ );
1430
+ await discardUnusedWorktree(childWorktree);
1431
+ return child;
1432
+ }
1433
+
1434
+ // Once the independent child is enqueued it remains valid even if the
1435
+ // source is retired; just skip source-side relationship mutation.
1436
+ if (!ownsFork()) return child;
1437
+ const childRunId = child.runId;
1438
+ if (!thread.forkChildRunIds.includes(childRunId)) thread.forkChildRunIds.push(childRunId);
1439
+ const childThread = runtime.threads.get(childRunId);
1440
+ if (childThread) childThread.forkedFromRunId = runId;
1441
+ monitor.setForkRelation(runId, childRunId);
1442
+ trajectoryStore.get(runId).trajectory.append({
1443
+ kind: "fork",
1444
+ sourceRunId: runId,
1445
+ childRunId,
1446
+ objective: forkObjective,
1447
+ });
1448
+ const sourceResult = runtime.settledRuns.get(runId) ?? thread.lastResult;
1449
+ if (sourceResult) sourceResult.forkChildRunIds = [...thread.forkChildRunIds];
1450
+ return child;
1451
+ } catch (error) {
1452
+ if (forkedSession) {
1453
+ await cleanupTrackedSessionDir(
1454
+ forkedSession.sessionDir,
1455
+ `Could not discard interrupted fork session clone for run #${runId}`,
1456
+ );
1457
+ }
1458
+ await discardUnusedWorktree(childWorktree);
1459
+ return failedStartResult(
1460
+ thread.agentName,
1461
+ forkObjective ?? thread.task,
1462
+ `Could not fork retained session for run #${runId}: ${error instanceof Error ? error.message : String(error)}`,
1463
+ );
1464
+ } finally {
1465
+ finishPreflight();
1466
+ if (thread.lifecycleVersion === forkVersion && thread.lifecycleOperation === "fork") {
1467
+ thread.lifecycleOperation = undefined;
1468
+ }
1469
+ }
1470
+ };
1471
+
1472
+ const onLive = makeLiveHandler(runId, runId, generation);
1473
+ const queueController = runtime.backgroundQueue.enqueue(
1474
+ async (backgroundSignal) => {
1475
+ if (runtime.threads.get(runId)?.generation !== generation) return;
1476
+ let result: SingleResult;
1477
+ try {
1478
+ result = await runSingleAgentWithModelFallback(
1479
+ {
1480
+ defaultCwd: executionCwd,
1481
+ agent: pool.agent,
1482
+ agentName,
1483
+ task,
1484
+ cwd: executionCwd,
1485
+ thinkingLevel,
1486
+ signal: backgroundSignal,
1487
+ onLive,
1488
+ control,
1489
+ makeDetails: makeDetails("single", true),
1490
+ idleTimeoutMs: runConfig.idleTimeoutSec * 1000,
1491
+ ...(priorSessionId && priorSessionDir
1492
+ ? {
1493
+ sessionId: priorSessionId,
1494
+ sessionDir: priorSessionDir,
1495
+ stdinText: seed?.prompt ?? (newObjectiveOnResume
1496
+ ? task
1497
+ : buildResumePrompt(priorTask ?? task, buildFallbackResumeReason())),
1498
+ }
1499
+ : {}),
1500
+ },
1501
+ pool.fallbackModelRefs,
1502
+ );
1503
+ } catch (error) {
1504
+ const errorMessage = error instanceof Error ? error.message : String(error);
1505
+ result = {
1506
+ ...pending,
1507
+ task: control.getObjective(),
1508
+ exitCode: 1,
1509
+ stderr: errorMessage,
1510
+ stopReason: backgroundSignal.aborted ? "aborted" : "error",
1511
+ errorMessage,
1512
+ dispatchFailed: true,
1513
+ };
1514
+ }
1515
+
1516
+ // A stale process/generation may finish after a park/resume race. It owns
1517
+ // no monitor mutation, result registration, or completion delivery.
1518
+ if (runtime.threads.get(runId)?.generation !== generation) return;
1519
+ result.runId = runId;
1520
+ result.isolation = isolation;
1521
+ result.originalCwd = originalCwd;
1522
+ result.isolationCwd = executionCwd;
1523
+ result.forkedFromRunId = thread.forkedFromRunId;
1524
+ result.forkChildRunIds = [...thread.forkChildRunIds];
1525
+ thread.queueController = undefined;
1526
+ runtime.runControllers.delete(runId);
1527
+ thread.task = result.task;
1528
+ thread.sessionId = result.sessionId;
1529
+ thread.sessionDir = result.sessionDir;
1530
+ thread.lastResult = result;
1531
+ runtime.retainSession(result);
1532
+ monitor.setModel(runId, result.model, result.modelFallbackFrom);
1533
+
1534
+ // Destructive stop owns publication once it has synchronously claimed
1535
+ // the lifecycle. Leave the partial result/session on the thread; the
1536
+ // stop path waits for this queue task, finalizes isolation, and emits
1537
+ // exactly one aborted result.
1538
+ if (thread.lifecycleOperation === "stop") return;
1539
+
1540
+ if (result.parked) {
1541
+ thread.state = "parked";
1542
+ monitor.setStatus(runId, "parked");
1543
+ runtime.settledRuns.delete(runId);
1544
+ return;
1545
+ }
1546
+
1547
+ if (thread.retireOnSettle) runtime.retireThreadSession(thread);
1548
+ const wantsFixLoop = shouldTriggerFixLoop(result, runConfig);
1549
+ if (wantsFixLoop && isolation === "shared" && runtime.sessionActive) {
1550
+ thread.state = "running";
1551
+ finishRun(runId, "done", { silent: true, retain: true });
1552
+ monitor.setAnnotation(runId, "auto-fix chain running");
1553
+ startFixLoop(result, `fix-${runId}`, runId, thread.executionCwd, vision);
1554
+ return;
1555
+ }
1556
+ // Claim terminal settlement synchronously before the first slow await.
1557
+ // Park therefore either wins while RPC is still active, or is rejected
1558
+ // once settlement owns the generation. Destructive stop may supersede
1559
+ // this reservation; publication is revalidated after Git finalization.
1560
+ const settlementVersion = ++thread.lifecycleVersion;
1561
+ thread.lifecycleOperation = "settle";
1562
+ const ownsSettlement = (): boolean =>
1563
+ runtime.threads.get(runId) === thread &&
1564
+ thread.generation === generation &&
1565
+ thread.lifecycleVersion === settlementVersion &&
1566
+ thread.lifecycleOperation === "settle" &&
1567
+ !thread.retired;
1568
+ try {
1569
+ // Worktree isolation is rejected for reviewers, the only role that can
1570
+ // trigger auto-fix. Keep that invariant explicit: an isolated result is
1571
+ // finalized once here and can never start a chain that would integrate
1572
+ // the same worktree early.
1573
+ await thread.finalizeIsolation(generation, result);
1574
+ if (!ownsSettlement()) return;
1575
+
1576
+ const failed = isFailedResult(result);
1577
+ thread.state = failed ? "failed" : "completed";
1578
+ // Stamp the terminal monitor state before projecting it. This gives every
1579
+ // path a fixed endedAt even when the row is removed immediately.
1580
+ monitor.setStatus(runId, failed ? "failed" : "done");
1581
+ trajectoryState.trajectory.append({
1582
+ kind: "settled",
1583
+ status: failed ? "failed" : "done",
1584
+ model: result.model,
1585
+ isolation,
1586
+ ...(result.integrationStatus && result.integrationStatus !== "pending"
1587
+ ? { integrationStatus: result.integrationStatus }
1588
+ : {}),
1589
+ });
1590
+ if (!runtime.sessionActive || !ownsSettlement()) return;
1591
+
1592
+ const modelLevel = failed && isModelLevelFailure(result);
1593
+ const dispatchFailed = result.dispatchFailed === true;
1594
+ finishRun(runId, failed ? "failed" : "done", modelLevel || dispatchFailed ? { silent: true } : undefined);
1595
+ runtime.registerRunResult(runId, result);
1596
+ const completion: CompletionMessageItem = {
1597
+ agent: result.agent,
1598
+ block: modelLevel
1599
+ ? `${formatCompletionBlock(result, runConfig.maxResultLines, runCtx.cwd)}\n\n${modelLevelTakeoverNote(result, { runId })}`
1600
+ : formatCompletionBlock(result, runConfig.maxResultLines, runCtx.cwd),
1601
+ triggerTurn: completionTriggersTurn(result, runConfig.notifyOnReviewPass),
1602
+ };
1603
+ if (modelLevel) {
1604
+ runCtx.ui.notify(`✗ ${result.agent} dispatch failed: model unavailable or broken — task handed to the main window`, "error");
1605
+ } else if (dispatchFailed) {
1606
+ runCtx.ui.notify(`✗ ${result.agent} dispatch failed: ${result.errorMessage ?? "dispatch crashed"}`, "error");
1607
+ }
1608
+ if (failed) {
1609
+ runtime.sendCompletionGroup([completion]);
1610
+ runtime.completionBatcher.flush();
1611
+ } else {
1612
+ runtime.completionBatcher.push(completion);
1613
+ }
1614
+ } finally {
1615
+ if (ownsSettlement()) thread.lifecycleOperation = undefined;
1616
+ }
1617
+ },
1618
+ () => {
1619
+ if (runtime.threads.get(runId)?.generation !== generation) return;
1620
+ // Queued park/stop owns publication and may still be finalizing an
1621
+ // isolated worktree. Do not expose a terminal monitor/trajectory state
1622
+ // before that owner records the checkpoint or aborted result.
1623
+ if (thread.lifecycleOperation === "park" || thread.lifecycleOperation === "stop") return;
1624
+ runtime.runControllers.delete(runId);
1625
+ thread.queueController = undefined;
1626
+ if (thread.state === "parked") {
1627
+ monitor.setStatus(runId, "parked");
1628
+ return;
1629
+ }
1630
+ thread.state = "stopped";
1631
+ monitor.setStatus(runId, "failed");
1632
+ trajectoryState.trajectory.append({ kind: "settled", status: "stopped", model: monitor.findRun(runId)?.model, isolation });
1633
+ if (!runtime.sessionActive) {
1634
+ monitor.removeRun(runId);
1635
+ return;
1636
+ }
1637
+ finishRun(runId, "failed");
1638
+ },
1639
+ async (error) => {
1640
+ if (runtime.threads.get(runId)?.generation !== generation) return;
1641
+ // Queue-level crashes use the same settlement reservation as ordinary
1642
+ // results. A concurrent destructive stop may supersede it while slow
1643
+ // worktree finalization is running, in which case stop publishes once.
1644
+ if (thread.lifecycleOperation === "stop") return;
1645
+ const settlementVersion = ++thread.lifecycleVersion;
1646
+ thread.lifecycleOperation = "settle";
1647
+ const ownsSettlement = (): boolean =>
1648
+ runtime.threads.get(runId) === thread &&
1649
+ thread.generation === generation &&
1650
+ thread.lifecycleVersion === settlementVersion &&
1651
+ thread.lifecycleOperation === "settle" &&
1652
+ !thread.retired;
1653
+ try {
1654
+ const crashed: SingleResult = {
1655
+ ...dispatchFailedResult(pool.agent, control.getObjective(), error, thinkingLevel),
1656
+ runId,
1657
+ isolation,
1658
+ originalCwd,
1659
+ isolationCwd: executionCwd,
1660
+ forkedFromRunId: thread.forkedFromRunId,
1661
+ };
1662
+ await thread.finalizeIsolation(generation, crashed);
1663
+ if (!ownsSettlement()) return;
1664
+ thread.state = "failed";
1665
+ monitor.setStatus(runId, "failed");
1666
+ trajectoryState.trajectory.append({
1667
+ kind: "settled",
1668
+ status: "failed",
1669
+ model: crashed.model,
1670
+ isolation,
1671
+ ...(crashed.integrationStatus && crashed.integrationStatus !== "pending"
1672
+ ? { integrationStatus: crashed.integrationStatus }
1673
+ : {}),
1674
+ });
1675
+ finishRun(runId, "failed", { silent: true });
1676
+ runtime.registerRunResult(runId, crashed);
1677
+ runtime.runControllers.delete(runId);
1678
+ thread.queueController = undefined;
1679
+ if (!runtime.sessionActive || !ownsSettlement()) return;
1680
+ try {
1681
+ runCtx.ui.notify(`✗ ${agent.name} dispatch failed: ${crashed.errorMessage}`, "error");
1682
+ runtime.sendCompletionGroup([
1683
+ {
1684
+ agent: agent.name,
1685
+ block: formatCompletionBlock(crashed, runConfig.maxResultLines, runCtx.cwd),
1686
+ triggerTurn: true,
1687
+ },
1688
+ ]);
1689
+ runtime.completionBatcher.flush();
1690
+ } catch {
1691
+ /* a second delivery failure must not throw through the queue */
1692
+ }
1693
+ } finally {
1694
+ if (ownsSettlement()) thread.lifecycleOperation = undefined;
1695
+ }
1696
+ },
1697
+ );
1698
+ thread.queueController = queueController;
1699
+ thread.generationCompletion = runtime.backgroundQueue.waitForTask(queueController);
1700
+ runtime.runControllers.set(runId, queueController);
1701
+ return pending;
1702
+ };
1703
+
1704
+ // Sub-agents intentionally detach from the foreground turn. This makes the
1705
+ // editor available immediately; completion messages later wake the main agent.
1706
+ if (params.tasks && params.tasks.length > 0) {
1707
+ if (params.tasks.length > config.maxConcurrency) {
1708
+ return {
1709
+ content: [
1710
+ {
1711
+ type: "text",
1712
+ text: `Too many parallel tasks (${params.tasks.length}). Max is ${config.maxConcurrency} (configurable via /subagents-setup).`,
1713
+ },
1714
+ ],
1715
+ details: makeDetails("parallel", true)([]),
1716
+ };
1717
+ }
1718
+
1719
+ const results: SingleResult[] = [];
1720
+ // Preserve caller order (and deterministic completion batching) while
1721
+ // preparing each isolated filesystem before its queue entry can start.
1722
+ for (const item of params.tasks) {
1723
+ results.push(await startBackground(
1724
+ item.agent,
1725
+ item.task,
1726
+ item.cwd,
1727
+ item.vision === true,
1728
+ defaultIsolationMode("parallel", item.agent, item.isolation as IsolationMode | undefined),
1729
+ ));
1730
+ }
1731
+ const startedRuns = results.filter((result) => result.exitCode === -1);
1732
+ const started = startedRuns.length;
1733
+ const startedRefs = startedRuns.map((result) =>
1734
+ result.runId === undefined ? result.agent : `#${result.runId} ${result.agent}`,
1735
+ );
1736
+ const failureLines = results.flatMap((result, index) => {
1737
+ if (result.exitCode === -1) return [];
1738
+ const reason = getResultOutput(result).trim() || "unknown startup failure";
1739
+ return [
1740
+ `- tasks[${index}] (${params.tasks![index]!.agent}) failed to start: ${reason.replace(/\n/g, "\n ")}`,
1741
+ ];
1742
+ });
1743
+ if (started === 0) {
1744
+ // Pi marks custom-tool failures only when execute throws; returning an
1745
+ // `isError` property is still a successful AgentToolResult.
1746
+ throw new Error(`No background subagents were started.\n${failureLines.join("\n")}`);
1747
+ }
1748
+ const text = [
1749
+ `Started ${started} background subagent${started === 1 ? "" : "s"}: ${startedRefs.join(", ")}. Results will automatically resume the main agent when ready.`,
1750
+ ...(failureLines.length > 0
1751
+ ? [`${failureLines.length} task${failureLines.length === 1 ? "" : "s"} failed before launch:`, ...failureLines]
1752
+ : []),
1753
+ ].join("\n");
1754
+ return {
1755
+ content: [{ type: "text", text }],
1756
+ details: makeDetails("parallel", true)(results),
1757
+ terminate: true,
1758
+ };
1759
+ }
1760
+
1761
+ const result = await startBackground(
1762
+ params.agent as string,
1763
+ params.task as string,
1764
+ params.cwd,
1765
+ params.vision === true,
1766
+ defaultIsolationMode("single", params.agent as string, params.isolation as IsolationMode | undefined),
1767
+ );
1768
+ if (result.exitCode !== -1) {
1769
+ throw new Error(getResultOutput(result));
1770
+ }
1771
+ const runRef = result.runId === undefined ? result.agent : `#${result.runId} ${result.agent}`;
1772
+ return {
1773
+ content: [{ type: "text", text: `Started ${runRef} in the background. Its result will automatically resume the main agent when ready.` }],
1774
+ details: makeDetails("single", true)([result]),
1775
+ terminate: true,
1776
+ };
1777
+
1778
+ },
1779
+
1780
+ renderCall(args, theme) {
1781
+ if (args.tasks && args.tasks.length > 0) {
1782
+ let text = `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", `parallel (${args.tasks.length})`)}`;
1783
+ for (const t of args.tasks.slice(0, 4)) {
1784
+ const preview = formatTaskSummary(t.task, 48);
1785
+ const isolation = defaultIsolationMode("parallel", t.agent, t.isolation) === "worktree" ? " [worktree]" : "";
1786
+ text += `\n ${theme.fg("accent", t.agent)}${theme.fg("dim", isolation)} ${theme.fg("dim", preview)}`;
1787
+ }
1788
+ if (args.tasks.length > 4) text += `\n ${theme.fg("dim", `… +${args.tasks.length - 4} more`)}`;
1789
+ return new Text(text, 0, 0);
1790
+ }
1791
+ const task: string = args.task ?? "";
1792
+ const preview = formatTaskSummary(task, 60);
1793
+ const isolation = args.isolation === "worktree" ? " [worktree]" : "";
1794
+ return new Text(
1795
+ `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", args.agent ?? "?")}${theme.fg("dim", isolation)} ${theme.fg("dim", preview)}`,
1796
+ 0,
1797
+ 0,
1798
+ );
1799
+ },
1800
+
1801
+ renderResult(result, _options, theme) {
1802
+ const details = result.details as SubagentDetails | undefined;
1803
+ if (!details || details.results.length === 0) return new Text(theme.fg("dim", "(no output)"), 0, 0);
1804
+
1805
+ if (details.mode === "single") {
1806
+ const r = details.results[0];
1807
+ const pending = r.exitCode === -1;
1808
+ const icon = statusIcon(pending ? "running" : isFailedResult(r) ? "failed" : "done", theme);
1809
+ const usage = formatUsage(r.usage);
1810
+ const model = `${r.model ?? "?"}${r.modelFallbackFrom ? ` (pool fallback from ${r.modelFallbackFrom})` : ""}`;
1811
+ const isolation = r.isolation === "worktree" ? ` · worktree ${r.integrationStatus ?? "active"}` : "";
1812
+ const runId = r.runId === undefined ? "" : `${theme.fg("dim", `#${r.runId}`)} `;
1813
+ 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}` : ""}`)}`;
1814
+ return new Text(line, 0, 0);
1815
+ }
1816
+
1817
+ // Parallel mode: header + one compact line per agent
1818
+ const lines: string[] = [
1819
+ `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", `parallel (${details.results.length})`)}`,
1820
+ ];
1821
+ for (const r of details.results) {
1822
+ const pending = r.exitCode === -1;
1823
+ const icon = statusIcon(pending ? "running" : isFailedResult(r) ? "failed" : "done", theme);
1824
+ const usage = formatUsage(r.usage);
1825
+ const model = `${r.model ?? "?"}${r.modelFallbackFrom ? ` (pool fallback from ${r.modelFallbackFrom})` : ""}`;
1826
+ const isolation = r.isolation === "worktree" ? ` · worktree ${r.integrationStatus ?? "active"}` : "";
1827
+ const runId = r.runId === undefined ? "" : `${theme.fg("dim", `#${r.runId}`)} `;
1828
+ lines.push(` ${icon} ${runId}${theme.fg("accent", r.agent)} ${theme.fg("dim", `· ${model}${r.thinking ? ` · thinking ${r.thinking}` : ""}${isolation}${pending ? " · background" : ""}${usage ? ` · ${usage}` : ""}`)}`);
1829
+ }
1830
+ return new Text(lines.join("\n"), 0, 0);
1831
+ },
1832
+ });
1833
+ }