@narumitw/pi-subagents 0.42.0 → 0.43.1

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,5 +1,4 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import * as path from "node:path";
3
2
  import { StringEnum } from "@earendil-works/pi-ai";
4
3
  import {
5
4
  defineTool,
@@ -13,9 +12,15 @@ import {
13
12
  discoverAgents,
14
13
  isThinkingLevel,
15
14
  type SubagentRuntimeSettings,
15
+ type SubagentSettings,
16
16
  THINKING_LEVELS,
17
17
  } from "./agents.js";
18
18
  import { buildContextSnapshot, type ContextMode, redactPrivateText } from "./context.js";
19
+ import {
20
+ assertDelegationTargetAllowed,
21
+ resolveSubagentTarget,
22
+ targetPolicyAudit,
23
+ } from "./cwd-policy.js";
19
24
  import { assertSubagentDepthAllowed } from "./execution.js";
20
25
  import {
21
26
  type ChildSessionFactory,
@@ -24,8 +29,29 @@ import {
24
29
  } from "./in-process-transport.js";
25
30
  import { DEFAULT_MAX_CONTEXT_BYTES, truncateUtf8 } from "./limits.js";
26
31
  import { AgentPersistence } from "./persistence.js";
27
- import { AgentRegistry, type AgentTurnCompletion, type ManagedAgent } from "./registry.js";
28
- import { readSubagentSettings } from "./settings.js";
32
+ import {
33
+ AgentRegistry,
34
+ type AgentRunInspectionDetail,
35
+ type AgentRunInspectionSummary,
36
+ type AgentTurnCompletion,
37
+ type ManagedAgent,
38
+ } from "./registry.js";
39
+ import { DEFAULT_DELEGATION_CWD_POLICY, readSubagentSettings } from "./settings.js";
40
+ import { createSpawnPromptGuidelines } from "./stateful-guidance.js";
41
+ import { assertCurrentSpawn, disposeStatefulRuntime } from "./stateful-lifecycle.js";
42
+ import { createStatefulToolRenderer } from "./stateful-render.js";
43
+ import {
44
+ assertFollowUpWriteAllowed,
45
+ assertNoSharedWriteConflict,
46
+ confirmProjectAgent,
47
+ } from "./stateful-safety.js";
48
+
49
+ export {
50
+ assertFollowUpWriteAllowed,
51
+ assertNoSharedWriteConflict,
52
+ isWriteCapable,
53
+ } from "./stateful-safety.js";
54
+
29
55
  import {
30
56
  MailboxParamsSchema,
31
57
  ManageParamsSchema,
@@ -53,45 +79,12 @@ const MAX_COMPLETION_ERROR_BYTES = 512;
53
79
  const MAX_COMPLETIONS_PER_MESSAGE = 16;
54
80
  const COMPLETION_BATCH_DELAY_MS = 10;
55
81
 
56
- function createSpawnPromptGuidelines(
57
- completionDelivery: CompletionDelivery,
58
- blockingEnabled = true,
59
- ): string[] {
60
- const deliveryGuidance =
61
- completionDelivery === "auto-resume"
62
- ? blockingEnabled
63
- ? "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."
64
- : "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."
65
- : blockingEnabled
66
- ? "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."
67
- : "With subagent_spawn completion delivery set to next-turn (the default), use subagent_spawn only when the current response does not depend on its result; complete final-answer-dependent work directly because an idle root is not awakened.";
68
- const noLocalWorkGuidance =
69
- completionDelivery === "auto-resume"
70
- ? "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."
71
- : "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.";
72
- return [
73
- "Do not use subagent_spawn for simple or critical-path work that the main agent can perform directly.",
74
- "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.",
75
- deliveryGuidance,
76
- "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.",
77
- ...(blockingEnabled
78
- ? [
79
- "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.",
80
- "When subagent_spawn fits the completion-delivery policy, do not choose a blocking parallel subagent merely to keep delegation in the same turn.",
81
- ]
82
- : []),
83
- "Add another subagent_spawn only for truly independent work with safe workspace concurrency.",
84
- noLocalWorkGuidance,
85
- 'Consume and synthesize available subagent_spawn completion messages; use subagent_manage with action "interrupt" or "close" for agents that are no longer needed.',
86
- 'Completion from subagent_spawn is delivered automatically. Do not poll with subagent_manage action "list" or subagent_mailbox action "read", repeatedly check progress, or duplicate the delegated work.',
87
- ];
88
- }
89
-
90
82
  export interface StatefulSubagentDependencies {
91
83
  blockingEnabled?: boolean;
92
84
  createInProcessSession?: ChildSessionFactory;
93
85
  workspaceManager?: WorkspaceManager;
94
86
  settings?: SubagentRuntimeSettings;
87
+ getSettings?: () => SubagentSettings | undefined;
95
88
  }
96
89
 
97
90
  export interface StatefulSubagentRuntimeStatus {
@@ -107,8 +100,11 @@ export interface StatefulSubagentController {
107
100
  getCompletionDelivery(): CompletionDelivery;
108
101
  setCompletionDelivery(value: CompletionDelivery): void;
109
102
  setAgentCatalog(value: string): void;
103
+ refreshSettingsGuidance(): void;
110
104
  getRuntimeStatus(): StatefulSubagentRuntimeStatus;
111
105
  listAgents(includeClosed?: boolean): ManagedAgent[];
106
+ listRunInspection(includeClosed?: boolean): AgentRunInspectionSummary[];
107
+ getRunInspection(agentId: string): AgentRunInspectionDetail | undefined;
112
108
  clearAgents(): Promise<number>;
113
109
  }
114
110
 
@@ -135,23 +131,31 @@ export function registerStatefulSubagents(
135
131
  let persistence: AgentPersistence | undefined;
136
132
  let sweepTimer: NodeJS.Timeout | undefined;
137
133
  let runtimeGeneration = 0;
134
+ let runtimeTransition: Promise<void> = Promise.resolve();
138
135
  const workspaceManager = dependencies.workspaceManager ?? new WorkspaceManager();
139
136
  const isolatedAgents = new Map<string, string>();
140
137
  const seenMessageIds = new Set<string>();
141
138
  const parentRuntime: ParentRuntimeSnapshot = { model: undefined, thinkingLevel: "off" };
139
+ const getCurrentSettings = () =>
140
+ dependencies.getSettings ? dependencies.getSettings() : readSubagentSettings();
142
141
 
143
142
  const clearAgents = async (): Promise<number> => {
143
+ const generation = runtimeGeneration;
144
144
  const currentRegistry = registry;
145
+ const currentPersistence = persistence;
145
146
  if (!currentRegistry) return 0;
146
147
  const count = currentRegistry.list().length;
147
- try {
148
+ const clear = async () => {
148
149
  await currentRegistry.closeAll();
149
- } finally {
150
+ if (generation !== runtimeGeneration) return;
150
151
  await workspaceManager.cleanupAll();
151
152
  isolatedAgents.clear();
152
- }
153
- seenMessageIds.clear();
154
- await persistence?.delete();
153
+ seenMessageIds.clear();
154
+ await currentPersistence?.delete();
155
+ };
156
+ const transition = runtimeTransition.then(clear, clear);
157
+ runtimeTransition = transition.catch(() => undefined);
158
+ await transition;
155
159
  return count;
156
160
  };
157
161
  const controller: StatefulSubagentController = {
@@ -167,22 +171,28 @@ export function registerStatefulSubagents(
167
171
  agentCatalog = value;
168
172
  refreshSpawnToolRegistration?.();
169
173
  },
174
+ refreshSettingsGuidance() {
175
+ refreshSpawnToolRegistration?.();
176
+ },
170
177
  getRuntimeStatus() {
171
- const agents = registry?.list(true) ?? [];
178
+ const counts = registry?.inspectionCounts() ?? { activeAgents: 0, retainedAgents: 0 };
172
179
  return {
173
180
  enabled,
174
181
  initialized: registry !== undefined,
175
182
  transport: transportKind,
176
183
  completionDelivery,
177
- activeAgents: agents.filter(
178
- (agent) => agent.state === "starting" || agent.state === "running",
179
- ).length,
180
- retainedAgents: agents.filter((agent) => agent.state !== "closed").length,
184
+ ...counts,
181
185
  };
182
186
  },
183
187
  listAgents(includeClosed = false) {
184
188
  return registry?.list(includeClosed) ?? [];
185
189
  },
190
+ listRunInspection(includeClosed = false) {
191
+ return registry?.listInspection(includeClosed) ?? [];
192
+ },
193
+ getRunInspection(agentId) {
194
+ return registry?.getInspection(agentId);
195
+ },
186
196
  clearAgents,
187
197
  };
188
198
  if (!enabled) return controller;
@@ -201,79 +211,125 @@ export function registerStatefulSubagents(
201
211
  const generation = ++runtimeGeneration;
202
212
  completionBroker?.close();
203
213
  completionBroker = undefined;
204
- parentRuntime.model = ctx.model;
205
- parentRuntime.thinkingLevel = normalizeRuntimeThinkingLevel(pi.getThinkingLevel());
206
- const owner =
207
- ctx.sessionManager.getSessionId?.() ??
208
- ctx.sessionManager.getSessionFile?.() ??
209
- `ephemeral:${ctx.cwd}`;
210
- const sessionPersistence = new AgentPersistence(owner, {
211
- retentionDays: settings.retentionDays,
212
- maxStoredAgents: settings.maxStoredAgents,
213
- });
214
- persistence = sessionPersistence;
215
- completionBroker = new CompletionDeliveryBroker(pi, ctx, completionDelivery, {
216
- onDeliveryError: (error) => {
217
- if (!ctx.hasUI) return;
218
- const reason = error instanceof Error ? error.message : String(error);
219
- ctx.ui.notify(`Subagent completion delivery failed: ${reason}`, "warning");
220
- },
221
- });
222
- const transport =
223
- transportKind === "in-process"
224
- ? new InProcessTransport({
225
- modelRegistry: ctx.modelRegistry,
226
- getParentRuntime: () => ({ ...parentRuntime }),
227
- createSession: dependencies.createInProcessSession,
228
- })
229
- : new SubprocessTransport();
230
- registry = new AgentRegistry(transport, {
231
- maxAgents: settings.maxAgents,
232
- maxActiveTurns: settings.maxActiveTurns,
233
- maxDepth: settings.maxDepth,
234
- maxChildrenPerAgent: settings.maxChildrenPerAgent,
235
- maxMailboxMessages: settings.maxMailboxMessages,
236
- maxMailboxMessageBytes: settings.maxMailboxMessageBytes,
237
- idleTtlMs: settings.idleTtlMs,
238
- onChange: async (agents) => {
239
- await sessionPersistence.save(agents);
240
- if (generation !== runtimeGeneration) return;
241
- for (const agent of agents) {
242
- for (const message of agent.mailbox) {
243
- if (seenMessageIds.has(message.id)) continue;
244
- seenMessageIds.add(message.id);
245
- pi.appendEntry("pi-subagent-message", {
246
- senderId: message.senderId,
247
- recipientId: message.recipientId,
248
- content: redactPrivateText(message.content).slice(0, 160),
249
- });
214
+ if (sweepTimer) clearInterval(sweepTimer);
215
+ sweepTimer = undefined;
216
+ const previousRegistry = registry;
217
+ registry = undefined;
218
+ persistence = undefined;
219
+ isolatedAgents.clear();
220
+ seenMessageIds.clear();
221
+ const initialize = async () => {
222
+ const cleanupErrors = await disposeStatefulRuntime(previousRegistry, workspaceManager);
223
+ if (generation !== runtimeGeneration) return;
224
+ if (cleanupErrors.length > 0 && ctx.hasUI) {
225
+ ctx.ui.notify(
226
+ `Previous subagent runtime cleanup reported ${cleanupErrors.length} error(s).`,
227
+ "warning",
228
+ );
229
+ }
230
+ parentRuntime.model = ctx.model;
231
+ parentRuntime.thinkingLevel = normalizeRuntimeThinkingLevel(pi.getThinkingLevel());
232
+ const owner =
233
+ ctx.sessionManager.getSessionId?.() ??
234
+ ctx.sessionManager.getSessionFile?.() ??
235
+ `ephemeral:${ctx.cwd}`;
236
+ const sessionPersistence = new AgentPersistence(owner, {
237
+ retentionDays: settings.retentionDays,
238
+ maxStoredAgents: settings.maxStoredAgents,
239
+ });
240
+ const sessionBroker = new CompletionDeliveryBroker(pi, ctx, completionDelivery, {
241
+ onDeliveryError: (error) => {
242
+ if (!ctx.hasUI) return;
243
+ const reason = error instanceof Error ? error.message : String(error);
244
+ ctx.ui.notify(`Subagent completion delivery failed: ${reason}`, "warning");
245
+ },
246
+ });
247
+ const transport =
248
+ transportKind === "in-process"
249
+ ? new InProcessTransport({
250
+ modelRegistry: ctx.modelRegistry,
251
+ getParentRuntime: () => ({ ...parentRuntime }),
252
+ createSession: dependencies.createInProcessSession,
253
+ discoverAgent: (agent) =>
254
+ discoverAgents(
255
+ agent.cwd,
256
+ agent.agentScope ?? "user",
257
+ getCurrentSettings(),
258
+ ).agents.find((candidate) => candidate.name === agent.agent),
259
+ })
260
+ : new SubprocessTransport({ getSettings: getCurrentSettings });
261
+ const nextRegistry = new AgentRegistry(transport, {
262
+ maxAgents: settings.maxAgents,
263
+ maxActiveTurns: settings.maxActiveTurns,
264
+ maxDepth: settings.maxDepth,
265
+ maxChildrenPerAgent: settings.maxChildrenPerAgent,
266
+ maxMailboxMessages: settings.maxMailboxMessages,
267
+ maxMailboxMessageBytes: settings.maxMailboxMessageBytes,
268
+ idleTtlMs: settings.idleTtlMs,
269
+ onChange: async (agents) => {
270
+ await sessionPersistence.save(agents);
271
+ if (generation !== runtimeGeneration) return;
272
+ for (const agent of agents) {
273
+ for (const message of agent.mailbox) {
274
+ if (seenMessageIds.has(message.id)) continue;
275
+ seenMessageIds.add(message.id);
276
+ pi.appendEntry("pi-subagent-message", {
277
+ senderId: message.senderId,
278
+ recipientId: message.recipientId,
279
+ content: redactPrivateText(message.content).slice(0, 160),
280
+ });
281
+ }
250
282
  }
251
- }
252
- },
253
- onTurnComplete: (completion) => {
254
- if (generation !== runtimeGeneration) return;
255
- completionBroker?.enqueue(completion);
256
- },
257
- });
258
- const restored = sessionPersistence
259
- .load()
260
- .filter(
261
- (agent) =>
262
- (agent.agentScope !== "project" && agent.agentScope !== "both") || ctx.isProjectTrusted(),
263
- );
264
- for (const agent of restored) {
265
- for (const message of agent.mailbox) seenMessageIds.add(message.id);
266
- }
267
- registry.restore(restored);
268
- const sweepEveryMs = Math.max(1_000, Math.min(settings.idleTtlMs ?? 60 * 60 * 1000, 60_000));
269
- sweepTimer = setInterval(() => {
270
- void registry?.sweepExpired().catch((error: unknown) => {
271
- if (!ctx.hasUI) return;
272
- const reason = error instanceof Error ? error.message : String(error);
273
- ctx.ui.notify(`Subagent expiry cleanup failed: ${reason}`, "warning");
283
+ },
284
+ onTurnComplete: (completion) => {
285
+ if (generation === runtimeGeneration) sessionBroker.enqueue(completion);
286
+ },
274
287
  });
275
- }, sweepEveryMs);
276
- sweepTimer.unref();
288
+ const restored = sessionPersistence
289
+ .load()
290
+ .filter(
291
+ (agent) =>
292
+ agent.workspaceMode !== "worktree" &&
293
+ ((agent.agentScope !== "project" && agent.agentScope !== "both") ||
294
+ ctx.isProjectTrusted()),
295
+ )
296
+ .flatMap((agent) => {
297
+ try {
298
+ const target = resolveSubagentTarget({
299
+ workspace: ctx.cwd,
300
+ requestedCwd: agent.cwd,
301
+ currentProjectTrusted: ctx.isProjectTrusted(),
302
+ });
303
+ return [{ ...agent, cwd: target.cwd, target: targetPolicyAudit(target) }];
304
+ } catch {
305
+ return [];
306
+ }
307
+ });
308
+ for (const agent of restored) {
309
+ for (const message of agent.mailbox) seenMessageIds.add(message.id);
310
+ }
311
+ nextRegistry.restore(restored);
312
+ if (generation !== runtimeGeneration) {
313
+ sessionBroker.close();
314
+ await disposeStatefulRuntime(nextRegistry, workspaceManager);
315
+ return;
316
+ }
317
+ registry = nextRegistry;
318
+ persistence = sessionPersistence;
319
+ completionBroker = sessionBroker;
320
+ const sweepEveryMs = Math.max(1_000, Math.min(settings.idleTtlMs ?? 60 * 60 * 1000, 60_000));
321
+ sweepTimer = setInterval(() => {
322
+ void nextRegistry.sweepExpired().catch((error: unknown) => {
323
+ if (!ctx.hasUI || generation !== runtimeGeneration) return;
324
+ const reason = error instanceof Error ? error.message : String(error);
325
+ ctx.ui.notify(`Subagent expiry cleanup failed: ${reason}`, "warning");
326
+ });
327
+ }, sweepEveryMs);
328
+ sweepTimer.unref();
329
+ };
330
+ const transition = runtimeTransition.then(initialize, initialize);
331
+ runtimeTransition = transition.catch(() => undefined);
332
+ await transition;
277
333
  });
278
334
 
279
335
  pi.on("agent_start", () => {
@@ -298,35 +354,28 @@ export function registerStatefulSubagents(
298
354
  completionBroker = undefined;
299
355
  if (sweepTimer) clearInterval(sweepTimer);
300
356
  sweepTimer = undefined;
301
- for (const agentId of isolatedAgents.keys()) {
302
- await registry?.closeTree(agentId).catch(() => undefined);
303
- }
357
+ const previousRegistry = registry;
358
+ registry = undefined;
359
+ persistence = undefined;
304
360
  isolatedAgents.clear();
305
361
  seenMessageIds.clear();
306
- let cleanupError: unknown;
307
- try {
308
- await workspaceManager.cleanupAll();
309
- } catch (error) {
310
- cleanupError = error;
311
- }
312
- try {
313
- await registry?.shutdown();
314
- } finally {
315
- registry = undefined;
316
- persistence = undefined;
317
- }
318
- if (cleanupError && ctx.hasUI) {
319
- const reason = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
320
- ctx.ui.notify(`Some isolated subagent workspaces could not be removed: ${reason}`, "warning");
321
- }
362
+ const shutdown = async () => {
363
+ const errors = await disposeStatefulRuntime(previousRegistry, workspaceManager);
364
+ if (errors.length > 0 && ctx.hasUI) {
365
+ ctx.ui.notify(`Subagent shutdown cleanup reported ${errors.length} error(s).`, "warning");
366
+ }
367
+ };
368
+ const transition = runtimeTransition.then(shutdown, shutdown);
369
+ runtimeTransition = transition.catch(() => undefined);
370
+ await transition;
322
371
  });
323
372
 
324
- const baseSpawnDescription =
325
- "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.";
373
+ const baseSpawnDescription = () =>
374
+ `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. Working-directory target policy: ${dependencies.getSettings?.()?.cwdPolicy?.delegation ?? DEFAULT_DELEGATION_CWD_POLICY}. This controls launch targets and protected project resources, not filesystem access or sandboxing.`;
326
375
  const spawnTool = defineTool({
327
376
  name: "subagent_spawn",
328
377
  label: "Spawn Subagent",
329
- description: appendAgentCatalog(baseSpawnDescription, agentCatalog),
378
+ description: appendAgentCatalog(baseSpawnDescription(), agentCatalog),
330
379
  promptSnippet: "Start a reusable detached subagent; completion is delivered asynchronously",
331
380
  promptGuidelines: createSpawnPromptGuidelines(completionDelivery, blockingEnabled),
332
381
  parameters: Type.Object({
@@ -350,12 +399,33 @@ export function registerStatefulSubagents(
350
399
  }),
351
400
  ),
352
401
  }),
353
- async execute(_id, params, _signal, _update, ctx) {
402
+ ...createStatefulToolRenderer("spawn"),
403
+ async execute(_id, params, signal, _update, ctx) {
354
404
  const scope = (params.agentScope ?? "user") as AgentScope;
355
405
  assertSubagentDepthAllowed();
356
- const cwd = params.cwd ?? ctx.cwd;
357
- await confirmProjectAgent(params.agent, scope, params.confirmProjectAgents ?? true, ctx, cwd);
358
- const resolvedAgent = discoverAgents(cwd, scope, readSubagentSettings()).agents.find(
406
+ const generation = runtimeGeneration;
407
+ const currentSettings = getCurrentSettings();
408
+ const target = resolveSubagentTarget({
409
+ workspace: ctx.cwd,
410
+ requestedCwd: params.cwd,
411
+ currentProjectTrusted: ctx.isProjectTrusted(),
412
+ });
413
+ assertDelegationTargetAllowed(
414
+ target,
415
+ currentSettings?.cwdPolicy?.delegation ?? DEFAULT_DELEGATION_CWD_POLICY,
416
+ );
417
+ const cwd = target.cwd;
418
+ await confirmProjectAgent(
419
+ params.agent,
420
+ scope,
421
+ params.confirmProjectAgents ?? true,
422
+ ctx,
423
+ cwd,
424
+ currentSettings,
425
+ );
426
+ assertCurrentSpawn(signal, generation, runtimeGeneration);
427
+ const ownedRegistry = requireRegistry();
428
+ const resolvedAgent = discoverAgents(cwd, scope, currentSettings).agents.find(
359
429
  (agent) => agent.name === params.agent,
360
430
  );
361
431
  if (params.workspaceMode === "worktree" && resolvedAgent?.source === "project") {
@@ -370,16 +440,29 @@ export function registerStatefulSubagents(
370
440
  );
371
441
  const requestedCwd = cwd;
372
442
  if ((params.workspaceMode ?? "shared") === "shared" && !params.allowConcurrentWrites) {
373
- assertNoSharedWriteConflict(requireRegistry(), params.agent, requestedCwd, scope);
443
+ assertNoSharedWriteConflict(
444
+ ownedRegistry,
445
+ params.agent,
446
+ requestedCwd,
447
+ scope,
448
+ currentSettings,
449
+ );
374
450
  }
375
451
  const workspaceOwner = `pending-${randomUUID()}`;
376
452
  const workspace =
377
453
  params.workspaceMode === "worktree"
378
454
  ? await workspaceManager.create(workspaceOwner, requestedCwd)
379
455
  : undefined;
380
- let agent: ManagedAgent;
381
456
  try {
382
- agent = await requireRegistry().spawn({
457
+ assertCurrentSpawn(signal, generation, runtimeGeneration);
458
+ } catch (error) {
459
+ if (workspace) await workspaceManager.cleanup(workspaceOwner);
460
+ throw error;
461
+ }
462
+ const targetSnapshot = targetPolicyAudit(target);
463
+ let agent: ManagedAgent | undefined;
464
+ try {
465
+ agent = await ownedRegistry.spawn({
383
466
  agent: params.agent,
384
467
  task: params.task,
385
468
  cwd: workspace?.path ?? requestedCwd,
@@ -389,11 +472,16 @@ export function registerStatefulSubagents(
389
472
  context: snapshot.text || undefined,
390
473
  contextSourceIds: snapshot.sourceIds,
391
474
  contextTruncated: snapshot.truncated,
475
+ workspaceMode: workspace ? "worktree" : undefined,
476
+ target: targetSnapshot,
392
477
  });
478
+ assertCurrentSpawn(signal, generation, runtimeGeneration);
393
479
  } catch (error) {
480
+ if (agent) await ownedRegistry.closeTree(agent.id).catch(() => undefined);
394
481
  if (workspace) await workspaceManager.cleanup(workspaceOwner);
395
482
  throw error;
396
483
  }
484
+ if (!agent) throw new Error("Subagent spawn completed without a retained agent");
397
485
  if (workspace) isolatedAgents.set(agent.id, workspaceOwner);
398
486
  const deliveryNote =
399
487
  completionDelivery === "auto-resume"
@@ -406,7 +494,7 @@ export function registerStatefulSubagents(
406
494
  },
407
495
  });
408
496
  refreshSpawnToolRegistration = () => {
409
- spawnTool.description = appendAgentCatalog(baseSpawnDescription, agentCatalog);
497
+ spawnTool.description = appendAgentCatalog(baseSpawnDescription(), agentCatalog);
410
498
  spawnTool.promptGuidelines = createSpawnPromptGuidelines(completionDelivery, blockingEnabled);
411
499
  pi.registerTool(spawnTool);
412
500
  };
@@ -425,8 +513,12 @@ export function registerStatefulSubagents(
425
513
  Type.Boolean({ description: "Override the shared-workspace write conflict guard." }),
426
514
  ),
427
515
  }),
428
- async execute(_id, params, _signal, _update, ctx) {
429
- const existing = requireRegistry().get(params.agentId);
516
+ ...createStatefulToolRenderer("send"),
517
+ async execute(_id, params, signal, _update, ctx) {
518
+ const generation = runtimeGeneration;
519
+ const ownedRegistry = requireRegistry();
520
+ const currentSettings = getCurrentSettings();
521
+ const existing = ownedRegistry.get(params.agentId);
430
522
  if (!existing) throw new Error(`Unknown subagent: ${params.agentId}`);
431
523
  await confirmProjectAgent(
432
524
  existing.agent,
@@ -434,14 +526,18 @@ export function registerStatefulSubagents(
434
526
  false,
435
527
  ctx,
436
528
  existing.cwd,
529
+ currentSettings,
437
530
  );
531
+ assertCurrentSpawn(signal, generation, runtimeGeneration);
438
532
  assertFollowUpWriteAllowed(
439
- requireRegistry(),
533
+ ownedRegistry,
440
534
  existing,
441
535
  params.allowConcurrentWrites ?? false,
442
536
  isolatedAgents.has(existing.id),
537
+ currentSettings,
443
538
  );
444
- const agent = await requireRegistry().followUp(params.agentId, params.task);
539
+ const agent = await ownedRegistry.followUp(params.agentId, params.task);
540
+ assertCurrentSpawn(signal, generation, runtimeGeneration);
445
541
  return result(agent, `Started follow-up for ${agent.id}.`);
446
542
  },
447
543
  });
@@ -450,9 +546,10 @@ export function registerStatefulSubagents(
450
546
  name: "subagent_manage",
451
547
  label: "Manage Subagents",
452
548
  description:
453
- "List retained subagents, interrupt active work while keeping an agent reusable, or close agents and release their resources.",
549
+ "List retained subagents through the compatibility route, interrupt active work while keeping an agent reusable, or close agents and release their resources. Prefer subagent_inspect when the whole activated capability must be read-only.",
454
550
  promptSnippet: "List or control retained detached subagents",
455
551
  parameters: ManageParamsSchema,
552
+ ...createStatefulToolRenderer("manage"),
456
553
  async execute(_id, params): Promise<StatefulActionToolResult> {
457
554
  const operation = validateManageParams(params);
458
555
  if (operation.action === "list") {
@@ -518,9 +615,10 @@ export function registerStatefulSubagents(
518
615
  name: "subagent_mailbox",
519
616
  label: "Subagent Mailbox",
520
617
  description:
521
- "Queue a bounded message without starting a turn, or read unread mailbox messages and optionally acknowledge them.",
618
+ "Queue a bounded message without starting a turn, or read unread mailbox messages. Read acknowledges returned messages by default; use subagent_inspect for metadata-only unread counts.",
522
619
  promptSnippet: "Send or read queue-only detached-subagent mailbox messages",
523
620
  parameters: MailboxParamsSchema,
621
+ ...createStatefulToolRenderer("mailbox"),
524
622
  async execute(_id, params): Promise<StatefulActionToolResult> {
525
623
  const operation = validateMailboxParams(params);
526
624
  if (operation.action === "send") {
@@ -559,77 +657,6 @@ export function registerStatefulSubagents(
559
657
  return controller;
560
658
  }
561
659
 
562
- export function assertNoSharedWriteConflict(
563
- registry: AgentRegistry,
564
- agentName: string,
565
- cwd: string,
566
- scope: AgentScope,
567
- ): void {
568
- const agents = discoverAgents(cwd, scope, readSubagentSettings()).agents;
569
- const requested = agents.find((agent) => agent.name === agentName);
570
- if (!isWriteCapable(requested?.tools)) return;
571
- for (const active of registry.list()) {
572
- if (
573
- !isSameCwd(active.cwd, cwd) ||
574
- (active.state !== "running" && active.state !== "starting")
575
- ) {
576
- continue;
577
- }
578
- const activeConfig = agents.find((agent) => agent.name === active.agent);
579
- if (isWriteCapable(activeConfig?.tools)) {
580
- throw new Error(
581
- `Write-capable subagent ${active.id} is already active in shared workspace ${cwd}. ` +
582
- "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.",
583
- );
584
- }
585
- }
586
- }
587
-
588
- export function assertFollowUpWriteAllowed(
589
- registry: AgentRegistry,
590
- agent: ManagedAgent,
591
- allowConcurrentWrites: boolean,
592
- isolatedWorkspace: boolean,
593
- ): void {
594
- if (allowConcurrentWrites || isolatedWorkspace) return;
595
- assertNoSharedWriteConflict(registry, agent.agent, agent.cwd, agent.agentScope ?? "user");
596
- }
597
-
598
- export function isWriteCapable(tools: string[] | undefined): boolean {
599
- if (!tools) return true;
600
- return tools.some((tool) => ["bash", "write", "edit"].includes(tool));
601
- }
602
-
603
- async function confirmProjectAgent(
604
- name: string,
605
- scope: AgentScope,
606
- confirm: boolean,
607
- ctx: ExtensionContext,
608
- cwd: string,
609
- ): Promise<void> {
610
- if (scope !== "project" && scope !== "both") return;
611
- const discovery = discoverAgents(cwd, scope, readSubagentSettings());
612
- const agent = discovery.agents.find((candidate) => candidate.name === name);
613
- if (agent?.source !== "project") return;
614
- if (!isSameCwd(cwd, ctx.cwd)) {
615
- throw new Error("Project-local subagent definitions cannot run with an overridden cwd");
616
- }
617
- if (!ctx.isProjectTrusted()) {
618
- throw new Error("Project-local subagent definitions require a trusted project");
619
- }
620
- if (confirm && ctx.hasUI) {
621
- const approved = await ctx.ui.confirm(
622
- "Run project-local agent?",
623
- `Agent: ${name}\nSource: ${agent.filePath}`,
624
- );
625
- if (!approved) throw new Error("Project-local subagent was not approved");
626
- }
627
- }
628
-
629
- function isSameCwd(left: string, right: string): boolean {
630
- return path.resolve(left) === path.resolve(right);
631
- }
632
-
633
660
  function normalizeContextMode(value: "none" | "all" | "summary" | number | undefined): ContextMode {
634
661
  if (value === undefined) return "none";
635
662
  if (value === "none" || value === "all" || value === "summary") return value;
@@ -686,6 +713,7 @@ function summarizeAgent(agent: ManagedAgent) {
686
713
  createdAt: agent.createdAt,
687
714
  updatedAt: agent.updatedAt,
688
715
  cwd: agent.cwd,
716
+ workspaceMode: agent.workspaceMode ?? "shared",
689
717
  thinkingLevel: agent.thinkingLevel,
690
718
  currentTask: agent.currentTask
691
719
  ? truncateUtf8(agent.currentTask, MAX_TOOL_MESSAGE_BYTES).text
@@ -693,6 +721,7 @@ function summarizeAgent(agent: ManagedAgent) {
693
721
  historyCount: agent.history.length,
694
722
  unreadMessages: agent.mailbox.filter((message) => !message.readAt).length,
695
723
  error: agent.error ? truncateUtf8(agent.error, MAX_TOOL_MESSAGE_BYTES).text : undefined,
724
+ target: agent.target,
696
725
  policy: agent.policy,
697
726
  };
698
727
  }