@narumitw/pi-subagents 0.25.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,21 +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 { 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 { SubprocessTransport } from "./subprocess-transport.js";
19
29
  import { WorkspaceManager } from "./workspace.js";
20
30
 
21
31
  const ContextModeSchema = Type.Union([
@@ -27,29 +37,121 @@ const ScopeSchema = StringEnum(["user", "project", "both"] as const, {
27
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.',
28
38
  default: "user",
29
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
+ });
30
44
  const MAX_TOOL_MESSAGE_BYTES = 2 * 1024;
31
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
+ }
32
71
 
33
72
  export interface StatefulSubagentDependencies {
34
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>;
35
92
  }
36
93
 
37
94
  export function registerStatefulSubagents(
38
95
  pi: ExtensionAPI,
39
96
  dependencies: StatefulSubagentDependencies = {},
40
- ): void {
97
+ ): StatefulSubagentController {
41
98
  const settings = readSubagentSettings()?.stateful ?? {};
42
- if (settings.enabled === false) return;
43
-
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;
44
104
  let registry: AgentRegistry | undefined;
45
105
  let persistence: AgentPersistence | undefined;
46
106
  let sweepTimer: NodeJS.Timeout | undefined;
47
107
  let runtimeGeneration = 0;
48
- const workspaceManager = new WorkspaceManager();
108
+ const workspaceManager = dependencies.workspaceManager ?? new WorkspaceManager();
49
109
  const isolatedAgents = new Map<string, string>();
50
110
  const seenMessageIds = new Set<string>();
51
111
  const parentRuntime: ParentRuntimeSnapshot = { model: undefined, thinkingLevel: "off" };
52
- 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;
53
155
 
54
156
  const requireRegistry = () => {
55
157
  if (!registry) throw new Error("Stateful subagents are not initialized for this session");
@@ -58,6 +160,8 @@ export function registerStatefulSubagents(
58
160
 
59
161
  pi.on("session_start", async (_event, ctx) => {
60
162
  const generation = ++runtimeGeneration;
163
+ completionBroker?.close();
164
+ completionBroker = undefined;
61
165
  parentRuntime.model = ctx.model;
62
166
  parentRuntime.thinkingLevel = normalizeRuntimeThinkingLevel(pi.getThinkingLevel());
63
167
  const owner =
@@ -69,14 +173,21 @@ export function registerStatefulSubagents(
69
173
  maxStoredAgents: settings.maxStoredAgents,
70
174
  });
71
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
+ });
72
183
  const transport =
73
- resolveStatefulTransportKind(settings.transport) === "in-process"
184
+ transportKind === "in-process"
74
185
  ? new InProcessTransport({
75
186
  modelRegistry: ctx.modelRegistry,
76
187
  getParentRuntime: () => ({ ...parentRuntime }),
77
188
  createSession: dependencies.createInProcessSession,
78
189
  })
79
- : new SubprocessTransport(ctx);
190
+ : new SubprocessTransport();
80
191
  registry = new AgentRegistry(transport, {
81
192
  maxAgents: settings.maxAgents,
82
193
  maxActiveTurns: settings.maxActiveTurns,
@@ -102,9 +213,7 @@ export function registerStatefulSubagents(
102
213
  },
103
214
  onTurnComplete: (completion) => {
104
215
  if (generation !== runtimeGeneration) return;
105
- if (!completionWaiters.has(completion.agent.id)) {
106
- sendDetachedCompletion(pi, completion);
107
- }
216
+ completionBroker?.enqueue(completion);
108
217
  },
109
218
  });
110
219
  const restored = sessionPersistence
@@ -128,6 +237,14 @@ export function registerStatefulSubagents(
128
237
  sweepTimer.unref();
129
238
  });
130
239
 
240
+ pi.on("agent_start", () => {
241
+ completionBroker?.onParentTurnStart();
242
+ });
243
+
244
+ pi.on("agent_settled", () => {
245
+ completionBroker?.onParentSettled();
246
+ });
247
+
131
248
  pi.on("model_select", (event) => {
132
249
  parentRuntime.model = event.model;
133
250
  });
@@ -138,6 +255,8 @@ export function registerStatefulSubagents(
138
255
 
139
256
  pi.on("session_shutdown", async (_event, ctx) => {
140
257
  runtimeGeneration++;
258
+ completionBroker?.close();
259
+ completionBroker = undefined;
141
260
  if (sweepTimer) clearInterval(sweepTimer);
142
261
  sweepTimer = undefined;
143
262
  for (const agentId of isolatedAgents.keys()) {
@@ -145,39 +264,35 @@ export function registerStatefulSubagents(
145
264
  }
146
265
  isolatedAgents.clear();
147
266
  seenMessageIds.clear();
148
- completionWaiters.clear();
149
267
  let cleanupError: unknown;
150
268
  try {
151
269
  await workspaceManager.cleanupAll();
152
270
  } catch (error) {
153
271
  cleanupError = error;
154
272
  }
155
- await registry?.shutdown();
156
- registry = undefined;
157
- persistence = undefined;
273
+ try {
274
+ await registry?.shutdown();
275
+ } finally {
276
+ registry = undefined;
277
+ persistence = undefined;
278
+ }
158
279
  if (cleanupError && ctx.hasUI) {
159
280
  const reason = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
160
281
  ctx.ui.notify(`Some isolated subagent workspaces could not be removed: ${reason}`, "warning");
161
282
  }
162
283
  });
163
284
 
164
- pi.registerTool({
285
+ const spawnTool = defineTool({
165
286
  name: "subagent_spawn",
166
287
  label: "Spawn Subagent",
167
288
  description:
168
- "Start an addressable background subagent, return immediately with an agentId, and receive its completion asynchronously.",
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.",
169
290
  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
- ],
291
+ promptGuidelines: createSpawnPromptGuidelines(completionDelivery),
178
292
  parameters: Type.Object({
179
293
  agent: Type.String({ minLength: 1 }),
180
294
  task: Type.String({ minLength: 1, maxLength: DEFAULT_MAX_CONTEXT_BYTES }),
295
+ thinkingLevel: Type.Optional(StatefulThinkingLevelSchema),
181
296
  cwd: Type.Optional(Type.String()),
182
297
  agentScope: Type.Optional(ScopeSchema),
183
298
  confirmProjectAgents: Type.Optional(Type.Boolean({ default: true })),
@@ -229,6 +344,7 @@ export function registerStatefulSubagents(
229
344
  task: params.task,
230
345
  cwd: workspace?.path ?? requestedCwd,
231
346
  agentScope: scope,
347
+ thinkingLevel: params.thinkingLevel,
232
348
  parentId: params.parentId,
233
349
  context: snapshot.text || undefined,
234
350
  contextSourceIds: snapshot.sourceIds,
@@ -239,12 +355,21 @@ export function registerStatefulSubagents(
239
355
  throw error;
240
356
  }
241
357
  if (workspace) isolatedAgents.set(agent.id, workspaceOwner);
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.";
242
362
  return result(
243
363
  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.`,
364
+ `Spawned ${agent.agent} as ${agent.id}. Do useful non-overlapping work immediately. ${deliveryNote} Do not poll for progress.`,
245
365
  );
246
366
  },
247
367
  });
368
+ refreshSpawnToolRegistration = () => {
369
+ spawnTool.promptGuidelines = createSpawnPromptGuidelines(completionDelivery);
370
+ pi.registerTool(spawnTool);
371
+ };
372
+ refreshSpawnToolRegistration();
248
373
 
249
374
  pi.registerTool({
250
375
  name: "subagent_send",
@@ -333,35 +458,6 @@ export function registerStatefulSubagents(
333
458
  },
334
459
  });
335
460
 
336
- pi.registerTool({
337
- name: "subagent_wait",
338
- label: "Wait for Subagent",
339
- 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
461
  pi.registerTool({
366
462
  name: "subagent_list",
367
463
  label: "List Subagents",
@@ -392,10 +488,12 @@ export function registerStatefulSubagents(
392
488
  async execute(_id, params) {
393
489
  if (params.subtree) {
394
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}`);
395
493
  return {
396
494
  content: [{ type: "text", text: `Interrupted ${agents.length} active agent(s).` }],
397
495
  details: {
398
- agent: summarizeAgent(requireRegistry().get(params.agentId)!),
496
+ agent: summarizeAgent(root),
399
497
  agents: agents.map(summarizeAgent),
400
498
  },
401
499
  };
@@ -428,10 +526,12 @@ export function registerStatefulSubagents(
428
526
  } finally {
429
527
  await cleanupClosedWorkspaces(requireRegistry(), isolatedAgents, workspaceManager);
430
528
  }
529
+ const root = requireRegistry().get(params.agentId);
530
+ if (!root) throw new Error(`Unknown subagent: ${params.agentId}`);
431
531
  return {
432
532
  content: [{ type: "text", text: `Closed ${agents.length} agent(s).` }],
433
533
  details: {
434
- agent: summarizeAgent(requireRegistry().get(params.agentId)!),
534
+ agent: summarizeAgent(root),
435
535
  agents: agents.map(summarizeAgent),
436
536
  },
437
537
  };
@@ -447,32 +547,39 @@ export function registerStatefulSubagents(
447
547
  });
448
548
 
449
549
  pi.registerCommand("subagents:agents", {
450
- description: "Inspect or clear stateful subagents",
550
+ description: "Inspect or clear current-session subagents",
451
551
  getArgumentCompletions(prefix: string) {
452
552
  return ["list", "clear"]
453
553
  .filter((value) => value.startsWith(prefix))
454
554
  .map((value) => ({ value, label: value }));
455
555
  },
456
556
  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");
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
+ );
467
566
  return;
468
567
  }
469
- 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);
470
573
  ctx.ui.notify(
471
- agents.length ? agents.map(formatLine).join("\n") : "No stateful subagents.",
574
+ agents.length
575
+ ? agents.map(formatLine).join("\n")
576
+ : statefulEmptyMessage(controller.getRuntimeStatus()),
472
577
  "info",
473
578
  );
474
579
  },
475
580
  });
581
+
582
+ return controller;
476
583
  }
477
584
 
478
585
  export function assertNoSharedWriteConflict(
@@ -495,7 +602,7 @@ export function assertNoSharedWriteConflict(
495
602
  if (isWriteCapable(activeConfig?.tools)) {
496
603
  throw new Error(
497
604
  `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.",
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.",
499
606
  );
500
607
  }
501
608
  }
@@ -516,12 +623,6 @@ export function isWriteCapable(tools: string[] | undefined): boolean {
516
623
  return tools.some((tool) => ["bash", "write", "edit"].includes(tool));
517
624
  }
518
625
 
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
626
  async function confirmProjectAgent(
526
627
  name: string,
527
628
  scope: AgentScope,
@@ -566,23 +667,40 @@ export function resolveSpawnContextMode(
566
667
  return normalizeContextMode(value);
567
668
  }
568
669
 
569
- 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 {
570
677
  const elapsedSeconds = Math.max(0, Math.floor((Date.now() - agent.updatedAt) / 1000));
571
678
  const actions =
572
679
  agent.state === "running" || agent.state === "starting"
573
- ? "wait, interrupt, close"
680
+ ? "interrupt, close"
574
681
  : agent.state === "closed"
575
682
  ? "inspect"
576
683
  : "send, close";
577
- const task = agent.currentTask ? ` — ${agent.currentTask.slice(0, 80)}` : "";
684
+ const task = agent.currentTask ? ` — ${sanitizeStatusLine(agent.currentTask, 80)}` : "";
578
685
  const unread = agent.mailbox.filter((message) => !message.readAt).length;
579
686
  const indent = " ".repeat(agent.depth);
580
- 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}`;
581
689
  }
582
690
 
583
- function formatFinal(agent: ManagedAgent): string {
584
- const last = agent.history.at(-1);
585
- 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);
586
704
  }
587
705
 
588
706
  function summarizeAgent(agent: ManagedAgent) {
@@ -597,6 +715,7 @@ function summarizeAgent(agent: ManagedAgent) {
597
715
  createdAt: agent.createdAt,
598
716
  updatedAt: agent.updatedAt,
599
717
  cwd: agent.cwd,
718
+ thinkingLevel: agent.thinkingLevel,
600
719
  currentTask: agent.currentTask
601
720
  ? truncateUtf8(agent.currentTask, MAX_TOOL_MESSAGE_BYTES).text
602
721
  : undefined,
@@ -607,21 +726,188 @@ function summarizeAgent(agent: ManagedAgent) {
607
726
  };
608
727
  }
609
728
 
610
- function sendDetachedCompletion(pi: ExtensionAPI, completion: AgentTurnCompletion): void {
611
- const content = buildDetachedCompletionMessage(completion);
612
- pi.sendMessage(
613
- {
729
+ interface CompletionMetadata {
730
+ agentId: string;
731
+ agent: string;
732
+ state: string;
733
+ }
734
+
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
+ };
745
+ }
746
+
747
+ type CompletionContext = Pick<ExtensionContext, "hasPendingMessages" | "isIdle">;
748
+ type CompletionPi = Pick<ExtensionAPI, "sendMessage">;
749
+
750
+ export interface CompletionDeliveryBrokerOptions {
751
+ onDeliveryError?: (error: unknown) => void;
752
+ }
753
+
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
+ }
828
+ }
829
+ }
830
+
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;
860
+ }
861
+ }
862
+ }
863
+
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));
868
+ }
869
+ return batches;
870
+ }
871
+
872
+ function buildCompletionMessage(completions: AgentTurnCompletion[]): CompletionMessage {
873
+ if (completions.length === 1) {
874
+ const completion = completions[0];
875
+ return {
614
876
  customType: "pi-subagent-completion",
615
- content,
877
+ content: buildDetachedCompletionMessage(completion),
616
878
  display: true,
617
- details: {
618
- agentId: completion.agent.id,
619
- agent: completion.agent.agent,
620
- state: completion.agent.state,
621
- },
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),
622
901
  },
623
- { deliverAs: "steer", triggerTurn: false },
624
- );
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
+ };
625
911
  }
626
912
 
627
913
  export function buildDetachedCompletionMessage(completion: AgentTurnCompletion): string {
@@ -647,10 +933,13 @@ export function buildDetachedCompletionMessage(completion: AgentTurnCompletion):
647
933
  }
648
934
 
649
935
  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();
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
+ );
654
943
  }
655
944
 
656
945
  async function cleanupClosedWorkspaces(
@@ -678,6 +967,12 @@ export function resolveStatefulTransportKind(
678
967
  return value ?? "subprocess";
679
968
  }
680
969
 
970
+ export function resolveCompletionDelivery(
971
+ value: CompletionDelivery | undefined,
972
+ ): CompletionDelivery {
973
+ return value ?? "next-turn";
974
+ }
975
+
681
976
  function normalizeRuntimeThinkingLevel(value: string): ParentRuntimeSnapshot["thinkingLevel"] {
682
977
  return isThinkingLevel(value) ? value : "off";
683
978
  }