@narumitw/pi-subagents 0.25.0 → 0.27.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/stateful.ts CHANGED
@@ -1,21 +1,37 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import * as path from "node:path";
3
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
3
  import { StringEnum } from "@earendil-works/pi-ai";
4
+ import {
5
+ defineTool,
6
+ type ExtensionAPI,
7
+ type ExtensionContext,
8
+ } from "@earendil-works/pi-coding-agent";
5
9
  import { Type } from "typebox";
6
- import { discoverAgents, type AgentScope, isThinkingLevel } from "./agents.js";
10
+ import {
11
+ type AgentScope,
12
+ type CompletionDelivery,
13
+ discoverAgents,
14
+ isThinkingLevel,
15
+ THINKING_LEVELS,
16
+ } from "./agents.js";
7
17
  import { buildContextSnapshot, type ContextMode, redactPrivateText } from "./context.js";
8
18
  import { assertSubagentDepthAllowed } from "./execution.js";
9
- import { DEFAULT_MAX_CONTEXT_BYTES, truncateUtf8 } from "./limits.js";
10
- import { AgentPersistence } from "./persistence.js";
11
- import { AgentRegistry, type AgentTurnCompletion, type ManagedAgent } from "./registry.js";
12
- import { readSubagentSettings } from "./settings.js";
13
- import { SubprocessTransport } from "./subprocess-transport.js";
14
19
  import {
15
20
  type ChildSessionFactory,
16
21
  InProcessTransport,
17
22
  type ParentRuntimeSnapshot,
18
23
  } from "./in-process-transport.js";
24
+ import { DEFAULT_MAX_CONTEXT_BYTES, truncateUtf8 } from "./limits.js";
25
+ import { AgentPersistence } from "./persistence.js";
26
+ import { AgentRegistry, type AgentTurnCompletion, type ManagedAgent } from "./registry.js";
27
+ import { readSubagentSettings } from "./settings.js";
28
+ import {
29
+ MailboxParamsSchema,
30
+ ManageParamsSchema,
31
+ validateMailboxParams,
32
+ validateManageParams,
33
+ } from "./stateful-tool-params.js";
34
+ import { SubprocessTransport } from "./subprocess-transport.js";
19
35
  import { WorkspaceManager } from "./workspace.js";
20
36
 
21
37
  const ContextModeSchema = Type.Union([
@@ -27,37 +43,141 @@ const ScopeSchema = StringEnum(["user", "project", "both"] as const, {
27
43
  'Per-invocation custom agent scope for this spawn. Default: "user". Use "project" for project-local agents or "both" for user and project agents; the selected scope is retained for follow-ups.',
28
44
  default: "user",
29
45
  });
46
+ const StatefulThinkingLevelSchema = StringEnum(THINKING_LEVELS, {
47
+ description:
48
+ "Optional requested Pi thinking level selected for this task difficulty; retained for every turn of the spawned agent.",
49
+ });
30
50
  const MAX_TOOL_MESSAGE_BYTES = 2 * 1024;
31
51
  const MAX_COMPLETION_ERROR_BYTES = 512;
52
+ const MAX_COMPLETIONS_PER_MESSAGE = 16;
53
+ const COMPLETION_BATCH_DELAY_MS = 10;
54
+
55
+ function createSpawnPromptGuidelines(completionDelivery: CompletionDelivery): string[] {
56
+ const deliveryGuidance =
57
+ completionDelivery === "auto-resume"
58
+ ? "With subagent_spawn completion delivery set to auto-resume, prefer one subagent_spawn for broad asynchronous research or review that covers related branches even when the final answer depends on its result; do not choose blocking parallel fan-out merely to keep delegation in the same turn."
59
+ : "With subagent_spawn completion delivery set to next-turn (the default), prefer one subagent_spawn for broad asynchronous research or review only when the current response does not depend on its result; use the blocking subagent when the final answer depends on the detached result.";
60
+ const noLocalWorkGuidance =
61
+ completionDelivery === "auto-resume"
62
+ ? "After subagent_spawn returns, do useful non-overlapping local work immediately. If none remains, briefly tell the user what subagent_spawn launched and end the response; auto-resume will request a synthesis turn after completion."
63
+ : "After subagent_spawn returns, do useful non-overlapping local work immediately. If none remains, briefly tell the user what subagent_spawn launched and end the response only when the current response does not depend on its result; next-turn delivery will not wake an idle root.";
64
+ return [
65
+ "Do not use subagent_spawn for simple or critical-path work that the main agent can perform directly.",
66
+ "Set subagent_spawn thinkingLevel to the lowest sufficient thinking level for the delegated task: use off or minimal for extraction, formatting, or mechanical work; low for straightforward bounded work; medium for ordinary multi-step research or implementation; high for complex debugging, design, review, or cross-file analysis; xhigh for highly ambiguous, cross-system, or high-risk analysis; and max only for the hardest tasks when quality clearly outweighs latency and cost. Omit subagent_spawn thinkingLevel only to preserve the agent or child default.",
67
+ deliveryGuidance,
68
+ "Use a single subagent_spawn only for a concrete bounded subtask that can run independently and has an isolation or specialization benefit such as independent review, bounded context/output, a distinct model/tool profile, or workspace isolation.",
69
+ "Use the blocking subagent instead of subagent_spawn when synchronous output is required before the main agent can continue and waiting is intentional; queued steering cannot be processed until that blocking call returns.",
70
+ "When subagent_spawn fits the completion-delivery policy, do not choose a blocking parallel subagent merely to keep delegation in the same turn.",
71
+ "Add another subagent_spawn only for truly independent work with safe workspace concurrency.",
72
+ noLocalWorkGuidance,
73
+ 'Consume and synthesize available subagent_spawn completion messages; use subagent_manage with action "interrupt" or "close" for agents that are no longer needed.',
74
+ 'Completion from subagent_spawn is delivered automatically. Do not poll with subagent_manage action "list" or subagent_mailbox action "read", repeatedly check progress, or duplicate the delegated work.',
75
+ ];
76
+ }
32
77
 
33
78
  export interface StatefulSubagentDependencies {
34
79
  createInProcessSession?: ChildSessionFactory;
80
+ workspaceManager?: WorkspaceManager;
81
+ }
82
+
83
+ export interface StatefulSubagentRuntimeStatus {
84
+ enabled: boolean;
85
+ initialized: boolean;
86
+ transport: "subprocess" | "in-process";
87
+ completionDelivery: CompletionDelivery;
88
+ activeAgents: number;
89
+ retainedAgents: number;
90
+ }
91
+
92
+ export interface StatefulSubagentController {
93
+ getCompletionDelivery(): CompletionDelivery;
94
+ setCompletionDelivery(value: CompletionDelivery): void;
95
+ getRuntimeStatus(): StatefulSubagentRuntimeStatus;
96
+ listAgents(includeClosed?: boolean): ManagedAgent[];
97
+ clearAgents(): Promise<number>;
98
+ }
99
+
100
+ interface StatefulActionToolResult {
101
+ content: Array<{ type: "text"; text: string }>;
102
+ details: Record<string, unknown>;
35
103
  }
36
104
 
37
105
  export function registerStatefulSubagents(
38
106
  pi: ExtensionAPI,
39
107
  dependencies: StatefulSubagentDependencies = {},
40
- ): void {
108
+ ): StatefulSubagentController {
41
109
  const settings = readSubagentSettings()?.stateful ?? {};
42
- if (settings.enabled === false) return;
43
-
110
+ const enabled = settings.enabled !== false;
111
+ const transportKind = resolveStatefulTransportKind(settings.transport);
112
+ let completionDelivery = resolveCompletionDelivery(settings.completionDelivery);
113
+ let completionBroker: CompletionDeliveryBroker | undefined;
114
+ let refreshSpawnToolRegistration: (() => void) | undefined;
44
115
  let registry: AgentRegistry | undefined;
45
116
  let persistence: AgentPersistence | undefined;
46
117
  let sweepTimer: NodeJS.Timeout | undefined;
47
118
  let runtimeGeneration = 0;
48
- const workspaceManager = new WorkspaceManager();
119
+ const workspaceManager = dependencies.workspaceManager ?? new WorkspaceManager();
49
120
  const isolatedAgents = new Map<string, string>();
50
121
  const seenMessageIds = new Set<string>();
51
122
  const parentRuntime: ParentRuntimeSnapshot = { model: undefined, thinkingLevel: "off" };
52
- const completionWaiters = new Map<string, number>();
123
+
124
+ const clearAgents = async (): Promise<number> => {
125
+ const currentRegistry = registry;
126
+ if (!currentRegistry) return 0;
127
+ const count = currentRegistry.list().length;
128
+ try {
129
+ await currentRegistry.closeAll();
130
+ } finally {
131
+ await workspaceManager.cleanupAll();
132
+ isolatedAgents.clear();
133
+ }
134
+ seenMessageIds.clear();
135
+ await persistence?.delete();
136
+ return count;
137
+ };
138
+ const controller: StatefulSubagentController = {
139
+ getCompletionDelivery() {
140
+ return completionDelivery;
141
+ },
142
+ setCompletionDelivery(value) {
143
+ completionDelivery = value;
144
+ completionBroker?.setDelivery(value);
145
+ refreshSpawnToolRegistration?.();
146
+ },
147
+ getRuntimeStatus() {
148
+ const agents = registry?.list(true) ?? [];
149
+ return {
150
+ enabled,
151
+ initialized: registry !== undefined,
152
+ transport: transportKind,
153
+ completionDelivery,
154
+ activeAgents: agents.filter(
155
+ (agent) => agent.state === "starting" || agent.state === "running",
156
+ ).length,
157
+ retainedAgents: agents.filter((agent) => agent.state !== "closed").length,
158
+ };
159
+ },
160
+ listAgents(includeClosed = false) {
161
+ return registry?.list(includeClosed) ?? [];
162
+ },
163
+ clearAgents,
164
+ };
165
+ if (!enabled) return controller;
53
166
 
54
167
  const requireRegistry = () => {
55
168
  if (!registry) throw new Error("Stateful subagents are not initialized for this session");
56
169
  return registry;
57
170
  };
171
+ const requireAgent = (agentId: string) => {
172
+ const agent = requireRegistry().get(agentId);
173
+ if (!agent) throw new Error(`Unknown subagent: ${agentId}`);
174
+ return agent;
175
+ };
58
176
 
59
177
  pi.on("session_start", async (_event, ctx) => {
60
178
  const generation = ++runtimeGeneration;
179
+ completionBroker?.close();
180
+ completionBroker = undefined;
61
181
  parentRuntime.model = ctx.model;
62
182
  parentRuntime.thinkingLevel = normalizeRuntimeThinkingLevel(pi.getThinkingLevel());
63
183
  const owner =
@@ -69,14 +189,21 @@ export function registerStatefulSubagents(
69
189
  maxStoredAgents: settings.maxStoredAgents,
70
190
  });
71
191
  persistence = sessionPersistence;
192
+ completionBroker = new CompletionDeliveryBroker(pi, ctx, completionDelivery, {
193
+ onDeliveryError: (error) => {
194
+ if (!ctx.hasUI) return;
195
+ const reason = error instanceof Error ? error.message : String(error);
196
+ ctx.ui.notify(`Subagent completion delivery failed: ${reason}`, "warning");
197
+ },
198
+ });
72
199
  const transport =
73
- resolveStatefulTransportKind(settings.transport) === "in-process"
200
+ transportKind === "in-process"
74
201
  ? new InProcessTransport({
75
202
  modelRegistry: ctx.modelRegistry,
76
203
  getParentRuntime: () => ({ ...parentRuntime }),
77
204
  createSession: dependencies.createInProcessSession,
78
205
  })
79
- : new SubprocessTransport(ctx);
206
+ : new SubprocessTransport();
80
207
  registry = new AgentRegistry(transport, {
81
208
  maxAgents: settings.maxAgents,
82
209
  maxActiveTurns: settings.maxActiveTurns,
@@ -102,9 +229,7 @@ export function registerStatefulSubagents(
102
229
  },
103
230
  onTurnComplete: (completion) => {
104
231
  if (generation !== runtimeGeneration) return;
105
- if (!completionWaiters.has(completion.agent.id)) {
106
- sendDetachedCompletion(pi, completion);
107
- }
232
+ completionBroker?.enqueue(completion);
108
233
  },
109
234
  });
110
235
  const restored = sessionPersistence
@@ -128,6 +253,14 @@ export function registerStatefulSubagents(
128
253
  sweepTimer.unref();
129
254
  });
130
255
 
256
+ pi.on("agent_start", () => {
257
+ completionBroker?.onParentTurnStart();
258
+ });
259
+
260
+ pi.on("agent_settled", () => {
261
+ completionBroker?.onParentSettled();
262
+ });
263
+
131
264
  pi.on("model_select", (event) => {
132
265
  parentRuntime.model = event.model;
133
266
  });
@@ -138,6 +271,8 @@ export function registerStatefulSubagents(
138
271
 
139
272
  pi.on("session_shutdown", async (_event, ctx) => {
140
273
  runtimeGeneration++;
274
+ completionBroker?.close();
275
+ completionBroker = undefined;
141
276
  if (sweepTimer) clearInterval(sweepTimer);
142
277
  sweepTimer = undefined;
143
278
  for (const agentId of isolatedAgents.keys()) {
@@ -145,39 +280,35 @@ export function registerStatefulSubagents(
145
280
  }
146
281
  isolatedAgents.clear();
147
282
  seenMessageIds.clear();
148
- completionWaiters.clear();
149
283
  let cleanupError: unknown;
150
284
  try {
151
285
  await workspaceManager.cleanupAll();
152
286
  } catch (error) {
153
287
  cleanupError = error;
154
288
  }
155
- await registry?.shutdown();
156
- registry = undefined;
157
- persistence = undefined;
289
+ try {
290
+ await registry?.shutdown();
291
+ } finally {
292
+ registry = undefined;
293
+ persistence = undefined;
294
+ }
158
295
  if (cleanupError && ctx.hasUI) {
159
296
  const reason = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
160
297
  ctx.ui.notify(`Some isolated subagent workspaces could not be removed: ${reason}`, "warning");
161
298
  }
162
299
  });
163
300
 
164
- pi.registerTool({
301
+ const spawnTool = defineTool({
165
302
  name: "subagent_spawn",
166
303
  label: "Spawn Subagent",
167
304
  description:
168
- "Start an addressable background subagent, return immediately with an agentId, and receive its completion asynchronously.",
305
+ "Start an addressable background subagent with an optional thinking level chosen for the task difficulty, return immediately with an agentId, and receive its completion asynchronously.",
169
306
  promptSnippet: "Start a reusable detached subagent; completion is delivered asynchronously",
170
- promptGuidelines: [
171
- "Do not use subagent_spawn for simple or critical-path work that the main agent can perform directly.",
172
- "Use a single subagent_spawn only for a concrete bounded subtask that can run independently alongside useful main-agent work and has an isolation or specialization benefit such as independent review, bounded context/output, a distinct model/tool profile, or workspace isolation.",
173
- "Use one blocking subagent parallel call for multiple independent one-shot tasks; do not use repeated subagent_spawn calls when no reuse or overlap is needed.",
174
- "After subagent_spawn returns, do useful non-overlapping local work immediately. Call subagent_wait sparingly, only when the immediate next critical-path step requires the result and is blocked until it arrives; do not wait repeatedly by reflex.",
175
- "Consume and synthesize available subagent_spawn completion messages; interrupt or close agents that are no longer needed.",
176
- "subagent_spawn completion is delivered automatically. Do not poll, wait forever, or spawn additional agents without a distinct need.",
177
- ],
307
+ promptGuidelines: createSpawnPromptGuidelines(completionDelivery),
178
308
  parameters: Type.Object({
179
309
  agent: Type.String({ minLength: 1 }),
180
310
  task: Type.String({ minLength: 1, maxLength: DEFAULT_MAX_CONTEXT_BYTES }),
311
+ thinkingLevel: Type.Optional(StatefulThinkingLevelSchema),
181
312
  cwd: Type.Optional(Type.String()),
182
313
  agentScope: Type.Optional(ScopeSchema),
183
314
  confirmProjectAgents: Type.Optional(Type.Boolean({ default: true })),
@@ -229,6 +360,7 @@ export function registerStatefulSubagents(
229
360
  task: params.task,
230
361
  cwd: workspace?.path ?? requestedCwd,
231
362
  agentScope: scope,
363
+ thinkingLevel: params.thinkingLevel,
232
364
  parentId: params.parentId,
233
365
  context: snapshot.text || undefined,
234
366
  contextSourceIds: snapshot.sourceIds,
@@ -239,17 +371,28 @@ export function registerStatefulSubagents(
239
371
  throw error;
240
372
  }
241
373
  if (workspace) isolatedAgents.set(agent.id, workspaceOwner);
374
+ const deliveryNote =
375
+ completionDelivery === "auto-resume"
376
+ ? "If no useful local work remains, briefly tell the user what was launched and end the response; auto-resume will request synthesis after completion."
377
+ : "End the response without the result only when the current response does not depend on it; next-turn delivery will not wake an idle root.";
242
378
  return result(
243
379
  agent,
244
- `Spawned ${agent.agent} as ${agent.id}. Do useful non-overlapping work immediately. Completion will arrive asynchronously; call subagent_wait only if the immediate next critical-path step is blocked on this result.`,
380
+ `Spawned ${agent.agent} as ${agent.id}. Do useful non-overlapping work immediately. ${deliveryNote} Do not poll for progress.`,
245
381
  );
246
382
  },
247
383
  });
384
+ refreshSpawnToolRegistration = () => {
385
+ spawnTool.promptGuidelines = createSpawnPromptGuidelines(completionDelivery);
386
+ pi.registerTool(spawnTool);
387
+ };
388
+ refreshSpawnToolRegistration();
248
389
 
249
390
  pi.registerTool({
250
391
  name: "subagent_send",
251
392
  label: "Send Subagent Follow-up",
252
- description: "Send a follow-up task to an idle, completed, interrupted, or failed subagent.",
393
+ description:
394
+ "Send follow-up work to an idle, completed, interrupted, or failed subagent and start a new turn. Use subagent_mailbox for queue-only messages.",
395
+ promptSnippet: "Start a new detached follow-up turn on a retained subagent",
253
396
  parameters: Type.Object({
254
397
  agentId: Type.String(),
255
398
  task: Type.String({ minLength: 1, maxLength: DEFAULT_MAX_CONTEXT_BYTES }),
@@ -279,166 +422,66 @@ export function registerStatefulSubagents(
279
422
  });
280
423
 
281
424
  pi.registerTool({
282
- name: "subagent_message",
283
- label: "Message Subagent",
284
- description: "Queue a bounded mailbox message without starting a turn.",
285
- parameters: Type.Object({
286
- agentId: Type.String(),
287
- message: Type.String({ minLength: 1, maxLength: 16 * 1024 }),
288
- senderId: Type.Optional(Type.String()),
289
- deduplicationKey: Type.Optional(Type.String({ maxLength: 256 })),
290
- }),
291
- async execute(_id, params) {
292
- const message = await requireRegistry().sendMessage(
293
- params.agentId,
294
- params.message,
295
- params.senderId,
296
- params.deduplicationKey,
297
- );
298
- return {
299
- content: [{ type: "text", text: `Queued ${message.id} for ${message.recipientId}.` }],
300
- details: { message },
301
- };
302
- },
303
- });
304
-
305
- pi.registerTool({
306
- name: "subagent_messages",
307
- label: "Read Subagent Messages",
308
- description: "Read unread mailbox messages and optionally acknowledge them.",
309
- parameters: Type.Object({
310
- agentId: Type.String(),
311
- acknowledge: Type.Optional(Type.Boolean({ default: true })),
312
- limit: Type.Optional(Type.Number({ minimum: 1, maximum: 20, default: 20 })),
313
- }),
314
- async execute(_id, params) {
315
- const messages = await requireRegistry().readMessages(
316
- params.agentId,
317
- params.acknowledge,
318
- params.limit,
319
- );
320
- const summaries = messages.map((message) => ({
321
- ...message,
322
- content: truncateUtf8(message.content, MAX_TOOL_MESSAGE_BYTES).text,
323
- }));
324
- const text = summaries.length
325
- ? summaries
326
- .map((message) => `${message.id} from ${message.senderId}: ${message.content}`)
327
- .join("\n")
328
- : "No unread messages.";
329
- return {
330
- content: [{ type: "text", text: truncateUtf8(text, DEFAULT_MAX_CONTEXT_BYTES).text }],
331
- details: { messages: summaries },
332
- };
333
- },
334
- });
335
-
336
- pi.registerTool({
337
- name: "subagent_wait",
338
- label: "Wait for Subagent",
425
+ name: "subagent_manage",
426
+ label: "Manage Subagents",
339
427
  description:
340
- "Wait for a stateful subagent turn without terminating it when the wait times out. Use sparingly, only when the immediate next critical-path step is blocked on this result.",
341
- parameters: Type.Object({
342
- agentId: Type.String(),
343
- timeoutMs: Type.Optional(Type.Number({ minimum: 1, maximum: 3_600_000, default: 30_000 })),
344
- }),
345
- async execute(_id, params, signal) {
346
- const existing = requireRegistry().get(params.agentId);
347
- const registered = existing?.state === "starting" || existing?.state === "running";
348
- if (registered) {
349
- completionWaiters.set(params.agentId, (completionWaiters.get(params.agentId) ?? 0) + 1);
350
- }
351
- try {
352
- const waited = await requireRegistry().wait(params.agentId, params.timeoutMs, signal);
353
- return result(
354
- waited.agent,
355
- waited.timedOut
356
- ? `Wait timed out; ${waited.agent.id} is ${waited.agent.state}.`
357
- : formatFinal(waited.agent),
358
- );
359
- } finally {
360
- if (registered) decrementWaiter(completionWaiters, params.agentId);
361
- }
362
- },
363
- });
364
-
365
- pi.registerTool({
366
- name: "subagent_list",
367
- label: "List Subagents",
368
- description: "List stateful subagents and lifecycle states.",
369
- parameters: Type.Object({ includeClosed: Type.Optional(Type.Boolean({ default: false })) }),
370
- async execute(_id, params) {
371
- const agents = requireRegistry().list(params.includeClosed);
372
- return {
373
- content: [
374
- {
375
- type: "text",
376
- text: agents.length ? agents.map(formatLine).join("\n") : "No stateful subagents.",
377
- },
378
- ],
379
- details: { agents: agents.map(summarizeAgent) },
380
- };
381
- },
382
- });
383
-
384
- pi.registerTool({
385
- name: "subagent_interrupt",
386
- label: "Interrupt Subagent",
387
- description: "Interrupt the current turn while retaining the subagent for follow-up work.",
388
- parameters: Type.Object({
389
- agentId: Type.String(),
390
- subtree: Type.Optional(Type.Boolean({ default: false })),
391
- }),
392
- async execute(_id, params) {
393
- if (params.subtree) {
394
- const agents = await requireRegistry().interruptTree(params.agentId);
428
+ "List retained subagents, interrupt active work while keeping an agent reusable, or close agents and release their resources.",
429
+ promptSnippet: "List or control retained detached subagents",
430
+ parameters: ManageParamsSchema,
431
+ async execute(_id, params): Promise<StatefulActionToolResult> {
432
+ const operation = validateManageParams(params);
433
+ if (operation.action === "list") {
434
+ const agents = requireRegistry().list(operation.includeClosed);
395
435
  return {
396
- content: [{ type: "text", text: `Interrupted ${agents.length} active agent(s).` }],
397
- details: {
398
- agent: summarizeAgent(requireRegistry().get(params.agentId)!),
399
- agents: agents.map(summarizeAgent),
400
- },
436
+ content: [
437
+ {
438
+ type: "text",
439
+ text: agents.length ? agents.map(formatLine).join("\n") : "No stateful subagents.",
440
+ },
441
+ ],
442
+ details: { agents: agents.map(summarizeAgent) },
401
443
  };
402
444
  }
403
- const agent = await requireRegistry().interrupt(params.agentId);
404
- return result(agent, `Interrupted ${agent.id}; it remains reusable.`);
405
- },
406
- });
407
-
408
- pi.registerTool({
409
- name: "subagent_close",
410
- label: "Close Subagent",
411
- description: "Close a stateful subagent and remove it from retained persistence.",
412
- parameters: Type.Object({
413
- agentId: Type.String(),
414
- subtree: Type.Optional(Type.Boolean({ default: false })),
415
- }),
416
- async execute(_id, params) {
417
- const existing = requireRegistry().get(params.agentId);
418
- if (existing?.state === "closed" && !params.subtree) {
445
+ const agentId = operation.agentId;
446
+ if (operation.action === "interrupt") {
447
+ if (operation.subtree) {
448
+ const agents = await requireRegistry().interruptTree(agentId);
449
+ return {
450
+ content: [{ type: "text", text: `Interrupted ${agents.length} active agent(s).` }],
451
+ details: {
452
+ agent: summarizeAgent(requireAgent(agentId)),
453
+ agents: agents.map(summarizeAgent),
454
+ },
455
+ };
456
+ }
457
+ const agent = await requireRegistry().interrupt(agentId);
458
+ return result(agent, `Interrupted ${agent.id}; it remains reusable.`);
459
+ }
460
+ const existing = requireRegistry().get(agentId);
461
+ if (existing?.state === "closed" && !operation.subtree) {
419
462
  const pendingOwner = isolatedAgents.get(existing.id);
420
463
  if (pendingOwner) await workspaceManager.cleanup(pendingOwner);
421
464
  isolatedAgents.delete(existing.id);
422
465
  return result(existing, `Closed ${existing.id}.`);
423
466
  }
424
- if (params.subtree) {
467
+ if (operation.subtree) {
425
468
  let agents: ManagedAgent[];
426
469
  try {
427
- agents = await requireRegistry().closeTree(params.agentId);
470
+ agents = await requireRegistry().closeTree(agentId);
428
471
  } finally {
429
472
  await cleanupClosedWorkspaces(requireRegistry(), isolatedAgents, workspaceManager);
430
473
  }
431
474
  return {
432
475
  content: [{ type: "text", text: `Closed ${agents.length} agent(s).` }],
433
476
  details: {
434
- agent: summarizeAgent(requireRegistry().get(params.agentId)!),
477
+ agent: summarizeAgent(requireAgent(agentId)),
435
478
  agents: agents.map(summarizeAgent),
436
479
  },
437
480
  };
438
481
  }
439
482
  let agent: ManagedAgent;
440
483
  try {
441
- agent = await requireRegistry().close(params.agentId);
484
+ agent = await requireRegistry().close(agentId);
442
485
  } finally {
443
486
  await cleanupClosedWorkspaces(requireRegistry(), isolatedAgents, workspaceManager);
444
487
  }
@@ -446,33 +489,82 @@ export function registerStatefulSubagents(
446
489
  },
447
490
  });
448
491
 
492
+ pi.registerTool({
493
+ name: "subagent_mailbox",
494
+ label: "Subagent Mailbox",
495
+ description:
496
+ "Queue a bounded message without starting a turn, or read unread mailbox messages and optionally acknowledge them.",
497
+ promptSnippet: "Send or read queue-only detached-subagent mailbox messages",
498
+ parameters: MailboxParamsSchema,
499
+ async execute(_id, params): Promise<StatefulActionToolResult> {
500
+ const operation = validateMailboxParams(params);
501
+ if (operation.action === "send") {
502
+ const message = await requireRegistry().sendMessage(
503
+ operation.agentId,
504
+ operation.message,
505
+ operation.senderId,
506
+ operation.deduplicationKey,
507
+ );
508
+ return {
509
+ content: [{ type: "text", text: `Queued ${message.id} for ${message.recipientId}.` }],
510
+ details: { message },
511
+ };
512
+ }
513
+ const messages = await requireRegistry().readMessages(
514
+ operation.agentId,
515
+ operation.acknowledge,
516
+ operation.limit,
517
+ );
518
+ const summaries = messages.map((message) => ({
519
+ ...message,
520
+ content: truncateUtf8(message.content, MAX_TOOL_MESSAGE_BYTES).text,
521
+ }));
522
+ const text = summaries.length
523
+ ? summaries
524
+ .map((message) => `${message.id} from ${message.senderId}: ${message.content}`)
525
+ .join("\n")
526
+ : "No unread messages.";
527
+ return {
528
+ content: [{ type: "text", text: truncateUtf8(text, DEFAULT_MAX_CONTEXT_BYTES).text }],
529
+ details: { messages: summaries },
530
+ };
531
+ },
532
+ });
533
+
449
534
  pi.registerCommand("subagents:agents", {
450
- description: "Inspect or clear stateful subagents",
535
+ description: "Inspect or clear current-session subagents",
451
536
  getArgumentCompletions(prefix: string) {
452
537
  return ["list", "clear"]
453
538
  .filter((value) => value.startsWith(prefix))
454
539
  .map((value) => ({ value, label: value }));
455
540
  },
456
541
  async handler(args, ctx) {
457
- if (args.trim() === "clear") {
458
- try {
459
- await requireRegistry().closeAll();
460
- } finally {
461
- await workspaceManager.cleanupAll();
462
- isolatedAgents.clear();
463
- }
464
- seenMessageIds.clear();
465
- await persistence?.delete();
466
- ctx.ui.notify("Cleared stateful subagents.", "info");
542
+ const subcommand = args.trim().toLowerCase() || "list";
543
+ if (subcommand === "clear") {
544
+ const count = await controller.clearAgents();
545
+ ctx.ui.notify(
546
+ count > 0
547
+ ? `Cleared ${count} current-session subagent${count === 1 ? "" : "s"}.`
548
+ : statefulEmptyMessage(controller.getRuntimeStatus()),
549
+ "info",
550
+ );
467
551
  return;
468
552
  }
469
- const agents = requireRegistry().list(true);
553
+ if (subcommand !== "list") {
554
+ ctx.ui.notify(`Unknown /subagents:agents subcommand: ${subcommand}`, "warning");
555
+ return;
556
+ }
557
+ const agents = controller.listAgents(true);
470
558
  ctx.ui.notify(
471
- agents.length ? agents.map(formatLine).join("\n") : "No stateful subagents.",
559
+ agents.length
560
+ ? agents.map(formatLine).join("\n")
561
+ : statefulEmptyMessage(controller.getRuntimeStatus()),
472
562
  "info",
473
563
  );
474
564
  },
475
565
  });
566
+
567
+ return controller;
476
568
  }
477
569
 
478
570
  export function assertNoSharedWriteConflict(
@@ -495,7 +587,7 @@ export function assertNoSharedWriteConflict(
495
587
  if (isWriteCapable(activeConfig?.tools)) {
496
588
  throw new Error(
497
589
  `Write-capable subagent ${active.id} is already active in shared workspace ${cwd}. ` +
498
- "For independent one-shot work, use subagent parallel mode. Otherwise wait or close the active agent; set allowConcurrentWrites only when overlapping writes are knowingly safe, or use workspaceMode worktree when repository isolation is needed.",
590
+ "Prefer one subagent_spawn covering combined asynchronous work. Use the blocking subagent parallel mode only when concurrent synchronous outputs justify making the main agent unavailable. Otherwise let the active agent finish or close it; set allowConcurrentWrites only when overlapping writes are knowingly safe, or use workspaceMode worktree when repository isolation is needed.",
499
591
  );
500
592
  }
501
593
  }
@@ -516,12 +608,6 @@ export function isWriteCapable(tools: string[] | undefined): boolean {
516
608
  return tools.some((tool) => ["bash", "write", "edit"].includes(tool));
517
609
  }
518
610
 
519
- function decrementWaiter(waiters: Map<string, number>, agentId: string): void {
520
- const count = waiters.get(agentId);
521
- if (count === undefined || count <= 1) waiters.delete(agentId);
522
- else waiters.set(agentId, count - 1);
523
- }
524
-
525
611
  async function confirmProjectAgent(
526
612
  name: string,
527
613
  scope: AgentScope,
@@ -566,23 +652,40 @@ export function resolveSpawnContextMode(
566
652
  return normalizeContextMode(value);
567
653
  }
568
654
 
569
- function formatLine(agent: ManagedAgent): string {
655
+ function statefulEmptyMessage(status: StatefulSubagentRuntimeStatus): string {
656
+ if (!status.enabled) return "Stateful subagents are disabled in user settings.";
657
+ if (!status.initialized) return "Stateful subagents are not initialized for this session.";
658
+ return "No current-session subagents.";
659
+ }
660
+
661
+ export function formatStatefulAgentLine(agent: ManagedAgent): string {
570
662
  const elapsedSeconds = Math.max(0, Math.floor((Date.now() - agent.updatedAt) / 1000));
571
663
  const actions =
572
664
  agent.state === "running" || agent.state === "starting"
573
- ? "wait, interrupt, close"
665
+ ? "interrupt, close"
574
666
  : agent.state === "closed"
575
667
  ? "inspect"
576
668
  : "send, close";
577
- const task = agent.currentTask ? ` — ${agent.currentTask.slice(0, 80)}` : "";
669
+ const task = agent.currentTask ? ` — ${sanitizeStatusLine(agent.currentTask, 80)}` : "";
578
670
  const unread = agent.mailbox.filter((message) => !message.readAt).length;
579
671
  const indent = " ".repeat(agent.depth);
580
- return `${indent}${agent.id} ${agent.agent} ${agent.state} ${elapsedSeconds}s unread:${unread} [${actions}]${task}`;
672
+ const thinking = agent.thinkingLevel ? ` thinking:${agent.thinkingLevel}` : "";
673
+ return `${indent}${sanitizeStatusLine(agent.id, 128)} ${sanitizeStatusLine(agent.agent, 128)} ${agent.state} ${elapsedSeconds}s${thinking} unread:${unread} [${actions}]${task}`;
674
+ }
675
+
676
+ function sanitizeStatusLine(value: string, maxLength: number): string {
677
+ return (
678
+ value
679
+ .slice(0, maxLength)
680
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: Escape untrusted terminal controls.
681
+ .replace(/[\u0000-\u001f\u007f-\u009f]/gu, " ")
682
+ .replace(/\s+/gu, " ")
683
+ .trim()
684
+ );
581
685
  }
582
686
 
583
- function formatFinal(agent: ManagedAgent): string {
584
- const last = agent.history.at(-1);
585
- return last?.output || agent.error || `${agent.id} is ${agent.state}.`;
687
+ function formatLine(agent: ManagedAgent): string {
688
+ return formatStatefulAgentLine(agent);
586
689
  }
587
690
 
588
691
  function summarizeAgent(agent: ManagedAgent) {
@@ -597,6 +700,7 @@ function summarizeAgent(agent: ManagedAgent) {
597
700
  createdAt: agent.createdAt,
598
701
  updatedAt: agent.updatedAt,
599
702
  cwd: agent.cwd,
703
+ thinkingLevel: agent.thinkingLevel,
600
704
  currentTask: agent.currentTask
601
705
  ? truncateUtf8(agent.currentTask, MAX_TOOL_MESSAGE_BYTES).text
602
706
  : undefined,
@@ -607,21 +711,188 @@ function summarizeAgent(agent: ManagedAgent) {
607
711
  };
608
712
  }
609
713
 
610
- function sendDetachedCompletion(pi: ExtensionAPI, completion: AgentTurnCompletion): void {
611
- const content = buildDetachedCompletionMessage(completion);
612
- pi.sendMessage(
613
- {
714
+ interface CompletionMetadata {
715
+ agentId: string;
716
+ agent: string;
717
+ state: string;
718
+ }
719
+
720
+ interface CompletionMessage {
721
+ customType: "pi-subagent-completion";
722
+ content: string;
723
+ display: true;
724
+ details:
725
+ | CompletionMetadata
726
+ | {
727
+ completionCount: number;
728
+ completions: CompletionMetadata[];
729
+ };
730
+ }
731
+
732
+ type CompletionContext = Pick<ExtensionContext, "hasPendingMessages" | "isIdle">;
733
+ type CompletionPi = Pick<ExtensionAPI, "sendMessage">;
734
+
735
+ export interface CompletionDeliveryBrokerOptions {
736
+ onDeliveryError?: (error: unknown) => void;
737
+ }
738
+
739
+ /**
740
+ * Coalesces detached completions so one bounded notification batch starts at
741
+ * most one root synthesis turn. The broker belongs to one parent session and
742
+ * must be closed when that session is replaced or shut down.
743
+ */
744
+ export class CompletionDeliveryBroker {
745
+ private pending: AgentTurnCompletion[] = [];
746
+ private flushTimer?: NodeJS.Timeout;
747
+ private wakeInFlight = false;
748
+ private closed = false;
749
+
750
+ constructor(
751
+ private readonly pi: CompletionPi,
752
+ private readonly ctx: CompletionContext,
753
+ private delivery: CompletionDelivery,
754
+ private readonly options: CompletionDeliveryBrokerOptions = {},
755
+ ) {}
756
+
757
+ enqueue(completion: AgentTurnCompletion): void {
758
+ if (this.closed) return;
759
+ this.pending.push(completion);
760
+ this.scheduleFlush();
761
+ }
762
+
763
+ setDelivery(value: CompletionDelivery): void {
764
+ this.delivery = value;
765
+ this.scheduleFlush();
766
+ }
767
+
768
+ onParentTurnStart(): void {
769
+ this.wakeInFlight = false;
770
+ this.scheduleFlush();
771
+ }
772
+
773
+ onParentSettled(): void {
774
+ this.wakeInFlight = false;
775
+ this.scheduleFlush();
776
+ }
777
+
778
+ flush(): void {
779
+ if (this.closed || this.pending.length === 0) return;
780
+ if (this.flushTimer) clearTimeout(this.flushTimer);
781
+ this.flushTimer = undefined;
782
+ if (this.delivery === "auto-resume" && !this.isRootIdle()) return;
783
+
784
+ const completions = this.pending.splice(0);
785
+ const batches = chunkCompletions(completions);
786
+ let canWake = this.shouldWakeRoot();
787
+ for (let index = 0; index < batches.length; index++) {
788
+ const triggerTurn = canWake && index === batches.length - 1;
789
+ const message = buildCompletionMessage(batches[index]);
790
+ if (triggerTurn) this.wakeInFlight = true;
791
+ try {
792
+ this.pi.sendMessage(message, { deliverAs: "steer", triggerTurn });
793
+ } catch (primaryError) {
794
+ if (triggerTurn) this.wakeInFlight = false;
795
+ canWake = false;
796
+ try {
797
+ this.pi.sendMessage(message, { deliverAs: "nextTurn", triggerTurn: false });
798
+ } catch (fallbackError) {
799
+ this.pending = [...batches.slice(index).flat(), ...this.pending];
800
+ try {
801
+ this.options.onDeliveryError?.(
802
+ new AggregateError(
803
+ [primaryError, fallbackError],
804
+ "Detached subagent completion delivery failed",
805
+ ),
806
+ );
807
+ } catch {
808
+ // Delivery retention must survive a failing observer.
809
+ }
810
+ return;
811
+ }
812
+ }
813
+ }
814
+ }
815
+
816
+ close(): void {
817
+ this.closed = true;
818
+ if (this.flushTimer) clearTimeout(this.flushTimer);
819
+ this.flushTimer = undefined;
820
+ this.pending = [];
821
+ }
822
+
823
+ private scheduleFlush(): void {
824
+ if (this.closed || this.pending.length === 0 || this.flushTimer) return;
825
+ this.flushTimer = setTimeout(() => {
826
+ this.flushTimer = undefined;
827
+ this.flush();
828
+ }, COMPLETION_BATCH_DELAY_MS);
829
+ }
830
+
831
+ private isRootIdle(): boolean {
832
+ try {
833
+ return this.ctx.isIdle();
834
+ } catch {
835
+ return false;
836
+ }
837
+ }
838
+
839
+ private shouldWakeRoot(): boolean {
840
+ if (this.delivery !== "auto-resume" || this.wakeInFlight) return false;
841
+ try {
842
+ return !this.ctx.hasPendingMessages();
843
+ } catch {
844
+ return false;
845
+ }
846
+ }
847
+ }
848
+
849
+ function chunkCompletions(completions: AgentTurnCompletion[]): AgentTurnCompletion[][] {
850
+ const batches: AgentTurnCompletion[][] = [];
851
+ for (let index = 0; index < completions.length; index += MAX_COMPLETIONS_PER_MESSAGE) {
852
+ batches.push(completions.slice(index, index + MAX_COMPLETIONS_PER_MESSAGE));
853
+ }
854
+ return batches;
855
+ }
856
+
857
+ function buildCompletionMessage(completions: AgentTurnCompletion[]): CompletionMessage {
858
+ if (completions.length === 1) {
859
+ const completion = completions[0];
860
+ return {
614
861
  customType: "pi-subagent-completion",
615
- content,
862
+ content: buildDetachedCompletionMessage(completion),
616
863
  display: true,
617
- details: {
618
- agentId: completion.agent.id,
619
- agent: completion.agent.agent,
620
- state: completion.agent.state,
621
- },
864
+ details: completionMetadata(completion),
865
+ };
866
+ }
867
+ const content = truncateUtf8(
868
+ [
869
+ "Message Type: SUBAGENT_COMPLETION_BATCH",
870
+ `Completion Count: ${completions.length}`,
871
+ ...completions.flatMap((completion, index) => [
872
+ "",
873
+ `--- Completion ${index + 1} of ${completions.length} ---`,
874
+ buildDetachedCompletionMessage(completion),
875
+ ]),
876
+ ].join("\n"),
877
+ DEFAULT_MAX_CONTEXT_BYTES,
878
+ ).text;
879
+ return {
880
+ customType: "pi-subagent-completion",
881
+ content,
882
+ display: true,
883
+ details: {
884
+ completionCount: completions.length,
885
+ completions: completions.map(completionMetadata),
622
886
  },
623
- { deliverAs: "steer", triggerTurn: false },
624
- );
887
+ };
888
+ }
889
+
890
+ function completionMetadata(completion: AgentTurnCompletion): CompletionMetadata {
891
+ return {
892
+ agentId: completion.agent.id,
893
+ agent: completion.agent.agent,
894
+ state: completion.agent.state,
895
+ };
625
896
  }
626
897
 
627
898
  export function buildDetachedCompletionMessage(completion: AgentTurnCompletion): string {
@@ -647,10 +918,13 @@ export function buildDetachedCompletionMessage(completion: AgentTurnCompletion):
647
918
  }
648
919
 
649
920
  function sanitizeCompletionLine(value: string, maxBytes: number): string {
650
- return truncateUtf8(redactPrivateText(value), maxBytes)
651
- .text.replace(/[\u0000-\u001f\u007f]+/g, " ")
652
- .replace(/\s+/g, " ")
653
- .trim();
921
+ return (
922
+ truncateUtf8(redactPrivateText(value), maxBytes)
923
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: Strip untrusted terminal controls.
924
+ .text.replace(/[\u0000-\u001f\u007f]+/g, " ")
925
+ .replace(/\s+/g, " ")
926
+ .trim()
927
+ );
654
928
  }
655
929
 
656
930
  async function cleanupClosedWorkspaces(
@@ -678,6 +952,12 @@ export function resolveStatefulTransportKind(
678
952
  return value ?? "subprocess";
679
953
  }
680
954
 
955
+ export function resolveCompletionDelivery(
956
+ value: CompletionDelivery | undefined,
957
+ ): CompletionDelivery {
958
+ return value ?? "next-turn";
959
+ }
960
+
681
961
  function normalizeRuntimeThinkingLevel(value: string): ParentRuntimeSnapshot["thinkingLevel"] {
682
962
  return isThinkingLevel(value) ? value : "off";
683
963
  }