@narumitw/pi-subagents 0.20.0 → 0.26.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,30 +1,31 @@
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 {
11
- ORCHESTRATION_MARKER_PREFIX,
12
- RootOrchestrationState,
13
- type OrchestrationRecoveryTicket,
14
- } from "./orchestration.js";
15
- import { AgentPersistence } from "./persistence.js";
16
- import {
17
- AgentRegistry,
18
- type AgentTurnCompletion,
19
- type ManagedAgent,
20
- } from "./registry.js";
21
- import { readSubagentSettings } from "./settings.js";
22
- import { SubprocessTransport } from "./subprocess-transport.js";
23
19
  import {
24
20
  type ChildSessionFactory,
25
21
  InProcessTransport,
26
22
  type ParentRuntimeSnapshot,
27
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 { SubprocessTransport } from "./subprocess-transport.js";
28
29
  import { WorkspaceManager } from "./workspace.js";
29
30
 
30
31
  const ContextModeSchema = Type.Union([
@@ -36,31 +37,121 @@ const ScopeSchema = StringEnum(["user", "project", "both"] as const, {
36
37
  '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.',
37
38
  default: "user",
38
39
  });
40
+ const StatefulThinkingLevelSchema = StringEnum(THINKING_LEVELS, {
41
+ description:
42
+ "Optional requested Pi thinking level selected for this task difficulty; retained for every turn of the spawned agent.",
43
+ });
39
44
  const MAX_TOOL_MESSAGE_BYTES = 2 * 1024;
40
45
  const MAX_COMPLETION_ERROR_BYTES = 512;
46
+ const MAX_COMPLETIONS_PER_MESSAGE = 16;
47
+ const COMPLETION_BATCH_DELAY_MS = 10;
48
+
49
+ function createSpawnPromptGuidelines(completionDelivery: CompletionDelivery): string[] {
50
+ const deliveryGuidance =
51
+ completionDelivery === "auto-resume"
52
+ ? "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."
53
+ : "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.";
54
+ const noLocalWorkGuidance =
55
+ completionDelivery === "auto-resume"
56
+ ? "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."
57
+ : "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.";
58
+ return [
59
+ "Do not use subagent_spawn for simple or critical-path work that the main agent can perform directly.",
60
+ "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.",
61
+ deliveryGuidance,
62
+ "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.",
63
+ "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.",
64
+ "When subagent_spawn fits the completion-delivery policy, do not choose a blocking parallel subagent merely to keep delegation in the same turn.",
65
+ "Add another subagent_spawn only for truly independent work with safe workspace concurrency.",
66
+ noLocalWorkGuidance,
67
+ "Consume and synthesize available subagent_spawn completion messages; interrupt or close agents that are no longer needed.",
68
+ "subagent_spawn completion is delivered automatically. Do not poll with subagent_list or subagent_messages, repeatedly check progress, or duplicate the delegated work.",
69
+ ];
70
+ }
41
71
 
42
72
  export interface StatefulSubagentDependencies {
43
73
  createInProcessSession?: ChildSessionFactory;
74
+ workspaceManager?: WorkspaceManager;
75
+ }
76
+
77
+ export interface StatefulSubagentRuntimeStatus {
78
+ enabled: boolean;
79
+ initialized: boolean;
80
+ transport: "subprocess" | "in-process";
81
+ completionDelivery: CompletionDelivery;
82
+ activeAgents: number;
83
+ retainedAgents: number;
84
+ }
85
+
86
+ export interface StatefulSubagentController {
87
+ getCompletionDelivery(): CompletionDelivery;
88
+ setCompletionDelivery(value: CompletionDelivery): void;
89
+ getRuntimeStatus(): StatefulSubagentRuntimeStatus;
90
+ listAgents(includeClosed?: boolean): ManagedAgent[];
91
+ clearAgents(): Promise<number>;
44
92
  }
45
93
 
46
94
  export function registerStatefulSubagents(
47
95
  pi: ExtensionAPI,
48
96
  dependencies: StatefulSubagentDependencies = {},
49
- ): void {
97
+ ): StatefulSubagentController {
50
98
  const settings = readSubagentSettings()?.stateful ?? {};
51
- if (settings.enabled === false) return;
52
-
99
+ const enabled = settings.enabled !== false;
100
+ const transportKind = resolveStatefulTransportKind(settings.transport);
101
+ let completionDelivery = resolveCompletionDelivery(settings.completionDelivery);
102
+ let completionBroker: CompletionDeliveryBroker | undefined;
103
+ let refreshSpawnToolRegistration: (() => void) | undefined;
53
104
  let registry: AgentRegistry | undefined;
54
105
  let persistence: AgentPersistence | undefined;
55
106
  let sweepTimer: NodeJS.Timeout | undefined;
56
107
  let runtimeGeneration = 0;
57
- const workspaceManager = new WorkspaceManager();
108
+ const workspaceManager = dependencies.workspaceManager ?? new WorkspaceManager();
58
109
  const isolatedAgents = new Map<string, string>();
59
110
  const seenMessageIds = new Set<string>();
60
111
  const parentRuntime: ParentRuntimeSnapshot = { model: undefined, thinkingLevel: "off" };
61
- const orchestration = new RootOrchestrationState();
62
- const cancelledRecoveryNonces = new Set<string>();
63
- const completionWaiters = new Map<string, number>();
112
+
113
+ const clearAgents = async (): Promise<number> => {
114
+ const currentRegistry = registry;
115
+ if (!currentRegistry) return 0;
116
+ const count = currentRegistry.list().length;
117
+ try {
118
+ await currentRegistry.closeAll();
119
+ } finally {
120
+ await workspaceManager.cleanupAll();
121
+ isolatedAgents.clear();
122
+ }
123
+ seenMessageIds.clear();
124
+ await persistence?.delete();
125
+ return count;
126
+ };
127
+ const controller: StatefulSubagentController = {
128
+ getCompletionDelivery() {
129
+ return completionDelivery;
130
+ },
131
+ setCompletionDelivery(value) {
132
+ completionDelivery = value;
133
+ completionBroker?.setDelivery(value);
134
+ refreshSpawnToolRegistration?.();
135
+ },
136
+ getRuntimeStatus() {
137
+ const agents = registry?.list(true) ?? [];
138
+ return {
139
+ enabled,
140
+ initialized: registry !== undefined,
141
+ transport: transportKind,
142
+ completionDelivery,
143
+ activeAgents: agents.filter(
144
+ (agent) => agent.state === "starting" || agent.state === "running",
145
+ ).length,
146
+ retainedAgents: agents.filter((agent) => agent.state !== "closed").length,
147
+ };
148
+ },
149
+ listAgents(includeClosed = false) {
150
+ return registry?.list(includeClosed) ?? [];
151
+ },
152
+ clearAgents,
153
+ };
154
+ if (!enabled) return controller;
64
155
 
65
156
  const requireRegistry = () => {
66
157
  if (!registry) throw new Error("Stateful subagents are not initialized for this session");
@@ -69,24 +160,34 @@ export function registerStatefulSubagents(
69
160
 
70
161
  pi.on("session_start", async (_event, ctx) => {
71
162
  const generation = ++runtimeGeneration;
72
- rememberCancelledRecovery(orchestration.supersedePending(), cancelledRecoveryNonces);
73
- orchestration.reset();
163
+ completionBroker?.close();
164
+ completionBroker = undefined;
74
165
  parentRuntime.model = ctx.model;
75
166
  parentRuntime.thinkingLevel = normalizeRuntimeThinkingLevel(pi.getThinkingLevel());
76
- const owner = ctx.sessionManager.getSessionId?.() ?? ctx.sessionManager.getSessionFile?.() ?? `ephemeral:${ctx.cwd}`;
167
+ const owner =
168
+ ctx.sessionManager.getSessionId?.() ??
169
+ ctx.sessionManager.getSessionFile?.() ??
170
+ `ephemeral:${ctx.cwd}`;
77
171
  const sessionPersistence = new AgentPersistence(owner, {
78
172
  retentionDays: settings.retentionDays,
79
173
  maxStoredAgents: settings.maxStoredAgents,
80
174
  });
81
175
  persistence = sessionPersistence;
176
+ completionBroker = new CompletionDeliveryBroker(pi, ctx, completionDelivery, {
177
+ onDeliveryError: (error) => {
178
+ if (!ctx.hasUI) return;
179
+ const reason = error instanceof Error ? error.message : String(error);
180
+ ctx.ui.notify(`Subagent completion delivery failed: ${reason}`, "warning");
181
+ },
182
+ });
82
183
  const transport =
83
- resolveStatefulTransportKind(settings.transport) === "in-process"
184
+ transportKind === "in-process"
84
185
  ? new InProcessTransport({
85
186
  modelRegistry: ctx.modelRegistry,
86
187
  getParentRuntime: () => ({ ...parentRuntime }),
87
188
  createSession: dependencies.createInProcessSession,
88
189
  })
89
- : new SubprocessTransport(ctx);
190
+ : new SubprocessTransport();
90
191
  registry = new AgentRegistry(transport, {
91
192
  maxAgents: settings.maxAgents,
92
193
  maxActiveTurns: settings.maxActiveTurns,
@@ -112,18 +213,14 @@ export function registerStatefulSubagents(
112
213
  },
113
214
  onTurnComplete: (completion) => {
114
215
  if (generation !== runtimeGeneration) return;
115
- orchestration.complete(completion.agent.id);
116
- if (!completionWaiters.has(completion.agent.id)) {
117
- sendDetachedCompletion(pi, completion);
118
- }
216
+ completionBroker?.enqueue(completion);
119
217
  },
120
218
  });
121
219
  const restored = sessionPersistence
122
220
  .load()
123
221
  .filter(
124
222
  (agent) =>
125
- (agent.agentScope !== "project" && agent.agentScope !== "both") ||
126
- ctx.isProjectTrusted(),
223
+ (agent.agentScope !== "project" && agent.agentScope !== "both") || ctx.isProjectTrusted(),
127
224
  );
128
225
  for (const agent of restored) {
129
226
  for (const message of agent.mailbox) seenMessageIds.add(message.id);
@@ -140,36 +237,12 @@ export function registerStatefulSubagents(
140
237
  sweepTimer.unref();
141
238
  });
142
239
 
143
- pi.on("input", (event) => {
144
- if (event.source === "extension") {
145
- const nonce = extractOrchestrationNonce(event.text);
146
- if (nonce && cancelledRecoveryNonces.delete(nonce)) return { action: "handled" as const };
147
- return;
148
- }
149
- rememberCancelledRecovery(orchestration.supersedePending(), cancelledRecoveryNonces);
150
- });
151
-
152
- pi.on("before_agent_start", () => {
153
- orchestration.beginTurn();
154
- });
155
-
156
- pi.on("before_provider_request", () => {
157
- orchestration.observeAvailable();
240
+ pi.on("agent_start", () => {
241
+ completionBroker?.onParentTurnStart();
158
242
  });
159
243
 
160
- pi.on("agent_end", (_event, ctx) => {
161
- const ticket = orchestration.endTurn();
162
- if (ticket && !hasPendingRootMessages(ctx)) queueOrchestrationFollowUp(pi, ctx, ticket);
163
- });
164
-
165
- const settledEvents = pi as unknown as {
166
- on(
167
- event: "agent_settled",
168
- handler: (event: unknown, ctx: ExtensionContext) => void,
169
- ): void;
170
- };
171
- settledEvents.on("agent_settled", (_event, ctx) => {
172
- dispatchOrchestrationRecovery(pi, ctx, orchestration);
244
+ pi.on("agent_settled", () => {
245
+ completionBroker?.onParentSettled();
173
246
  });
174
247
 
175
248
  pi.on("model_select", (event) => {
@@ -182,8 +255,8 @@ export function registerStatefulSubagents(
182
255
 
183
256
  pi.on("session_shutdown", async (_event, ctx) => {
184
257
  runtimeGeneration++;
185
- rememberCancelledRecovery(orchestration.supersedePending(), cancelledRecoveryNonces);
186
- orchestration.reset();
258
+ completionBroker?.close();
259
+ completionBroker = undefined;
187
260
  if (sweepTimer) clearInterval(sweepTimer);
188
261
  sweepTimer = undefined;
189
262
  for (const agentId of isolatedAgents.keys()) {
@@ -191,41 +264,35 @@ export function registerStatefulSubagents(
191
264
  }
192
265
  isolatedAgents.clear();
193
266
  seenMessageIds.clear();
194
- completionWaiters.clear();
195
267
  let cleanupError: unknown;
196
268
  try {
197
269
  await workspaceManager.cleanupAll();
198
270
  } catch (error) {
199
271
  cleanupError = error;
200
272
  }
201
- await registry?.shutdown();
202
- registry = undefined;
203
- persistence = undefined;
273
+ try {
274
+ await registry?.shutdown();
275
+ } finally {
276
+ registry = undefined;
277
+ persistence = undefined;
278
+ }
204
279
  if (cleanupError && ctx.hasUI) {
205
280
  const reason = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
206
- ctx.ui.notify(
207
- `Some isolated subagent workspaces could not be removed: ${reason}`,
208
- "warning",
209
- );
281
+ ctx.ui.notify(`Some isolated subagent workspaces could not be removed: ${reason}`, "warning");
210
282
  }
211
283
  });
212
284
 
213
- pi.registerTool({
285
+ const spawnTool = defineTool({
214
286
  name: "subagent_spawn",
215
287
  label: "Spawn Subagent",
216
- description: "Start an addressable background subagent, return immediately with an agentId, and receive its completion asynchronously.",
288
+ description:
289
+ "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.",
217
290
  promptSnippet: "Start a reusable detached subagent; completion is delivered asynchronously",
218
- promptGuidelines: [
219
- "Do not delegate simple or critical-path work that the main agent can perform directly.",
220
- "A single detached subagent is appropriate only for a concrete isolation or specialization benefit such as independent review, bounded context/output, a distinct model/tool profile, or workspace isolation.",
221
- "Use one blocking subagent parallel call for multiple independent one-shot tasks; do not use repeated detached spawns when no reuse or overlap is needed.",
222
- "After spawning, continue useful non-overlapping local work when available; otherwise call subagent_wait rather than yielding while delegated work remains unresolved.",
223
- "Consume available completion messages and synthesize their results before finishing; interrupt or close agents that are no longer needed.",
224
- "Detached completion is delivered automatically. Do not poll, wait forever, or spawn additional agents without a distinct need.",
225
- ],
291
+ promptGuidelines: createSpawnPromptGuidelines(completionDelivery),
226
292
  parameters: Type.Object({
227
293
  agent: Type.String({ minLength: 1 }),
228
294
  task: Type.String({ minLength: 1, maxLength: DEFAULT_MAX_CONTEXT_BYTES }),
295
+ thinkingLevel: Type.Optional(StatefulThinkingLevelSchema),
229
296
  cwd: Type.Optional(Type.String()),
230
297
  agentScope: Type.Optional(ScopeSchema),
231
298
  confirmProjectAgents: Type.Optional(Type.Boolean({ default: true })),
@@ -247,13 +314,7 @@ export function registerStatefulSubagents(
247
314
  const scope = (params.agentScope ?? "user") as AgentScope;
248
315
  assertSubagentDepthAllowed();
249
316
  const cwd = params.cwd ?? ctx.cwd;
250
- await confirmProjectAgent(
251
- params.agent,
252
- scope,
253
- params.confirmProjectAgents ?? true,
254
- ctx,
255
- cwd,
256
- );
317
+ await confirmProjectAgent(params.agent, scope, params.confirmProjectAgents ?? true, ctx, cwd);
257
318
  const resolvedAgent = discoverAgents(cwd, scope, readSubagentSettings()).agents.find(
258
319
  (agent) => agent.name === params.agent,
259
320
  );
@@ -269,12 +330,7 @@ export function registerStatefulSubagents(
269
330
  );
270
331
  const requestedCwd = cwd;
271
332
  if ((params.workspaceMode ?? "shared") === "shared" && !params.allowConcurrentWrites) {
272
- assertNoSharedWriteConflict(
273
- requireRegistry(),
274
- params.agent,
275
- requestedCwd,
276
- scope,
277
- );
333
+ assertNoSharedWriteConflict(requireRegistry(), params.agent, requestedCwd, scope);
278
334
  }
279
335
  const workspaceOwner = `pending-${randomUUID()}`;
280
336
  const workspace =
@@ -288,6 +344,7 @@ export function registerStatefulSubagents(
288
344
  task: params.task,
289
345
  cwd: workspace?.path ?? requestedCwd,
290
346
  agentScope: scope,
347
+ thinkingLevel: params.thinkingLevel,
291
348
  parentId: params.parentId,
292
349
  context: snapshot.text || undefined,
293
350
  contextSourceIds: snapshot.sourceIds,
@@ -298,13 +355,21 @@ export function registerStatefulSubagents(
298
355
  throw error;
299
356
  }
300
357
  if (workspace) isolatedAgents.set(agent.id, workspaceOwner);
301
- trackSpawnedAgent(orchestration, agent);
358
+ const deliveryNote =
359
+ completionDelivery === "auto-resume"
360
+ ? "If no useful local work remains, briefly tell the user what was launched and end the response; auto-resume will request synthesis after completion."
361
+ : "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.";
302
362
  return result(
303
363
  agent,
304
- `Spawned ${agent.agent} as ${agent.id}. Continue coordinating this agent; do useful non-overlapping work or call subagent_wait, then synthesize its result before finishing.`,
364
+ `Spawned ${agent.agent} as ${agent.id}. Do useful non-overlapping work immediately. ${deliveryNote} Do not poll for progress.`,
305
365
  );
306
366
  },
307
367
  });
368
+ refreshSpawnToolRegistration = () => {
369
+ spawnTool.promptGuidelines = createSpawnPromptGuidelines(completionDelivery);
370
+ pi.registerTool(spawnTool);
371
+ };
372
+ refreshSpawnToolRegistration();
308
373
 
309
374
  pi.registerTool({
310
375
  name: "subagent_send",
@@ -334,7 +399,6 @@ export function registerStatefulSubagents(
334
399
  isolatedAgents.has(existing.id),
335
400
  );
336
401
  const agent = await requireRegistry().followUp(params.agentId, params.task);
337
- trackSpawnedAgent(orchestration, agent);
338
402
  return result(agent, `Started follow-up for ${agent.id}.`);
339
403
  },
340
404
  });
@@ -384,9 +448,7 @@ export function registerStatefulSubagents(
384
448
  }));
385
449
  const text = summaries.length
386
450
  ? summaries
387
- .map(
388
- (message) => `${message.id} from ${message.senderId}: ${message.content}`,
389
- )
451
+ .map((message) => `${message.id} from ${message.senderId}: ${message.content}`)
390
452
  .join("\n")
391
453
  : "No unread messages.";
392
454
  return {
@@ -396,35 +458,6 @@ export function registerStatefulSubagents(
396
458
  },
397
459
  });
398
460
 
399
- pi.registerTool({
400
- name: "subagent_wait",
401
- label: "Wait for Subagent",
402
- description: "Wait for a stateful subagent turn without terminating it when the wait times out.",
403
- parameters: Type.Object({
404
- agentId: Type.String(),
405
- timeoutMs: Type.Optional(Type.Number({ minimum: 1, maximum: 3_600_000, default: 30_000 })),
406
- }),
407
- async execute(_id, params, signal) {
408
- const existing = requireRegistry().get(params.agentId);
409
- const registered = existing?.state === "starting" || existing?.state === "running";
410
- if (registered) {
411
- completionWaiters.set(params.agentId, (completionWaiters.get(params.agentId) ?? 0) + 1);
412
- }
413
- try {
414
- const waited = await requireRegistry().wait(params.agentId, params.timeoutMs, signal);
415
- if (!waited.timedOut) orchestration.observe(waited.agent.id);
416
- return result(
417
- waited.agent,
418
- waited.timedOut
419
- ? `Wait timed out; ${waited.agent.id} is ${waited.agent.state}.`
420
- : formatFinal(waited.agent),
421
- );
422
- } finally {
423
- if (registered) decrementWaiter(completionWaiters, params.agentId);
424
- }
425
- },
426
- });
427
-
428
461
  pi.registerTool({
429
462
  name: "subagent_list",
430
463
  label: "List Subagents",
@@ -436,9 +469,7 @@ export function registerStatefulSubagents(
436
469
  content: [
437
470
  {
438
471
  type: "text",
439
- text: agents.length
440
- ? agents.map(formatLine).join("\n")
441
- : "No stateful subagents.",
472
+ text: agents.length ? agents.map(formatLine).join("\n") : "No stateful subagents.",
442
473
  },
443
474
  ],
444
475
  details: { agents: agents.map(summarizeAgent) },
@@ -457,10 +488,12 @@ export function registerStatefulSubagents(
457
488
  async execute(_id, params) {
458
489
  if (params.subtree) {
459
490
  const agents = await requireRegistry().interruptTree(params.agentId);
491
+ const root = requireRegistry().get(params.agentId);
492
+ if (!root) throw new Error(`Unknown subagent: ${params.agentId}`);
460
493
  return {
461
494
  content: [{ type: "text", text: `Interrupted ${agents.length} active agent(s).` }],
462
495
  details: {
463
- agent: summarizeAgent(requireRegistry().get(params.agentId)!),
496
+ agent: summarizeAgent(root),
464
497
  agents: agents.map(summarizeAgent),
465
498
  },
466
499
  };
@@ -481,7 +514,6 @@ export function registerStatefulSubagents(
481
514
  async execute(_id, params) {
482
515
  const existing = requireRegistry().get(params.agentId);
483
516
  if (existing?.state === "closed" && !params.subtree) {
484
- orchestration.resolve(existing.id);
485
517
  const pendingOwner = isolatedAgents.get(existing.id);
486
518
  if (pendingOwner) await workspaceManager.cleanup(pendingOwner);
487
519
  isolatedAgents.delete(existing.id);
@@ -494,11 +526,12 @@ export function registerStatefulSubagents(
494
526
  } finally {
495
527
  await cleanupClosedWorkspaces(requireRegistry(), isolatedAgents, workspaceManager);
496
528
  }
497
- for (const closed of agents) orchestration.resolve(closed.id);
529
+ const root = requireRegistry().get(params.agentId);
530
+ if (!root) throw new Error(`Unknown subagent: ${params.agentId}`);
498
531
  return {
499
532
  content: [{ type: "text", text: `Closed ${agents.length} agent(s).` }],
500
533
  details: {
501
- agent: summarizeAgent(requireRegistry().get(params.agentId)!),
534
+ agent: summarizeAgent(root),
502
535
  agents: agents.map(summarizeAgent),
503
536
  },
504
537
  };
@@ -509,39 +542,44 @@ export function registerStatefulSubagents(
509
542
  } finally {
510
543
  await cleanupClosedWorkspaces(requireRegistry(), isolatedAgents, workspaceManager);
511
544
  }
512
- orchestration.resolve(agent.id);
513
545
  return result(agent, `Closed ${agent.id}.`);
514
546
  },
515
547
  });
516
548
 
517
549
  pi.registerCommand("subagents:agents", {
518
- description: "Inspect or clear stateful subagents",
550
+ description: "Inspect or clear current-session subagents",
519
551
  getArgumentCompletions(prefix: string) {
520
552
  return ["list", "clear"]
521
553
  .filter((value) => value.startsWith(prefix))
522
554
  .map((value) => ({ value, label: value }));
523
555
  },
524
556
  async handler(args, ctx) {
525
- if (args.trim() === "clear") {
526
- try {
527
- await requireRegistry().closeAll();
528
- } finally {
529
- await workspaceManager.cleanupAll();
530
- isolatedAgents.clear();
531
- }
532
- seenMessageIds.clear();
533
- orchestration.reset();
534
- await persistence?.delete();
535
- ctx.ui.notify("Cleared stateful subagents.", "info");
557
+ const subcommand = args.trim().toLowerCase() || "list";
558
+ if (subcommand === "clear") {
559
+ const count = await controller.clearAgents();
560
+ ctx.ui.notify(
561
+ count > 0
562
+ ? `Cleared ${count} current-session subagent${count === 1 ? "" : "s"}.`
563
+ : statefulEmptyMessage(controller.getRuntimeStatus()),
564
+ "info",
565
+ );
536
566
  return;
537
567
  }
538
- const agents = requireRegistry().list(true);
568
+ if (subcommand !== "list") {
569
+ ctx.ui.notify(`Unknown /subagents:agents subcommand: ${subcommand}`, "warning");
570
+ return;
571
+ }
572
+ const agents = controller.listAgents(true);
539
573
  ctx.ui.notify(
540
- agents.length ? agents.map(formatLine).join("\n") : "No stateful subagents.",
574
+ agents.length
575
+ ? agents.map(formatLine).join("\n")
576
+ : statefulEmptyMessage(controller.getRuntimeStatus()),
541
577
  "info",
542
578
  );
543
579
  },
544
580
  });
581
+
582
+ return controller;
545
583
  }
546
584
 
547
585
  export function assertNoSharedWriteConflict(
@@ -564,7 +602,7 @@ export function assertNoSharedWriteConflict(
564
602
  if (isWriteCapable(activeConfig?.tools)) {
565
603
  throw new Error(
566
604
  `Write-capable subagent ${active.id} is already active in shared workspace ${cwd}. ` +
567
- "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.",
605
+ "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.",
568
606
  );
569
607
  }
570
608
  }
@@ -577,12 +615,7 @@ export function assertFollowUpWriteAllowed(
577
615
  isolatedWorkspace: boolean,
578
616
  ): void {
579
617
  if (allowConcurrentWrites || isolatedWorkspace) return;
580
- assertNoSharedWriteConflict(
581
- registry,
582
- agent.agent,
583
- agent.cwd,
584
- agent.agentScope ?? "user",
585
- );
618
+ assertNoSharedWriteConflict(registry, agent.agent, agent.cwd, agent.agentScope ?? "user");
586
619
  }
587
620
 
588
621
  export function isWriteCapable(tools: string[] | undefined): boolean {
@@ -590,12 +623,6 @@ export function isWriteCapable(tools: string[] | undefined): boolean {
590
623
  return tools.some((tool) => ["bash", "write", "edit"].includes(tool));
591
624
  }
592
625
 
593
- function decrementWaiter(waiters: Map<string, number>, agentId: string): void {
594
- const count = waiters.get(agentId);
595
- if (count === undefined || count <= 1) waiters.delete(agentId);
596
- else waiters.set(agentId, count - 1);
597
- }
598
-
599
626
  async function confirmProjectAgent(
600
627
  name: string,
601
628
  scope: AgentScope,
@@ -614,7 +641,10 @@ async function confirmProjectAgent(
614
641
  throw new Error("Project-local subagent definitions require a trusted project");
615
642
  }
616
643
  if (confirm && ctx.hasUI) {
617
- const approved = await ctx.ui.confirm("Run project-local agent?", `Agent: ${name}\nSource: ${agent.filePath}`);
644
+ const approved = await ctx.ui.confirm(
645
+ "Run project-local agent?",
646
+ `Agent: ${name}\nSource: ${agent.filePath}`,
647
+ );
618
648
  if (!approved) throw new Error("Project-local subagent was not approved");
619
649
  }
620
650
  }
@@ -623,9 +653,7 @@ function isSameCwd(left: string, right: string): boolean {
623
653
  return path.resolve(left) === path.resolve(right);
624
654
  }
625
655
 
626
- function normalizeContextMode(
627
- value: "none" | "all" | "summary" | number | undefined,
628
- ): ContextMode {
656
+ function normalizeContextMode(value: "none" | "all" | "summary" | number | undefined): ContextMode {
629
657
  if (value === undefined) return "none";
630
658
  if (value === "none" || value === "all" || value === "summary") return value;
631
659
  return Math.max(1, Math.floor(value));
@@ -639,23 +667,40 @@ export function resolveSpawnContextMode(
639
667
  return normalizeContextMode(value);
640
668
  }
641
669
 
642
- function formatLine(agent: ManagedAgent): string {
670
+ function statefulEmptyMessage(status: StatefulSubagentRuntimeStatus): string {
671
+ if (!status.enabled) return "Stateful subagents are disabled in user settings.";
672
+ if (!status.initialized) return "Stateful subagents are not initialized for this session.";
673
+ return "No current-session subagents.";
674
+ }
675
+
676
+ export function formatStatefulAgentLine(agent: ManagedAgent): string {
643
677
  const elapsedSeconds = Math.max(0, Math.floor((Date.now() - agent.updatedAt) / 1000));
644
678
  const actions =
645
679
  agent.state === "running" || agent.state === "starting"
646
- ? "wait, interrupt, close"
680
+ ? "interrupt, close"
647
681
  : agent.state === "closed"
648
682
  ? "inspect"
649
683
  : "send, close";
650
- const task = agent.currentTask ? ` — ${agent.currentTask.slice(0, 80)}` : "";
684
+ const task = agent.currentTask ? ` — ${sanitizeStatusLine(agent.currentTask, 80)}` : "";
651
685
  const unread = agent.mailbox.filter((message) => !message.readAt).length;
652
686
  const indent = " ".repeat(agent.depth);
653
- return `${indent}${agent.id} ${agent.agent} ${agent.state} ${elapsedSeconds}s unread:${unread} [${actions}]${task}`;
687
+ const thinking = agent.thinkingLevel ? ` thinking:${agent.thinkingLevel}` : "";
688
+ return `${indent}${sanitizeStatusLine(agent.id, 128)} ${sanitizeStatusLine(agent.agent, 128)} ${agent.state} ${elapsedSeconds}s${thinking} unread:${unread} [${actions}]${task}`;
654
689
  }
655
690
 
656
- function formatFinal(agent: ManagedAgent): string {
657
- const last = agent.history.at(-1);
658
- return last?.output || agent.error || `${agent.id} is ${agent.state}.`;
691
+ function sanitizeStatusLine(value: string, maxLength: number): string {
692
+ return (
693
+ value
694
+ .slice(0, maxLength)
695
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: Escape untrusted terminal controls.
696
+ .replace(/[\u0000-\u001f\u007f-\u009f]/gu, " ")
697
+ .replace(/\s+/gu, " ")
698
+ .trim()
699
+ );
700
+ }
701
+
702
+ function formatLine(agent: ManagedAgent): string {
703
+ return formatStatefulAgentLine(agent);
659
704
  }
660
705
 
661
706
  function summarizeAgent(agent: ManagedAgent) {
@@ -670,6 +715,7 @@ function summarizeAgent(agent: ManagedAgent) {
670
715
  createdAt: agent.createdAt,
671
716
  updatedAt: agent.updatedAt,
672
717
  cwd: agent.cwd,
718
+ thinkingLevel: agent.thinkingLevel,
673
719
  currentTask: agent.currentTask
674
720
  ? truncateUtf8(agent.currentTask, MAX_TOOL_MESSAGE_BYTES).text
675
721
  : undefined,
@@ -680,99 +726,188 @@ function summarizeAgent(agent: ManagedAgent) {
680
726
  };
681
727
  }
682
728
 
683
- function trackSpawnedAgent(
684
- orchestration: RootOrchestrationState,
685
- agent: ManagedAgent,
686
- ): void {
687
- orchestration.spawn(agent.id);
688
- if (agent.state === "closed") orchestration.resolve(agent.id);
689
- else if (agent.state !== "starting" && agent.state !== "running") {
690
- orchestration.complete(agent.id);
691
- }
729
+ interface CompletionMetadata {
730
+ agentId: string;
731
+ agent: string;
732
+ state: string;
692
733
  }
693
734
 
694
- function rememberCancelledRecovery(
695
- ticket: OrchestrationRecoveryTicket | undefined,
696
- cancelledNonces: Set<string>,
697
- ): void {
698
- if (!ticket) return;
699
- cancelledNonces.add(ticket.nonce);
700
- if (cancelledNonces.size <= 64) return;
701
- const oldest = cancelledNonces.values().next().value;
702
- if (oldest) cancelledNonces.delete(oldest);
735
+ interface CompletionMessage {
736
+ customType: "pi-subagent-completion";
737
+ content: string;
738
+ display: true;
739
+ details:
740
+ | CompletionMetadata
741
+ | {
742
+ completionCount: number;
743
+ completions: CompletionMetadata[];
744
+ };
703
745
  }
704
746
 
705
- function extractOrchestrationNonce(text: string): string | undefined {
706
- const marker = `<!-- ${ORCHESTRATION_MARKER_PREFIX}`;
707
- const start = text.lastIndexOf(marker);
708
- if (start < 0) return undefined;
709
- const valueStart = start + marker.length;
710
- const end = text.indexOf(" -->", valueStart);
711
- return end < 0 ? undefined : text.slice(valueStart, end);
747
+ type CompletionContext = Pick<ExtensionContext, "hasPendingMessages" | "isIdle">;
748
+ type CompletionPi = Pick<ExtensionAPI, "sendMessage">;
749
+
750
+ export interface CompletionDeliveryBrokerOptions {
751
+ onDeliveryError?: (error: unknown) => void;
712
752
  }
713
753
 
714
- function queueOrchestrationFollowUp(
715
- pi: ExtensionAPI,
716
- ctx: ExtensionContext,
717
- ticket: OrchestrationRecoveryTicket,
718
- ): void {
719
- try {
720
- pi.sendUserMessage(ticket.prompt, { deliverAs: "followUp" });
721
- } catch (error) {
722
- if (ctx.hasUI) {
723
- const reason = error instanceof Error ? error.message : String(error);
724
- ctx.ui.notify(`Subagent coordination follow-up failed: ${reason}`, "warning");
754
+ /**
755
+ * Coalesces detached completions so one bounded notification batch starts at
756
+ * most one root synthesis turn. The broker belongs to one parent session and
757
+ * must be closed when that session is replaced or shut down.
758
+ */
759
+ export class CompletionDeliveryBroker {
760
+ private pending: AgentTurnCompletion[] = [];
761
+ private flushTimer?: NodeJS.Timeout;
762
+ private wakeInFlight = false;
763
+ private closed = false;
764
+
765
+ constructor(
766
+ private readonly pi: CompletionPi,
767
+ private readonly ctx: CompletionContext,
768
+ private delivery: CompletionDelivery,
769
+ private readonly options: CompletionDeliveryBrokerOptions = {},
770
+ ) {}
771
+
772
+ enqueue(completion: AgentTurnCompletion): void {
773
+ if (this.closed) return;
774
+ this.pending.push(completion);
775
+ this.scheduleFlush();
776
+ }
777
+
778
+ setDelivery(value: CompletionDelivery): void {
779
+ this.delivery = value;
780
+ this.scheduleFlush();
781
+ }
782
+
783
+ onParentTurnStart(): void {
784
+ this.wakeInFlight = false;
785
+ this.scheduleFlush();
786
+ }
787
+
788
+ onParentSettled(): void {
789
+ this.wakeInFlight = false;
790
+ this.scheduleFlush();
791
+ }
792
+
793
+ flush(): void {
794
+ if (this.closed || this.pending.length === 0) return;
795
+ if (this.flushTimer) clearTimeout(this.flushTimer);
796
+ this.flushTimer = undefined;
797
+ if (this.delivery === "auto-resume" && !this.isRootIdle()) return;
798
+
799
+ const completions = this.pending.splice(0);
800
+ const batches = chunkCompletions(completions);
801
+ let canWake = this.shouldWakeRoot();
802
+ for (let index = 0; index < batches.length; index++) {
803
+ const triggerTurn = canWake && index === batches.length - 1;
804
+ const message = buildCompletionMessage(batches[index]);
805
+ if (triggerTurn) this.wakeInFlight = true;
806
+ try {
807
+ this.pi.sendMessage(message, { deliverAs: "steer", triggerTurn });
808
+ } catch (primaryError) {
809
+ if (triggerTurn) this.wakeInFlight = false;
810
+ canWake = false;
811
+ try {
812
+ this.pi.sendMessage(message, { deliverAs: "nextTurn", triggerTurn: false });
813
+ } catch (fallbackError) {
814
+ this.pending = [...batches.slice(index).flat(), ...this.pending];
815
+ try {
816
+ this.options.onDeliveryError?.(
817
+ new AggregateError(
818
+ [primaryError, fallbackError],
819
+ "Detached subagent completion delivery failed",
820
+ ),
821
+ );
822
+ } catch {
823
+ // Delivery retention must survive a failing observer.
824
+ }
825
+ return;
826
+ }
827
+ }
725
828
  }
726
829
  }
727
- }
728
830
 
729
- function dispatchOrchestrationRecovery(
730
- pi: ExtensionAPI,
731
- ctx: ExtensionContext,
732
- orchestration: RootOrchestrationState,
733
- ): boolean {
734
- const ticket = orchestration.pendingTicket();
735
- if (!ticket || !orchestration.isCurrent(ticket)) return false;
736
- if (hasPendingRootMessages(ctx)) return false;
737
- try {
738
- pi.sendUserMessage(ticket.prompt);
739
- orchestration.markDelivered(ticket);
740
- return true;
741
- } catch (error) {
742
- if (ctx.hasUI) {
743
- const reason = error instanceof Error ? error.message : String(error);
744
- ctx.ui.notify(`Subagent coordination prompt failed: ${reason}`, "warning");
831
+ close(): void {
832
+ this.closed = true;
833
+ if (this.flushTimer) clearTimeout(this.flushTimer);
834
+ this.flushTimer = undefined;
835
+ this.pending = [];
836
+ }
837
+
838
+ private scheduleFlush(): void {
839
+ if (this.closed || this.pending.length === 0 || this.flushTimer) return;
840
+ this.flushTimer = setTimeout(() => {
841
+ this.flushTimer = undefined;
842
+ this.flush();
843
+ }, COMPLETION_BATCH_DELAY_MS);
844
+ }
845
+
846
+ private isRootIdle(): boolean {
847
+ try {
848
+ return this.ctx.isIdle();
849
+ } catch {
850
+ return false;
851
+ }
852
+ }
853
+
854
+ private shouldWakeRoot(): boolean {
855
+ if (this.delivery !== "auto-resume" || this.wakeInFlight) return false;
856
+ try {
857
+ return !this.ctx.hasPendingMessages();
858
+ } catch {
859
+ return false;
745
860
  }
746
- return false;
747
861
  }
748
862
  }
749
863
 
750
- function hasPendingRootMessages(ctx: ExtensionContext): boolean {
751
- try {
752
- return ctx.hasPendingMessages();
753
- } catch {
754
- return true;
864
+ function chunkCompletions(completions: AgentTurnCompletion[]): AgentTurnCompletion[][] {
865
+ const batches: AgentTurnCompletion[][] = [];
866
+ for (let index = 0; index < completions.length; index += MAX_COMPLETIONS_PER_MESSAGE) {
867
+ batches.push(completions.slice(index, index + MAX_COMPLETIONS_PER_MESSAGE));
755
868
  }
869
+ return batches;
756
870
  }
757
871
 
758
- function sendDetachedCompletion(
759
- pi: ExtensionAPI,
760
- completion: AgentTurnCompletion,
761
- ): void {
762
- const content = buildDetachedCompletionMessage(completion);
763
- pi.sendMessage(
764
- {
872
+ function buildCompletionMessage(completions: AgentTurnCompletion[]): CompletionMessage {
873
+ if (completions.length === 1) {
874
+ const completion = completions[0];
875
+ return {
765
876
  customType: "pi-subagent-completion",
766
- content,
877
+ content: buildDetachedCompletionMessage(completion),
767
878
  display: true,
768
- details: {
769
- agentId: completion.agent.id,
770
- agent: completion.agent.agent,
771
- state: completion.agent.state,
772
- },
879
+ details: completionMetadata(completion),
880
+ };
881
+ }
882
+ const content = truncateUtf8(
883
+ [
884
+ "Message Type: SUBAGENT_COMPLETION_BATCH",
885
+ `Completion Count: ${completions.length}`,
886
+ ...completions.flatMap((completion, index) => [
887
+ "",
888
+ `--- Completion ${index + 1} of ${completions.length} ---`,
889
+ buildDetachedCompletionMessage(completion),
890
+ ]),
891
+ ].join("\n"),
892
+ DEFAULT_MAX_CONTEXT_BYTES,
893
+ ).text;
894
+ return {
895
+ customType: "pi-subagent-completion",
896
+ content,
897
+ display: true,
898
+ details: {
899
+ completionCount: completions.length,
900
+ completions: completions.map(completionMetadata),
773
901
  },
774
- { deliverAs: "steer", triggerTurn: false },
775
- );
902
+ };
903
+ }
904
+
905
+ function completionMetadata(completion: AgentTurnCompletion): CompletionMetadata {
906
+ return {
907
+ agentId: completion.agent.id,
908
+ agent: completion.agent.agent,
909
+ state: completion.agent.state,
910
+ };
776
911
  }
777
912
 
778
913
  export function buildDetachedCompletionMessage(completion: AgentTurnCompletion): string {
@@ -798,10 +933,13 @@ export function buildDetachedCompletionMessage(completion: AgentTurnCompletion):
798
933
  }
799
934
 
800
935
  function sanitizeCompletionLine(value: string, maxBytes: number): string {
801
- return truncateUtf8(redactPrivateText(value), maxBytes).text
802
- .replace(/[\u0000-\u001f\u007f]+/g, " ")
803
- .replace(/\s+/g, " ")
804
- .trim();
936
+ return (
937
+ truncateUtf8(redactPrivateText(value), maxBytes)
938
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: Strip untrusted terminal controls.
939
+ .text.replace(/[\u0000-\u001f\u007f]+/g, " ")
940
+ .replace(/\s+/g, " ")
941
+ .trim()
942
+ );
805
943
  }
806
944
 
807
945
  async function cleanupClosedWorkspaces(
@@ -829,6 +967,12 @@ export function resolveStatefulTransportKind(
829
967
  return value ?? "subprocess";
830
968
  }
831
969
 
970
+ export function resolveCompletionDelivery(
971
+ value: CompletionDelivery | undefined,
972
+ ): CompletionDelivery {
973
+ return value ?? "next-turn";
974
+ }
975
+
832
976
  function normalizeRuntimeThinkingLevel(value: string): ParentRuntimeSnapshot["thinkingLevel"] {
833
977
  return isThinkingLevel(value) ? value : "off";
834
978
  }