@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.
package/src/descriptor.ts CHANGED
@@ -3,23 +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 = 1;
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}$/;
31
+ /** UUIDv7 with the standard RFC 9562 variant bits (version 7, variant 10xx). */
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
+ ]);
23
38
 
24
39
  type UnknownRecord = Record<string, unknown>;
25
40
 
@@ -46,6 +61,14 @@ function optionalString(value: unknown, field: string): string | undefined {
46
61
  return value === undefined ? undefined : string(value, field);
47
62
  }
48
63
 
64
+ function agentId(value: unknown, field: string): string {
65
+ const parsed = string(value, field);
66
+ if (!AGENT_ID_PATTERN.test(parsed)) {
67
+ throw new Error(`${field} must be a UUIDv7 agent id`);
68
+ }
69
+ return parsed;
70
+ }
71
+
49
72
  function limitedString(value: unknown, field: string, maxLength: number): string {
50
73
  const parsed = string(value, field);
51
74
  if (parsed.length > maxLength) throw new Error(`${field} exceeds ${maxLength} characters`);
@@ -87,7 +110,7 @@ function stringArray(value: unknown, field: string): string[] | undefined {
87
110
 
88
111
  function parseAgent(value: unknown): AgentSnapshot {
89
112
  const input = record(value, "agent");
90
- const source = string(input.source, "agent.source") as AgentSource;
113
+ const source = string(input.source, "agent.source") as AgentSnapshotSource;
91
114
  if (!AGENT_SOURCES.has(source)) throw new Error(`agent.source is unsupported: ${source}`);
92
115
  const thinking = optionalString(input.thinking, "agent.thinking") as ThinkingLevel | undefined;
93
116
  if (thinking && !THINKING_LEVELS.has(thinking)) throw new Error(`agent.thinking is unsupported: ${thinking}`);
@@ -106,11 +129,40 @@ function parseAgent(value: unknown): AgentSnapshot {
106
129
  };
107
130
  }
108
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
+
109
157
  export function parseDescriptor(value: unknown): SubagentDescriptor {
110
158
  const input = record(value, "descriptor");
111
- if (input.version !== DESCRIPTOR_VERSION) {
159
+ if (
160
+ input.version !== LEGACY_DESCRIPTOR_VERSION
161
+ && input.version !== DESCRIPTOR_VERSION
162
+ ) {
112
163
  throw new Error(`unsupported descriptor version: ${String(input.version)}`);
113
164
  }
165
+ const version = input.version;
114
166
  const mode = string(input.mode, "mode") as SubagentMode;
115
167
  if (!MODES.has(mode)) throw new Error(`unsupported descriptor mode: ${mode}`);
116
168
  const provider = string(input.provider, "provider") as SubagentProviderName;
@@ -126,16 +178,29 @@ export function parseDescriptor(value: unknown): SubagentDescriptor {
126
178
  if (!REPORT_DELIVERIES.has(reportDelivery)) {
127
179
  throw new Error(`unsupported runtime.reportDelivery: ${reportDelivery}`);
128
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
+ }
129
193
 
130
194
  const createdAt = string(input.createdAt, "createdAt");
131
195
  if (Number.isNaN(Date.parse(createdAt))) throw new Error("createdAt must be an ISO date string");
132
196
 
133
- return {
134
- version: DESCRIPTOR_VERSION,
197
+ const base = {
135
198
  mode,
136
199
  provider,
137
200
  label: limitedString(input.label, "label", 200),
138
- parentSessionId: string(input.parentSessionId, "parentSessionId"),
201
+ agentId: agentId(input.agentId, "agentId"),
202
+ parentAgentId: agentId(input.parentAgentId, "parentAgentId"),
203
+ parentPiSessionId: string(input.parentPiSessionId, "parentPiSessionId"),
139
204
  ...(optionalString(input.parentSessionFile, "parentSessionFile")
140
205
  ? { parentSessionFile: input.parentSessionFile as string }
141
206
  : {}),
@@ -150,18 +215,34 @@ export function parseDescriptor(value: unknown): SubagentDescriptor {
150
215
  thinkingLevel,
151
216
  runtime: {
152
217
  agentScope,
153
- syncBundledAgents:
154
- runtime.syncBundledAgents === undefined
155
- ? true
156
- : boolean(runtime.syncBundledAgents, "runtime.syncBundledAgents"),
157
218
  maxDepth: safeNatural(runtime.maxDepth, "runtime.maxDepth"),
158
219
  enableRunInBackground:
159
220
  runtime.enableRunInBackground === undefined
160
221
  ? true
161
222
  : boolean(runtime.enableRunInBackground, "runtime.enableRunInBackground"),
162
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,
163
243
  reportDelivery,
164
244
  inheritExtensions: boolean(runtime.inheritExtensions, "runtime.inheritExtensions"),
245
+ openAIIdentity: boolean(runtime.openAIIdentity, "runtime.openAIIdentity"),
165
246
  maxOutputBytes: boundedInteger(
166
247
  runtime.maxOutputBytes,
167
248
  "runtime.maxOutputBytes",
@@ -170,6 +251,22 @@ export function parseDescriptor(value: unknown): SubagentDescriptor {
170
251
  ),
171
252
  },
172
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
+ };
173
270
  }
174
271
 
175
272
  export function foldDescriptor(entries: readonly SessionEntry[]): DescriptorFold {
@@ -187,3 +284,12 @@ export function foldDescriptor(entries: readonly SessionEntry[]): DescriptorFold
187
284
  return { kind: "corrupt", message: error instanceof Error ? error.message : String(error) };
188
285
  }
189
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 child id; use send_message for later FIFO turns. " +
83
- "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 child id.";
86
- const promptGuidelines = !enableRunInBackground
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. ") +
87
+ "Start independent children together in one assistant message."
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. " +
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", id: 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", id: 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,9 +418,13 @@ export default function subagentExtension(pi: ExtensionAPI): void {
307
418
  : "foreground-first";
308
419
  const sections = [
309
420
  `Mode: ${schedulingMode}`,
310
- sessionSettings.syncBundledAgents
311
- ? `Bundled presets: synchronized to ${agentSync?.userAgentsDir ?? coordinator.getUserAgentsDir()}`
312
- : "Bundled presets: package defaults (no filesystem sync)",
421
+ `Background concurrency: ${sessionSettings.maxConcurrentBackgroundRuns}`,
422
+ `Idle runtime LRU: ${sessionSettings.maxIdleRuntimes}`,
423
+ `Background protocol: ${sessionSettings.backgroundProtocol ?? "legacy"}`,
424
+ `OpenAI identity inline: ${sessionSettings.openAIIdentity ? "enabled" : "disabled"}`,
425
+ agentSync?.diagnostics.length
426
+ ? "Bundled templates: initialization skipped (see diagnostics)"
427
+ : `Bundled templates: initialization only (${agentSync?.packageVersion ?? "not initialized"})`,
313
428
  `User agent dir: ${agentSync?.userAgentsDir ?? coordinator.getUserAgentsDir()}`,
314
429
  `Agents:\n${formatAgentCatalog(discovery.agents)}`,
315
430
  `Children:\n${coordinator.formatCatalog(entries, "descendants")}`,
@@ -323,10 +438,14 @@ export default function subagentExtension(pi: ExtensionAPI): void {
323
438
 
324
439
  pi.on("session_start", async (_event, ctx) => {
325
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
+ );
326
447
  sessionSettings = loaded.settings;
327
- agentSync = sessionSettings.syncBundledAgents
328
- ? coordinator.synchronizeBundledAgents()
329
- : undefined;
448
+ agentSync = coordinator.synchronizeBundledAgents();
330
449
  sessionDiscovery = coordinator.discoverAvailableAgents(
331
450
  ctx.cwd,
332
451
  sessionSettings,
@@ -347,6 +466,29 @@ export default function subagentExtension(pi: ExtensionAPI): void {
347
466
  }
348
467
  if (!sessionSettings.enableRunInBackground) {
349
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
+ }
350
492
  }
351
493
  if (!agentSyncNotified && agentSync) {
352
494
  agentSyncNotified = true;
@@ -377,6 +519,14 @@ export default function subagentExtension(pi: ExtensionAPI): void {
377
519
  }
378
520
  });
379
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
+
380
530
  pi.on("session_shutdown", async () => {
381
531
  await coordinator.shutdown();
382
532
  });