@narumitw/pi-subagents 0.43.0 → 0.46.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,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,
@@ -31,8 +36,22 @@ import {
31
36
  type AgentTurnCompletion,
32
37
  type ManagedAgent,
33
38
  } from "./registry.js";
34
- import { safeTerminalLine } from "./safe-text.js";
35
- import { readSubagentSettings } from "./settings.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
+
36
55
  import {
37
56
  MailboxParamsSchema,
38
57
  ManageParamsSchema,
@@ -60,45 +79,12 @@ const MAX_COMPLETION_ERROR_BYTES = 512;
60
79
  const MAX_COMPLETIONS_PER_MESSAGE = 16;
61
80
  const COMPLETION_BATCH_DELAY_MS = 10;
62
81
 
63
- function createSpawnPromptGuidelines(
64
- completionDelivery: CompletionDelivery,
65
- blockingEnabled = true,
66
- ): string[] {
67
- const deliveryGuidance =
68
- completionDelivery === "auto-resume"
69
- ? blockingEnabled
70
- ? "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."
71
- : "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."
72
- : blockingEnabled
73
- ? "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."
74
- : "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.";
75
- const noLocalWorkGuidance =
76
- completionDelivery === "auto-resume"
77
- ? "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."
78
- : "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.";
79
- return [
80
- "Do not use subagent_spawn for simple or critical-path work that the main agent can perform directly.",
81
- "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.",
82
- deliveryGuidance,
83
- "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.",
84
- ...(blockingEnabled
85
- ? [
86
- "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.",
87
- "When subagent_spawn fits the completion-delivery policy, do not choose a blocking parallel subagent merely to keep delegation in the same turn.",
88
- ]
89
- : []),
90
- "Add another subagent_spawn only for truly independent work with safe workspace concurrency.",
91
- noLocalWorkGuidance,
92
- 'Consume and synthesize available subagent_spawn completion messages; use subagent_manage with action "interrupt" or "close" for agents that are no longer needed.',
93
- '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.',
94
- ];
95
- }
96
-
97
82
  export interface StatefulSubagentDependencies {
98
83
  blockingEnabled?: boolean;
99
84
  createInProcessSession?: ChildSessionFactory;
100
85
  workspaceManager?: WorkspaceManager;
101
86
  settings?: SubagentRuntimeSettings;
87
+ getSettings?: () => SubagentSettings | undefined;
102
88
  }
103
89
 
104
90
  export interface StatefulSubagentRuntimeStatus {
@@ -114,6 +100,7 @@ export interface StatefulSubagentController {
114
100
  getCompletionDelivery(): CompletionDelivery;
115
101
  setCompletionDelivery(value: CompletionDelivery): void;
116
102
  setAgentCatalog(value: string): void;
103
+ refreshSettingsGuidance(): void;
117
104
  getRuntimeStatus(): StatefulSubagentRuntimeStatus;
118
105
  listAgents(includeClosed?: boolean): ManagedAgent[];
119
106
  listRunInspection(includeClosed?: boolean): AgentRunInspectionSummary[];
@@ -144,23 +131,31 @@ export function registerStatefulSubagents(
144
131
  let persistence: AgentPersistence | undefined;
145
132
  let sweepTimer: NodeJS.Timeout | undefined;
146
133
  let runtimeGeneration = 0;
134
+ let runtimeTransition: Promise<void> = Promise.resolve();
147
135
  const workspaceManager = dependencies.workspaceManager ?? new WorkspaceManager();
148
136
  const isolatedAgents = new Map<string, string>();
149
137
  const seenMessageIds = new Set<string>();
150
138
  const parentRuntime: ParentRuntimeSnapshot = { model: undefined, thinkingLevel: "off" };
139
+ const getCurrentSettings = () =>
140
+ dependencies.getSettings ? dependencies.getSettings() : readSubagentSettings();
151
141
 
152
142
  const clearAgents = async (): Promise<number> => {
143
+ const generation = runtimeGeneration;
153
144
  const currentRegistry = registry;
145
+ const currentPersistence = persistence;
154
146
  if (!currentRegistry) return 0;
155
147
  const count = currentRegistry.list().length;
156
- try {
148
+ const clear = async () => {
157
149
  await currentRegistry.closeAll();
158
- } finally {
150
+ if (generation !== runtimeGeneration) return;
159
151
  await workspaceManager.cleanupAll();
160
152
  isolatedAgents.clear();
161
- }
162
- seenMessageIds.clear();
163
- 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;
164
159
  return count;
165
160
  };
166
161
  const controller: StatefulSubagentController = {
@@ -176,6 +171,9 @@ export function registerStatefulSubagents(
176
171
  agentCatalog = value;
177
172
  refreshSpawnToolRegistration?.();
178
173
  },
174
+ refreshSettingsGuidance() {
175
+ refreshSpawnToolRegistration?.();
176
+ },
179
177
  getRuntimeStatus() {
180
178
  const counts = registry?.inspectionCounts() ?? { activeAgents: 0, retainedAgents: 0 };
181
179
  return {
@@ -213,79 +211,125 @@ export function registerStatefulSubagents(
213
211
  const generation = ++runtimeGeneration;
214
212
  completionBroker?.close();
215
213
  completionBroker = undefined;
216
- parentRuntime.model = ctx.model;
217
- parentRuntime.thinkingLevel = normalizeRuntimeThinkingLevel(pi.getThinkingLevel());
218
- const owner =
219
- ctx.sessionManager.getSessionId?.() ??
220
- ctx.sessionManager.getSessionFile?.() ??
221
- `ephemeral:${ctx.cwd}`;
222
- const sessionPersistence = new AgentPersistence(owner, {
223
- retentionDays: settings.retentionDays,
224
- maxStoredAgents: settings.maxStoredAgents,
225
- });
226
- persistence = sessionPersistence;
227
- completionBroker = new CompletionDeliveryBroker(pi, ctx, completionDelivery, {
228
- onDeliveryError: (error) => {
229
- if (!ctx.hasUI) return;
230
- const reason = error instanceof Error ? error.message : String(error);
231
- ctx.ui.notify(`Subagent completion delivery failed: ${reason}`, "warning");
232
- },
233
- });
234
- const transport =
235
- transportKind === "in-process"
236
- ? new InProcessTransport({
237
- modelRegistry: ctx.modelRegistry,
238
- getParentRuntime: () => ({ ...parentRuntime }),
239
- createSession: dependencies.createInProcessSession,
240
- })
241
- : new SubprocessTransport();
242
- registry = new AgentRegistry(transport, {
243
- maxAgents: settings.maxAgents,
244
- maxActiveTurns: settings.maxActiveTurns,
245
- maxDepth: settings.maxDepth,
246
- maxChildrenPerAgent: settings.maxChildrenPerAgent,
247
- maxMailboxMessages: settings.maxMailboxMessages,
248
- maxMailboxMessageBytes: settings.maxMailboxMessageBytes,
249
- idleTtlMs: settings.idleTtlMs,
250
- onChange: async (agents) => {
251
- await sessionPersistence.save(agents);
252
- if (generation !== runtimeGeneration) return;
253
- for (const agent of agents) {
254
- for (const message of agent.mailbox) {
255
- if (seenMessageIds.has(message.id)) continue;
256
- seenMessageIds.add(message.id);
257
- pi.appendEntry("pi-subagent-message", {
258
- senderId: message.senderId,
259
- recipientId: message.recipientId,
260
- content: redactPrivateText(message.content).slice(0, 160),
261
- });
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
+ }
262
282
  }
263
- }
264
- },
265
- onTurnComplete: (completion) => {
266
- if (generation !== runtimeGeneration) return;
267
- completionBroker?.enqueue(completion);
268
- },
269
- });
270
- const restored = sessionPersistence
271
- .load()
272
- .filter(
273
- (agent) =>
274
- (agent.agentScope !== "project" && agent.agentScope !== "both") || ctx.isProjectTrusted(),
275
- );
276
- for (const agent of restored) {
277
- for (const message of agent.mailbox) seenMessageIds.add(message.id);
278
- }
279
- registry.restore(restored);
280
- const sweepEveryMs = Math.max(1_000, Math.min(settings.idleTtlMs ?? 60 * 60 * 1000, 60_000));
281
- sweepTimer = setInterval(() => {
282
- void registry?.sweepExpired().catch((error: unknown) => {
283
- if (!ctx.hasUI) return;
284
- const reason = error instanceof Error ? error.message : String(error);
285
- ctx.ui.notify(`Subagent expiry cleanup failed: ${reason}`, "warning");
283
+ },
284
+ onTurnComplete: (completion) => {
285
+ if (generation === runtimeGeneration) sessionBroker.enqueue(completion);
286
+ },
286
287
  });
287
- }, sweepEveryMs);
288
- 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;
289
333
  });
290
334
 
291
335
  pi.on("agent_start", () => {
@@ -310,35 +354,28 @@ export function registerStatefulSubagents(
310
354
  completionBroker = undefined;
311
355
  if (sweepTimer) clearInterval(sweepTimer);
312
356
  sweepTimer = undefined;
313
- for (const agentId of isolatedAgents.keys()) {
314
- await registry?.closeTree(agentId).catch(() => undefined);
315
- }
357
+ const previousRegistry = registry;
358
+ registry = undefined;
359
+ persistence = undefined;
316
360
  isolatedAgents.clear();
317
361
  seenMessageIds.clear();
318
- let cleanupError: unknown;
319
- try {
320
- await workspaceManager.cleanupAll();
321
- } catch (error) {
322
- cleanupError = error;
323
- }
324
- try {
325
- await registry?.shutdown();
326
- } finally {
327
- registry = undefined;
328
- persistence = undefined;
329
- }
330
- if (cleanupError && ctx.hasUI) {
331
- const reason = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
332
- ctx.ui.notify(`Some isolated subagent workspaces could not be removed: ${reason}`, "warning");
333
- }
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;
334
371
  });
335
372
 
336
- const baseSpawnDescription =
337
- "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.`;
338
375
  const spawnTool = defineTool({
339
376
  name: "subagent_spawn",
340
377
  label: "Spawn Subagent",
341
- description: appendAgentCatalog(baseSpawnDescription, agentCatalog),
378
+ description: appendAgentCatalog(baseSpawnDescription(), agentCatalog),
342
379
  promptSnippet: "Start a reusable detached subagent; completion is delivered asynchronously",
343
380
  promptGuidelines: createSpawnPromptGuidelines(completionDelivery, blockingEnabled),
344
381
  parameters: Type.Object({
@@ -362,12 +399,33 @@ export function registerStatefulSubagents(
362
399
  }),
363
400
  ),
364
401
  }),
365
- async execute(_id, params, _signal, _update, ctx) {
402
+ ...createStatefulToolRenderer("spawn"),
403
+ async execute(_id, params, signal, _update, ctx) {
366
404
  const scope = (params.agentScope ?? "user") as AgentScope;
367
405
  assertSubagentDepthAllowed();
368
- const cwd = params.cwd ?? ctx.cwd;
369
- await confirmProjectAgent(params.agent, scope, params.confirmProjectAgents ?? true, ctx, cwd);
370
- 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(
371
429
  (agent) => agent.name === params.agent,
372
430
  );
373
431
  if (params.workspaceMode === "worktree" && resolvedAgent?.source === "project") {
@@ -382,16 +440,29 @@ export function registerStatefulSubagents(
382
440
  );
383
441
  const requestedCwd = cwd;
384
442
  if ((params.workspaceMode ?? "shared") === "shared" && !params.allowConcurrentWrites) {
385
- assertNoSharedWriteConflict(requireRegistry(), params.agent, requestedCwd, scope);
443
+ assertNoSharedWriteConflict(
444
+ ownedRegistry,
445
+ params.agent,
446
+ requestedCwd,
447
+ scope,
448
+ currentSettings,
449
+ );
386
450
  }
387
451
  const workspaceOwner = `pending-${randomUUID()}`;
388
452
  const workspace =
389
453
  params.workspaceMode === "worktree"
390
454
  ? await workspaceManager.create(workspaceOwner, requestedCwd)
391
455
  : undefined;
392
- let agent: ManagedAgent;
393
456
  try {
394
- 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({
395
466
  agent: params.agent,
396
467
  task: params.task,
397
468
  cwd: workspace?.path ?? requestedCwd,
@@ -401,11 +472,16 @@ export function registerStatefulSubagents(
401
472
  context: snapshot.text || undefined,
402
473
  contextSourceIds: snapshot.sourceIds,
403
474
  contextTruncated: snapshot.truncated,
475
+ workspaceMode: workspace ? "worktree" : undefined,
476
+ target: targetSnapshot,
404
477
  });
478
+ assertCurrentSpawn(signal, generation, runtimeGeneration);
405
479
  } catch (error) {
480
+ if (agent) await ownedRegistry.closeTree(agent.id).catch(() => undefined);
406
481
  if (workspace) await workspaceManager.cleanup(workspaceOwner);
407
482
  throw error;
408
483
  }
484
+ if (!agent) throw new Error("Subagent spawn completed without a retained agent");
409
485
  if (workspace) isolatedAgents.set(agent.id, workspaceOwner);
410
486
  const deliveryNote =
411
487
  completionDelivery === "auto-resume"
@@ -418,7 +494,7 @@ export function registerStatefulSubagents(
418
494
  },
419
495
  });
420
496
  refreshSpawnToolRegistration = () => {
421
- spawnTool.description = appendAgentCatalog(baseSpawnDescription, agentCatalog);
497
+ spawnTool.description = appendAgentCatalog(baseSpawnDescription(), agentCatalog);
422
498
  spawnTool.promptGuidelines = createSpawnPromptGuidelines(completionDelivery, blockingEnabled);
423
499
  pi.registerTool(spawnTool);
424
500
  };
@@ -437,8 +513,12 @@ export function registerStatefulSubagents(
437
513
  Type.Boolean({ description: "Override the shared-workspace write conflict guard." }),
438
514
  ),
439
515
  }),
440
- async execute(_id, params, _signal, _update, ctx) {
441
- 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);
442
522
  if (!existing) throw new Error(`Unknown subagent: ${params.agentId}`);
443
523
  await confirmProjectAgent(
444
524
  existing.agent,
@@ -446,14 +526,18 @@ export function registerStatefulSubagents(
446
526
  false,
447
527
  ctx,
448
528
  existing.cwd,
529
+ currentSettings,
449
530
  );
531
+ assertCurrentSpawn(signal, generation, runtimeGeneration);
450
532
  assertFollowUpWriteAllowed(
451
- requireRegistry(),
533
+ ownedRegistry,
452
534
  existing,
453
535
  params.allowConcurrentWrites ?? false,
454
536
  isolatedAgents.has(existing.id),
537
+ currentSettings,
455
538
  );
456
- const agent = await requireRegistry().followUp(params.agentId, params.task);
539
+ const agent = await ownedRegistry.followUp(params.agentId, params.task);
540
+ assertCurrentSpawn(signal, generation, runtimeGeneration);
457
541
  return result(agent, `Started follow-up for ${agent.id}.`);
458
542
  },
459
543
  });
@@ -465,6 +549,7 @@ export function registerStatefulSubagents(
465
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.",
466
550
  promptSnippet: "List or control retained detached subagents",
467
551
  parameters: ManageParamsSchema,
552
+ ...createStatefulToolRenderer("manage"),
468
553
  async execute(_id, params): Promise<StatefulActionToolResult> {
469
554
  const operation = validateManageParams(params);
470
555
  if (operation.action === "list") {
@@ -533,6 +618,7 @@ export function registerStatefulSubagents(
533
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.",
534
619
  promptSnippet: "Send or read queue-only detached-subagent mailbox messages",
535
620
  parameters: MailboxParamsSchema,
621
+ ...createStatefulToolRenderer("mailbox"),
536
622
  async execute(_id, params): Promise<StatefulActionToolResult> {
537
623
  const operation = validateMailboxParams(params);
538
624
  if (operation.action === "send") {
@@ -571,77 +657,6 @@ export function registerStatefulSubagents(
571
657
  return controller;
572
658
  }
573
659
 
574
- export function assertNoSharedWriteConflict(
575
- registry: AgentRegistry,
576
- agentName: string,
577
- cwd: string,
578
- scope: AgentScope,
579
- ): void {
580
- const agents = discoverAgents(cwd, scope, readSubagentSettings()).agents;
581
- const requested = agents.find((agent) => agent.name === agentName);
582
- if (!isWriteCapable(requested?.tools)) return;
583
- for (const active of registry.list()) {
584
- if (
585
- !isSameCwd(active.cwd, cwd) ||
586
- (active.state !== "running" && active.state !== "starting")
587
- ) {
588
- continue;
589
- }
590
- const activeConfig = agents.find((agent) => agent.name === active.agent);
591
- if (isWriteCapable(activeConfig?.tools)) {
592
- throw new Error(
593
- `Write-capable subagent ${active.id} is already active in shared workspace ${cwd}. ` +
594
- "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.",
595
- );
596
- }
597
- }
598
- }
599
-
600
- export function assertFollowUpWriteAllowed(
601
- registry: AgentRegistry,
602
- agent: ManagedAgent,
603
- allowConcurrentWrites: boolean,
604
- isolatedWorkspace: boolean,
605
- ): void {
606
- if (allowConcurrentWrites || isolatedWorkspace) return;
607
- assertNoSharedWriteConflict(registry, agent.agent, agent.cwd, agent.agentScope ?? "user");
608
- }
609
-
610
- export function isWriteCapable(tools: string[] | undefined): boolean {
611
- if (!tools) return true;
612
- return tools.some((tool) => ["bash", "write", "edit"].includes(tool));
613
- }
614
-
615
- async function confirmProjectAgent(
616
- name: string,
617
- scope: AgentScope,
618
- confirm: boolean,
619
- ctx: ExtensionContext,
620
- cwd: string,
621
- ): Promise<void> {
622
- if (scope !== "project" && scope !== "both") return;
623
- if (!isSameCwd(cwd, ctx.cwd)) {
624
- throw new Error("Project-local subagent definitions cannot run with an overridden cwd");
625
- }
626
- if (!ctx.isProjectTrusted()) {
627
- throw new Error("Project-local subagent definitions require a trusted project");
628
- }
629
- const discovery = discoverAgents(cwd, scope, readSubagentSettings());
630
- const agent = discovery.agents.find((candidate) => candidate.name === name);
631
- if (agent?.source !== "project") return;
632
- if (confirm && ctx.hasUI) {
633
- const approved = await ctx.ui.confirm(
634
- "Run project-local agent?",
635
- `Agent: ${safeTerminalLine(name, 256)}\nSource: ${safeTerminalLine(agent.filePath)}`,
636
- );
637
- if (!approved) throw new Error("Project-local subagent was not approved");
638
- }
639
- }
640
-
641
- function isSameCwd(left: string, right: string): boolean {
642
- return path.resolve(left) === path.resolve(right);
643
- }
644
-
645
660
  function normalizeContextMode(value: "none" | "all" | "summary" | number | undefined): ContextMode {
646
661
  if (value === undefined) return "none";
647
662
  if (value === "none" || value === "all" || value === "summary") return value;
@@ -698,6 +713,7 @@ function summarizeAgent(agent: ManagedAgent) {
698
713
  createdAt: agent.createdAt,
699
714
  updatedAt: agent.updatedAt,
700
715
  cwd: agent.cwd,
716
+ workspaceMode: agent.workspaceMode ?? "shared",
701
717
  thinkingLevel: agent.thinkingLevel,
702
718
  currentTask: agent.currentTask
703
719
  ? truncateUtf8(agent.currentTask, MAX_TOOL_MESSAGE_BYTES).text
@@ -705,6 +721,7 @@ function summarizeAgent(agent: ManagedAgent) {
705
721
  historyCount: agent.history.length,
706
722
  unreadMessages: agent.mailbox.filter((message) => !message.readAt).length,
707
723
  error: agent.error ? truncateUtf8(agent.error, MAX_TOOL_MESSAGE_BYTES).text : undefined,
724
+ target: agent.target,
708
725
  policy: agent.policy,
709
726
  };
710
727
  }