@narumitw/pi-subagents 0.20.0 → 0.25.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/README.md CHANGED
@@ -81,9 +81,11 @@ Count-selection guidance:
81
81
  - A single `subagent` call is blocking. Use one only when context isolation, high-volume output
82
82
  isolation, or independent review is worth waiting for; otherwise the main agent should do the work.
83
83
  - Do not delegate ordinary critical-path work merely to wait for it. Use detached `subagent_spawn`
84
- only when parallel work, bounded context/output, independent review, a distinct model/tool profile,
85
- or workspace isolation provides concrete value. Continue useful non-overlapping work when available;
86
- otherwise call `subagent_wait` and synthesize the result before finishing.
84
+ only for a concrete bounded subtask that can run independently alongside useful main-agent work
85
+ and when parallel work, bounded context/output, independent review, a distinct model/tool profile,
86
+ or workspace isolation provides concrete value. After spawning, do useful non-overlapping work
87
+ immediately. Call `subagent_wait` sparingly, only when the immediate next critical-path step needs
88
+ the result and is blocked until it arrives; do not wait repeatedly by reflex.
87
89
  - Prefer **2–4 parallel read-only subagents** when a broad task naturally splits into independent
88
90
  branches that can each return a concise summary.
89
91
  - Exceed 4 tasks only when the branches are clearly distinct and worth the extra cost, while staying
@@ -202,9 +204,9 @@ Run a chain where each step receives the previous output:
202
204
 
203
205
  Stateful lifecycle tools are available by default. `subagent_spawn` is detached: it schedules work, returns immediately with an opaque `agentId`, and later injects one bounded `pi-subagent-completion` message per settled turn. The message uses `deliverAs: "steer"` with `triggerTurn: false`, so an active root turn can consume it naturally.
204
206
 
205
- Detached work has a root-coordination invariant: the main agent should continue useful non-overlapping work when available, or call `subagent_wait` when coordination is the only useful next action. If it yields while current delegated work is still live or awaiting synthesis, the extension schedules one idle-gated recovery continuation for that orchestration revision. A completion, close, interruption, new root turn, session replacement, or shutdown revises or clears that ephemeral state. Recovery is bounded and does not internally wait forever or persist across sessions.
207
+ Detached work follows a critical-path policy: spawn only a concrete bounded subtask that can run independently alongside useful main-agent work, then do that non-overlapping work immediately. Use `subagent_wait` sparingly and only when the immediate next critical-path step requires the result and is blocked until it arrives. The extension does not start an autonomous recovery turn merely because delegated work remains live or completes. If the root turn has already ended, completion stays queued for the next turn instead of waking the root.
206
208
 
207
- A single detached agent is justified only by a concrete isolation or specialization benefit such as independent review, bounded context/output, a distinct model/tool profile, or workspace isolation. Simple work that the main agent can perform directly should not be delegated.
209
+ A single detached agent additionally needs a concrete isolation or specialization benefit such as independent review, bounded context/output, a distinct model/tool profile, or workspace isolation. Simple work that the main agent can perform directly should not be delegated.
208
210
 
209
211
  When `subagent_wait` starts while a turn is running, that wait consumes the turn's completion: the terminal output is returned by the tool and is not also injected as a duplicate asynchronous completion. Multiple active waiters may all receive the terminal result. A timed-out or aborted wait does not consume it, so later settlement still delivers the normal asynchronous completion. If a turn has already settled and queued its completion before a wait starts, the queued message cannot be retracted safely.
210
212
 
@@ -237,7 +239,7 @@ Set `"enabled": false` to remove all stateful lifecycle tools. Otherwise, the ex
237
239
  | `subagent_send` | Send follow-up work to a reusable agent; shared-workspace write conflicts are guarded unless explicitly overridden. |
238
240
  | `subagent_message` | Queue a bounded mailbox message without starting a turn; sender IDs must be `root` or an agent in the same tree. |
239
241
  | `subagent_messages` | Read and optionally acknowledge unread mailbox messages. |
240
- | `subagent_wait` | Wait when coordination is the useful next action; timeout does not terminate the agent, and parent abort/user steering cancels only the wait. Already-terminal agents return immediately. |
242
+ | `subagent_wait` | Wait sparingly when the immediate next critical-path step is blocked on a result; timeout does not terminate the agent, and parent abort/user steering cancels only the wait. Already-terminal agents return immediately. |
241
243
  | `subagent_list` | List retained agents and lifecycle states. |
242
244
  | `subagent_interrupt` | Abort the current turn while retaining its identity and history. |
243
245
  | `subagent_close` | Abort if necessary, close the agent, and remove it from retained persistence. |
@@ -292,10 +294,12 @@ Built-in agents are available without setup and can be overridden by user or pro
292
294
  | --- | --- | --- |
293
295
  | `scout` | Read-only codebase reconnaissance. | `read`, `grep`, `find`, `ls`, `bash` |
294
296
  | `planner` | Grounded implementation plans. | `read`, `grep`, `find`, `ls` |
295
- | `reviewer` | Independent review and verification. | `read`, `grep`, `find`, `ls`, `bash` |
297
+ | `reviewer` | Independent review of code and existing verification evidence. | `read`, `grep`, `find`, `ls`, `bash` |
296
298
  | `worker` | General-purpose implementation. | Pi default tools |
297
299
  | `general`, `general-purpose` | Aliases for `worker`. | Pi default tools |
298
300
 
301
+ The built-in `reviewer` does not run tests, builds, benchmarks, or formatters. It recommends additional verification commands for the main agent to run instead. Custom agents can override this behavior.
302
+
299
303
  Built-in agents inherit the active/default Pi model instead of forcing a provider-specific model alias, which keeps subprocesses usable across different Pi setups.
300
304
 
301
305
  ## ⚙️ Configure agent tools
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-subagents",
3
- "version": "0.20.0",
3
+ "version": "0.25.0",
4
4
  "description": "Pi extension for delegating work to specialized isolated subagents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/agents.ts CHANGED
@@ -93,14 +93,15 @@ const BUILT_IN_AGENTS: AgentConfig[] = [
93
93
  },
94
94
  {
95
95
  name: "reviewer",
96
- description: "Independent code review and verification agent for completed changes.",
96
+ description: "Independent code review agent that inspects existing verification evidence.",
97
97
  tools: ["read", "grep", "find", "ls", "bash"],
98
98
  source: "built-in",
99
99
  filePath: "built-in:reviewer",
100
100
  systemPrompt: [
101
- "You are a reviewer subagent. Review changes adversarially and verify claims.",
102
- "Do not edit files. Run safe inspection or test commands when useful.",
103
- "Report PASS, FAIL, or PARTIAL with evidence, commands run, and specific follow-ups.",
101
+ "You are a reviewer subagent. Review changes adversarially and assess claims against the code and existing evidence.",
102
+ "Do not edit files or run tests, builds, benchmarks, formatters, or other long-running verification commands.",
103
+ "Inspect code, diffs, test definitions, and existing verification evidence. Recommend any additional commands for the main agent to run.",
104
+ "Report PASS, FAIL, or PARTIAL with evidence, commands inspected, and specific follow-ups.",
104
105
  ].join("\n"),
105
106
  },
106
107
  {
package/src/stateful.ts CHANGED
@@ -7,17 +7,8 @@ import { discoverAgents, type AgentScope, isThinkingLevel } from "./agents.js";
7
7
  import { buildContextSnapshot, type ContextMode, redactPrivateText } from "./context.js";
8
8
  import { assertSubagentDepthAllowed } from "./execution.js";
9
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
10
  import { AgentPersistence } from "./persistence.js";
16
- import {
17
- AgentRegistry,
18
- type AgentTurnCompletion,
19
- type ManagedAgent,
20
- } from "./registry.js";
11
+ import { AgentRegistry, type AgentTurnCompletion, type ManagedAgent } from "./registry.js";
21
12
  import { readSubagentSettings } from "./settings.js";
22
13
  import { SubprocessTransport } from "./subprocess-transport.js";
23
14
  import {
@@ -58,8 +49,6 @@ export function registerStatefulSubagents(
58
49
  const isolatedAgents = new Map<string, string>();
59
50
  const seenMessageIds = new Set<string>();
60
51
  const parentRuntime: ParentRuntimeSnapshot = { model: undefined, thinkingLevel: "off" };
61
- const orchestration = new RootOrchestrationState();
62
- const cancelledRecoveryNonces = new Set<string>();
63
52
  const completionWaiters = new Map<string, number>();
64
53
 
65
54
  const requireRegistry = () => {
@@ -69,11 +58,12 @@ export function registerStatefulSubagents(
69
58
 
70
59
  pi.on("session_start", async (_event, ctx) => {
71
60
  const generation = ++runtimeGeneration;
72
- rememberCancelledRecovery(orchestration.supersedePending(), cancelledRecoveryNonces);
73
- orchestration.reset();
74
61
  parentRuntime.model = ctx.model;
75
62
  parentRuntime.thinkingLevel = normalizeRuntimeThinkingLevel(pi.getThinkingLevel());
76
- const owner = ctx.sessionManager.getSessionId?.() ?? ctx.sessionManager.getSessionFile?.() ?? `ephemeral:${ctx.cwd}`;
63
+ const owner =
64
+ ctx.sessionManager.getSessionId?.() ??
65
+ ctx.sessionManager.getSessionFile?.() ??
66
+ `ephemeral:${ctx.cwd}`;
77
67
  const sessionPersistence = new AgentPersistence(owner, {
78
68
  retentionDays: settings.retentionDays,
79
69
  maxStoredAgents: settings.maxStoredAgents,
@@ -112,7 +102,6 @@ export function registerStatefulSubagents(
112
102
  },
113
103
  onTurnComplete: (completion) => {
114
104
  if (generation !== runtimeGeneration) return;
115
- orchestration.complete(completion.agent.id);
116
105
  if (!completionWaiters.has(completion.agent.id)) {
117
106
  sendDetachedCompletion(pi, completion);
118
107
  }
@@ -122,8 +111,7 @@ export function registerStatefulSubagents(
122
111
  .load()
123
112
  .filter(
124
113
  (agent) =>
125
- (agent.agentScope !== "project" && agent.agentScope !== "both") ||
126
- ctx.isProjectTrusted(),
114
+ (agent.agentScope !== "project" && agent.agentScope !== "both") || ctx.isProjectTrusted(),
127
115
  );
128
116
  for (const agent of restored) {
129
117
  for (const message of agent.mailbox) seenMessageIds.add(message.id);
@@ -140,38 +128,6 @@ export function registerStatefulSubagents(
140
128
  sweepTimer.unref();
141
129
  });
142
130
 
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();
158
- });
159
-
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);
173
- });
174
-
175
131
  pi.on("model_select", (event) => {
176
132
  parentRuntime.model = event.model;
177
133
  });
@@ -182,8 +138,6 @@ export function registerStatefulSubagents(
182
138
 
183
139
  pi.on("session_shutdown", async (_event, ctx) => {
184
140
  runtimeGeneration++;
185
- rememberCancelledRecovery(orchestration.supersedePending(), cancelledRecoveryNonces);
186
- orchestration.reset();
187
141
  if (sweepTimer) clearInterval(sweepTimer);
188
142
  sweepTimer = undefined;
189
143
  for (const agentId of isolatedAgents.keys()) {
@@ -203,25 +157,23 @@ export function registerStatefulSubagents(
203
157
  persistence = undefined;
204
158
  if (cleanupError && ctx.hasUI) {
205
159
  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
- );
160
+ ctx.ui.notify(`Some isolated subagent workspaces could not be removed: ${reason}`, "warning");
210
161
  }
211
162
  });
212
163
 
213
164
  pi.registerTool({
214
165
  name: "subagent_spawn",
215
166
  label: "Spawn Subagent",
216
- description: "Start an addressable background subagent, return immediately with an agentId, and receive its completion asynchronously.",
167
+ description:
168
+ "Start an addressable background subagent, return immediately with an agentId, and receive its completion asynchronously.",
217
169
  promptSnippet: "Start a reusable detached subagent; completion is delivered asynchronously",
218
170
  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.",
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.",
225
177
  ],
226
178
  parameters: Type.Object({
227
179
  agent: Type.String({ minLength: 1 }),
@@ -247,13 +199,7 @@ export function registerStatefulSubagents(
247
199
  const scope = (params.agentScope ?? "user") as AgentScope;
248
200
  assertSubagentDepthAllowed();
249
201
  const cwd = params.cwd ?? ctx.cwd;
250
- await confirmProjectAgent(
251
- params.agent,
252
- scope,
253
- params.confirmProjectAgents ?? true,
254
- ctx,
255
- cwd,
256
- );
202
+ await confirmProjectAgent(params.agent, scope, params.confirmProjectAgents ?? true, ctx, cwd);
257
203
  const resolvedAgent = discoverAgents(cwd, scope, readSubagentSettings()).agents.find(
258
204
  (agent) => agent.name === params.agent,
259
205
  );
@@ -269,12 +215,7 @@ export function registerStatefulSubagents(
269
215
  );
270
216
  const requestedCwd = cwd;
271
217
  if ((params.workspaceMode ?? "shared") === "shared" && !params.allowConcurrentWrites) {
272
- assertNoSharedWriteConflict(
273
- requireRegistry(),
274
- params.agent,
275
- requestedCwd,
276
- scope,
277
- );
218
+ assertNoSharedWriteConflict(requireRegistry(), params.agent, requestedCwd, scope);
278
219
  }
279
220
  const workspaceOwner = `pending-${randomUUID()}`;
280
221
  const workspace =
@@ -298,10 +239,9 @@ export function registerStatefulSubagents(
298
239
  throw error;
299
240
  }
300
241
  if (workspace) isolatedAgents.set(agent.id, workspaceOwner);
301
- trackSpawnedAgent(orchestration, agent);
302
242
  return result(
303
243
  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.`,
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.`,
305
245
  );
306
246
  },
307
247
  });
@@ -334,7 +274,6 @@ export function registerStatefulSubagents(
334
274
  isolatedAgents.has(existing.id),
335
275
  );
336
276
  const agent = await requireRegistry().followUp(params.agentId, params.task);
337
- trackSpawnedAgent(orchestration, agent);
338
277
  return result(agent, `Started follow-up for ${agent.id}.`);
339
278
  },
340
279
  });
@@ -384,9 +323,7 @@ export function registerStatefulSubagents(
384
323
  }));
385
324
  const text = summaries.length
386
325
  ? summaries
387
- .map(
388
- (message) => `${message.id} from ${message.senderId}: ${message.content}`,
389
- )
326
+ .map((message) => `${message.id} from ${message.senderId}: ${message.content}`)
390
327
  .join("\n")
391
328
  : "No unread messages.";
392
329
  return {
@@ -399,7 +336,8 @@ export function registerStatefulSubagents(
399
336
  pi.registerTool({
400
337
  name: "subagent_wait",
401
338
  label: "Wait for Subagent",
402
- description: "Wait for a stateful subagent turn without terminating it when the wait times out.",
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.",
403
341
  parameters: Type.Object({
404
342
  agentId: Type.String(),
405
343
  timeoutMs: Type.Optional(Type.Number({ minimum: 1, maximum: 3_600_000, default: 30_000 })),
@@ -412,7 +350,6 @@ export function registerStatefulSubagents(
412
350
  }
413
351
  try {
414
352
  const waited = await requireRegistry().wait(params.agentId, params.timeoutMs, signal);
415
- if (!waited.timedOut) orchestration.observe(waited.agent.id);
416
353
  return result(
417
354
  waited.agent,
418
355
  waited.timedOut
@@ -436,9 +373,7 @@ export function registerStatefulSubagents(
436
373
  content: [
437
374
  {
438
375
  type: "text",
439
- text: agents.length
440
- ? agents.map(formatLine).join("\n")
441
- : "No stateful subagents.",
376
+ text: agents.length ? agents.map(formatLine).join("\n") : "No stateful subagents.",
442
377
  },
443
378
  ],
444
379
  details: { agents: agents.map(summarizeAgent) },
@@ -481,7 +416,6 @@ export function registerStatefulSubagents(
481
416
  async execute(_id, params) {
482
417
  const existing = requireRegistry().get(params.agentId);
483
418
  if (existing?.state === "closed" && !params.subtree) {
484
- orchestration.resolve(existing.id);
485
419
  const pendingOwner = isolatedAgents.get(existing.id);
486
420
  if (pendingOwner) await workspaceManager.cleanup(pendingOwner);
487
421
  isolatedAgents.delete(existing.id);
@@ -494,7 +428,6 @@ export function registerStatefulSubagents(
494
428
  } finally {
495
429
  await cleanupClosedWorkspaces(requireRegistry(), isolatedAgents, workspaceManager);
496
430
  }
497
- for (const closed of agents) orchestration.resolve(closed.id);
498
431
  return {
499
432
  content: [{ type: "text", text: `Closed ${agents.length} agent(s).` }],
500
433
  details: {
@@ -509,7 +442,6 @@ export function registerStatefulSubagents(
509
442
  } finally {
510
443
  await cleanupClosedWorkspaces(requireRegistry(), isolatedAgents, workspaceManager);
511
444
  }
512
- orchestration.resolve(agent.id);
513
445
  return result(agent, `Closed ${agent.id}.`);
514
446
  },
515
447
  });
@@ -530,7 +462,6 @@ export function registerStatefulSubagents(
530
462
  isolatedAgents.clear();
531
463
  }
532
464
  seenMessageIds.clear();
533
- orchestration.reset();
534
465
  await persistence?.delete();
535
466
  ctx.ui.notify("Cleared stateful subagents.", "info");
536
467
  return;
@@ -577,12 +508,7 @@ export function assertFollowUpWriteAllowed(
577
508
  isolatedWorkspace: boolean,
578
509
  ): void {
579
510
  if (allowConcurrentWrites || isolatedWorkspace) return;
580
- assertNoSharedWriteConflict(
581
- registry,
582
- agent.agent,
583
- agent.cwd,
584
- agent.agentScope ?? "user",
585
- );
511
+ assertNoSharedWriteConflict(registry, agent.agent, agent.cwd, agent.agentScope ?? "user");
586
512
  }
587
513
 
588
514
  export function isWriteCapable(tools: string[] | undefined): boolean {
@@ -614,7 +540,10 @@ async function confirmProjectAgent(
614
540
  throw new Error("Project-local subagent definitions require a trusted project");
615
541
  }
616
542
  if (confirm && ctx.hasUI) {
617
- const approved = await ctx.ui.confirm("Run project-local agent?", `Agent: ${name}\nSource: ${agent.filePath}`);
543
+ const approved = await ctx.ui.confirm(
544
+ "Run project-local agent?",
545
+ `Agent: ${name}\nSource: ${agent.filePath}`,
546
+ );
618
547
  if (!approved) throw new Error("Project-local subagent was not approved");
619
548
  }
620
549
  }
@@ -623,9 +552,7 @@ function isSameCwd(left: string, right: string): boolean {
623
552
  return path.resolve(left) === path.resolve(right);
624
553
  }
625
554
 
626
- function normalizeContextMode(
627
- value: "none" | "all" | "summary" | number | undefined,
628
- ): ContextMode {
555
+ function normalizeContextMode(value: "none" | "all" | "summary" | number | undefined): ContextMode {
629
556
  if (value === undefined) return "none";
630
557
  if (value === "none" || value === "all" || value === "summary") return value;
631
558
  return Math.max(1, Math.floor(value));
@@ -680,85 +607,7 @@ function summarizeAgent(agent: ManagedAgent) {
680
607
  };
681
608
  }
682
609
 
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
- }
692
- }
693
-
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);
703
- }
704
-
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);
712
- }
713
-
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");
725
- }
726
- }
727
- }
728
-
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");
745
- }
746
- return false;
747
- }
748
- }
749
-
750
- function hasPendingRootMessages(ctx: ExtensionContext): boolean {
751
- try {
752
- return ctx.hasPendingMessages();
753
- } catch {
754
- return true;
755
- }
756
- }
757
-
758
- function sendDetachedCompletion(
759
- pi: ExtensionAPI,
760
- completion: AgentTurnCompletion,
761
- ): void {
610
+ function sendDetachedCompletion(pi: ExtensionAPI, completion: AgentTurnCompletion): void {
762
611
  const content = buildDetachedCompletionMessage(completion);
763
612
  pi.sendMessage(
764
613
  {
@@ -798,8 +647,8 @@ export function buildDetachedCompletionMessage(completion: AgentTurnCompletion):
798
647
  }
799
648
 
800
649
  function sanitizeCompletionLine(value: string, maxBytes: number): string {
801
- return truncateUtf8(redactPrivateText(value), maxBytes).text
802
- .replace(/[\u0000-\u001f\u007f]+/g, " ")
650
+ return truncateUtf8(redactPrivateText(value), maxBytes)
651
+ .text.replace(/[\u0000-\u001f\u007f]+/g, " ")
803
652
  .replace(/\s+/g, " ")
804
653
  .trim();
805
654
  }
package/src/subagents.ts CHANGED
@@ -39,9 +39,9 @@ export default function (pi: ExtensionAPI) {
39
39
  "Use no subagent for simple answers, quick targeted edits, latency-sensitive one-step work, tasks requiring frequent user back-and-forth, or critical-path work needed for the main agent's next action.",
40
40
  "A single blocking subagent call should be used only when independent context, high-volume output isolation, or an external review is worth waiting for; otherwise do the task in the main agent.",
41
41
  "For one-shot parallel work, use a single subagent call with tasks instead of repeated subagent_spawn calls, even when the user explicitly requests multiple agents.",
42
- "When subagent_spawn is available, use it only when detached delegation has a concrete parallel, isolation, or specialization benefit; otherwise keep the work in the main agent.",
43
- "After detached spawn, continue useful non-overlapping work when available, or call subagent_wait when coordination is the only useful next action; do not yield permanently while delegated work remains unresolved.",
44
- "Consume detached completion messages and synthesize their results before finishing; interrupt or close agents that are no longer needed.",
42
+ "When subagent_spawn is available, use it only for a concrete bounded subtask that can run independently alongside useful main-agent work and has a parallel, isolation, or specialization benefit; otherwise keep the work in the main agent.",
43
+ "After subagent_spawn returns, do useful non-overlapping 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.",
44
+ "Consume and synthesize available subagent_spawn completion messages; interrupt or close agents that are no longer needed.",
45
45
  "Use subagent parallel mode with 2-4 parallel read-only subagents when work has broad independent branches; prefer scout or reviewer for fan-out and add an aggregator when synthesis helps.",
46
46
  "Use more than 4 subagent tasks only when clearly justified by distinct independent branches, and stay within the existing hard max 8 parallel tasks.",
47
47
  "Do not use subagent parallel mode for write-heavy implementation touching the same files or shared state; serialize those changes in the main agent or one worker.",
@@ -65,7 +65,8 @@ export default function (pi: ExtensionAPI) {
65
65
 
66
66
  pi.on("tool_result", (event) => {
67
67
  if (event.toolName !== "subagent") return;
68
- if ((event.details as (SubagentDetails & { isError?: boolean }) | undefined)?.isError) return { isError: true };
68
+ if ((event.details as (SubagentDetails & { isError?: boolean }) | undefined)?.isError)
69
+ return { isError: true };
69
70
  });
70
71
 
71
72
  pi.on("session_start", (_event, ctx) => {
@@ -1,164 +0,0 @@
1
- import { randomUUID } from "node:crypto";
2
-
3
- export type OrchestrationRecoveryTicket = {
4
- generation: number;
5
- revision: number;
6
- nonce: string;
7
- prompt: string;
8
- };
9
-
10
- type DelegatedAgent = {
11
- live: boolean;
12
- resultAvailable: boolean;
13
- observedInTurn: boolean;
14
- };
15
-
16
- export const ORCHESTRATION_MARKER_PREFIX = "pi-subagent-orchestration:";
17
-
18
- const RECOVERY_PROMPT = [
19
- "Delegated subagents still need root coordination.",
20
- "Do not yield permanently while current delegated work is unresolved.",
21
- "Continue useful non-overlapping local work, or call subagent_wait when no useful local continuation remains.",
22
- "Consume available subagent results and synthesize them before finishing.",
23
- "Interrupt or close agents that are no longer needed; do not wait forever or spawn more agents unnecessarily.",
24
- ].join(" ");
25
-
26
- /** Ephemeral root-turn coordination state. Never persisted across sessions. */
27
- export class RootOrchestrationState {
28
- private generation = 0;
29
- private revision = 0;
30
- private turnOpen = false;
31
- private agents = new Map<string, DelegatedAgent>();
32
- private pending: OrchestrationRecoveryTicket | undefined;
33
- private deliveredRevision: number | undefined;
34
-
35
- reset(): void {
36
- this.generation += 1;
37
- this.revision = 0;
38
- this.turnOpen = false;
39
- this.agents.clear();
40
- this.pending = undefined;
41
- this.deliveredRevision = undefined;
42
- }
43
-
44
- beginTurn(): void {
45
- this.turnOpen = true;
46
- if (this.pending) this.deliveredRevision = this.pending.revision;
47
- this.observeAvailable();
48
- // New root work supersedes an intent that has not been delivered yet. The
49
- // turn gets a chance to coordinate before agent_end evaluates it again.
50
- this.pending = undefined;
51
- }
52
-
53
- spawn(agentId: string): void {
54
- this.agents.set(agentId, {
55
- live: true,
56
- resultAvailable: false,
57
- observedInTurn: false,
58
- });
59
- this.bumpRevision();
60
- }
61
-
62
- complete(agentId: string): void {
63
- const existing = this.agents.get(agentId);
64
- if (!existing) return;
65
- this.agents.set(agentId, {
66
- live: false,
67
- resultAvailable: true,
68
- observedInTurn: false,
69
- });
70
- this.bumpRevision();
71
- if (!this.turnOpen) this.ensureRecovery();
72
- }
73
-
74
- resolve(agentId: string): void {
75
- if (!this.agents.delete(agentId)) return;
76
- this.bumpRevision();
77
- }
78
-
79
- observe(agentId: string): void {
80
- const agent = this.agents.get(agentId);
81
- if (agent && !agent.live && agent.resultAvailable) agent.observedInTurn = true;
82
- }
83
-
84
- observeAvailable(): void {
85
- for (const agent of this.agents.values()) {
86
- if (!agent.live && agent.resultAvailable) agent.observedInTurn = true;
87
- }
88
- }
89
-
90
- endTurn(): OrchestrationRecoveryTicket | undefined {
91
- if (this.turnOpen) {
92
- for (const [id, agent] of this.agents) {
93
- if (!agent.live && agent.resultAvailable && agent.observedInTurn) {
94
- this.agents.delete(id);
95
- }
96
- }
97
- this.turnOpen = false;
98
- }
99
- return this.ensureRecovery();
100
- }
101
-
102
- pendingTicket(): OrchestrationRecoveryTicket | undefined {
103
- return this.pending;
104
- }
105
-
106
- isCurrent(ticket: OrchestrationRecoveryTicket): boolean {
107
- return (
108
- ticket.generation === this.generation &&
109
- ticket.revision === this.revision &&
110
- ticket.nonce === this.pending?.nonce &&
111
- this.hasUnresolved()
112
- );
113
- }
114
-
115
- markDelivered(ticket: OrchestrationRecoveryTicket): void {
116
- if (!this.isCurrent(ticket)) return;
117
- this.pending = undefined;
118
- this.deliveredRevision = ticket.revision;
119
- }
120
-
121
- supersedePending(): OrchestrationRecoveryTicket | undefined {
122
- const ticket = this.pending;
123
- if (!ticket) return undefined;
124
- this.pending = undefined;
125
- this.deliveredRevision = ticket.revision;
126
- return ticket;
127
- }
128
-
129
- hasUnresolved(): boolean {
130
- return this.agents.size > 0;
131
- }
132
-
133
- liveAgentIds(): string[] {
134
- return [...this.agents]
135
- .filter(([, agent]) => agent.live)
136
- .map(([id]) => id)
137
- .sort();
138
- }
139
-
140
- private ensureRecovery(): OrchestrationRecoveryTicket | undefined {
141
- if (!this.hasUnresolved()) {
142
- this.pending = undefined;
143
- return undefined;
144
- }
145
- if (this.deliveredRevision === this.revision) return undefined;
146
- if (this.pending?.revision === this.revision && this.pending.generation === this.generation) {
147
- return this.pending;
148
- }
149
- const nonce = randomUUID();
150
- this.pending = {
151
- generation: this.generation,
152
- revision: this.revision,
153
- nonce,
154
- prompt: `${RECOVERY_PROMPT}\n<!-- ${ORCHESTRATION_MARKER_PREFIX}${nonce} -->`,
155
- };
156
- return this.pending;
157
- }
158
-
159
- private bumpRevision(): void {
160
- this.revision += 1;
161
- this.pending = undefined;
162
- this.deliveredRevision = undefined;
163
- }
164
- }