@oai404iao/pi-subagent 0.3.0 → 0.4.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/descriptor.ts CHANGED
@@ -3,25 +3,38 @@ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
3
3
  import type {
4
4
  AgentScope,
5
5
  AgentSnapshot,
6
- AgentSource,
6
+ AgentSnapshotSource,
7
+ BackgroundProtocol,
8
+ ContextInheritance,
7
9
  ReportDelivery,
8
10
  SubagentDescriptor,
9
11
  SubagentMode,
10
12
  SubagentProviderName,
11
13
  } from "./types.ts";
14
+ import { validateDescriptorTask } from "./task-path.ts";
12
15
 
13
16
  export const DESCRIPTOR_CUSTOM_TYPE = "pi-subagent/descriptor";
14
- export const DESCRIPTOR_VERSION = 2;
17
+ export const DESCRIPTOR_VERSION = 3;
18
+ export const LEGACY_DESCRIPTOR_VERSION = 2;
19
+ const LEGACY_MAX_CONCURRENT_BACKGROUND_RUNS = 4;
20
+ const LEGACY_MAX_IDLE_RUNTIMES = 0;
21
+ const LEGACY_BACKGROUND_PROTOCOL: BackgroundProtocol = "legacy";
15
22
 
16
23
  const THINKING_LEVELS = new Set<ThinkingLevel>(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
17
- const AGENT_SOURCES = new Set<AgentSource>(["bundled", "user", "project"]);
24
+ const AGENT_SOURCES = new Set<AgentSnapshotSource>(["bundled", "user", "project"]);
18
25
  const MODES = new Set<SubagentMode>(["one-shot", "continuable"]);
19
26
  const PROVIDERS = new Set<SubagentProviderName>(["spawn", "fork"]);
20
27
  const REPORT_DELIVERIES = new Set<ReportDelivery>(["wakeup", "quiet"]);
28
+ const BACKGROUND_PROTOCOLS = new Set<BackgroundProtocol>(["legacy", "mailbox-v2"]);
21
29
  const AGENT_SCOPES = new Set<AgentScope>(["user", "project", "both"]);
22
30
  const AGENT_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
23
31
  /** UUIDv7 with the standard RFC 9562 variant bits (version 7, variant 10xx). */
24
32
  const AGENT_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
33
+ const CONTEXT_MODES = new Set<ContextInheritance["mode"]>([
34
+ "fresh",
35
+ "all_completed",
36
+ "last_n_completed",
37
+ ]);
25
38
 
26
39
  type UnknownRecord = Record<string, unknown>;
27
40
 
@@ -97,7 +110,7 @@ function stringArray(value: unknown, field: string): string[] | undefined {
97
110
 
98
111
  function parseAgent(value: unknown): AgentSnapshot {
99
112
  const input = record(value, "agent");
100
- const source = string(input.source, "agent.source") as AgentSource;
113
+ const source = string(input.source, "agent.source") as AgentSnapshotSource;
101
114
  if (!AGENT_SOURCES.has(source)) throw new Error(`agent.source is unsupported: ${source}`);
102
115
  const thinking = optionalString(input.thinking, "agent.thinking") as ThinkingLevel | undefined;
103
116
  if (thinking && !THINKING_LEVELS.has(thinking)) throw new Error(`agent.thinking is unsupported: ${thinking}`);
@@ -116,11 +129,40 @@ function parseAgent(value: unknown): AgentSnapshot {
116
129
  };
117
130
  }
118
131
 
132
+ function parseContext(value: unknown): ContextInheritance {
133
+ const input = record(value, "context");
134
+ const mode = string(input.mode, "context.mode") as ContextInheritance["mode"];
135
+ if (!CONTEXT_MODES.has(mode)) {
136
+ throw new Error(`unsupported context.mode: ${mode}`);
137
+ }
138
+ if (mode === "last_n_completed") {
139
+ return {
140
+ mode,
141
+ completedTurns: boundedInteger(
142
+ input.completedTurns,
143
+ "context.completedTurns",
144
+ 1,
145
+ 100,
146
+ ),
147
+ };
148
+ }
149
+ if (input.completedTurns !== undefined) {
150
+ throw new Error(
151
+ "context.completedTurns is available only for last_n_completed",
152
+ );
153
+ }
154
+ return { mode };
155
+ }
156
+
119
157
  export function parseDescriptor(value: unknown): SubagentDescriptor {
120
158
  const input = record(value, "descriptor");
121
- if (input.version !== DESCRIPTOR_VERSION) {
159
+ if (
160
+ input.version !== LEGACY_DESCRIPTOR_VERSION
161
+ && input.version !== DESCRIPTOR_VERSION
162
+ ) {
122
163
  throw new Error(`unsupported descriptor version: ${String(input.version)}`);
123
164
  }
165
+ const version = input.version;
124
166
  const mode = string(input.mode, "mode") as SubagentMode;
125
167
  if (!MODES.has(mode)) throw new Error(`unsupported descriptor mode: ${mode}`);
126
168
  const provider = string(input.provider, "provider") as SubagentProviderName;
@@ -136,12 +178,23 @@ export function parseDescriptor(value: unknown): SubagentDescriptor {
136
178
  if (!REPORT_DELIVERIES.has(reportDelivery)) {
137
179
  throw new Error(`unsupported runtime.reportDelivery: ${reportDelivery}`);
138
180
  }
181
+ const backgroundProtocol =
182
+ runtime.backgroundProtocol === undefined
183
+ ? LEGACY_BACKGROUND_PROTOCOL
184
+ : string(runtime.backgroundProtocol, "runtime.backgroundProtocol") as BackgroundProtocol;
185
+ if (!BACKGROUND_PROTOCOLS.has(backgroundProtocol)) {
186
+ throw new Error(`unsupported runtime.backgroundProtocol: ${backgroundProtocol}`);
187
+ }
188
+ if (runtime.syncBundledAgents !== undefined) {
189
+ // Validate, then discard the retired 0.2/0.3 runtime switch. Keeping this
190
+ // read compatibility allows persisted children to cold-resume.
191
+ boolean(runtime.syncBundledAgents, "runtime.syncBundledAgents");
192
+ }
139
193
 
140
194
  const createdAt = string(input.createdAt, "createdAt");
141
195
  if (Number.isNaN(Date.parse(createdAt))) throw new Error("createdAt must be an ISO date string");
142
196
 
143
- return {
144
- version: DESCRIPTOR_VERSION,
197
+ const base = {
145
198
  mode,
146
199
  provider,
147
200
  label: limitedString(input.label, "label", 200),
@@ -162,16 +215,31 @@ export function parseDescriptor(value: unknown): SubagentDescriptor {
162
215
  thinkingLevel,
163
216
  runtime: {
164
217
  agentScope,
165
- syncBundledAgents:
166
- runtime.syncBundledAgents === undefined
167
- ? true
168
- : boolean(runtime.syncBundledAgents, "runtime.syncBundledAgents"),
169
218
  maxDepth: safeNatural(runtime.maxDepth, "runtime.maxDepth"),
170
219
  enableRunInBackground:
171
220
  runtime.enableRunInBackground === undefined
172
221
  ? true
173
222
  : boolean(runtime.enableRunInBackground, "runtime.enableRunInBackground"),
174
223
  defaultBackground: boolean(runtime.defaultBackground, "runtime.defaultBackground"),
224
+ maxConcurrentBackgroundRuns:
225
+ runtime.maxConcurrentBackgroundRuns === undefined
226
+ ? LEGACY_MAX_CONCURRENT_BACKGROUND_RUNS
227
+ : boundedInteger(
228
+ runtime.maxConcurrentBackgroundRuns,
229
+ "runtime.maxConcurrentBackgroundRuns",
230
+ 1,
231
+ Number.MAX_SAFE_INTEGER,
232
+ ),
233
+ maxIdleRuntimes:
234
+ runtime.maxIdleRuntimes === undefined
235
+ ? LEGACY_MAX_IDLE_RUNTIMES
236
+ : boundedInteger(
237
+ runtime.maxIdleRuntimes,
238
+ "runtime.maxIdleRuntimes",
239
+ 0,
240
+ Number.MAX_SAFE_INTEGER,
241
+ ),
242
+ backgroundProtocol,
175
243
  reportDelivery,
176
244
  inheritExtensions: boolean(runtime.inheritExtensions, "runtime.inheritExtensions"),
177
245
  openAIIdentity: boolean(runtime.openAIIdentity, "runtime.openAIIdentity"),
@@ -183,6 +251,22 @@ export function parseDescriptor(value: unknown): SubagentDescriptor {
183
251
  ),
184
252
  },
185
253
  };
254
+ if (version === LEGACY_DESCRIPTOR_VERSION) {
255
+ return {
256
+ version: LEGACY_DESCRIPTOR_VERSION,
257
+ ...base,
258
+ };
259
+ }
260
+ const task = record(input.task, "task");
261
+ return {
262
+ version: DESCRIPTOR_VERSION,
263
+ ...base,
264
+ task: validateDescriptorTask({
265
+ name: string(task.name, "task.name"),
266
+ path: string(task.path, "task.path"),
267
+ }),
268
+ context: parseContext(input.context),
269
+ };
186
270
  }
187
271
 
188
272
  export function foldDescriptor(entries: readonly SessionEntry[]): DescriptorFold {
@@ -200,3 +284,12 @@ export function foldDescriptor(entries: readonly SessionEntry[]): DescriptorFold
200
284
  return { kind: "corrupt", message: error instanceof Error ? error.message : String(error) };
201
285
  }
202
286
  }
287
+
288
+ export function descriptorContext(
289
+ descriptor: SubagentDescriptor,
290
+ ): ContextInheritance {
291
+ if (descriptor.version === 3) return structuredClone(descriptor.context);
292
+ return descriptor.provider === "fork"
293
+ ? { mode: "all_completed" }
294
+ : { mode: "fresh" };
295
+ }
package/src/index.ts CHANGED
@@ -14,9 +14,11 @@ import {
14
14
  } from "./agents.ts";
15
15
  import type { AgentSyncResult } from "./agent-sync.ts";
16
16
  import {
17
+ FollowupTaskParameters,
17
18
  InterruptParameters,
18
19
  ListAgentsParameters,
19
20
  SendMessageParameters,
21
+ WaitAgentParameters,
20
22
  delegationParameters,
21
23
  forkDelegationParameters,
22
24
  } from "./schemas.ts";
@@ -74,16 +76,18 @@ function registerDelegationTool(
74
76
  const { enableRunInBackground, defaultBackground } = settings;
75
77
  const agentNames = agentDiscovery?.agents.map((agent) => agent.name);
76
78
  const description = !enableRunInBackground
77
- ? "Delegate a complete standalone task to a fresh child with its own Pi session and context. " +
79
+ ? "Delegate a complete task to a named child path with selectable completed-turn context. " +
78
80
  "This foreground-only tool waits for the child and returns its final answer. " +
79
81
  "Independent sibling calls may still execute in parallel."
80
82
  : defaultBackground
81
- ? "Delegate a complete standalone task to a fresh child with its own Pi session and context. " +
82
- "Background mode is continuable and returns a durable agent id; use send_message for later FIFO turns. " +
83
+ ? "Delegate a complete task to a named child path with selectable completed-turn context. " +
84
+ (settings.backgroundProtocol === "mailbox-v2"
85
+ ? "Background mode is continuable and returns a readable path plus durable id; use send_message to enqueue, followup_task to start, and wait_agent for quiet completions. "
86
+ : "Background mode is continuable and returns a readable path plus durable id; use send_message for later FIFO turns. ") +
83
87
  "Start independent children together in one assistant message."
84
- : "Delegate a complete standalone task to a fresh child with its own Pi session and context. " +
85
- "This tool waits for the result by default; set run_in_background to true to return a durable agent id.";
86
- const promptGuidelines = !enableRunInBackground
88
+ : "Delegate a complete task to a named child path with selectable completed-turn context. " +
89
+ "This tool waits for the result by default; set run_in_background to true to return a readable path plus durable id.";
90
+ const promptGuidelines = (!enableRunInBackground
87
91
  ? [
88
92
  "Use subagent for focused independent work and give it a complete standalone prompt.",
89
93
  "This subagent tool is foreground-only: every call waits for and returns the child's final answer.",
@@ -99,14 +103,17 @@ function registerDelegationTool(
99
103
  "Use subagent for focused independent work and give it a complete standalone prompt.",
100
104
  "Subagent calls wait for the result by default; request background mode only when work can continue independently.",
101
105
  "Independent subagent calls can still be issued together in one assistant message and execute in parallel.",
102
- ];
106
+ ]).concat([
107
+ "Set task_name when a stable readable path will help later control calls; otherwise a name is generated from description.",
108
+ "Use fresh context by default and request all_completed or last_n_completed only when parent history materially helps.",
109
+ ]);
103
110
  const parameters = delegationParameters(enableRunInBackground, agentNames);
104
111
  pi.registerTool({
105
112
  name: "subagent",
106
113
  label: "Subagent",
107
114
  description,
108
115
  promptSnippet: enableRunInBackground
109
- ? "Delegate focused independent work to fresh child agents"
116
+ ? "Delegate focused work to named child agents"
110
117
  : "Run focused independent work in foreground child agents",
111
118
  promptGuidelines,
112
119
  executionMode: "parallel",
@@ -150,13 +157,16 @@ function registerForkDelegationTool(
150
157
  agentDiscovery?: AgentDiscoveryResult,
151
158
  ): unknown {
152
159
  const agentNames = agentDiscovery?.agents.map((agent) => agent.name);
153
- const parameters = forkDelegationParameters(agentNames);
160
+ const parameters = forkDelegationParameters(
161
+ agentNames,
162
+ settings.enableRunInBackground,
163
+ );
154
164
  pi.registerTool({
155
165
  name: "subagent_fork",
156
166
  label: "Subagent Fork",
157
167
  description:
158
- "Delegate a one-shot task to a child seeded with all completed turns in this conversation. " +
159
- "The current in-flight tool-calling turn is excluded. Use this when the child needs parent history.",
168
+ "Delegate a task to a child seeded with all completed turns in this conversation. " +
169
+ "The current in-flight tool-calling turn is excluded. It is foreground by default; request background mode for a continuable fork.",
160
170
  promptSnippet: "Delegate context-dependent work to a child seeded with completed turns",
161
171
  promptGuidelines: [
162
172
  "Use subagent_fork only when completed conversation history materially helps the delegated task.",
@@ -206,6 +216,8 @@ export default function subagentExtension(pi: ExtensionAPI): void {
206
216
  registerForkDelegationTool(pi, coordinator, DEFAULT_SETTINGS);
207
217
  const backgroundControlParameters = new Map<string, unknown>([
208
218
  ["send_message", SendMessageParameters],
219
+ ["followup_task", FollowupTaskParameters],
220
+ ["wait_agent", WaitAgentParameters],
209
221
  ["interrupt_agent", InterruptParameters],
210
222
  ["list_agents", ListAgentsParameters],
211
223
  ]);
@@ -214,23 +226,114 @@ export default function subagentExtension(pi: ExtensionAPI): void {
214
226
  name: "send_message",
215
227
  label: "Send Message",
216
228
  description:
217
- "Queue a message as a direct continuable child's next FIFO turn. If it is inactive, its persisted session is cold-resumed by agent id. " +
229
+ "Send a message to a direct continuable child. Legacy children start or join a FIFO turn; mailbox-v2 children only durably enqueue it and require followup_task to execute. " +
218
230
  "This call returns acceptance only, never the child's answer.",
219
- promptSnippet: "Send a later FIFO turn to a direct continuable subagent",
231
+ promptSnippet: "Send or enqueue a message for a direct continuable subagent",
220
232
  executionMode: "parallel",
221
233
  parameters: SendMessageParameters,
222
234
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
223
235
  assertBackgroundControlsEnabled(sessionSettings, "send_message");
224
236
  const parent = await coordinator.parentFromContext(ctx);
225
- await coordinator.sendMessage(parent, params.subagent_id, params.message, signal);
237
+ const delivery = await coordinator.sendMessageWithOutcome(
238
+ parent,
239
+ params.subagent_id,
240
+ params.message,
241
+ signal,
242
+ );
226
243
  return {
227
244
  content: [
228
245
  {
229
246
  type: "text",
230
- text: `message queued as the next turn for subagent ${params.subagent_id}`,
247
+ text:
248
+ delivery.kind === "mailbox-v2"
249
+ ? `message ${delivery.messageId} durably enqueued for ${delivery.taskPath}; ${delivery.pendingMessages} pending`
250
+ : `message queued as the next turn for ${delivery.taskPath}`,
231
251
  },
232
252
  ],
233
- details: { kind: "control", action: "send", agentId: params.subagent_id } satisfies ControlDetails,
253
+ details: {
254
+ kind: "control",
255
+ action: "send",
256
+ agentId: delivery.agentId,
257
+ taskPath: delivery.taskPath,
258
+ ...(delivery.kind === "mailbox-v2"
259
+ ? {
260
+ messageId: delivery.messageId,
261
+ pendingMessages: delivery.pendingMessages,
262
+ }
263
+ : {}),
264
+ } satisfies ControlDetails,
265
+ };
266
+ },
267
+ });
268
+
269
+ pi.registerTool({
270
+ name: "followup_task",
271
+ label: "Follow-up Task",
272
+ description:
273
+ "For a direct mailbox-v2 continuable child, atomically claim its current pending FIFO mailbox and start exactly one scheduled turn. " +
274
+ "This call returns turn acceptance, not the child's answer.",
275
+ promptSnippet: "Start one mailbox-v2 child turn from queued messages",
276
+ executionMode: "parallel",
277
+ parameters: FollowupTaskParameters,
278
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
279
+ assertBackgroundControlsEnabled(sessionSettings, "followup_task");
280
+ const parent = await coordinator.parentFromContext(ctx);
281
+ const outcome = await coordinator.followupTask(
282
+ parent,
283
+ params.subagent_id,
284
+ signal,
285
+ );
286
+ return {
287
+ content: [
288
+ {
289
+ type: "text",
290
+ text: `started turn ${outcome.turnId} for ${outcome.taskPath}, claiming ${outcome.claimedMessages} mailbox message${outcome.claimedMessages === 1 ? "" : "s"}`,
291
+ },
292
+ ],
293
+ details: {
294
+ kind: "control",
295
+ action: "followup",
296
+ agentId: outcome.agentId,
297
+ taskPath: outcome.taskPath,
298
+ turnId: outcome.turnId,
299
+ claimedMessages: outcome.claimedMessages,
300
+ } satisfies ControlDetails,
301
+ };
302
+ },
303
+ });
304
+
305
+ pi.registerTool({
306
+ name: "wait_agent",
307
+ label: "Wait Agent",
308
+ description:
309
+ "Wait event-driven for unread completion updates from direct mailbox-v2 children. Existing updates return immediately; timeout does not consume later updates.",
310
+ promptSnippet: "Wait for quiet mailbox-v2 child completion updates",
311
+ parameters: WaitAgentParameters,
312
+ async execute(toolCallId, params, signal, _onUpdate, ctx) {
313
+ assertBackgroundControlsEnabled(sessionSettings, "wait_agent");
314
+ const parent = await coordinator.parentFromContext(ctx);
315
+ const outcome = await coordinator.waitAgent(
316
+ parent,
317
+ toolCallId,
318
+ params.timeout_ms,
319
+ signal,
320
+ );
321
+ return {
322
+ content: [
323
+ {
324
+ type: "text",
325
+ text: coordinator.formatWaitAgentOutcome(outcome),
326
+ },
327
+ ],
328
+ details: {
329
+ kind: "control",
330
+ action: "wait",
331
+ timedOut: outcome.timedOut,
332
+ completionIds: outcome.updates.map(
333
+ (update) => update.completionId,
334
+ ),
335
+ unreadUpdates: outcome.unreadUpdates,
336
+ } satisfies ControlDetails,
234
337
  };
235
338
  },
236
339
  });
@@ -247,10 +350,18 @@ export default function subagentExtension(pi: ExtensionAPI): void {
247
350
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
248
351
  assertBackgroundControlsEnabled(sessionSettings, "interrupt_agent");
249
352
  const parent = await coordinator.parentFromContext(ctx);
250
- await coordinator.interrupt(parent, params.agent_id);
353
+ const outcome = await coordinator.interruptWithOutcome(
354
+ parent,
355
+ params.agent_id,
356
+ );
251
357
  return {
252
- content: [{ type: "text", text: `interrupt requested for agent ${params.agent_id}` }],
253
- details: { kind: "control", action: "interrupt", agentId: params.agent_id } satisfies ControlDetails,
358
+ content: [{ type: "text", text: `interrupt requested for ${outcome.taskPath}` }],
359
+ details: {
360
+ kind: "control",
361
+ action: "interrupt",
362
+ agentId: outcome.agentId,
363
+ taskPath: outcome.taskPath,
364
+ } satisfies ControlDetails,
254
365
  };
255
366
  },
256
367
  });
@@ -260,7 +371,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
260
371
  label: "List Agents",
261
372
  description:
262
373
  "List direct continuable children or all descendants. running means an active turn, idle means resident between turns, " +
263
- "and ready means persisted and cold-resumable.",
374
+ "ready means persisted and cold-resumable, and mailbox-v2 children show pending task messages separately from unread completion updates.",
264
375
  promptSnippet: "List continuable child agents and their lifecycle status",
265
376
  parameters: ListAgentsParameters,
266
377
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -307,10 +418,13 @@ export default function subagentExtension(pi: ExtensionAPI): void {
307
418
  : "foreground-first";
308
419
  const sections = [
309
420
  `Mode: ${schedulingMode}`,
421
+ `Background concurrency: ${sessionSettings.maxConcurrentBackgroundRuns}`,
422
+ `Idle runtime LRU: ${sessionSettings.maxIdleRuntimes}`,
423
+ `Background protocol: ${sessionSettings.backgroundProtocol ?? "legacy"}`,
310
424
  `OpenAI identity inline: ${sessionSettings.openAIIdentity ? "enabled" : "disabled"}`,
311
- sessionSettings.syncBundledAgents
312
- ? `Bundled presets: synchronized to ${agentSync?.userAgentsDir ?? coordinator.getUserAgentsDir()}`
313
- : "Bundled presets: package defaults (no filesystem sync)",
425
+ agentSync?.diagnostics.length
426
+ ? "Bundled templates: initialization skipped (see diagnostics)"
427
+ : `Bundled templates: initialization only (${agentSync?.packageVersion ?? "not initialized"})`,
314
428
  `User agent dir: ${agentSync?.userAgentsDir ?? coordinator.getUserAgentsDir()}`,
315
429
  `Agents:\n${formatAgentCatalog(discovery.agents)}`,
316
430
  `Children:\n${coordinator.formatCatalog(entries, "descendants")}`,
@@ -324,10 +438,14 @@ export default function subagentExtension(pi: ExtensionAPI): void {
324
438
 
325
439
  pi.on("session_start", async (_event, ctx) => {
326
440
  const loaded = loadSettings({ cwd: ctx.cwd, projectTrusted: ctx.isProjectTrusted() });
441
+ coordinator.configureBackgroundRuns(
442
+ loaded.settings.maxConcurrentBackgroundRuns,
443
+ );
444
+ await coordinator.configureIdleRuntimes(
445
+ loaded.settings.maxIdleRuntimes,
446
+ );
327
447
  sessionSettings = loaded.settings;
328
- agentSync = sessionSettings.syncBundledAgents
329
- ? coordinator.synchronizeBundledAgents()
330
- : undefined;
448
+ agentSync = coordinator.synchronizeBundledAgents();
331
449
  sessionDiscovery = coordinator.discoverAvailableAgents(
332
450
  ctx.cwd,
333
451
  sessionSettings,
@@ -348,6 +466,29 @@ export default function subagentExtension(pi: ExtensionAPI): void {
348
466
  }
349
467
  if (!sessionSettings.enableRunInBackground) {
350
468
  disableOwnedTools(pi, backgroundControlParameters);
469
+ } else if (sessionSettings.backgroundProtocol !== "mailbox-v2") {
470
+ let hasMailboxChild = true;
471
+ try {
472
+ const parent = await coordinator.parentFromContext(ctx);
473
+ const entries = await coordinator.list(parent, "descendants");
474
+ hasMailboxChild = entries.some(
475
+ (entry) =>
476
+ entry.kind === "child"
477
+ && entry.descriptor.runtime.backgroundProtocol === "mailbox-v2",
478
+ );
479
+ } catch {
480
+ // Keep the control available when catalog inspection fails so a
481
+ // transient diagnostic cannot strand an existing mailbox child.
482
+ }
483
+ if (!hasMailboxChild) {
484
+ disableOwnedTools(
485
+ pi,
486
+ new Map<string, unknown>([
487
+ ["followup_task", FollowupTaskParameters],
488
+ ["wait_agent", WaitAgentParameters],
489
+ ]),
490
+ );
491
+ }
351
492
  }
352
493
  if (!agentSyncNotified && agentSync) {
353
494
  agentSyncNotified = true;
@@ -378,6 +519,14 @@ export default function subagentExtension(pi: ExtensionAPI): void {
378
519
  }
379
520
  });
380
521
 
522
+ pi.on("agent_end", async (_event, ctx) => {
523
+ const parent = await coordinator.parentFromContext(ctx);
524
+ await coordinator.releaseWaitAgentDeliveries(
525
+ parent,
526
+ "parent agent turn ended without a durable wait_agent result",
527
+ );
528
+ });
529
+
381
530
  pi.on("session_shutdown", async () => {
382
531
  await coordinator.shutdown();
383
532
  });