@oai404iao/pi-subagent 0.3.0 → 0.4.0-alpha.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,
@@ -81,8 +135,11 @@ 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
  ]);
@@ -101,17 +158,54 @@ const REPORT_PROMPT = [
101
158
 
102
159
  export interface DelegationInput {
103
160
  agent: string;
161
+ task_name?: string;
104
162
  description: string;
105
163
  prompt: string;
106
164
  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 type SendMessageOutcome =
176
+ | { kind: "legacy"; agentId: string; taskPath: string }
177
+ | {
178
+ kind: "mailbox-v2";
179
+ agentId: string;
180
+ taskPath: string;
181
+ messageId: string;
182
+ pendingMessages: number;
183
+ };
184
+
185
+ export interface FollowupTaskOutcome {
186
+ agentId: string;
187
+ taskPath: string;
188
+ turnId: string;
189
+ claimedMessages: number;
190
+ }
191
+
192
+ export interface InterruptOutcome {
193
+ agentId: string;
194
+ taskPath: string;
195
+ active: boolean;
196
+ }
197
+
198
+ export interface WaitAgentOutcome {
199
+ timedOut: boolean;
200
+ timeoutMs: number;
201
+ updates: CompletionUpdate[];
202
+ unreadUpdates: number;
203
+ taskPaths: Record<string, string>;
204
+ }
205
+
113
206
  interface ParentRef {
114
207
  agentId: string;
208
+ taskPath: string;
115
209
  depth: number;
116
210
  cwd: string;
117
211
  sessionManager: SessionView;
@@ -136,21 +230,53 @@ interface Activation {
136
230
  runtime: AgentSessionRuntime;
137
231
  seedMessageCount: number;
138
232
  epochMessageStart: number;
139
- status: DelegationDetails["status"];
233
+ controlState: AgentControlState;
140
234
  trace: TraceItem[];
141
235
  streamedText: string;
142
236
  usage: ReturnType<typeof emptyUsage>;
237
+ startedTurnIds: Set<string>;
238
+ silentSettlementTurnIds: Set<string>;
239
+ pendingMailboxClaims: Set<string>;
240
+ userMessageGates: Map<string, UserMessageGate>;
143
241
  ownedChildren: Set<string>;
144
242
  currentRun?: Promise<SubagentRunResult>;
243
+ currentMessageGate?: Promise<void>;
145
244
  pendingSettlement?: SubagentRunResult;
146
245
  unsubscribe?: () => void;
147
246
  onUpdate?: (details: DelegationDetails) => void;
148
247
  published: boolean;
248
+ everPublished: boolean;
149
249
  suppressSettlement: boolean;
150
250
  finalizing: boolean;
151
- finalizePromise?: Promise<void>;
251
+ finalizePromise?: Promise<boolean>;
252
+ holdsBackgroundSlot: boolean;
253
+ turnAbortController?: AbortController;
254
+ persistenceGate: PersistenceGate;
152
255
  disposed: boolean;
153
256
  lastError?: string;
257
+ ownerActivation?: Activation;
258
+ lastUsedSequence: number;
259
+ }
260
+
261
+ interface UserMessageGate {
262
+ prepare(): void;
263
+ accept(): void;
264
+ reject(error: Error): void;
265
+ }
266
+
267
+ interface PersistenceGate {
268
+ promise: Promise<void>;
269
+ resolve(): void;
270
+ reject(error: Error): void;
271
+ readonly settled: boolean;
272
+ readonly state: "pending" | "fulfilled" | "rejected";
273
+ }
274
+
275
+ interface CompletionWaiter {
276
+ promise: Promise<"activity" | "timeout">;
277
+ wake(): void;
278
+ reject(error: Error): void;
279
+ dispose(): void;
154
280
  }
155
281
 
156
282
  interface CreateActivationOptions {
@@ -161,16 +287,60 @@ interface CreateActivationOptions {
161
287
  onUpdate?: (details: DelegationDetails) => void;
162
288
  }
163
289
 
290
+ interface StartPromptOptions {
291
+ detachAtAcceptance: boolean;
292
+ waitForCapacity: boolean;
293
+ preparePrompt?: (turnId: string) => string;
294
+ onPromptAccepted?: (turnId: string) => void;
295
+ acceptAfterUserMessage?: boolean;
296
+ }
297
+
298
+ interface PendingPromptStart {
299
+ activation: Activation;
300
+ accepted: Promise<void>;
301
+ coldPrepared?: PreparedChildSession;
302
+ }
303
+
304
+ type SerializedSendMessageOutcome =
305
+ | {
306
+ kind: "legacy";
307
+ agentId: string;
308
+ taskPath: string;
309
+ pending?: PendingPromptStart;
310
+ }
311
+ | Extract<SendMessageOutcome, { kind: "mailbox-v2" }>;
312
+
313
+ interface PendingFollowupStart extends PendingPromptStart {
314
+ outcome: FollowupTaskOutcome;
315
+ }
316
+
164
317
  interface CatalogRecord {
165
318
  agentId: string;
319
+ piSessionId: string;
320
+ taskPath: string;
166
321
  descriptor: SubagentDescriptor;
167
322
  sessionFile?: string;
168
323
  active?: Activation;
324
+ pendingMessages: number;
325
+ unreadUpdatesByChild: Map<string, number>;
326
+ }
327
+
328
+ interface ResolvedTarget {
329
+ agentId: string;
330
+ taskPath: string;
331
+ record?: CatalogRecord;
332
+ }
333
+
334
+ interface ReservedTask {
335
+ name: string;
336
+ path: string;
337
+ release(): void;
169
338
  }
170
339
 
171
340
  interface CoordinatorCatalog {
172
341
  records: CatalogRecord[];
173
342
  diagnostics: CatalogEntry[];
343
+ rootUnreadUpdatesByChild: Map<string, number>;
174
344
  }
175
345
 
176
346
  function runtimeFromRegistry(registry: ModelRegistry): ModelRuntime {
@@ -195,6 +365,79 @@ function errorText(error: unknown): string {
195
365
  return error instanceof Error ? error.message : String(error);
196
366
  }
197
367
 
368
+ function abortReason(signal: AbortSignal): Error {
369
+ return signal.reason instanceof Error
370
+ ? signal.reason
371
+ : new Error(signal.reason ? String(signal.reason) : "operation aborted");
372
+ }
373
+
374
+ function waitForPromise<T>(
375
+ promise: Promise<T>,
376
+ signal?: AbortSignal,
377
+ ): Promise<T> {
378
+ if (!signal) return promise;
379
+ if (signal.aborted) return Promise.reject(abortReason(signal));
380
+ return new Promise<T>((resolvePromise, rejectPromise) => {
381
+ const abort = () => {
382
+ rejectPromise(abortReason(signal));
383
+ };
384
+ signal.addEventListener("abort", abort, { once: true });
385
+ promise.then(
386
+ (value) => {
387
+ signal.removeEventListener("abort", abort);
388
+ resolvePromise(value);
389
+ },
390
+ (error) => {
391
+ signal.removeEventListener("abort", abort);
392
+ rejectPromise(error);
393
+ },
394
+ );
395
+ });
396
+ }
397
+
398
+ function createPersistenceGate(
399
+ session: Pick<SessionView, "getEntries">,
400
+ requireNewAssistant: boolean,
401
+ ): PersistenceGate {
402
+ const alreadyDurable = !requireNewAssistant && session
403
+ .getEntries()
404
+ .some(
405
+ (entry) =>
406
+ entry.type === "message"
407
+ && entry.message.role === "assistant",
408
+ );
409
+ let state: PersistenceGate["state"] =
410
+ alreadyDurable ? "fulfilled" : "pending";
411
+ let resolvePromise!: () => void;
412
+ let rejectPromise!: (error: Error) => void;
413
+ const promise = alreadyDurable
414
+ ? Promise.resolve()
415
+ : new Promise<void>((resolve, reject) => {
416
+ resolvePromise = resolve;
417
+ rejectPromise = reject;
418
+ });
419
+ void promise.catch(() => {});
420
+ return {
421
+ promise,
422
+ resolve: () => {
423
+ if (state !== "pending") return;
424
+ state = "fulfilled";
425
+ resolvePromise();
426
+ },
427
+ reject: (error) => {
428
+ if (state !== "pending") return;
429
+ state = "rejected";
430
+ rejectPromise(error);
431
+ },
432
+ get settled() {
433
+ return state !== "pending";
434
+ },
435
+ get state() {
436
+ return state;
437
+ },
438
+ };
439
+ }
440
+
198
441
  /**
199
442
  * Read the durable pi-subagent control id recorded for a session, if any.
200
443
  *
@@ -246,10 +489,12 @@ function stopReasonHeadline(reason: SubagentStopReason): string {
246
489
  function makeRuntimeSettings(descriptor: SubagentDescriptor): SubagentSettings {
247
490
  return {
248
491
  agentScope: descriptor.runtime.agentScope,
249
- syncBundledAgents: descriptor.runtime.syncBundledAgents,
250
492
  maxDepth: descriptor.runtime.maxDepth,
251
493
  enableRunInBackground: descriptor.runtime.enableRunInBackground,
252
494
  defaultBackground: descriptor.runtime.defaultBackground,
495
+ maxConcurrentBackgroundRuns: descriptor.runtime.maxConcurrentBackgroundRuns,
496
+ maxIdleRuntimes: descriptor.runtime.maxIdleRuntimes,
497
+ backgroundProtocol: descriptor.runtime.backgroundProtocol,
253
498
  reportDelivery: descriptor.runtime.reportDelivery,
254
499
  inheritExtensions: descriptor.runtime.inheritExtensions,
255
500
  openAIIdentity: descriptor.runtime.openAIIdentity,
@@ -257,6 +502,78 @@ function makeRuntimeSettings(descriptor: SubagentDescriptor): SubagentSettings {
257
502
  };
258
503
  }
259
504
 
505
+ function delegationContext(
506
+ providerName: SubagentProviderName,
507
+ input: DelegationInput,
508
+ ): ContextInheritance {
509
+ if (providerName === "fork") {
510
+ if (input.context !== undefined) {
511
+ throw new Error(
512
+ "subagent_fork always uses all_completed context; use subagent for another context policy",
513
+ );
514
+ }
515
+ return { mode: "all_completed" };
516
+ }
517
+ const context = input.context;
518
+ if (!context) return { mode: "fresh" };
519
+ if (context.mode === "last_n_completed") {
520
+ if (
521
+ !Number.isSafeInteger(context.completed_turns)
522
+ || context.completed_turns === undefined
523
+ || context.completed_turns < 1
524
+ || context.completed_turns > 100
525
+ ) {
526
+ throw new Error(
527
+ "context.completed_turns must be an integer between 1 and 100 for last_n_completed",
528
+ );
529
+ }
530
+ return {
531
+ mode: context.mode,
532
+ completedTurns: context.completed_turns,
533
+ };
534
+ }
535
+ if (
536
+ context.mode !== "fresh"
537
+ && context.mode !== "all_completed"
538
+ ) {
539
+ throw new Error(`unsupported context mode: ${String(context.mode)}`);
540
+ }
541
+ if (context.completed_turns !== undefined) {
542
+ throw new Error(
543
+ "context.completed_turns is available only for last_n_completed",
544
+ );
545
+ }
546
+ return { mode: context.mode };
547
+ }
548
+
549
+ function mailboxOwner(descriptor: SubagentDescriptor): {
550
+ parentAgentId: string;
551
+ agentId: string;
552
+ } {
553
+ return {
554
+ parentAgentId: descriptor.parentAgentId,
555
+ agentId: descriptor.agentId,
556
+ };
557
+ }
558
+
559
+ function completionWaiterKey(parent: ParentRef): string {
560
+ return `${parent.agentId}:${parent.sessionManager.getSessionId()}`;
561
+ }
562
+
563
+ function waitTimeout(value: number | undefined): number {
564
+ const timeoutMs = value ?? DEFAULT_WAIT_AGENT_TIMEOUT_MS;
565
+ if (
566
+ !Number.isSafeInteger(timeoutMs)
567
+ || timeoutMs < 0
568
+ || timeoutMs > MAX_WAIT_AGENT_TIMEOUT_MS
569
+ ) {
570
+ throw new Error(
571
+ `timeout_ms must be a safe integer between 0 and ${MAX_WAIT_AGENT_TIMEOUT_MS}`,
572
+ );
573
+ }
574
+ return timeoutMs;
575
+ }
576
+
260
577
  function isPathInside(parent: string, child: string): boolean {
261
578
  const rel = relative(resolve(parent), resolve(child));
262
579
  return rel === "" || (!rel.startsWith("..") && !rel.startsWith("/"));
@@ -288,8 +605,19 @@ async function loadCodexIdentityInlineExtension(
288
605
  export class SubagentCoordinator {
289
606
  private readonly providers = new ProviderRegistry();
290
607
  private readonly active = new Map<string, Activation>();
608
+ private readonly agentOperations = new AgentOperationQueue();
609
+ private readonly completionOperations = new AgentOperationQueue();
610
+ private readonly idleRuntimeOperations = new AgentOperationQueue();
611
+ private readonly backgroundRuns = new BackgroundRunLimiter();
612
+ private readonly admittedOperations = new Set<Promise<unknown>>();
613
+ private readonly completionWaiters = new Map<string, CompletionWaiter>();
614
+ private readonly reservedTaskPaths = new Set<string>();
615
+ private readonly runtimeId = uuidv7();
616
+ private idleRuntimeLimit = 0;
617
+ private activationSequence = 0;
291
618
  private agentSyncResult: AgentSyncResult | undefined;
292
619
  private draining = false;
620
+ private shutdownPromise: Promise<void> | undefined;
293
621
 
294
622
  constructor(
295
623
  private readonly pi: ExtensionAPI,
@@ -314,26 +642,39 @@ export class SubagentCoordinator {
314
642
  return join(this.agentDir, "agents");
315
643
  }
316
644
 
645
+ configureBackgroundRuns(limit: number): void {
646
+ this.backgroundRuns.configure(limit);
647
+ }
648
+
649
+ async configureIdleRuntimes(limit: number): Promise<void> {
650
+ if (!Number.isSafeInteger(limit) || limit < 0) {
651
+ throw new Error("maxIdleRuntimes must be a non-negative safe integer");
652
+ }
653
+ this.idleRuntimeLimit = limit;
654
+ await this.trimIdleRuntimes();
655
+ }
656
+
317
657
  discoverAvailableAgents(
318
658
  cwd: string,
319
659
  settings: SubagentSettings,
320
660
  projectTrusted: boolean,
321
661
  ): AgentDiscoveryResult {
322
- if (settings.syncBundledAgents && !this.agentSyncResult) {
662
+ if (!this.agentSyncResult) {
323
663
  this.synchronizeBundledAgents();
324
664
  }
325
- const excludeUserAgentNames = settings.syncBundledAgents
326
- ? undefined
327
- : unmodifiedManagedAgentNames(this.agentDir);
328
- return discoverAgents({
665
+ const discovery = discoverAgents({
329
666
  cwd,
330
667
  scope: settings.agentScope,
331
668
  projectTrusted,
332
- bundledDir: this.bundledAgentsDir,
333
669
  agentDir: this.agentDir,
334
- includeBundled: !settings.syncBundledAgents && settings.agentScope !== "project",
335
- excludeUserAgentNames,
336
670
  });
671
+ return {
672
+ ...discovery,
673
+ diagnostics: [
674
+ ...(this.agentSyncResult?.diagnostics ?? []),
675
+ ...discovery.diagnostics,
676
+ ],
677
+ };
337
678
  }
338
679
 
339
680
  async parentFromContext(ctx: ExtensionContext): Promise<ParentRef> {
@@ -344,6 +685,9 @@ export class SubagentCoordinator {
344
685
  agentId: readAgentId(ctx.sessionManager) ?? ensureAgentId(
345
686
  ctx.sessionManager as unknown as SessionView,
346
687
  ),
688
+ taskPath: descriptor
689
+ ? descriptorTaskPath(descriptor)
690
+ : ROOT_TASK_PATH,
347
691
  depth: descriptor?.depth ?? 0,
348
692
  cwd: ctx.cwd,
349
693
  // ExtensionContext narrows the live SessionManager to a read-only
@@ -374,11 +718,35 @@ export class SubagentCoordinator {
374
718
  signal?: AbortSignal,
375
719
  onUpdate?: (details: DelegationDetails) => void,
376
720
  agentDiscovery?: AgentDiscoveryResult,
721
+ ): Promise<DelegationOutcome> {
722
+ return this.runAdmittedOperation(() =>
723
+ this.delegateAdmitted(
724
+ parent,
725
+ providerName,
726
+ input,
727
+ settings,
728
+ signal,
729
+ onUpdate,
730
+ agentDiscovery,
731
+ ),
732
+ );
733
+ }
734
+
735
+ private async delegateAdmitted(
736
+ parent: ParentRef,
737
+ providerName: SubagentProviderName,
738
+ input: DelegationInput,
739
+ settings: SubagentSettings,
740
+ signal?: AbortSignal,
741
+ onUpdate?: (details: DelegationDetails) => void,
742
+ agentDiscovery?: AgentDiscoveryResult,
377
743
  ): Promise<DelegationOutcome> {
378
744
  if (this.draining) throw new Error("pi-subagent is shutting down; no new delegation was accepted");
379
- const provider = this.providers.get(providerName);
745
+ const context = delegationContext(providerName, input);
746
+ const resolvedProviderName: SubagentProviderName =
747
+ context.mode === "fresh" ? "spawn" : "fork";
748
+ const provider = this.providers.get(resolvedProviderName);
380
749
  if (
381
- providerName === "spawn" &&
382
750
  !settings.enableRunInBackground &&
383
751
  input.run_in_background === true
384
752
  ) {
@@ -387,8 +755,10 @@ export class SubagentCoordinator {
387
755
  );
388
756
  }
389
757
  const runInBackground =
390
- providerName === "spawn" && settings.enableRunInBackground
391
- ? (input.run_in_background ?? settings.defaultBackground)
758
+ settings.enableRunInBackground
759
+ ? providerName === "fork"
760
+ ? (input.run_in_background ?? false)
761
+ : (input.run_in_background ?? settings.defaultBackground)
392
762
  : false;
393
763
  const mode: SubagentMode = runInBackground ? "continuable" : "one-shot";
394
764
  if (mode === "continuable" && !provider.supportsContinuable) {
@@ -429,57 +799,74 @@ export class SubagentCoordinator {
429
799
 
430
800
  const model = this.resolveModel(parent, agent);
431
801
  const thinkingLevel = agent.thinking ?? parent.thinkingLevel;
432
- const prepared = await provider.prepare(parent, mode);
433
802
  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
-
803
+ const reservedTask = await this.reserveTask(
804
+ parent,
805
+ input.task_name,
806
+ input.description,
807
+ );
808
+ let prepared: PreparedChildSession | undefined;
481
809
  let activation: Activation | undefined;
810
+ let keepTaskReservation = false;
482
811
  try {
812
+ prepared = await provider.prepare(parent, mode, context);
813
+ if (this.draining) {
814
+ throw new Error("pi-subagent is shutting down; no new delegation was accepted");
815
+ }
816
+ const openAIIdentityEnabled =
817
+ settings.openAIIdentity && isOpenAIResponsesModel(model);
818
+ const descriptor: SubagentDescriptor = {
819
+ version: DESCRIPTOR_VERSION,
820
+ mode,
821
+ provider: resolvedProviderName,
822
+ label: input.description.trim(),
823
+ agentId: uuidv7(),
824
+ parentAgentId: parent.agentId,
825
+ parentPiSessionId: parent.sessionManager.getSessionId(),
826
+ ...(parent.sessionManager.getSessionFile()
827
+ ? { parentSessionFile: parent.sessionManager.getSessionFile() }
828
+ : {}),
829
+ depth,
830
+ cwd: parent.cwd,
831
+ createdAt: new Date().toISOString(),
832
+ agent: snapshotAgent(agent),
833
+ model: { provider: model.provider, id: model.id },
834
+ thinkingLevel,
835
+ task: {
836
+ name: reservedTask.name,
837
+ path: reservedTask.path,
838
+ },
839
+ context,
840
+ runtime: {
841
+ agentScope: settings.agentScope,
842
+ maxDepth: settings.maxDepth,
843
+ enableRunInBackground: settings.enableRunInBackground,
844
+ defaultBackground: settings.defaultBackground,
845
+ maxConcurrentBackgroundRuns: settings.maxConcurrentBackgroundRuns,
846
+ maxIdleRuntimes: settings.maxIdleRuntimes,
847
+ backgroundProtocol: settings.backgroundProtocol ?? "legacy",
848
+ reportDelivery: settings.reportDelivery,
849
+ inheritExtensions: settings.inheritExtensions,
850
+ openAIIdentity: openAIIdentityEnabled,
851
+ maxOutputBytes: settings.maxOutputBytes,
852
+ },
853
+ };
854
+ prepared.sessionManager.appendCustomEntry(AGENT_CUSTOM_TYPE, {
855
+ agentId: descriptor.agentId,
856
+ });
857
+ prepared.sessionManager.appendCustomEntry(LINEAGE_CUSTOM_TYPE, {
858
+ version: 1,
859
+ agentId: descriptor.agentId,
860
+ parentAgentId: descriptor.parentAgentId,
861
+ parentPiSessionId: descriptor.parentPiSessionId,
862
+ relation: descriptor.provider,
863
+ agentName: descriptor.agent.name,
864
+ taskPath: descriptor.task.path,
865
+ openAIIdentity: openAIIdentityEnabled,
866
+ ...(descriptor.parentSessionFile
867
+ ? { parentSessionFile: descriptor.parentSessionFile }
868
+ : {}),
869
+ });
483
870
  activation = await this.createActivation({
484
871
  parent,
485
872
  descriptor,
@@ -487,11 +874,18 @@ export class SubagentCoordinator {
487
874
  isNew: true,
488
875
  onUpdate,
489
876
  });
877
+ if (this.draining) {
878
+ throw new Error("pi-subagent is shutting down; no new delegation was accepted");
879
+ }
490
880
  if (mode === "continuable" && parent.activation) {
491
- parent.activation.ownedChildren.add(activation.agentId);
881
+ this.acquireParentOwnership(activation, parent);
492
882
  }
493
- const started = this.startPrompt(activation, input.prompt, signal, mode === "continuable");
883
+ const started = this.startPrompt(activation, input.prompt, signal, {
884
+ detachAtAcceptance: mode === "continuable",
885
+ waitForCapacity: !parent.activation?.holdsBackgroundSlot,
886
+ });
494
887
  await started.accepted;
888
+ keepTaskReservation = true;
495
889
  if (mode === "continuable") {
496
890
  activation.onUpdate = undefined;
497
891
  return { kind: "continuable", details: this.detailsOf(activation) };
@@ -503,37 +897,144 @@ export class SubagentCoordinator {
503
897
  await this.disposeActivation(activation);
504
898
  return { kind: "foreground", details, result };
505
899
  } catch (error) {
506
- if (activation && !activation.published) {
900
+ if (activation && prepared && !activation.published) {
507
901
  activation.suppressSettlement = true;
508
902
  await this.rollbackActivation(activation, prepared);
509
- } else if (!activation) {
903
+ } else if (!activation && prepared) {
510
904
  await prepared.rollback();
511
905
  }
512
906
  throw error;
907
+ } finally {
908
+ if (!keepTaskReservation) reservedTask.release();
513
909
  }
514
910
  }
515
911
 
516
- async sendMessage(parent: ParentRef, childId: string, message: string, signal?: AbortSignal): Promise<void> {
912
+ async sendMessage(
913
+ parent: ParentRef,
914
+ childId: string,
915
+ message: string,
916
+ signal?: AbortSignal,
917
+ ): Promise<void> {
918
+ await this.sendMessageWithOutcome(parent, childId, message, signal);
919
+ }
920
+
921
+ async sendMessageWithOutcome(
922
+ parent: ParentRef,
923
+ childId: string,
924
+ message: string,
925
+ signal?: AbortSignal,
926
+ ): Promise<SendMessageOutcome> {
927
+ return this.runAdmittedOperation(() =>
928
+ this.sendMessageAdmitted(parent, childId, message, signal),
929
+ );
930
+ }
931
+
932
+ private async sendMessageAdmitted(
933
+ parent: ParentRef,
934
+ childId: string,
935
+ message: string,
936
+ signal?: AbortSignal,
937
+ ): Promise<SendMessageOutcome> {
517
938
  if (this.draining) throw new Error("pi-subagent is shutting down; message was not delivered");
939
+ const target = await this.resolveTarget(parent, childId);
940
+ const delivery = await this.agentOperations.run(target.agentId, () =>
941
+ this.sendMessageSerialized(parent, target.agentId, message, signal),
942
+ );
943
+ if (delivery.kind === "mailbox-v2") return delivery;
944
+ const pending = delivery.pending;
945
+ if (!pending) return delivery;
946
+ try {
947
+ await pending.accepted;
948
+ } catch (error) {
949
+ if (pending.coldPrepared && !pending.activation.published) {
950
+ await this.agentOperations.run(target.agentId, async () => {
951
+ if (this.active.get(target.agentId) !== pending.activation) return;
952
+ pending.activation.suppressSettlement = true;
953
+ await this.rollbackActivation(
954
+ pending.activation,
955
+ pending.coldPrepared!,
956
+ );
957
+ });
958
+ }
959
+ throw error;
960
+ }
961
+ return {
962
+ kind: "legacy",
963
+ agentId: delivery.agentId,
964
+ taskPath: delivery.taskPath,
965
+ };
966
+ }
967
+
968
+ private async sendMessageSerialized(
969
+ parent: ParentRef,
970
+ childId: string,
971
+ message: string,
972
+ signal?: AbortSignal,
973
+ ): Promise<SerializedSendMessageOutcome> {
974
+ if (this.draining) {
975
+ throw new Error("pi-subagent is shutting down; message was not delivered");
976
+ }
518
977
  let activation = this.active.get(childId);
519
978
  let coldPrepared: PreparedChildSession | undefined;
520
- if (activation?.finalizing && activation.finalizePromise) {
521
- await activation.finalizePromise;
522
- activation = undefined;
979
+ if (activation?.disposed) activation = undefined;
980
+ if (activation) {
981
+ this.touchActivation(activation);
982
+ this.assertContinuableDirectChild(parent, activation.descriptor);
983
+ if (activation.descriptor.runtime.backgroundProtocol === "mailbox-v2") {
984
+ if (signal?.aborted) throw abortReason(signal);
985
+ await waitForPromise(activation.persistenceGate.promise, signal);
986
+ if (this.draining || activation.disposed) {
987
+ throw new Error(
988
+ `subagent ${childId} became unavailable before its mailbox could be persisted`,
989
+ );
990
+ }
991
+ const enqueued = enqueueMailboxMessage(
992
+ activation.runtime.session.sessionManager,
993
+ {
994
+ senderAgentId: parent.agentId,
995
+ recipientAgentId: childId,
996
+ content: message,
997
+ },
998
+ );
999
+ return {
1000
+ kind: "mailbox-v2",
1001
+ agentId: childId,
1002
+ taskPath: descriptorTaskPath(activation.descriptor),
1003
+ messageId: enqueued.message.messageId,
1004
+ pendingMessages: enqueued.pendingMessages,
1005
+ };
1006
+ }
1007
+ }
1008
+ if (activation?.pendingSettlement && !activation.currentRun) {
1009
+ await this.finalizeContinuableLocked(activation);
1010
+ if (activation.disposed || this.active.get(childId) !== activation) {
1011
+ activation = undefined;
1012
+ }
523
1013
  }
524
-
525
1014
  if (!activation) {
526
1015
  const located = await this.findPersistedChild(parent, childId);
527
1016
  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`);
530
- }
531
- this.assertDirectParent(parent, located.descriptor);
1017
+ this.assertContinuableDirectChild(parent, located.descriptor);
532
1018
  const manager = SessionManager.open(
533
1019
  located.sessionFile,
534
1020
  parent.sessionManager.getSessionDir(),
535
1021
  parent.cwd,
536
1022
  );
1023
+ if (located.descriptor.runtime.backgroundProtocol === "mailbox-v2") {
1024
+ if (signal?.aborted) throw abortReason(signal);
1025
+ const enqueued = enqueueMailboxMessage(manager, {
1026
+ senderAgentId: parent.agentId,
1027
+ recipientAgentId: childId,
1028
+ content: message,
1029
+ });
1030
+ return {
1031
+ kind: "mailbox-v2",
1032
+ agentId: childId,
1033
+ taskPath: descriptorTaskPath(located.descriptor),
1034
+ messageId: enqueued.message.messageId,
1035
+ pendingMessages: enqueued.pendingMessages,
1036
+ };
1037
+ }
537
1038
  coldPrepared = {
538
1039
  sessionManager: manager,
539
1040
  seedMessageCount: manager.buildSessionContext().messages.length,
@@ -545,21 +1046,61 @@ export class SubagentCoordinator {
545
1046
  prepared: coldPrepared,
546
1047
  isNew: false,
547
1048
  });
548
- if (parent.activation) parent.activation.ownedChildren.add(activation.agentId);
1049
+ this.acquireParentOwnership(activation, parent);
549
1050
  } else {
550
- this.assertDirectParent(parent, activation.descriptor);
1051
+ this.assertContinuableDirectChild(parent, activation.descriptor);
551
1052
  }
1053
+ const resolvedPath = descriptorTaskPath(activation.descriptor);
552
1054
 
553
1055
  const session = activation.runtime.session;
554
1056
  if (activation.currentRun || session.isStreaming) {
555
1057
  if (signal?.aborted) throw signal.reason ?? new Error("message delivery aborted");
1058
+ if (activation.controlState.turn.state === "queued") {
1059
+ if (!activation.currentMessageGate) {
1060
+ throw new Error(
1061
+ `subagent ${activation.agentId} is queued but has no message gate`,
1062
+ );
1063
+ }
1064
+ return {
1065
+ kind: "legacy",
1066
+ agentId: childId,
1067
+ taskPath: resolvedPath,
1068
+ pending: {
1069
+ activation,
1070
+ accepted: waitForPromise(
1071
+ activation.currentMessageGate,
1072
+ signal,
1073
+ ).then(() => {
1074
+ if (signal?.aborted) throw abortReason(signal);
1075
+ return session.followUp(message);
1076
+ }),
1077
+ },
1078
+ };
1079
+ }
556
1080
  await session.followUp(message);
557
- return;
1081
+ return {
1082
+ kind: "legacy",
1083
+ agentId: childId,
1084
+ taskPath: resolvedPath,
1085
+ };
558
1086
  }
559
1087
 
560
1088
  try {
561
- const started = this.startPrompt(activation, message, signal, true);
562
- await started.accepted;
1089
+ this.acquireParentOwnership(activation, parent);
1090
+ const started = this.startPrompt(activation, message, signal, {
1091
+ detachAtAcceptance: true,
1092
+ waitForCapacity: !parent.activation?.holdsBackgroundSlot,
1093
+ });
1094
+ return {
1095
+ kind: "legacy",
1096
+ agentId: childId,
1097
+ taskPath: resolvedPath,
1098
+ pending: {
1099
+ activation,
1100
+ accepted: started.accepted,
1101
+ ...(coldPrepared ? { coldPrepared } : {}),
1102
+ },
1103
+ };
563
1104
  } catch (error) {
564
1105
  if (coldPrepared && !activation.published) {
565
1106
  activation.suppressSettlement = true;
@@ -569,89 +1110,704 @@ 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);
581
- });
1113
+ async followupTask(
1114
+ parent: ParentRef,
1115
+ childId: string,
1116
+ signal?: AbortSignal,
1117
+ ): Promise<FollowupTaskOutcome> {
1118
+ return this.runAdmittedOperation(() =>
1119
+ this.followupTaskAdmitted(parent, childId, signal),
1120
+ );
582
1121
  }
583
1122
 
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
- });
1123
+ private async followupTaskAdmitted(
1124
+ parent: ParentRef,
1125
+ childId: string,
1126
+ signal?: AbortSignal,
1127
+ ): Promise<FollowupTaskOutcome> {
1128
+ if (this.draining) {
1129
+ throw new Error("pi-subagent is shutting down; follow-up task was not started");
606
1130
  }
607
- children.sort(
608
- (left, right) =>
609
- left.descriptor.createdAt.localeCompare(right.descriptor.createdAt) ||
610
- left.agentId.localeCompare(right.agentId),
1131
+ const target = await this.resolveTarget(parent, childId);
1132
+ const pending = await this.agentOperations.run(target.agentId, () =>
1133
+ this.followupTaskSerialized(parent, target.agentId, signal),
611
1134
  );
612
- const parentFile = parent.sessionManager.getSessionFile();
613
- const diagnostics = parentFile
614
- ? catalog.diagnostics.filter(
615
- (entry) => entry.kind === "diagnostic" && entry.parentSessionFile === parentFile,
616
- )
617
- : [];
618
- return [...children, ...diagnostics];
1135
+ try {
1136
+ await pending.accepted;
1137
+ } catch (error) {
1138
+ if (pending.coldPrepared && !pending.activation.published) {
1139
+ await this.agentOperations.run(target.agentId, async () => {
1140
+ if (this.active.get(target.agentId) !== pending.activation) return;
1141
+ pending.activation.suppressSettlement = true;
1142
+ await this.rollbackActivation(
1143
+ pending.activation,
1144
+ pending.coldPrepared!,
1145
+ );
1146
+ });
1147
+ }
1148
+ throw error;
1149
+ }
1150
+ return pending.outcome;
619
1151
  }
620
1152
 
621
- async report(child: Activation, output: string): Promise<void> {
622
- if (child.descriptor.mode !== "continuable") {
623
- throw new Error("report is available only to continuable subagents");
1153
+ private async followupTaskSerialized(
1154
+ parent: ParentRef,
1155
+ childId: string,
1156
+ signal?: AbortSignal,
1157
+ ): Promise<PendingFollowupStart> {
1158
+ if (this.draining) {
1159
+ throw new Error("pi-subagent is shutting down; follow-up task was not started");
624
1160
  }
625
- const truncated = truncateUtf8(output, child.descriptor.runtime.maxOutputBytes);
626
- const content = `Background subagent ${child.agentId} reported:\n\n${truncated.text}${
627
- truncated.truncated ? `\n\n[Report truncated; ${truncated.omittedBytes} bytes omitted.]` : ""
628
- }`;
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
- );
640
- }
1161
+ if (signal?.aborted) throw abortReason(signal);
1162
+ let activation = this.active.get(childId);
1163
+ if (activation?.disposed) activation = undefined;
1164
+ let descriptor: SubagentDescriptor;
1165
+ let manager: SessionManager;
1166
+ let coldPrepared: PreparedChildSession | undefined;
1167
+
1168
+ if (activation) {
1169
+ this.touchActivation(activation);
1170
+ descriptor = activation.descriptor;
1171
+ this.assertContinuableDirectChild(parent, descriptor);
1172
+ manager = activation.runtime.session.sessionManager;
1173
+ if (activation.currentRun || activation.runtime.session.isStreaming) {
1174
+ throw new Error(
1175
+ `subagent ${childId} already has a scheduled or running turn; mailbox messages were not consumed`,
1176
+ );
1177
+ }
1178
+ } else {
1179
+ const located = await this.findPersistedChild(parent, childId);
1180
+ if (!located) {
1181
+ throw new Error(`unknown subagent: ${childId}; follow-up task was not started`);
1182
+ }
1183
+ descriptor = located.descriptor;
1184
+ this.assertContinuableDirectChild(parent, descriptor);
1185
+ manager = SessionManager.open(
1186
+ located.sessionFile,
1187
+ parent.sessionManager.getSessionDir(),
1188
+ parent.cwd,
1189
+ );
1190
+ }
1191
+ if (descriptor.runtime.backgroundProtocol !== "mailbox-v2") {
1192
+ throw new Error(
1193
+ `subagent ${childId} uses the legacy background protocol; use send_message to start its next turn`,
1194
+ );
1195
+ }
1196
+
1197
+ const owner = mailboxOwner(descriptor);
1198
+ const batch = readMailbox(manager.getEntries(), owner).pending;
1199
+ if (batch.length === 0) {
1200
+ throw new Error(`subagent ${childId} has no pending mailbox messages`);
1201
+ }
1202
+ const messageIds = batch.map((message) => message.messageId);
1203
+
1204
+ if (!activation) {
1205
+ coldPrepared = {
1206
+ sessionManager: manager,
1207
+ seedMessageCount: manager.buildSessionContext().messages.length,
1208
+ rollback: () => Promise.resolve(),
1209
+ };
1210
+ activation = await this.createActivation({
1211
+ parent,
1212
+ descriptor,
1213
+ prepared: coldPrepared,
1214
+ isNew: false,
1215
+ });
1216
+ this.acquireParentOwnership(activation, parent);
1217
+ }
1218
+
1219
+ try {
1220
+ this.acquireParentOwnership(activation, parent);
1221
+ const started = this.startPrompt(activation, "", signal, {
1222
+ detachAtAcceptance: true,
1223
+ waitForCapacity: !parent.activation?.holdsBackgroundSlot,
1224
+ preparePrompt: (turnId) =>
1225
+ formatMailboxBatch(batch, turnId),
1226
+ onPromptAccepted: (turnId) => {
1227
+ claimMailboxMessages(
1228
+ manager,
1229
+ messageIds,
1230
+ turnId,
1231
+ owner,
1232
+ );
1233
+ activation.pendingMailboxClaims.add(turnId);
1234
+ },
1235
+ acceptAfterUserMessage: true,
1236
+ });
1237
+ return {
1238
+ activation,
1239
+ accepted: started.accepted,
1240
+ ...(coldPrepared ? { coldPrepared } : {}),
1241
+ outcome: {
1242
+ agentId: childId,
1243
+ taskPath: descriptorTaskPath(descriptor),
1244
+ turnId: started.turnId,
1245
+ claimedMessages: batch.length,
1246
+ },
1247
+ };
1248
+ } catch (error) {
1249
+ if (coldPrepared && !activation.published) {
1250
+ activation.suppressSettlement = true;
1251
+ await this.rollbackActivation(activation, coldPrepared);
1252
+ }
1253
+ throw error;
1254
+ }
1255
+ }
1256
+
1257
+ async waitAgent(
1258
+ parent: ParentRef,
1259
+ toolCallId: string,
1260
+ timeoutMs?: number,
1261
+ signal?: AbortSignal,
1262
+ ): Promise<WaitAgentOutcome> {
1263
+ return this.runAdmittedOperation(async () => {
1264
+ const outcome = await this.waitAgentAdmitted(
1265
+ parent,
1266
+ toolCallId,
1267
+ waitTimeout(timeoutMs),
1268
+ signal,
1269
+ );
1270
+ if (outcome.updates.length === 0) {
1271
+ return { ...outcome, taskPaths: {} };
1272
+ }
1273
+ let paths = new Map<string, string>();
1274
+ try {
1275
+ const catalog = await this.catalogRecords(parent);
1276
+ paths = new Map(
1277
+ catalog.records.map((record) => [
1278
+ record.agentId,
1279
+ record.taskPath,
1280
+ ]),
1281
+ );
1282
+ } catch {
1283
+ // Completion delivery is already durably reserved. Readable
1284
+ // path enrichment is cosmetic and must not turn that delivery
1285
+ // into a failed tool call.
1286
+ }
1287
+ return {
1288
+ ...outcome,
1289
+ taskPaths: Object.fromEntries(
1290
+ outcome.updates.map((update) => [
1291
+ update.childAgentId,
1292
+ paths.get(update.childAgentId)
1293
+ ?? update.childAgentId,
1294
+ ]),
1295
+ ),
1296
+ };
1297
+ });
1298
+ }
1299
+
1300
+ private async waitAgentAdmitted(
1301
+ parent: ParentRef,
1302
+ toolCallId: string,
1303
+ timeoutMs: number,
1304
+ signal?: AbortSignal,
1305
+ ): Promise<WaitAgentOutcome> {
1306
+ if (this.draining) {
1307
+ throw new Error("pi-subagent is shutting down; wait_agent was not accepted");
1308
+ }
1309
+ if (signal?.aborted) throw abortReason(signal);
1310
+ if (!toolCallId.trim() || toolCallId.length > 512) {
1311
+ throw new Error("wait_agent requires a non-empty tool call id of at most 512 characters");
1312
+ }
1313
+ const key = completionWaiterKey(parent);
1314
+ const deadline = Date.now() + timeoutMs;
1315
+ while (true) {
1316
+ let waiter: CompletionWaiter | undefined;
1317
+ const immediate = await this.completionOperations.run(
1318
+ key,
1319
+ async (): Promise<WaitAgentOutcome | undefined> => {
1320
+ if (this.draining) {
1321
+ throw new Error("pi-subagent is shutting down; wait_agent was interrupted");
1322
+ }
1323
+ if (signal?.aborted) throw abortReason(signal);
1324
+ if (this.completionWaiters.has(key)) {
1325
+ throw new Error(
1326
+ `wait_agent is already waiting for direct-child activity on ${parent.agentId}`,
1327
+ );
1328
+ }
1329
+ const snapshot = readCompletionMailbox(
1330
+ parent.sessionManager.getEntries(),
1331
+ {
1332
+ parentAgentId: parent.agentId,
1333
+ activeRuntimeId: this.runtimeId,
1334
+ },
1335
+ );
1336
+ if (snapshot.currentRuntimeReservations.length > 0) {
1337
+ throw new Error(
1338
+ "a previous wait_agent delivery is awaiting its durable tool result",
1339
+ );
1340
+ }
1341
+ if (snapshot.available.length > 0) {
1342
+ return this.reserveWaitAgentOutcome(
1343
+ parent,
1344
+ toolCallId,
1345
+ timeoutMs,
1346
+ snapshot,
1347
+ );
1348
+ }
1349
+ const remaining = Math.max(0, deadline - Date.now());
1350
+ if (remaining === 0) {
1351
+ return {
1352
+ timedOut: true,
1353
+ timeoutMs,
1354
+ updates: [],
1355
+ unreadUpdates: snapshot.unread.length,
1356
+ taskPaths: {},
1357
+ };
1358
+ }
1359
+ waiter = this.createCompletionWaiter(remaining, signal);
1360
+ this.completionWaiters.set(key, waiter);
1361
+
1362
+ // Append happens before notify, but fold once more after
1363
+ // subscription so no completion can land in the check/register gap.
1364
+ const rechecked = readCompletionMailbox(
1365
+ parent.sessionManager.getEntries(),
1366
+ {
1367
+ parentAgentId: parent.agentId,
1368
+ activeRuntimeId: this.runtimeId,
1369
+ },
1370
+ );
1371
+ if (rechecked.available.length > 0) {
1372
+ try {
1373
+ return this.reserveWaitAgentOutcome(
1374
+ parent,
1375
+ toolCallId,
1376
+ timeoutMs,
1377
+ rechecked,
1378
+ );
1379
+ } finally {
1380
+ this.removeCompletionWaiter(key, waiter);
1381
+ waiter = undefined;
1382
+ }
1383
+ }
1384
+ return undefined;
1385
+ },
1386
+ );
1387
+ if (immediate) return immediate;
1388
+ if (!waiter) {
1389
+ throw new Error("wait_agent failed to establish an activity subscription");
1390
+ }
1391
+ let activity: "activity" | "timeout";
1392
+ try {
1393
+ activity = await waiter.promise;
1394
+ } catch (error) {
1395
+ this.removeCompletionWaiter(key, waiter);
1396
+ throw error;
1397
+ }
1398
+ const afterWake = await this.completionOperations.run(
1399
+ key,
1400
+ async (): Promise<WaitAgentOutcome | undefined> => {
1401
+ try {
1402
+ if (this.draining) {
1403
+ throw new Error(
1404
+ "pi-subagent is shutting down; wait_agent was interrupted",
1405
+ );
1406
+ }
1407
+ if (signal?.aborted) throw abortReason(signal);
1408
+ if (this.completionWaiters.get(key) !== waiter) {
1409
+ throw new Error(
1410
+ "wait_agent lost ownership of its activity subscription",
1411
+ );
1412
+ }
1413
+ const snapshot = readCompletionMailbox(
1414
+ parent.sessionManager.getEntries(),
1415
+ {
1416
+ parentAgentId: parent.agentId,
1417
+ activeRuntimeId: this.runtimeId,
1418
+ },
1419
+ );
1420
+ if (snapshot.currentRuntimeReservations.length > 0) {
1421
+ throw new Error(
1422
+ "a previous wait_agent delivery is awaiting its durable tool result",
1423
+ );
1424
+ }
1425
+ if (snapshot.available.length > 0) {
1426
+ return this.reserveWaitAgentOutcome(
1427
+ parent,
1428
+ toolCallId,
1429
+ timeoutMs,
1430
+ snapshot,
1431
+ );
1432
+ }
1433
+ if (activity === "timeout" || Date.now() >= deadline) {
1434
+ return {
1435
+ timedOut: true,
1436
+ timeoutMs,
1437
+ updates: [],
1438
+ unreadUpdates: snapshot.unread.length,
1439
+ taskPaths: {},
1440
+ };
1441
+ }
1442
+ return undefined;
1443
+ } finally {
1444
+ this.removeCompletionWaiter(key, waiter!);
1445
+ }
1446
+ },
1447
+ );
1448
+ if (afterWake) return afterWake;
1449
+ }
1450
+ }
1451
+
1452
+ private reserveWaitAgentOutcome(
1453
+ parent: ParentRef,
1454
+ toolCallId: string,
1455
+ timeoutMs: number,
1456
+ snapshot: ReturnType<typeof readCompletionMailbox>,
1457
+ ): WaitAgentOutcome {
1458
+ const updates = this.boundedWaitAgentUpdates(snapshot.available);
1459
+ reserveCompletionDelivery(parent.sessionManager, {
1460
+ parentAgentId: parent.agentId,
1461
+ runtimeId: this.runtimeId,
1462
+ toolCallId,
1463
+ completionIds: updates.map((update) => update.completionId),
1464
+ });
1465
+ return {
1466
+ timedOut: false,
1467
+ timeoutMs,
1468
+ updates,
1469
+ unreadUpdates: Math.max(
1470
+ 0,
1471
+ snapshot.unread.length - updates.length,
1472
+ ),
1473
+ taskPaths: {},
1474
+ };
1475
+ }
1476
+
1477
+ private boundedWaitAgentUpdates(
1478
+ available: readonly CompletionUpdate[],
1479
+ ): CompletionUpdate[] {
1480
+ const updates: CompletionUpdate[] = [];
1481
+ let remainingBytes = MAX_WAIT_AGENT_RESULT_BYTES;
1482
+ for (const update of available) {
1483
+ if (updates.length >= MAX_COMPLETIONS_PER_DELIVERY) break;
1484
+ const metadataBytes = Buffer.byteLength(
1485
+ `completion ${update.completionId}\nchild=${update.childAgentId} turn=${update.turnId} stop=${update.stopReason}\n`,
1486
+ "utf8",
1487
+ ) + 256;
1488
+ const fullBytes =
1489
+ metadataBytes + Buffer.byteLength(update.output, "utf8");
1490
+ if (updates.length > 0 && fullBytes > remainingBytes) break;
1491
+ const outputBudget = Math.max(0, remainingBytes - metadataBytes);
1492
+ const truncated = truncateUtf8(update.output, outputBudget);
1493
+ updates.push({
1494
+ ...update,
1495
+ output: truncated.text,
1496
+ ...(truncated.truncated || update.outputTruncated
1497
+ ? {
1498
+ outputTruncated: true,
1499
+ omittedBytes:
1500
+ (update.omittedBytes ?? 0)
1501
+ + truncated.omittedBytes,
1502
+ }
1503
+ : {}),
1504
+ });
1505
+ remainingBytes = Math.max(
1506
+ 0,
1507
+ remainingBytes
1508
+ - metadataBytes
1509
+ - Buffer.byteLength(truncated.text, "utf8"),
1510
+ );
1511
+ if (truncated.truncated) break;
1512
+ }
1513
+ return updates;
1514
+ }
1515
+
1516
+ async releaseWaitAgentDeliveries(
1517
+ parent: ParentRef,
1518
+ reason: string,
1519
+ ): Promise<number> {
1520
+ const key = completionWaiterKey(parent);
1521
+ return this.completionOperations.run(key, async () =>
1522
+ releaseCompletionDeliveries(parent.sessionManager, {
1523
+ parentAgentId: parent.agentId,
1524
+ runtimeId: this.runtimeId,
1525
+ reason,
1526
+ }),
1527
+ );
1528
+ }
1529
+
1530
+ private createCompletionWaiter(
1531
+ timeoutMs: number,
1532
+ signal?: AbortSignal,
1533
+ ): CompletionWaiter {
1534
+ let settled = false;
1535
+ let timer: ReturnType<typeof setTimeout> | undefined;
1536
+ let resolvePromise!: (activity: "activity" | "timeout") => void;
1537
+ let rejectPromise!: (error: Error) => void;
1538
+ const promise = new Promise<"activity" | "timeout">(
1539
+ (resolve, reject) => {
1540
+ resolvePromise = resolve;
1541
+ rejectPromise = reject;
1542
+ },
1543
+ );
1544
+ const onAbort = () => {
1545
+ if (settled) return;
1546
+ settled = true;
1547
+ if (timer) clearTimeout(timer);
1548
+ rejectPromise(abortReason(signal!));
1549
+ };
1550
+ if (signal) signal.addEventListener("abort", onAbort, { once: true });
1551
+ timer = setTimeout(() => {
1552
+ if (settled) return;
1553
+ settled = true;
1554
+ if (signal) signal.removeEventListener("abort", onAbort);
1555
+ resolvePromise("timeout");
1556
+ }, timeoutMs);
1557
+ // An awaited tool deadline must keep headless SDK processes alive.
1558
+ // Wake, abort and shutdown clear it rather than leaving a background timer.
1559
+ const dispose = () => {
1560
+ if (timer) clearTimeout(timer);
1561
+ if (signal) signal.removeEventListener("abort", onAbort);
1562
+ };
1563
+ return {
1564
+ promise,
1565
+ wake: () => {
1566
+ if (settled) return;
1567
+ settled = true;
1568
+ dispose();
1569
+ resolvePromise("activity");
1570
+ },
1571
+ reject: (error) => {
1572
+ if (settled) return;
1573
+ settled = true;
1574
+ dispose();
1575
+ rejectPromise(error);
1576
+ },
1577
+ dispose,
1578
+ };
1579
+ }
1580
+
1581
+ private removeCompletionWaiter(
1582
+ key: string,
1583
+ waiter: CompletionWaiter,
1584
+ ): void {
1585
+ if (this.completionWaiters.get(key) === waiter) {
1586
+ this.completionWaiters.delete(key);
1587
+ }
1588
+ waiter.dispose();
1589
+ }
1590
+
1591
+ async interrupt(parent: ParentRef, targetId: string): Promise<void> {
1592
+ await this.interruptWithOutcome(parent, targetId);
1593
+ }
1594
+
1595
+ async interruptWithOutcome(
1596
+ parent: ParentRef,
1597
+ targetId: string,
1598
+ ): Promise<InterruptOutcome> {
1599
+ return this.runAdmittedOperation(() =>
1600
+ this.interruptAdmitted(parent, targetId),
1601
+ );
1602
+ }
1603
+
1604
+ private async interruptAdmitted(
1605
+ parent: ParentRef,
1606
+ targetId: string,
1607
+ ): Promise<InterruptOutcome> {
1608
+ const resolved = await this.resolveTarget(parent, targetId, {
1609
+ allowUnknownId: true,
1610
+ });
1611
+ return this.agentOperations.run(resolved.agentId, async () => {
1612
+ if (
1613
+ resolved.record
1614
+ && !(await this.isDescendantOf(
1615
+ parent,
1616
+ resolved.record.descriptor,
1617
+ ))
1618
+ ) {
1619
+ throw new Error(
1620
+ `subagent ${resolved.taskPath} is not a descendant of ${parent.taskPath}`,
1621
+ );
1622
+ }
1623
+ const target = this.active.get(resolved.agentId);
1624
+ if (!target) {
1625
+ return {
1626
+ agentId: resolved.agentId,
1627
+ taskPath: resolved.taskPath,
1628
+ active: false,
1629
+ };
1630
+ }
1631
+ if (!(await this.isDescendantOf(parent, target.descriptor))) {
1632
+ throw new Error(
1633
+ `subagent ${resolved.taskPath} is not a live descendant of ${parent.taskPath}`,
1634
+ );
1635
+ }
1636
+ this.touchActivation(target);
1637
+ interruptAgentTurn(target.controlState);
1638
+ this.emitUpdate(target);
1639
+ target.turnAbortController?.abort(
1640
+ new Error(`subagent ${resolved.taskPath} was interrupted`),
1641
+ );
1642
+ void target.runtime.session.abort().catch((error) => {
1643
+ target.lastError = errorText(error);
1644
+ });
1645
+ return {
1646
+ agentId: resolved.agentId,
1647
+ taskPath: descriptorTaskPath(target.descriptor),
1648
+ active: true,
1649
+ };
1650
+ });
1651
+ }
1652
+
1653
+ async list(parent: ParentRef, scope: "children" | "descendants"): Promise<CatalogEntry[]> {
1654
+ return this.runAdmittedOperation(() => this.listAdmitted(parent, scope));
1655
+ }
1656
+
1657
+ private async listAdmitted(
1658
+ parent: ParentRef,
1659
+ scope: "children" | "descendants",
1660
+ ): Promise<CatalogEntry[]> {
1661
+ const catalog = await this.catalogRecords(parent);
1662
+ const records = catalog.records;
1663
+ const byId = new Map(records.map((record) => [record.agentId, record]));
1664
+ const children: CatalogChild[] = [];
1665
+ for (const record of records) {
1666
+ if (record.descriptor.mode !== "continuable") continue;
1667
+ const distance = this.distanceFrom(parent.agentId, record.descriptor, byId);
1668
+ if (distance === undefined || (scope === "children" && distance !== 1)) continue;
1669
+ children.push({
1670
+ kind: "child",
1671
+ agentId: record.agentId,
1672
+ parentAgentId: record.descriptor.parentAgentId,
1673
+ taskPath: record.taskPath,
1674
+ parentTaskPath:
1675
+ record.descriptor.parentAgentId === parent.agentId
1676
+ ? parent.taskPath
1677
+ : (byId.get(record.descriptor.parentAgentId)?.taskPath
1678
+ ?? record.descriptor.parentAgentId),
1679
+ depth: distance,
1680
+ descriptor: record.descriptor,
1681
+ ...(record.sessionFile ? { sessionFile: record.sessionFile } : {}),
1682
+ status: record.active ? catalogStatus(record.active.controlState) : "ready",
1683
+ pendingMessages: record.pendingMessages,
1684
+ unreadUpdates:
1685
+ record.descriptor.parentAgentId === parent.agentId
1686
+ ? (catalog.rootUnreadUpdatesByChild.get(record.agentId) ?? 0)
1687
+ : (byId
1688
+ .get(record.descriptor.parentAgentId)
1689
+ ?.unreadUpdatesByChild.get(record.agentId) ?? 0),
1690
+ });
1691
+ }
1692
+ children.sort(
1693
+ (left, right) =>
1694
+ left.descriptor.createdAt.localeCompare(right.descriptor.createdAt) ||
1695
+ left.agentId.localeCompare(right.agentId),
1696
+ );
1697
+ const parentFile = parent.sessionManager.getSessionFile();
1698
+ const diagnostics = parentFile
1699
+ ? catalog.diagnostics.filter(
1700
+ (entry) => entry.kind === "diagnostic" && entry.parentSessionFile === parentFile,
1701
+ )
1702
+ : [];
1703
+ return [...children, ...diagnostics];
1704
+ }
1705
+
1706
+ async report(child: Activation, output: string): Promise<void> {
1707
+ return this.runAdmittedOperation(() => this.reportAdmitted(child, output));
1708
+ }
1709
+
1710
+ private async reportAdmitted(child: Activation, output: string): Promise<void> {
1711
+ if (child.descriptor.mode !== "continuable") {
1712
+ throw new Error("report is available only to continuable subagents");
1713
+ }
1714
+ const truncated = truncateUtf8(output, child.descriptor.runtime.maxOutputBytes);
1715
+ const readablePath = descriptorTaskPath(child.descriptor);
1716
+ const content = `Background subagent ${readablePath} (${child.agentId}) reported:\n\n${truncated.text}${
1717
+ truncated.truncated ? `\n\n[Report truncated; ${truncated.omittedBytes} bytes omitted.]` : ""
1718
+ }`;
1719
+ await child.parent.deliver(
1720
+ REPORT_CUSTOM_TYPE,
1721
+ content,
1722
+ {
1723
+ kind: "report",
1724
+ childAgentId: child.agentId,
1725
+ taskPath: readablePath,
1726
+ label: child.descriptor.label,
1727
+ ...(truncated.truncated ? { truncated: true } : {}),
1728
+ },
1729
+ child.descriptor.runtime.reportDelivery,
1730
+ );
1731
+ }
1732
+
1733
+ private runAdmittedOperation<T>(operation: () => Promise<T>): Promise<T> {
1734
+ if (this.draining) {
1735
+ return Promise.reject(new Error("pi-subagent is shutting down"));
1736
+ }
1737
+ const promise = operation();
1738
+ this.admittedOperations.add(promise);
1739
+ const remove = () => {
1740
+ this.admittedOperations.delete(promise);
1741
+ };
1742
+ void promise.then(remove, remove);
1743
+ return promise;
1744
+ }
1745
+
1746
+ private async waitForAdmittedOperations(): Promise<void> {
1747
+ while (this.admittedOperations.size > 0) {
1748
+ await Promise.allSettled([...this.admittedOperations]);
1749
+ }
1750
+ }
641
1751
 
642
1752
  async shutdown(): Promise<void> {
643
- if (this.draining) return;
1753
+ if (this.shutdownPromise) return this.shutdownPromise;
644
1754
  this.draining = true;
1755
+ this.rejectCompletionWaiters(
1756
+ new Error("pi-subagent is shutting down"),
1757
+ );
1758
+ this.shutdownPromise = this.performShutdown();
1759
+ return this.shutdownPromise;
1760
+ }
1761
+
1762
+ private async performShutdown(): Promise<void> {
1763
+ this.backgroundRuns.close();
1764
+ await this.abortActiveRuns();
1765
+ await this.agentOperations.waitForIdle();
1766
+ await this.idleRuntimeOperations.waitForIdle();
1767
+ await this.completionOperations.waitForIdle();
1768
+ await this.abortActiveRuns();
1769
+ await this.waitForAdmittedOperations();
1770
+ await this.agentOperations.waitForIdle();
1771
+ await this.abortActiveRuns();
645
1772
  const activations = [...this.active.values()];
646
- for (const activation of activations) activation.suppressSettlement = true;
647
1773
  await Promise.allSettled(
648
- activations.map(async (activation) => {
649
- if (!activation.runtime.session.isIdle) await activation.runtime.session.abort();
650
- }),
1774
+ activations
1775
+ .map((activation) => activation.currentRun)
1776
+ .filter(
1777
+ (run): run is Promise<SubagentRunResult> => run !== undefined,
1778
+ ),
651
1779
  );
652
1780
  for (const activation of activations.sort((left, right) => right.descriptor.depth - left.descriptor.depth)) {
653
1781
  await this.disposeActivation(activation).catch(() => {});
654
1782
  }
1783
+ await this.agentOperations.waitForIdle();
1784
+ await this.idleRuntimeOperations.waitForIdle();
1785
+ await this.completionOperations.waitForIdle();
1786
+ }
1787
+
1788
+ private rejectCompletionWaiters(error: Error): void {
1789
+ for (const [key, waiter] of this.completionWaiters) {
1790
+ this.completionWaiters.delete(key);
1791
+ waiter.reject(error);
1792
+ waiter.dispose();
1793
+ }
1794
+ }
1795
+
1796
+ private async abortActiveRuns(): Promise<void> {
1797
+ const activations = [...this.active.values()];
1798
+ for (const activation of activations) {
1799
+ activation.suppressSettlement = true;
1800
+ activation.turnAbortController?.abort(
1801
+ new Error("pi-subagent is shutting down"),
1802
+ );
1803
+ }
1804
+ await Promise.allSettled(
1805
+ activations.map(async (activation) => {
1806
+ if (!activation.runtime.session.isIdle) {
1807
+ await activation.runtime.session.abort();
1808
+ }
1809
+ }),
1810
+ );
655
1811
  }
656
1812
 
657
1813
  createChildToolDefinitions(
@@ -659,6 +1815,7 @@ export class SubagentCoordinator {
659
1815
  enableRunInBackground = true,
660
1816
  defaultBackground = true,
661
1817
  agentDiscovery?: AgentDiscoveryResult,
1818
+ backgroundProtocol: NonNullable<SubagentSettings["backgroundProtocol"]> = "legacy",
662
1819
  ): ToolDefinition[] {
663
1820
  const agentNames = agentDiscovery?.agents.map((agent) => agent.name);
664
1821
  const assertBackgroundControlEnabled = (toolName: string): void => {
@@ -678,12 +1835,12 @@ export class SubagentCoordinator {
678
1835
  name: "subagent",
679
1836
  label: "Subagent",
680
1837
  description:
681
- "Delegate a standalone task to a fresh child in an isolated session. " +
1838
+ "Delegate a standalone task to a named child path, with optional completed-turn context inheritance. " +
682
1839
  (!enableRunInBackground
683
1840
  ? "This foreground-only instance always waits for the result."
684
1841
  : 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."),
1842
+ ? "It runs in the background by default and returns a readable path plus durable id."
1843
+ : "It waits for the result by default; background mode returns a readable path plus durable id."),
687
1844
  parameters: delegationParameters(enableRunInBackground, agentNames),
688
1845
  execute: async (_id, params, signal, onUpdate) => {
689
1846
  const activation = getActivation();
@@ -704,8 +1861,11 @@ export class SubagentCoordinator {
704
1861
  name: "subagent_fork",
705
1862
  label: "Subagent Fork",
706
1863
  description:
707
- "Delegate a one-shot task to a child seeded with completed turns from this conversation.",
708
- parameters: forkDelegationParameters(agentNames),
1864
+ "Delegate a task to a child seeded with all completed turns from this conversation. It is foreground by default and can be explicitly continuable.",
1865
+ parameters: forkDelegationParameters(
1866
+ agentNames,
1867
+ enableRunInBackground,
1868
+ ),
709
1869
  execute: async (_id, params, signal, onUpdate) => {
710
1870
  const activation = getActivation();
711
1871
  const outcome = await this.delegate(
@@ -725,12 +1885,14 @@ export class SubagentCoordinator {
725
1885
  name: "send_message",
726
1886
  label: "Send Message",
727
1887
  description:
728
- "Queue a message as a direct continuable child's next FIFO turn. This returns acceptance, not the child's answer.",
1888
+ backgroundProtocol === "mailbox-v2"
1889
+ ? "Durably append a message to a direct mailbox-v2 child's FIFO mailbox without starting or resuming it."
1890
+ : "Queue a message as a direct continuable child's next FIFO turn. This returns acceptance, not the child's answer.",
729
1891
  parameters: SendMessageParameters,
730
1892
  execute: async (_id, params, signal) => {
731
1893
  assertBackgroundControlEnabled("send_message");
732
1894
  const activation = getActivation();
733
- await this.sendMessage(
1895
+ const delivery = await this.sendMessageWithOutcome(
734
1896
  this.parentForActivation(activation),
735
1897
  params.subagent_id,
736
1898
  params.message,
@@ -740,10 +1902,92 @@ export class SubagentCoordinator {
740
1902
  content: [
741
1903
  {
742
1904
  type: "text",
743
- text: `message queued as the next turn for subagent ${params.subagent_id}`,
1905
+ text:
1906
+ delivery.kind === "mailbox-v2"
1907
+ ? `message ${delivery.messageId} durably enqueued for ${delivery.taskPath}; ${delivery.pendingMessages} pending`
1908
+ : `message queued as the next turn for ${delivery.taskPath}`,
1909
+ },
1910
+ ],
1911
+ details: {
1912
+ kind: "control",
1913
+ action: "send",
1914
+ agentId: delivery.agentId,
1915
+ taskPath: delivery.taskPath,
1916
+ ...(delivery.kind === "mailbox-v2"
1917
+ ? {
1918
+ messageId: delivery.messageId,
1919
+ pendingMessages: delivery.pendingMessages,
1920
+ }
1921
+ : {}),
1922
+ } satisfies ControlDetails,
1923
+ };
1924
+ },
1925
+ });
1926
+
1927
+ const followup = defineTool({
1928
+ name: "followup_task",
1929
+ label: "Follow-up Task",
1930
+ description:
1931
+ "Start exactly one scheduled turn for a direct mailbox-v2 child, atomically claiming its current pending FIFO mailbox batch.",
1932
+ parameters: FollowupTaskParameters,
1933
+ execute: async (_id, params, signal) => {
1934
+ assertBackgroundControlEnabled("followup_task");
1935
+ const activation = getActivation();
1936
+ const outcome = await this.followupTask(
1937
+ this.parentForActivation(activation),
1938
+ params.subagent_id,
1939
+ signal,
1940
+ );
1941
+ return {
1942
+ content: [
1943
+ {
1944
+ type: "text",
1945
+ text: `started turn ${outcome.turnId} for ${outcome.taskPath}, claiming ${outcome.claimedMessages} mailbox message${outcome.claimedMessages === 1 ? "" : "s"}`,
744
1946
  },
745
1947
  ],
746
- details: { kind: "control", action: "send", agentId: params.subagent_id } satisfies ControlDetails,
1948
+ details: {
1949
+ kind: "control",
1950
+ action: "followup",
1951
+ agentId: outcome.agentId,
1952
+ taskPath: outcome.taskPath,
1953
+ turnId: outcome.turnId,
1954
+ claimedMessages: outcome.claimedMessages,
1955
+ } satisfies ControlDetails,
1956
+ };
1957
+ },
1958
+ });
1959
+
1960
+ const wait = defineTool({
1961
+ name: "wait_agent",
1962
+ label: "Wait Agent",
1963
+ description:
1964
+ "Wait event-driven for unread completion updates from direct mailbox-v2 children. This does not start a child or occupy a background scheduler slot.",
1965
+ parameters: WaitAgentParameters,
1966
+ execute: async (id, params, signal) => {
1967
+ assertBackgroundControlEnabled("wait_agent");
1968
+ const activation = getActivation();
1969
+ const outcome = await this.waitAgent(
1970
+ this.parentForActivation(activation),
1971
+ id,
1972
+ params.timeout_ms,
1973
+ signal,
1974
+ );
1975
+ return {
1976
+ content: [
1977
+ {
1978
+ type: "text",
1979
+ text: this.formatWaitAgentOutcome(outcome),
1980
+ },
1981
+ ],
1982
+ details: {
1983
+ kind: "control",
1984
+ action: "wait",
1985
+ timedOut: outcome.timedOut,
1986
+ completionIds: outcome.updates.map(
1987
+ (update) => update.completionId,
1988
+ ),
1989
+ unreadUpdates: outcome.unreadUpdates,
1990
+ } satisfies ControlDetails,
747
1991
  };
748
1992
  },
749
1993
  });
@@ -757,10 +2001,18 @@ export class SubagentCoordinator {
757
2001
  execute: async (_id, params) => {
758
2002
  assertBackgroundControlEnabled("interrupt_agent");
759
2003
  const activation = getActivation();
760
- await this.interrupt(this.parentForActivation(activation), params.agent_id);
2004
+ const outcome = await this.interruptWithOutcome(
2005
+ this.parentForActivation(activation),
2006
+ params.agent_id,
2007
+ );
761
2008
  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,
2009
+ content: [{ type: "text", text: `interrupt requested for ${outcome.taskPath}` }],
2010
+ details: {
2011
+ kind: "control",
2012
+ action: "interrupt",
2013
+ agentId: outcome.agentId,
2014
+ taskPath: outcome.taskPath,
2015
+ } satisfies ControlDetails,
764
2016
  };
765
2017
  },
766
2018
  });
@@ -769,7 +2021,7 @@ export class SubagentCoordinator {
769
2021
  name: "list_agents",
770
2022
  label: "List Agents",
771
2023
  description:
772
- "List direct continuable children or all descendants as running, idle, or ready (persisted and resumable).",
2024
+ "List direct continuable children or all descendants as running, idle, or ready, with separate mailbox task and completion counts.",
773
2025
  parameters: ListAgentsParameters,
774
2026
  execute: async (_id, params) => {
775
2027
  assertBackgroundControlEnabled("list_agents");
@@ -796,18 +2048,28 @@ export class SubagentCoordinator {
796
2048
  await this.report(activation, params.output);
797
2049
  return {
798
2050
  content: [{ type: "text", text: `report accepted by the agent that started you` }],
799
- details: { kind: "control", action: "report", agentId: activation.agentId } satisfies ControlDetails,
2051
+ details: {
2052
+ kind: "control",
2053
+ action: "report",
2054
+ agentId: activation.agentId,
2055
+ taskPath: descriptorTaskPath(activation.descriptor),
2056
+ } satisfies ControlDetails,
800
2057
  };
801
2058
  },
802
2059
  });
803
2060
 
804
- return [spawn, fork, send, interrupt, list, report];
2061
+ return [spawn, fork, send, followup, wait, interrupt, list, report];
805
2062
  }
806
2063
 
807
2064
  outcomeToolResult(outcome: DelegationOutcome): AgentToolResult<DelegationDetails> {
808
2065
  if (outcome.kind === "continuable") {
809
2066
  return {
810
- content: [{ type: "text", text: `started subagent ${outcome.details.agentId}` }],
2067
+ content: [
2068
+ {
2069
+ type: "text",
2070
+ text: `started subagent ${outcome.details.taskPath} (${outcome.details.agentId})`,
2071
+ },
2072
+ ],
811
2073
  details: outcome.details,
812
2074
  };
813
2075
  }
@@ -834,12 +2096,52 @@ export class SubagentCoordinator {
834
2096
  return `${entry.piSessionId} [diagnostic: ${entry.reason}]`;
835
2097
  }
836
2098
  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})`;
2099
+ scope === "descendants"
2100
+ ? ` parent=${entry.parentTaskPath} depth=${entry.depth}`
2101
+ : "";
2102
+ const mailbox =
2103
+ entry.descriptor.runtime.backgroundProtocol === "mailbox-v2"
2104
+ ? ` pending=${entry.pendingMessages} updates=${entry.unreadUpdates}`
2105
+ : "";
2106
+ return `${entry.taskPath} [${entry.status}]${mailbox}${location} — ${entry.descriptor.label} (${entry.descriptor.agent.name}) id=${entry.agentId}`;
839
2107
  })
840
2108
  .join("\n");
841
2109
  }
842
2110
 
2111
+ formatWaitAgentOutcome(outcome: WaitAgentOutcome): string {
2112
+ if (outcome.timedOut) {
2113
+ return `wait_agent timed out after ${outcome.timeoutMs}ms with no completion updates`;
2114
+ }
2115
+ const updates = outcome.updates.map((update) => {
2116
+ const output = update.output.trim() || "(no output)";
2117
+ const fullPath =
2118
+ outcome.taskPaths[update.childAgentId]
2119
+ ?? update.childAgentId;
2120
+ const readablePath =
2121
+ fullPath.length > 200
2122
+ ? `${fullPath.slice(0, 199)}…`
2123
+ : fullPath;
2124
+ const truncation = update.outputTruncated
2125
+ ? `\n[Completion output truncated${
2126
+ update.omittedBytes !== undefined
2127
+ ? `; ${update.omittedBytes} bytes omitted`
2128
+ : ""
2129
+ }.]`
2130
+ : "";
2131
+ return [
2132
+ `completion ${update.completionId}`,
2133
+ `child=${readablePath} id=${update.childAgentId} turn=${update.turnId} stop=${update.stopReason}`,
2134
+ `${output}${truncation}`,
2135
+ ].join("\n");
2136
+ });
2137
+ if (outcome.unreadUpdates > 0) {
2138
+ updates.push(
2139
+ `${outcome.unreadUpdates} additional completion update${outcome.unreadUpdates === 1 ? "" : "s"} remain unread`,
2140
+ );
2141
+ }
2142
+ return updates.join("\n\n");
2143
+ }
2144
+
843
2145
  private resolveModel(parent: ParentRef, agent: AgentDefinition): Model<any> {
844
2146
  if (!agent.model) {
845
2147
  if (!parent.model) throw new Error("no parent model is selected for the subagent");
@@ -881,6 +2183,7 @@ export class SubagentCoordinator {
881
2183
  descriptor.runtime.enableRunInBackground,
882
2184
  descriptor.runtime.defaultBackground,
883
2185
  agentDiscovery,
2186
+ descriptor.runtime.backgroundProtocol,
884
2187
  );
885
2188
  const model =
886
2189
  options.parent.modelRuntime.getModel(descriptor.model.provider, descriptor.model.id) ??
@@ -1001,6 +2304,15 @@ export class SubagentCoordinator {
1001
2304
  ) {
1002
2305
  return false;
1003
2306
  }
2307
+ if (
2308
+ descriptor.runtime.backgroundProtocol !== "mailbox-v2"
2309
+ && (
2310
+ tool === "followup_task"
2311
+ || tool === "wait_agent"
2312
+ )
2313
+ ) {
2314
+ return false;
2315
+ }
1004
2316
  return true;
1005
2317
  });
1006
2318
  runtime.session.setActiveToolsByName(activeTools);
@@ -1045,17 +2357,32 @@ export class SubagentCoordinator {
1045
2357
  runtime,
1046
2358
  seedMessageCount: options.prepared.seedMessageCount,
1047
2359
  epochMessageStart: runtime.session.messages.length,
1048
- status: "starting",
2360
+ controlState: createAgentControlState(),
1049
2361
  trace: [],
1050
2362
  streamedText: "",
1051
2363
  usage: emptyUsage(),
2364
+ startedTurnIds: new Set(),
2365
+ silentSettlementTurnIds: new Set(),
2366
+ pendingMailboxClaims: new Set(),
2367
+ userMessageGates: new Map(),
1052
2368
  ownedChildren: new Set(),
1053
2369
  onUpdate: options.onUpdate,
1054
2370
  published: false,
2371
+ everPublished: false,
1055
2372
  suppressSettlement: false,
1056
2373
  finalizing: false,
2374
+ holdsBackgroundSlot: false,
2375
+ persistenceGate: createPersistenceGate(
2376
+ runtime.session.sessionManager,
2377
+ options.isNew,
2378
+ ),
2379
+ lastUsedSequence: ++this.activationSequence,
1057
2380
  disposed: false,
1058
2381
  };
2382
+ const existing = this.active.get(activation.agentId);
2383
+ if (existing && !existing.disposed) {
2384
+ throw new Error(`subagent ${activation.agentId} already has a resident runtime`);
2385
+ }
1059
2386
  activation.unsubscribe = runtime.session.subscribe((event) => this.observe(activation!, event));
1060
2387
  this.active.set(activation.agentId, activation);
1061
2388
  return activation;
@@ -1065,16 +2392,33 @@ export class SubagentCoordinator {
1065
2392
  }
1066
2393
  }
1067
2394
 
2395
+ private async acquireBackgroundRun(
2396
+ activation: Activation,
2397
+ signal: AbortSignal | undefined,
2398
+ waitForCapacity: boolean,
2399
+ ): Promise<BackgroundRunPermit | undefined> {
2400
+ if (activation.descriptor.mode !== "continuable") return undefined;
2401
+ const permit = await this.backgroundRuns.acquire({
2402
+ ...(signal ? { signal } : {}),
2403
+ waitForCapacity,
2404
+ });
2405
+ activation.holdsBackgroundSlot = true;
2406
+ return permit;
2407
+ }
2408
+
1068
2409
  private startPrompt(
1069
2410
  activation: Activation,
1070
2411
  prompt: string,
1071
2412
  signal: AbortSignal | undefined,
1072
- detachAtAcceptance: boolean,
1073
- ): { accepted: Promise<void>; result: Promise<SubagentRunResult> } {
2413
+ options: StartPromptOptions,
2414
+ ): { turnId: string; accepted: Promise<void>; result: Promise<SubagentRunResult> } {
1074
2415
  if (activation.currentRun) throw new Error(`subagent ${activation.agentId} is already running`);
1075
2416
  if (signal?.aborted) throw signal.reason ?? new Error("subagent start aborted");
2417
+ this.touchActivation(activation);
1076
2418
  activation.pendingSettlement = undefined;
1077
- activation.status = "running";
2419
+ this.resetTurnCapture(activation);
2420
+ const turnId = uuidv7();
2421
+ queueAgentTurn(activation.controlState, turnId);
1078
2422
  this.emitUpdate(activation);
1079
2423
 
1080
2424
  let resolveAccepted!: () => void;
@@ -1084,47 +2428,134 @@ export class SubagentCoordinator {
1084
2428
  resolveAccepted = resolvePromise;
1085
2429
  rejectAccepted = rejectPromise;
1086
2430
  });
2431
+ activation.currentMessageGate = accepted;
2432
+ const clearCurrentMessageGate = () => {
2433
+ if (activation.currentMessageGate === accepted) {
2434
+ activation.currentMessageGate = undefined;
2435
+ }
2436
+ };
2437
+ void accepted.then(clearCurrentMessageGate, clearCurrentMessageGate);
2438
+ const turnAbortController = new AbortController();
2439
+ activation.turnAbortController = turnAbortController;
1087
2440
  const abort = () => {
2441
+ turnAbortController.abort(
2442
+ signal?.reason ?? new Error(`subagent turn ${turnId} was aborted`),
2443
+ );
1088
2444
  void activation.runtime.session.abort().catch(() => {});
1089
2445
  };
1090
2446
  if (signal) signal.addEventListener("abort", abort, { once: true });
2447
+ let preflightSucceeded = false;
2448
+ const acceptPrompt = () => {
2449
+ if (acceptedSettled) return;
2450
+ if (turnAbortController.signal.aborted) {
2451
+ throw abortReason(turnAbortController.signal);
2452
+ }
2453
+ acceptedSettled = true;
2454
+ activation.userMessageGates.delete(turnId);
2455
+ this.publish(activation);
2456
+ if (options.detachAtAcceptance && signal) {
2457
+ signal.removeEventListener("abort", abort);
2458
+ }
2459
+ resolveAccepted();
2460
+ };
2461
+ const rejectPrompt = (error: Error) => {
2462
+ if (acceptedSettled) return;
2463
+ acceptedSettled = true;
2464
+ activation.userMessageGates.delete(turnId);
2465
+ activation.silentSettlementTurnIds.add(turnId);
2466
+ if (!activation.published && !activation.everPublished) {
2467
+ activation.suppressSettlement = true;
2468
+ }
2469
+ rejectAccepted(error);
2470
+ };
2471
+ if (options.acceptAfterUserMessage) {
2472
+ activation.userMessageGates.set(turnId, {
2473
+ prepare: () => {
2474
+ if (turnAbortController.signal.aborted) {
2475
+ throw abortReason(turnAbortController.signal);
2476
+ }
2477
+ options.onPromptAccepted?.(turnId);
2478
+ },
2479
+ accept: acceptPrompt,
2480
+ reject: rejectPrompt,
2481
+ });
2482
+ }
1091
2483
 
1092
2484
  const core = (async (): Promise<SubagentRunResult> => {
2485
+ let permit: BackgroundRunPermit | undefined;
1093
2486
  try {
1094
- await activation.runtime.session.prompt(prompt, {
2487
+ permit = await this.acquireBackgroundRun(
2488
+ activation,
2489
+ turnAbortController.signal,
2490
+ options.waitForCapacity,
2491
+ );
2492
+ if (turnAbortController.signal.aborted) {
2493
+ throw abortReason(turnAbortController.signal);
2494
+ }
2495
+ const preparedPrompt = options.preparePrompt
2496
+ ? options.preparePrompt(turnId)
2497
+ : prompt;
2498
+ startAgentTurn(activation.controlState, turnId);
2499
+ activation.startedTurnIds.add(turnId);
2500
+ this.emitTurnStart(activation, turnId);
2501
+ this.emitUpdate(activation);
2502
+ await activation.runtime.session.prompt(preparedPrompt, {
2503
+ ...(options.preparePrompt
2504
+ ? {
2505
+ expandPromptTemplates: false,
2506
+ source: "extension" as const,
2507
+ }
2508
+ : {}),
1095
2509
  preflightResult: (success) => {
1096
2510
  if (acceptedSettled) return;
1097
- acceptedSettled = true;
1098
2511
  if (success) {
1099
- this.publish(activation);
1100
- if (detachAtAcceptance && signal) signal.removeEventListener("abort", abort);
1101
- resolveAccepted();
2512
+ preflightSucceeded = true;
2513
+ if (options.acceptAfterUserMessage) {
2514
+ return;
2515
+ }
2516
+ options.onPromptAccepted?.(turnId);
2517
+ acceptPrompt();
1102
2518
  } else {
1103
- activation.suppressSettlement = true;
1104
- rejectAccepted(new Error("subagent prompt was rejected before acceptance"));
2519
+ rejectPrompt(
2520
+ new Error("subagent prompt was rejected before acceptance"),
2521
+ );
1105
2522
  }
1106
2523
  },
1107
2524
  });
1108
2525
  if (!acceptedSettled) {
1109
- acceptedSettled = true;
1110
- this.publish(activation);
1111
- if (detachAtAcceptance && signal) signal.removeEventListener("abort", abort);
1112
- resolveAccepted();
2526
+ if (options.acceptAfterUserMessage && preflightSucceeded) {
2527
+ throw new Error(
2528
+ "subagent mailbox prompt was handled before a user turn started",
2529
+ );
2530
+ }
2531
+ if (turnAbortController.signal.aborted) {
2532
+ throw abortReason(turnAbortController.signal);
2533
+ }
2534
+ options.onPromptAccepted?.(turnId);
2535
+ acceptPrompt();
1113
2536
  }
1114
- return this.collectResult(activation, "completed");
2537
+ return this.collectResult(activation, turnId, "completed");
1115
2538
  } catch (error) {
1116
2539
  activation.lastError = errorText(error);
1117
2540
  if (!acceptedSettled) {
1118
- acceptedSettled = true;
1119
- activation.suppressSettlement = true;
1120
- rejectAccepted(error instanceof Error ? error : new Error(String(error)));
2541
+ rejectPrompt(
2542
+ error instanceof Error ? error : new Error(String(error)),
2543
+ );
1121
2544
  }
1122
- const fallback = signal?.aborted ? "aborted" : "error";
1123
- const result = this.collectResult(activation, fallback);
2545
+ const fallback = turnAbortController.signal.aborted ? "aborted" : "error";
2546
+ const result = this.collectResult(activation, turnId, fallback);
1124
2547
  if (!result.output) result.output = activation.lastError;
1125
2548
  return result;
1126
2549
  } finally {
2550
+ activation.userMessageGates.delete(turnId);
1127
2551
  if (signal) signal.removeEventListener("abort", abort);
2552
+ if (activation.turnAbortController === turnAbortController) {
2553
+ activation.turnAbortController = undefined;
2554
+ }
2555
+ if (permit) {
2556
+ activation.holdsBackgroundSlot = false;
2557
+ permit.release();
2558
+ }
1128
2559
  }
1129
2560
  })();
1130
2561
 
@@ -1136,7 +2567,7 @@ export class SubagentCoordinator {
1136
2567
  });
1137
2568
  activation.currentRun = lifecycle;
1138
2569
  void lifecycle.catch(() => {});
1139
- return { accepted, result: lifecycle };
2570
+ return { turnId, accepted, result: lifecycle };
1140
2571
  }
1141
2572
 
1142
2573
  private startInternalMessage(
@@ -1147,23 +2578,68 @@ export class SubagentCoordinator {
1147
2578
  ): void {
1148
2579
  if (activation.currentRun || activation.disposed) return;
1149
2580
  activation.pendingSettlement = undefined;
1150
- activation.status = "running";
1151
- const core = Promise.resolve()
1152
- .then(() =>
1153
- activation.runtime.session.sendCustomMessage(
2581
+ this.resetTurnCapture(activation);
2582
+ const turnId = uuidv7();
2583
+ queueAgentTurn(activation.controlState, turnId);
2584
+ this.emitUpdate(activation);
2585
+ const turnAbortController = new AbortController();
2586
+ activation.turnAbortController = turnAbortController;
2587
+ let resolveMessageGate!: () => void;
2588
+ let rejectMessageGate!: (error: Error) => void;
2589
+ let messageGateSettled = false;
2590
+ const messageGate = new Promise<void>((resolvePromise, rejectPromise) => {
2591
+ resolveMessageGate = resolvePromise;
2592
+ rejectMessageGate = rejectPromise;
2593
+ });
2594
+ activation.currentMessageGate = messageGate;
2595
+ void messageGate.catch(() => {});
2596
+ const core = (async (): Promise<SubagentRunResult> => {
2597
+ let permit: BackgroundRunPermit | undefined;
2598
+ try {
2599
+ permit = await this.acquireBackgroundRun(
2600
+ activation,
2601
+ turnAbortController.signal,
2602
+ true,
2603
+ );
2604
+ startAgentTurn(activation.controlState, turnId);
2605
+ activation.startedTurnIds.add(turnId);
2606
+ this.emitTurnStart(activation, turnId);
2607
+ this.emitUpdate(activation);
2608
+ messageGateSettled = true;
2609
+ resolveMessageGate();
2610
+ await activation.runtime.session.sendCustomMessage(
1154
2611
  { customType, content, display: true, details },
1155
2612
  { 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
- );
2613
+ );
2614
+ return this.collectResult(activation, turnId, "completed");
2615
+ } catch (error) {
2616
+ activation.lastError = errorText(error);
2617
+ if (!messageGateSettled) {
2618
+ messageGateSettled = true;
2619
+ rejectMessageGate(
2620
+ error instanceof Error ? error : new Error(String(error)),
2621
+ );
2622
+ }
2623
+ const result = this.collectResult(
2624
+ activation,
2625
+ turnId,
2626
+ turnAbortController.signal.aborted ? "aborted" : "error",
2627
+ );
2628
+ if (!result.output) result.output = activation.lastError;
2629
+ return result;
2630
+ } finally {
2631
+ if (activation.turnAbortController === turnAbortController) {
2632
+ activation.turnAbortController = undefined;
2633
+ }
2634
+ if (activation.currentMessageGate === messageGate) {
2635
+ activation.currentMessageGate = undefined;
2636
+ }
2637
+ if (permit) {
2638
+ activation.holdsBackgroundSlot = false;
2639
+ permit.release();
2640
+ }
2641
+ }
2642
+ })();
1167
2643
  let lifecycle!: Promise<SubagentRunResult>;
1168
2644
  lifecycle = core.then(async (result) => {
1169
2645
  if (activation.currentRun === lifecycle) activation.currentRun = undefined;
@@ -1175,8 +2651,66 @@ export class SubagentCoordinator {
1175
2651
  }
1176
2652
 
1177
2653
  private async runFinished(activation: Activation, result: SubagentRunResult): Promise<void> {
1178
- activation.status = result.stopReason === "completed" ? "completed" : "failed";
2654
+ activation.pendingMailboxClaims.delete(result.turnId);
2655
+ activation.persistenceGate.reject(
2656
+ new Error(
2657
+ `subagent ${activation.agentId} ended before its session became durable`,
2658
+ ),
2659
+ );
2660
+ finishAgentTurn(activation.controlState, result.turnId, result.stopReason);
2661
+ if (result.stopReason !== "completed") {
2662
+ activation.runtime.session.clearQueue();
2663
+ }
2664
+ const turnStarted = activation.startedTurnIds.delete(result.turnId);
2665
+ if (turnStarted) {
2666
+ this.emitTurnEnd(activation, result);
2667
+ }
1179
2668
  activation.pendingSettlement = result;
2669
+ if (
2670
+ turnStarted
2671
+ && !activation.silentSettlementTurnIds.has(result.turnId)
2672
+ && activation.published
2673
+ && activation.descriptor.mode === "continuable"
2674
+ && activation.descriptor.runtime.backgroundProtocol === "mailbox-v2"
2675
+ ) {
2676
+ try {
2677
+ appendCompletionUpdate(activation.parent.sessionManager, {
2678
+ parentAgentId: activation.descriptor.parentAgentId,
2679
+ childAgentId: activation.agentId,
2680
+ result,
2681
+ });
2682
+ } catch (error) {
2683
+ activation.lastError = errorText(error);
2684
+ let durableFallback = false;
2685
+ try {
2686
+ appendUndeliveredCompletion(
2687
+ activation.runtime.session.sessionManager,
2688
+ {
2689
+ parentAgentId:
2690
+ activation.descriptor.parentAgentId,
2691
+ childAgentId: activation.agentId,
2692
+ result,
2693
+ error: activation.lastError,
2694
+ },
2695
+ );
2696
+ durableFallback = true;
2697
+ } catch {
2698
+ // The explicit event below remains the final observable path
2699
+ // when both parent delivery and child fallback persistence fail.
2700
+ }
2701
+ this.pi.events.emit("pi-subagent:completion-error", {
2702
+ runId: activation.runId,
2703
+ turnId: result.turnId,
2704
+ agentId: activation.agentId,
2705
+ parentAgentId: activation.descriptor.parentAgentId,
2706
+ error: activation.lastError,
2707
+ durableFallback,
2708
+ });
2709
+ }
2710
+ this.completionWaiters
2711
+ .get(completionWaiterKey(activation.parent))
2712
+ ?.wake();
2713
+ }
1180
2714
  this.emitUpdate(activation, result);
1181
2715
  if (activation.descriptor.mode === "one-shot") {
1182
2716
  this.emitEnd(activation, result);
@@ -1184,7 +2718,6 @@ export class SubagentCoordinator {
1184
2718
  }
1185
2719
  if (activation.suppressSettlement || this.draining) return;
1186
2720
  if (activation.ownedChildren.size > 0) {
1187
- activation.status = "waiting";
1188
2721
  this.emitUpdate(activation, result);
1189
2722
  return;
1190
2723
  }
@@ -1192,29 +2725,74 @@ export class SubagentCoordinator {
1192
2725
  }
1193
2726
 
1194
2727
  private async finalizeContinuable(activation: Activation): Promise<void> {
1195
- if (activation.finalizing || activation.disposed || activation.currentRun) return;
2728
+ const retained = await this.agentOperations.run(activation.agentId, () =>
2729
+ this.finalizeContinuableLocked(activation),
2730
+ );
2731
+ if (retained) {
2732
+ await this.trimIdleRuntimes(activation.agentId);
2733
+ }
2734
+ }
2735
+
2736
+ private async finalizeContinuableLocked(activation: Activation): Promise<boolean> {
2737
+ if (activation.finalizing || activation.disposed || activation.currentRun) {
2738
+ return false;
2739
+ }
1196
2740
  const result = activation.pendingSettlement;
1197
- if (!result || activation.ownedChildren.size > 0) return;
2741
+ if (!result || activation.ownedChildren.size > 0) return false;
1198
2742
  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
- });
2743
+ const finalizePromise = (async (): Promise<boolean> => {
2744
+ let retained = false;
2745
+ try {
2746
+ const silentSettlement =
2747
+ activation.silentSettlementTurnIds.has(result.turnId);
2748
+ if (
2749
+ activation.descriptor.runtime.backgroundProtocol !== "mailbox-v2"
2750
+ && !silentSettlement
2751
+ && !activation.suppressSettlement
2752
+ && !this.draining
2753
+ ) {
2754
+ await this.deliverSettlement(activation, result).catch((error) => {
2755
+ activation.lastError = errorText(error);
2756
+ });
2757
+ }
2758
+ this.emitEnd(activation, result);
2759
+ activation.silentSettlementTurnIds.delete(result.turnId);
2760
+ activation.pendingSettlement = undefined;
2761
+ if (this.shouldRetainIdleRuntime(activation)) {
2762
+ activation.published = false;
2763
+ activation.runId = uuidv7();
2764
+ await this.releaseParentOwnership(activation);
2765
+ this.touchActivation(activation);
2766
+ retained = true;
2767
+ } else {
2768
+ await this.disposeActivation(activation);
2769
+ await this.releaseParentOwnership(activation);
2770
+ }
2771
+ } finally {
2772
+ if (!activation.disposed) {
2773
+ activation.finalizing = false;
2774
+ }
1204
2775
  }
1205
- this.emitEnd(activation, result);
1206
- await this.disposeActivation(activation);
1207
- await this.releaseParentOwnership(activation);
2776
+ return retained;
1208
2777
  })();
1209
- await activation.finalizePromise;
2778
+ activation.finalizePromise = finalizePromise;
2779
+ try {
2780
+ return await finalizePromise;
2781
+ } finally {
2782
+ if (activation.finalizePromise === finalizePromise) {
2783
+ activation.finalizePromise = undefined;
2784
+ }
2785
+ if (!activation.disposed) activation.finalizing = false;
2786
+ }
1210
2787
  }
1211
2788
 
1212
2789
  private async deliverSettlement(activation: Activation, result: SubagentRunResult): Promise<void> {
2790
+ const readablePath = descriptorTaskPath(activation.descriptor);
1213
2791
  const truncated = truncateUtf8(result.output, activation.descriptor.runtime.maxOutputBytes);
1214
2792
  const closing = truncated.text.trim()
1215
2793
  ? `Its closing message:\n\n${truncated.text}`
1216
2794
  : "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}${
2795
+ const content = `Background subagent ${readablePath} (${activation.agentId}) ${stopReasonHeadline(result.stopReason)} and will do no further work unless you send it more.\n\n${closing}${
1218
2796
  truncated.truncated ? `\n\n[Closing message truncated; ${truncated.omittedBytes} bytes omitted.]` : ""
1219
2797
  }`;
1220
2798
  await activation.parent.deliver(
@@ -1223,6 +2801,7 @@ export class SubagentCoordinator {
1223
2801
  {
1224
2802
  kind: "settled",
1225
2803
  childAgentId: activation.agentId,
2804
+ taskPath: readablePath,
1226
2805
  label: activation.descriptor.label,
1227
2806
  stopReason: result.stopReason,
1228
2807
  ...(truncated.truncated ? { truncated: true } : {}),
@@ -1234,6 +2813,7 @@ export class SubagentCoordinator {
1234
2813
  private parentForActivation(activation: Activation): ParentRef {
1235
2814
  return {
1236
2815
  agentId: activation.agentId,
2816
+ taskPath: descriptorTaskPath(activation.descriptor),
1237
2817
  depth: activation.descriptor.depth,
1238
2818
  cwd: activation.descriptor.cwd,
1239
2819
  sessionManager: activation.runtime.session.sessionManager,
@@ -1266,7 +2846,17 @@ export class SubagentCoordinator {
1266
2846
  };
1267
2847
  }
1268
2848
 
1269
- private collectResult(activation: Activation, fallback: SubagentStopReason): SubagentRunResult {
2849
+ private resetTurnCapture(activation: Activation): void {
2850
+ activation.epochMessageStart = activation.runtime.session.messages.length;
2851
+ activation.streamedText = "";
2852
+ activation.usage = emptyUsage();
2853
+ }
2854
+
2855
+ private collectResult(
2856
+ activation: Activation,
2857
+ turnId: string,
2858
+ fallback: SubagentStopReason,
2859
+ ): SubagentRunResult {
1270
2860
  const messages = activation.runtime.session.messages;
1271
2861
  const output = finalAssistantText(messages, activation.epochMessageStart, activation.streamedText);
1272
2862
  const stopReason = finalStopReason(messages, activation.epochMessageStart, fallback);
@@ -1274,6 +2864,7 @@ export class SubagentCoordinator {
1274
2864
  const sessionFile = activation.runtime.session.sessionFile;
1275
2865
  return {
1276
2866
  agentId: activation.agentId,
2867
+ turnId,
1277
2868
  piSessionId: activation.runtime.session.sessionId,
1278
2869
  ...(sessionFile ? { sessionFile } : {}),
1279
2870
  output: truncated.truncated
@@ -1281,6 +2872,12 @@ export class SubagentCoordinator {
1281
2872
  sessionFile ? ` Full output: ${sessionFile}` : " Full output remains in the active child session."
1282
2873
  }]`
1283
2874
  : truncated.text,
2875
+ ...(truncated.truncated
2876
+ ? {
2877
+ outputTruncated: true,
2878
+ omittedBytes: truncated.omittedBytes,
2879
+ }
2880
+ : {}),
1284
2881
  stopReason,
1285
2882
  usage: structuredClone(activation.usage),
1286
2883
  };
@@ -1301,12 +2898,47 @@ export class SubagentCoordinator {
1301
2898
  this.emitUpdate(activation);
1302
2899
  return;
1303
2900
  }
2901
+ if (event.type === "agent_end") {
2902
+ void this.releaseWaitAgentDeliveries(
2903
+ this.parentForActivation(activation),
2904
+ "parent agent turn ended without a durable wait_agent result",
2905
+ ).catch((error) => {
2906
+ activation.lastError = errorText(error);
2907
+ });
2908
+ return;
2909
+ }
1304
2910
  if (event.type !== "message_end") return;
2911
+ if (event.message.role === "user") {
2912
+ const turnId = currentAgentTurnId(activation.controlState);
2913
+ const gate = turnId
2914
+ ? activation.userMessageGates.get(turnId)
2915
+ : undefined;
2916
+ if (turnId && gate) {
2917
+ try {
2918
+ gate.prepare();
2919
+ commitMailboxClaim(
2920
+ activation.runtime.session.sessionManager,
2921
+ turnId,
2922
+ event.message,
2923
+ );
2924
+ activation.pendingMailboxClaims.delete(turnId);
2925
+ gate.accept();
2926
+ } catch (error) {
2927
+ const failure =
2928
+ error instanceof Error ? error : new Error(String(error));
2929
+ activation.lastError = failure.message;
2930
+ gate.reject(failure);
2931
+ void activation.runtime.session.abort().catch(() => {});
2932
+ }
2933
+ }
2934
+ return;
2935
+ }
1305
2936
  if (event.message.role === "toolResult") {
1306
2937
  if (event.message.usage) addUsage(activation.usage, event.message.usage, false);
1307
2938
  return;
1308
2939
  }
1309
2940
  if (event.message.role !== "assistant") return;
2941
+ activation.persistenceGate.resolve();
1310
2942
  addUsage(activation.usage, event.message.usage);
1311
2943
  const text = event.message.content
1312
2944
  .filter((part): part is Extract<(typeof event.message.content)[number], { type: "text" }> => part.type === "text")
@@ -1327,13 +2959,16 @@ export class SubagentCoordinator {
1327
2959
  private publish(activation: Activation): void {
1328
2960
  if (activation.published) return;
1329
2961
  activation.published = true;
2962
+ activation.everPublished = true;
1330
2963
  this.pi.events.emit("pi-subagent:start", {
1331
2964
  runId: activation.runId,
1332
2965
  agentId: activation.agentId,
2966
+ taskPath: descriptorTaskPath(activation.descriptor),
1333
2967
  piSessionId: activation.runtime.session.sessionId,
1334
2968
  parentAgentId: activation.descriptor.parentAgentId,
1335
2969
  provider: activation.descriptor.provider,
1336
2970
  mode: activation.descriptor.mode,
2971
+ context: descriptorContext(activation.descriptor),
1337
2972
  });
1338
2973
  }
1339
2974
 
@@ -1342,6 +2977,35 @@ export class SubagentCoordinator {
1342
2977
  this.pi.events.emit("pi-subagent:end", {
1343
2978
  runId: activation.runId,
1344
2979
  agentId: activation.agentId,
2980
+ taskPath: descriptorTaskPath(activation.descriptor),
2981
+ piSessionId: activation.runtime.session.sessionId,
2982
+ parentAgentId: activation.descriptor.parentAgentId,
2983
+ provider: activation.descriptor.provider,
2984
+ mode: activation.descriptor.mode,
2985
+ stopReason: result.stopReason,
2986
+ output: result.output,
2987
+ });
2988
+ }
2989
+
2990
+ private emitTurnStart(activation: Activation, turnId: string): void {
2991
+ this.pi.events.emit("pi-subagent:turn-start", {
2992
+ runId: activation.runId,
2993
+ turnId,
2994
+ agentId: activation.agentId,
2995
+ taskPath: descriptorTaskPath(activation.descriptor),
2996
+ piSessionId: activation.runtime.session.sessionId,
2997
+ parentAgentId: activation.descriptor.parentAgentId,
2998
+ provider: activation.descriptor.provider,
2999
+ mode: activation.descriptor.mode,
3000
+ });
3001
+ }
3002
+
3003
+ private emitTurnEnd(activation: Activation, result: SubagentRunResult): void {
3004
+ this.pi.events.emit("pi-subagent:turn-end", {
3005
+ runId: activation.runId,
3006
+ turnId: result.turnId,
3007
+ agentId: activation.agentId,
3008
+ taskPath: descriptorTaskPath(activation.descriptor),
1345
3009
  piSessionId: activation.runtime.session.sessionId,
1346
3010
  parentAgentId: activation.descriptor.parentAgentId,
1347
3011
  provider: activation.descriptor.provider,
@@ -1355,13 +3019,21 @@ export class SubagentCoordinator {
1355
3019
  return {
1356
3020
  kind: "delegation",
1357
3021
  agentId: activation.agentId,
3022
+ taskPath: descriptorTaskPath(activation.descriptor),
3023
+ ...(currentAgentTurnId(activation.controlState)
3024
+ ? { turnId: currentAgentTurnId(activation.controlState) }
3025
+ : {}),
1358
3026
  piSessionId: activation.runtime.session.sessionId,
1359
3027
  provider: activation.descriptor.provider,
1360
3028
  mode: activation.descriptor.mode,
3029
+ context: descriptorContext(activation.descriptor),
1361
3030
  agent: activation.descriptor.agent.name,
1362
3031
  label: activation.descriptor.label,
1363
3032
  depth: activation.descriptor.depth,
1364
- status: activation.status,
3033
+ status: delegationStatus(
3034
+ activation.controlState,
3035
+ activation.ownedChildren.size > 0,
3036
+ ),
1365
3037
  ...(activation.runtime.session.sessionFile
1366
3038
  ? { sessionFile: activation.runtime.session.sessionFile }
1367
3039
  : {}),
@@ -1403,6 +3075,25 @@ export class SubagentCoordinator {
1403
3075
  private async disposeActivation(activation: Activation): Promise<void> {
1404
3076
  if (activation.disposed) return;
1405
3077
  activation.disposed = true;
3078
+ const waiterKey = completionWaiterKey(
3079
+ this.parentForActivation(activation),
3080
+ );
3081
+ const waiter = this.completionWaiters.get(waiterKey);
3082
+ if (waiter) {
3083
+ this.removeCompletionWaiter(waiterKey, waiter);
3084
+ waiter.reject(
3085
+ new Error(
3086
+ `parent subagent ${activation.agentId} was disposed while wait_agent was pending`,
3087
+ ),
3088
+ );
3089
+ }
3090
+ activation.persistenceGate.reject(
3091
+ new Error(
3092
+ `subagent ${activation.agentId} ended before its session became durable`,
3093
+ ),
3094
+ );
3095
+ setAgentResidency(activation.controlState, "unloaded");
3096
+ if (activation.descriptor.mode === "one-shot") closeAgent(activation.controlState);
1406
3097
  activation.unsubscribe?.();
1407
3098
  activation.unsubscribe = undefined;
1408
3099
  if (!activation.runtime.session.isIdle) await activation.runtime.session.abort().catch(() => {});
@@ -1414,8 +3105,9 @@ export class SubagentCoordinator {
1414
3105
  }
1415
3106
 
1416
3107
  private async releaseParentOwnership(activation: Activation): Promise<void> {
1417
- const owner = activation.parent.activation;
3108
+ const owner = activation.ownerActivation;
1418
3109
  if (!owner) return;
3110
+ activation.ownerActivation = undefined;
1419
3111
  owner.ownedChildren.delete(activation.agentId);
1420
3112
  if (
1421
3113
  !owner.currentRun &&
@@ -1423,16 +3115,395 @@ export class SubagentCoordinator {
1423
3115
  owner.pendingSettlement &&
1424
3116
  owner.descriptor.mode === "continuable"
1425
3117
  ) {
1426
- await this.finalizeContinuable(owner);
3118
+ void this.finalizeContinuable(owner).catch((error) => {
3119
+ owner.lastError = errorText(error);
3120
+ });
3121
+ }
3122
+ }
3123
+
3124
+ private acquireParentOwnership(
3125
+ activation: Activation,
3126
+ parent: ParentRef,
3127
+ ): void {
3128
+ if (
3129
+ activation.ownerActivation
3130
+ && activation.ownerActivation !== parent.activation
3131
+ ) {
3132
+ throw new Error(
3133
+ `subagent ${descriptorTaskPath(activation.descriptor)} is still owned by another resident parent`,
3134
+ );
3135
+ }
3136
+ activation.parent = parent;
3137
+ if (!parent.activation) return;
3138
+ parent.activation.ownedChildren.add(activation.agentId);
3139
+ activation.ownerActivation = parent.activation;
3140
+ }
3141
+
3142
+ private touchActivation(activation: Activation): void {
3143
+ activation.lastUsedSequence = ++this.activationSequence;
3144
+ }
3145
+
3146
+ private shouldRetainIdleRuntime(activation: Activation): boolean {
3147
+ return (
3148
+ this.idleRuntimeLimit > 0
3149
+ && !this.draining
3150
+ && activation.descriptor.mode === "continuable"
3151
+ && !activation.disposed
3152
+ && !activation.currentRun
3153
+ && activation.runtime.session.isIdle
3154
+ && activation.ownedChildren.size === 0
3155
+ && activation.pendingMailboxClaims.size === 0
3156
+ && activation.persistenceGate.state !== "rejected"
3157
+ );
3158
+ }
3159
+
3160
+ private isEvictableIdleRuntime(activation: Activation): boolean {
3161
+ return (
3162
+ !activation.disposed
3163
+ && !activation.finalizing
3164
+ && activation.descriptor.mode === "continuable"
3165
+ && !activation.currentRun
3166
+ && !activation.pendingSettlement
3167
+ && activation.runtime.session.isIdle
3168
+ && activation.ownedChildren.size === 0
3169
+ && activation.pendingMailboxClaims.size === 0
3170
+ && !activation.ownerActivation
3171
+ );
3172
+ }
3173
+
3174
+ private async trimIdleRuntimes(protectedAgentId?: string): Promise<void> {
3175
+ await this.idleRuntimeOperations.run("idle-runtime-lru", async () =>
3176
+ this.trimIdleRuntimesLocked(protectedAgentId),
3177
+ );
3178
+ }
3179
+
3180
+ private async trimIdleRuntimesLocked(
3181
+ protectedAgentId?: string,
3182
+ ): Promise<void> {
3183
+ while (true) {
3184
+ const retained = [...this.active.values()].filter(
3185
+ (activation) =>
3186
+ activation.agentId === protectedAgentId
3187
+ ? this.shouldRetainIdleRuntime(activation)
3188
+ : this.isEvictableIdleRuntime(activation),
3189
+ );
3190
+ if (retained.length <= this.idleRuntimeLimit) return;
3191
+ const candidate = retained
3192
+ .filter(
3193
+ (activation) =>
3194
+ activation.agentId !== protectedAgentId
3195
+ && this.isEvictableIdleRuntime(activation),
3196
+ )
3197
+ .sort(
3198
+ (left, right) =>
3199
+ left.lastUsedSequence - right.lastUsedSequence
3200
+ || left.agentId.localeCompare(right.agentId),
3201
+ )[0];
3202
+ if (!candidate) return;
3203
+ await this.agentOperations.run(candidate.agentId, async () => {
3204
+ if (
3205
+ this.active.get(candidate.agentId) === candidate
3206
+ && this.isEvictableIdleRuntime(candidate)
3207
+ ) {
3208
+ await this.disposeActivation(candidate);
3209
+ }
3210
+ });
1427
3211
  }
1428
3212
  }
1429
3213
 
1430
3214
  private assertDirectParent(parent: ParentRef, descriptor: SubagentDescriptor): void {
1431
3215
  if (descriptor.parentAgentId !== parent.agentId) {
1432
3216
  throw new Error(
1433
- `subagent ${descriptor.label} is not a direct child of ${parent.agentId}; message was not delivered`,
3217
+ `subagent ${descriptor.label} is not a direct child of ${parent.agentId}`,
3218
+ );
3219
+ }
3220
+ }
3221
+
3222
+ private assertContinuableDirectChild(
3223
+ parent: ParentRef,
3224
+ descriptor: SubagentDescriptor,
3225
+ ): void {
3226
+ if (descriptor.mode !== "continuable") {
3227
+ throw new Error(
3228
+ `subagent ${descriptor.agentId} is one-shot and cannot accept follow-up work`,
3229
+ );
3230
+ }
3231
+ this.assertDirectParent(parent, descriptor);
3232
+ }
3233
+
3234
+ private treeRootAgentId(
3235
+ agentId: string,
3236
+ byId: ReadonlyMap<string, CatalogRecord>,
3237
+ ): string {
3238
+ let current = agentId;
3239
+ const visited = new Set<string>();
3240
+ while (true) {
3241
+ if (visited.has(current)) {
3242
+ throw new Error("subagent descriptor lineage contains a cycle");
3243
+ }
3244
+ visited.add(current);
3245
+ const record = byId.get(current);
3246
+ if (!record) return current;
3247
+ current = record.descriptor.parentAgentId;
3248
+ }
3249
+ }
3250
+
3251
+ private async reserveTask(
3252
+ parent: ParentRef,
3253
+ requestedName: string | undefined,
3254
+ label: string,
3255
+ ): Promise<ReservedTask> {
3256
+ const catalog = await this.catalogRecords(parent);
3257
+ const usedPaths = new Set(
3258
+ catalog.records
3259
+ .filter(
3260
+ (record) =>
3261
+ record.descriptor.parentAgentId === parent.agentId,
3262
+ )
3263
+ .map((record) => record.taskPath),
3264
+ );
3265
+ const reserve = (name: string): ReservedTask | undefined => {
3266
+ const path = taskPath(parent.taskPath, name);
3267
+ const reservationKey = `${parent.agentId}:${path}`;
3268
+ if (
3269
+ usedPaths.has(path)
3270
+ || this.reservedTaskPaths.has(reservationKey)
3271
+ ) {
3272
+ return undefined;
3273
+ }
3274
+ this.reservedTaskPaths.add(reservationKey);
3275
+ let released = false;
3276
+ return {
3277
+ name,
3278
+ path,
3279
+ release: () => {
3280
+ if (released) return;
3281
+ released = true;
3282
+ this.reservedTaskPaths.delete(reservationKey);
3283
+ },
3284
+ };
3285
+ };
3286
+
3287
+ if (requestedName !== undefined) {
3288
+ const name = validateTaskName(requestedName);
3289
+ const reserved = reserve(name);
3290
+ if (!reserved) {
3291
+ throw new Error(
3292
+ `task path ${taskPath(parent.taskPath, name)} is already in use`,
3293
+ );
3294
+ }
3295
+ return reserved;
3296
+ }
3297
+
3298
+ const base = slugTaskName(label);
3299
+ const initial = reserve(base);
3300
+ if (initial) return initial;
3301
+ for (let ordinal = 2; ordinal < Number.MAX_SAFE_INTEGER; ordinal++) {
3302
+ const candidate = reserve(numberedTaskName(base, ordinal));
3303
+ if (candidate) return candidate;
3304
+ }
3305
+ throw new Error(`could not allocate a readable child path under ${parent.taskPath}`);
3306
+ }
3307
+
3308
+ private async resolveTarget(
3309
+ parent: ParentRef,
3310
+ target: string,
3311
+ options: { allowUnknownId?: boolean } = {},
3312
+ ): Promise<ResolvedTarget> {
3313
+ const catalog = await this.catalogRecords(parent);
3314
+ const byId = new Map(
3315
+ catalog.records.map((record) => [record.agentId, record]),
3316
+ );
3317
+ if (isAgentId(target)) {
3318
+ const record = byId.get(target);
3319
+ if (record) {
3320
+ return {
3321
+ agentId: record.agentId,
3322
+ taskPath: record.taskPath,
3323
+ record,
3324
+ };
3325
+ }
3326
+ if (options.allowUnknownId) {
3327
+ return {
3328
+ agentId: target,
3329
+ taskPath: target,
3330
+ };
3331
+ }
3332
+ return {
3333
+ agentId: target,
3334
+ taskPath: target,
3335
+ };
3336
+ }
3337
+
3338
+ const path = resolveTaskPath(parent.taskPath, target);
3339
+ const rootAgentId = this.treeRootAgentId(parent.agentId, byId);
3340
+ const matches = this.reachableTreeRecords(rootAgentId, byId).filter(
3341
+ (record) => record.taskPath === path,
3342
+ );
3343
+ if (matches.length === 0) {
3344
+ throw new Error(`unknown subagent task path: ${path}`);
3345
+ }
3346
+ if (matches.length > 1) {
3347
+ throw new Error(
3348
+ `ambiguous subagent task path ${path}; use a durable agent id`,
1434
3349
  );
1435
3350
  }
3351
+ const record = matches[0]!;
3352
+ return {
3353
+ agentId: record.agentId,
3354
+ taskPath: record.taskPath,
3355
+ record,
3356
+ };
3357
+ }
3358
+
3359
+ private reachableTreeRecords(
3360
+ rootAgentId: string,
3361
+ byId: ReadonlyMap<string, CatalogRecord>,
3362
+ ): CatalogRecord[] {
3363
+ const paths = new Map<string, string>([
3364
+ [rootAgentId, ROOT_TASK_PATH],
3365
+ ]);
3366
+ const depths = new Map<string, number>([[rootAgentId, 0]]);
3367
+ const reachable: CatalogRecord[] = [];
3368
+ const pending = new Set(byId.values());
3369
+ let progressed = true;
3370
+ while (progressed) {
3371
+ progressed = false;
3372
+ for (const record of [...pending]) {
3373
+ const parentPath = paths.get(
3374
+ record.descriptor.parentAgentId,
3375
+ );
3376
+ const parentDepth = depths.get(
3377
+ record.descriptor.parentAgentId,
3378
+ );
3379
+ if (parentPath === undefined || parentDepth === undefined) {
3380
+ continue;
3381
+ }
3382
+ if (record.descriptor.version === 3) {
3383
+ let expectedPath: string;
3384
+ try {
3385
+ expectedPath = taskPath(
3386
+ parentPath,
3387
+ record.descriptor.task.name,
3388
+ );
3389
+ } catch {
3390
+ pending.delete(record);
3391
+ progressed = true;
3392
+ continue;
3393
+ }
3394
+ if (
3395
+ record.taskPath !== expectedPath
3396
+ || record.descriptor.depth !== parentDepth + 1
3397
+ ) {
3398
+ pending.delete(record);
3399
+ progressed = true;
3400
+ continue;
3401
+ }
3402
+ }
3403
+ pending.delete(record);
3404
+ reachable.push(record);
3405
+ paths.set(record.agentId, record.taskPath);
3406
+ depths.set(record.agentId, record.descriptor.depth);
3407
+ progressed = true;
3408
+ }
3409
+ }
3410
+ return reachable;
3411
+ }
3412
+
3413
+ private catalogTopologyDiagnostics(
3414
+ records: readonly CatalogRecord[],
3415
+ ): CatalogDiagnostic[] {
3416
+ const byId = new Map(
3417
+ records.map((record) => [record.agentId, record]),
3418
+ );
3419
+ const problems = new Map<string, string>();
3420
+ for (const record of records) {
3421
+ if (record.descriptor.version !== 3) continue;
3422
+ const parent = byId.get(record.descriptor.parentAgentId);
3423
+ if (parent) {
3424
+ let expectedPath: string | undefined;
3425
+ try {
3426
+ expectedPath = taskPath(
3427
+ parent.taskPath,
3428
+ record.descriptor.task.name,
3429
+ );
3430
+ } catch {
3431
+ problems.set(
3432
+ record.agentId,
3433
+ "descriptor task path cannot be joined to its parent",
3434
+ );
3435
+ }
3436
+ if (
3437
+ expectedPath !== undefined
3438
+ && (
3439
+ record.taskPath !== expectedPath
3440
+ || record.descriptor.depth
3441
+ !== parent.descriptor.depth + 1
3442
+ )
3443
+ ) {
3444
+ problems.set(
3445
+ record.agentId,
3446
+ "descriptor task path or depth does not match its parent",
3447
+ );
3448
+ }
3449
+ } else if (
3450
+ record.descriptor.depth === 1
3451
+ && record.taskPath
3452
+ !== taskPath(
3453
+ ROOT_TASK_PATH,
3454
+ record.descriptor.task.name,
3455
+ )
3456
+ ) {
3457
+ problems.set(
3458
+ record.agentId,
3459
+ "root child task path does not match its task name",
3460
+ );
3461
+ }
3462
+ }
3463
+
3464
+ for (const start of records) {
3465
+ const chain: string[] = [];
3466
+ const indexes = new Map<string, number>();
3467
+ let current: CatalogRecord | undefined = start;
3468
+ while (current) {
3469
+ const existing = indexes.get(current.agentId);
3470
+ if (existing !== undefined) {
3471
+ for (const agentId of chain.slice(existing)) {
3472
+ problems.set(
3473
+ agentId,
3474
+ "descriptor lineage contains a cycle",
3475
+ );
3476
+ }
3477
+ break;
3478
+ }
3479
+ indexes.set(current.agentId, chain.length);
3480
+ chain.push(current.agentId);
3481
+ current = byId.get(current.descriptor.parentAgentId);
3482
+ }
3483
+ }
3484
+
3485
+ return [...problems]
3486
+ .map(([agentId, message]) => {
3487
+ const record = byId.get(agentId)!;
3488
+ return {
3489
+ kind: "diagnostic" as const,
3490
+ piSessionId: record.piSessionId,
3491
+ reason: "corrupt" as const,
3492
+ ...(record.sessionFile
3493
+ ? { sessionFile: record.sessionFile }
3494
+ : {}),
3495
+ ...(record.descriptor.parentSessionFile
3496
+ ? {
3497
+ parentSessionFile:
3498
+ record.descriptor.parentSessionFile,
3499
+ }
3500
+ : {}),
3501
+ message,
3502
+ };
3503
+ })
3504
+ .sort((left, right) =>
3505
+ left.piSessionId.localeCompare(right.piSessionId),
3506
+ );
1436
3507
  }
1437
3508
 
1438
3509
  private async findPersistedChild(
@@ -1450,29 +3521,112 @@ export class SubagentCoordinator {
1450
3521
  for (const item of persisted.descriptors) {
1451
3522
  records.set(item.agentId, {
1452
3523
  agentId: item.agentId,
3524
+ piSessionId: item.piSessionId,
3525
+ taskPath: descriptorTaskPath(item.descriptor),
1453
3526
  descriptor: item.descriptor,
1454
3527
  sessionFile: item.sessionFile,
3528
+ pendingMessages: item.pendingMessages,
3529
+ unreadUpdatesByChild: new Map(item.unreadUpdatesByChild),
1455
3530
  });
1456
3531
  }
1457
3532
  const activeSessionIds = new Set(
1458
3533
  [...this.active.values()].map((activation) => activation.runtime.session.sessionId),
1459
3534
  );
3535
+ const activeDiagnostics: CatalogEntry[] = [];
3536
+ const rootCompletions = foldCompletionMailbox(
3537
+ parent.sessionManager.getEntries(),
3538
+ { parentAgentId: parent.agentId },
3539
+ );
3540
+ const rootUnreadUpdatesByChild =
3541
+ rootCompletions.kind === "valid"
3542
+ ? unreadCompletionCounts(rootCompletions.snapshot)
3543
+ : new Map<string, number>();
3544
+ if (rootCompletions.kind === "corrupt") {
3545
+ const parentFile = parent.sessionManager.getSessionFile();
3546
+ activeDiagnostics.push({
3547
+ kind: "diagnostic",
3548
+ piSessionId: parent.sessionManager.getSessionId(),
3549
+ reason: "corrupt",
3550
+ ...(parentFile
3551
+ ? {
3552
+ sessionFile: parentFile,
3553
+ parentSessionFile: parentFile,
3554
+ }
3555
+ : {}),
3556
+ message: `corrupt completion mailbox: ${rootCompletions.message}`,
3557
+ });
3558
+ }
1460
3559
  for (const activation of this.active.values()) {
1461
3560
  if (activation.descriptor.cwd !== parent.cwd) continue;
3561
+ let pendingMessages = 0;
3562
+ if (activation.descriptor.runtime.backgroundProtocol === "mailbox-v2") {
3563
+ const mailbox = foldOwnedMailbox(
3564
+ activation.runtime.session.sessionManager.getEntries(),
3565
+ mailboxOwner(activation.descriptor),
3566
+ );
3567
+ if (mailbox.kind === "valid") {
3568
+ pendingMessages = mailbox.snapshot.pending.length;
3569
+ } else {
3570
+ activeDiagnostics.push({
3571
+ kind: "diagnostic",
3572
+ piSessionId: activation.runtime.session.sessionId,
3573
+ reason: "corrupt",
3574
+ ...(activation.runtime.session.sessionFile
3575
+ ? { sessionFile: activation.runtime.session.sessionFile }
3576
+ : {}),
3577
+ ...(activation.descriptor.parentSessionFile
3578
+ ? { parentSessionFile: activation.descriptor.parentSessionFile }
3579
+ : {}),
3580
+ message: `corrupt subagent mailbox: ${mailbox.message}`,
3581
+ });
3582
+ continue;
3583
+ }
3584
+ }
3585
+ const completions = foldCompletionMailbox(
3586
+ activation.runtime.session.sessionManager.getEntries(),
3587
+ { parentAgentId: activation.agentId },
3588
+ );
3589
+ if (completions.kind === "corrupt") {
3590
+ activeDiagnostics.push({
3591
+ kind: "diagnostic",
3592
+ piSessionId: activation.runtime.session.sessionId,
3593
+ reason: "corrupt",
3594
+ ...(activation.runtime.session.sessionFile
3595
+ ? { sessionFile: activation.runtime.session.sessionFile }
3596
+ : {}),
3597
+ ...(activation.descriptor.parentSessionFile
3598
+ ? { parentSessionFile: activation.descriptor.parentSessionFile }
3599
+ : {}),
3600
+ message: `corrupt completion mailbox: ${completions.message}`,
3601
+ });
3602
+ continue;
3603
+ }
1462
3604
  records.set(activation.agentId, {
1463
3605
  agentId: activation.agentId,
3606
+ piSessionId: activation.runtime.session.sessionId,
3607
+ taskPath: descriptorTaskPath(activation.descriptor),
1464
3608
  descriptor: activation.descriptor,
1465
3609
  ...(activation.runtime.session.sessionFile
1466
3610
  ? { sessionFile: activation.runtime.session.sessionFile }
1467
3611
  : {}),
1468
3612
  active: activation,
3613
+ pendingMessages,
3614
+ unreadUpdatesByChild: unreadCompletionCounts(
3615
+ completions.snapshot,
3616
+ ),
1469
3617
  });
1470
3618
  }
3619
+ const catalogRecords = [...records.values()];
1471
3620
  return {
1472
- records: [...records.values()],
1473
- diagnostics: persisted.diagnostics.filter(
1474
- (diagnostic) => !activeSessionIds.has(diagnostic.piSessionId),
1475
- ),
3621
+ records: catalogRecords,
3622
+ diagnostics: [
3623
+ ...persisted.diagnostics.filter(
3624
+ (diagnostic) => !activeSessionIds.has(diagnostic.piSessionId),
3625
+ ),
3626
+ ...activeDiagnostics,
3627
+ ...this.catalogTopologyDiagnostics(catalogRecords),
3628
+ ],
3629
+ rootUnreadUpdatesByChild,
1476
3630
  };
1477
3631
  }
1478
3632