@oai404iao/pi-subagent 0.3.0 → 0.4.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.
@@ -20,23 +20,60 @@ import {
20
20
  ModelRuntime,
21
21
  SessionManager,
22
22
  } from "@earendil-works/pi-coding-agent";
23
+ import { syncBundledAgents, type AgentSyncResult } from "./agent-sync.ts";
23
24
  import {
24
- syncBundledAgents,
25
- type AgentSyncResult,
26
- unmodifiedManagedAgentNames,
27
- } from "./agent-sync.ts";
25
+ catalogStatus,
26
+ closeAgent,
27
+ createAgentControlState,
28
+ currentAgentTurnId,
29
+ delegationStatus,
30
+ finishAgentTurn,
31
+ interruptAgentTurn,
32
+ queueAgentTurn,
33
+ setAgentResidency,
34
+ startAgentTurn,
35
+ type AgentControlState,
36
+ } from "./agent-state.ts";
28
37
  import {
29
38
  discoverAgents,
30
39
  formatAgentCatalog,
31
40
  type AgentDiscoveryResult,
32
41
  } from "./agents.ts";
33
42
  import { readPersistedCatalog } from "./catalog.ts";
34
- import { DESCRIPTOR_CUSTOM_TYPE, foldDescriptor } from "./descriptor.ts";
35
43
  import {
44
+ MAX_COMPLETIONS_PER_DELIVERY,
45
+ appendCompletionUpdate,
46
+ appendUndeliveredCompletion,
47
+ foldCompletionMailbox,
48
+ readCompletionMailbox,
49
+ releaseCompletionDeliveries,
50
+ reserveCompletionDelivery,
51
+ unreadCompletionCounts,
52
+ type CompletionUpdate,
53
+ } from "./completion-mailbox.ts";
54
+ import {
55
+ DESCRIPTOR_CUSTOM_TYPE,
56
+ DESCRIPTOR_VERSION,
57
+ descriptorContext,
58
+ foldDescriptor,
59
+ } from "./descriptor.ts";
60
+ import {
61
+ claimMailboxMessages,
62
+ commitMailboxClaim,
63
+ enqueueMailboxMessage,
64
+ foldOwnedMailbox,
65
+ formatMailboxBatch,
66
+ readMailbox,
67
+ } from "./mailbox.ts";
68
+ import {
69
+ FollowupTaskParameters,
36
70
  InterruptParameters,
37
71
  ListAgentsParameters,
38
72
  ReportParameters,
39
73
  SendMessageParameters,
74
+ WaitAgentParameters,
75
+ DEFAULT_WAIT_AGENT_TIMEOUT_MS,
76
+ MAX_WAIT_AGENT_TIMEOUT_MS,
40
77
  delegationParameters,
41
78
  forkDelegationParameters,
42
79
  } from "./schemas.ts";
@@ -55,13 +92,30 @@ import {
55
92
  ProviderRegistry,
56
93
  SpawnProvider,
57
94
  } from "./providers.ts";
95
+ import {
96
+ AgentOperationQueue,
97
+ BackgroundRunLimiter,
98
+ type BackgroundRunPermit,
99
+ } from "./scheduler.ts";
58
100
  import { buildToolCeiling, resolveToolPolicy } from "./tool-policy.ts";
101
+ import {
102
+ ROOT_TASK_PATH,
103
+ descriptorTaskPath,
104
+ isAgentId,
105
+ numberedTaskName,
106
+ resolveTaskPath,
107
+ slugTaskName,
108
+ taskPath,
109
+ validateTaskName,
110
+ } from "./task-path.ts";
59
111
  import {
60
112
  snapshotAgent,
61
113
  type AgentDefinition,
62
114
  type CatalogChild,
115
+ type CatalogDiagnostic,
63
116
  type CatalogEntry,
64
117
  type ControlDetails,
118
+ type ContextInheritance,
65
119
  type DelegationDetails,
66
120
  type ParentMessageDetails,
67
121
  type SubagentDescriptor,
@@ -69,20 +123,23 @@ import {
69
123
  type SubagentProviderName,
70
124
  type SubagentRunResult,
71
125
  type SubagentSettings,
126
+ type RuntimeMode,
72
127
  type SubagentStopReason,
73
128
  type TraceItem,
74
129
  } from "./types.ts";
75
130
 
76
131
  const REPORT_CUSTOM_TYPE = "pi-subagent/report";
77
- const SETTLED_CUSTOM_TYPE = "pi-subagent/settled";
78
132
  const AGENT_CUSTOM_TYPE = "pi-subagent/agent";
79
133
  const LINEAGE_CUSTOM_TYPE = "pi-subagent/lineage";
80
134
  const AGENT_ID_PATTERN =
81
135
  /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
82
136
  const MAX_TRACE_ITEMS = 100;
83
137
  const MAX_TRACE_TEXT = 4000;
138
+ const MAX_WAIT_AGENT_RESULT_BYTES = 256 * 1024;
84
139
  const BACKGROUND_CONTROL_TOOLS = new Set([
85
140
  "send_message",
141
+ "followup_task",
142
+ "wait_agent",
86
143
  "interrupt_agent",
87
144
  "list_agents",
88
145
  ]);
@@ -96,22 +153,57 @@ const DELEGATION_SCOPE_PROMPT = [
96
153
  const REPORT_PROMPT = [
97
154
  "You have a `report` tool that sends a selected update to the agent that started you.",
98
155
  "Call it with a self-contained answer before finishing, and earlier when a finding changes what the parent should do.",
99
- "Reporting does not end this turn and does not prevent later follow-up messages.",
156
+ "A report is recorded for the parent without waking it; it does not end this turn and does not prevent later follow-up messages.",
157
+ "Your final answer is delivered separately as a completion update.",
100
158
  ].join(" ");
101
159
 
102
160
  export interface DelegationInput {
103
161
  agent: string;
162
+ task_name?: string;
104
163
  description: string;
105
164
  prompt: string;
106
- run_in_background?: boolean;
165
+ context?: {
166
+ mode: ContextInheritance["mode"];
167
+ completed_turns?: number;
168
+ };
107
169
  }
108
170
 
109
171
  export type DelegationOutcome =
110
172
  | { kind: "continuable"; details: DelegationDetails }
111
173
  | { kind: "foreground"; details: DelegationDetails; result: SubagentRunResult };
112
174
 
175
+ export interface SendMessageOutcome {
176
+ kind: "mailbox";
177
+ agentId: string;
178
+ taskPath: string;
179
+ messageId: string;
180
+ pendingMessages: number;
181
+ }
182
+
183
+ export interface FollowupTaskOutcome {
184
+ agentId: string;
185
+ taskPath: string;
186
+ turnId: string;
187
+ claimedMessages: number;
188
+ }
189
+
190
+ export interface InterruptOutcome {
191
+ agentId: string;
192
+ taskPath: string;
193
+ active: boolean;
194
+ }
195
+
196
+ export interface WaitAgentOutcome {
197
+ timedOut: boolean;
198
+ timeoutMs: number;
199
+ updates: CompletionUpdate[];
200
+ unreadUpdates: number;
201
+ taskPaths: Record<string, string>;
202
+ }
203
+
113
204
  interface ParentRef {
114
205
  agentId: string;
206
+ taskPath: string;
115
207
  depth: number;
116
208
  cwd: string;
117
209
  sessionManager: SessionView;
@@ -124,7 +216,6 @@ interface ParentRef {
124
216
  customType: string,
125
217
  content: string,
126
218
  details: ParentMessageDetails,
127
- delivery: "wakeup" | "quiet",
128
219
  ): Promise<void>;
129
220
  }
130
221
 
@@ -136,21 +227,52 @@ interface Activation {
136
227
  runtime: AgentSessionRuntime;
137
228
  seedMessageCount: number;
138
229
  epochMessageStart: number;
139
- status: DelegationDetails["status"];
230
+ controlState: AgentControlState;
140
231
  trace: TraceItem[];
141
232
  streamedText: string;
142
233
  usage: ReturnType<typeof emptyUsage>;
234
+ startedTurnIds: Set<string>;
235
+ silentSettlementTurnIds: Set<string>;
236
+ pendingMailboxClaims: Set<string>;
237
+ userMessageGates: Map<string, UserMessageGate>;
143
238
  ownedChildren: Set<string>;
144
239
  currentRun?: Promise<SubagentRunResult>;
145
240
  pendingSettlement?: SubagentRunResult;
146
241
  unsubscribe?: () => void;
147
242
  onUpdate?: (details: DelegationDetails) => void;
148
243
  published: boolean;
244
+ everPublished: boolean;
149
245
  suppressSettlement: boolean;
150
246
  finalizing: boolean;
151
- finalizePromise?: Promise<void>;
247
+ finalizePromise?: Promise<boolean>;
248
+ holdsBackgroundSlot: boolean;
249
+ turnAbortController?: AbortController;
250
+ persistenceGate: PersistenceGate;
152
251
  disposed: boolean;
153
252
  lastError?: string;
253
+ ownerActivation?: Activation;
254
+ lastUsedSequence: number;
255
+ }
256
+
257
+ interface UserMessageGate {
258
+ prepare(): void;
259
+ accept(): void;
260
+ reject(error: Error): void;
261
+ }
262
+
263
+ interface PersistenceGate {
264
+ promise: Promise<void>;
265
+ resolve(): void;
266
+ reject(error: Error): void;
267
+ readonly settled: boolean;
268
+ readonly state: "pending" | "fulfilled" | "rejected";
269
+ }
270
+
271
+ interface CompletionWaiter {
272
+ promise: Promise<"activity" | "timeout">;
273
+ wake(): void;
274
+ reject(error: Error): void;
275
+ dispose(): void;
154
276
  }
155
277
 
156
278
  interface CreateActivationOptions {
@@ -161,16 +283,53 @@ interface CreateActivationOptions {
161
283
  onUpdate?: (details: DelegationDetails) => void;
162
284
  }
163
285
 
286
+ interface StartPromptOptions {
287
+ detachAtAcceptance: boolean;
288
+ waitForCapacity: boolean;
289
+ preparePrompt?: (turnId: string) => string;
290
+ onPromptAccepted?: (turnId: string) => void;
291
+ acceptAfterUserMessage?: boolean;
292
+ }
293
+
294
+ interface PendingPromptStart {
295
+ activation: Activation;
296
+ accepted: Promise<void>;
297
+ coldPrepared?: PreparedChildSession;
298
+ }
299
+
300
+ type SerializedSendMessageOutcome = SendMessageOutcome;
301
+
302
+ interface PendingFollowupStart extends PendingPromptStart {
303
+ outcome: FollowupTaskOutcome;
304
+ }
305
+
164
306
  interface CatalogRecord {
165
307
  agentId: string;
308
+ piSessionId: string;
309
+ taskPath: string;
166
310
  descriptor: SubagentDescriptor;
167
311
  sessionFile?: string;
168
312
  active?: Activation;
313
+ pendingMessages: number;
314
+ unreadUpdatesByChild: Map<string, number>;
315
+ }
316
+
317
+ interface ResolvedTarget {
318
+ agentId: string;
319
+ taskPath: string;
320
+ record?: CatalogRecord;
321
+ }
322
+
323
+ interface ReservedTask {
324
+ name: string;
325
+ path: string;
326
+ release(): void;
169
327
  }
170
328
 
171
329
  interface CoordinatorCatalog {
172
330
  records: CatalogRecord[];
173
331
  diagnostics: CatalogEntry[];
332
+ rootUnreadUpdatesByChild: Map<string, number>;
174
333
  }
175
334
 
176
335
  function runtimeFromRegistry(registry: ModelRegistry): ModelRuntime {
@@ -195,6 +354,79 @@ function errorText(error: unknown): string {
195
354
  return error instanceof Error ? error.message : String(error);
196
355
  }
197
356
 
357
+ function abortReason(signal: AbortSignal): Error {
358
+ return signal.reason instanceof Error
359
+ ? signal.reason
360
+ : new Error(signal.reason ? String(signal.reason) : "operation aborted");
361
+ }
362
+
363
+ function waitForPromise<T>(
364
+ promise: Promise<T>,
365
+ signal?: AbortSignal,
366
+ ): Promise<T> {
367
+ if (!signal) return promise;
368
+ if (signal.aborted) return Promise.reject(abortReason(signal));
369
+ return new Promise<T>((resolvePromise, rejectPromise) => {
370
+ const abort = () => {
371
+ rejectPromise(abortReason(signal));
372
+ };
373
+ signal.addEventListener("abort", abort, { once: true });
374
+ promise.then(
375
+ (value) => {
376
+ signal.removeEventListener("abort", abort);
377
+ resolvePromise(value);
378
+ },
379
+ (error) => {
380
+ signal.removeEventListener("abort", abort);
381
+ rejectPromise(error);
382
+ },
383
+ );
384
+ });
385
+ }
386
+
387
+ function createPersistenceGate(
388
+ session: Pick<SessionView, "getEntries">,
389
+ requireNewAssistant: boolean,
390
+ ): PersistenceGate {
391
+ const alreadyDurable = !requireNewAssistant && session
392
+ .getEntries()
393
+ .some(
394
+ (entry) =>
395
+ entry.type === "message"
396
+ && entry.message.role === "assistant",
397
+ );
398
+ let state: PersistenceGate["state"] =
399
+ alreadyDurable ? "fulfilled" : "pending";
400
+ let resolvePromise!: () => void;
401
+ let rejectPromise!: (error: Error) => void;
402
+ const promise = alreadyDurable
403
+ ? Promise.resolve()
404
+ : new Promise<void>((resolve, reject) => {
405
+ resolvePromise = resolve;
406
+ rejectPromise = reject;
407
+ });
408
+ void promise.catch(() => {});
409
+ return {
410
+ promise,
411
+ resolve: () => {
412
+ if (state !== "pending") return;
413
+ state = "fulfilled";
414
+ resolvePromise();
415
+ },
416
+ reject: (error) => {
417
+ if (state !== "pending") return;
418
+ state = "rejected";
419
+ rejectPromise(error);
420
+ },
421
+ get settled() {
422
+ return state !== "pending";
423
+ },
424
+ get state() {
425
+ return state;
426
+ },
427
+ };
428
+ }
429
+
198
430
  /**
199
431
  * Read the durable pi-subagent control id recorded for a session, if any.
200
432
  *
@@ -246,17 +478,88 @@ function stopReasonHeadline(reason: SubagentStopReason): string {
246
478
  function makeRuntimeSettings(descriptor: SubagentDescriptor): SubagentSettings {
247
479
  return {
248
480
  agentScope: descriptor.runtime.agentScope,
249
- syncBundledAgents: descriptor.runtime.syncBundledAgents,
250
481
  maxDepth: descriptor.runtime.maxDepth,
251
- enableRunInBackground: descriptor.runtime.enableRunInBackground,
252
- defaultBackground: descriptor.runtime.defaultBackground,
253
- reportDelivery: descriptor.runtime.reportDelivery,
482
+ runtimeMode: descriptor.runtime.runtimeMode,
483
+ maxConcurrentBackgroundRuns: descriptor.runtime.maxConcurrentBackgroundRuns,
484
+ maxIdleRuntimes: descriptor.runtime.maxIdleRuntimes,
254
485
  inheritExtensions: descriptor.runtime.inheritExtensions,
255
486
  openAIIdentity: descriptor.runtime.openAIIdentity,
256
487
  maxOutputBytes: descriptor.runtime.maxOutputBytes,
257
488
  };
258
489
  }
259
490
 
491
+ function delegationContext(
492
+ providerName: SubagentProviderName,
493
+ input: DelegationInput,
494
+ ): ContextInheritance {
495
+ if (providerName === "fork") {
496
+ if (input.context !== undefined) {
497
+ throw new Error(
498
+ "subagent_fork always uses all_completed context; use subagent for another context policy",
499
+ );
500
+ }
501
+ return { mode: "all_completed" };
502
+ }
503
+ const context = input.context;
504
+ if (!context) return { mode: "fresh" };
505
+ if (context.mode === "last_n_completed") {
506
+ if (
507
+ !Number.isSafeInteger(context.completed_turns)
508
+ || context.completed_turns === undefined
509
+ || context.completed_turns < 1
510
+ || context.completed_turns > 100
511
+ ) {
512
+ throw new Error(
513
+ "context.completed_turns must be an integer between 1 and 100 for last_n_completed",
514
+ );
515
+ }
516
+ return {
517
+ mode: context.mode,
518
+ completedTurns: context.completed_turns,
519
+ };
520
+ }
521
+ if (
522
+ context.mode !== "fresh"
523
+ && context.mode !== "all_completed"
524
+ ) {
525
+ throw new Error(`unsupported context mode: ${String(context.mode)}`);
526
+ }
527
+ if (context.completed_turns !== undefined) {
528
+ throw new Error(
529
+ "context.completed_turns is available only for last_n_completed",
530
+ );
531
+ }
532
+ return { mode: context.mode };
533
+ }
534
+
535
+ function mailboxOwner(descriptor: SubagentDescriptor): {
536
+ parentAgentId: string;
537
+ agentId: string;
538
+ } {
539
+ return {
540
+ parentAgentId: descriptor.parentAgentId,
541
+ agentId: descriptor.agentId,
542
+ };
543
+ }
544
+
545
+ function completionWaiterKey(parent: ParentRef): string {
546
+ return `${parent.agentId}:${parent.sessionManager.getSessionId()}`;
547
+ }
548
+
549
+ function waitTimeout(value: number | undefined): number {
550
+ const timeoutMs = value ?? DEFAULT_WAIT_AGENT_TIMEOUT_MS;
551
+ if (
552
+ !Number.isSafeInteger(timeoutMs)
553
+ || timeoutMs < 0
554
+ || timeoutMs > MAX_WAIT_AGENT_TIMEOUT_MS
555
+ ) {
556
+ throw new Error(
557
+ `timeout_ms must be a safe integer between 0 and ${MAX_WAIT_AGENT_TIMEOUT_MS}`,
558
+ );
559
+ }
560
+ return timeoutMs;
561
+ }
562
+
260
563
  function isPathInside(parent: string, child: string): boolean {
261
564
  const rel = relative(resolve(parent), resolve(child));
262
565
  return rel === "" || (!rel.startsWith("..") && !rel.startsWith("/"));
@@ -288,8 +591,19 @@ async function loadCodexIdentityInlineExtension(
288
591
  export class SubagentCoordinator {
289
592
  private readonly providers = new ProviderRegistry();
290
593
  private readonly active = new Map<string, Activation>();
594
+ private readonly agentOperations = new AgentOperationQueue();
595
+ private readonly completionOperations = new AgentOperationQueue();
596
+ private readonly idleRuntimeOperations = new AgentOperationQueue();
597
+ private readonly backgroundRuns = new BackgroundRunLimiter();
598
+ private readonly admittedOperations = new Set<Promise<unknown>>();
599
+ private readonly completionWaiters = new Map<string, CompletionWaiter>();
600
+ private readonly reservedTaskPaths = new Set<string>();
601
+ private readonly runtimeId = uuidv7();
602
+ private idleRuntimeLimit = 0;
603
+ private activationSequence = 0;
291
604
  private agentSyncResult: AgentSyncResult | undefined;
292
605
  private draining = false;
606
+ private shutdownPromise: Promise<void> | undefined;
293
607
 
294
608
  constructor(
295
609
  private readonly pi: ExtensionAPI,
@@ -314,26 +628,39 @@ export class SubagentCoordinator {
314
628
  return join(this.agentDir, "agents");
315
629
  }
316
630
 
631
+ configureBackgroundRuns(limit: number): void {
632
+ this.backgroundRuns.configure(limit);
633
+ }
634
+
635
+ async configureIdleRuntimes(limit: number): Promise<void> {
636
+ if (!Number.isSafeInteger(limit) || limit < 0) {
637
+ throw new Error("maxIdleRuntimes must be a non-negative safe integer");
638
+ }
639
+ this.idleRuntimeLimit = limit;
640
+ await this.trimIdleRuntimes();
641
+ }
642
+
317
643
  discoverAvailableAgents(
318
644
  cwd: string,
319
645
  settings: SubagentSettings,
320
646
  projectTrusted: boolean,
321
647
  ): AgentDiscoveryResult {
322
- if (settings.syncBundledAgents && !this.agentSyncResult) {
648
+ if (!this.agentSyncResult) {
323
649
  this.synchronizeBundledAgents();
324
650
  }
325
- const excludeUserAgentNames = settings.syncBundledAgents
326
- ? undefined
327
- : unmodifiedManagedAgentNames(this.agentDir);
328
- return discoverAgents({
651
+ const discovery = discoverAgents({
329
652
  cwd,
330
653
  scope: settings.agentScope,
331
654
  projectTrusted,
332
- bundledDir: this.bundledAgentsDir,
333
655
  agentDir: this.agentDir,
334
- includeBundled: !settings.syncBundledAgents && settings.agentScope !== "project",
335
- excludeUserAgentNames,
336
656
  });
657
+ return {
658
+ ...discovery,
659
+ diagnostics: [
660
+ ...(this.agentSyncResult?.diagnostics ?? []),
661
+ ...discovery.diagnostics,
662
+ ],
663
+ };
337
664
  }
338
665
 
339
666
  async parentFromContext(ctx: ExtensionContext): Promise<ParentRef> {
@@ -344,6 +671,9 @@ export class SubagentCoordinator {
344
671
  agentId: readAgentId(ctx.sessionManager) ?? ensureAgentId(
345
672
  ctx.sessionManager as unknown as SessionView,
346
673
  ),
674
+ taskPath: descriptor
675
+ ? descriptorTaskPath(descriptor)
676
+ : ROOT_TASK_PATH,
347
677
  depth: descriptor?.depth ?? 0,
348
678
  cwd: ctx.cwd,
349
679
  // ExtensionContext narrows the live SessionManager to a read-only
@@ -354,13 +684,13 @@ export class SubagentCoordinator {
354
684
  model: ctx.model,
355
685
  thinkingLevel: ctx.thinkingLevel ?? "off",
356
686
  projectTrusted: ctx.isProjectTrusted(),
357
- deliver: async (customType, content, details, delivery) => {
358
- const options =
359
- delivery === "wakeup"
360
- ? { triggerTurn: true, deliverAs: "followUp" as const }
361
- : ctx.isIdle()
362
- ? { triggerTurn: false }
363
- : { triggerTurn: false, deliverAs: "nextTurn" as const };
687
+ // Reports never start a parent turn. The entry becomes part of the
688
+ // parent session (and therefore its next model context) while the
689
+ // parent keeps working; durable completions are read with wait_agent.
690
+ deliver: async (customType, content, details) => {
691
+ const options = ctx.isIdle()
692
+ ? { triggerTurn: false }
693
+ : { triggerTurn: false, deliverAs: "nextTurn" as const };
364
694
  this.pi.sendMessage({ customType, content, display: true, details }, options);
365
695
  },
366
696
  };
@@ -374,34 +704,47 @@ export class SubagentCoordinator {
374
704
  signal?: AbortSignal,
375
705
  onUpdate?: (details: DelegationDetails) => void,
376
706
  agentDiscovery?: AgentDiscoveryResult,
707
+ ): Promise<DelegationOutcome> {
708
+ return this.runAdmittedOperation(() =>
709
+ this.delegateAdmitted(
710
+ parent,
711
+ providerName,
712
+ input,
713
+ settings,
714
+ signal,
715
+ onUpdate,
716
+ agentDiscovery,
717
+ ),
718
+ );
719
+ }
720
+
721
+ private async delegateAdmitted(
722
+ parent: ParentRef,
723
+ providerName: SubagentProviderName,
724
+ input: DelegationInput,
725
+ settings: SubagentSettings,
726
+ signal?: AbortSignal,
727
+ onUpdate?: (details: DelegationDetails) => void,
728
+ agentDiscovery?: AgentDiscoveryResult,
377
729
  ): Promise<DelegationOutcome> {
378
730
  if (this.draining) throw new Error("pi-subagent is shutting down; no new delegation was accepted");
379
- const provider = this.providers.get(providerName);
380
- if (
381
- providerName === "spawn" &&
382
- !settings.enableRunInBackground &&
383
- input.run_in_background === true
384
- ) {
385
- throw new Error(
386
- "run_in_background is disabled by pi-subagent foreground-only mode (enableRunInBackground: false)",
387
- );
388
- }
389
- const runInBackground =
390
- providerName === "spawn" && settings.enableRunInBackground
391
- ? (input.run_in_background ?? settings.defaultBackground)
392
- : false;
393
- const mode: SubagentMode = runInBackground ? "continuable" : "one-shot";
731
+ const context = delegationContext(providerName, input);
732
+ const resolvedProviderName: SubagentProviderName =
733
+ context.mode === "fresh" ? "spawn" : "fork";
734
+ const provider = this.providers.get(resolvedProviderName);
735
+ const mode: SubagentMode =
736
+ settings.runtimeMode === "background" ? "continuable" : "one-shot";
394
737
  if (mode === "continuable" && !provider.supportsContinuable) {
395
738
  throw new Error(`subagent provider "${provider.name}" does not support continuable children`);
396
739
  }
397
740
  if (mode === "continuable" && !parent.sessionManager.getSessionFile()) {
398
741
  throw new Error(
399
- "continuable subagents require a persisted parent session; set run_in_background to false",
742
+ 'continuable subagents require a persisted parent session; set runtimeMode to "foreground" to delegate from an ephemeral session',
400
743
  );
401
744
  }
402
745
  if (mode === "continuable" && parent.activation?.descriptor.mode === "one-shot") {
403
746
  throw new Error(
404
- "a one-shot child cannot leave a continuable descendant behind; set run_in_background to false",
747
+ "a foreground child cannot leave a continuable descendant behind; its runtime mode is fixed at creation",
405
748
  );
406
749
  }
407
750
 
@@ -429,57 +772,71 @@ export class SubagentCoordinator {
429
772
 
430
773
  const model = this.resolveModel(parent, agent);
431
774
  const thinkingLevel = agent.thinking ?? parent.thinkingLevel;
432
- const prepared = await provider.prepare(parent, mode);
433
775
  parent.agentId = ensureAgentId(parent.sessionManager);
434
- const openAIIdentityEnabled =
435
- settings.openAIIdentity && isOpenAIResponsesModel(model);
436
- const descriptor: SubagentDescriptor = {
437
- version: 2,
438
- mode,
439
- provider: providerName,
440
- label: input.description.trim(),
441
- agentId: uuidv7(),
442
- parentAgentId: parent.agentId,
443
- parentPiSessionId: parent.sessionManager.getSessionId(),
444
- ...(parent.sessionManager.getSessionFile()
445
- ? { parentSessionFile: parent.sessionManager.getSessionFile() }
446
- : {}),
447
- depth,
448
- cwd: parent.cwd,
449
- createdAt: new Date().toISOString(),
450
- agent: snapshotAgent(agent),
451
- model: { provider: model.provider, id: model.id },
452
- thinkingLevel,
453
- runtime: {
454
- agentScope: settings.agentScope,
455
- syncBundledAgents: settings.syncBundledAgents,
456
- maxDepth: settings.maxDepth,
457
- enableRunInBackground: settings.enableRunInBackground,
458
- defaultBackground: settings.defaultBackground,
459
- reportDelivery: settings.reportDelivery,
460
- inheritExtensions: settings.inheritExtensions,
461
- openAIIdentity: openAIIdentityEnabled,
462
- maxOutputBytes: settings.maxOutputBytes,
463
- },
464
- };
465
- prepared.sessionManager.appendCustomEntry(AGENT_CUSTOM_TYPE, {
466
- agentId: descriptor.agentId,
467
- });
468
- prepared.sessionManager.appendCustomEntry(LINEAGE_CUSTOM_TYPE, {
469
- version: 1,
470
- agentId: descriptor.agentId,
471
- parentAgentId: descriptor.parentAgentId,
472
- parentPiSessionId: descriptor.parentPiSessionId,
473
- relation: descriptor.provider,
474
- agentName: descriptor.agent.name,
475
- openAIIdentity: openAIIdentityEnabled,
476
- ...(descriptor.parentSessionFile
477
- ? { parentSessionFile: descriptor.parentSessionFile }
478
- : {}),
479
- });
480
-
776
+ const reservedTask = await this.reserveTask(
777
+ parent,
778
+ input.task_name,
779
+ input.description,
780
+ );
781
+ let prepared: PreparedChildSession | undefined;
481
782
  let activation: Activation | undefined;
783
+ let keepTaskReservation = false;
482
784
  try {
785
+ prepared = await provider.prepare(parent, mode, context);
786
+ if (this.draining) {
787
+ throw new Error("pi-subagent is shutting down; no new delegation was accepted");
788
+ }
789
+ const openAIIdentityEnabled =
790
+ settings.openAIIdentity && isOpenAIResponsesModel(model);
791
+ const descriptor: SubagentDescriptor = {
792
+ version: DESCRIPTOR_VERSION,
793
+ mode,
794
+ provider: resolvedProviderName,
795
+ label: input.description.trim(),
796
+ agentId: uuidv7(),
797
+ parentAgentId: parent.agentId,
798
+ parentPiSessionId: parent.sessionManager.getSessionId(),
799
+ ...(parent.sessionManager.getSessionFile()
800
+ ? { parentSessionFile: parent.sessionManager.getSessionFile() }
801
+ : {}),
802
+ depth,
803
+ cwd: parent.cwd,
804
+ createdAt: new Date().toISOString(),
805
+ agent: snapshotAgent(agent),
806
+ model: { provider: model.provider, id: model.id },
807
+ thinkingLevel,
808
+ task: {
809
+ name: reservedTask.name,
810
+ path: reservedTask.path,
811
+ },
812
+ context,
813
+ runtime: {
814
+ agentScope: settings.agentScope,
815
+ maxDepth: settings.maxDepth,
816
+ runtimeMode: settings.runtimeMode,
817
+ maxConcurrentBackgroundRuns: settings.maxConcurrentBackgroundRuns,
818
+ maxIdleRuntimes: settings.maxIdleRuntimes,
819
+ inheritExtensions: settings.inheritExtensions,
820
+ openAIIdentity: openAIIdentityEnabled,
821
+ maxOutputBytes: settings.maxOutputBytes,
822
+ },
823
+ };
824
+ prepared.sessionManager.appendCustomEntry(AGENT_CUSTOM_TYPE, {
825
+ agentId: descriptor.agentId,
826
+ });
827
+ prepared.sessionManager.appendCustomEntry(LINEAGE_CUSTOM_TYPE, {
828
+ version: 1,
829
+ agentId: descriptor.agentId,
830
+ parentAgentId: descriptor.parentAgentId,
831
+ parentPiSessionId: descriptor.parentPiSessionId,
832
+ relation: descriptor.provider,
833
+ agentName: descriptor.agent.name,
834
+ taskPath: descriptor.task.path,
835
+ openAIIdentity: openAIIdentityEnabled,
836
+ ...(descriptor.parentSessionFile
837
+ ? { parentSessionFile: descriptor.parentSessionFile }
838
+ : {}),
839
+ });
483
840
  activation = await this.createActivation({
484
841
  parent,
485
842
  descriptor,
@@ -487,11 +844,18 @@ export class SubagentCoordinator {
487
844
  isNew: true,
488
845
  onUpdate,
489
846
  });
847
+ if (this.draining) {
848
+ throw new Error("pi-subagent is shutting down; no new delegation was accepted");
849
+ }
490
850
  if (mode === "continuable" && parent.activation) {
491
- parent.activation.ownedChildren.add(activation.agentId);
851
+ this.acquireParentOwnership(activation, parent);
492
852
  }
493
- const started = this.startPrompt(activation, input.prompt, signal, mode === "continuable");
853
+ const started = this.startPrompt(activation, input.prompt, signal, {
854
+ detachAtAcceptance: mode === "continuable",
855
+ waitForCapacity: !parent.activation?.holdsBackgroundSlot,
856
+ });
494
857
  await started.accepted;
858
+ keepTaskReservation = true;
495
859
  if (mode === "continuable") {
496
860
  activation.onUpdate = undefined;
497
861
  return { kind: "continuable", details: this.detailsOf(activation) };
@@ -503,37 +867,197 @@ export class SubagentCoordinator {
503
867
  await this.disposeActivation(activation);
504
868
  return { kind: "foreground", details, result };
505
869
  } catch (error) {
506
- if (activation && !activation.published) {
870
+ if (activation && prepared && !activation.published) {
507
871
  activation.suppressSettlement = true;
508
872
  await this.rollbackActivation(activation, prepared);
509
- } else if (!activation) {
873
+ } else if (!activation && prepared) {
510
874
  await prepared.rollback();
511
875
  }
512
876
  throw error;
877
+ } finally {
878
+ if (!keepTaskReservation) reservedTask.release();
513
879
  }
514
880
  }
515
881
 
516
- async sendMessage(parent: ParentRef, childId: string, message: string, signal?: AbortSignal): Promise<void> {
882
+ async sendMessage(
883
+ parent: ParentRef,
884
+ childId: string,
885
+ message: string,
886
+ signal?: AbortSignal,
887
+ ): Promise<void> {
888
+ await this.sendMessageWithOutcome(parent, childId, message, signal);
889
+ }
890
+
891
+ async sendMessageWithOutcome(
892
+ parent: ParentRef,
893
+ childId: string,
894
+ message: string,
895
+ signal?: AbortSignal,
896
+ ): Promise<SendMessageOutcome> {
897
+ return this.runAdmittedOperation(() =>
898
+ this.sendMessageAdmitted(parent, childId, message, signal),
899
+ );
900
+ }
901
+
902
+ private async sendMessageAdmitted(
903
+ parent: ParentRef,
904
+ childId: string,
905
+ message: string,
906
+ signal?: AbortSignal,
907
+ ): Promise<SendMessageOutcome> {
517
908
  if (this.draining) throw new Error("pi-subagent is shutting down; message was not delivered");
909
+ const target = await this.resolveTarget(parent, childId);
910
+ return this.agentOperations.run(target.agentId, () =>
911
+ this.sendMessageSerialized(parent, target.agentId, message, signal),
912
+ );
913
+ }
914
+
915
+ private async sendMessageSerialized(
916
+ parent: ParentRef,
917
+ childId: string,
918
+ message: string,
919
+ signal?: AbortSignal,
920
+ ): Promise<SerializedSendMessageOutcome> {
921
+ if (this.draining) {
922
+ throw new Error("pi-subagent is shutting down; message was not delivered");
923
+ }
518
924
  let activation = this.active.get(childId);
519
- let coldPrepared: PreparedChildSession | undefined;
520
- if (activation?.finalizing && activation.finalizePromise) {
521
- await activation.finalizePromise;
522
- activation = undefined;
925
+ if (activation?.disposed) activation = undefined;
926
+ if (activation) {
927
+ this.touchActivation(activation);
928
+ this.assertContinuableDirectChild(parent, activation.descriptor);
929
+ if (signal?.aborted) throw abortReason(signal);
930
+ await waitForPromise(activation.persistenceGate.promise, signal);
931
+ if (this.draining || activation.disposed) {
932
+ throw new Error(
933
+ `subagent ${childId} became unavailable before its mailbox could be persisted`,
934
+ );
935
+ }
936
+ const enqueued = enqueueMailboxMessage(
937
+ activation.runtime.session.sessionManager,
938
+ {
939
+ senderAgentId: parent.agentId,
940
+ recipientAgentId: childId,
941
+ content: message,
942
+ },
943
+ );
944
+ return {
945
+ kind: "mailbox",
946
+ agentId: childId,
947
+ taskPath: descriptorTaskPath(activation.descriptor),
948
+ messageId: enqueued.message.messageId,
949
+ pendingMessages: enqueued.pendingMessages,
950
+ };
523
951
  }
952
+ const located = await this.findPersistedChild(parent, childId);
953
+ if (!located) throw new Error(`unknown subagent: ${childId}; message was not delivered`);
954
+ this.assertContinuableDirectChild(parent, located.descriptor);
955
+ const manager = SessionManager.open(
956
+ located.sessionFile,
957
+ parent.sessionManager.getSessionDir(),
958
+ parent.cwd,
959
+ );
960
+ if (signal?.aborted) throw abortReason(signal);
961
+ const enqueued = enqueueMailboxMessage(manager, {
962
+ senderAgentId: parent.agentId,
963
+ recipientAgentId: childId,
964
+ content: message,
965
+ });
966
+ return {
967
+ kind: "mailbox",
968
+ agentId: childId,
969
+ taskPath: descriptorTaskPath(located.descriptor),
970
+ messageId: enqueued.message.messageId,
971
+ pendingMessages: enqueued.pendingMessages,
972
+ };
973
+ }
524
974
 
525
- if (!activation) {
975
+ async followupTask(
976
+ parent: ParentRef,
977
+ childId: string,
978
+ signal?: AbortSignal,
979
+ ): Promise<FollowupTaskOutcome> {
980
+ return this.runAdmittedOperation(() =>
981
+ this.followupTaskAdmitted(parent, childId, signal),
982
+ );
983
+ }
984
+
985
+ private async followupTaskAdmitted(
986
+ parent: ParentRef,
987
+ childId: string,
988
+ signal?: AbortSignal,
989
+ ): Promise<FollowupTaskOutcome> {
990
+ if (this.draining) {
991
+ throw new Error("pi-subagent is shutting down; follow-up task was not started");
992
+ }
993
+ const target = await this.resolveTarget(parent, childId);
994
+ const pending = await this.agentOperations.run(target.agentId, () =>
995
+ this.followupTaskSerialized(parent, target.agentId, signal),
996
+ );
997
+ try {
998
+ await pending.accepted;
999
+ } catch (error) {
1000
+ if (pending.coldPrepared && !pending.activation.published) {
1001
+ await this.agentOperations.run(target.agentId, async () => {
1002
+ if (this.active.get(target.agentId) !== pending.activation) return;
1003
+ pending.activation.suppressSettlement = true;
1004
+ await this.rollbackActivation(
1005
+ pending.activation,
1006
+ pending.coldPrepared!,
1007
+ );
1008
+ });
1009
+ }
1010
+ throw error;
1011
+ }
1012
+ return pending.outcome;
1013
+ }
1014
+
1015
+ private async followupTaskSerialized(
1016
+ parent: ParentRef,
1017
+ childId: string,
1018
+ signal?: AbortSignal,
1019
+ ): Promise<PendingFollowupStart> {
1020
+ if (this.draining) {
1021
+ throw new Error("pi-subagent is shutting down; follow-up task was not started");
1022
+ }
1023
+ if (signal?.aborted) throw abortReason(signal);
1024
+ let activation = this.active.get(childId);
1025
+ if (activation?.disposed) activation = undefined;
1026
+ let descriptor: SubagentDescriptor;
1027
+ let manager: SessionManager;
1028
+ let coldPrepared: PreparedChildSession | undefined;
1029
+
1030
+ if (activation) {
1031
+ this.touchActivation(activation);
1032
+ descriptor = activation.descriptor;
1033
+ this.assertContinuableDirectChild(parent, descriptor);
1034
+ manager = activation.runtime.session.sessionManager;
1035
+ if (activation.currentRun || activation.runtime.session.isStreaming) {
1036
+ throw new Error(
1037
+ `subagent ${childId} already has a scheduled or running turn; mailbox messages were not consumed`,
1038
+ );
1039
+ }
1040
+ } else {
526
1041
  const located = await this.findPersistedChild(parent, childId);
527
- if (!located) throw new Error(`unknown subagent: ${childId}; message was not delivered`);
528
- if (located.descriptor.mode !== "continuable") {
529
- throw new Error(`subagent ${childId} is one-shot and cannot accept follow-up messages`);
1042
+ if (!located) {
1043
+ throw new Error(`unknown subagent: ${childId}; follow-up task was not started`);
530
1044
  }
531
- this.assertDirectParent(parent, located.descriptor);
532
- const manager = SessionManager.open(
1045
+ descriptor = located.descriptor;
1046
+ this.assertContinuableDirectChild(parent, descriptor);
1047
+ manager = SessionManager.open(
533
1048
  located.sessionFile,
534
1049
  parent.sessionManager.getSessionDir(),
535
1050
  parent.cwd,
536
1051
  );
1052
+ }
1053
+ const owner = mailboxOwner(descriptor);
1054
+ const batch = readMailbox(manager.getEntries(), owner).pending;
1055
+ if (batch.length === 0) {
1056
+ throw new Error(`subagent ${childId} has no pending mailbox messages`);
1057
+ }
1058
+ const messageIds = batch.map((message) => message.messageId);
1059
+
1060
+ if (!activation) {
537
1061
  coldPrepared = {
538
1062
  sessionManager: manager,
539
1063
  seedMessageCount: manager.buildSessionContext().messages.length,
@@ -541,25 +1065,42 @@ export class SubagentCoordinator {
541
1065
  };
542
1066
  activation = await this.createActivation({
543
1067
  parent,
544
- descriptor: located.descriptor,
1068
+ descriptor,
545
1069
  prepared: coldPrepared,
546
1070
  isNew: false,
547
1071
  });
548
- if (parent.activation) parent.activation.ownedChildren.add(activation.agentId);
549
- } else {
550
- this.assertDirectParent(parent, activation.descriptor);
551
- }
552
-
553
- const session = activation.runtime.session;
554
- if (activation.currentRun || session.isStreaming) {
555
- if (signal?.aborted) throw signal.reason ?? new Error("message delivery aborted");
556
- await session.followUp(message);
557
- return;
1072
+ this.acquireParentOwnership(activation, parent);
558
1073
  }
559
1074
 
560
1075
  try {
561
- const started = this.startPrompt(activation, message, signal, true);
562
- await started.accepted;
1076
+ this.acquireParentOwnership(activation, parent);
1077
+ const started = this.startPrompt(activation, "", signal, {
1078
+ detachAtAcceptance: true,
1079
+ waitForCapacity: !parent.activation?.holdsBackgroundSlot,
1080
+ preparePrompt: (turnId) =>
1081
+ formatMailboxBatch(batch, turnId),
1082
+ onPromptAccepted: (turnId) => {
1083
+ claimMailboxMessages(
1084
+ manager,
1085
+ messageIds,
1086
+ turnId,
1087
+ owner,
1088
+ );
1089
+ activation.pendingMailboxClaims.add(turnId);
1090
+ },
1091
+ acceptAfterUserMessage: true,
1092
+ });
1093
+ return {
1094
+ activation,
1095
+ accepted: started.accepted,
1096
+ ...(coldPrepared ? { coldPrepared } : {}),
1097
+ outcome: {
1098
+ agentId: childId,
1099
+ taskPath: descriptorTaskPath(descriptor),
1100
+ turnId: started.turnId,
1101
+ claimedMessages: batch.length,
1102
+ },
1103
+ };
563
1104
  } catch (error) {
564
1105
  if (coldPrepared && !activation.published) {
565
1106
  activation.suppressSettlement = true;
@@ -569,49 +1110,449 @@ export class SubagentCoordinator {
569
1110
  }
570
1111
  }
571
1112
 
572
- async interrupt(parent: ParentRef, targetId: string): Promise<void> {
573
- const target = this.active.get(targetId);
574
- if (!target) return;
575
- if (!(await this.isDescendantOf(parent, target.descriptor))) {
576
- throw new Error(`subagent ${targetId} is not a live descendant of ${parent.agentId}`);
577
- }
578
- target.status = "failed";
579
- void target.runtime.session.abort().catch((error) => {
580
- target.lastError = errorText(error);
1113
+ async waitAgent(
1114
+ parent: ParentRef,
1115
+ toolCallId: string,
1116
+ timeoutMs?: number,
1117
+ signal?: AbortSignal,
1118
+ ): Promise<WaitAgentOutcome> {
1119
+ return this.runAdmittedOperation(async () => {
1120
+ const outcome = await this.waitAgentAdmitted(
1121
+ parent,
1122
+ toolCallId,
1123
+ waitTimeout(timeoutMs),
1124
+ signal,
1125
+ );
1126
+ if (outcome.updates.length === 0) {
1127
+ return { ...outcome, taskPaths: {} };
1128
+ }
1129
+ let paths = new Map<string, string>();
1130
+ try {
1131
+ const catalog = await this.catalogRecords(parent);
1132
+ paths = new Map(
1133
+ catalog.records.map((record) => [
1134
+ record.agentId,
1135
+ record.taskPath,
1136
+ ]),
1137
+ );
1138
+ } catch {
1139
+ // Completion delivery is already durably reserved. Readable
1140
+ // path enrichment is cosmetic and must not turn that delivery
1141
+ // into a failed tool call.
1142
+ }
1143
+ return {
1144
+ ...outcome,
1145
+ taskPaths: Object.fromEntries(
1146
+ outcome.updates.map((update) => [
1147
+ update.childAgentId,
1148
+ paths.get(update.childAgentId)
1149
+ ?? update.childAgentId,
1150
+ ]),
1151
+ ),
1152
+ };
581
1153
  });
582
1154
  }
583
1155
 
584
- async list(parent: ParentRef, scope: "children" | "descendants"): Promise<CatalogEntry[]> {
585
- const catalog = await this.catalogRecords(parent);
586
- const records = catalog.records;
587
- const byId = new Map(records.map((record) => [record.agentId, record]));
588
- const children: CatalogChild[] = [];
589
- for (const record of records) {
590
- if (record.descriptor.mode !== "continuable") continue;
591
- const distance = this.distanceFrom(parent.agentId, record.descriptor, byId);
592
- if (distance === undefined || (scope === "children" && distance !== 1)) continue;
593
- children.push({
594
- kind: "child",
595
- agentId: record.agentId,
596
- parentAgentId: record.descriptor.parentAgentId,
597
- depth: distance,
598
- descriptor: record.descriptor,
599
- ...(record.sessionFile ? { sessionFile: record.sessionFile } : {}),
600
- status: record.active
601
- ? record.active.currentRun || record.active.runtime.session.isStreaming
602
- ? "running"
603
- : "idle"
604
- : "ready",
605
- });
1156
+ private async waitAgentAdmitted(
1157
+ parent: ParentRef,
1158
+ toolCallId: string,
1159
+ timeoutMs: number,
1160
+ signal?: AbortSignal,
1161
+ ): Promise<WaitAgentOutcome> {
1162
+ if (this.draining) {
1163
+ throw new Error("pi-subagent is shutting down; wait_agent was not accepted");
606
1164
  }
607
- children.sort(
608
- (left, right) =>
609
- left.descriptor.createdAt.localeCompare(right.descriptor.createdAt) ||
610
- left.agentId.localeCompare(right.agentId),
611
- );
612
- const parentFile = parent.sessionManager.getSessionFile();
613
- const diagnostics = parentFile
614
- ? catalog.diagnostics.filter(
1165
+ if (signal?.aborted) throw abortReason(signal);
1166
+ if (!toolCallId.trim() || toolCallId.length > 512) {
1167
+ throw new Error("wait_agent requires a non-empty tool call id of at most 512 characters");
1168
+ }
1169
+ const key = completionWaiterKey(parent);
1170
+ const deadline = Date.now() + timeoutMs;
1171
+ while (true) {
1172
+ let waiter: CompletionWaiter | undefined;
1173
+ const immediate = await this.completionOperations.run(
1174
+ key,
1175
+ async (): Promise<WaitAgentOutcome | undefined> => {
1176
+ if (this.draining) {
1177
+ throw new Error("pi-subagent is shutting down; wait_agent was interrupted");
1178
+ }
1179
+ if (signal?.aborted) throw abortReason(signal);
1180
+ if (this.completionWaiters.has(key)) {
1181
+ throw new Error(
1182
+ `wait_agent is already waiting for direct-child activity on ${parent.agentId}`,
1183
+ );
1184
+ }
1185
+ const snapshot = readCompletionMailbox(
1186
+ parent.sessionManager.getEntries(),
1187
+ {
1188
+ parentAgentId: parent.agentId,
1189
+ activeRuntimeId: this.runtimeId,
1190
+ },
1191
+ );
1192
+ if (snapshot.currentRuntimeReservations.length > 0) {
1193
+ throw new Error(
1194
+ "a previous wait_agent delivery is awaiting its durable tool result",
1195
+ );
1196
+ }
1197
+ if (snapshot.available.length > 0) {
1198
+ return this.reserveWaitAgentOutcome(
1199
+ parent,
1200
+ toolCallId,
1201
+ timeoutMs,
1202
+ snapshot,
1203
+ );
1204
+ }
1205
+ const remaining = Math.max(0, deadline - Date.now());
1206
+ if (remaining === 0) {
1207
+ return {
1208
+ timedOut: true,
1209
+ timeoutMs,
1210
+ updates: [],
1211
+ unreadUpdates: snapshot.unread.length,
1212
+ taskPaths: {},
1213
+ };
1214
+ }
1215
+ waiter = this.createCompletionWaiter(remaining, signal);
1216
+ this.completionWaiters.set(key, waiter);
1217
+
1218
+ // Append happens before notify, but fold once more after
1219
+ // subscription so no completion can land in the check/register gap.
1220
+ const rechecked = readCompletionMailbox(
1221
+ parent.sessionManager.getEntries(),
1222
+ {
1223
+ parentAgentId: parent.agentId,
1224
+ activeRuntimeId: this.runtimeId,
1225
+ },
1226
+ );
1227
+ if (rechecked.available.length > 0) {
1228
+ try {
1229
+ return this.reserveWaitAgentOutcome(
1230
+ parent,
1231
+ toolCallId,
1232
+ timeoutMs,
1233
+ rechecked,
1234
+ );
1235
+ } finally {
1236
+ this.removeCompletionWaiter(key, waiter);
1237
+ waiter = undefined;
1238
+ }
1239
+ }
1240
+ return undefined;
1241
+ },
1242
+ );
1243
+ if (immediate) return immediate;
1244
+ if (!waiter) {
1245
+ throw new Error("wait_agent failed to establish an activity subscription");
1246
+ }
1247
+ let activity: "activity" | "timeout";
1248
+ try {
1249
+ activity = await waiter.promise;
1250
+ } catch (error) {
1251
+ this.removeCompletionWaiter(key, waiter);
1252
+ throw error;
1253
+ }
1254
+ const afterWake = await this.completionOperations.run(
1255
+ key,
1256
+ async (): Promise<WaitAgentOutcome | undefined> => {
1257
+ try {
1258
+ if (this.draining) {
1259
+ throw new Error(
1260
+ "pi-subagent is shutting down; wait_agent was interrupted",
1261
+ );
1262
+ }
1263
+ if (signal?.aborted) throw abortReason(signal);
1264
+ if (this.completionWaiters.get(key) !== waiter) {
1265
+ throw new Error(
1266
+ "wait_agent lost ownership of its activity subscription",
1267
+ );
1268
+ }
1269
+ const snapshot = readCompletionMailbox(
1270
+ parent.sessionManager.getEntries(),
1271
+ {
1272
+ parentAgentId: parent.agentId,
1273
+ activeRuntimeId: this.runtimeId,
1274
+ },
1275
+ );
1276
+ if (snapshot.currentRuntimeReservations.length > 0) {
1277
+ throw new Error(
1278
+ "a previous wait_agent delivery is awaiting its durable tool result",
1279
+ );
1280
+ }
1281
+ if (snapshot.available.length > 0) {
1282
+ return this.reserveWaitAgentOutcome(
1283
+ parent,
1284
+ toolCallId,
1285
+ timeoutMs,
1286
+ snapshot,
1287
+ );
1288
+ }
1289
+ if (activity === "timeout" || Date.now() >= deadline) {
1290
+ return {
1291
+ timedOut: true,
1292
+ timeoutMs,
1293
+ updates: [],
1294
+ unreadUpdates: snapshot.unread.length,
1295
+ taskPaths: {},
1296
+ };
1297
+ }
1298
+ return undefined;
1299
+ } finally {
1300
+ this.removeCompletionWaiter(key, waiter!);
1301
+ }
1302
+ },
1303
+ );
1304
+ if (afterWake) return afterWake;
1305
+ }
1306
+ }
1307
+
1308
+ private reserveWaitAgentOutcome(
1309
+ parent: ParentRef,
1310
+ toolCallId: string,
1311
+ timeoutMs: number,
1312
+ snapshot: ReturnType<typeof readCompletionMailbox>,
1313
+ ): WaitAgentOutcome {
1314
+ const updates = this.boundedWaitAgentUpdates(snapshot.available);
1315
+ reserveCompletionDelivery(parent.sessionManager, {
1316
+ parentAgentId: parent.agentId,
1317
+ runtimeId: this.runtimeId,
1318
+ toolCallId,
1319
+ completionIds: updates.map((update) => update.completionId),
1320
+ });
1321
+ return {
1322
+ timedOut: false,
1323
+ timeoutMs,
1324
+ updates,
1325
+ unreadUpdates: Math.max(
1326
+ 0,
1327
+ snapshot.unread.length - updates.length,
1328
+ ),
1329
+ taskPaths: {},
1330
+ };
1331
+ }
1332
+
1333
+ private boundedWaitAgentUpdates(
1334
+ available: readonly CompletionUpdate[],
1335
+ ): CompletionUpdate[] {
1336
+ const updates: CompletionUpdate[] = [];
1337
+ let remainingBytes = MAX_WAIT_AGENT_RESULT_BYTES;
1338
+ for (const update of available) {
1339
+ if (updates.length >= MAX_COMPLETIONS_PER_DELIVERY) break;
1340
+ const metadataBytes = Buffer.byteLength(
1341
+ `completion ${update.completionId}\nchild=${update.childAgentId} turn=${update.turnId} stop=${update.stopReason}\n`,
1342
+ "utf8",
1343
+ ) + 256;
1344
+ const fullBytes =
1345
+ metadataBytes + Buffer.byteLength(update.output, "utf8");
1346
+ if (updates.length > 0 && fullBytes > remainingBytes) break;
1347
+ const outputBudget = Math.max(0, remainingBytes - metadataBytes);
1348
+ const truncated = truncateUtf8(update.output, outputBudget);
1349
+ updates.push({
1350
+ ...update,
1351
+ output: truncated.text,
1352
+ ...(truncated.truncated || update.outputTruncated
1353
+ ? {
1354
+ outputTruncated: true,
1355
+ omittedBytes:
1356
+ (update.omittedBytes ?? 0)
1357
+ + truncated.omittedBytes,
1358
+ }
1359
+ : {}),
1360
+ });
1361
+ remainingBytes = Math.max(
1362
+ 0,
1363
+ remainingBytes
1364
+ - metadataBytes
1365
+ - Buffer.byteLength(truncated.text, "utf8"),
1366
+ );
1367
+ if (truncated.truncated) break;
1368
+ }
1369
+ return updates;
1370
+ }
1371
+
1372
+ async releaseWaitAgentDeliveries(
1373
+ parent: ParentRef,
1374
+ reason: string,
1375
+ ): Promise<number> {
1376
+ const key = completionWaiterKey(parent);
1377
+ return this.completionOperations.run(key, async () =>
1378
+ releaseCompletionDeliveries(parent.sessionManager, {
1379
+ parentAgentId: parent.agentId,
1380
+ runtimeId: this.runtimeId,
1381
+ reason,
1382
+ }),
1383
+ );
1384
+ }
1385
+
1386
+ private createCompletionWaiter(
1387
+ timeoutMs: number,
1388
+ signal?: AbortSignal,
1389
+ ): CompletionWaiter {
1390
+ let settled = false;
1391
+ let timer: ReturnType<typeof setTimeout> | undefined;
1392
+ let resolvePromise!: (activity: "activity" | "timeout") => void;
1393
+ let rejectPromise!: (error: Error) => void;
1394
+ const promise = new Promise<"activity" | "timeout">(
1395
+ (resolve, reject) => {
1396
+ resolvePromise = resolve;
1397
+ rejectPromise = reject;
1398
+ },
1399
+ );
1400
+ const onAbort = () => {
1401
+ if (settled) return;
1402
+ settled = true;
1403
+ if (timer) clearTimeout(timer);
1404
+ rejectPromise(abortReason(signal!));
1405
+ };
1406
+ if (signal) signal.addEventListener("abort", onAbort, { once: true });
1407
+ timer = setTimeout(() => {
1408
+ if (settled) return;
1409
+ settled = true;
1410
+ if (signal) signal.removeEventListener("abort", onAbort);
1411
+ resolvePromise("timeout");
1412
+ }, timeoutMs);
1413
+ // An awaited tool deadline must keep headless SDK processes alive.
1414
+ // Wake, abort and shutdown clear it rather than leaving a background timer.
1415
+ const dispose = () => {
1416
+ if (timer) clearTimeout(timer);
1417
+ if (signal) signal.removeEventListener("abort", onAbort);
1418
+ };
1419
+ return {
1420
+ promise,
1421
+ wake: () => {
1422
+ if (settled) return;
1423
+ settled = true;
1424
+ dispose();
1425
+ resolvePromise("activity");
1426
+ },
1427
+ reject: (error) => {
1428
+ if (settled) return;
1429
+ settled = true;
1430
+ dispose();
1431
+ rejectPromise(error);
1432
+ },
1433
+ dispose,
1434
+ };
1435
+ }
1436
+
1437
+ private removeCompletionWaiter(
1438
+ key: string,
1439
+ waiter: CompletionWaiter,
1440
+ ): void {
1441
+ if (this.completionWaiters.get(key) === waiter) {
1442
+ this.completionWaiters.delete(key);
1443
+ }
1444
+ waiter.dispose();
1445
+ }
1446
+
1447
+ async interrupt(parent: ParentRef, targetId: string): Promise<void> {
1448
+ await this.interruptWithOutcome(parent, targetId);
1449
+ }
1450
+
1451
+ async interruptWithOutcome(
1452
+ parent: ParentRef,
1453
+ targetId: string,
1454
+ ): Promise<InterruptOutcome> {
1455
+ return this.runAdmittedOperation(() =>
1456
+ this.interruptAdmitted(parent, targetId),
1457
+ );
1458
+ }
1459
+
1460
+ private async interruptAdmitted(
1461
+ parent: ParentRef,
1462
+ targetId: string,
1463
+ ): Promise<InterruptOutcome> {
1464
+ const resolved = await this.resolveTarget(parent, targetId, {
1465
+ allowUnknownId: true,
1466
+ });
1467
+ return this.agentOperations.run(resolved.agentId, async () => {
1468
+ if (
1469
+ resolved.record
1470
+ && !(await this.isDescendantOf(
1471
+ parent,
1472
+ resolved.record.descriptor,
1473
+ ))
1474
+ ) {
1475
+ throw new Error(
1476
+ `subagent ${resolved.taskPath} is not a descendant of ${parent.taskPath}`,
1477
+ );
1478
+ }
1479
+ const target = this.active.get(resolved.agentId);
1480
+ if (!target) {
1481
+ return {
1482
+ agentId: resolved.agentId,
1483
+ taskPath: resolved.taskPath,
1484
+ active: false,
1485
+ };
1486
+ }
1487
+ if (!(await this.isDescendantOf(parent, target.descriptor))) {
1488
+ throw new Error(
1489
+ `subagent ${resolved.taskPath} is not a live descendant of ${parent.taskPath}`,
1490
+ );
1491
+ }
1492
+ this.touchActivation(target);
1493
+ interruptAgentTurn(target.controlState);
1494
+ this.emitUpdate(target);
1495
+ target.turnAbortController?.abort(
1496
+ new Error(`subagent ${resolved.taskPath} was interrupted`),
1497
+ );
1498
+ void target.runtime.session.abort().catch((error) => {
1499
+ target.lastError = errorText(error);
1500
+ });
1501
+ return {
1502
+ agentId: resolved.agentId,
1503
+ taskPath: descriptorTaskPath(target.descriptor),
1504
+ active: true,
1505
+ };
1506
+ });
1507
+ }
1508
+
1509
+ async list(parent: ParentRef, scope: "children" | "descendants"): Promise<CatalogEntry[]> {
1510
+ return this.runAdmittedOperation(() => this.listAdmitted(parent, scope));
1511
+ }
1512
+
1513
+ private async listAdmitted(
1514
+ parent: ParentRef,
1515
+ scope: "children" | "descendants",
1516
+ ): Promise<CatalogEntry[]> {
1517
+ const catalog = await this.catalogRecords(parent);
1518
+ const records = catalog.records;
1519
+ const byId = new Map(records.map((record) => [record.agentId, record]));
1520
+ const children: CatalogChild[] = [];
1521
+ for (const record of records) {
1522
+ if (record.descriptor.mode !== "continuable") continue;
1523
+ const distance = this.distanceFrom(parent.agentId, record.descriptor, byId);
1524
+ if (distance === undefined || (scope === "children" && distance !== 1)) continue;
1525
+ children.push({
1526
+ kind: "child",
1527
+ agentId: record.agentId,
1528
+ parentAgentId: record.descriptor.parentAgentId,
1529
+ taskPath: record.taskPath,
1530
+ parentTaskPath:
1531
+ record.descriptor.parentAgentId === parent.agentId
1532
+ ? parent.taskPath
1533
+ : (byId.get(record.descriptor.parentAgentId)?.taskPath
1534
+ ?? record.descriptor.parentAgentId),
1535
+ depth: distance,
1536
+ descriptor: record.descriptor,
1537
+ ...(record.sessionFile ? { sessionFile: record.sessionFile } : {}),
1538
+ status: record.active ? catalogStatus(record.active.controlState) : "ready",
1539
+ pendingMessages: record.pendingMessages,
1540
+ unreadUpdates:
1541
+ record.descriptor.parentAgentId === parent.agentId
1542
+ ? (catalog.rootUnreadUpdatesByChild.get(record.agentId) ?? 0)
1543
+ : (byId
1544
+ .get(record.descriptor.parentAgentId)
1545
+ ?.unreadUpdatesByChild.get(record.agentId) ?? 0),
1546
+ });
1547
+ }
1548
+ children.sort(
1549
+ (left, right) =>
1550
+ left.descriptor.createdAt.localeCompare(right.descriptor.createdAt) ||
1551
+ left.agentId.localeCompare(right.agentId),
1552
+ );
1553
+ const parentFile = parent.sessionManager.getSessionFile();
1554
+ const diagnostics = parentFile
1555
+ ? catalog.diagnostics.filter(
615
1556
  (entry) => entry.kind === "diagnostic" && entry.parentSessionFile === parentFile,
616
1557
  )
617
1558
  : [];
@@ -619,50 +1560,115 @@ export class SubagentCoordinator {
619
1560
  }
620
1561
 
621
1562
  async report(child: Activation, output: string): Promise<void> {
1563
+ return this.runAdmittedOperation(() => this.reportAdmitted(child, output));
1564
+ }
1565
+
1566
+ private async reportAdmitted(child: Activation, output: string): Promise<void> {
622
1567
  if (child.descriptor.mode !== "continuable") {
623
1568
  throw new Error("report is available only to continuable subagents");
624
1569
  }
625
1570
  const truncated = truncateUtf8(output, child.descriptor.runtime.maxOutputBytes);
626
- const content = `Background subagent ${child.agentId} reported:\n\n${truncated.text}${
1571
+ const readablePath = descriptorTaskPath(child.descriptor);
1572
+ const content = `Background subagent ${readablePath} (${child.agentId}) reported:\n\n${truncated.text}${
627
1573
  truncated.truncated ? `\n\n[Report truncated; ${truncated.omittedBytes} bytes omitted.]` : ""
628
1574
  }`;
629
- await child.parent.deliver(
630
- REPORT_CUSTOM_TYPE,
631
- content,
632
- {
633
- kind: "report",
634
- childAgentId: child.agentId,
635
- label: child.descriptor.label,
636
- ...(truncated.truncated ? { truncated: true } : {}),
637
- },
638
- child.descriptor.runtime.reportDelivery,
639
- );
1575
+ await child.parent.deliver(REPORT_CUSTOM_TYPE, content, {
1576
+ kind: "report",
1577
+ childAgentId: child.agentId,
1578
+ taskPath: readablePath,
1579
+ label: child.descriptor.label,
1580
+ ...(truncated.truncated ? { truncated: true } : {}),
1581
+ });
1582
+ }
1583
+
1584
+ private runAdmittedOperation<T>(operation: () => Promise<T>): Promise<T> {
1585
+ if (this.draining) {
1586
+ return Promise.reject(new Error("pi-subagent is shutting down"));
1587
+ }
1588
+ const promise = operation();
1589
+ this.admittedOperations.add(promise);
1590
+ const remove = () => {
1591
+ this.admittedOperations.delete(promise);
1592
+ };
1593
+ void promise.then(remove, remove);
1594
+ return promise;
1595
+ }
1596
+
1597
+ private async waitForAdmittedOperations(): Promise<void> {
1598
+ while (this.admittedOperations.size > 0) {
1599
+ await Promise.allSettled([...this.admittedOperations]);
1600
+ }
640
1601
  }
641
1602
 
642
1603
  async shutdown(): Promise<void> {
643
- if (this.draining) return;
1604
+ if (this.shutdownPromise) return this.shutdownPromise;
644
1605
  this.draining = true;
1606
+ this.rejectCompletionWaiters(
1607
+ new Error("pi-subagent is shutting down"),
1608
+ );
1609
+ this.shutdownPromise = this.performShutdown();
1610
+ return this.shutdownPromise;
1611
+ }
1612
+
1613
+ private async performShutdown(): Promise<void> {
1614
+ this.backgroundRuns.close();
1615
+ await this.abortActiveRuns();
1616
+ await this.agentOperations.waitForIdle();
1617
+ await this.idleRuntimeOperations.waitForIdle();
1618
+ await this.completionOperations.waitForIdle();
1619
+ await this.abortActiveRuns();
1620
+ await this.waitForAdmittedOperations();
1621
+ await this.agentOperations.waitForIdle();
1622
+ await this.abortActiveRuns();
645
1623
  const activations = [...this.active.values()];
646
- for (const activation of activations) activation.suppressSettlement = true;
647
1624
  await Promise.allSettled(
648
- activations.map(async (activation) => {
649
- if (!activation.runtime.session.isIdle) await activation.runtime.session.abort();
650
- }),
1625
+ activations
1626
+ .map((activation) => activation.currentRun)
1627
+ .filter(
1628
+ (run): run is Promise<SubagentRunResult> => run !== undefined,
1629
+ ),
651
1630
  );
652
1631
  for (const activation of activations.sort((left, right) => right.descriptor.depth - left.descriptor.depth)) {
653
1632
  await this.disposeActivation(activation).catch(() => {});
654
1633
  }
1634
+ await this.agentOperations.waitForIdle();
1635
+ await this.idleRuntimeOperations.waitForIdle();
1636
+ await this.completionOperations.waitForIdle();
1637
+ }
1638
+
1639
+ private rejectCompletionWaiters(error: Error): void {
1640
+ for (const [key, waiter] of this.completionWaiters) {
1641
+ this.completionWaiters.delete(key);
1642
+ waiter.reject(error);
1643
+ waiter.dispose();
1644
+ }
1645
+ }
1646
+
1647
+ private async abortActiveRuns(): Promise<void> {
1648
+ const activations = [...this.active.values()];
1649
+ for (const activation of activations) {
1650
+ activation.suppressSettlement = true;
1651
+ activation.turnAbortController?.abort(
1652
+ new Error("pi-subagent is shutting down"),
1653
+ );
1654
+ }
1655
+ await Promise.allSettled(
1656
+ activations.map(async (activation) => {
1657
+ if (!activation.runtime.session.isIdle) {
1658
+ await activation.runtime.session.abort();
1659
+ }
1660
+ }),
1661
+ );
655
1662
  }
656
1663
 
657
1664
  createChildToolDefinitions(
658
1665
  getActivation: () => Activation,
659
- enableRunInBackground = true,
660
- defaultBackground = true,
1666
+ runtimeMode: RuntimeMode = "background",
661
1667
  agentDiscovery?: AgentDiscoveryResult,
662
1668
  ): ToolDefinition[] {
663
1669
  const agentNames = agentDiscovery?.agents.map((agent) => agent.name);
664
1670
  const assertBackgroundControlEnabled = (toolName: string): void => {
665
- if (!enableRunInBackground) {
1671
+ if (runtimeMode === "foreground") {
666
1672
  throw new Error(`tool "${toolName}" is unavailable in foreground-only mode`);
667
1673
  }
668
1674
  };
@@ -678,13 +1684,11 @@ export class SubagentCoordinator {
678
1684
  name: "subagent",
679
1685
  label: "Subagent",
680
1686
  description:
681
- "Delegate a standalone task to a fresh child in an isolated session. " +
682
- (!enableRunInBackground
1687
+ "Delegate a standalone task to a named child path, with optional completed-turn context inheritance. " +
1688
+ (runtimeMode === "foreground"
683
1689
  ? "This foreground-only instance always waits for the result."
684
- : defaultBackground
685
- ? "It runs in the background by default and returns a durable id."
686
- : "It waits for the result by default; background mode returns a durable id."),
687
- parameters: delegationParameters(enableRunInBackground, agentNames),
1690
+ : "It returns a readable path plus durable id; use send_message, followup_task, and wait_agent to continue it."),
1691
+ parameters: delegationParameters(agentNames),
688
1692
  execute: async (_id, params, signal, onUpdate) => {
689
1693
  const activation = getActivation();
690
1694
  const outcome = await this.delegate(
@@ -704,7 +1708,10 @@ export class SubagentCoordinator {
704
1708
  name: "subagent_fork",
705
1709
  label: "Subagent Fork",
706
1710
  description:
707
- "Delegate a one-shot task to a child seeded with completed turns from this conversation.",
1711
+ "Delegate a task to a child seeded with all completed turns from this conversation. " +
1712
+ (runtimeMode === "foreground"
1713
+ ? "This foreground-only instance always waits for the result."
1714
+ : "It returns a readable path plus durable id and can be continued through its mailbox."),
708
1715
  parameters: forkDelegationParameters(agentNames),
709
1716
  execute: async (_id, params, signal, onUpdate) => {
710
1717
  const activation = getActivation();
@@ -725,12 +1732,12 @@ export class SubagentCoordinator {
725
1732
  name: "send_message",
726
1733
  label: "Send Message",
727
1734
  description:
728
- "Queue a message as a direct continuable child's next FIFO turn. This returns acceptance, not the child's answer.",
1735
+ "Durably append a message to a direct continuable child's FIFO mailbox without starting or resuming it. Use followup_task to start the queued batch. This returns acceptance, not the child's answer.",
729
1736
  parameters: SendMessageParameters,
730
1737
  execute: async (_id, params, signal) => {
731
1738
  assertBackgroundControlEnabled("send_message");
732
1739
  const activation = getActivation();
733
- await this.sendMessage(
1740
+ const delivery = await this.sendMessageWithOutcome(
734
1741
  this.parentForActivation(activation),
735
1742
  params.subagent_id,
736
1743
  params.message,
@@ -740,10 +1747,92 @@ export class SubagentCoordinator {
740
1747
  content: [
741
1748
  {
742
1749
  type: "text",
743
- text: `message queued as the next turn for subagent ${params.subagent_id}`,
1750
+ text:
1751
+ delivery.kind === "mailbox"
1752
+ ? `message ${delivery.messageId} durably enqueued for ${delivery.taskPath}; ${delivery.pendingMessages} pending`
1753
+ : `message queued as the next turn for ${delivery.taskPath}`,
744
1754
  },
745
1755
  ],
746
- details: { kind: "control", action: "send", agentId: params.subagent_id } satisfies ControlDetails,
1756
+ details: {
1757
+ kind: "control",
1758
+ action: "send",
1759
+ agentId: delivery.agentId,
1760
+ taskPath: delivery.taskPath,
1761
+ ...(delivery.kind === "mailbox"
1762
+ ? {
1763
+ messageId: delivery.messageId,
1764
+ pendingMessages: delivery.pendingMessages,
1765
+ }
1766
+ : {}),
1767
+ } satisfies ControlDetails,
1768
+ };
1769
+ },
1770
+ });
1771
+
1772
+ const followup = defineTool({
1773
+ name: "followup_task",
1774
+ label: "Follow-up Task",
1775
+ description:
1776
+ "Start exactly one scheduled turn for a direct child, atomically claiming its current pending FIFO mailbox batch.",
1777
+ parameters: FollowupTaskParameters,
1778
+ execute: async (_id, params, signal) => {
1779
+ assertBackgroundControlEnabled("followup_task");
1780
+ const activation = getActivation();
1781
+ const outcome = await this.followupTask(
1782
+ this.parentForActivation(activation),
1783
+ params.subagent_id,
1784
+ signal,
1785
+ );
1786
+ return {
1787
+ content: [
1788
+ {
1789
+ type: "text",
1790
+ text: `started turn ${outcome.turnId} for ${outcome.taskPath}, claiming ${outcome.claimedMessages} mailbox message${outcome.claimedMessages === 1 ? "" : "s"}`,
1791
+ },
1792
+ ],
1793
+ details: {
1794
+ kind: "control",
1795
+ action: "followup",
1796
+ agentId: outcome.agentId,
1797
+ taskPath: outcome.taskPath,
1798
+ turnId: outcome.turnId,
1799
+ claimedMessages: outcome.claimedMessages,
1800
+ } satisfies ControlDetails,
1801
+ };
1802
+ },
1803
+ });
1804
+
1805
+ const wait = defineTool({
1806
+ name: "wait_agent",
1807
+ label: "Wait Agent",
1808
+ description:
1809
+ "Wait event-driven for unread completion updates from direct children. This does not start a child or occupy a background scheduler slot.",
1810
+ parameters: WaitAgentParameters,
1811
+ execute: async (id, params, signal) => {
1812
+ assertBackgroundControlEnabled("wait_agent");
1813
+ const activation = getActivation();
1814
+ const outcome = await this.waitAgent(
1815
+ this.parentForActivation(activation),
1816
+ id,
1817
+ params.timeout_ms,
1818
+ signal,
1819
+ );
1820
+ return {
1821
+ content: [
1822
+ {
1823
+ type: "text",
1824
+ text: this.formatWaitAgentOutcome(outcome),
1825
+ },
1826
+ ],
1827
+ details: {
1828
+ kind: "control",
1829
+ action: "wait",
1830
+ timedOut: outcome.timedOut,
1831
+ completionIds: outcome.updates.map(
1832
+ (update) => update.completionId,
1833
+ ),
1834
+ unreadUpdates: outcome.unreadUpdates,
1835
+ } satisfies ControlDetails,
747
1836
  };
748
1837
  },
749
1838
  });
@@ -757,10 +1846,18 @@ export class SubagentCoordinator {
757
1846
  execute: async (_id, params) => {
758
1847
  assertBackgroundControlEnabled("interrupt_agent");
759
1848
  const activation = getActivation();
760
- await this.interrupt(this.parentForActivation(activation), params.agent_id);
1849
+ const outcome = await this.interruptWithOutcome(
1850
+ this.parentForActivation(activation),
1851
+ params.agent_id,
1852
+ );
761
1853
  return {
762
- content: [{ type: "text", text: `interrupt requested for agent ${params.agent_id}` }],
763
- details: { kind: "control", action: "interrupt", agentId: params.agent_id } satisfies ControlDetails,
1854
+ content: [{ type: "text", text: `interrupt requested for ${outcome.taskPath}` }],
1855
+ details: {
1856
+ kind: "control",
1857
+ action: "interrupt",
1858
+ agentId: outcome.agentId,
1859
+ taskPath: outcome.taskPath,
1860
+ } satisfies ControlDetails,
764
1861
  };
765
1862
  },
766
1863
  });
@@ -769,7 +1866,7 @@ export class SubagentCoordinator {
769
1866
  name: "list_agents",
770
1867
  label: "List Agents",
771
1868
  description:
772
- "List direct continuable children or all descendants as running, idle, or ready (persisted and resumable).",
1869
+ "List direct continuable children or all descendants as running, idle, or ready, with separate mailbox task and completion counts.",
773
1870
  parameters: ListAgentsParameters,
774
1871
  execute: async (_id, params) => {
775
1872
  assertBackgroundControlEnabled("list_agents");
@@ -796,18 +1893,28 @@ export class SubagentCoordinator {
796
1893
  await this.report(activation, params.output);
797
1894
  return {
798
1895
  content: [{ type: "text", text: `report accepted by the agent that started you` }],
799
- details: { kind: "control", action: "report", agentId: activation.agentId } satisfies ControlDetails,
1896
+ details: {
1897
+ kind: "control",
1898
+ action: "report",
1899
+ agentId: activation.agentId,
1900
+ taskPath: descriptorTaskPath(activation.descriptor),
1901
+ } satisfies ControlDetails,
800
1902
  };
801
1903
  },
802
1904
  });
803
1905
 
804
- return [spawn, fork, send, interrupt, list, report];
1906
+ return [spawn, fork, send, followup, wait, interrupt, list, report];
805
1907
  }
806
1908
 
807
1909
  outcomeToolResult(outcome: DelegationOutcome): AgentToolResult<DelegationDetails> {
808
1910
  if (outcome.kind === "continuable") {
809
1911
  return {
810
- content: [{ type: "text", text: `started subagent ${outcome.details.agentId}` }],
1912
+ content: [
1913
+ {
1914
+ type: "text",
1915
+ text: `started subagent ${outcome.details.taskPath} (${outcome.details.agentId})`,
1916
+ },
1917
+ ],
811
1918
  details: outcome.details,
812
1919
  };
813
1920
  }
@@ -834,12 +1941,49 @@ export class SubagentCoordinator {
834
1941
  return `${entry.piSessionId} [diagnostic: ${entry.reason}]`;
835
1942
  }
836
1943
  const location =
837
- scope === "descendants" ? ` parent=${entry.parentAgentId} depth=${entry.depth}` : "";
838
- return `${entry.agentId} [${entry.status}]${location} — ${entry.descriptor.label} (${entry.descriptor.agent.name})`;
1944
+ scope === "descendants"
1945
+ ? ` parent=${entry.parentTaskPath} depth=${entry.depth}`
1946
+ : "";
1947
+ const mailbox = ` pending=${entry.pendingMessages} updates=${entry.unreadUpdates}`;
1948
+ return `${entry.taskPath} [${entry.status}]${mailbox}${location} — ${entry.descriptor.label} (${entry.descriptor.agent.name}) id=${entry.agentId}`;
839
1949
  })
840
1950
  .join("\n");
841
1951
  }
842
1952
 
1953
+ formatWaitAgentOutcome(outcome: WaitAgentOutcome): string {
1954
+ if (outcome.timedOut) {
1955
+ return `wait_agent timed out after ${outcome.timeoutMs}ms with no completion updates`;
1956
+ }
1957
+ const updates = outcome.updates.map((update) => {
1958
+ const output = update.output.trim() || "(no output)";
1959
+ const fullPath =
1960
+ outcome.taskPaths[update.childAgentId]
1961
+ ?? update.childAgentId;
1962
+ const readablePath =
1963
+ fullPath.length > 200
1964
+ ? `${fullPath.slice(0, 199)}…`
1965
+ : fullPath;
1966
+ const truncation = update.outputTruncated
1967
+ ? `\n[Completion output truncated${
1968
+ update.omittedBytes !== undefined
1969
+ ? `; ${update.omittedBytes} bytes omitted`
1970
+ : ""
1971
+ }.]`
1972
+ : "";
1973
+ return [
1974
+ `completion ${update.completionId}`,
1975
+ `child=${readablePath} id=${update.childAgentId} turn=${update.turnId} stop=${update.stopReason}`,
1976
+ `${output}${truncation}`,
1977
+ ].join("\n");
1978
+ });
1979
+ if (outcome.unreadUpdates > 0) {
1980
+ updates.push(
1981
+ `${outcome.unreadUpdates} additional completion update${outcome.unreadUpdates === 1 ? "" : "s"} remain unread`,
1982
+ );
1983
+ }
1984
+ return updates.join("\n\n");
1985
+ }
1986
+
843
1987
  private resolveModel(parent: ParentRef, agent: AgentDefinition): Model<any> {
844
1988
  if (!agent.model) {
845
1989
  if (!parent.model) throw new Error("no parent model is selected for the subagent");
@@ -878,8 +2022,7 @@ export class SubagentCoordinator {
878
2022
  if (!activation) throw new Error("subagent activation is not published yet");
879
2023
  return activation;
880
2024
  },
881
- descriptor.runtime.enableRunInBackground,
882
- descriptor.runtime.defaultBackground,
2025
+ descriptor.runtime.runtimeMode,
883
2026
  agentDiscovery,
884
2027
  );
885
2028
  const model =
@@ -996,8 +2139,8 @@ export class SubagentCoordinator {
996
2139
  return false;
997
2140
  }
998
2141
  if (
999
- !descriptor.runtime.enableRunInBackground &&
1000
- BACKGROUND_CONTROL_TOOLS.has(tool)
2142
+ descriptor.runtime.runtimeMode === "foreground"
2143
+ && BACKGROUND_CONTROL_TOOLS.has(tool)
1001
2144
  ) {
1002
2145
  return false;
1003
2146
  }
@@ -1045,17 +2188,32 @@ export class SubagentCoordinator {
1045
2188
  runtime,
1046
2189
  seedMessageCount: options.prepared.seedMessageCount,
1047
2190
  epochMessageStart: runtime.session.messages.length,
1048
- status: "starting",
2191
+ controlState: createAgentControlState(),
1049
2192
  trace: [],
1050
2193
  streamedText: "",
1051
2194
  usage: emptyUsage(),
2195
+ startedTurnIds: new Set(),
2196
+ silentSettlementTurnIds: new Set(),
2197
+ pendingMailboxClaims: new Set(),
2198
+ userMessageGates: new Map(),
1052
2199
  ownedChildren: new Set(),
1053
2200
  onUpdate: options.onUpdate,
1054
2201
  published: false,
2202
+ everPublished: false,
1055
2203
  suppressSettlement: false,
1056
2204
  finalizing: false,
2205
+ holdsBackgroundSlot: false,
2206
+ persistenceGate: createPersistenceGate(
2207
+ runtime.session.sessionManager,
2208
+ options.isNew,
2209
+ ),
2210
+ lastUsedSequence: ++this.activationSequence,
1057
2211
  disposed: false,
1058
2212
  };
2213
+ const existing = this.active.get(activation.agentId);
2214
+ if (existing && !existing.disposed) {
2215
+ throw new Error(`subagent ${activation.agentId} already has a resident runtime`);
2216
+ }
1059
2217
  activation.unsubscribe = runtime.session.subscribe((event) => this.observe(activation!, event));
1060
2218
  this.active.set(activation.agentId, activation);
1061
2219
  return activation;
@@ -1065,16 +2223,33 @@ export class SubagentCoordinator {
1065
2223
  }
1066
2224
  }
1067
2225
 
2226
+ private async acquireBackgroundRun(
2227
+ activation: Activation,
2228
+ signal: AbortSignal | undefined,
2229
+ waitForCapacity: boolean,
2230
+ ): Promise<BackgroundRunPermit | undefined> {
2231
+ if (activation.descriptor.mode !== "continuable") return undefined;
2232
+ const permit = await this.backgroundRuns.acquire({
2233
+ ...(signal ? { signal } : {}),
2234
+ waitForCapacity,
2235
+ });
2236
+ activation.holdsBackgroundSlot = true;
2237
+ return permit;
2238
+ }
2239
+
1068
2240
  private startPrompt(
1069
2241
  activation: Activation,
1070
2242
  prompt: string,
1071
2243
  signal: AbortSignal | undefined,
1072
- detachAtAcceptance: boolean,
1073
- ): { accepted: Promise<void>; result: Promise<SubagentRunResult> } {
2244
+ options: StartPromptOptions,
2245
+ ): { turnId: string; accepted: Promise<void>; result: Promise<SubagentRunResult> } {
1074
2246
  if (activation.currentRun) throw new Error(`subagent ${activation.agentId} is already running`);
1075
2247
  if (signal?.aborted) throw signal.reason ?? new Error("subagent start aborted");
2248
+ this.touchActivation(activation);
1076
2249
  activation.pendingSettlement = undefined;
1077
- activation.status = "running";
2250
+ this.resetTurnCapture(activation);
2251
+ const turnId = uuidv7();
2252
+ queueAgentTurn(activation.controlState, turnId);
1078
2253
  this.emitUpdate(activation);
1079
2254
 
1080
2255
  let resolveAccepted!: () => void;
@@ -1084,47 +2259,127 @@ export class SubagentCoordinator {
1084
2259
  resolveAccepted = resolvePromise;
1085
2260
  rejectAccepted = rejectPromise;
1086
2261
  });
2262
+ const turnAbortController = new AbortController();
2263
+ activation.turnAbortController = turnAbortController;
1087
2264
  const abort = () => {
2265
+ turnAbortController.abort(
2266
+ signal?.reason ?? new Error(`subagent turn ${turnId} was aborted`),
2267
+ );
1088
2268
  void activation.runtime.session.abort().catch(() => {});
1089
2269
  };
1090
2270
  if (signal) signal.addEventListener("abort", abort, { once: true });
2271
+ let preflightSucceeded = false;
2272
+ const acceptPrompt = () => {
2273
+ if (acceptedSettled) return;
2274
+ if (turnAbortController.signal.aborted) {
2275
+ throw abortReason(turnAbortController.signal);
2276
+ }
2277
+ acceptedSettled = true;
2278
+ activation.userMessageGates.delete(turnId);
2279
+ this.publish(activation);
2280
+ if (options.detachAtAcceptance && signal) {
2281
+ signal.removeEventListener("abort", abort);
2282
+ }
2283
+ resolveAccepted();
2284
+ };
2285
+ const rejectPrompt = (error: Error) => {
2286
+ if (acceptedSettled) return;
2287
+ acceptedSettled = true;
2288
+ activation.userMessageGates.delete(turnId);
2289
+ activation.silentSettlementTurnIds.add(turnId);
2290
+ if (!activation.published && !activation.everPublished) {
2291
+ activation.suppressSettlement = true;
2292
+ }
2293
+ rejectAccepted(error);
2294
+ };
2295
+ if (options.acceptAfterUserMessage) {
2296
+ activation.userMessageGates.set(turnId, {
2297
+ prepare: () => {
2298
+ if (turnAbortController.signal.aborted) {
2299
+ throw abortReason(turnAbortController.signal);
2300
+ }
2301
+ options.onPromptAccepted?.(turnId);
2302
+ },
2303
+ accept: acceptPrompt,
2304
+ reject: rejectPrompt,
2305
+ });
2306
+ }
1091
2307
 
1092
2308
  const core = (async (): Promise<SubagentRunResult> => {
2309
+ let permit: BackgroundRunPermit | undefined;
1093
2310
  try {
1094
- await activation.runtime.session.prompt(prompt, {
2311
+ permit = await this.acquireBackgroundRun(
2312
+ activation,
2313
+ turnAbortController.signal,
2314
+ options.waitForCapacity,
2315
+ );
2316
+ if (turnAbortController.signal.aborted) {
2317
+ throw abortReason(turnAbortController.signal);
2318
+ }
2319
+ const preparedPrompt = options.preparePrompt
2320
+ ? options.preparePrompt(turnId)
2321
+ : prompt;
2322
+ startAgentTurn(activation.controlState, turnId);
2323
+ activation.startedTurnIds.add(turnId);
2324
+ this.emitTurnStart(activation, turnId);
2325
+ this.emitUpdate(activation);
2326
+ await activation.runtime.session.prompt(preparedPrompt, {
2327
+ ...(options.preparePrompt
2328
+ ? {
2329
+ expandPromptTemplates: false,
2330
+ source: "extension" as const,
2331
+ }
2332
+ : {}),
1095
2333
  preflightResult: (success) => {
1096
2334
  if (acceptedSettled) return;
1097
- acceptedSettled = true;
1098
2335
  if (success) {
1099
- this.publish(activation);
1100
- if (detachAtAcceptance && signal) signal.removeEventListener("abort", abort);
1101
- resolveAccepted();
2336
+ preflightSucceeded = true;
2337
+ if (options.acceptAfterUserMessage) {
2338
+ return;
2339
+ }
2340
+ options.onPromptAccepted?.(turnId);
2341
+ acceptPrompt();
1102
2342
  } else {
1103
- activation.suppressSettlement = true;
1104
- rejectAccepted(new Error("subagent prompt was rejected before acceptance"));
2343
+ rejectPrompt(
2344
+ new Error("subagent prompt was rejected before acceptance"),
2345
+ );
1105
2346
  }
1106
2347
  },
1107
2348
  });
1108
2349
  if (!acceptedSettled) {
1109
- acceptedSettled = true;
1110
- this.publish(activation);
1111
- if (detachAtAcceptance && signal) signal.removeEventListener("abort", abort);
1112
- resolveAccepted();
2350
+ if (options.acceptAfterUserMessage && preflightSucceeded) {
2351
+ throw new Error(
2352
+ "subagent mailbox prompt was handled before a user turn started",
2353
+ );
2354
+ }
2355
+ if (turnAbortController.signal.aborted) {
2356
+ throw abortReason(turnAbortController.signal);
2357
+ }
2358
+ options.onPromptAccepted?.(turnId);
2359
+ acceptPrompt();
1113
2360
  }
1114
- return this.collectResult(activation, "completed");
2361
+ return this.collectResult(activation, turnId, "completed");
1115
2362
  } catch (error) {
1116
2363
  activation.lastError = errorText(error);
1117
2364
  if (!acceptedSettled) {
1118
- acceptedSettled = true;
1119
- activation.suppressSettlement = true;
1120
- rejectAccepted(error instanceof Error ? error : new Error(String(error)));
2365
+ rejectPrompt(
2366
+ error instanceof Error ? error : new Error(String(error)),
2367
+ );
1121
2368
  }
1122
- const fallback = signal?.aborted ? "aborted" : "error";
1123
- const result = this.collectResult(activation, fallback);
2369
+ const fallback = turnAbortController.signal.aborted ? "aborted" : "error";
2370
+ const result = this.collectResult(activation, turnId, fallback);
1124
2371
  if (!result.output) result.output = activation.lastError;
1125
2372
  return result;
1126
2373
  } finally {
2374
+ activation.userMessageGates.delete(turnId);
1127
2375
  if (signal) signal.removeEventListener("abort", abort);
2376
+ if (activation.turnAbortController === turnAbortController) {
2377
+ activation.turnAbortController = undefined;
2378
+ }
2379
+ if (permit) {
2380
+ activation.holdsBackgroundSlot = false;
2381
+ permit.release();
2382
+ }
1128
2383
  }
1129
2384
  })();
1130
2385
 
@@ -1136,47 +2391,69 @@ export class SubagentCoordinator {
1136
2391
  });
1137
2392
  activation.currentRun = lifecycle;
1138
2393
  void lifecycle.catch(() => {});
1139
- return { accepted, result: lifecycle };
1140
- }
1141
-
1142
- private startInternalMessage(
1143
- activation: Activation,
1144
- customType: string,
1145
- content: string,
1146
- details: ParentMessageDetails,
1147
- ): void {
1148
- if (activation.currentRun || activation.disposed) return;
1149
- activation.pendingSettlement = undefined;
1150
- activation.status = "running";
1151
- const core = Promise.resolve()
1152
- .then(() =>
1153
- activation.runtime.session.sendCustomMessage(
1154
- { customType, content, display: true, details },
1155
- { triggerTurn: true, deliverAs: "followUp" },
1156
- ),
1157
- )
1158
- .then(
1159
- () => this.collectResult(activation, "completed"),
1160
- (error) => {
1161
- activation.lastError = errorText(error);
1162
- const result = this.collectResult(activation, "error");
1163
- if (!result.output) result.output = activation.lastError ?? "";
1164
- return result;
1165
- },
1166
- );
1167
- let lifecycle!: Promise<SubagentRunResult>;
1168
- lifecycle = core.then(async (result) => {
1169
- if (activation.currentRun === lifecycle) activation.currentRun = undefined;
1170
- await this.runFinished(activation, result);
1171
- return result;
1172
- });
1173
- activation.currentRun = lifecycle;
1174
- void lifecycle.catch(() => {});
2394
+ return { turnId, accepted, result: lifecycle };
1175
2395
  }
1176
2396
 
1177
2397
  private async runFinished(activation: Activation, result: SubagentRunResult): Promise<void> {
1178
- activation.status = result.stopReason === "completed" ? "completed" : "failed";
2398
+ activation.pendingMailboxClaims.delete(result.turnId);
2399
+ activation.persistenceGate.reject(
2400
+ new Error(
2401
+ `subagent ${activation.agentId} ended before its session became durable`,
2402
+ ),
2403
+ );
2404
+ finishAgentTurn(activation.controlState, result.turnId, result.stopReason);
2405
+ if (result.stopReason !== "completed") {
2406
+ activation.runtime.session.clearQueue();
2407
+ }
2408
+ const turnStarted = activation.startedTurnIds.delete(result.turnId);
2409
+ if (turnStarted) {
2410
+ this.emitTurnEnd(activation, result);
2411
+ }
1179
2412
  activation.pendingSettlement = result;
2413
+ if (
2414
+ turnStarted
2415
+ && !activation.silentSettlementTurnIds.has(result.turnId)
2416
+ && activation.published
2417
+ && activation.descriptor.mode === "continuable"
2418
+ ) {
2419
+ try {
2420
+ appendCompletionUpdate(activation.parent.sessionManager, {
2421
+ parentAgentId: activation.descriptor.parentAgentId,
2422
+ childAgentId: activation.agentId,
2423
+ result,
2424
+ });
2425
+ } catch (error) {
2426
+ activation.lastError = errorText(error);
2427
+ let durableFallback = false;
2428
+ try {
2429
+ appendUndeliveredCompletion(
2430
+ activation.runtime.session.sessionManager,
2431
+ {
2432
+ parentAgentId:
2433
+ activation.descriptor.parentAgentId,
2434
+ childAgentId: activation.agentId,
2435
+ result,
2436
+ error: activation.lastError,
2437
+ },
2438
+ );
2439
+ durableFallback = true;
2440
+ } catch {
2441
+ // The explicit event below remains the final observable path
2442
+ // when both parent delivery and child fallback persistence fail.
2443
+ }
2444
+ this.pi.events.emit("pi-subagent:completion-error", {
2445
+ runId: activation.runId,
2446
+ turnId: result.turnId,
2447
+ agentId: activation.agentId,
2448
+ parentAgentId: activation.descriptor.parentAgentId,
2449
+ error: activation.lastError,
2450
+ durableFallback,
2451
+ });
2452
+ }
2453
+ this.completionWaiters
2454
+ .get(completionWaiterKey(activation.parent))
2455
+ ?.wake();
2456
+ }
1180
2457
  this.emitUpdate(activation, result);
1181
2458
  if (activation.descriptor.mode === "one-shot") {
1182
2459
  this.emitEnd(activation, result);
@@ -1184,7 +2461,6 @@ export class SubagentCoordinator {
1184
2461
  }
1185
2462
  if (activation.suppressSettlement || this.draining) return;
1186
2463
  if (activation.ownedChildren.size > 0) {
1187
- activation.status = "waiting";
1188
2464
  this.emitUpdate(activation, result);
1189
2465
  return;
1190
2466
  }
@@ -1192,48 +2468,59 @@ export class SubagentCoordinator {
1192
2468
  }
1193
2469
 
1194
2470
  private async finalizeContinuable(activation: Activation): Promise<void> {
1195
- if (activation.finalizing || activation.disposed || activation.currentRun) return;
2471
+ const retained = await this.agentOperations.run(activation.agentId, () =>
2472
+ this.finalizeContinuableLocked(activation),
2473
+ );
2474
+ if (retained) {
2475
+ await this.trimIdleRuntimes(activation.agentId);
2476
+ }
2477
+ }
2478
+
2479
+ private async finalizeContinuableLocked(activation: Activation): Promise<boolean> {
2480
+ if (activation.finalizing || activation.disposed || activation.currentRun) {
2481
+ return false;
2482
+ }
1196
2483
  const result = activation.pendingSettlement;
1197
- if (!result || activation.ownedChildren.size > 0) return;
2484
+ if (!result || activation.ownedChildren.size > 0) return false;
1198
2485
  activation.finalizing = true;
1199
- activation.finalizePromise = (async () => {
1200
- if (!activation.suppressSettlement && !this.draining) {
1201
- await this.deliverSettlement(activation, result).catch((error) => {
1202
- activation.lastError = errorText(error);
1203
- });
2486
+ const finalizePromise = (async (): Promise<boolean> => {
2487
+ let retained = false;
2488
+ try {
2489
+ this.emitEnd(activation, result);
2490
+ activation.silentSettlementTurnIds.delete(result.turnId);
2491
+ activation.pendingSettlement = undefined;
2492
+ if (this.shouldRetainIdleRuntime(activation)) {
2493
+ activation.published = false;
2494
+ activation.runId = uuidv7();
2495
+ await this.releaseParentOwnership(activation);
2496
+ this.touchActivation(activation);
2497
+ retained = true;
2498
+ } else {
2499
+ await this.disposeActivation(activation);
2500
+ await this.releaseParentOwnership(activation);
2501
+ }
2502
+ } finally {
2503
+ if (!activation.disposed) {
2504
+ activation.finalizing = false;
2505
+ }
1204
2506
  }
1205
- this.emitEnd(activation, result);
1206
- await this.disposeActivation(activation);
1207
- await this.releaseParentOwnership(activation);
2507
+ return retained;
1208
2508
  })();
1209
- await activation.finalizePromise;
1210
- }
1211
-
1212
- private async deliverSettlement(activation: Activation, result: SubagentRunResult): Promise<void> {
1213
- const truncated = truncateUtf8(result.output, activation.descriptor.runtime.maxOutputBytes);
1214
- const closing = truncated.text.trim()
1215
- ? `Its closing message:\n\n${truncated.text}`
1216
- : "It left no closing message.";
1217
- const content = `Background subagent ${activation.agentId} ${stopReasonHeadline(result.stopReason)} and will do no further work unless you send it more.\n\n${closing}${
1218
- truncated.truncated ? `\n\n[Closing message truncated; ${truncated.omittedBytes} bytes omitted.]` : ""
1219
- }`;
1220
- await activation.parent.deliver(
1221
- SETTLED_CUSTOM_TYPE,
1222
- content,
1223
- {
1224
- kind: "settled",
1225
- childAgentId: activation.agentId,
1226
- label: activation.descriptor.label,
1227
- stopReason: result.stopReason,
1228
- ...(truncated.truncated ? { truncated: true } : {}),
1229
- },
1230
- "wakeup",
1231
- );
2509
+ activation.finalizePromise = finalizePromise;
2510
+ try {
2511
+ return await finalizePromise;
2512
+ } finally {
2513
+ if (activation.finalizePromise === finalizePromise) {
2514
+ activation.finalizePromise = undefined;
2515
+ }
2516
+ if (!activation.disposed) activation.finalizing = false;
2517
+ }
1232
2518
  }
1233
2519
 
1234
2520
  private parentForActivation(activation: Activation): ParentRef {
1235
2521
  return {
1236
2522
  agentId: activation.agentId,
2523
+ taskPath: descriptorTaskPath(activation.descriptor),
1237
2524
  depth: activation.descriptor.depth,
1238
2525
  cwd: activation.descriptor.cwd,
1239
2526
  sessionManager: activation.runtime.session.sessionManager,
@@ -1242,31 +2529,30 @@ export class SubagentCoordinator {
1242
2529
  thinkingLevel: activation.runtime.session.thinkingLevel,
1243
2530
  projectTrusted: activation.parent.projectTrusted,
1244
2531
  activation,
1245
- deliver: async (customType, content, details, delivery) => {
2532
+ deliver: async (customType, content, details) => {
1246
2533
  if (activation.disposed) throw new Error(`parent subagent ${activation.agentId} is no longer resident`);
1247
2534
  const session = activation.runtime.session;
1248
- if (delivery === "quiet") {
1249
- await session.sendCustomMessage(
1250
- { customType, content, display: true, details },
1251
- session.isStreaming
1252
- ? { triggerTurn: false, deliverAs: "nextTurn" }
1253
- : { triggerTurn: false },
1254
- );
1255
- return;
1256
- }
1257
- if (activation.currentRun || session.isStreaming) {
1258
- await session.sendCustomMessage(
1259
- { customType, content, display: true, details },
1260
- { triggerTurn: true, deliverAs: "followUp" },
1261
- );
1262
- return;
1263
- }
1264
- this.startInternalMessage(activation, customType, content, details);
2535
+ await session.sendCustomMessage(
2536
+ { customType, content, display: true, details },
2537
+ session.isStreaming
2538
+ ? { triggerTurn: false, deliverAs: "nextTurn" }
2539
+ : { triggerTurn: false },
2540
+ );
1265
2541
  },
1266
2542
  };
1267
2543
  }
1268
2544
 
1269
- private collectResult(activation: Activation, fallback: SubagentStopReason): SubagentRunResult {
2545
+ private resetTurnCapture(activation: Activation): void {
2546
+ activation.epochMessageStart = activation.runtime.session.messages.length;
2547
+ activation.streamedText = "";
2548
+ activation.usage = emptyUsage();
2549
+ }
2550
+
2551
+ private collectResult(
2552
+ activation: Activation,
2553
+ turnId: string,
2554
+ fallback: SubagentStopReason,
2555
+ ): SubagentRunResult {
1270
2556
  const messages = activation.runtime.session.messages;
1271
2557
  const output = finalAssistantText(messages, activation.epochMessageStart, activation.streamedText);
1272
2558
  const stopReason = finalStopReason(messages, activation.epochMessageStart, fallback);
@@ -1274,6 +2560,7 @@ export class SubagentCoordinator {
1274
2560
  const sessionFile = activation.runtime.session.sessionFile;
1275
2561
  return {
1276
2562
  agentId: activation.agentId,
2563
+ turnId,
1277
2564
  piSessionId: activation.runtime.session.sessionId,
1278
2565
  ...(sessionFile ? { sessionFile } : {}),
1279
2566
  output: truncated.truncated
@@ -1281,6 +2568,12 @@ export class SubagentCoordinator {
1281
2568
  sessionFile ? ` Full output: ${sessionFile}` : " Full output remains in the active child session."
1282
2569
  }]`
1283
2570
  : truncated.text,
2571
+ ...(truncated.truncated
2572
+ ? {
2573
+ outputTruncated: true,
2574
+ omittedBytes: truncated.omittedBytes,
2575
+ }
2576
+ : {}),
1284
2577
  stopReason,
1285
2578
  usage: structuredClone(activation.usage),
1286
2579
  };
@@ -1301,12 +2594,47 @@ export class SubagentCoordinator {
1301
2594
  this.emitUpdate(activation);
1302
2595
  return;
1303
2596
  }
2597
+ if (event.type === "agent_end") {
2598
+ void this.releaseWaitAgentDeliveries(
2599
+ this.parentForActivation(activation),
2600
+ "parent agent turn ended without a durable wait_agent result",
2601
+ ).catch((error) => {
2602
+ activation.lastError = errorText(error);
2603
+ });
2604
+ return;
2605
+ }
1304
2606
  if (event.type !== "message_end") return;
2607
+ if (event.message.role === "user") {
2608
+ const turnId = currentAgentTurnId(activation.controlState);
2609
+ const gate = turnId
2610
+ ? activation.userMessageGates.get(turnId)
2611
+ : undefined;
2612
+ if (turnId && gate) {
2613
+ try {
2614
+ gate.prepare();
2615
+ commitMailboxClaim(
2616
+ activation.runtime.session.sessionManager,
2617
+ turnId,
2618
+ event.message,
2619
+ );
2620
+ activation.pendingMailboxClaims.delete(turnId);
2621
+ gate.accept();
2622
+ } catch (error) {
2623
+ const failure =
2624
+ error instanceof Error ? error : new Error(String(error));
2625
+ activation.lastError = failure.message;
2626
+ gate.reject(failure);
2627
+ void activation.runtime.session.abort().catch(() => {});
2628
+ }
2629
+ }
2630
+ return;
2631
+ }
1305
2632
  if (event.message.role === "toolResult") {
1306
2633
  if (event.message.usage) addUsage(activation.usage, event.message.usage, false);
1307
2634
  return;
1308
2635
  }
1309
2636
  if (event.message.role !== "assistant") return;
2637
+ activation.persistenceGate.resolve();
1310
2638
  addUsage(activation.usage, event.message.usage);
1311
2639
  const text = event.message.content
1312
2640
  .filter((part): part is Extract<(typeof event.message.content)[number], { type: "text" }> => part.type === "text")
@@ -1327,13 +2655,16 @@ export class SubagentCoordinator {
1327
2655
  private publish(activation: Activation): void {
1328
2656
  if (activation.published) return;
1329
2657
  activation.published = true;
2658
+ activation.everPublished = true;
1330
2659
  this.pi.events.emit("pi-subagent:start", {
1331
2660
  runId: activation.runId,
1332
2661
  agentId: activation.agentId,
2662
+ taskPath: descriptorTaskPath(activation.descriptor),
1333
2663
  piSessionId: activation.runtime.session.sessionId,
1334
2664
  parentAgentId: activation.descriptor.parentAgentId,
1335
2665
  provider: activation.descriptor.provider,
1336
2666
  mode: activation.descriptor.mode,
2667
+ context: descriptorContext(activation.descriptor),
1337
2668
  });
1338
2669
  }
1339
2670
 
@@ -1342,6 +2673,35 @@ export class SubagentCoordinator {
1342
2673
  this.pi.events.emit("pi-subagent:end", {
1343
2674
  runId: activation.runId,
1344
2675
  agentId: activation.agentId,
2676
+ taskPath: descriptorTaskPath(activation.descriptor),
2677
+ piSessionId: activation.runtime.session.sessionId,
2678
+ parentAgentId: activation.descriptor.parentAgentId,
2679
+ provider: activation.descriptor.provider,
2680
+ mode: activation.descriptor.mode,
2681
+ stopReason: result.stopReason,
2682
+ output: result.output,
2683
+ });
2684
+ }
2685
+
2686
+ private emitTurnStart(activation: Activation, turnId: string): void {
2687
+ this.pi.events.emit("pi-subagent:turn-start", {
2688
+ runId: activation.runId,
2689
+ turnId,
2690
+ agentId: activation.agentId,
2691
+ taskPath: descriptorTaskPath(activation.descriptor),
2692
+ piSessionId: activation.runtime.session.sessionId,
2693
+ parentAgentId: activation.descriptor.parentAgentId,
2694
+ provider: activation.descriptor.provider,
2695
+ mode: activation.descriptor.mode,
2696
+ });
2697
+ }
2698
+
2699
+ private emitTurnEnd(activation: Activation, result: SubagentRunResult): void {
2700
+ this.pi.events.emit("pi-subagent:turn-end", {
2701
+ runId: activation.runId,
2702
+ turnId: result.turnId,
2703
+ agentId: activation.agentId,
2704
+ taskPath: descriptorTaskPath(activation.descriptor),
1345
2705
  piSessionId: activation.runtime.session.sessionId,
1346
2706
  parentAgentId: activation.descriptor.parentAgentId,
1347
2707
  provider: activation.descriptor.provider,
@@ -1355,13 +2715,21 @@ export class SubagentCoordinator {
1355
2715
  return {
1356
2716
  kind: "delegation",
1357
2717
  agentId: activation.agentId,
2718
+ taskPath: descriptorTaskPath(activation.descriptor),
2719
+ ...(currentAgentTurnId(activation.controlState)
2720
+ ? { turnId: currentAgentTurnId(activation.controlState) }
2721
+ : {}),
1358
2722
  piSessionId: activation.runtime.session.sessionId,
1359
2723
  provider: activation.descriptor.provider,
1360
2724
  mode: activation.descriptor.mode,
2725
+ context: descriptorContext(activation.descriptor),
1361
2726
  agent: activation.descriptor.agent.name,
1362
2727
  label: activation.descriptor.label,
1363
2728
  depth: activation.descriptor.depth,
1364
- status: activation.status,
2729
+ status: delegationStatus(
2730
+ activation.controlState,
2731
+ activation.ownedChildren.size > 0,
2732
+ ),
1365
2733
  ...(activation.runtime.session.sessionFile
1366
2734
  ? { sessionFile: activation.runtime.session.sessionFile }
1367
2735
  : {}),
@@ -1403,6 +2771,25 @@ export class SubagentCoordinator {
1403
2771
  private async disposeActivation(activation: Activation): Promise<void> {
1404
2772
  if (activation.disposed) return;
1405
2773
  activation.disposed = true;
2774
+ const waiterKey = completionWaiterKey(
2775
+ this.parentForActivation(activation),
2776
+ );
2777
+ const waiter = this.completionWaiters.get(waiterKey);
2778
+ if (waiter) {
2779
+ this.removeCompletionWaiter(waiterKey, waiter);
2780
+ waiter.reject(
2781
+ new Error(
2782
+ `parent subagent ${activation.agentId} was disposed while wait_agent was pending`,
2783
+ ),
2784
+ );
2785
+ }
2786
+ activation.persistenceGate.reject(
2787
+ new Error(
2788
+ `subagent ${activation.agentId} ended before its session became durable`,
2789
+ ),
2790
+ );
2791
+ setAgentResidency(activation.controlState, "unloaded");
2792
+ if (activation.descriptor.mode === "one-shot") closeAgent(activation.controlState);
1406
2793
  activation.unsubscribe?.();
1407
2794
  activation.unsubscribe = undefined;
1408
2795
  if (!activation.runtime.session.isIdle) await activation.runtime.session.abort().catch(() => {});
@@ -1414,8 +2801,9 @@ export class SubagentCoordinator {
1414
2801
  }
1415
2802
 
1416
2803
  private async releaseParentOwnership(activation: Activation): Promise<void> {
1417
- const owner = activation.parent.activation;
2804
+ const owner = activation.ownerActivation;
1418
2805
  if (!owner) return;
2806
+ activation.ownerActivation = undefined;
1419
2807
  owner.ownedChildren.delete(activation.agentId);
1420
2808
  if (
1421
2809
  !owner.currentRun &&
@@ -1423,16 +2811,394 @@ export class SubagentCoordinator {
1423
2811
  owner.pendingSettlement &&
1424
2812
  owner.descriptor.mode === "continuable"
1425
2813
  ) {
1426
- await this.finalizeContinuable(owner);
2814
+ void this.finalizeContinuable(owner).catch((error) => {
2815
+ owner.lastError = errorText(error);
2816
+ });
2817
+ }
2818
+ }
2819
+
2820
+ private acquireParentOwnership(
2821
+ activation: Activation,
2822
+ parent: ParentRef,
2823
+ ): void {
2824
+ if (
2825
+ activation.ownerActivation
2826
+ && activation.ownerActivation !== parent.activation
2827
+ ) {
2828
+ throw new Error(
2829
+ `subagent ${descriptorTaskPath(activation.descriptor)} is still owned by another resident parent`,
2830
+ );
2831
+ }
2832
+ activation.parent = parent;
2833
+ if (!parent.activation) return;
2834
+ parent.activation.ownedChildren.add(activation.agentId);
2835
+ activation.ownerActivation = parent.activation;
2836
+ }
2837
+
2838
+ private touchActivation(activation: Activation): void {
2839
+ activation.lastUsedSequence = ++this.activationSequence;
2840
+ }
2841
+
2842
+ private shouldRetainIdleRuntime(activation: Activation): boolean {
2843
+ return (
2844
+ this.idleRuntimeLimit > 0
2845
+ && !this.draining
2846
+ && activation.descriptor.mode === "continuable"
2847
+ && !activation.disposed
2848
+ && !activation.currentRun
2849
+ && activation.runtime.session.isIdle
2850
+ && activation.ownedChildren.size === 0
2851
+ && activation.pendingMailboxClaims.size === 0
2852
+ && activation.persistenceGate.state !== "rejected"
2853
+ );
2854
+ }
2855
+
2856
+ private isEvictableIdleRuntime(activation: Activation): boolean {
2857
+ return (
2858
+ !activation.disposed
2859
+ && !activation.finalizing
2860
+ && activation.descriptor.mode === "continuable"
2861
+ && !activation.currentRun
2862
+ && !activation.pendingSettlement
2863
+ && activation.runtime.session.isIdle
2864
+ && activation.ownedChildren.size === 0
2865
+ && activation.pendingMailboxClaims.size === 0
2866
+ && !activation.ownerActivation
2867
+ );
2868
+ }
2869
+
2870
+ private async trimIdleRuntimes(protectedAgentId?: string): Promise<void> {
2871
+ await this.idleRuntimeOperations.run("idle-runtime-lru", async () =>
2872
+ this.trimIdleRuntimesLocked(protectedAgentId),
2873
+ );
2874
+ }
2875
+
2876
+ private async trimIdleRuntimesLocked(
2877
+ protectedAgentId?: string,
2878
+ ): Promise<void> {
2879
+ while (true) {
2880
+ const retained = [...this.active.values()].filter(
2881
+ (activation) =>
2882
+ activation.agentId === protectedAgentId
2883
+ ? this.shouldRetainIdleRuntime(activation)
2884
+ : this.isEvictableIdleRuntime(activation),
2885
+ );
2886
+ if (retained.length <= this.idleRuntimeLimit) return;
2887
+ const candidate = retained
2888
+ .filter(
2889
+ (activation) =>
2890
+ activation.agentId !== protectedAgentId
2891
+ && this.isEvictableIdleRuntime(activation),
2892
+ )
2893
+ .sort(
2894
+ (left, right) =>
2895
+ left.lastUsedSequence - right.lastUsedSequence
2896
+ || left.agentId.localeCompare(right.agentId),
2897
+ )[0];
2898
+ if (!candidate) return;
2899
+ await this.agentOperations.run(candidate.agentId, async () => {
2900
+ if (
2901
+ this.active.get(candidate.agentId) === candidate
2902
+ && this.isEvictableIdleRuntime(candidate)
2903
+ ) {
2904
+ await this.disposeActivation(candidate);
2905
+ }
2906
+ });
1427
2907
  }
1428
2908
  }
1429
2909
 
1430
2910
  private assertDirectParent(parent: ParentRef, descriptor: SubagentDescriptor): void {
1431
2911
  if (descriptor.parentAgentId !== parent.agentId) {
1432
2912
  throw new Error(
1433
- `subagent ${descriptor.label} is not a direct child of ${parent.agentId}; message was not delivered`,
2913
+ `subagent ${descriptor.label} is not a direct child of ${parent.agentId}`,
2914
+ );
2915
+ }
2916
+ }
2917
+
2918
+ private assertContinuableDirectChild(
2919
+ parent: ParentRef,
2920
+ descriptor: SubagentDescriptor,
2921
+ ): void {
2922
+ if (descriptor.mode !== "continuable") {
2923
+ throw new Error(
2924
+ `subagent ${descriptor.agentId} is one-shot and cannot accept follow-up work`,
1434
2925
  );
1435
2926
  }
2927
+ this.assertDirectParent(parent, descriptor);
2928
+ }
2929
+
2930
+ private treeRootAgentId(
2931
+ agentId: string,
2932
+ byId: ReadonlyMap<string, CatalogRecord>,
2933
+ ): string {
2934
+ let current = agentId;
2935
+ const visited = new Set<string>();
2936
+ while (true) {
2937
+ if (visited.has(current)) {
2938
+ throw new Error("subagent descriptor lineage contains a cycle");
2939
+ }
2940
+ visited.add(current);
2941
+ const record = byId.get(current);
2942
+ if (!record) return current;
2943
+ current = record.descriptor.parentAgentId;
2944
+ }
2945
+ }
2946
+
2947
+ private async reserveTask(
2948
+ parent: ParentRef,
2949
+ requestedName: string | undefined,
2950
+ label: string,
2951
+ ): Promise<ReservedTask> {
2952
+ const catalog = await this.catalogRecords(parent);
2953
+ const usedPaths = new Set(
2954
+ catalog.records
2955
+ .filter(
2956
+ (record) =>
2957
+ record.descriptor.parentAgentId === parent.agentId,
2958
+ )
2959
+ .map((record) => record.taskPath),
2960
+ );
2961
+ const reserve = (name: string): ReservedTask | undefined => {
2962
+ const path = taskPath(parent.taskPath, name);
2963
+ const reservationKey = `${parent.agentId}:${path}`;
2964
+ if (
2965
+ usedPaths.has(path)
2966
+ || this.reservedTaskPaths.has(reservationKey)
2967
+ ) {
2968
+ return undefined;
2969
+ }
2970
+ this.reservedTaskPaths.add(reservationKey);
2971
+ let released = false;
2972
+ return {
2973
+ name,
2974
+ path,
2975
+ release: () => {
2976
+ if (released) return;
2977
+ released = true;
2978
+ this.reservedTaskPaths.delete(reservationKey);
2979
+ },
2980
+ };
2981
+ };
2982
+
2983
+ if (requestedName !== undefined) {
2984
+ const name = validateTaskName(requestedName);
2985
+ const reserved = reserve(name);
2986
+ if (!reserved) {
2987
+ throw new Error(
2988
+ `task path ${taskPath(parent.taskPath, name)} is already in use`,
2989
+ );
2990
+ }
2991
+ return reserved;
2992
+ }
2993
+
2994
+ const base = slugTaskName(label);
2995
+ const initial = reserve(base);
2996
+ if (initial) return initial;
2997
+ for (let ordinal = 2; ordinal < Number.MAX_SAFE_INTEGER; ordinal++) {
2998
+ const candidate = reserve(numberedTaskName(base, ordinal));
2999
+ if (candidate) return candidate;
3000
+ }
3001
+ throw new Error(`could not allocate a readable child path under ${parent.taskPath}`);
3002
+ }
3003
+
3004
+ private async resolveTarget(
3005
+ parent: ParentRef,
3006
+ target: string,
3007
+ options: { allowUnknownId?: boolean } = {},
3008
+ ): Promise<ResolvedTarget> {
3009
+ const catalog = await this.catalogRecords(parent);
3010
+ const byId = new Map(
3011
+ catalog.records.map((record) => [record.agentId, record]),
3012
+ );
3013
+ if (isAgentId(target)) {
3014
+ const record = byId.get(target);
3015
+ if (record) {
3016
+ return {
3017
+ agentId: record.agentId,
3018
+ taskPath: record.taskPath,
3019
+ record,
3020
+ };
3021
+ }
3022
+ if (options.allowUnknownId) {
3023
+ return {
3024
+ agentId: target,
3025
+ taskPath: target,
3026
+ };
3027
+ }
3028
+ return {
3029
+ agentId: target,
3030
+ taskPath: target,
3031
+ };
3032
+ }
3033
+
3034
+ const path = resolveTaskPath(parent.taskPath, target);
3035
+ const rootAgentId = this.treeRootAgentId(parent.agentId, byId);
3036
+ const matches = this.reachableTreeRecords(rootAgentId, byId).filter(
3037
+ (record) => record.taskPath === path,
3038
+ );
3039
+ if (matches.length === 0) {
3040
+ throw new Error(`unknown subagent task path: ${path}`);
3041
+ }
3042
+ if (matches.length > 1) {
3043
+ throw new Error(
3044
+ `ambiguous subagent task path ${path}; use a durable agent id`,
3045
+ );
3046
+ }
3047
+ const record = matches[0]!;
3048
+ return {
3049
+ agentId: record.agentId,
3050
+ taskPath: record.taskPath,
3051
+ record,
3052
+ };
3053
+ }
3054
+
3055
+ private reachableTreeRecords(
3056
+ rootAgentId: string,
3057
+ byId: ReadonlyMap<string, CatalogRecord>,
3058
+ ): CatalogRecord[] {
3059
+ const paths = new Map<string, string>([
3060
+ [rootAgentId, ROOT_TASK_PATH],
3061
+ ]);
3062
+ const depths = new Map<string, number>([[rootAgentId, 0]]);
3063
+ const reachable: CatalogRecord[] = [];
3064
+ const pending = new Set(byId.values());
3065
+ let progressed = true;
3066
+ while (progressed) {
3067
+ progressed = false;
3068
+ for (const record of [...pending]) {
3069
+ const parentPath = paths.get(
3070
+ record.descriptor.parentAgentId,
3071
+ );
3072
+ const parentDepth = depths.get(
3073
+ record.descriptor.parentAgentId,
3074
+ );
3075
+ if (parentPath === undefined || parentDepth === undefined) {
3076
+ continue;
3077
+ }
3078
+ {
3079
+ let expectedPath: string;
3080
+ try {
3081
+ expectedPath = taskPath(
3082
+ parentPath,
3083
+ record.descriptor.task.name,
3084
+ );
3085
+ } catch {
3086
+ pending.delete(record);
3087
+ progressed = true;
3088
+ continue;
3089
+ }
3090
+ if (
3091
+ record.taskPath !== expectedPath
3092
+ || record.descriptor.depth !== parentDepth + 1
3093
+ ) {
3094
+ pending.delete(record);
3095
+ progressed = true;
3096
+ continue;
3097
+ }
3098
+ }
3099
+ pending.delete(record);
3100
+ reachable.push(record);
3101
+ paths.set(record.agentId, record.taskPath);
3102
+ depths.set(record.agentId, record.descriptor.depth);
3103
+ progressed = true;
3104
+ }
3105
+ }
3106
+ return reachable;
3107
+ }
3108
+
3109
+ private catalogTopologyDiagnostics(
3110
+ records: readonly CatalogRecord[],
3111
+ ): CatalogDiagnostic[] {
3112
+ const byId = new Map(
3113
+ records.map((record) => [record.agentId, record]),
3114
+ );
3115
+ const problems = new Map<string, string>();
3116
+ for (const record of records) {
3117
+ const parent = byId.get(record.descriptor.parentAgentId);
3118
+ if (parent) {
3119
+ let expectedPath: string | undefined;
3120
+ try {
3121
+ expectedPath = taskPath(
3122
+ parent.taskPath,
3123
+ record.descriptor.task.name,
3124
+ );
3125
+ } catch {
3126
+ problems.set(
3127
+ record.agentId,
3128
+ "descriptor task path cannot be joined to its parent",
3129
+ );
3130
+ }
3131
+ if (
3132
+ expectedPath !== undefined
3133
+ && (
3134
+ record.taskPath !== expectedPath
3135
+ || record.descriptor.depth
3136
+ !== parent.descriptor.depth + 1
3137
+ )
3138
+ ) {
3139
+ problems.set(
3140
+ record.agentId,
3141
+ "descriptor task path or depth does not match its parent",
3142
+ );
3143
+ }
3144
+ } else if (
3145
+ record.descriptor.depth === 1
3146
+ && record.taskPath
3147
+ !== taskPath(
3148
+ ROOT_TASK_PATH,
3149
+ record.descriptor.task.name,
3150
+ )
3151
+ ) {
3152
+ problems.set(
3153
+ record.agentId,
3154
+ "root child task path does not match its task name",
3155
+ );
3156
+ }
3157
+ }
3158
+
3159
+ for (const start of records) {
3160
+ const chain: string[] = [];
3161
+ const indexes = new Map<string, number>();
3162
+ let current: CatalogRecord | undefined = start;
3163
+ while (current) {
3164
+ const existing = indexes.get(current.agentId);
3165
+ if (existing !== undefined) {
3166
+ for (const agentId of chain.slice(existing)) {
3167
+ problems.set(
3168
+ agentId,
3169
+ "descriptor lineage contains a cycle",
3170
+ );
3171
+ }
3172
+ break;
3173
+ }
3174
+ indexes.set(current.agentId, chain.length);
3175
+ chain.push(current.agentId);
3176
+ current = byId.get(current.descriptor.parentAgentId);
3177
+ }
3178
+ }
3179
+
3180
+ return [...problems]
3181
+ .map(([agentId, message]) => {
3182
+ const record = byId.get(agentId)!;
3183
+ return {
3184
+ kind: "diagnostic" as const,
3185
+ piSessionId: record.piSessionId,
3186
+ reason: "corrupt" as const,
3187
+ ...(record.sessionFile
3188
+ ? { sessionFile: record.sessionFile }
3189
+ : {}),
3190
+ ...(record.descriptor.parentSessionFile
3191
+ ? {
3192
+ parentSessionFile:
3193
+ record.descriptor.parentSessionFile,
3194
+ }
3195
+ : {}),
3196
+ message,
3197
+ };
3198
+ })
3199
+ .sort((left, right) =>
3200
+ left.piSessionId.localeCompare(right.piSessionId),
3201
+ );
1436
3202
  }
1437
3203
 
1438
3204
  private async findPersistedChild(
@@ -1450,29 +3216,110 @@ export class SubagentCoordinator {
1450
3216
  for (const item of persisted.descriptors) {
1451
3217
  records.set(item.agentId, {
1452
3218
  agentId: item.agentId,
3219
+ piSessionId: item.piSessionId,
3220
+ taskPath: descriptorTaskPath(item.descriptor),
1453
3221
  descriptor: item.descriptor,
1454
3222
  sessionFile: item.sessionFile,
3223
+ pendingMessages: item.pendingMessages,
3224
+ unreadUpdatesByChild: new Map(item.unreadUpdatesByChild),
1455
3225
  });
1456
3226
  }
1457
3227
  const activeSessionIds = new Set(
1458
3228
  [...this.active.values()].map((activation) => activation.runtime.session.sessionId),
1459
3229
  );
3230
+ const activeDiagnostics: CatalogEntry[] = [];
3231
+ const rootCompletions = foldCompletionMailbox(
3232
+ parent.sessionManager.getEntries(),
3233
+ { parentAgentId: parent.agentId },
3234
+ );
3235
+ const rootUnreadUpdatesByChild =
3236
+ rootCompletions.kind === "valid"
3237
+ ? unreadCompletionCounts(rootCompletions.snapshot)
3238
+ : new Map<string, number>();
3239
+ if (rootCompletions.kind === "corrupt") {
3240
+ const parentFile = parent.sessionManager.getSessionFile();
3241
+ activeDiagnostics.push({
3242
+ kind: "diagnostic",
3243
+ piSessionId: parent.sessionManager.getSessionId(),
3244
+ reason: "corrupt",
3245
+ ...(parentFile
3246
+ ? {
3247
+ sessionFile: parentFile,
3248
+ parentSessionFile: parentFile,
3249
+ }
3250
+ : {}),
3251
+ message: `corrupt completion mailbox: ${rootCompletions.message}`,
3252
+ });
3253
+ }
1460
3254
  for (const activation of this.active.values()) {
1461
3255
  if (activation.descriptor.cwd !== parent.cwd) continue;
3256
+ let pendingMessages = 0;
3257
+ const mailbox = foldOwnedMailbox(
3258
+ activation.runtime.session.sessionManager.getEntries(),
3259
+ mailboxOwner(activation.descriptor),
3260
+ );
3261
+ if (mailbox.kind === "valid") {
3262
+ pendingMessages = mailbox.snapshot.pending.length;
3263
+ } else {
3264
+ activeDiagnostics.push({
3265
+ kind: "diagnostic",
3266
+ piSessionId: activation.runtime.session.sessionId,
3267
+ reason: "corrupt",
3268
+ ...(activation.runtime.session.sessionFile
3269
+ ? { sessionFile: activation.runtime.session.sessionFile }
3270
+ : {}),
3271
+ ...(activation.descriptor.parentSessionFile
3272
+ ? { parentSessionFile: activation.descriptor.parentSessionFile }
3273
+ : {}),
3274
+ message: `corrupt subagent mailbox: ${mailbox.message}`,
3275
+ });
3276
+ continue;
3277
+ }
3278
+ const completions = foldCompletionMailbox(
3279
+ activation.runtime.session.sessionManager.getEntries(),
3280
+ { parentAgentId: activation.agentId },
3281
+ );
3282
+ if (completions.kind === "corrupt") {
3283
+ activeDiagnostics.push({
3284
+ kind: "diagnostic",
3285
+ piSessionId: activation.runtime.session.sessionId,
3286
+ reason: "corrupt",
3287
+ ...(activation.runtime.session.sessionFile
3288
+ ? { sessionFile: activation.runtime.session.sessionFile }
3289
+ : {}),
3290
+ ...(activation.descriptor.parentSessionFile
3291
+ ? { parentSessionFile: activation.descriptor.parentSessionFile }
3292
+ : {}),
3293
+ message: `corrupt completion mailbox: ${completions.message}`,
3294
+ });
3295
+ continue;
3296
+ }
1462
3297
  records.set(activation.agentId, {
1463
3298
  agentId: activation.agentId,
3299
+ piSessionId: activation.runtime.session.sessionId,
3300
+ taskPath: descriptorTaskPath(activation.descriptor),
1464
3301
  descriptor: activation.descriptor,
1465
3302
  ...(activation.runtime.session.sessionFile
1466
3303
  ? { sessionFile: activation.runtime.session.sessionFile }
1467
3304
  : {}),
1468
3305
  active: activation,
3306
+ pendingMessages,
3307
+ unreadUpdatesByChild: unreadCompletionCounts(
3308
+ completions.snapshot,
3309
+ ),
1469
3310
  });
1470
3311
  }
3312
+ const catalogRecords = [...records.values()];
1471
3313
  return {
1472
- records: [...records.values()],
1473
- diagnostics: persisted.diagnostics.filter(
1474
- (diagnostic) => !activeSessionIds.has(diagnostic.piSessionId),
1475
- ),
3314
+ records: catalogRecords,
3315
+ diagnostics: [
3316
+ ...persisted.diagnostics.filter(
3317
+ (diagnostic) => !activeSessionIds.has(diagnostic.piSessionId),
3318
+ ),
3319
+ ...activeDiagnostics,
3320
+ ...this.catalogTopologyDiagnostics(catalogRecords),
3321
+ ],
3322
+ rootUnreadUpdatesByChild,
1476
3323
  };
1477
3324
  }
1478
3325
 
@@ -1504,7 +3351,6 @@ export class SubagentCoordinator {
1504
3351
 
1505
3352
  export {
1506
3353
  REPORT_CUSTOM_TYPE,
1507
- SETTLED_CUSTOM_TYPE,
1508
3354
  AGENT_CUSTOM_TYPE,
1509
3355
  LINEAGE_CUSTOM_TYPE,
1510
3356
  };