@oai404iao/pi-subagent 0.2.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.
@@ -0,0 +1,1393 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { join, relative, resolve } from "node:path";
3
+ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
4
+ import type { Model } from "@earendil-works/pi-ai";
5
+ import {
6
+ type AgentSessionEvent,
7
+ type AgentToolResult,
8
+ type AgentToolUpdateCallback,
9
+ type ExtensionAPI,
10
+ type ExtensionContext,
11
+ type ModelRegistry,
12
+ type ToolDefinition,
13
+ type CreateAgentSessionRuntimeFactory,
14
+ AgentSessionRuntime,
15
+ createAgentSessionFromServices,
16
+ createAgentSessionRuntime,
17
+ createAgentSessionServices,
18
+ defineTool,
19
+ getAgentDir,
20
+ ModelRuntime,
21
+ SessionManager,
22
+ } from "@earendil-works/pi-coding-agent";
23
+ import {
24
+ syncBundledAgents,
25
+ type AgentSyncResult,
26
+ unmodifiedManagedAgentNames,
27
+ } from "./agent-sync.ts";
28
+ import {
29
+ discoverAgents,
30
+ formatAgentCatalog,
31
+ type AgentDiscoveryResult,
32
+ } from "./agents.ts";
33
+ import { readPersistedCatalog } from "./catalog.ts";
34
+ import { DESCRIPTOR_CUSTOM_TYPE, foldDescriptor } from "./descriptor.ts";
35
+ import {
36
+ InterruptParameters,
37
+ ListAgentsParameters,
38
+ ReportParameters,
39
+ SendMessageParameters,
40
+ delegationParameters,
41
+ forkDelegationParameters,
42
+ } from "./schemas.ts";
43
+ import {
44
+ addUsage,
45
+ emptyUsage,
46
+ finalAssistantText,
47
+ finalStopReason,
48
+ formatToolArguments,
49
+ truncateUtf8,
50
+ } from "./result.ts";
51
+ import {
52
+ ForkProvider,
53
+ type PreparedChildSession,
54
+ type SessionView,
55
+ ProviderRegistry,
56
+ SpawnProvider,
57
+ } from "./providers.ts";
58
+ import { buildToolCeiling, resolveToolPolicy } from "./tool-policy.ts";
59
+ import {
60
+ snapshotAgent,
61
+ type AgentDefinition,
62
+ type CatalogChild,
63
+ type CatalogEntry,
64
+ type ControlDetails,
65
+ type DelegationDetails,
66
+ type ParentMessageDetails,
67
+ type SubagentDescriptor,
68
+ type SubagentMode,
69
+ type SubagentProviderName,
70
+ type SubagentRunResult,
71
+ type SubagentSettings,
72
+ type SubagentStopReason,
73
+ type TraceItem,
74
+ } from "./types.ts";
75
+
76
+ const REPORT_CUSTOM_TYPE = "pi-subagent/report";
77
+ const SETTLED_CUSTOM_TYPE = "pi-subagent/settled";
78
+ const MAX_TRACE_ITEMS = 100;
79
+ const MAX_TRACE_TEXT = 4000;
80
+ const BACKGROUND_CONTROL_TOOLS = new Set([
81
+ "send_message",
82
+ "interrupt_agent",
83
+ "list_agents",
84
+ ]);
85
+
86
+ const DELEGATION_SCOPE_PROMPT = [
87
+ "You are a delegated Pi subagent. Work only on the task assigned in this session.",
88
+ "Your permission and tool scope were fixed when you were created. If required access is unavailable,",
89
+ "state the limitation instead of repeatedly retrying or asking for interactive approval.",
90
+ ].join(" ");
91
+
92
+ const REPORT_PROMPT = [
93
+ "You have a `report` tool that sends a selected update to the agent that started you.",
94
+ "Call it with a self-contained answer before finishing, and earlier when a finding changes what the parent should do.",
95
+ "Reporting does not end this turn and does not prevent later follow-up messages.",
96
+ ].join(" ");
97
+
98
+ export interface DelegationInput {
99
+ agent: string;
100
+ description: string;
101
+ prompt: string;
102
+ run_in_background?: boolean;
103
+ }
104
+
105
+ export type DelegationOutcome =
106
+ | { kind: "continuable"; details: DelegationDetails }
107
+ | { kind: "foreground"; details: DelegationDetails; result: SubagentRunResult };
108
+
109
+ interface ParentRef {
110
+ id: string;
111
+ depth: number;
112
+ cwd: string;
113
+ sessionManager: SessionView;
114
+ modelRuntime: ModelRuntime;
115
+ model: Model<any> | undefined;
116
+ thinkingLevel: ThinkingLevel;
117
+ projectTrusted: boolean;
118
+ activation?: Activation;
119
+ deliver(
120
+ customType: string,
121
+ content: string,
122
+ details: ParentMessageDetails,
123
+ delivery: "wakeup" | "quiet",
124
+ ): Promise<void>;
125
+ }
126
+
127
+ interface Activation {
128
+ id: string;
129
+ epochId: string;
130
+ descriptor: SubagentDescriptor;
131
+ parent: ParentRef;
132
+ runtime: AgentSessionRuntime;
133
+ seedMessageCount: number;
134
+ epochMessageStart: number;
135
+ status: DelegationDetails["status"];
136
+ trace: TraceItem[];
137
+ streamedText: string;
138
+ usage: ReturnType<typeof emptyUsage>;
139
+ ownedChildren: Set<string>;
140
+ currentRun?: Promise<SubagentRunResult>;
141
+ pendingSettlement?: SubagentRunResult;
142
+ unsubscribe?: () => void;
143
+ onUpdate?: (details: DelegationDetails) => void;
144
+ published: boolean;
145
+ suppressSettlement: boolean;
146
+ finalizing: boolean;
147
+ finalizePromise?: Promise<void>;
148
+ disposed: boolean;
149
+ lastError?: string;
150
+ }
151
+
152
+ interface CreateActivationOptions {
153
+ parent: ParentRef;
154
+ descriptor: SubagentDescriptor;
155
+ prepared: PreparedChildSession;
156
+ isNew: boolean;
157
+ onUpdate?: (details: DelegationDetails) => void;
158
+ }
159
+
160
+ interface CatalogRecord {
161
+ id: string;
162
+ descriptor: SubagentDescriptor;
163
+ sessionFile?: string;
164
+ active?: Activation;
165
+ }
166
+
167
+ interface CoordinatorCatalog {
168
+ records: CatalogRecord[];
169
+ diagnostics: CatalogEntry[];
170
+ }
171
+
172
+ function runtimeFromRegistry(registry: ModelRegistry): ModelRuntime {
173
+ for (const value of Object.values(registry as unknown as Record<string, unknown>)) {
174
+ if (value instanceof ModelRuntime) return value;
175
+ if (
176
+ value &&
177
+ typeof value === "object" &&
178
+ typeof (value as ModelRuntime).getModel === "function" &&
179
+ typeof (value as ModelRuntime).streamSimple === "function" &&
180
+ typeof (value as ModelRuntime).getAuth === "function"
181
+ ) {
182
+ return value as ModelRuntime;
183
+ }
184
+ }
185
+ throw new Error(
186
+ "pi-subagent could not access Pi's active ModelRuntime. This extension requires Pi 0.83 or newer.",
187
+ );
188
+ }
189
+
190
+ function errorText(error: unknown): string {
191
+ return error instanceof Error ? error.message : String(error);
192
+ }
193
+
194
+ function stopReasonHeadline(reason: SubagentStopReason): string {
195
+ switch (reason) {
196
+ case "completed":
197
+ return "finished";
198
+ case "aborted":
199
+ return "was interrupted";
200
+ case "max-tokens":
201
+ return "ran out of output tokens";
202
+ case "error":
203
+ return "failed";
204
+ }
205
+ }
206
+
207
+ function makeRuntimeSettings(descriptor: SubagentDescriptor): SubagentSettings {
208
+ return {
209
+ agentScope: descriptor.runtime.agentScope,
210
+ syncBundledAgents: descriptor.runtime.syncBundledAgents,
211
+ maxDepth: descriptor.runtime.maxDepth,
212
+ enableRunInBackground: descriptor.runtime.enableRunInBackground,
213
+ defaultBackground: descriptor.runtime.defaultBackground,
214
+ reportDelivery: descriptor.runtime.reportDelivery,
215
+ inheritExtensions: descriptor.runtime.inheritExtensions,
216
+ maxOutputBytes: descriptor.runtime.maxOutputBytes,
217
+ };
218
+ }
219
+
220
+ function isPathInside(parent: string, child: string): boolean {
221
+ const rel = relative(resolve(parent), resolve(child));
222
+ return rel === "" || (!rel.startsWith("..") && !rel.startsWith("/"));
223
+ }
224
+
225
+ export class SubagentCoordinator {
226
+ private readonly providers = new ProviderRegistry();
227
+ private readonly active = new Map<string, Activation>();
228
+ private agentSyncResult: AgentSyncResult | undefined;
229
+ private draining = false;
230
+
231
+ constructor(
232
+ private readonly pi: ExtensionAPI,
233
+ private readonly bundledAgentsDir: string,
234
+ private readonly packageRoot: string,
235
+ private readonly agentDir: string = getAgentDir(),
236
+ ) {
237
+ this.providers.register(new SpawnProvider());
238
+ this.providers.register(new ForkProvider());
239
+ }
240
+
241
+ synchronizeBundledAgents(): AgentSyncResult {
242
+ this.agentSyncResult = syncBundledAgents({
243
+ bundledDir: this.bundledAgentsDir,
244
+ agentDir: this.agentDir,
245
+ packageRoot: this.packageRoot,
246
+ });
247
+ return this.agentSyncResult;
248
+ }
249
+
250
+ getUserAgentsDir(): string {
251
+ return join(this.agentDir, "agents");
252
+ }
253
+
254
+ discoverAvailableAgents(
255
+ cwd: string,
256
+ settings: SubagentSettings,
257
+ projectTrusted: boolean,
258
+ ): AgentDiscoveryResult {
259
+ if (settings.syncBundledAgents && !this.agentSyncResult) {
260
+ this.synchronizeBundledAgents();
261
+ }
262
+ const excludeUserAgentNames = settings.syncBundledAgents
263
+ ? undefined
264
+ : unmodifiedManagedAgentNames(this.agentDir);
265
+ return discoverAgents({
266
+ cwd,
267
+ scope: settings.agentScope,
268
+ projectTrusted,
269
+ bundledDir: this.bundledAgentsDir,
270
+ agentDir: this.agentDir,
271
+ includeBundled: !settings.syncBundledAgents && settings.agentScope !== "project",
272
+ excludeUserAgentNames,
273
+ });
274
+ }
275
+
276
+ async parentFromContext(ctx: ExtensionContext): Promise<ParentRef> {
277
+ const folded = foldDescriptor(ctx.sessionManager.getEntries());
278
+ const descriptor = folded.kind === "valid" ? folded.descriptor : undefined;
279
+ const modelRuntime = runtimeFromRegistry(ctx.modelRegistry);
280
+ return {
281
+ id: ctx.sessionManager.getSessionId(),
282
+ depth: descriptor?.depth ?? 0,
283
+ cwd: ctx.cwd,
284
+ sessionManager: ctx.sessionManager,
285
+ modelRuntime,
286
+ model: ctx.model,
287
+ thinkingLevel: ctx.thinkingLevel ?? "off",
288
+ projectTrusted: ctx.isProjectTrusted(),
289
+ deliver: async (customType, content, details, delivery) => {
290
+ const options =
291
+ delivery === "wakeup"
292
+ ? { triggerTurn: true, deliverAs: "followUp" as const }
293
+ : ctx.isIdle()
294
+ ? { triggerTurn: false }
295
+ : { triggerTurn: false, deliverAs: "nextTurn" as const };
296
+ this.pi.sendMessage({ customType, content, display: true, details }, options);
297
+ },
298
+ };
299
+ }
300
+
301
+ async delegate(
302
+ parent: ParentRef,
303
+ providerName: SubagentProviderName,
304
+ input: DelegationInput,
305
+ settings: SubagentSettings,
306
+ signal?: AbortSignal,
307
+ onUpdate?: (details: DelegationDetails) => void,
308
+ agentDiscovery?: AgentDiscoveryResult,
309
+ ): Promise<DelegationOutcome> {
310
+ if (this.draining) throw new Error("pi-subagent is shutting down; no new delegation was accepted");
311
+ const provider = this.providers.get(providerName);
312
+ if (
313
+ providerName === "spawn" &&
314
+ !settings.enableRunInBackground &&
315
+ input.run_in_background === true
316
+ ) {
317
+ throw new Error(
318
+ "run_in_background is disabled by pi-subagent foreground-only mode (enableRunInBackground: false)",
319
+ );
320
+ }
321
+ const runInBackground =
322
+ providerName === "spawn" && settings.enableRunInBackground
323
+ ? (input.run_in_background ?? settings.defaultBackground)
324
+ : false;
325
+ const mode: SubagentMode = runInBackground ? "continuable" : "one-shot";
326
+ if (mode === "continuable" && !provider.supportsContinuable) {
327
+ throw new Error(`subagent provider "${provider.name}" does not support continuable children`);
328
+ }
329
+ if (mode === "continuable" && !parent.sessionManager.getSessionFile()) {
330
+ throw new Error(
331
+ "continuable subagents require a persisted parent session; set run_in_background to false",
332
+ );
333
+ }
334
+ if (mode === "continuable" && parent.activation?.descriptor.mode === "one-shot") {
335
+ throw new Error(
336
+ "a one-shot child cannot leave a continuable descendant behind; set run_in_background to false",
337
+ );
338
+ }
339
+
340
+ const depth = parent.depth + 1;
341
+ if (!Number.isSafeInteger(depth)) throw new Error("subagent child depth exceeds the safe-integer range");
342
+ if (depth > settings.maxDepth) {
343
+ throw new Error(`subagent depth ${depth} exceeds maxDepth ${settings.maxDepth}`);
344
+ }
345
+
346
+ const discovery =
347
+ agentDiscovery ??
348
+ this.discoverAvailableAgents(
349
+ parent.cwd,
350
+ settings,
351
+ parent.projectTrusted,
352
+ );
353
+ const agent = discovery.agents.find((candidate) => candidate.name === input.agent);
354
+ if (!agent) {
355
+ const diagnosticText =
356
+ discovery.diagnostics.length > 0 ? `\nDiagnostics:\n${discovery.diagnostics.join("\n")}` : "";
357
+ throw new Error(
358
+ `unknown subagent "${input.agent}". Available agents:\n${formatAgentCatalog(discovery.agents)}${diagnosticText}`,
359
+ );
360
+ }
361
+
362
+ const model = this.resolveModel(parent, agent);
363
+ const thinkingLevel = agent.thinking ?? parent.thinkingLevel;
364
+ const prepared = await provider.prepare(parent, mode);
365
+ const descriptor: SubagentDescriptor = {
366
+ version: 1,
367
+ mode,
368
+ provider: providerName,
369
+ label: input.description.trim(),
370
+ parentSessionId: parent.id,
371
+ ...(parent.sessionManager.getSessionFile()
372
+ ? { parentSessionFile: parent.sessionManager.getSessionFile() }
373
+ : {}),
374
+ depth,
375
+ cwd: parent.cwd,
376
+ createdAt: new Date().toISOString(),
377
+ agent: snapshotAgent(agent),
378
+ model: { provider: model.provider, id: model.id },
379
+ thinkingLevel,
380
+ runtime: {
381
+ agentScope: settings.agentScope,
382
+ syncBundledAgents: settings.syncBundledAgents,
383
+ maxDepth: settings.maxDepth,
384
+ enableRunInBackground: settings.enableRunInBackground,
385
+ defaultBackground: settings.defaultBackground,
386
+ reportDelivery: settings.reportDelivery,
387
+ inheritExtensions: settings.inheritExtensions,
388
+ maxOutputBytes: settings.maxOutputBytes,
389
+ },
390
+ };
391
+
392
+ let activation: Activation | undefined;
393
+ try {
394
+ activation = await this.createActivation({
395
+ parent,
396
+ descriptor,
397
+ prepared,
398
+ isNew: true,
399
+ onUpdate,
400
+ });
401
+ if (mode === "continuable" && parent.activation) {
402
+ parent.activation.ownedChildren.add(activation.id);
403
+ }
404
+ const started = this.startPrompt(activation, input.prompt, signal, mode === "continuable");
405
+ await started.accepted;
406
+ if (mode === "continuable") {
407
+ activation.onUpdate = undefined;
408
+ return { kind: "continuable", details: this.detailsOf(activation) };
409
+ }
410
+
411
+ const result = await started.result;
412
+ const details = this.detailsOf(activation, result);
413
+ activation.onUpdate = undefined;
414
+ await this.disposeActivation(activation);
415
+ return { kind: "foreground", details, result };
416
+ } catch (error) {
417
+ if (activation && !activation.published) {
418
+ activation.suppressSettlement = true;
419
+ await this.rollbackActivation(activation, prepared);
420
+ } else if (!activation) {
421
+ await prepared.rollback();
422
+ }
423
+ throw error;
424
+ }
425
+ }
426
+
427
+ async sendMessage(parent: ParentRef, childId: string, message: string, signal?: AbortSignal): Promise<void> {
428
+ if (this.draining) throw new Error("pi-subagent is shutting down; message was not delivered");
429
+ let activation = this.active.get(childId);
430
+ let coldPrepared: PreparedChildSession | undefined;
431
+ if (activation?.finalizing && activation.finalizePromise) {
432
+ await activation.finalizePromise;
433
+ activation = undefined;
434
+ }
435
+
436
+ if (!activation) {
437
+ const located = await this.findPersistedChild(parent, childId);
438
+ if (!located) throw new Error(`unknown subagent: ${childId}; message was not delivered`);
439
+ if (located.descriptor.mode !== "continuable") {
440
+ throw new Error(`subagent ${childId} is one-shot and cannot accept follow-up messages`);
441
+ }
442
+ this.assertDirectParent(parent, located.descriptor);
443
+ const manager = SessionManager.open(
444
+ located.sessionFile,
445
+ parent.sessionManager.getSessionDir(),
446
+ parent.cwd,
447
+ );
448
+ coldPrepared = {
449
+ sessionManager: manager,
450
+ seedMessageCount: manager.buildSessionContext().messages.length,
451
+ rollback: () => Promise.resolve(),
452
+ };
453
+ activation = await this.createActivation({
454
+ parent,
455
+ descriptor: located.descriptor,
456
+ prepared: coldPrepared,
457
+ isNew: false,
458
+ });
459
+ if (parent.activation) parent.activation.ownedChildren.add(activation.id);
460
+ } else {
461
+ this.assertDirectParent(parent, activation.descriptor);
462
+ }
463
+
464
+ const session = activation.runtime.session;
465
+ if (activation.currentRun || session.isStreaming) {
466
+ if (signal?.aborted) throw signal.reason ?? new Error("message delivery aborted");
467
+ await session.followUp(message);
468
+ return;
469
+ }
470
+
471
+ try {
472
+ const started = this.startPrompt(activation, message, signal, true);
473
+ await started.accepted;
474
+ } catch (error) {
475
+ if (coldPrepared && !activation.published) {
476
+ activation.suppressSettlement = true;
477
+ await this.rollbackActivation(activation, coldPrepared);
478
+ }
479
+ throw error;
480
+ }
481
+ }
482
+
483
+ async interrupt(parent: ParentRef, targetId: string): Promise<void> {
484
+ const target = this.active.get(targetId);
485
+ if (!target) return;
486
+ if (!(await this.isDescendantOf(parent, target.descriptor))) {
487
+ throw new Error(`subagent ${targetId} is not a live descendant of ${parent.id}`);
488
+ }
489
+ target.status = "failed";
490
+ void target.runtime.session.abort().catch((error) => {
491
+ target.lastError = errorText(error);
492
+ });
493
+ }
494
+
495
+ async list(parent: ParentRef, scope: "children" | "descendants"): Promise<CatalogEntry[]> {
496
+ const catalog = await this.catalogRecords(parent);
497
+ const records = catalog.records;
498
+ const byId = new Map(records.map((record) => [record.id, record]));
499
+ const children: CatalogChild[] = [];
500
+ for (const record of records) {
501
+ if (record.descriptor.mode !== "continuable") continue;
502
+ const distance = this.distanceFrom(parent.id, record.descriptor, byId);
503
+ if (distance === undefined || (scope === "children" && distance !== 1)) continue;
504
+ children.push({
505
+ kind: "child",
506
+ id: record.id,
507
+ parentId: record.descriptor.parentSessionId,
508
+ depth: distance,
509
+ descriptor: record.descriptor,
510
+ ...(record.sessionFile ? { sessionFile: record.sessionFile } : {}),
511
+ status: record.active
512
+ ? record.active.currentRun || record.active.runtime.session.isStreaming
513
+ ? "running"
514
+ : "idle"
515
+ : "ready",
516
+ });
517
+ }
518
+ children.sort(
519
+ (left, right) =>
520
+ left.descriptor.createdAt.localeCompare(right.descriptor.createdAt) || left.id.localeCompare(right.id),
521
+ );
522
+ const parentFile = parent.sessionManager.getSessionFile();
523
+ const diagnostics = parentFile
524
+ ? catalog.diagnostics.filter(
525
+ (entry) => entry.kind === "diagnostic" && entry.parentSessionFile === parentFile,
526
+ )
527
+ : [];
528
+ return [...children, ...diagnostics];
529
+ }
530
+
531
+ async report(child: Activation, output: string): Promise<void> {
532
+ if (child.descriptor.mode !== "continuable") {
533
+ throw new Error("report is available only to continuable subagents");
534
+ }
535
+ const truncated = truncateUtf8(output, child.descriptor.runtime.maxOutputBytes);
536
+ const content = `Background subagent ${child.id} reported:\n\n${truncated.text}${
537
+ truncated.truncated ? `\n\n[Report truncated; ${truncated.omittedBytes} bytes omitted.]` : ""
538
+ }`;
539
+ await child.parent.deliver(
540
+ REPORT_CUSTOM_TYPE,
541
+ content,
542
+ {
543
+ kind: "report",
544
+ childId: child.id,
545
+ label: child.descriptor.label,
546
+ ...(truncated.truncated ? { truncated: true } : {}),
547
+ },
548
+ child.descriptor.runtime.reportDelivery,
549
+ );
550
+ }
551
+
552
+ async shutdown(): Promise<void> {
553
+ if (this.draining) return;
554
+ this.draining = true;
555
+ const activations = [...this.active.values()];
556
+ for (const activation of activations) activation.suppressSettlement = true;
557
+ await Promise.allSettled(
558
+ activations.map(async (activation) => {
559
+ if (!activation.runtime.session.isIdle) await activation.runtime.session.abort();
560
+ }),
561
+ );
562
+ for (const activation of activations.sort((left, right) => right.descriptor.depth - left.descriptor.depth)) {
563
+ await this.disposeActivation(activation).catch(() => {});
564
+ }
565
+ }
566
+
567
+ createChildToolDefinitions(
568
+ getActivation: () => Activation,
569
+ enableRunInBackground = true,
570
+ defaultBackground = true,
571
+ agentDiscovery?: AgentDiscoveryResult,
572
+ ): ToolDefinition[] {
573
+ const agentNames = agentDiscovery?.agents.map((agent) => agent.name);
574
+ const assertBackgroundControlEnabled = (toolName: string): void => {
575
+ if (!enableRunInBackground) {
576
+ throw new Error(`tool "${toolName}" is unavailable in foreground-only mode`);
577
+ }
578
+ };
579
+ const update =
580
+ (onUpdate: AgentToolUpdateCallback<DelegationDetails> | undefined) => (details: DelegationDetails) => {
581
+ onUpdate?.({
582
+ content: [{ type: "text", text: this.progressText(details) }],
583
+ details,
584
+ });
585
+ };
586
+
587
+ const spawn = defineTool({
588
+ name: "subagent",
589
+ label: "Subagent",
590
+ description:
591
+ "Delegate a standalone task to a fresh child in an isolated session. " +
592
+ (!enableRunInBackground
593
+ ? "This foreground-only instance always waits for the result."
594
+ : defaultBackground
595
+ ? "It runs in the background by default and returns a durable id."
596
+ : "It waits for the result by default; background mode returns a durable id."),
597
+ parameters: delegationParameters(enableRunInBackground, agentNames),
598
+ execute: async (_id, params, signal, onUpdate) => {
599
+ const activation = getActivation();
600
+ const outcome = await this.delegate(
601
+ this.parentForActivation(activation),
602
+ "spawn",
603
+ params,
604
+ makeRuntimeSettings(activation.descriptor),
605
+ signal,
606
+ update(onUpdate),
607
+ agentDiscovery,
608
+ );
609
+ return this.outcomeToolResult(outcome);
610
+ },
611
+ });
612
+
613
+ const fork = defineTool({
614
+ name: "subagent_fork",
615
+ label: "Subagent Fork",
616
+ description:
617
+ "Delegate a one-shot task to a child seeded with completed turns from this conversation.",
618
+ parameters: forkDelegationParameters(agentNames),
619
+ execute: async (_id, params, signal, onUpdate) => {
620
+ const activation = getActivation();
621
+ const outcome = await this.delegate(
622
+ this.parentForActivation(activation),
623
+ "fork",
624
+ params,
625
+ makeRuntimeSettings(activation.descriptor),
626
+ signal,
627
+ update(onUpdate),
628
+ agentDiscovery,
629
+ );
630
+ return this.outcomeToolResult(outcome);
631
+ },
632
+ });
633
+
634
+ const send = defineTool({
635
+ name: "send_message",
636
+ label: "Send Message",
637
+ description:
638
+ "Queue a message as a direct continuable child's next FIFO turn. This returns acceptance, not the child's answer.",
639
+ parameters: SendMessageParameters,
640
+ execute: async (_id, params, signal) => {
641
+ assertBackgroundControlEnabled("send_message");
642
+ const activation = getActivation();
643
+ await this.sendMessage(
644
+ this.parentForActivation(activation),
645
+ params.subagent_id,
646
+ params.message,
647
+ signal,
648
+ );
649
+ return {
650
+ content: [
651
+ {
652
+ type: "text",
653
+ text: `message queued as the next turn for subagent ${params.subagent_id}`,
654
+ },
655
+ ],
656
+ details: { kind: "control", action: "send", id: params.subagent_id } satisfies ControlDetails,
657
+ };
658
+ },
659
+ });
660
+
661
+ const interrupt = defineTool({
662
+ name: "interrupt_agent",
663
+ label: "Interrupt Agent",
664
+ description:
665
+ "Request cancellation of a live descendant's current turn. Its durable session remains available.",
666
+ parameters: InterruptParameters,
667
+ execute: async (_id, params) => {
668
+ assertBackgroundControlEnabled("interrupt_agent");
669
+ const activation = getActivation();
670
+ await this.interrupt(this.parentForActivation(activation), params.agent_id);
671
+ return {
672
+ content: [{ type: "text", text: `interrupt requested for agent ${params.agent_id}` }],
673
+ details: { kind: "control", action: "interrupt", id: params.agent_id } satisfies ControlDetails,
674
+ };
675
+ },
676
+ });
677
+
678
+ const list = defineTool({
679
+ name: "list_agents",
680
+ label: "List Agents",
681
+ description:
682
+ "List direct continuable children or all descendants as running, idle, or ready (persisted and resumable).",
683
+ parameters: ListAgentsParameters,
684
+ execute: async (_id, params) => {
685
+ assertBackgroundControlEnabled("list_agents");
686
+ const activation = getActivation();
687
+ const entries = await this.list(
688
+ this.parentForActivation(activation),
689
+ params.scope ?? "children",
690
+ );
691
+ return {
692
+ content: [{ type: "text", text: this.formatCatalog(entries, params.scope ?? "children") }],
693
+ details: { kind: "control", action: "list" } satisfies ControlDetails,
694
+ };
695
+ },
696
+ });
697
+
698
+ const report = defineTool({
699
+ name: "report",
700
+ label: "Report",
701
+ description:
702
+ "Send a self-contained update to the agent that started you. This does not end the current turn.",
703
+ parameters: ReportParameters,
704
+ execute: async (_id, params) => {
705
+ const activation = getActivation();
706
+ await this.report(activation, params.output);
707
+ return {
708
+ content: [{ type: "text", text: `report accepted by the agent that started you` }],
709
+ details: { kind: "control", action: "report", id: activation.id } satisfies ControlDetails,
710
+ };
711
+ },
712
+ });
713
+
714
+ return [spawn, fork, send, interrupt, list, report];
715
+ }
716
+
717
+ outcomeToolResult(outcome: DelegationOutcome): AgentToolResult<DelegationDetails> {
718
+ if (outcome.kind === "continuable") {
719
+ return {
720
+ content: [{ type: "text", text: `started subagent ${outcome.details.id}` }],
721
+ details: outcome.details,
722
+ };
723
+ }
724
+ if (outcome.result.stopReason !== "completed") {
725
+ const partial = outcome.result.output.trim()
726
+ ? `\nPartial output before the run ended:\n${outcome.result.output}`
727
+ : "";
728
+ throw new Error(
729
+ `subagent ${stopReasonHeadline(outcome.result.stopReason)} (${outcome.result.stopReason})${partial}`,
730
+ );
731
+ }
732
+ return {
733
+ content: [{ type: "text", text: outcome.result.output || "(no output)" }],
734
+ details: outcome.details,
735
+ usage: outcome.result.usage,
736
+ };
737
+ }
738
+
739
+ formatCatalog(entries: CatalogEntry[], scope: "children" | "descendants"): string {
740
+ if (entries.length === 0) return "(no subagents)";
741
+ return entries
742
+ .map((entry) => {
743
+ if (entry.kind === "diagnostic") return `${entry.id} [diagnostic: ${entry.reason}]`;
744
+ const location =
745
+ scope === "descendants" ? ` parent=${entry.parentId} depth=${entry.depth}` : "";
746
+ return `${entry.id} [${entry.status}]${location} — ${entry.descriptor.label} (${entry.descriptor.agent.name})`;
747
+ })
748
+ .join("\n");
749
+ }
750
+
751
+ private resolveModel(parent: ParentRef, agent: AgentDefinition): Model<any> {
752
+ if (!agent.model) {
753
+ if (!parent.model) throw new Error("no parent model is selected for the subagent");
754
+ return parent.model;
755
+ }
756
+ const slash = agent.model.indexOf("/");
757
+ if (slash > 0) {
758
+ const provider = agent.model.slice(0, slash);
759
+ const id = agent.model.slice(slash + 1);
760
+ const resolved = parent.modelRuntime.getModel(provider, id);
761
+ if (!resolved) throw new Error(`agent ${agent.name} references unknown model ${agent.model}`);
762
+ return resolved;
763
+ }
764
+ const sameProvider = parent.model
765
+ ? parent.modelRuntime.getModel(parent.model.provider, agent.model)
766
+ : undefined;
767
+ if (sameProvider) return sameProvider;
768
+ const matches = parent.modelRuntime.getModels().filter((model) => model.id === agent.model);
769
+ if (matches.length === 1) return matches[0];
770
+ if (matches.length === 0) throw new Error(`agent ${agent.name} references unknown model ${agent.model}`);
771
+ throw new Error(
772
+ `agent ${agent.name} model "${agent.model}" is ambiguous; use provider/model in its frontmatter`,
773
+ );
774
+ }
775
+
776
+ private async createActivation(options: CreateActivationOptions): Promise<Activation> {
777
+ let activation: Activation | undefined;
778
+ const descriptor = options.descriptor;
779
+ const agentDiscovery = this.discoverAvailableAgents(
780
+ descriptor.cwd,
781
+ makeRuntimeSettings(descriptor),
782
+ options.parent.projectTrusted,
783
+ );
784
+ const customTools = this.createChildToolDefinitions(
785
+ () => {
786
+ if (!activation) throw new Error("subagent activation is not published yet");
787
+ return activation;
788
+ },
789
+ descriptor.runtime.enableRunInBackground,
790
+ descriptor.runtime.defaultBackground,
791
+ agentDiscovery,
792
+ );
793
+ const model =
794
+ options.parent.modelRuntime.getModel(descriptor.model.provider, descriptor.model.id) ??
795
+ (options.parent.model?.provider === descriptor.model.provider &&
796
+ options.parent.model.id === descriptor.model.id
797
+ ? options.parent.model
798
+ : undefined);
799
+ if (!model) {
800
+ throw new Error(
801
+ `cannot materialize subagent: model ${descriptor.model.provider}/${descriptor.model.id} is unavailable`,
802
+ );
803
+ }
804
+
805
+ const appendSystemPrompt = [
806
+ descriptor.agent.systemPrompt,
807
+ DELEGATION_SCOPE_PROMPT,
808
+ ...(descriptor.mode === "continuable" ? [REPORT_PROMPT] : []),
809
+ ];
810
+ const mandatoryTools = descriptor.mode === "continuable" ? ["report"] : [];
811
+ const deniedTools = descriptor.mode === "one-shot" ? ["report"] : [];
812
+ let toolCeiling: string[] | undefined;
813
+ try {
814
+ toolCeiling = buildToolCeiling({
815
+ requested: descriptor.agent.tools,
816
+ mandatory: mandatoryTools,
817
+ denied: deniedTools,
818
+ });
819
+ } catch (error) {
820
+ throw new Error(
821
+ `agent ${descriptor.agent.name} tool policy is invalid: ${
822
+ error instanceof Error ? error.message : String(error)
823
+ }`,
824
+ );
825
+ }
826
+ const createRuntime: CreateAgentSessionRuntimeFactory = async ({
827
+ cwd,
828
+ sessionManager,
829
+ sessionStartEvent,
830
+ }) => {
831
+ const services = await createAgentSessionServices({
832
+ cwd,
833
+ agentDir: this.agentDir,
834
+ modelRuntime: options.parent.modelRuntime,
835
+ resourceLoaderOptions: {
836
+ noExtensions: !descriptor.runtime.inheritExtensions,
837
+ noThemes: true,
838
+ appendSystemPrompt,
839
+ extensionsOverride: (base) => ({
840
+ ...base,
841
+ extensions: base.extensions.filter(
842
+ (extension) => !isPathInside(this.packageRoot, extension.resolvedPath),
843
+ ),
844
+ }),
845
+ },
846
+ });
847
+ const created = await createAgentSessionFromServices({
848
+ services,
849
+ sessionManager,
850
+ sessionStartEvent,
851
+ model,
852
+ thinkingLevel: descriptor.thinkingLevel,
853
+ customTools,
854
+ ...(toolCeiling !== undefined ? { tools: toolCeiling } : {}),
855
+ });
856
+ return {
857
+ ...created,
858
+ services,
859
+ diagnostics: [
860
+ ...services.diagnostics,
861
+ ...created.extensionsResult.errors.map((error) => ({
862
+ type: "error" as const,
863
+ message: `${error.path}: ${error.error}`,
864
+ })),
865
+ ],
866
+ };
867
+ };
868
+
869
+ const runtime = await createAgentSessionRuntime(createRuntime, {
870
+ cwd: descriptor.cwd,
871
+ agentDir: this.agentDir,
872
+ sessionManager: options.prepared.sessionManager,
873
+ });
874
+ try {
875
+ const fatalDiagnostics = runtime.diagnostics.filter((diagnostic) => diagnostic.type === "error");
876
+ if (fatalDiagnostics.length > 0) {
877
+ throw new Error(fatalDiagnostics.map((diagnostic) => diagnostic.message).join("; "));
878
+ }
879
+ await runtime.session.bindExtensions({ mode: "print" });
880
+ try {
881
+ const policy = resolveToolPolicy({
882
+ requested: descriptor.agent.tools,
883
+ mandatory: mandatoryTools,
884
+ denied: deniedTools,
885
+ registered: runtime.session.getAllTools().map((tool) => tool.name),
886
+ active: runtime.session.getActiveToolNames(),
887
+ });
888
+ const activeTools = policy.activeTools.filter((tool) => {
889
+ if (
890
+ agentDiscovery.agents.length === 0 &&
891
+ (tool === "subagent" || tool === "subagent_fork")
892
+ ) {
893
+ return false;
894
+ }
895
+ if (
896
+ !descriptor.runtime.enableRunInBackground &&
897
+ BACKGROUND_CONTROL_TOOLS.has(tool)
898
+ ) {
899
+ return false;
900
+ }
901
+ return true;
902
+ });
903
+ runtime.session.setActiveToolsByName(activeTools);
904
+ } catch (error) {
905
+ throw new Error(
906
+ `agent ${descriptor.agent.name} tool policy could not be satisfied: ${
907
+ error instanceof Error ? error.message : String(error)
908
+ }`,
909
+ );
910
+ }
911
+
912
+ const activeModel = runtime.session.model;
913
+ if (!activeModel) throw new Error("child runtime has no selected model");
914
+ descriptor.model = { provider: activeModel.provider, id: activeModel.id };
915
+ descriptor.thinkingLevel = runtime.session.thinkingLevel;
916
+ const persistedContext = runtime.session.sessionManager.buildSessionContext();
917
+ if (
918
+ persistedContext.model?.provider !== descriptor.model.provider ||
919
+ persistedContext.model.modelId !== descriptor.model.id
920
+ ) {
921
+ runtime.session.sessionManager.appendModelChange(
922
+ descriptor.model.provider,
923
+ descriptor.model.id,
924
+ );
925
+ }
926
+ if (persistedContext.thinkingLevel !== descriptor.thinkingLevel) {
927
+ runtime.session.sessionManager.appendThinkingLevelChange(descriptor.thinkingLevel);
928
+ }
929
+ if (options.isNew) {
930
+ runtime.session.sessionManager.appendCustomEntry(
931
+ DESCRIPTOR_CUSTOM_TYPE,
932
+ structuredClone(descriptor),
933
+ );
934
+ runtime.session.sessionManager.appendSessionInfo(`[subagent] ${descriptor.label}`);
935
+ }
936
+
937
+ activation = {
938
+ id: runtime.session.sessionId,
939
+ epochId: randomUUID(),
940
+ descriptor,
941
+ parent: options.parent,
942
+ runtime,
943
+ seedMessageCount: options.prepared.seedMessageCount,
944
+ epochMessageStart: runtime.session.messages.length,
945
+ status: "starting",
946
+ trace: [],
947
+ streamedText: "",
948
+ usage: emptyUsage(),
949
+ ownedChildren: new Set(),
950
+ onUpdate: options.onUpdate,
951
+ published: false,
952
+ suppressSettlement: false,
953
+ finalizing: false,
954
+ disposed: false,
955
+ };
956
+ activation.unsubscribe = runtime.session.subscribe((event) => this.observe(activation!, event));
957
+ this.active.set(activation.id, activation);
958
+ return activation;
959
+ } catch (error) {
960
+ await runtime.dispose().catch(() => {});
961
+ throw error;
962
+ }
963
+ }
964
+
965
+ private startPrompt(
966
+ activation: Activation,
967
+ prompt: string,
968
+ signal: AbortSignal | undefined,
969
+ detachAtAcceptance: boolean,
970
+ ): { accepted: Promise<void>; result: Promise<SubagentRunResult> } {
971
+ if (activation.currentRun) throw new Error(`subagent ${activation.id} is already running`);
972
+ if (signal?.aborted) throw signal.reason ?? new Error("subagent start aborted");
973
+ activation.pendingSettlement = undefined;
974
+ activation.status = "running";
975
+ this.emitUpdate(activation);
976
+
977
+ let resolveAccepted!: () => void;
978
+ let rejectAccepted!: (error: Error) => void;
979
+ let acceptedSettled = false;
980
+ const accepted = new Promise<void>((resolvePromise, rejectPromise) => {
981
+ resolveAccepted = resolvePromise;
982
+ rejectAccepted = rejectPromise;
983
+ });
984
+ const abort = () => {
985
+ void activation.runtime.session.abort().catch(() => {});
986
+ };
987
+ if (signal) signal.addEventListener("abort", abort, { once: true });
988
+
989
+ const core = (async (): Promise<SubagentRunResult> => {
990
+ try {
991
+ await activation.runtime.session.prompt(prompt, {
992
+ preflightResult: (success) => {
993
+ if (acceptedSettled) return;
994
+ acceptedSettled = true;
995
+ if (success) {
996
+ this.publish(activation);
997
+ if (detachAtAcceptance && signal) signal.removeEventListener("abort", abort);
998
+ resolveAccepted();
999
+ } else {
1000
+ activation.suppressSettlement = true;
1001
+ rejectAccepted(new Error("subagent prompt was rejected before acceptance"));
1002
+ }
1003
+ },
1004
+ });
1005
+ if (!acceptedSettled) {
1006
+ acceptedSettled = true;
1007
+ this.publish(activation);
1008
+ if (detachAtAcceptance && signal) signal.removeEventListener("abort", abort);
1009
+ resolveAccepted();
1010
+ }
1011
+ return this.collectResult(activation, "completed");
1012
+ } catch (error) {
1013
+ activation.lastError = errorText(error);
1014
+ if (!acceptedSettled) {
1015
+ acceptedSettled = true;
1016
+ activation.suppressSettlement = true;
1017
+ rejectAccepted(error instanceof Error ? error : new Error(String(error)));
1018
+ }
1019
+ const fallback = signal?.aborted ? "aborted" : "error";
1020
+ const result = this.collectResult(activation, fallback);
1021
+ if (!result.output) result.output = activation.lastError;
1022
+ return result;
1023
+ } finally {
1024
+ if (signal) signal.removeEventListener("abort", abort);
1025
+ }
1026
+ })();
1027
+
1028
+ let lifecycle!: Promise<SubagentRunResult>;
1029
+ lifecycle = core.then(async (result) => {
1030
+ if (activation.currentRun === lifecycle) activation.currentRun = undefined;
1031
+ await this.runFinished(activation, result);
1032
+ return result;
1033
+ });
1034
+ activation.currentRun = lifecycle;
1035
+ void lifecycle.catch(() => {});
1036
+ return { accepted, result: lifecycle };
1037
+ }
1038
+
1039
+ private startInternalMessage(
1040
+ activation: Activation,
1041
+ customType: string,
1042
+ content: string,
1043
+ details: ParentMessageDetails,
1044
+ ): void {
1045
+ if (activation.currentRun || activation.disposed) return;
1046
+ activation.pendingSettlement = undefined;
1047
+ activation.status = "running";
1048
+ const core = Promise.resolve()
1049
+ .then(() =>
1050
+ activation.runtime.session.sendCustomMessage(
1051
+ { customType, content, display: true, details },
1052
+ { triggerTurn: true, deliverAs: "followUp" },
1053
+ ),
1054
+ )
1055
+ .then(
1056
+ () => this.collectResult(activation, "completed"),
1057
+ (error) => {
1058
+ activation.lastError = errorText(error);
1059
+ const result = this.collectResult(activation, "error");
1060
+ if (!result.output) result.output = activation.lastError ?? "";
1061
+ return result;
1062
+ },
1063
+ );
1064
+ let lifecycle!: Promise<SubagentRunResult>;
1065
+ lifecycle = core.then(async (result) => {
1066
+ if (activation.currentRun === lifecycle) activation.currentRun = undefined;
1067
+ await this.runFinished(activation, result);
1068
+ return result;
1069
+ });
1070
+ activation.currentRun = lifecycle;
1071
+ void lifecycle.catch(() => {});
1072
+ }
1073
+
1074
+ private async runFinished(activation: Activation, result: SubagentRunResult): Promise<void> {
1075
+ activation.status = result.stopReason === "completed" ? "completed" : "failed";
1076
+ activation.pendingSettlement = result;
1077
+ this.emitUpdate(activation, result);
1078
+ if (activation.descriptor.mode === "one-shot") {
1079
+ this.emitEnd(activation, result);
1080
+ return;
1081
+ }
1082
+ if (activation.suppressSettlement || this.draining) return;
1083
+ if (activation.ownedChildren.size > 0) {
1084
+ activation.status = "waiting";
1085
+ this.emitUpdate(activation, result);
1086
+ return;
1087
+ }
1088
+ await this.finalizeContinuable(activation);
1089
+ }
1090
+
1091
+ private async finalizeContinuable(activation: Activation): Promise<void> {
1092
+ if (activation.finalizing || activation.disposed || activation.currentRun) return;
1093
+ const result = activation.pendingSettlement;
1094
+ if (!result || activation.ownedChildren.size > 0) return;
1095
+ activation.finalizing = true;
1096
+ activation.finalizePromise = (async () => {
1097
+ if (!activation.suppressSettlement && !this.draining) {
1098
+ await this.deliverSettlement(activation, result).catch((error) => {
1099
+ activation.lastError = errorText(error);
1100
+ });
1101
+ }
1102
+ this.emitEnd(activation, result);
1103
+ await this.disposeActivation(activation);
1104
+ await this.releaseParentOwnership(activation);
1105
+ })();
1106
+ await activation.finalizePromise;
1107
+ }
1108
+
1109
+ private async deliverSettlement(activation: Activation, result: SubagentRunResult): Promise<void> {
1110
+ const truncated = truncateUtf8(result.output, activation.descriptor.runtime.maxOutputBytes);
1111
+ const closing = truncated.text.trim()
1112
+ ? `Its closing message:\n\n${truncated.text}`
1113
+ : "It left no closing message.";
1114
+ const content = `Background subagent ${activation.id} ${stopReasonHeadline(result.stopReason)} and will do no further work unless you send it more.\n\n${closing}${
1115
+ truncated.truncated ? `\n\n[Closing message truncated; ${truncated.omittedBytes} bytes omitted.]` : ""
1116
+ }`;
1117
+ await activation.parent.deliver(
1118
+ SETTLED_CUSTOM_TYPE,
1119
+ content,
1120
+ {
1121
+ kind: "settled",
1122
+ childId: activation.id,
1123
+ label: activation.descriptor.label,
1124
+ stopReason: result.stopReason,
1125
+ ...(truncated.truncated ? { truncated: true } : {}),
1126
+ },
1127
+ "wakeup",
1128
+ );
1129
+ }
1130
+
1131
+ private parentForActivation(activation: Activation): ParentRef {
1132
+ return {
1133
+ id: activation.id,
1134
+ depth: activation.descriptor.depth,
1135
+ cwd: activation.descriptor.cwd,
1136
+ sessionManager: activation.runtime.session.sessionManager,
1137
+ modelRuntime: activation.parent.modelRuntime,
1138
+ model: activation.runtime.session.model,
1139
+ thinkingLevel: activation.runtime.session.thinkingLevel,
1140
+ projectTrusted: activation.parent.projectTrusted,
1141
+ activation,
1142
+ deliver: async (customType, content, details, delivery) => {
1143
+ if (activation.disposed) throw new Error(`parent subagent ${activation.id} is no longer resident`);
1144
+ const session = activation.runtime.session;
1145
+ if (delivery === "quiet") {
1146
+ await session.sendCustomMessage(
1147
+ { customType, content, display: true, details },
1148
+ session.isStreaming
1149
+ ? { triggerTurn: false, deliverAs: "nextTurn" }
1150
+ : { triggerTurn: false },
1151
+ );
1152
+ return;
1153
+ }
1154
+ if (activation.currentRun || session.isStreaming) {
1155
+ await session.sendCustomMessage(
1156
+ { customType, content, display: true, details },
1157
+ { triggerTurn: true, deliverAs: "followUp" },
1158
+ );
1159
+ return;
1160
+ }
1161
+ this.startInternalMessage(activation, customType, content, details);
1162
+ },
1163
+ };
1164
+ }
1165
+
1166
+ private collectResult(activation: Activation, fallback: SubagentStopReason): SubagentRunResult {
1167
+ const messages = activation.runtime.session.messages;
1168
+ const output = finalAssistantText(messages, activation.epochMessageStart, activation.streamedText);
1169
+ const stopReason = finalStopReason(messages, activation.epochMessageStart, fallback);
1170
+ const truncated = truncateUtf8(output, activation.descriptor.runtime.maxOutputBytes);
1171
+ const sessionFile = activation.runtime.session.sessionFile;
1172
+ return {
1173
+ id: activation.id,
1174
+ ...(sessionFile ? { sessionFile } : {}),
1175
+ output: truncated.truncated
1176
+ ? `${truncated.text}\n\n[Output truncated; ${truncated.omittedBytes} bytes omitted.${
1177
+ sessionFile ? ` Full output: ${sessionFile}` : " Full output remains in the active child session."
1178
+ }]`
1179
+ : truncated.text,
1180
+ stopReason,
1181
+ usage: structuredClone(activation.usage),
1182
+ };
1183
+ }
1184
+
1185
+ private observe(activation: Activation, event: AgentSessionEvent): void {
1186
+ if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
1187
+ activation.streamedText += event.assistantMessageEvent.delta;
1188
+ this.emitUpdate(activation);
1189
+ return;
1190
+ }
1191
+ if (event.type === "tool_execution_start") {
1192
+ this.pushTrace(activation, {
1193
+ type: "tool",
1194
+ name: event.toolName,
1195
+ text: formatToolArguments(event.toolName, event.args as Record<string, unknown>),
1196
+ });
1197
+ this.emitUpdate(activation);
1198
+ return;
1199
+ }
1200
+ if (event.type !== "message_end") return;
1201
+ if (event.message.role === "toolResult") {
1202
+ if (event.message.usage) addUsage(activation.usage, event.message.usage, false);
1203
+ return;
1204
+ }
1205
+ if (event.message.role !== "assistant") return;
1206
+ addUsage(activation.usage, event.message.usage);
1207
+ const text = event.message.content
1208
+ .filter((part): part is Extract<(typeof event.message.content)[number], { type: "text" }> => part.type === "text")
1209
+ .map((part) => part.text)
1210
+ .join("");
1211
+ if (text.trim()) this.pushTrace(activation, { type: "text", text });
1212
+ this.emitUpdate(activation);
1213
+ }
1214
+
1215
+ private pushTrace(activation: Activation, item: TraceItem): void {
1216
+ activation.trace.push({
1217
+ ...item,
1218
+ text: item.text.length > MAX_TRACE_TEXT ? `${item.text.slice(0, MAX_TRACE_TEXT)}…` : item.text,
1219
+ });
1220
+ if (activation.trace.length > MAX_TRACE_ITEMS) activation.trace.splice(0, activation.trace.length - MAX_TRACE_ITEMS);
1221
+ }
1222
+
1223
+ private publish(activation: Activation): void {
1224
+ if (activation.published) return;
1225
+ activation.published = true;
1226
+ this.pi.events.emit("pi-subagent:start", {
1227
+ runId: activation.epochId,
1228
+ id: activation.id,
1229
+ provider: activation.descriptor.provider,
1230
+ mode: activation.descriptor.mode,
1231
+ parentId: activation.descriptor.parentSessionId,
1232
+ });
1233
+ }
1234
+
1235
+ private emitEnd(activation: Activation, result: SubagentRunResult): void {
1236
+ if (!activation.published) return;
1237
+ this.pi.events.emit("pi-subagent:end", {
1238
+ runId: activation.epochId,
1239
+ id: activation.id,
1240
+ provider: activation.descriptor.provider,
1241
+ mode: activation.descriptor.mode,
1242
+ parentId: activation.descriptor.parentSessionId,
1243
+ stopReason: result.stopReason,
1244
+ output: result.output,
1245
+ });
1246
+ }
1247
+
1248
+ private detailsOf(activation: Activation, result?: SubagentRunResult): DelegationDetails {
1249
+ return {
1250
+ kind: "delegation",
1251
+ id: activation.id,
1252
+ provider: activation.descriptor.provider,
1253
+ mode: activation.descriptor.mode,
1254
+ agent: activation.descriptor.agent.name,
1255
+ label: activation.descriptor.label,
1256
+ depth: activation.descriptor.depth,
1257
+ status: activation.status,
1258
+ ...(activation.runtime.session.sessionFile
1259
+ ? { sessionFile: activation.runtime.session.sessionFile }
1260
+ : {}),
1261
+ ...(result
1262
+ ? {
1263
+ stopReason: result.stopReason,
1264
+ output: result.output,
1265
+ usage: result.usage,
1266
+ }
1267
+ : {}),
1268
+ trace: activation.trace.map((item) => ({ ...item })),
1269
+ };
1270
+ }
1271
+
1272
+ private progressText(details: DelegationDetails): string {
1273
+ const latest = details.trace.at(-1);
1274
+ return latest?.text || `${details.agent}: ${details.status}`;
1275
+ }
1276
+
1277
+ private emitUpdate(activation: Activation, result?: SubagentRunResult): void {
1278
+ if (!activation.onUpdate) return;
1279
+ try {
1280
+ activation.onUpdate(this.detailsOf(activation, result));
1281
+ } catch {
1282
+ // A stale tool-row update must not affect child execution.
1283
+ }
1284
+ }
1285
+
1286
+ private async rollbackActivation(
1287
+ activation: Activation,
1288
+ prepared: PreparedChildSession,
1289
+ ): Promise<void> {
1290
+ if (!activation.runtime.session.isIdle) await activation.runtime.session.abort().catch(() => {});
1291
+ await this.disposeActivation(activation).catch(() => {});
1292
+ await this.releaseParentOwnership(activation);
1293
+ await prepared.rollback();
1294
+ }
1295
+
1296
+ private async disposeActivation(activation: Activation): Promise<void> {
1297
+ if (activation.disposed) return;
1298
+ activation.disposed = true;
1299
+ activation.unsubscribe?.();
1300
+ activation.unsubscribe = undefined;
1301
+ if (!activation.runtime.session.isIdle) await activation.runtime.session.abort().catch(() => {});
1302
+ try {
1303
+ await activation.runtime.dispose();
1304
+ } finally {
1305
+ if (this.active.get(activation.id) === activation) this.active.delete(activation.id);
1306
+ }
1307
+ }
1308
+
1309
+ private async releaseParentOwnership(activation: Activation): Promise<void> {
1310
+ const owner = activation.parent.activation;
1311
+ if (!owner) return;
1312
+ owner.ownedChildren.delete(activation.id);
1313
+ if (
1314
+ !owner.currentRun &&
1315
+ owner.ownedChildren.size === 0 &&
1316
+ owner.pendingSettlement &&
1317
+ owner.descriptor.mode === "continuable"
1318
+ ) {
1319
+ await this.finalizeContinuable(owner);
1320
+ }
1321
+ }
1322
+
1323
+ private assertDirectParent(parent: ParentRef, descriptor: SubagentDescriptor): void {
1324
+ if (descriptor.parentSessionId !== parent.id) {
1325
+ throw new Error(
1326
+ `subagent ${descriptor.label} is not a direct child of ${parent.id}; message was not delivered`,
1327
+ );
1328
+ }
1329
+ }
1330
+
1331
+ private async findPersistedChild(
1332
+ parent: ParentRef,
1333
+ childId: string,
1334
+ ): Promise<{ descriptor: SubagentDescriptor; sessionFile: string } | undefined> {
1335
+ const catalog = await readPersistedCatalog(parent.sessionManager);
1336
+ const entry = catalog.descriptors.find((candidate) => candidate.id === childId);
1337
+ return entry ? { descriptor: entry.descriptor, sessionFile: entry.sessionFile } : undefined;
1338
+ }
1339
+
1340
+ private async catalogRecords(parent: ParentRef): Promise<CoordinatorCatalog> {
1341
+ const persisted = await readPersistedCatalog(parent.sessionManager);
1342
+ const records = new Map<string, CatalogRecord>();
1343
+ for (const item of persisted.descriptors) {
1344
+ records.set(item.id, {
1345
+ id: item.id,
1346
+ descriptor: item.descriptor,
1347
+ sessionFile: item.sessionFile,
1348
+ });
1349
+ }
1350
+ for (const activation of this.active.values()) {
1351
+ if (activation.descriptor.cwd !== parent.cwd) continue;
1352
+ records.set(activation.id, {
1353
+ id: activation.id,
1354
+ descriptor: activation.descriptor,
1355
+ ...(activation.runtime.session.sessionFile
1356
+ ? { sessionFile: activation.runtime.session.sessionFile }
1357
+ : {}),
1358
+ active: activation,
1359
+ });
1360
+ }
1361
+ return {
1362
+ records: [...records.values()],
1363
+ diagnostics: persisted.diagnostics.filter((diagnostic) => !this.active.has(diagnostic.id)),
1364
+ };
1365
+ }
1366
+
1367
+ private distanceFrom(
1368
+ rootId: string,
1369
+ descriptor: SubagentDescriptor,
1370
+ byId: Map<string, CatalogRecord>,
1371
+ ): number | undefined {
1372
+ let parentId = descriptor.parentSessionId;
1373
+ let distance = 1;
1374
+ const visited = new Set<string>();
1375
+ while (true) {
1376
+ if (parentId === rootId) return distance;
1377
+ if (visited.has(parentId)) return undefined;
1378
+ visited.add(parentId);
1379
+ const parent = byId.get(parentId);
1380
+ if (!parent) return undefined;
1381
+ parentId = parent.descriptor.parentSessionId;
1382
+ distance++;
1383
+ }
1384
+ }
1385
+
1386
+ private async isDescendantOf(parent: ParentRef, descriptor: SubagentDescriptor): Promise<boolean> {
1387
+ const { records } = await this.catalogRecords(parent);
1388
+ const byId = new Map(records.map((record) => [record.id, record]));
1389
+ return this.distanceFrom(parent.id, descriptor, byId) !== undefined;
1390
+ }
1391
+ }
1392
+
1393
+ export { REPORT_CUSTOM_TYPE, SETTLED_CUSTOM_TYPE };