@oai404iao/pi-subagent 0.2.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.
@@ -1,13 +1,13 @@
1
- import { randomUUID } from "node:crypto";
2
1
  import { join, relative, resolve } from "node:path";
3
2
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
4
- import type { Model } from "@earendil-works/pi-ai";
3
+ import { type Model, uuidv7 } from "@earendil-works/pi-ai";
5
4
  import {
6
5
  type AgentSessionEvent,
7
6
  type AgentToolResult,
8
7
  type AgentToolUpdateCallback,
9
8
  type ExtensionAPI,
10
9
  type ExtensionContext,
10
+ type InlineExtension,
11
11
  type ModelRegistry,
12
12
  type ToolDefinition,
13
13
  type CreateAgentSessionRuntimeFactory,
@@ -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,
@@ -75,10 +129,17 @@ import {
75
129
 
76
130
  const REPORT_CUSTOM_TYPE = "pi-subagent/report";
77
131
  const SETTLED_CUSTOM_TYPE = "pi-subagent/settled";
132
+ const AGENT_CUSTOM_TYPE = "pi-subagent/agent";
133
+ const LINEAGE_CUSTOM_TYPE = "pi-subagent/lineage";
134
+ const AGENT_ID_PATTERN =
135
+ /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
78
136
  const MAX_TRACE_ITEMS = 100;
79
137
  const MAX_TRACE_TEXT = 4000;
138
+ const MAX_WAIT_AGENT_RESULT_BYTES = 256 * 1024;
80
139
  const BACKGROUND_CONTROL_TOOLS = new Set([
81
140
  "send_message",
141
+ "followup_task",
142
+ "wait_agent",
82
143
  "interrupt_agent",
83
144
  "list_agents",
84
145
  ]);
@@ -97,17 +158,54 @@ const REPORT_PROMPT = [
97
158
 
98
159
  export interface DelegationInput {
99
160
  agent: string;
161
+ task_name?: string;
100
162
  description: string;
101
163
  prompt: string;
102
164
  run_in_background?: boolean;
165
+ context?: {
166
+ mode: ContextInheritance["mode"];
167
+ completed_turns?: number;
168
+ };
103
169
  }
104
170
 
105
171
  export type DelegationOutcome =
106
172
  | { kind: "continuable"; details: DelegationDetails }
107
173
  | { kind: "foreground"; details: DelegationDetails; result: SubagentRunResult };
108
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
+
109
206
  interface ParentRef {
110
- id: string;
207
+ agentId: string;
208
+ taskPath: string;
111
209
  depth: number;
112
210
  cwd: string;
113
211
  sessionManager: SessionView;
@@ -125,28 +223,60 @@ interface ParentRef {
125
223
  }
126
224
 
127
225
  interface Activation {
128
- id: string;
129
- epochId: string;
226
+ agentId: string;
227
+ runId: string;
130
228
  descriptor: SubagentDescriptor;
131
229
  parent: ParentRef;
132
230
  runtime: AgentSessionRuntime;
133
231
  seedMessageCount: number;
134
232
  epochMessageStart: number;
135
- status: DelegationDetails["status"];
233
+ controlState: AgentControlState;
136
234
  trace: TraceItem[];
137
235
  streamedText: string;
138
236
  usage: ReturnType<typeof emptyUsage>;
237
+ startedTurnIds: Set<string>;
238
+ silentSettlementTurnIds: Set<string>;
239
+ pendingMailboxClaims: Set<string>;
240
+ userMessageGates: Map<string, UserMessageGate>;
139
241
  ownedChildren: Set<string>;
140
242
  currentRun?: Promise<SubagentRunResult>;
243
+ currentMessageGate?: Promise<void>;
141
244
  pendingSettlement?: SubagentRunResult;
142
245
  unsubscribe?: () => void;
143
246
  onUpdate?: (details: DelegationDetails) => void;
144
247
  published: boolean;
248
+ everPublished: boolean;
145
249
  suppressSettlement: boolean;
146
250
  finalizing: boolean;
147
- finalizePromise?: Promise<void>;
251
+ finalizePromise?: Promise<boolean>;
252
+ holdsBackgroundSlot: boolean;
253
+ turnAbortController?: AbortController;
254
+ persistenceGate: PersistenceGate;
148
255
  disposed: boolean;
149
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;
150
280
  }
151
281
 
152
282
  interface CreateActivationOptions {
@@ -157,16 +287,60 @@ interface CreateActivationOptions {
157
287
  onUpdate?: (details: DelegationDetails) => void;
158
288
  }
159
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
+
160
317
  interface CatalogRecord {
161
- id: string;
318
+ agentId: string;
319
+ piSessionId: string;
320
+ taskPath: string;
162
321
  descriptor: SubagentDescriptor;
163
322
  sessionFile?: string;
164
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;
165
338
  }
166
339
 
167
340
  interface CoordinatorCatalog {
168
341
  records: CatalogRecord[];
169
342
  diagnostics: CatalogEntry[];
343
+ rootUnreadUpdatesByChild: Map<string, number>;
170
344
  }
171
345
 
172
346
  function runtimeFromRegistry(registry: ModelRegistry): ModelRuntime {
@@ -191,6 +365,114 @@ function errorText(error: unknown): string {
191
365
  return error instanceof Error ? error.message : String(error);
192
366
  }
193
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
+
441
+ /**
442
+ * Read the durable pi-subagent control id recorded for a session, if any.
443
+ *
444
+ * The latest entry wins because a copied/forked Pi session may contain older
445
+ * identity checkpoints.
446
+ */
447
+ function readAgentId(session: Pick<SessionView, "getEntries">): string | undefined {
448
+ const entries = session.getEntries();
449
+ for (let index = entries.length - 1; index >= 0; index--) {
450
+ const entry = entries[index];
451
+ if (!entry) continue;
452
+ if (entry.type !== "custom" || entry.customType !== AGENT_CUSTOM_TYPE) continue;
453
+ const data = entry.data as { agentId?: unknown } | undefined;
454
+ if (
455
+ typeof data?.agentId === "string"
456
+ && AGENT_ID_PATTERN.test(data.agentId)
457
+ ) {
458
+ return data.agentId;
459
+ }
460
+ }
461
+ return undefined;
462
+ }
463
+
464
+ /**
465
+ * Resolve the provider-neutral control id for a session, creating it on first
466
+ * use. In-memory SessionManager instances retain custom entries too.
467
+ */
468
+ function ensureAgentId(session: SessionView): string {
469
+ const existing = readAgentId(session);
470
+ if (existing) return existing;
471
+ const agentId = uuidv7();
472
+ session.appendCustomEntry(AGENT_CUSTOM_TYPE, { agentId });
473
+ return agentId;
474
+ }
475
+
194
476
  function stopReasonHeadline(reason: SubagentStopReason): string {
195
477
  switch (reason) {
196
478
  case "completed":
@@ -207,26 +489,135 @@ function stopReasonHeadline(reason: SubagentStopReason): string {
207
489
  function makeRuntimeSettings(descriptor: SubagentDescriptor): SubagentSettings {
208
490
  return {
209
491
  agentScope: descriptor.runtime.agentScope,
210
- syncBundledAgents: descriptor.runtime.syncBundledAgents,
211
492
  maxDepth: descriptor.runtime.maxDepth,
212
493
  enableRunInBackground: descriptor.runtime.enableRunInBackground,
213
494
  defaultBackground: descriptor.runtime.defaultBackground,
495
+ maxConcurrentBackgroundRuns: descriptor.runtime.maxConcurrentBackgroundRuns,
496
+ maxIdleRuntimes: descriptor.runtime.maxIdleRuntimes,
497
+ backgroundProtocol: descriptor.runtime.backgroundProtocol,
214
498
  reportDelivery: descriptor.runtime.reportDelivery,
215
499
  inheritExtensions: descriptor.runtime.inheritExtensions,
500
+ openAIIdentity: descriptor.runtime.openAIIdentity,
216
501
  maxOutputBytes: descriptor.runtime.maxOutputBytes,
217
502
  };
218
503
  }
219
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
+
220
577
  function isPathInside(parent: string, child: string): boolean {
221
578
  const rel = relative(resolve(parent), resolve(child));
222
579
  return rel === "" || (!rel.startsWith("..") && !rel.startsWith("/"));
223
580
  }
224
581
 
582
+ function isOpenAIResponsesModel(model: Model<any>): boolean {
583
+ return model.api === "openai-responses"
584
+ || model.api === "openai-codex-responses";
585
+ }
586
+
587
+ async function loadCodexIdentityInlineExtension(
588
+ parentSessionManager: SessionView,
589
+ ): Promise<InlineExtension> {
590
+ try {
591
+ const integration = await import(
592
+ "@oai404iao/pi-codex-minimal-tools/subagent-inline"
593
+ );
594
+ return integration.createCodexSubagentInlineExtension({
595
+ parentSessionManager,
596
+ });
597
+ } catch (error) {
598
+ throw new Error(
599
+ "openAIIdentity requires @oai404iao/pi-codex-minimal-tools with its subagent-inline export",
600
+ { cause: error },
601
+ );
602
+ }
603
+ }
604
+
225
605
  export class SubagentCoordinator {
226
606
  private readonly providers = new ProviderRegistry();
227
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;
228
618
  private agentSyncResult: AgentSyncResult | undefined;
229
619
  private draining = false;
620
+ private shutdownPromise: Promise<void> | undefined;
230
621
 
231
622
  constructor(
232
623
  private readonly pi: ExtensionAPI,
@@ -251,26 +642,39 @@ export class SubagentCoordinator {
251
642
  return join(this.agentDir, "agents");
252
643
  }
253
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
+
254
657
  discoverAvailableAgents(
255
658
  cwd: string,
256
659
  settings: SubagentSettings,
257
660
  projectTrusted: boolean,
258
661
  ): AgentDiscoveryResult {
259
- if (settings.syncBundledAgents && !this.agentSyncResult) {
662
+ if (!this.agentSyncResult) {
260
663
  this.synchronizeBundledAgents();
261
664
  }
262
- const excludeUserAgentNames = settings.syncBundledAgents
263
- ? undefined
264
- : unmodifiedManagedAgentNames(this.agentDir);
265
- return discoverAgents({
665
+ const discovery = discoverAgents({
266
666
  cwd,
267
667
  scope: settings.agentScope,
268
668
  projectTrusted,
269
- bundledDir: this.bundledAgentsDir,
270
669
  agentDir: this.agentDir,
271
- includeBundled: !settings.syncBundledAgents && settings.agentScope !== "project",
272
- excludeUserAgentNames,
273
670
  });
671
+ return {
672
+ ...discovery,
673
+ diagnostics: [
674
+ ...(this.agentSyncResult?.diagnostics ?? []),
675
+ ...discovery.diagnostics,
676
+ ],
677
+ };
274
678
  }
275
679
 
276
680
  async parentFromContext(ctx: ExtensionContext): Promise<ParentRef> {
@@ -278,10 +682,18 @@ export class SubagentCoordinator {
278
682
  const descriptor = folded.kind === "valid" ? folded.descriptor : undefined;
279
683
  const modelRuntime = runtimeFromRegistry(ctx.modelRegistry);
280
684
  return {
281
- id: ctx.sessionManager.getSessionId(),
685
+ agentId: readAgentId(ctx.sessionManager) ?? ensureAgentId(
686
+ ctx.sessionManager as unknown as SessionView,
687
+ ),
688
+ taskPath: descriptor
689
+ ? descriptorTaskPath(descriptor)
690
+ : ROOT_TASK_PATH,
282
691
  depth: descriptor?.depth ?? 0,
283
692
  cwd: ctx.cwd,
284
- sessionManager: ctx.sessionManager,
693
+ // ExtensionContext narrows the live SessionManager to a read-only
694
+ // view; appending the agent entry through the same instance keeps
695
+ // the in-memory tree and the session file consistent.
696
+ sessionManager: ctx.sessionManager as unknown as SessionView,
285
697
  modelRuntime,
286
698
  model: ctx.model,
287
699
  thinkingLevel: ctx.thinkingLevel ?? "off",
@@ -306,11 +718,35 @@ export class SubagentCoordinator {
306
718
  signal?: AbortSignal,
307
719
  onUpdate?: (details: DelegationDetails) => void,
308
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,
309
743
  ): Promise<DelegationOutcome> {
310
744
  if (this.draining) throw new Error("pi-subagent is shutting down; no new delegation was accepted");
311
- 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);
312
749
  if (
313
- providerName === "spawn" &&
314
750
  !settings.enableRunInBackground &&
315
751
  input.run_in_background === true
316
752
  ) {
@@ -319,8 +755,10 @@ export class SubagentCoordinator {
319
755
  );
320
756
  }
321
757
  const runInBackground =
322
- providerName === "spawn" && settings.enableRunInBackground
323
- ? (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)
324
762
  : false;
325
763
  const mode: SubagentMode = runInBackground ? "continuable" : "one-shot";
326
764
  if (mode === "continuable" && !provider.supportsContinuable) {
@@ -361,36 +799,74 @@ export class SubagentCoordinator {
361
799
 
362
800
  const model = this.resolveModel(parent, agent);
363
801
  const thinkingLevel = agent.thinking ?? parent.thinkingLevel;
364
- const prepared = await provider.prepare(parent, mode);
365
- const descriptor: SubagentDescriptor = {
366
- version: 1,
367
- mode,
368
- provider: providerName,
369
- label: input.description.trim(),
370
- parentSessionId: parent.id,
371
- ...(parent.sessionManager.getSessionFile()
372
- ? { parentSessionFile: parent.sessionManager.getSessionFile() }
373
- : {}),
374
- depth,
375
- cwd: parent.cwd,
376
- createdAt: new Date().toISOString(),
377
- agent: snapshotAgent(agent),
378
- model: { provider: model.provider, id: model.id },
379
- thinkingLevel,
380
- runtime: {
381
- agentScope: settings.agentScope,
382
- syncBundledAgents: settings.syncBundledAgents,
383
- maxDepth: settings.maxDepth,
384
- enableRunInBackground: settings.enableRunInBackground,
385
- defaultBackground: settings.defaultBackground,
386
- reportDelivery: settings.reportDelivery,
387
- inheritExtensions: settings.inheritExtensions,
388
- maxOutputBytes: settings.maxOutputBytes,
389
- },
390
- };
391
-
802
+ parent.agentId = ensureAgentId(parent.sessionManager);
803
+ const reservedTask = await this.reserveTask(
804
+ parent,
805
+ input.task_name,
806
+ input.description,
807
+ );
808
+ let prepared: PreparedChildSession | undefined;
392
809
  let activation: Activation | undefined;
810
+ let keepTaskReservation = false;
393
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
+ });
394
870
  activation = await this.createActivation({
395
871
  parent,
396
872
  descriptor,
@@ -398,11 +874,18 @@ export class SubagentCoordinator {
398
874
  isNew: true,
399
875
  onUpdate,
400
876
  });
877
+ if (this.draining) {
878
+ throw new Error("pi-subagent is shutting down; no new delegation was accepted");
879
+ }
401
880
  if (mode === "continuable" && parent.activation) {
402
- parent.activation.ownedChildren.add(activation.id);
881
+ this.acquireParentOwnership(activation, parent);
403
882
  }
404
- 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
+ });
405
887
  await started.accepted;
888
+ keepTaskReservation = true;
406
889
  if (mode === "continuable") {
407
890
  activation.onUpdate = undefined;
408
891
  return { kind: "continuable", details: this.detailsOf(activation) };
@@ -414,37 +897,144 @@ export class SubagentCoordinator {
414
897
  await this.disposeActivation(activation);
415
898
  return { kind: "foreground", details, result };
416
899
  } catch (error) {
417
- if (activation && !activation.published) {
900
+ if (activation && prepared && !activation.published) {
418
901
  activation.suppressSettlement = true;
419
902
  await this.rollbackActivation(activation, prepared);
420
- } else if (!activation) {
903
+ } else if (!activation && prepared) {
421
904
  await prepared.rollback();
422
905
  }
423
906
  throw error;
907
+ } finally {
908
+ if (!keepTaskReservation) reservedTask.release();
424
909
  }
425
910
  }
426
911
 
427
- 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> {
428
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
+ }
429
977
  let activation = this.active.get(childId);
430
978
  let coldPrepared: PreparedChildSession | undefined;
431
- if (activation?.finalizing && activation.finalizePromise) {
432
- await activation.finalizePromise;
433
- 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
+ }
434
1013
  }
435
-
436
1014
  if (!activation) {
437
1015
  const located = await this.findPersistedChild(parent, childId);
438
1016
  if (!located) throw new Error(`unknown subagent: ${childId}; message was not delivered`);
439
- if (located.descriptor.mode !== "continuable") {
440
- throw new Error(`subagent ${childId} is one-shot and cannot accept follow-up messages`);
441
- }
442
- this.assertDirectParent(parent, located.descriptor);
1017
+ this.assertContinuableDirectChild(parent, located.descriptor);
443
1018
  const manager = SessionManager.open(
444
1019
  located.sessionFile,
445
1020
  parent.sessionManager.getSessionDir(),
446
1021
  parent.cwd,
447
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
+ }
448
1038
  coldPrepared = {
449
1039
  sessionManager: manager,
450
1040
  seedMessageCount: manager.buildSessionContext().messages.length,
@@ -456,21 +1046,61 @@ export class SubagentCoordinator {
456
1046
  prepared: coldPrepared,
457
1047
  isNew: false,
458
1048
  });
459
- if (parent.activation) parent.activation.ownedChildren.add(activation.id);
1049
+ this.acquireParentOwnership(activation, parent);
460
1050
  } else {
461
- this.assertDirectParent(parent, activation.descriptor);
1051
+ this.assertContinuableDirectChild(parent, activation.descriptor);
462
1052
  }
1053
+ const resolvedPath = descriptorTaskPath(activation.descriptor);
463
1054
 
464
1055
  const session = activation.runtime.session;
465
1056
  if (activation.currentRun || session.isStreaming) {
466
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
+ }
467
1080
  await session.followUp(message);
468
- return;
1081
+ return {
1082
+ kind: "legacy",
1083
+ agentId: childId,
1084
+ taskPath: resolvedPath,
1085
+ };
469
1086
  }
470
1087
 
471
1088
  try {
472
- const started = this.startPrompt(activation, message, signal, true);
473
- 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
+ };
474
1104
  } catch (error) {
475
1105
  if (coldPrepared && !activation.published) {
476
1106
  activation.suppressSettlement = true;
@@ -480,44 +1110,589 @@ export class SubagentCoordinator {
480
1110
  }
481
1111
  }
482
1112
 
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
+ );
1121
+ }
1122
+
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");
1130
+ }
1131
+ const target = await this.resolveTarget(parent, childId);
1132
+ const pending = await this.agentOperations.run(target.agentId, () =>
1133
+ this.followupTaskSerialized(parent, target.agentId, signal),
1134
+ );
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;
1151
+ }
1152
+
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");
1160
+ }
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
+
483
1591
  async interrupt(parent: ParentRef, targetId: string): Promise<void> {
484
- const target = this.active.get(targetId);
485
- if (!target) return;
486
- if (!(await this.isDescendantOf(parent, target.descriptor))) {
487
- throw new Error(`subagent ${targetId} is not a live descendant of ${parent.id}`);
488
- }
489
- target.status = "failed";
490
- void target.runtime.session.abort().catch((error) => {
491
- target.lastError = errorText(error);
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
+ };
492
1650
  });
493
1651
  }
494
1652
 
495
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[]> {
496
1661
  const catalog = await this.catalogRecords(parent);
497
1662
  const records = catalog.records;
498
- const byId = new Map(records.map((record) => [record.id, record]));
1663
+ const byId = new Map(records.map((record) => [record.agentId, record]));
499
1664
  const children: CatalogChild[] = [];
500
1665
  for (const record of records) {
501
1666
  if (record.descriptor.mode !== "continuable") continue;
502
- const distance = this.distanceFrom(parent.id, record.descriptor, byId);
1667
+ const distance = this.distanceFrom(parent.agentId, record.descriptor, byId);
503
1668
  if (distance === undefined || (scope === "children" && distance !== 1)) continue;
504
1669
  children.push({
505
1670
  kind: "child",
506
- id: record.id,
507
- parentId: record.descriptor.parentSessionId,
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),
508
1679
  depth: distance,
509
1680
  descriptor: record.descriptor,
510
1681
  ...(record.sessionFile ? { sessionFile: record.sessionFile } : {}),
511
- status: record.active
512
- ? record.active.currentRun || record.active.runtime.session.isStreaming
513
- ? "running"
514
- : "idle"
515
- : "ready",
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),
516
1690
  });
517
1691
  }
518
1692
  children.sort(
519
1693
  (left, right) =>
520
- left.descriptor.createdAt.localeCompare(right.descriptor.createdAt) || left.id.localeCompare(right.id),
1694
+ left.descriptor.createdAt.localeCompare(right.descriptor.createdAt) ||
1695
+ left.agentId.localeCompare(right.agentId),
521
1696
  );
522
1697
  const parentFile = parent.sessionManager.getSessionFile();
523
1698
  const diagnostics = parentFile
@@ -529,11 +1704,16 @@ export class SubagentCoordinator {
529
1704
  }
530
1705
 
531
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> {
532
1711
  if (child.descriptor.mode !== "continuable") {
533
1712
  throw new Error("report is available only to continuable subagents");
534
1713
  }
535
1714
  const truncated = truncateUtf8(output, child.descriptor.runtime.maxOutputBytes);
536
- const content = `Background subagent ${child.id} reported:\n\n${truncated.text}${
1715
+ const readablePath = descriptorTaskPath(child.descriptor);
1716
+ const content = `Background subagent ${readablePath} (${child.agentId}) reported:\n\n${truncated.text}${
537
1717
  truncated.truncated ? `\n\n[Report truncated; ${truncated.omittedBytes} bytes omitted.]` : ""
538
1718
  }`;
539
1719
  await child.parent.deliver(
@@ -541,7 +1721,8 @@ export class SubagentCoordinator {
541
1721
  content,
542
1722
  {
543
1723
  kind: "report",
544
- childId: child.id,
1724
+ childAgentId: child.agentId,
1725
+ taskPath: readablePath,
545
1726
  label: child.descriptor.label,
546
1727
  ...(truncated.truncated ? { truncated: true } : {}),
547
1728
  },
@@ -549,19 +1730,84 @@ export class SubagentCoordinator {
549
1730
  );
550
1731
  }
551
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
+ }
1751
+
552
1752
  async shutdown(): Promise<void> {
553
- if (this.draining) return;
1753
+ if (this.shutdownPromise) return this.shutdownPromise;
554
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();
555
1772
  const activations = [...this.active.values()];
556
- for (const activation of activations) activation.suppressSettlement = true;
557
1773
  await Promise.allSettled(
558
- activations.map(async (activation) => {
559
- if (!activation.runtime.session.isIdle) await activation.runtime.session.abort();
560
- }),
1774
+ activations
1775
+ .map((activation) => activation.currentRun)
1776
+ .filter(
1777
+ (run): run is Promise<SubagentRunResult> => run !== undefined,
1778
+ ),
561
1779
  );
562
1780
  for (const activation of activations.sort((left, right) => right.descriptor.depth - left.descriptor.depth)) {
563
1781
  await this.disposeActivation(activation).catch(() => {});
564
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
+ );
565
1811
  }
566
1812
 
567
1813
  createChildToolDefinitions(
@@ -569,6 +1815,7 @@ export class SubagentCoordinator {
569
1815
  enableRunInBackground = true,
570
1816
  defaultBackground = true,
571
1817
  agentDiscovery?: AgentDiscoveryResult,
1818
+ backgroundProtocol: NonNullable<SubagentSettings["backgroundProtocol"]> = "legacy",
572
1819
  ): ToolDefinition[] {
573
1820
  const agentNames = agentDiscovery?.agents.map((agent) => agent.name);
574
1821
  const assertBackgroundControlEnabled = (toolName: string): void => {
@@ -588,12 +1835,12 @@ export class SubagentCoordinator {
588
1835
  name: "subagent",
589
1836
  label: "Subagent",
590
1837
  description:
591
- "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. " +
592
1839
  (!enableRunInBackground
593
1840
  ? "This foreground-only instance always waits for the result."
594
1841
  : defaultBackground
595
- ? "It runs in the background by default and returns a durable id."
596
- : "It waits for the result by default; background mode returns a durable id."),
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."),
597
1844
  parameters: delegationParameters(enableRunInBackground, agentNames),
598
1845
  execute: async (_id, params, signal, onUpdate) => {
599
1846
  const activation = getActivation();
@@ -614,8 +1861,11 @@ export class SubagentCoordinator {
614
1861
  name: "subagent_fork",
615
1862
  label: "Subagent Fork",
616
1863
  description:
617
- "Delegate a one-shot task to a child seeded with completed turns from this conversation.",
618
- 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
+ ),
619
1869
  execute: async (_id, params, signal, onUpdate) => {
620
1870
  const activation = getActivation();
621
1871
  const outcome = await this.delegate(
@@ -635,12 +1885,14 @@ export class SubagentCoordinator {
635
1885
  name: "send_message",
636
1886
  label: "Send Message",
637
1887
  description:
638
- "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.",
639
1891
  parameters: SendMessageParameters,
640
1892
  execute: async (_id, params, signal) => {
641
1893
  assertBackgroundControlEnabled("send_message");
642
1894
  const activation = getActivation();
643
- await this.sendMessage(
1895
+ const delivery = await this.sendMessageWithOutcome(
644
1896
  this.parentForActivation(activation),
645
1897
  params.subagent_id,
646
1898
  params.message,
@@ -650,10 +1902,92 @@ export class SubagentCoordinator {
650
1902
  content: [
651
1903
  {
652
1904
  type: "text",
653
- 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"}`,
654
1946
  },
655
1947
  ],
656
- details: { kind: "control", action: "send", id: 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,
657
1991
  };
658
1992
  },
659
1993
  });
@@ -667,10 +2001,18 @@ export class SubagentCoordinator {
667
2001
  execute: async (_id, params) => {
668
2002
  assertBackgroundControlEnabled("interrupt_agent");
669
2003
  const activation = getActivation();
670
- await this.interrupt(this.parentForActivation(activation), params.agent_id);
2004
+ const outcome = await this.interruptWithOutcome(
2005
+ this.parentForActivation(activation),
2006
+ params.agent_id,
2007
+ );
671
2008
  return {
672
- content: [{ type: "text", text: `interrupt requested for agent ${params.agent_id}` }],
673
- details: { kind: "control", action: "interrupt", id: params.agent_id } satisfies ControlDetails,
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,
674
2016
  };
675
2017
  },
676
2018
  });
@@ -679,7 +2021,7 @@ export class SubagentCoordinator {
679
2021
  name: "list_agents",
680
2022
  label: "List Agents",
681
2023
  description:
682
- "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.",
683
2025
  parameters: ListAgentsParameters,
684
2026
  execute: async (_id, params) => {
685
2027
  assertBackgroundControlEnabled("list_agents");
@@ -706,18 +2048,28 @@ export class SubagentCoordinator {
706
2048
  await this.report(activation, params.output);
707
2049
  return {
708
2050
  content: [{ type: "text", text: `report accepted by the agent that started you` }],
709
- details: { kind: "control", action: "report", id: activation.id } satisfies ControlDetails,
2051
+ details: {
2052
+ kind: "control",
2053
+ action: "report",
2054
+ agentId: activation.agentId,
2055
+ taskPath: descriptorTaskPath(activation.descriptor),
2056
+ } satisfies ControlDetails,
710
2057
  };
711
2058
  },
712
2059
  });
713
2060
 
714
- return [spawn, fork, send, interrupt, list, report];
2061
+ return [spawn, fork, send, followup, wait, interrupt, list, report];
715
2062
  }
716
2063
 
717
2064
  outcomeToolResult(outcome: DelegationOutcome): AgentToolResult<DelegationDetails> {
718
2065
  if (outcome.kind === "continuable") {
719
2066
  return {
720
- content: [{ type: "text", text: `started subagent ${outcome.details.id}` }],
2067
+ content: [
2068
+ {
2069
+ type: "text",
2070
+ text: `started subagent ${outcome.details.taskPath} (${outcome.details.agentId})`,
2071
+ },
2072
+ ],
721
2073
  details: outcome.details,
722
2074
  };
723
2075
  }
@@ -740,14 +2092,56 @@ export class SubagentCoordinator {
740
2092
  if (entries.length === 0) return "(no subagents)";
741
2093
  return entries
742
2094
  .map((entry) => {
743
- if (entry.kind === "diagnostic") return `${entry.id} [diagnostic: ${entry.reason}]`;
2095
+ if (entry.kind === "diagnostic") {
2096
+ return `${entry.piSessionId} [diagnostic: ${entry.reason}]`;
2097
+ }
744
2098
  const location =
745
- scope === "descendants" ? ` parent=${entry.parentId} depth=${entry.depth}` : "";
746
- return `${entry.id} [${entry.status}]${location} — ${entry.descriptor.label} (${entry.descriptor.agent.name})`;
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}`;
747
2107
  })
748
2108
  .join("\n");
749
2109
  }
750
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
+
751
2145
  private resolveModel(parent: ParentRef, agent: AgentDefinition): Model<any> {
752
2146
  if (!agent.model) {
753
2147
  if (!parent.model) throw new Error("no parent model is selected for the subagent");
@@ -789,6 +2183,7 @@ export class SubagentCoordinator {
789
2183
  descriptor.runtime.enableRunInBackground,
790
2184
  descriptor.runtime.defaultBackground,
791
2185
  agentDiscovery,
2186
+ descriptor.runtime.backgroundProtocol,
792
2187
  );
793
2188
  const model =
794
2189
  options.parent.modelRuntime.getModel(descriptor.model.provider, descriptor.model.id) ??
@@ -801,6 +2196,14 @@ export class SubagentCoordinator {
801
2196
  `cannot materialize subagent: model ${descriptor.model.provider}/${descriptor.model.id} is unavailable`,
802
2197
  );
803
2198
  }
2199
+ const extensionFactories: InlineExtension[] =
2200
+ descriptor.runtime.openAIIdentity && isOpenAIResponsesModel(model)
2201
+ ? [
2202
+ await loadCodexIdentityInlineExtension(
2203
+ options.parent.sessionManager,
2204
+ ),
2205
+ ]
2206
+ : [];
804
2207
 
805
2208
  const appendSystemPrompt = [
806
2209
  descriptor.agent.systemPrompt,
@@ -836,10 +2239,13 @@ export class SubagentCoordinator {
836
2239
  noExtensions: !descriptor.runtime.inheritExtensions,
837
2240
  noThemes: true,
838
2241
  appendSystemPrompt,
2242
+ extensionFactories,
839
2243
  extensionsOverride: (base) => ({
840
2244
  ...base,
841
2245
  extensions: base.extensions.filter(
842
- (extension) => !isPathInside(this.packageRoot, extension.resolvedPath),
2246
+ (extension) =>
2247
+ extension.resolvedPath.startsWith("<inline:")
2248
+ || !isPathInside(this.packageRoot, extension.resolvedPath),
843
2249
  ),
844
2250
  }),
845
2251
  },
@@ -898,6 +2304,15 @@ export class SubagentCoordinator {
898
2304
  ) {
899
2305
  return false;
900
2306
  }
2307
+ if (
2308
+ descriptor.runtime.backgroundProtocol !== "mailbox-v2"
2309
+ && (
2310
+ tool === "followup_task"
2311
+ || tool === "wait_agent"
2312
+ )
2313
+ ) {
2314
+ return false;
2315
+ }
901
2316
  return true;
902
2317
  });
903
2318
  runtime.session.setActiveToolsByName(activeTools);
@@ -935,26 +2350,41 @@ export class SubagentCoordinator {
935
2350
  }
936
2351
 
937
2352
  activation = {
938
- id: runtime.session.sessionId,
939
- epochId: randomUUID(),
2353
+ agentId: descriptor.agentId,
2354
+ runId: uuidv7(),
940
2355
  descriptor,
941
2356
  parent: options.parent,
942
2357
  runtime,
943
2358
  seedMessageCount: options.prepared.seedMessageCount,
944
2359
  epochMessageStart: runtime.session.messages.length,
945
- status: "starting",
2360
+ controlState: createAgentControlState(),
946
2361
  trace: [],
947
2362
  streamedText: "",
948
2363
  usage: emptyUsage(),
2364
+ startedTurnIds: new Set(),
2365
+ silentSettlementTurnIds: new Set(),
2366
+ pendingMailboxClaims: new Set(),
2367
+ userMessageGates: new Map(),
949
2368
  ownedChildren: new Set(),
950
2369
  onUpdate: options.onUpdate,
951
2370
  published: false,
2371
+ everPublished: false,
952
2372
  suppressSettlement: false,
953
2373
  finalizing: false,
2374
+ holdsBackgroundSlot: false,
2375
+ persistenceGate: createPersistenceGate(
2376
+ runtime.session.sessionManager,
2377
+ options.isNew,
2378
+ ),
2379
+ lastUsedSequence: ++this.activationSequence,
954
2380
  disposed: false,
955
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
+ }
956
2386
  activation.unsubscribe = runtime.session.subscribe((event) => this.observe(activation!, event));
957
- this.active.set(activation.id, activation);
2387
+ this.active.set(activation.agentId, activation);
958
2388
  return activation;
959
2389
  } catch (error) {
960
2390
  await runtime.dispose().catch(() => {});
@@ -962,16 +2392,33 @@ export class SubagentCoordinator {
962
2392
  }
963
2393
  }
964
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
+
965
2409
  private startPrompt(
966
2410
  activation: Activation,
967
2411
  prompt: string,
968
2412
  signal: AbortSignal | undefined,
969
- detachAtAcceptance: boolean,
970
- ): { accepted: Promise<void>; result: Promise<SubagentRunResult> } {
971
- if (activation.currentRun) throw new Error(`subagent ${activation.id} is already running`);
2413
+ options: StartPromptOptions,
2414
+ ): { turnId: string; accepted: Promise<void>; result: Promise<SubagentRunResult> } {
2415
+ if (activation.currentRun) throw new Error(`subagent ${activation.agentId} is already running`);
972
2416
  if (signal?.aborted) throw signal.reason ?? new Error("subagent start aborted");
2417
+ this.touchActivation(activation);
973
2418
  activation.pendingSettlement = undefined;
974
- activation.status = "running";
2419
+ this.resetTurnCapture(activation);
2420
+ const turnId = uuidv7();
2421
+ queueAgentTurn(activation.controlState, turnId);
975
2422
  this.emitUpdate(activation);
976
2423
 
977
2424
  let resolveAccepted!: () => void;
@@ -981,47 +2428,134 @@ export class SubagentCoordinator {
981
2428
  resolveAccepted = resolvePromise;
982
2429
  rejectAccepted = rejectPromise;
983
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;
984
2440
  const abort = () => {
2441
+ turnAbortController.abort(
2442
+ signal?.reason ?? new Error(`subagent turn ${turnId} was aborted`),
2443
+ );
985
2444
  void activation.runtime.session.abort().catch(() => {});
986
2445
  };
987
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
+ }
988
2483
 
989
2484
  const core = (async (): Promise<SubagentRunResult> => {
2485
+ let permit: BackgroundRunPermit | undefined;
990
2486
  try {
991
- 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
+ : {}),
992
2509
  preflightResult: (success) => {
993
2510
  if (acceptedSettled) return;
994
- acceptedSettled = true;
995
2511
  if (success) {
996
- this.publish(activation);
997
- if (detachAtAcceptance && signal) signal.removeEventListener("abort", abort);
998
- resolveAccepted();
2512
+ preflightSucceeded = true;
2513
+ if (options.acceptAfterUserMessage) {
2514
+ return;
2515
+ }
2516
+ options.onPromptAccepted?.(turnId);
2517
+ acceptPrompt();
999
2518
  } else {
1000
- activation.suppressSettlement = true;
1001
- rejectAccepted(new Error("subagent prompt was rejected before acceptance"));
2519
+ rejectPrompt(
2520
+ new Error("subagent prompt was rejected before acceptance"),
2521
+ );
1002
2522
  }
1003
2523
  },
1004
2524
  });
1005
2525
  if (!acceptedSettled) {
1006
- acceptedSettled = true;
1007
- this.publish(activation);
1008
- if (detachAtAcceptance && signal) signal.removeEventListener("abort", abort);
1009
- 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();
1010
2536
  }
1011
- return this.collectResult(activation, "completed");
2537
+ return this.collectResult(activation, turnId, "completed");
1012
2538
  } catch (error) {
1013
2539
  activation.lastError = errorText(error);
1014
2540
  if (!acceptedSettled) {
1015
- acceptedSettled = true;
1016
- activation.suppressSettlement = true;
1017
- rejectAccepted(error instanceof Error ? error : new Error(String(error)));
2541
+ rejectPrompt(
2542
+ error instanceof Error ? error : new Error(String(error)),
2543
+ );
1018
2544
  }
1019
- const fallback = signal?.aborted ? "aborted" : "error";
1020
- const result = this.collectResult(activation, fallback);
2545
+ const fallback = turnAbortController.signal.aborted ? "aborted" : "error";
2546
+ const result = this.collectResult(activation, turnId, fallback);
1021
2547
  if (!result.output) result.output = activation.lastError;
1022
2548
  return result;
1023
2549
  } finally {
2550
+ activation.userMessageGates.delete(turnId);
1024
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
+ }
1025
2559
  }
1026
2560
  })();
1027
2561
 
@@ -1033,7 +2567,7 @@ export class SubagentCoordinator {
1033
2567
  });
1034
2568
  activation.currentRun = lifecycle;
1035
2569
  void lifecycle.catch(() => {});
1036
- return { accepted, result: lifecycle };
2570
+ return { turnId, accepted, result: lifecycle };
1037
2571
  }
1038
2572
 
1039
2573
  private startInternalMessage(
@@ -1044,23 +2578,68 @@ export class SubagentCoordinator {
1044
2578
  ): void {
1045
2579
  if (activation.currentRun || activation.disposed) return;
1046
2580
  activation.pendingSettlement = undefined;
1047
- activation.status = "running";
1048
- const core = Promise.resolve()
1049
- .then(() =>
1050
- 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(
1051
2611
  { customType, content, display: true, details },
1052
2612
  { triggerTurn: true, deliverAs: "followUp" },
1053
- ),
1054
- )
1055
- .then(
1056
- () => this.collectResult(activation, "completed"),
1057
- (error) => {
1058
- activation.lastError = errorText(error);
1059
- const result = this.collectResult(activation, "error");
1060
- if (!result.output) result.output = activation.lastError ?? "";
1061
- return result;
1062
- },
1063
- );
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
+ })();
1064
2643
  let lifecycle!: Promise<SubagentRunResult>;
1065
2644
  lifecycle = core.then(async (result) => {
1066
2645
  if (activation.currentRun === lifecycle) activation.currentRun = undefined;
@@ -1072,8 +2651,66 @@ export class SubagentCoordinator {
1072
2651
  }
1073
2652
 
1074
2653
  private async runFinished(activation: Activation, result: SubagentRunResult): Promise<void> {
1075
- 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
+ }
1076
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
+ }
1077
2714
  this.emitUpdate(activation, result);
1078
2715
  if (activation.descriptor.mode === "one-shot") {
1079
2716
  this.emitEnd(activation, result);
@@ -1081,7 +2718,6 @@ export class SubagentCoordinator {
1081
2718
  }
1082
2719
  if (activation.suppressSettlement || this.draining) return;
1083
2720
  if (activation.ownedChildren.size > 0) {
1084
- activation.status = "waiting";
1085
2721
  this.emitUpdate(activation, result);
1086
2722
  return;
1087
2723
  }
@@ -1089,29 +2725,74 @@ export class SubagentCoordinator {
1089
2725
  }
1090
2726
 
1091
2727
  private async finalizeContinuable(activation: Activation): Promise<void> {
1092
- 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
+ }
1093
2740
  const result = activation.pendingSettlement;
1094
- if (!result || activation.ownedChildren.size > 0) return;
2741
+ if (!result || activation.ownedChildren.size > 0) return false;
1095
2742
  activation.finalizing = true;
1096
- activation.finalizePromise = (async () => {
1097
- if (!activation.suppressSettlement && !this.draining) {
1098
- await this.deliverSettlement(activation, result).catch((error) => {
1099
- activation.lastError = errorText(error);
1100
- });
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
+ }
1101
2775
  }
1102
- this.emitEnd(activation, result);
1103
- await this.disposeActivation(activation);
1104
- await this.releaseParentOwnership(activation);
2776
+ return retained;
1105
2777
  })();
1106
- 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
+ }
1107
2787
  }
1108
2788
 
1109
2789
  private async deliverSettlement(activation: Activation, result: SubagentRunResult): Promise<void> {
2790
+ const readablePath = descriptorTaskPath(activation.descriptor);
1110
2791
  const truncated = truncateUtf8(result.output, activation.descriptor.runtime.maxOutputBytes);
1111
2792
  const closing = truncated.text.trim()
1112
2793
  ? `Its closing message:\n\n${truncated.text}`
1113
2794
  : "It left no closing message.";
1114
- const content = `Background subagent ${activation.id} ${stopReasonHeadline(result.stopReason)} and will do no further work unless you send it more.\n\n${closing}${
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}${
1115
2796
  truncated.truncated ? `\n\n[Closing message truncated; ${truncated.omittedBytes} bytes omitted.]` : ""
1116
2797
  }`;
1117
2798
  await activation.parent.deliver(
@@ -1119,7 +2800,8 @@ export class SubagentCoordinator {
1119
2800
  content,
1120
2801
  {
1121
2802
  kind: "settled",
1122
- childId: activation.id,
2803
+ childAgentId: activation.agentId,
2804
+ taskPath: readablePath,
1123
2805
  label: activation.descriptor.label,
1124
2806
  stopReason: result.stopReason,
1125
2807
  ...(truncated.truncated ? { truncated: true } : {}),
@@ -1130,7 +2812,8 @@ export class SubagentCoordinator {
1130
2812
 
1131
2813
  private parentForActivation(activation: Activation): ParentRef {
1132
2814
  return {
1133
- id: activation.id,
2815
+ agentId: activation.agentId,
2816
+ taskPath: descriptorTaskPath(activation.descriptor),
1134
2817
  depth: activation.descriptor.depth,
1135
2818
  cwd: activation.descriptor.cwd,
1136
2819
  sessionManager: activation.runtime.session.sessionManager,
@@ -1140,7 +2823,7 @@ export class SubagentCoordinator {
1140
2823
  projectTrusted: activation.parent.projectTrusted,
1141
2824
  activation,
1142
2825
  deliver: async (customType, content, details, delivery) => {
1143
- if (activation.disposed) throw new Error(`parent subagent ${activation.id} is no longer resident`);
2826
+ if (activation.disposed) throw new Error(`parent subagent ${activation.agentId} is no longer resident`);
1144
2827
  const session = activation.runtime.session;
1145
2828
  if (delivery === "quiet") {
1146
2829
  await session.sendCustomMessage(
@@ -1163,20 +2846,38 @@ export class SubagentCoordinator {
1163
2846
  };
1164
2847
  }
1165
2848
 
1166
- 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 {
1167
2860
  const messages = activation.runtime.session.messages;
1168
2861
  const output = finalAssistantText(messages, activation.epochMessageStart, activation.streamedText);
1169
2862
  const stopReason = finalStopReason(messages, activation.epochMessageStart, fallback);
1170
2863
  const truncated = truncateUtf8(output, activation.descriptor.runtime.maxOutputBytes);
1171
2864
  const sessionFile = activation.runtime.session.sessionFile;
1172
2865
  return {
1173
- id: activation.id,
2866
+ agentId: activation.agentId,
2867
+ turnId,
2868
+ piSessionId: activation.runtime.session.sessionId,
1174
2869
  ...(sessionFile ? { sessionFile } : {}),
1175
2870
  output: truncated.truncated
1176
2871
  ? `${truncated.text}\n\n[Output truncated; ${truncated.omittedBytes} bytes omitted.${
1177
2872
  sessionFile ? ` Full output: ${sessionFile}` : " Full output remains in the active child session."
1178
2873
  }]`
1179
2874
  : truncated.text,
2875
+ ...(truncated.truncated
2876
+ ? {
2877
+ outputTruncated: true,
2878
+ omittedBytes: truncated.omittedBytes,
2879
+ }
2880
+ : {}),
1180
2881
  stopReason,
1181
2882
  usage: structuredClone(activation.usage),
1182
2883
  };
@@ -1197,12 +2898,47 @@ export class SubagentCoordinator {
1197
2898
  this.emitUpdate(activation);
1198
2899
  return;
1199
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
+ }
1200
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
+ }
1201
2936
  if (event.message.role === "toolResult") {
1202
2937
  if (event.message.usage) addUsage(activation.usage, event.message.usage, false);
1203
2938
  return;
1204
2939
  }
1205
2940
  if (event.message.role !== "assistant") return;
2941
+ activation.persistenceGate.resolve();
1206
2942
  addUsage(activation.usage, event.message.usage);
1207
2943
  const text = event.message.content
1208
2944
  .filter((part): part is Extract<(typeof event.message.content)[number], { type: "text" }> => part.type === "text")
@@ -1223,23 +2959,57 @@ export class SubagentCoordinator {
1223
2959
  private publish(activation: Activation): void {
1224
2960
  if (activation.published) return;
1225
2961
  activation.published = true;
2962
+ activation.everPublished = true;
1226
2963
  this.pi.events.emit("pi-subagent:start", {
1227
- runId: activation.epochId,
1228
- id: activation.id,
2964
+ runId: activation.runId,
2965
+ agentId: activation.agentId,
2966
+ taskPath: descriptorTaskPath(activation.descriptor),
2967
+ piSessionId: activation.runtime.session.sessionId,
2968
+ parentAgentId: activation.descriptor.parentAgentId,
1229
2969
  provider: activation.descriptor.provider,
1230
2970
  mode: activation.descriptor.mode,
1231
- parentId: activation.descriptor.parentSessionId,
2971
+ context: descriptorContext(activation.descriptor),
1232
2972
  });
1233
2973
  }
1234
2974
 
1235
2975
  private emitEnd(activation: Activation, result: SubagentRunResult): void {
1236
2976
  if (!activation.published) return;
1237
2977
  this.pi.events.emit("pi-subagent:end", {
1238
- runId: activation.epochId,
1239
- id: activation.id,
2978
+ runId: activation.runId,
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),
3009
+ piSessionId: activation.runtime.session.sessionId,
3010
+ parentAgentId: activation.descriptor.parentAgentId,
1240
3011
  provider: activation.descriptor.provider,
1241
3012
  mode: activation.descriptor.mode,
1242
- parentId: activation.descriptor.parentSessionId,
1243
3013
  stopReason: result.stopReason,
1244
3014
  output: result.output,
1245
3015
  });
@@ -1248,13 +3018,22 @@ export class SubagentCoordinator {
1248
3018
  private detailsOf(activation: Activation, result?: SubagentRunResult): DelegationDetails {
1249
3019
  return {
1250
3020
  kind: "delegation",
1251
- id: activation.id,
3021
+ agentId: activation.agentId,
3022
+ taskPath: descriptorTaskPath(activation.descriptor),
3023
+ ...(currentAgentTurnId(activation.controlState)
3024
+ ? { turnId: currentAgentTurnId(activation.controlState) }
3025
+ : {}),
3026
+ piSessionId: activation.runtime.session.sessionId,
1252
3027
  provider: activation.descriptor.provider,
1253
3028
  mode: activation.descriptor.mode,
3029
+ context: descriptorContext(activation.descriptor),
1254
3030
  agent: activation.descriptor.agent.name,
1255
3031
  label: activation.descriptor.label,
1256
3032
  depth: activation.descriptor.depth,
1257
- status: activation.status,
3033
+ status: delegationStatus(
3034
+ activation.controlState,
3035
+ activation.ownedChildren.size > 0,
3036
+ ),
1258
3037
  ...(activation.runtime.session.sessionFile
1259
3038
  ? { sessionFile: activation.runtime.session.sessionFile }
1260
3039
  : {}),
@@ -1296,36 +3075,435 @@ export class SubagentCoordinator {
1296
3075
  private async disposeActivation(activation: Activation): Promise<void> {
1297
3076
  if (activation.disposed) return;
1298
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);
1299
3097
  activation.unsubscribe?.();
1300
3098
  activation.unsubscribe = undefined;
1301
3099
  if (!activation.runtime.session.isIdle) await activation.runtime.session.abort().catch(() => {});
1302
3100
  try {
1303
3101
  await activation.runtime.dispose();
1304
3102
  } finally {
1305
- if (this.active.get(activation.id) === activation) this.active.delete(activation.id);
3103
+ if (this.active.get(activation.agentId) === activation) this.active.delete(activation.agentId);
1306
3104
  }
1307
3105
  }
1308
3106
 
1309
3107
  private async releaseParentOwnership(activation: Activation): Promise<void> {
1310
- const owner = activation.parent.activation;
3108
+ const owner = activation.ownerActivation;
1311
3109
  if (!owner) return;
1312
- owner.ownedChildren.delete(activation.id);
3110
+ activation.ownerActivation = undefined;
3111
+ owner.ownedChildren.delete(activation.agentId);
1313
3112
  if (
1314
3113
  !owner.currentRun &&
1315
3114
  owner.ownedChildren.size === 0 &&
1316
3115
  owner.pendingSettlement &&
1317
3116
  owner.descriptor.mode === "continuable"
1318
3117
  ) {
1319
- 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
+ });
1320
3211
  }
1321
3212
  }
1322
3213
 
1323
3214
  private assertDirectParent(parent: ParentRef, descriptor: SubagentDescriptor): void {
1324
- if (descriptor.parentSessionId !== parent.id) {
3215
+ if (descriptor.parentAgentId !== parent.agentId) {
3216
+ throw new Error(
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) {
1325
3347
  throw new Error(
1326
- `subagent ${descriptor.label} is not a direct child of ${parent.id}; message was not delivered`,
3348
+ `ambiguous subagent task path ${path}; use a durable agent id`,
1327
3349
  );
1328
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
+ );
1329
3507
  }
1330
3508
 
1331
3509
  private async findPersistedChild(
@@ -1333,7 +3511,7 @@ export class SubagentCoordinator {
1333
3511
  childId: string,
1334
3512
  ): Promise<{ descriptor: SubagentDescriptor; sessionFile: string } | undefined> {
1335
3513
  const catalog = await readPersistedCatalog(parent.sessionManager);
1336
- const entry = catalog.descriptors.find((candidate) => candidate.id === childId);
3514
+ const entry = catalog.descriptors.find((candidate) => candidate.agentId === childId);
1337
3515
  return entry ? { descriptor: entry.descriptor, sessionFile: entry.sessionFile } : undefined;
1338
3516
  }
1339
3517
 
@@ -1341,26 +3519,114 @@ export class SubagentCoordinator {
1341
3519
  const persisted = await readPersistedCatalog(parent.sessionManager);
1342
3520
  const records = new Map<string, CatalogRecord>();
1343
3521
  for (const item of persisted.descriptors) {
1344
- records.set(item.id, {
1345
- id: item.id,
3522
+ records.set(item.agentId, {
3523
+ agentId: item.agentId,
3524
+ piSessionId: item.piSessionId,
3525
+ taskPath: descriptorTaskPath(item.descriptor),
1346
3526
  descriptor: item.descriptor,
1347
3527
  sessionFile: item.sessionFile,
3528
+ pendingMessages: item.pendingMessages,
3529
+ unreadUpdatesByChild: new Map(item.unreadUpdatesByChild),
3530
+ });
3531
+ }
3532
+ const activeSessionIds = new Set(
3533
+ [...this.active.values()].map((activation) => activation.runtime.session.sessionId),
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}`,
1348
3557
  });
1349
3558
  }
1350
3559
  for (const activation of this.active.values()) {
1351
3560
  if (activation.descriptor.cwd !== parent.cwd) continue;
1352
- records.set(activation.id, {
1353
- id: activation.id,
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
+ }
3604
+ records.set(activation.agentId, {
3605
+ agentId: activation.agentId,
3606
+ piSessionId: activation.runtime.session.sessionId,
3607
+ taskPath: descriptorTaskPath(activation.descriptor),
1354
3608
  descriptor: activation.descriptor,
1355
3609
  ...(activation.runtime.session.sessionFile
1356
3610
  ? { sessionFile: activation.runtime.session.sessionFile }
1357
3611
  : {}),
1358
3612
  active: activation,
3613
+ pendingMessages,
3614
+ unreadUpdatesByChild: unreadCompletionCounts(
3615
+ completions.snapshot,
3616
+ ),
1359
3617
  });
1360
3618
  }
3619
+ const catalogRecords = [...records.values()];
1361
3620
  return {
1362
- records: [...records.values()],
1363
- diagnostics: persisted.diagnostics.filter((diagnostic) => !this.active.has(diagnostic.id)),
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,
1364
3630
  };
1365
3631
  }
1366
3632
 
@@ -1369,7 +3635,7 @@ export class SubagentCoordinator {
1369
3635
  descriptor: SubagentDescriptor,
1370
3636
  byId: Map<string, CatalogRecord>,
1371
3637
  ): number | undefined {
1372
- let parentId = descriptor.parentSessionId;
3638
+ let parentId = descriptor.parentAgentId;
1373
3639
  let distance = 1;
1374
3640
  const visited = new Set<string>();
1375
3641
  while (true) {
@@ -1378,16 +3644,21 @@ export class SubagentCoordinator {
1378
3644
  visited.add(parentId);
1379
3645
  const parent = byId.get(parentId);
1380
3646
  if (!parent) return undefined;
1381
- parentId = parent.descriptor.parentSessionId;
3647
+ parentId = parent.descriptor.parentAgentId;
1382
3648
  distance++;
1383
3649
  }
1384
3650
  }
1385
3651
 
1386
3652
  private async isDescendantOf(parent: ParentRef, descriptor: SubagentDescriptor): Promise<boolean> {
1387
3653
  const { records } = await this.catalogRecords(parent);
1388
- const byId = new Map(records.map((record) => [record.id, record]));
1389
- return this.distanceFrom(parent.id, descriptor, byId) !== undefined;
3654
+ const byId = new Map(records.map((record) => [record.agentId, record]));
3655
+ return this.distanceFrom(parent.agentId, descriptor, byId) !== undefined;
1390
3656
  }
1391
3657
  }
1392
3658
 
1393
- export { REPORT_CUSTOM_TYPE, SETTLED_CUSTOM_TYPE };
3659
+ export {
3660
+ REPORT_CUSTOM_TYPE,
3661
+ SETTLED_CUSTOM_TYPE,
3662
+ AGENT_CUSTOM_TYPE,
3663
+ LINEAGE_CUSTOM_TYPE,
3664
+ };