@narumitw/pi-subagents 0.49.3 → 0.52.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.
Files changed (83) hide show
  1. package/README.md +362 -53
  2. package/package.json +10 -7
  3. package/src/adaptive-scheduler.ts +224 -0
  4. package/src/admission-benchmark.ts +95 -0
  5. package/src/admission-policy.ts +78 -0
  6. package/src/agent-projection.ts +53 -0
  7. package/src/agents.ts +58 -1
  8. package/src/auto-transport.ts +114 -0
  9. package/src/blocking-status.ts +63 -0
  10. package/src/capabilities.ts +145 -0
  11. package/src/capability-grant.ts +115 -0
  12. package/src/capability-router.ts +107 -0
  13. package/src/completion-delivery.ts +257 -0
  14. package/src/config-status.ts +221 -0
  15. package/src/config-ui.ts +215 -236
  16. package/src/consult-resources.ts +4 -27
  17. package/src/consult.ts +9 -1
  18. package/src/create-stateful-transport.ts +55 -0
  19. package/src/delegation-contract.ts +417 -0
  20. package/src/execution-plan.ts +322 -0
  21. package/src/execution-profiles.ts +95 -0
  22. package/src/execution-ui.ts +320 -0
  23. package/src/execution.ts +1098 -158
  24. package/src/in-process-transport.ts +269 -25
  25. package/src/inspect-render.ts +101 -1
  26. package/src/inspect.ts +321 -3
  27. package/src/integration-controller.ts +98 -0
  28. package/src/limits.ts +3 -0
  29. package/src/orchestration-metrics.ts +109 -0
  30. package/src/outcome.ts +61 -0
  31. package/src/panel-child-group.ts +35 -0
  32. package/src/panel-contract.ts +343 -0
  33. package/src/panel-evidence.ts +59 -0
  34. package/src/panel-execution.ts +770 -0
  35. package/src/panel-failure.ts +56 -0
  36. package/src/panel-planning.ts +175 -0
  37. package/src/panel-prompts.ts +132 -0
  38. package/src/panel-reconciliation.ts +57 -0
  39. package/src/panel-render.ts +103 -0
  40. package/src/parallel-limit-ui.ts +112 -0
  41. package/src/params.ts +179 -3
  42. package/src/persistence.ts +182 -32
  43. package/src/prompt-resources.ts +38 -0
  44. package/src/registry-types.ts +175 -0
  45. package/src/registry.ts +466 -143
  46. package/src/render.ts +72 -6
  47. package/src/result-contract.ts +416 -0
  48. package/src/retained-semantic-state.ts +100 -0
  49. package/src/rpc-timeout-finalization.ts +207 -0
  50. package/src/rpc-transport-metadata.ts +65 -0
  51. package/src/rpc-transport.ts +990 -0
  52. package/src/rpc-turn-capture.ts +142 -0
  53. package/src/runner-result.ts +55 -0
  54. package/src/runner-usage.ts +48 -0
  55. package/src/runner.ts +325 -73
  56. package/src/semantic-snapshot.ts +214 -0
  57. package/src/settings.ts +254 -35
  58. package/src/spawn-idempotency.ts +61 -0
  59. package/src/stateful-config.ts +13 -0
  60. package/src/stateful-guidance.ts +1 -0
  61. package/src/stateful-lifecycle.ts +45 -2
  62. package/src/stateful-limit-ui.ts +246 -0
  63. package/src/stateful-limits.ts +96 -0
  64. package/src/stateful-prompt.ts +11 -2
  65. package/src/stateful-render.ts +48 -3
  66. package/src/stateful.ts +467 -357
  67. package/src/subagents.ts +114 -46
  68. package/src/subprocess-transport.ts +64 -5
  69. package/src/supervision.ts +103 -0
  70. package/src/timeout-checkpoint.ts +305 -0
  71. package/src/timeout-finalization.ts +75 -0
  72. package/src/transport-types.ts +68 -0
  73. package/src/transport-ui.ts +169 -0
  74. package/src/transport.ts +16 -4
  75. package/src/turn-budget.ts +109 -0
  76. package/src/verification-policy.ts +67 -0
  77. package/src/work-item-ledger.ts +931 -0
  78. package/src/work-item-persistence.ts +223 -0
  79. package/src/workflow-planning.ts +162 -0
  80. package/src/workflow-tree-identity.ts +289 -0
  81. package/src/workflow-ui.ts +61 -0
  82. package/src/workflow-verification.ts +296 -0
  83. package/src/workspace.ts +69 -12
package/src/stateful.ts CHANGED
@@ -1,10 +1,10 @@
1
+ /**
2
+ * Stateful registration remains one lifecycle owner so session replacement, persistence,
3
+ * capability revocation, workspace cleanup, and completion delivery cannot reorder.
4
+ */
1
5
  import { randomUUID } from "node:crypto";
2
6
  import { StringEnum } from "@earendil-works/pi-ai";
3
- import {
4
- defineTool,
5
- type ExtensionAPI,
6
- type ExtensionContext,
7
- } from "@earendil-works/pi-coding-agent";
7
+ import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
8
  import { Type } from "typebox";
9
9
  import {
10
10
  type AgentScope,
@@ -13,38 +13,54 @@ import {
13
13
  isThinkingLevel,
14
14
  type SubagentRuntimeSettings,
15
15
  type SubagentSettings,
16
+ type SubagentTransportKind,
16
17
  THINKING_LEVELS,
17
18
  } from "./agents.js";
19
+ import { issueCapabilityGrant } from "./capability-grant.js";
20
+ import { CompletionDeliveryBroker } from "./completion-delivery.js";
18
21
  import { buildContextSnapshot, type ContextMode, redactPrivateText } from "./context.js";
22
+ import { createStatefulTransport } from "./create-stateful-transport.js";
19
23
  import {
20
24
  assertDelegationTargetAllowed,
21
25
  resolveSubagentTarget,
22
26
  targetPolicyAudit,
23
27
  } from "./cwd-policy.js";
28
+ import { DelegationContractSchema, normalizeDelegationContract } from "./delegation-contract.js";
24
29
  import { assertSubagentDepthAllowed } from "./execution.js";
25
- import {
26
- type ChildSessionFactory,
27
- InProcessTransport,
28
- type ParentRuntimeSnapshot,
29
- } from "./in-process-transport.js";
30
- import { DEFAULT_MAX_CONTEXT_BYTES, truncateUtf8 } from "./limits.js";
30
+ import type { ChildSessionFactory, ParentRuntimeSnapshot } from "./in-process-transport.js";
31
+ import { DEFAULT_MAX_CONTEXT_BYTES, MAX_SUBAGENT_TIMEOUT_MS, truncateUtf8 } from "./limits.js";
31
32
  import { AgentPersistence } from "./persistence.js";
32
33
  import {
33
34
  AgentRegistry,
34
35
  type AgentRunInspectionDetail,
35
36
  type AgentRunInspectionSummary,
36
- type AgentTurnCompletion,
37
37
  type ManagedAgent,
38
38
  } from "./registry.js";
39
+ import { SUBAGENT_RESULT_FORMATS, type SubagentResultFormat } from "./result-contract.js";
40
+ import { buildRetainedSemanticState } from "./retained-semantic-state.js";
41
+ import { evaluateSemanticCompatibility } from "./semantic-snapshot.js";
39
42
  import { DEFAULT_DELEGATION_CWD_POLICY, readSubagentSettings } from "./settings.js";
43
+ import {
44
+ assertSpawnIdempotencyKey,
45
+ hashSpawnRequest,
46
+ MAX_SPAWN_IDEMPOTENCY_KEY_LENGTH,
47
+ } from "./spawn-idempotency.js";
48
+ import { resolveCompletionDelivery, resolveStatefulTransportKind } from "./stateful-config.js";
40
49
  import { createSpawnPromptGuidelines } from "./stateful-guidance.js";
41
- import { assertCurrentSpawn, disposeStatefulRuntime } from "./stateful-lifecycle.js";
50
+ import {
51
+ assertCurrentSpawn,
52
+ cleanupPersistedWorkspaces,
53
+ disposeStatefulRuntime,
54
+ waitForOwnedSpawn,
55
+ } from "./stateful-lifecycle.js";
56
+ import { resolveStatefulLimits, type StatefulLimits } from "./stateful-limits.js";
42
57
  import { createStatefulToolRenderer } from "./stateful-render.js";
43
58
  import {
44
59
  assertFollowUpWriteAllowed,
45
60
  assertNoSharedWriteConflict,
46
61
  confirmProjectAgent,
47
62
  } from "./stateful-safety.js";
63
+ import { MAX_SUBAGENT_TOOL_CALLS, MAX_SUBAGENT_TURNS } from "./turn-budget.js";
48
64
 
49
65
  export {
50
66
  assertFollowUpWriteAllowed,
@@ -58,7 +74,6 @@ import {
58
74
  validateMailboxParams,
59
75
  validateManageParams,
60
76
  } from "./stateful-tool-params.js";
61
- import { SubprocessTransport } from "./subprocess-transport.js";
62
77
  import { WorkspaceManager } from "./workspace.js";
63
78
 
64
79
  const ContextModeSchema = Type.Union([
@@ -74,10 +89,37 @@ const StatefulThinkingLevelSchema = StringEnum(THINKING_LEVELS, {
74
89
  description:
75
90
  "Optional requested Pi thinking level selected for this task difficulty; retained for every turn of the spawned agent.",
76
91
  });
92
+ const StatefulTimeoutSchema = Type.Integer({
93
+ minimum: 1,
94
+ maximum: MAX_SUBAGENT_TIMEOUT_MS,
95
+ description:
96
+ "Work deadline in milliseconds selected for the task difficulty. On expiry, Pi aborts the work and makes one separately bounded summary attempt. Retained as the agent default.",
97
+ });
98
+ const StatefulTurnLimitFields = {
99
+ idleTimeoutMs: Type.Optional(
100
+ Type.Integer({
101
+ minimum: 1,
102
+ maximum: MAX_SUBAGENT_TIMEOUT_MS,
103
+ description:
104
+ "Maximum milliseconds without a completed assistant turn or tool result; retained as the agent default.",
105
+ }),
106
+ ),
107
+ maxTurns: Type.Optional(
108
+ Type.Integer({
109
+ minimum: 1,
110
+ maximum: MAX_SUBAGENT_TURNS,
111
+ description: "Maximum unfinished assistant turns; retained as the agent default.",
112
+ }),
113
+ ),
114
+ maxToolCalls: Type.Optional(
115
+ Type.Integer({
116
+ minimum: 1,
117
+ maximum: MAX_SUBAGENT_TOOL_CALLS,
118
+ description: "Maximum tool calls; retained as the agent default.",
119
+ }),
120
+ ),
121
+ };
77
122
  const MAX_TOOL_MESSAGE_BYTES = 2 * 1024;
78
- const MAX_COMPLETION_ERROR_BYTES = 512;
79
- const MAX_COMPLETIONS_PER_MESSAGE = 16;
80
- const COMPLETION_BATCH_DELAY_MS = 10;
81
123
 
82
124
  export interface StatefulSubagentDependencies {
83
125
  blockingEnabled?: boolean;
@@ -90,8 +132,9 @@ export interface StatefulSubagentDependencies {
90
132
  export interface StatefulSubagentRuntimeStatus {
91
133
  enabled: boolean;
92
134
  initialized: boolean;
93
- transport: "subprocess" | "in-process";
135
+ transport: SubagentTransportKind;
94
136
  completionDelivery: CompletionDelivery;
137
+ limits: StatefulLimits;
95
138
  activeAgents: number;
96
139
  retainedAgents: number;
97
140
  }
@@ -124,6 +167,7 @@ export function registerStatefulSubagents(
124
167
  const enabled = settings.enabled !== false;
125
168
  const transportKind = resolveStatefulTransportKind(settings.transport);
126
169
  let completionDelivery = resolveCompletionDelivery(settings.completionDelivery);
170
+ let runtimeLimits = resolveStatefulLimits(settings);
127
171
  let agentCatalog = "";
128
172
  let completionBroker: CompletionDeliveryBroker | undefined;
129
173
  let refreshSpawnToolRegistration: (() => void) | undefined;
@@ -135,9 +179,17 @@ export function registerStatefulSubagents(
135
179
  const workspaceManager = dependencies.workspaceManager ?? new WorkspaceManager();
136
180
  const isolatedAgents = new Map<string, string>();
137
181
  const seenMessageIds = new Set<string>();
182
+ type PendingIdempotentSpawn = {
183
+ generation: number;
184
+ requestHash: string;
185
+ promise: Promise<ManagedAgent>;
186
+ };
187
+ const pendingIdempotentSpawns = new Map<string, PendingIdempotentSpawn>();
138
188
  const parentRuntime: ParentRuntimeSnapshot = { model: undefined, thinkingLevel: "off" };
139
189
  const getCurrentSettings = () =>
140
190
  dependencies.getSettings ? dependencies.getSettings() : readSubagentSettings();
191
+ const getCurrentStatefulSettings = () =>
192
+ dependencies.getSettings ? (dependencies.getSettings()?.stateful ?? {}) : settings;
141
193
 
142
194
  const clearAgents = async (): Promise<number> => {
143
195
  const generation = runtimeGeneration;
@@ -181,6 +233,7 @@ export function registerStatefulSubagents(
181
233
  initialized: registry !== undefined,
182
234
  transport: transportKind,
183
235
  completionDelivery,
236
+ limits: { ...runtimeLimits },
184
237
  ...counts,
185
238
  };
186
239
  },
@@ -201,12 +254,6 @@ export function registerStatefulSubagents(
201
254
  if (!registry) throw new Error("Stateful subagents are not initialized for this session");
202
255
  return registry;
203
256
  };
204
- const requireAgent = (agentId: string) => {
205
- const agent = requireRegistry().get(agentId);
206
- if (!agent) throw new Error(`Unknown subagent: ${agentId}`);
207
- return agent;
208
- };
209
-
210
257
  pi.on("session_start", async (_event, ctx) => {
211
258
  const generation = ++runtimeGeneration;
212
259
  completionBroker?.close();
@@ -218,6 +265,7 @@ export function registerStatefulSubagents(
218
265
  persistence = undefined;
219
266
  isolatedAgents.clear();
220
267
  seenMessageIds.clear();
268
+ pendingIdempotentSpawns.clear();
221
269
  const initialize = async () => {
222
270
  const cleanupErrors = await disposeStatefulRuntime(previousRegistry, workspaceManager);
223
271
  if (generation !== runtimeGeneration) return;
@@ -229,44 +277,47 @@ export function registerStatefulSubagents(
229
277
  }
230
278
  parentRuntime.model = ctx.model;
231
279
  parentRuntime.thinkingLevel = normalizeRuntimeThinkingLevel(pi.getThinkingLevel());
280
+ const sessionSettings = getCurrentStatefulSettings();
281
+ const nextLimits = resolveStatefulLimits(sessionSettings);
232
282
  const owner =
233
283
  ctx.sessionManager.getSessionId?.() ??
234
284
  ctx.sessionManager.getSessionFile?.() ??
235
285
  `ephemeral:${ctx.cwd}`;
236
286
  const sessionPersistence = new AgentPersistence(owner, {
237
- retentionDays: settings.retentionDays,
238
- maxStoredAgents: settings.maxStoredAgents,
287
+ retentionDays: sessionSettings.retentionDays,
288
+ maxStoredAgents: nextLimits.maxStoredAgents,
239
289
  });
290
+ let nextRegistry: AgentRegistry;
240
291
  const sessionBroker = new CompletionDeliveryBroker(pi, ctx, completionDelivery, {
241
292
  onDeliveryError: (error) => {
242
293
  if (!ctx.hasUI) return;
243
294
  const reason = error instanceof Error ? error.message : String(error);
244
295
  ctx.ui.notify(`Subagent completion delivery failed: ${reason}`, "warning");
245
296
  },
297
+ onDelivered: (completions, deliveredAt) => {
298
+ if (generation !== runtimeGeneration) return;
299
+ for (const completion of completions) {
300
+ nextRegistry.markCompletionDelivered(completion.agent.id, deliveredAt);
301
+ }
302
+ },
246
303
  });
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,
304
+ const transport = createStatefulTransport({
305
+ kind: transportKind,
306
+ modelRegistry: ctx.modelRegistry,
307
+ getParentRuntime: () => ({ ...parentRuntime }),
308
+ getSettings: getCurrentSettings,
309
+ createInProcessSession: dependencies.createInProcessSession,
310
+ });
311
+ nextRegistry = new AgentRegistry(transport, {
312
+ maxAgents: nextLimits.maxAgents,
313
+ maxActiveTurns: nextLimits.maxActiveTurns,
314
+ maxDepth: nextLimits.maxDepth,
315
+ maxChildrenPerAgent: nextLimits.maxChildrenPerAgent,
316
+ maxMailboxMessages: sessionSettings.maxMailboxMessages,
317
+ maxMailboxMessageBytes: sessionSettings.maxMailboxMessageBytes,
318
+ idleTtlMs: sessionSettings.idleTtlMs,
269
319
  onChange: async (agents) => {
320
+ if (generation !== runtimeGeneration) return;
270
321
  await sessionPersistence.save(agents);
271
322
  if (generation !== runtimeGeneration) return;
272
323
  for (const agent of agents) {
@@ -285,8 +336,12 @@ export function registerStatefulSubagents(
285
336
  if (generation === runtimeGeneration) sessionBroker.enqueue(completion);
286
337
  },
287
338
  });
288
- const restored = sessionPersistence
289
- .load()
339
+ const persisted = sessionPersistence.load();
340
+ const orphanCleanupFailures = await cleanupPersistedWorkspaces(persisted, workspaceManager);
341
+ if (ctx.hasUI && orphanCleanupFailures > 0) {
342
+ ctx.ui.notify("Some orphaned subagent worktrees could not be cleaned", "warning");
343
+ }
344
+ const restored = persisted
290
345
  .filter(
291
346
  (agent) =>
292
347
  agent.workspaceMode !== "worktree" &&
@@ -317,7 +372,12 @@ export function registerStatefulSubagents(
317
372
  registry = nextRegistry;
318
373
  persistence = sessionPersistence;
319
374
  completionBroker = sessionBroker;
320
- const sweepEveryMs = Math.max(1_000, Math.min(settings.idleTtlMs ?? 60 * 60 * 1000, 60_000));
375
+ runtimeLimits = nextLimits;
376
+ refreshSpawnToolRegistration?.();
377
+ const sweepEveryMs = Math.max(
378
+ 1_000,
379
+ Math.min(sessionSettings.idleTtlMs ?? 60 * 60 * 1000, 60_000),
380
+ );
321
381
  sweepTimer = setInterval(() => {
322
382
  void nextRegistry.sweepExpired().catch((error: unknown) => {
323
383
  if (!ctx.hasUI || generation !== runtimeGeneration) return;
@@ -359,6 +419,7 @@ export function registerStatefulSubagents(
359
419
  persistence = undefined;
360
420
  isolatedAgents.clear();
361
421
  seenMessageIds.clear();
422
+ pendingIdempotentSpawns.clear();
362
423
  const shutdown = async () => {
363
424
  const errors = await disposeStatefulRuntime(previousRegistry, workspaceManager);
364
425
  if (errors.length > 0 && ctx.hasUI) {
@@ -371,7 +432,7 @@ export function registerStatefulSubagents(
371
432
  });
372
433
 
373
434
  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.`;
435
+ `Start an addressable background subagent with an optional thinking level and execution budgets chosen for the task difficulty, return immediately with an agentId, and receive its completion asynchronously. Detached capacity: ${runtimeLimits.maxAgents} retained agents, ${runtimeLimits.maxActiveTurns} active turns, ${runtimeLimits.maxChildrenPerAgent} direct children per agent, and depth ${runtimeLimits.maxDepth}. 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.`;
375
436
  const spawnTool = defineTool({
376
437
  name: "subagent_spawn",
377
438
  label: "Spawn Subagent",
@@ -382,6 +443,8 @@ export function registerStatefulSubagents(
382
443
  agent: Type.String({ minLength: 1 }),
383
444
  task: Type.String({ minLength: 1, maxLength: DEFAULT_MAX_CONTEXT_BYTES }),
384
445
  thinkingLevel: Type.Optional(StatefulThinkingLevelSchema),
446
+ timeoutMs: Type.Optional(StatefulTimeoutSchema),
447
+ ...StatefulTurnLimitFields,
385
448
  cwd: Type.Optional(Type.String()),
386
449
  agentScope: Type.Optional(ScopeSchema),
387
450
  confirmProjectAgents: Type.Optional(Type.Boolean({ default: true })),
@@ -398,11 +461,33 @@ export function registerStatefulSubagents(
398
461
  description: "Use the shared workspace or an opt-in disposable Git worktree.",
399
462
  }),
400
463
  ),
464
+ contract: Type.Optional(DelegationContractSchema),
465
+ idempotencyKey: Type.Optional(
466
+ Type.String({
467
+ minLength: 1,
468
+ maxLength: MAX_SPAWN_IDEMPOTENCY_KEY_LENGTH,
469
+ description: "Reuse the same retained agent for an exact accepted spawn retry.",
470
+ }),
471
+ ),
472
+ resultFormat: Type.Optional(
473
+ StringEnum(SUBAGENT_RESULT_FORMATS, {
474
+ description:
475
+ "Use text (default), structured-v1, or the evidence-preserving structured-v2 completion contract.",
476
+ }),
477
+ ),
401
478
  }),
402
479
  ...createStatefulToolRenderer("spawn"),
403
480
  async execute(_id, params, signal, _update, ctx) {
404
481
  const scope = (params.agentScope ?? "user") as AgentScope;
482
+ const resultFormat = (params.resultFormat ?? "text") as SubagentResultFormat;
483
+ const contract = normalizeDelegationContract(params.contract);
484
+ if (params.contract !== undefined && !contract) {
485
+ throw new Error(
486
+ "subagent_spawn contract must be a valid pi-subagents:delegation:v2 object",
487
+ );
488
+ }
405
489
  assertSubagentDepthAllowed();
490
+ assertSpawnIdempotencyKey(params.idempotencyKey);
406
491
  const generation = runtimeGeneration;
407
492
  const currentSettings = getCurrentSettings();
408
493
  const target = resolveSubagentTarget({
@@ -415,22 +500,6 @@ export function registerStatefulSubagents(
415
500
  currentSettings?.cwdPolicy?.delegation ?? DEFAULT_DELEGATION_CWD_POLICY,
416
501
  );
417
502
  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(
429
- (agent) => agent.name === params.agent,
430
- );
431
- if (params.workspaceMode === "worktree" && resolvedAgent?.source === "project") {
432
- throw new Error("Project-local subagent definitions cannot run in a detached worktree");
433
- }
434
503
  const mode = resolveSpawnContextMode(params.context, params.contextEntryIds);
435
504
  const snapshot = buildContextSnapshot(
436
505
  ctx.sessionManager.getBranch(),
@@ -438,59 +507,181 @@ export function registerStatefulSubagents(
438
507
  DEFAULT_MAX_CONTEXT_BYTES,
439
508
  params.contextEntryIds,
440
509
  );
441
- const requestedCwd = cwd;
442
- if ((params.workspaceMode ?? "shared") === "shared" && !params.allowConcurrentWrites) {
443
- assertNoSharedWriteConflict(
444
- ownedRegistry,
510
+ if ((scope === "project" || scope === "both") && !ctx.isProjectTrusted()) {
511
+ throw new Error("Project-local subagent definitions require a trusted project");
512
+ }
513
+ const resolvedAgents = discoverAgents(cwd, scope, currentSettings).agents;
514
+ const resolvedAgent = resolvedAgents.find((agent) => agent.name === params.agent);
515
+ if (!resolvedAgent) {
516
+ const available = resolvedAgents.map((agent) => agent.name).join(", ") || "none";
517
+ throw new Error(`Unknown subagent ${params.agent}. Available agents: ${available}`);
518
+ }
519
+ const targetSnapshot = targetPolicyAudit(target);
520
+ const { executionPlan, semanticSnapshot } = await buildRetainedSemanticState({
521
+ agent: resolvedAgent,
522
+ contract,
523
+ target: targetSnapshot,
524
+ cwd,
525
+ workspaceMode: params.workspaceMode === "worktree" ? "worktree" : "shared",
526
+ transport: transportKind,
527
+ resultFormat,
528
+ thinkingLevel: params.thinkingLevel,
529
+ timeoutMs: params.timeoutMs,
530
+ taskGeneration: 1,
531
+ });
532
+ assertCurrentSpawn(signal, generation, runtimeGeneration);
533
+ const requestHash = hashSpawnRequest({
534
+ agent: params.agent,
535
+ task: params.task,
536
+ cwd,
537
+ agentScope: scope,
538
+ thinkingLevel: params.thinkingLevel,
539
+ timeoutMs: params.timeoutMs,
540
+ idleTimeoutMs: params.idleTimeoutMs,
541
+ maxTurns: params.maxTurns,
542
+ maxToolCalls: params.maxToolCalls,
543
+ parentId: params.parentId,
544
+ context: snapshot.text || undefined,
545
+ contextSourceIds: snapshot.sourceIds,
546
+ workspaceMode: params.workspaceMode ?? "shared",
547
+ allowConcurrentWrites: params.allowConcurrentWrites ?? false,
548
+ contract,
549
+ resultFormat,
550
+ });
551
+ const ownedRegistry = requireRegistry();
552
+ const retained = ownedRegistry.findBySpawnIdempotencyKey(params.idempotencyKey, requestHash);
553
+ if (retained) return result(retained, `Reused ${retained.agent} as ${retained.id}.`);
554
+ const foundPending = params.idempotencyKey
555
+ ? pendingIdempotentSpawns.get(params.idempotencyKey)
556
+ : undefined;
557
+ if (
558
+ params.idempotencyKey &&
559
+ foundPending &&
560
+ foundPending.generation !== generation &&
561
+ pendingIdempotentSpawns.get(params.idempotencyKey) === foundPending
562
+ ) {
563
+ pendingIdempotentSpawns.delete(params.idempotencyKey);
564
+ }
565
+ const pending = foundPending?.generation === generation ? foundPending : undefined;
566
+ if (pending) {
567
+ if (pending.requestHash !== requestHash) {
568
+ throw new Error("The subagent_spawn idempotencyKey is pending with different parameters");
569
+ }
570
+ const agent = await waitForOwnedSpawn(pending.promise, signal);
571
+ assertCurrentSpawn(signal, generation, runtimeGeneration);
572
+ return result(agent, `Reused ${agent.agent} as ${agent.id}.`);
573
+ }
574
+ let resolvePending: ((agent: ManagedAgent) => void) | undefined;
575
+ let rejectPending: ((error: unknown) => void) | undefined;
576
+ let ownedPending: PendingIdempotentSpawn | undefined;
577
+ if (params.idempotencyKey) {
578
+ const promise = new Promise<ManagedAgent>((resolve, reject) => {
579
+ resolvePending = resolve;
580
+ rejectPending = reject;
581
+ });
582
+ void promise.catch(() => undefined);
583
+ ownedPending = { generation, requestHash, promise };
584
+ pendingIdempotentSpawns.set(params.idempotencyKey, ownedPending);
585
+ }
586
+ try {
587
+ await confirmProjectAgent(
445
588
  params.agent,
446
- requestedCwd,
447
589
  scope,
590
+ params.confirmProjectAgents ?? true,
591
+ ctx,
592
+ cwd,
448
593
  currentSettings,
449
594
  );
450
- }
451
- const workspaceOwner = `pending-${randomUUID()}`;
452
- const workspace =
453
- params.workspaceMode === "worktree"
454
- ? await workspaceManager.create(workspaceOwner, requestedCwd)
455
- : undefined;
456
- try {
457
595
  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({
466
- agent: params.agent,
467
- task: params.task,
468
- cwd: workspace?.path ?? requestedCwd,
469
- agentScope: scope,
470
- thinkingLevel: params.thinkingLevel,
471
- parentId: params.parentId,
472
- context: snapshot.text || undefined,
473
- contextSourceIds: snapshot.sourceIds,
474
- contextTruncated: snapshot.truncated,
475
- workspaceMode: workspace ? "worktree" : undefined,
476
- target: targetSnapshot,
477
- });
596
+ if (params.workspaceMode === "worktree" && resolvedAgent.source === "project") {
597
+ throw new Error("Project-local subagent definitions cannot run in a detached worktree");
598
+ }
599
+ const requestedCwd = cwd;
600
+ if ((params.workspaceMode ?? "shared") === "shared" && !params.allowConcurrentWrites) {
601
+ assertNoSharedWriteConflict(
602
+ ownedRegistry,
603
+ params.agent,
604
+ requestedCwd,
605
+ scope,
606
+ currentSettings,
607
+ );
608
+ }
609
+ const workspaceOwner = `pending-${randomUUID()}`;
610
+ const workspace =
611
+ params.workspaceMode === "worktree"
612
+ ? await workspaceManager.create(workspaceOwner, requestedCwd)
613
+ : undefined;
614
+ try {
615
+ assertCurrentSpawn(signal, generation, runtimeGeneration);
616
+ } catch (error) {
617
+ if (workspace) await workspaceManager.cleanup(workspaceOwner);
618
+ throw error;
619
+ }
620
+ let agent: ManagedAgent | undefined;
621
+ try {
622
+ const capabilityGrant = issueCapabilityGrant(
623
+ executionPlan,
624
+ Date.now(),
625
+ Math.max(1, (params.timeoutMs ?? resolvedAgent.timeoutMs ?? 600_000) + 60_000),
626
+ );
627
+ agent = await ownedRegistry.spawn({
628
+ agent: params.agent,
629
+ task: params.task,
630
+ cwd: workspace?.path ?? requestedCwd,
631
+ agentScope: scope,
632
+ thinkingLevel: params.thinkingLevel,
633
+ timeoutMs: params.timeoutMs,
634
+ idleTimeoutMs: params.idleTimeoutMs,
635
+ maxTurns: params.maxTurns,
636
+ maxToolCalls: params.maxToolCalls,
637
+ parentId: params.parentId,
638
+ context: snapshot.text || undefined,
639
+ contextSourceIds: snapshot.sourceIds,
640
+ contextTruncated: snapshot.truncated,
641
+ contextTurns: snapshot.turns,
642
+ contextBytes: Buffer.byteLength(snapshot.text, "utf8"),
643
+ workspaceMode: workspace ? "worktree" : undefined,
644
+ spawnIdempotencyKey: params.idempotencyKey,
645
+ spawnRequestHash: params.idempotencyKey ? requestHash : undefined,
646
+ contract,
647
+ resultFormat: resultFormat === "text" ? undefined : resultFormat,
648
+ executionPlan,
649
+ capabilityGrant,
650
+ semanticSnapshot,
651
+ semanticCompatibility: { status: "compatible", changedComponents: [] },
652
+ target: targetSnapshot,
653
+ });
654
+ assertCurrentSpawn(signal, generation, runtimeGeneration);
655
+ } catch (error) {
656
+ if (agent) await ownedRegistry.closeTree(agent.id).catch(() => undefined);
657
+ if (workspace) await workspaceManager.cleanup(workspaceOwner);
658
+ throw error;
659
+ }
660
+ if (!agent) throw new Error("Subagent spawn completed without a retained agent");
661
+ if (workspace && agent.cwd === workspace.path) isolatedAgents.set(agent.id, workspaceOwner);
662
+ else if (workspace) await workspaceManager.cleanup(workspaceOwner);
478
663
  assertCurrentSpawn(signal, generation, runtimeGeneration);
664
+ resolvePending?.(agent);
665
+ const deliveryNote =
666
+ completionDelivery === "auto-resume"
667
+ ? "If no useful local work remains, briefly tell the user what was launched and end the response; auto-resume will request synthesis after completion."
668
+ : "End the response without the result only when the current response does not depend on it; next-turn delivery will not wake an idle root.";
669
+ return result(
670
+ agent,
671
+ `Spawned ${agent.agent} as ${agent.id}. Do useful non-overlapping work immediately. ${deliveryNote} Do not poll for progress.`,
672
+ );
479
673
  } catch (error) {
480
- if (agent) await ownedRegistry.closeTree(agent.id).catch(() => undefined);
481
- if (workspace) await workspaceManager.cleanup(workspaceOwner);
674
+ rejectPending?.(error);
482
675
  throw error;
676
+ } finally {
677
+ if (
678
+ params.idempotencyKey &&
679
+ ownedPending &&
680
+ pendingIdempotentSpawns.get(params.idempotencyKey) === ownedPending
681
+ ) {
682
+ pendingIdempotentSpawns.delete(params.idempotencyKey);
683
+ }
483
684
  }
484
- if (!agent) throw new Error("Subagent spawn completed without a retained agent");
485
- if (workspace) isolatedAgents.set(agent.id, workspaceOwner);
486
- const deliveryNote =
487
- completionDelivery === "auto-resume"
488
- ? "If no useful local work remains, briefly tell the user what was launched and end the response; auto-resume will request synthesis after completion."
489
- : "End the response without the result only when the current response does not depend on it; next-turn delivery will not wake an idle root.";
490
- return result(
491
- agent,
492
- `Spawned ${agent.agent} as ${agent.id}. Do useful non-overlapping work immediately. ${deliveryNote} Do not poll for progress.`,
493
- );
494
685
  },
495
686
  });
496
687
  refreshSpawnToolRegistration = () => {
@@ -504,11 +695,26 @@ export function registerStatefulSubagents(
504
695
  name: "subagent_send",
505
696
  label: "Send Subagent Follow-up",
506
697
  description:
507
- "Send follow-up work to an idle, completed, interrupted, or failed subagent and start a new turn. Use subagent_mailbox for queue-only messages.",
698
+ "Send follow-up work to a reusable retained subagent and start a new turn. Semantic resource skew requires explicit revalidation. Use subagent_mailbox for queue-only messages.",
508
699
  promptSnippet: "Start a new detached follow-up turn on a retained subagent",
509
700
  parameters: Type.Object({
510
701
  agentId: Type.String(),
511
702
  task: Type.String({ minLength: 1, maxLength: DEFAULT_MAX_CONTEXT_BYTES }),
703
+ timeoutMs: Type.Optional(
704
+ Type.Integer({
705
+ minimum: 1,
706
+ maximum: MAX_SUBAGENT_TIMEOUT_MS,
707
+ description:
708
+ "Optional work deadline for this follow-up turn. On expiry, Pi aborts the work and makes one separately bounded summary attempt.",
709
+ }),
710
+ ),
711
+ ...StatefulTurnLimitFields,
712
+ revalidate: Type.Optional(
713
+ Type.Boolean({
714
+ description:
715
+ "Accept the current semantic resource snapshot after a reported mismatch before starting this follow-up.",
716
+ }),
717
+ ),
512
718
  allowConcurrentWrites: Type.Optional(
513
719
  Type.Boolean({ description: "Override the shared-workspace write conflict guard." }),
514
720
  ),
@@ -520,6 +726,64 @@ export function registerStatefulSubagents(
520
726
  const currentSettings = getCurrentSettings();
521
727
  const existing = ownedRegistry.get(params.agentId);
522
728
  if (!existing) throw new Error(`Unknown subagent: ${params.agentId}`);
729
+ const currentAgent = discoverAgents(
730
+ existing.cwd,
731
+ existing.agentScope ?? "user",
732
+ currentSettings,
733
+ ).agents.find((agent) => agent.name === existing.agent);
734
+ if (!currentAgent) throw new Error(`Unknown retained subagent definition: ${existing.agent}`);
735
+ const resolvedFollowUpTarget =
736
+ existing.workspaceMode === "worktree"
737
+ ? undefined
738
+ : resolveSubagentTarget({
739
+ workspace: ctx.cwd,
740
+ requestedCwd: existing.cwd,
741
+ currentProjectTrusted: ctx.isProjectTrusted(),
742
+ });
743
+ if (resolvedFollowUpTarget) {
744
+ assertDelegationTargetAllowed(
745
+ resolvedFollowUpTarget,
746
+ currentSettings?.cwdPolicy?.delegation ?? DEFAULT_DELEGATION_CWD_POLICY,
747
+ );
748
+ }
749
+ if (existing.workspaceMode === "worktree" && !existing.target) {
750
+ throw new Error("Retained worktree is missing its trusted target snapshot");
751
+ }
752
+ const currentTarget =
753
+ existing.workspaceMode === "worktree" && existing.target
754
+ ? existing.target
755
+ : targetPolicyAudit(resolvedFollowUpTarget as NonNullable<typeof resolvedFollowUpTarget>);
756
+ const { executionPlan: currentPlan, semanticSnapshot: currentSnapshot } =
757
+ await buildRetainedSemanticState({
758
+ agent: currentAgent,
759
+ contract: existing.contract,
760
+ target: currentTarget,
761
+ cwd: existing.cwd,
762
+ workspaceMode: existing.workspaceMode === "worktree" ? "worktree" : "shared",
763
+ transport: transportKind,
764
+ resultFormat: existing.resultFormat ?? "text",
765
+ thinkingLevel: existing.thinkingLevel,
766
+ timeoutMs: params.timeoutMs ?? existing.timeoutMs,
767
+ taskGeneration: (existing.executionPlan?.taskGeneration ?? 0) + 1,
768
+ cancellationLineage: [
769
+ ...(existing.executionPlan?.cancellationLineage ?? []),
770
+ ...(existing.state === "interrupted" && existing.executionPlan?.id
771
+ ? [existing.executionPlan.id]
772
+ : []),
773
+ ],
774
+ });
775
+ assertCurrentSpawn(signal, generation, runtimeGeneration);
776
+ const compatibility = existing.semanticSnapshot
777
+ ? evaluateSemanticCompatibility(existing.semanticSnapshot, currentSnapshot)
778
+ : { status: "warning" as const, changedComponents: ["legacy-missing-snapshot"] };
779
+ if (
780
+ (compatibility.status === "needs-revalidation" || compatibility.status === "rejected") &&
781
+ params.revalidate !== true
782
+ ) {
783
+ throw new Error(
784
+ `Subagent semantic resources changed; retry with revalidate: true after reviewing: ${compatibility.changedComponents.join(", ")}`,
785
+ );
786
+ }
523
787
  await confirmProjectAgent(
524
788
  existing.agent,
525
789
  existing.agentScope ?? "user",
@@ -536,7 +800,26 @@ export function registerStatefulSubagents(
536
800
  isolatedAgents.has(existing.id),
537
801
  currentSettings,
538
802
  );
539
- const agent = await ownedRegistry.followUp(params.agentId, params.task);
803
+ const currentGrant = issueCapabilityGrant(
804
+ currentPlan,
805
+ Date.now(),
806
+ Math.max(1, (params.timeoutMs ?? existing.timeoutMs ?? 600_000) + 60_000),
807
+ );
808
+ await ownedRegistry.updateSemanticState(
809
+ existing.id,
810
+ currentPlan,
811
+ currentGrant,
812
+ currentSnapshot,
813
+ params.revalidate === true && compatibility.status !== "compatible"
814
+ ? { status: "warning", changedComponents: compatibility.changedComponents }
815
+ : compatibility,
816
+ );
817
+ const agent = await ownedRegistry.followUp(params.agentId, params.task, {
818
+ timeoutMs: params.timeoutMs,
819
+ idleTimeoutMs: params.idleTimeoutMs,
820
+ maxTurns: params.maxTurns,
821
+ maxToolCalls: params.maxToolCalls,
822
+ });
540
823
  assertCurrentSpawn(signal, generation, runtimeGeneration);
541
824
  return result(agent, `Started follow-up for ${agent.id}.`);
542
825
  },
@@ -550,10 +833,17 @@ export function registerStatefulSubagents(
550
833
  promptSnippet: "List or control retained detached subagents",
551
834
  parameters: ManageParamsSchema,
552
835
  ...createStatefulToolRenderer("manage"),
553
- async execute(_id, params): Promise<StatefulActionToolResult> {
836
+ async execute(_id, params, signal): Promise<StatefulActionToolResult> {
837
+ const generation = runtimeGeneration;
838
+ const ownedRegistry = requireRegistry();
839
+ const ownedAgent = (agentId: string): ManagedAgent => {
840
+ const value = ownedRegistry.get(agentId);
841
+ if (!value) throw new Error(`Unknown subagent: ${agentId}`);
842
+ return value;
843
+ };
554
844
  const operation = validateManageParams(params);
555
845
  if (operation.action === "list") {
556
- const agents = requireRegistry().list(operation.includeClosed);
846
+ const agents = ownedRegistry.list(operation.includeClosed);
557
847
  return {
558
848
  content: [
559
849
  {
@@ -567,46 +857,51 @@ export function registerStatefulSubagents(
567
857
  const agentId = operation.agentId;
568
858
  if (operation.action === "interrupt") {
569
859
  if (operation.subtree) {
570
- const agents = await requireRegistry().interruptTree(agentId);
860
+ const agents = await ownedRegistry.interruptTree(agentId);
861
+ assertCurrentSpawn(signal, generation, runtimeGeneration);
571
862
  return {
572
863
  content: [{ type: "text", text: `Interrupted ${agents.length} active agent(s).` }],
573
864
  details: {
574
- agent: summarizeAgent(requireAgent(agentId)),
865
+ agent: summarizeAgent(ownedAgent(agentId)),
575
866
  agents: agents.map(summarizeAgent),
576
867
  },
577
868
  };
578
869
  }
579
- const agent = await requireRegistry().interrupt(agentId);
870
+ const agent = await ownedRegistry.interrupt(agentId);
871
+ assertCurrentSpawn(signal, generation, runtimeGeneration);
580
872
  return result(agent, `Interrupted ${agent.id}; it remains reusable.`);
581
873
  }
582
- const existing = requireRegistry().get(agentId);
874
+ const existing = ownedRegistry.get(agentId);
583
875
  if (existing?.state === "closed" && !operation.subtree) {
584
876
  const pendingOwner = isolatedAgents.get(existing.id);
585
877
  if (pendingOwner) await workspaceManager.cleanup(pendingOwner);
878
+ assertCurrentSpawn(signal, generation, runtimeGeneration);
586
879
  isolatedAgents.delete(existing.id);
587
880
  return result(existing, `Closed ${existing.id}.`);
588
881
  }
589
882
  if (operation.subtree) {
590
883
  let agents: ManagedAgent[];
591
884
  try {
592
- agents = await requireRegistry().closeTree(agentId);
885
+ agents = await ownedRegistry.closeTree(agentId);
593
886
  } finally {
594
- await cleanupClosedWorkspaces(requireRegistry(), isolatedAgents, workspaceManager);
887
+ await cleanupClosedWorkspaces(ownedRegistry, isolatedAgents, workspaceManager);
595
888
  }
889
+ assertCurrentSpawn(signal, generation, runtimeGeneration);
596
890
  return {
597
891
  content: [{ type: "text", text: `Closed ${agents.length} agent(s).` }],
598
892
  details: {
599
- agent: summarizeAgent(requireAgent(agentId)),
893
+ agent: summarizeAgent(ownedAgent(agentId)),
600
894
  agents: agents.map(summarizeAgent),
601
895
  },
602
896
  };
603
897
  }
604
898
  let agent: ManagedAgent;
605
899
  try {
606
- agent = await requireRegistry().close(agentId);
900
+ agent = await ownedRegistry.close(agentId);
607
901
  } finally {
608
- await cleanupClosedWorkspaces(requireRegistry(), isolatedAgents, workspaceManager);
902
+ await cleanupClosedWorkspaces(ownedRegistry, isolatedAgents, workspaceManager);
609
903
  }
904
+ assertCurrentSpawn(signal, generation, runtimeGeneration);
610
905
  return result(agent, `Closed ${agent.id}.`);
611
906
  },
612
907
  });
@@ -619,25 +914,29 @@ export function registerStatefulSubagents(
619
914
  promptSnippet: "Send or read queue-only detached-subagent mailbox messages",
620
915
  parameters: MailboxParamsSchema,
621
916
  ...createStatefulToolRenderer("mailbox"),
622
- async execute(_id, params): Promise<StatefulActionToolResult> {
917
+ async execute(_id, params, signal): Promise<StatefulActionToolResult> {
918
+ const generation = runtimeGeneration;
919
+ const ownedRegistry = requireRegistry();
623
920
  const operation = validateMailboxParams(params);
624
921
  if (operation.action === "send") {
625
- const message = await requireRegistry().sendMessage(
922
+ const message = await ownedRegistry.sendMessage(
626
923
  operation.agentId,
627
924
  operation.message,
628
925
  operation.senderId,
629
926
  operation.deduplicationKey,
630
927
  );
928
+ assertCurrentSpawn(signal, generation, runtimeGeneration);
631
929
  return {
632
930
  content: [{ type: "text", text: `Queued ${message.id} for ${message.recipientId}.` }],
633
931
  details: { message },
634
932
  };
635
933
  }
636
- const messages = await requireRegistry().readMessages(
934
+ const messages = await ownedRegistry.readMessages(
637
935
  operation.agentId,
638
936
  operation.acknowledge,
639
937
  operation.limit,
640
938
  );
939
+ assertCurrentSpawn(signal, generation, runtimeGeneration);
641
940
  const summaries = messages.map((message) => ({
642
941
  ...message,
643
942
  content: truncateUtf8(message.content, MAX_TOOL_MESSAGE_BYTES).text,
@@ -683,7 +982,18 @@ export function formatStatefulAgentLine(agent: ManagedAgent): string {
683
982
  const unread = agent.mailbox.filter((message) => !message.readAt).length;
684
983
  const indent = " ".repeat(agent.depth);
685
984
  const thinking = agent.thinkingLevel ? ` thinking:${agent.thinkingLevel}` : "";
686
- return `${indent}${sanitizeStatusLine(agent.id, 128)} ${sanitizeStatusLine(agent.agent, 128)} ${agent.state} ${elapsedSeconds}s${thinking} unread:${unread} [${actions}]${task}`;
985
+ const timeout = agent.currentTimeoutMs ?? agent.timeoutMs;
986
+ const timeoutText = timeout ? ` timeout:${timeout}ms` : "";
987
+ const idleTimeout = agent.currentIdleTimeoutMs ?? agent.idleTimeoutMs;
988
+ const idleText = idleTimeout ? ` idle:${idleTimeout}ms` : "";
989
+ const maxTurns = agent.currentMaxTurns ?? agent.maxTurns;
990
+ const turnsText = maxTurns ? ` turns:${maxTurns}` : "";
991
+ const maxToolCalls = agent.currentMaxToolCalls ?? agent.maxToolCalls;
992
+ const toolsText = maxToolCalls ? ` tools:${maxToolCalls}` : "";
993
+ const transport = agent.telemetry?.transport ? ` transport:${agent.telemetry.transport}` : "";
994
+ const phase = agent.telemetry?.phase ? ` phase:${agent.telemetry.phase}` : "";
995
+ const queued = agent.telemetry?.queuePosition ? ` queue:${agent.telemetry.queuePosition}` : "";
996
+ return `${indent}${sanitizeStatusLine(agent.id, 128)} ${sanitizeStatusLine(agent.agent, 128)} ${agent.state} ${elapsedSeconds}s${thinking}${timeoutText}${idleText}${turnsText}${toolsText}${transport}${phase}${queued} unread:${unread} [${actions}]${task}`;
687
997
  }
688
998
 
689
999
  function sanitizeStatusLine(value: string, maxLength: number): string {
@@ -715,233 +1025,40 @@ function summarizeAgent(agent: ManagedAgent) {
715
1025
  cwd: agent.cwd,
716
1026
  workspaceMode: agent.workspaceMode ?? "shared",
717
1027
  thinkingLevel: agent.thinkingLevel,
1028
+ timeoutMs: agent.timeoutMs,
1029
+ currentTimeoutMs: agent.currentTimeoutMs,
1030
+ idleTimeoutMs: agent.idleTimeoutMs,
1031
+ currentIdleTimeoutMs: agent.currentIdleTimeoutMs,
1032
+ maxTurns: agent.maxTurns,
1033
+ currentMaxTurns: agent.currentMaxTurns,
1034
+ maxToolCalls: agent.maxToolCalls,
1035
+ currentMaxToolCalls: agent.currentMaxToolCalls,
718
1036
  currentTask: agent.currentTask
719
1037
  ? truncateUtf8(agent.currentTask, MAX_TOOL_MESSAGE_BYTES).text
720
1038
  : undefined,
721
1039
  historyCount: agent.history.length,
722
1040
  unreadMessages: agent.mailbox.filter((message) => !message.readAt).length,
1041
+ context: {
1042
+ turns: agent.contextTurns ?? 0,
1043
+ sources: agent.contextSourceIds?.length ?? 0,
1044
+ bytes: agent.contextBytes ?? 0,
1045
+ truncated: agent.contextTruncated === true,
1046
+ },
1047
+ resultFormat: agent.resultFormat ?? "text",
1048
+ structuredResult: agent.structuredResult,
1049
+ termination: agent.termination,
1050
+ outcome: agent.outcome,
1051
+ executionPlan: agent.executionPlan,
1052
+ capabilityGrant: agent.capabilityGrant,
1053
+ semanticCompatibility: agent.semanticCompatibility,
1054
+ semanticSnapshotDigest: agent.semanticSnapshot?.digest,
1055
+ telemetry: agent.telemetry,
723
1056
  error: agent.error ? truncateUtf8(agent.error, MAX_TOOL_MESSAGE_BYTES).text : undefined,
724
1057
  target: agent.target,
725
1058
  policy: agent.policy,
726
1059
  };
727
1060
  }
728
1061
 
729
- interface CompletionMetadata {
730
- agentId: string;
731
- agent: string;
732
- state: string;
733
- }
734
-
735
- interface CompletionMessage {
736
- customType: "pi-subagent-completion";
737
- content: string;
738
- display: true;
739
- details:
740
- | CompletionMetadata
741
- | {
742
- completionCount: number;
743
- completions: CompletionMetadata[];
744
- };
745
- }
746
-
747
- type CompletionContext = Pick<ExtensionContext, "hasPendingMessages" | "isIdle">;
748
- type CompletionPi = Pick<ExtensionAPI, "sendMessage">;
749
-
750
- export interface CompletionDeliveryBrokerOptions {
751
- onDeliveryError?: (error: unknown) => void;
752
- }
753
-
754
- /**
755
- * Coalesces detached completions so one bounded notification batch starts at
756
- * most one root synthesis turn. The broker belongs to one parent session and
757
- * must be closed when that session is replaced or shut down.
758
- */
759
- export class CompletionDeliveryBroker {
760
- private pending: AgentTurnCompletion[] = [];
761
- private flushTimer?: NodeJS.Timeout;
762
- private wakeInFlight = false;
763
- private closed = false;
764
-
765
- constructor(
766
- private readonly pi: CompletionPi,
767
- private readonly ctx: CompletionContext,
768
- private delivery: CompletionDelivery,
769
- private readonly options: CompletionDeliveryBrokerOptions = {},
770
- ) {}
771
-
772
- enqueue(completion: AgentTurnCompletion): void {
773
- if (this.closed) return;
774
- this.pending.push(completion);
775
- this.scheduleFlush();
776
- }
777
-
778
- setDelivery(value: CompletionDelivery): void {
779
- this.delivery = value;
780
- this.scheduleFlush();
781
- }
782
-
783
- onParentTurnStart(): void {
784
- this.wakeInFlight = false;
785
- this.scheduleFlush();
786
- }
787
-
788
- onParentSettled(): void {
789
- this.wakeInFlight = false;
790
- this.scheduleFlush();
791
- }
792
-
793
- flush(): void {
794
- if (this.closed || this.pending.length === 0) return;
795
- if (this.flushTimer) clearTimeout(this.flushTimer);
796
- this.flushTimer = undefined;
797
- if (this.delivery === "auto-resume" && !this.isRootIdle()) return;
798
-
799
- const completions = this.pending.splice(0);
800
- const batches = chunkCompletions(completions);
801
- let canWake = this.shouldWakeRoot();
802
- for (let index = 0; index < batches.length; index++) {
803
- const triggerTurn = canWake && index === batches.length - 1;
804
- const message = buildCompletionMessage(batches[index]);
805
- if (triggerTurn) this.wakeInFlight = true;
806
- try {
807
- this.pi.sendMessage(message, { deliverAs: "steer", triggerTurn });
808
- } catch (primaryError) {
809
- if (triggerTurn) this.wakeInFlight = false;
810
- canWake = false;
811
- try {
812
- this.pi.sendMessage(message, { deliverAs: "nextTurn", triggerTurn: false });
813
- } catch (fallbackError) {
814
- this.pending = [...batches.slice(index).flat(), ...this.pending];
815
- try {
816
- this.options.onDeliveryError?.(
817
- new AggregateError(
818
- [primaryError, fallbackError],
819
- "Detached subagent completion delivery failed",
820
- ),
821
- );
822
- } catch {
823
- // Delivery retention must survive a failing observer.
824
- }
825
- return;
826
- }
827
- }
828
- }
829
- }
830
-
831
- close(): void {
832
- this.closed = true;
833
- if (this.flushTimer) clearTimeout(this.flushTimer);
834
- this.flushTimer = undefined;
835
- this.pending = [];
836
- }
837
-
838
- private scheduleFlush(): void {
839
- if (this.closed || this.pending.length === 0 || this.flushTimer) return;
840
- this.flushTimer = setTimeout(() => {
841
- this.flushTimer = undefined;
842
- this.flush();
843
- }, COMPLETION_BATCH_DELAY_MS);
844
- }
845
-
846
- private isRootIdle(): boolean {
847
- try {
848
- return this.ctx.isIdle();
849
- } catch {
850
- return false;
851
- }
852
- }
853
-
854
- private shouldWakeRoot(): boolean {
855
- if (this.delivery !== "auto-resume" || this.wakeInFlight) return false;
856
- try {
857
- return !this.ctx.hasPendingMessages();
858
- } catch {
859
- return false;
860
- }
861
- }
862
- }
863
-
864
- function chunkCompletions(completions: AgentTurnCompletion[]): AgentTurnCompletion[][] {
865
- const batches: AgentTurnCompletion[][] = [];
866
- for (let index = 0; index < completions.length; index += MAX_COMPLETIONS_PER_MESSAGE) {
867
- batches.push(completions.slice(index, index + MAX_COMPLETIONS_PER_MESSAGE));
868
- }
869
- return batches;
870
- }
871
-
872
- function buildCompletionMessage(completions: AgentTurnCompletion[]): CompletionMessage {
873
- if (completions.length === 1) {
874
- const completion = completions[0];
875
- return {
876
- customType: "pi-subagent-completion",
877
- content: buildDetachedCompletionMessage(completion),
878
- display: true,
879
- details: completionMetadata(completion),
880
- };
881
- }
882
- const content = truncateUtf8(
883
- [
884
- "Message Type: SUBAGENT_COMPLETION_BATCH",
885
- `Completion Count: ${completions.length}`,
886
- ...completions.flatMap((completion, index) => [
887
- "",
888
- `--- Completion ${index + 1} of ${completions.length} ---`,
889
- buildDetachedCompletionMessage(completion),
890
- ]),
891
- ].join("\n"),
892
- DEFAULT_MAX_CONTEXT_BYTES,
893
- ).text;
894
- return {
895
- customType: "pi-subagent-completion",
896
- content,
897
- display: true,
898
- details: {
899
- completionCount: completions.length,
900
- completions: completions.map(completionMetadata),
901
- },
902
- };
903
- }
904
-
905
- function completionMetadata(completion: AgentTurnCompletion): CompletionMetadata {
906
- return {
907
- agentId: completion.agent.id,
908
- agent: completion.agent.agent,
909
- state: completion.agent.state,
910
- };
911
- }
912
-
913
- export function buildDetachedCompletionMessage(completion: AgentTurnCompletion): string {
914
- const task = sanitizeCompletionLine(completion.task, 256) || "(unknown task)";
915
- const agentName = sanitizeCompletionLine(completion.agent.agent, 128) || "(unknown agent)";
916
- const output = redactPrivateText(completion.output);
917
- const error = completion.error
918
- ? truncateUtf8(redactPrivateText(completion.error), MAX_COMPLETION_ERROR_BYTES).text
919
- : "";
920
- return truncateUtf8(
921
- [
922
- "Message Type: SUBAGENT_COMPLETION",
923
- `Agent ID: ${completion.agent.id}`,
924
- `Agent: ${agentName}`,
925
- `Task: ${task}`,
926
- `State: ${completion.agent.state}`,
927
- ...(error.trim() ? ["Error:", error] : []),
928
- "Payload:",
929
- output.trim() ? output : "(no output)",
930
- ].join("\n"),
931
- MAX_TOOL_MESSAGE_BYTES,
932
- ).text;
933
- }
934
-
935
- function sanitizeCompletionLine(value: string, maxBytes: number): string {
936
- return (
937
- truncateUtf8(redactPrivateText(value), maxBytes)
938
- // biome-ignore lint/suspicious/noControlCharactersInRegex: Strip untrusted terminal controls.
939
- .text.replace(/[\u0000-\u001f\u007f]+/g, " ")
940
- .replace(/\s+/g, " ")
941
- .trim()
942
- );
943
- }
944
-
945
1062
  async function cleanupClosedWorkspaces(
946
1063
  registry: AgentRegistry,
947
1064
  isolatedAgents: Map<string, string>,
@@ -965,22 +1082,15 @@ function result(agent: ManagedAgent, text: string) {
965
1082
  };
966
1083
  }
967
1084
 
968
- export function resolveStatefulTransportKind(
969
- value: "subprocess" | "in-process" | undefined,
970
- ): "subprocess" | "in-process" {
971
- return value ?? "subprocess";
972
- }
973
-
974
- export function resolveCompletionDelivery(
975
- value: CompletionDelivery | undefined,
976
- ): CompletionDelivery {
977
- return value ?? "next-turn";
978
- }
979
-
980
1085
  function normalizeRuntimeThinkingLevel(value: string): ParentRuntimeSnapshot["thinkingLevel"] {
981
1086
  return isThinkingLevel(value) ? value : "off";
982
1087
  }
983
1088
 
1089
+ export {
1090
+ buildDetachedCompletionMessage,
1091
+ CompletionDeliveryBroker,
1092
+ } from "./completion-delivery.js";
1093
+ export { resolveCompletionDelivery, resolveStatefulTransportKind } from "./stateful-config.js";
984
1094
  export {
985
1095
  buildStatefulTurnPrompt,
986
1096
  resolveStatefulTurnTimeout,