@narumitw/pi-subagents 0.54.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -299,8 +299,8 @@ No benchmark result in this release changes the default delegation policy or mak
299
299
  | --- | --- | --- |
300
300
  | `list_agents` | Optional `agentScope` (default `user`) and `limit` (default 32, maximum 100) | Bounded agent metadata and omission counts |
301
301
  | `get_agent` | Required `agent`; optional `agentScope` | One resolved definition, safe source path, configured tools, and consultation-effective tools; never the system prompt |
302
- | `list_runs` | Optional `includeClosed` and `limit` (default 50, maximum 100) | Metadata-only retained-run summaries and unread counts |
303
- | `get_run` | Required `agentId` | Safe `cwd`, current-task/error summaries, thinking level, context footprint, protocol, effective transport, bounded timing/usage telemetry, structured result when valid, policy, history count, and unread count |
302
+ | `list_runs` | Optional `includeClosed` and `limit` (default 50, maximum 100) | Metadata-only retained-run summaries, turn generation, pending-completion count, and unread counts |
303
+ | `get_run` | Required `agentId` | Safe `cwd`, current run and turn generation, current-task/error summaries, thinking level, context footprint, protocol, effective transport, bounded timing/usage telemetry, structured result when valid, policy, history count, pending-completion count, and unread count |
304
304
  | `list_workflows` | Optional `limit` (default 50, maximum 100) | Metadata-only persisted blocking-workflow summaries for the current session |
305
305
  | `get_workflow` | Required `workflowId` | Bounded task states, generations, dependencies, plan identities, artifact metadata, verification state, and outcome reasons without artifact contents |
306
306
  | `list_models` | Optional `limit` (default 50, maximum 100) | Session-scoped models, or the already-loaded available snapshot |
@@ -310,7 +310,7 @@ No benchmark result in this release changes the default delegation policy or mak
310
310
 
311
311
  The schema rejects fields that do not belong to the selected action. Explicit `project` or `both` scope fails before project-agent discovery unless Pi already trusts the project. Run inspection never returns history output, stored context, or mailbox content; unread counts come from a metadata-only snapshot and do not acknowledge messages. Workflow inspection reads validated, redacted snapshots without quarantining or rewriting invalid files. Paths beneath the Pi agent directory use `~`, project paths are workspace-relative, model objects are projected through an allow-list, and model-facing text is bounded to 50 KiB or 2,000 lines.
312
312
 
313
- Compatibility: `subagent_manage({ "action": "list" })` remains supported with its existing behavior. Prefer `subagent_inspect` when a whole tool must be safe to activate on a read-only surface.
313
+ `subagent_manage` no longer accepts its former compatibility `list` action. Use `subagent_inspect({ "action": "list_runs", "includeClosed": true })` for metadata-only discovery and `get_run` for detail.
314
314
 
315
315
  ## 📖 Read-only consultation
316
316
 
@@ -599,9 +599,9 @@ Legacy v1 and v2 records without acceptance fields retain their prior completed
599
599
 
600
600
  ## 🔁 Stateful agents
601
601
 
602
- Stateful lifecycle tools are available by default. `subagent_spawn` is detached: it schedules work, returns immediately with an opaque `agentId`, and later injects a bounded `pi-subagent-completion` custom message. Completions that settle in the same dispatch window are batched, and the broker allows at most one in-flight root wake until that parent turn starts.
602
+ Stateful lifecycle tools are available by default. `subagent_spawn` is detached: it schedules work, returns immediately with an opaque `agentId`, and later injects a bounded `pi-subagent-completion` custom message. Every turn receives an executor-owned `runId`, monotonically increasing agent-local generation, and unique `completionId`. The terminal completion is persisted before delivery, simultaneous completions are batched, and the broker allows at most one in-flight root wake until that parent turn starts.
603
603
 
604
- Detached work follows a non-polling policy. With default `next-turn` delivery, prefer one bounded `subagent_spawn` for related asynchronous research or review only when the current response does not depend on its result. If it does, use blocking `subagent` when registered; in **Async only**, complete required work directly, opt into `auto-resume` when a later synthesis turn is appropriate, or switch workflows. With opt-in `auto-resume`, detached broad work may be final-answer-dependent because completion requests a synthesis turn after the root settles. In either mode, do useful non-overlapping main-agent work immediately, do not poll `subagent_manage` with `action: "list"` or `subagent_mailbox` with `action: "read"`, and do not duplicate delegated work. Add another detached agent only for truly independent work with safe workspace concurrency. Detached lifecycle work intentionally has no `subagent_wait` tool.
604
+ Detached work follows a non-polling policy. With default `next-turn` delivery, prefer one bounded `subagent_spawn` for related asynchronous research or review only when the current response does not depend on its result. If it does, use blocking `subagent` when registered; in **Async only**, complete required work directly, opt into `auto-resume` when a later synthesis turn is appropriate, or switch workflows. With opt-in `auto-resume`, detached broad work may be final-answer-dependent because completion requests a synthesis turn after the root settles. In either mode, do useful non-overlapping main-agent work immediately, do not poll `subagent_inspect` or `subagent_mailbox` with `action: "read"`, and do not duplicate delegated work. Add another detached agent only for truly independent work with safe workspace concurrency. Detached lifecycle work intentionally has no `subagent_wait` tool.
605
605
 
606
606
  A detached agent additionally needs a concrete isolation or specialization benefit such as independent review, bounded context/output, a distinct model/tool profile, or workspace isolation. Simple work that the main agent can perform directly should not be delegated.
607
607
 
@@ -610,7 +610,7 @@ A detached agent additionally needs a concrete isolation or specialization benef
610
610
  - `"next-turn"` (default) preserves the previous behavior: use `deliverAs: "steer"` with `triggerTurn: false`. An active root can consume completion naturally; an idle root is not awakened.
611
611
  - `"auto-resume"` holds completion while the root is active, then requests one synthesis turn after the parent settles when no user or extension messages are already pending. Simultaneous completions share that turn, active work is not interrupted, and pending input suppresses the autonomous wake.
612
612
 
613
- Auto-resume is best-effort because Pi's custom-message API is fire-and-forget. Session-generation checks, shutdown cleanup, batching, and the in-flight wake guard prevent stale or duplicate scheduling pressure, but they do not make completion delivery durable across process exit.
613
+ The bounded persisted completion outbox provides ordered at-least-once delivery across process restart without replaying the child turn. When state must be reduced to its storage bound, persistence drops roots without pending completions first and trims old history rather than discarding an outbox-owned root. A completion is acknowledged only after parent context assembly observes its exact `completionId`; an injection that returns synchronously but never reaches context remains pending for retry. If the process exits after context assembly but before acknowledgement is persisted, the same ID can be delivered again and consumers must deduplicate it. Auto-resume wake admission remains best-effort because Pi's custom-message API is fire-and-forget, but an unacknowledged terminal completion itself remains available for redelivery on the next start of the owning session. Transient terminal-persistence failures retry with bounded exponential backoff and keep the run pending; shutdown cancels retry waits and reports a final persistence failure instead of silently resolving unsaved work.
614
614
 
615
615
  The default `subprocess` transport preserves compatibility: each turn starts a fresh isolated `pi --mode json -p --no-session` child and receives sanitized, bounded history.
616
616
  Set `transport` to `in-process` to retain one public Pi SDK `AgentSession` per stateful `agentId`, avoiding repeated process startup while preserving native child history in memory.
@@ -687,7 +687,7 @@ This avoids lifecycle-driven tool-schema churn and preserves a stable provider p
687
687
  | --- | --- |
688
688
  | `subagent_spawn` | Start detached work with optional task-selected thinking and retained timeout, exact-retry `idempotencyKey`, and `text`, `structured-v1`, or `structured-v2` result format; return an opaque `agentId` immediately and deliver completion asynchronously. |
689
689
  | `subagent_send` | Send follow-up work with an optional one-turn timeout override and trigger a new turn on a reusable agent; semantic skew requires explicit `revalidate: true`, and shared-workspace write conflicts are guarded unless explicitly overridden. |
690
- | `subagent_manage` | Use `action: "list"` to inspect agents, `"interrupt"` to retain an agent after aborting active work, or `"close"` to release it; interrupt/close accept optional `subtree`. |
690
+ | `subagent_manage` | Use `"interrupt"` to retain an agent after aborting active work or `"close"` to release it; both actions accept optional `subtree`. Use `subagent_inspect` for all list and detail operations. |
691
691
  | `subagent_mailbox` | Use `action: "send"` for queue-only messages that do not start a turn, or `"read"` to read and optionally acknowledge unread messages. |
692
692
 
693
693
  The action schemas are flat for provider compatibility and reject parameters that belong to another action. For example:
@@ -721,13 +721,14 @@ The five replaced names are intentionally not registered as aliases. Update expl
721
721
 
722
722
  | Previous call | Fixed-surface call |
723
723
  | --- | --- |
724
- | `subagent_list({ includeClosed })` | `subagent_manage({ action: "list", includeClosed })` |
724
+ | `subagent_list({ includeClosed })` | `subagent_inspect({ action: "list_runs", includeClosed })` |
725
+ | `subagent_manage({ action: "list", includeClosed })` | `subagent_inspect({ action: "list_runs", includeClosed })` |
725
726
  | `subagent_interrupt({ agentId, subtree })` | `subagent_manage({ action: "interrupt", agentId, subtree })` |
726
727
  | `subagent_close({ agentId, subtree })` | `subagent_manage({ action: "close", agentId, subtree })` |
727
728
  | `subagent_message({ agentId, message, ... })` | `subagent_mailbox({ action: "send", agentId, message, ... })` |
728
729
  | `subagent_messages({ agentId, acknowledge, limit })` | `subagent_mailbox({ action: "read", agentId, acknowledge, limit })` |
729
730
 
730
- Persisted agent and mailbox records require no migration. If an explicit prompt in a resumed conversation keeps requesting an old name, update it with the mapping above or start a fresh conversation. To roll back after an upgrade, pin the package version used before the upgrade; for this migration, use `pi install npm:@narumitw/pi-subagents@0.26.0`. The previous release can read the same state directory.
731
+ Persisted agent and mailbox records require no manual migration; older records load with an empty completion outbox and generation zero. If an explicit prompt in a resumed conversation keeps requesting an old name, update it with the mapping above or start a fresh conversation. To roll back after an upgrade, pin the package version used before the upgrade; for this migration, use `pi install npm:@narumitw/pi-subagents@0.26.0`. The previous release can read the same state directory.
731
732
 
732
733
  A spawn can request a thinking level explicitly:
733
734
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-subagents",
3
- "version": "0.54.0",
3
+ "version": "1.0.0",
4
4
  "description": "Pi extension for delegating work to specialized isolated subagents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -12,6 +12,9 @@ const COMPLETION_BATCH_DELAY_MS = 10;
12
12
 
13
13
  interface CompletionMetadata {
14
14
  protocol: typeof PI_SUBAGENTS_RPC_PROTOCOL;
15
+ completionId: string;
16
+ runId: string;
17
+ generation: number;
15
18
  agentId: string;
16
19
  agent: string;
17
20
  state: string;
@@ -38,13 +41,15 @@ type CompletionPi = Pick<ExtensionAPI, "sendMessage">;
38
41
 
39
42
  export interface CompletionDeliveryBrokerOptions {
40
43
  onDeliveryError?: (error: unknown) => void;
41
- onDelivered?: (completions: readonly AgentTurnCompletion[], deliveredAt: number) => void;
44
+ onAcknowledged?: (completions: readonly AgentTurnCompletion[], acknowledgedAt: number) => void;
42
45
  now?: () => number;
43
46
  }
44
47
 
45
48
  /** Owns bounded completion batching and at most one idle-root wake for one parent session. */
46
49
  export class CompletionDeliveryBroker {
47
50
  private pending: AgentTurnCompletion[] = [];
51
+ private readonly knownCompletionIds = new Set<string>();
52
+ private awaitingParentAck: AgentTurnCompletion[] = [];
48
53
  private flushTimer?: NodeJS.Timeout;
49
54
  private wakeInFlight = false;
50
55
  private closed = false;
@@ -57,7 +62,8 @@ export class CompletionDeliveryBroker {
57
62
  ) {}
58
63
 
59
64
  enqueue(completion: AgentTurnCompletion): void {
60
- if (this.closed) return;
65
+ if (this.closed || this.knownCompletionIds.has(completion.completionId)) return;
66
+ this.knownCompletionIds.add(completion.completionId);
61
67
  this.pending.push(completion);
62
68
  this.scheduleFlush();
63
69
  }
@@ -72,6 +78,14 @@ export class CompletionDeliveryBroker {
72
78
  this.scheduleFlush();
73
79
  }
74
80
 
81
+ onParentContext(messages: readonly unknown[]): void {
82
+ this.acknowledgeVisible(completionIdsFromContext(messages));
83
+ if (this.awaitingParentAck.length > 0) {
84
+ this.pending = [...this.awaitingParentAck.splice(0), ...this.pending];
85
+ }
86
+ this.scheduleFlush();
87
+ }
88
+
75
89
  onParentSettled(): void {
76
90
  this.wakeInFlight = false;
77
91
  this.scheduleFlush();
@@ -88,18 +102,21 @@ export class CompletionDeliveryBroker {
88
102
  let canWake = this.shouldWakeRoot();
89
103
  for (let index = 0; index < batches.length; index++) {
90
104
  const triggerTurn = canWake && index === batches.length - 1;
91
- const message = buildCompletionMessage(batches[index]);
105
+ const batch = batches[index];
106
+ const message = buildCompletionMessage(batch);
92
107
  if (triggerTurn) this.wakeInFlight = true;
108
+ this.awaitingParentAck.push(...batch);
93
109
  try {
94
110
  this.pi.sendMessage(message, { deliverAs: "steer", triggerTurn });
95
- this.notifyDelivered(batches[index]);
96
111
  } catch (primaryError) {
112
+ this.removeAwaiting(batch);
97
113
  if (triggerTurn) this.wakeInFlight = false;
98
114
  canWake = false;
115
+ this.awaitingParentAck.push(...batch);
99
116
  try {
100
117
  this.pi.sendMessage(message, { deliverAs: "nextTurn", triggerTurn: false });
101
- this.notifyDelivered(batches[index]);
102
118
  } catch (fallbackError) {
119
+ this.removeAwaiting(batch);
103
120
  this.pending = [...batches.slice(index).flat(), ...this.pending];
104
121
  try {
105
122
  this.options.onDeliveryError?.(
@@ -122,6 +139,8 @@ export class CompletionDeliveryBroker {
122
139
  if (this.flushTimer) clearTimeout(this.flushTimer);
123
140
  this.flushTimer = undefined;
124
141
  this.pending = [];
142
+ this.awaitingParentAck = [];
143
+ this.knownCompletionIds.clear();
125
144
  }
126
145
 
127
146
  private scheduleFlush(): void {
@@ -132,14 +151,34 @@ export class CompletionDeliveryBroker {
132
151
  }, COMPLETION_BATCH_DELAY_MS);
133
152
  }
134
153
 
135
- private notifyDelivered(completions: readonly AgentTurnCompletion[]): void {
154
+ private acknowledgeVisible(visibleIds: ReadonlySet<string>): void {
155
+ if (visibleIds.size === 0) return;
156
+ const completions = [...this.awaitingParentAck, ...this.pending].filter((completion) =>
157
+ visibleIds.has(completion.completionId),
158
+ );
159
+ if (completions.length === 0) return;
160
+ const acknowledgedIds = new Set(completions.map((completion) => completion.completionId));
161
+ this.awaitingParentAck = this.awaitingParentAck.filter(
162
+ (completion) => !acknowledgedIds.has(completion.completionId),
163
+ );
164
+ this.pending = this.pending.filter(
165
+ (completion) => !acknowledgedIds.has(completion.completionId),
166
+ );
167
+ for (const completion of completions) this.knownCompletionIds.delete(completion.completionId);
136
168
  try {
137
- this.options.onDelivered?.(completions, (this.options.now ?? Date.now)());
169
+ this.options.onAcknowledged?.(completions, (this.options.now ?? Date.now)());
138
170
  } catch {
139
- // Delivery already succeeded, so observer failures cannot requeue it.
171
+ // Context assembly already observed the message, so observer failures cannot retract it.
140
172
  }
141
173
  }
142
174
 
175
+ private removeAwaiting(completions: readonly AgentTurnCompletion[]): void {
176
+ const removed = new Set(completions.map((completion) => completion.completionId));
177
+ this.awaitingParentAck = this.awaitingParentAck.filter(
178
+ (completion) => !removed.has(completion.completionId),
179
+ );
180
+ }
181
+
143
182
  private isRootIdle(): boolean {
144
183
  try {
145
184
  return this.ctx.isIdle();
@@ -158,6 +197,33 @@ export class CompletionDeliveryBroker {
158
197
  }
159
198
  }
160
199
 
200
+ function completionIdsFromContext(messages: readonly unknown[]): Set<string> {
201
+ const ids = new Set<string>();
202
+ for (const message of messages) {
203
+ if (!message || typeof message !== "object" || Array.isArray(message)) continue;
204
+ const record = message as Record<string, unknown>;
205
+ if (record.role !== "custom" || record.customType !== "pi-subagent-completion") continue;
206
+ const details = record.details;
207
+ if (!details || typeof details !== "object" || Array.isArray(details)) continue;
208
+ const metadata = details as Record<string, unknown>;
209
+ if (
210
+ metadata.protocol === PI_SUBAGENTS_RPC_PROTOCOL &&
211
+ typeof metadata.completionId === "string"
212
+ ) {
213
+ ids.add(metadata.completionId);
214
+ }
215
+ if (!Array.isArray(metadata.completions)) continue;
216
+ for (const completion of metadata.completions) {
217
+ if (!completion || typeof completion !== "object" || Array.isArray(completion)) continue;
218
+ const item = completion as Record<string, unknown>;
219
+ if (item.protocol === PI_SUBAGENTS_RPC_PROTOCOL && typeof item.completionId === "string") {
220
+ ids.add(item.completionId);
221
+ }
222
+ }
223
+ }
224
+ return ids;
225
+ }
226
+
161
227
  function chunkCompletions(completions: AgentTurnCompletion[]): AgentTurnCompletion[][] {
162
228
  const batches: AgentTurnCompletion[][] = [];
163
229
  for (let index = 0; index < completions.length; index += MAX_COMPLETIONS_PER_MESSAGE) {
@@ -203,6 +269,9 @@ function buildCompletionMessage(completions: AgentTurnCompletion[]): CompletionM
203
269
  function completionMetadata(completion: AgentTurnCompletion): CompletionMetadata {
204
270
  return {
205
271
  protocol: PI_SUBAGENTS_RPC_PROTOCOL,
272
+ completionId: completion.completionId,
273
+ runId: completion.runId,
274
+ generation: completion.generation,
206
275
  agentId: completion.agent.id,
207
276
  agent: completion.agent.agent,
208
277
  state: completion.agent.state,
@@ -233,6 +302,9 @@ export function buildDetachedCompletionMessage(completion: AgentTurnCompletion):
233
302
  [
234
303
  "Message Type: SUBAGENT_COMPLETION",
235
304
  `Protocol: ${PI_SUBAGENTS_RPC_PROTOCOL}`,
305
+ `Completion ID: ${completion.completionId}`,
306
+ `Run ID: ${completion.runId}`,
307
+ `Generation: ${completion.generation}`,
236
308
  `Agent ID: ${completion.agent.id}`,
237
309
  `Agent: ${agentName}`,
238
310
  `Task: ${task}`,
package/src/inspect.ts CHANGED
@@ -404,6 +404,8 @@ function projectRunSummary(run: AgentRunInspectionSummary): Record<string, unkno
404
404
  updatedAt: run.updatedAt,
405
405
  historyCount: run.historyCount,
406
406
  unreadMessages: run.unreadMessages,
407
+ turnGeneration: run.turnGeneration,
408
+ pendingCompletionCount: run.pendingCompletionCount,
407
409
  };
408
410
  }
409
411
 
@@ -474,6 +476,8 @@ function projectRun(run: AgentRunInspectionDetail, ctx: ExtensionContext): Recor
474
476
  : undefined,
475
477
  semanticCompatibility: run.semanticCompatibility,
476
478
  telemetry: run.telemetry,
479
+ currentRunId: run.currentRunId,
480
+ currentTurnGeneration: run.currentTurnGeneration,
477
481
  currentTask: run.currentTask ? boundedPrivateText(run.currentTask, 2 * 1024) : undefined,
478
482
  error: run.error ? boundedPrivateText(run.error, 2 * 1024) : undefined,
479
483
  target: run.target
@@ -16,12 +16,13 @@ import { resolveStatefulLimits } from "./stateful-limits.js";
16
16
  import { copyTurnTerminationReport, type TurnTerminationReport } from "./timeout-checkpoint.js";
17
17
  import { MAX_SUBAGENT_TOOL_CALLS, MAX_SUBAGENT_TURNS } from "./turn-budget.js";
18
18
 
19
- const STATE_VERSION = 2;
19
+ const STATE_VERSION = 3;
20
+ const MAX_STORED_COMPLETIONS_PER_AGENT = 20;
20
21
  const DEFAULT_STATEFUL_LIMITS = resolveStatefulLimits();
21
- const MAX_STATE_BYTES = 1024 * 1024;
22
+ const MAX_STATE_BYTES = 5 * 1024 * 1024;
22
23
 
23
24
  interface StoredState {
24
- version: 2;
25
+ version: 3;
25
26
  updatedAt: number;
26
27
  agents: ManagedAgent[];
27
28
  }
@@ -86,8 +87,19 @@ export class AgentPersistence {
86
87
  const state: StoredState = { version: STATE_VERSION, updatedAt: Date.now(), agents: records };
87
88
  let content = `${JSON.stringify(state, null, "\t")}\n`;
88
89
  while (Buffer.byteLength(content, "utf8") > MAX_STATE_BYTES && state.agents.length > 0) {
89
- const oldestRootId = state.agents[0].rootId;
90
- state.agents = state.agents.filter((agent) => agent.rootId !== oldestRootId);
90
+ const rootsWithPendingCompletions = new Set(
91
+ state.agents
92
+ .filter((agent) => (agent.pendingCompletions?.length ?? 0) > 0)
93
+ .map((agent) => agent.rootId),
94
+ );
95
+ const droppableRoot = state.agents.find(
96
+ (agent) => !rootsWithPendingCompletions.has(agent.rootId),
97
+ )?.rootId;
98
+ if (droppableRoot) {
99
+ state.agents = state.agents.filter((agent) => agent.rootId !== droppableRoot);
100
+ } else if (!trimOldestHistory(state.agents) && !clearOldestContext(state.agents)) {
101
+ throw new Error("Subagent state exceeds its durable completion storage limit");
102
+ }
91
103
  content = `${JSON.stringify(state, null, "\t")}\n`;
92
104
  }
93
105
  await fs.promises.mkdir(path.dirname(this.filePath), { recursive: true });
@@ -113,6 +125,27 @@ export class AgentPersistence {
113
125
  }
114
126
  }
115
127
 
128
+ function trimOldestHistory(agents: ManagedAgent[]): boolean {
129
+ const candidate = agents
130
+ .filter((agent) => agent.history.length > 0)
131
+ .sort(
132
+ (left, right) =>
133
+ (left.history[0]?.completedAt ?? Number.POSITIVE_INFINITY) -
134
+ (right.history[0]?.completedAt ?? Number.POSITIVE_INFINITY),
135
+ )[0];
136
+ if (!candidate) return false;
137
+ candidate.history.shift();
138
+ return true;
139
+ }
140
+
141
+ function clearOldestContext(agents: ManagedAgent[]): boolean {
142
+ const candidate = agents.find((agent) => agent.context !== undefined);
143
+ if (!candidate) return false;
144
+ candidate.context = undefined;
145
+ candidate.contextTruncated = true;
146
+ return true;
147
+ }
148
+
116
149
  function sanitizeAgent(agent: ManagedAgent): ManagedAgent {
117
150
  return {
118
151
  ...agent,
@@ -126,6 +159,17 @@ function sanitizeAgent(agent: ManagedAgent): ManagedAgent {
126
159
  })),
127
160
  state: agent.state === "running" || agent.state === "starting" ? "interrupted" : agent.state,
128
161
  currentTask: undefined,
162
+ currentRunId: undefined,
163
+ currentTurnGeneration: undefined,
164
+ turnGeneration: agent.turnGeneration ?? 0,
165
+ pendingCompletions: (agent.pendingCompletions ?? [])
166
+ .slice(-MAX_STORED_COMPLETIONS_PER_AGENT)
167
+ .map((completion) => ({
168
+ ...completion,
169
+ task: redactPrivateText(completion.task),
170
+ output: redactPrivateText(completion.output),
171
+ error: completion.error ? redactPrivateText(completion.error) : undefined,
172
+ })),
129
173
  currentTimeoutMs: undefined,
130
174
  currentIdleTimeoutMs: undefined,
131
175
  currentMaxTurns: undefined,
@@ -184,7 +228,10 @@ function sanitizeTermination(report: TurnTerminationReport): TurnTerminationRepo
184
228
  function isStoredState(value: unknown): value is StoredState {
185
229
  if (!value || typeof value !== "object") return false;
186
230
  const state = value as { version?: unknown; agents?: unknown };
187
- if ((state.version !== 1 && state.version !== STATE_VERSION) || !Array.isArray(state.agents)) {
231
+ if (
232
+ (state.version !== 1 && state.version !== 2 && state.version !== STATE_VERSION) ||
233
+ !Array.isArray(state.agents)
234
+ ) {
188
235
  return false;
189
236
  }
190
237
  return state.agents.every((agent) => {
@@ -208,6 +255,9 @@ function isStoredState(value: unknown): value is StoredState {
208
255
  (record.termination === undefined || isTerminationReport(record.termination)) &&
209
256
  (record.contextTurns === undefined || isNonNegativeInteger(record.contextTurns)) &&
210
257
  (record.contextBytes === undefined || isNonNegativeInteger(record.contextBytes)) &&
258
+ (record.turnGeneration === undefined || isNonNegativeInteger(record.turnGeneration)) &&
259
+ (record.pendingCompletions === undefined ||
260
+ isCompletionOutbox(record.pendingCompletions, record.turnGeneration ?? 0)) &&
211
261
  (record.spawnIdempotencyKey === undefined ||
212
262
  (typeof record.spawnIdempotencyKey === "string" &&
213
263
  record.spawnIdempotencyKey.length > 0 &&
@@ -356,10 +406,59 @@ function isTargetPolicyAudit(value: unknown): boolean {
356
406
  );
357
407
  }
358
408
 
409
+ function isCompletionOutbox(value: unknown, turnGeneration: number): boolean {
410
+ if (!Array.isArray(value) || value.length > MAX_STORED_COMPLETIONS_PER_AGENT) return false;
411
+ const ids = new Set<string>();
412
+ let previousGeneration = 0;
413
+ let previousCreatedAt = Number.NEGATIVE_INFINITY;
414
+ for (const completion of value) {
415
+ if (!isPersistedCompletion(completion, turnGeneration)) return false;
416
+ if (ids.has(completion.completionId)) return false;
417
+ if (completion.generation <= previousGeneration || completion.createdAt <= previousCreatedAt) {
418
+ return false;
419
+ }
420
+ ids.add(completion.completionId);
421
+ previousGeneration = completion.generation;
422
+ previousCreatedAt = completion.createdAt;
423
+ }
424
+ return true;
425
+ }
426
+
427
+ function isPersistedCompletion(
428
+ value: unknown,
429
+ turnGeneration: number,
430
+ ): value is import("./registry.js").PersistedAgentCompletion {
431
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
432
+ const completion = value as Record<string, unknown>;
433
+ return (
434
+ typeof completion.completionId === "string" &&
435
+ completion.completionId.length > 0 &&
436
+ completion.completionId.length <= 256 &&
437
+ typeof completion.runId === "string" &&
438
+ completion.runId.length > 0 &&
439
+ completion.runId.length <= 256 &&
440
+ typeof completion.generation === "number" &&
441
+ Number.isSafeInteger(completion.generation) &&
442
+ completion.generation >= 1 &&
443
+ completion.generation <= turnGeneration &&
444
+ typeof completion.task === "string" &&
445
+ typeof completion.output === "string" &&
446
+ (completion.error === undefined || typeof completion.error === "string") &&
447
+ typeof completion.createdAt === "number" &&
448
+ Number.isFinite(completion.createdAt)
449
+ );
450
+ }
451
+
359
452
  function isAgentTurn(value: unknown): boolean {
360
453
  if (!value || typeof value !== "object") return false;
361
454
  const turn = value as Record<string, unknown>;
362
455
  return (
456
+ (turn.runId === undefined ||
457
+ (typeof turn.runId === "string" && turn.runId.length > 0 && turn.runId.length <= 256)) &&
458
+ (turn.generation === undefined ||
459
+ (typeof turn.generation === "number" &&
460
+ Number.isSafeInteger(turn.generation) &&
461
+ turn.generation >= 1)) &&
363
462
  typeof turn.task === "string" &&
364
463
  typeof turn.output === "string" &&
365
464
  typeof turn.startedAt === "number" &&
@@ -23,6 +23,8 @@ export type AgentLifecycleState =
23
23
  | "closed";
24
24
 
25
25
  export interface AgentTurn {
26
+ runId?: string;
27
+ generation?: number;
26
28
  task: string;
27
29
  output: string;
28
30
  startedAt: number;
@@ -32,6 +34,16 @@ export interface AgentTurn {
32
34
  termination?: TurnTerminationReport;
33
35
  }
34
36
 
37
+ export interface PersistedAgentCompletion {
38
+ completionId: string;
39
+ runId: string;
40
+ generation: number;
41
+ task: string;
42
+ output: string;
43
+ error?: string;
44
+ createdAt: number;
45
+ }
46
+
35
47
  export interface AgentMailboxMessage {
36
48
  id: string;
37
49
  senderId: string;
@@ -64,6 +76,10 @@ export interface ManagedAgent {
64
76
  maxToolCalls?: number;
65
77
  currentMaxToolCalls?: number;
66
78
  currentTask?: string;
79
+ turnGeneration?: number;
80
+ currentRunId?: string;
81
+ currentTurnGeneration?: number;
82
+ pendingCompletions?: PersistedAgentCompletion[];
67
83
  history: AgentTurn[];
68
84
  error?: string;
69
85
  context?: string;
@@ -98,6 +114,8 @@ export interface AgentRunInspectionSummary {
98
114
  updatedAt: number;
99
115
  historyCount: number;
100
116
  unreadMessages: number;
117
+ turnGeneration: number;
118
+ pendingCompletionCount: number;
101
119
  }
102
120
 
103
121
  export interface AgentRunInspectionDetail extends AgentRunInspectionSummary {
@@ -112,6 +130,8 @@ export interface AgentRunInspectionDetail extends AgentRunInspectionSummary {
112
130
  maxToolCalls?: number;
113
131
  currentMaxToolCalls?: number;
114
132
  currentTask?: string;
133
+ currentRunId?: string;
134
+ currentTurnGeneration?: number;
115
135
  error?: string;
116
136
  workspaceMode?: "worktree";
117
137
  contextTurns?: number;
@@ -151,11 +171,8 @@ export interface TurnOutcome {
151
171
  telemetry?: TransportTelemetry;
152
172
  }
153
173
 
154
- export interface AgentTurnCompletion {
174
+ export interface AgentTurnCompletion extends PersistedAgentCompletion {
155
175
  agent: ManagedAgent;
156
- task: string;
157
- output: string;
158
- error?: string;
159
176
  }
160
177
 
161
178
  export interface AgentRegistryOptions {
package/src/registry.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  DEFAULT_MAX_CONTEXT_BYTES,
22
22
  DEFAULT_MAX_OUTPUT_BYTES,
23
23
  MAX_SUBAGENT_TIMEOUT_MS,
24
+ MAX_TOOL_MESSAGE_BYTES,
24
25
  truncateUtf8,
25
26
  } from "./limits.js";
26
27
  import { classifyStructuredOutcome } from "./outcome.js";
@@ -47,6 +48,9 @@ import type { TransportTelemetry } from "./transport-types.js";
47
48
  import { type TurnLimits, validateTurnLimits } from "./turn-budget.js";
48
49
 
49
50
  const DEFAULT_STATEFUL_LIMITS = resolveStatefulLimits();
51
+ const MAX_PENDING_COMPLETIONS_PER_AGENT = 20;
52
+ const INITIAL_PERSISTENCE_RETRY_DELAY_MS = 25;
53
+ const MAX_PERSISTENCE_RETRY_DELAY_MS = 1_000;
50
54
 
51
55
  export type * from "./registry-types.js";
52
56
 
@@ -73,6 +77,8 @@ function validateTurnTimeout(value: number): number {
73
77
 
74
78
  function clearCurrentTurn(agent: ManagedAgent): void {
75
79
  agent.currentTask = undefined;
80
+ agent.currentRunId = undefined;
81
+ agent.currentTurnGeneration = undefined;
76
82
  agent.currentTimeoutMs = undefined;
77
83
  agent.currentIdleTimeoutMs = undefined;
78
84
  agent.currentMaxTurns = undefined;
@@ -86,6 +92,21 @@ function waitAbortError(): Error {
86
92
  return error;
87
93
  }
88
94
 
95
+ function waitForPersistenceRetry(milliseconds: number, signal: AbortSignal): Promise<void> {
96
+ if (signal.aborted) return Promise.reject(signal.reason);
97
+ return new Promise((resolve, reject) => {
98
+ const onAbort = () => {
99
+ clearTimeout(timer);
100
+ reject(signal.reason);
101
+ };
102
+ const timer = setTimeout(() => {
103
+ signal.removeEventListener("abort", onAbort);
104
+ resolve();
105
+ }, milliseconds);
106
+ signal.addEventListener("abort", onAbort, { once: true });
107
+ });
108
+ }
109
+
89
110
  export class AgentRegistry {
90
111
  private readonly agents = new Map<string, ManagedAgent>();
91
112
  private readonly controllers = new Map<string, AbortController>();
@@ -96,6 +117,7 @@ export class AgentRegistry {
96
117
  resolve: (agent: ManagedAgent) => void;
97
118
  }> = [];
98
119
  private changeQueue: Promise<void> = Promise.resolve();
120
+ private readonly shutdownController = new AbortController();
99
121
  private readonly maxAgents: number;
100
122
  private readonly maxActiveTurns: number;
101
123
  private readonly maxHistoryTurns: number;
@@ -108,6 +130,7 @@ export class AgentRegistry {
108
130
  private readonly idleTtlMs: number;
109
131
  private readonly transport: SubagentTransport;
110
132
  private readonly now: () => number;
133
+ private lastCompletionAt = 0;
111
134
 
112
135
  constructor(
113
136
  transport: SubagentTransport | AgentTurnRunner,
@@ -176,6 +199,9 @@ export class AgentRegistry {
176
199
  }
177
200
  const depth = seen.size - 1;
178
201
  if (cyclic || depth > this.maxDepth) continue;
202
+ for (const completion of record.pendingCompletions ?? []) {
203
+ this.lastCompletionAt = Math.max(this.lastCompletionAt, completion.createdAt);
204
+ }
179
205
  this.agents.set(record.id, {
180
206
  ...record,
181
207
  state:
@@ -183,6 +209,12 @@ export class AgentRegistry {
183
209
  rootId,
184
210
  depth,
185
211
  currentTask: undefined,
212
+ turnGeneration: record.turnGeneration ?? 0,
213
+ currentRunId: undefined,
214
+ currentTurnGeneration: undefined,
215
+ pendingCompletions: (record.pendingCompletions ?? []).map((completion) => ({
216
+ ...completion,
217
+ })),
186
218
  currentTimeoutMs: undefined,
187
219
  currentIdleTimeoutMs: undefined,
188
220
  currentMaxTurns: undefined,
@@ -286,6 +318,8 @@ export class AgentRegistry {
286
318
  maxToolCalls: input.maxToolCalls,
287
319
  currentMaxToolCalls: input.maxToolCalls,
288
320
  currentTask: task,
321
+ turnGeneration: 0,
322
+ pendingCompletions: [],
289
323
  history: [],
290
324
  mailbox: [],
291
325
  context: input.context,
@@ -486,19 +520,30 @@ export class AgentRegistry {
486
520
  const index = this.queue.findIndex((entry) => entry.agent.id === id);
487
521
  if (index >= 0) {
488
522
  const [entry] = this.queue.splice(index, 1);
523
+ const persistedCompletion = {
524
+ completionId: `completion:${agent.id}:${randomUUID()}`,
525
+ runId: agent.currentRunId ?? `run:${agent.id}:${randomUUID()}`,
526
+ generation: agent.currentTurnGeneration ?? agent.turnGeneration ?? 1,
527
+ task: truncateUtf8(entry.task, 256).text,
528
+ output: "",
529
+ error: "Interrupted before execution",
530
+ createdAt: this.completionCreatedAt(),
531
+ };
489
532
  agent.state = "interrupted";
533
+ agent.pendingCompletions = [...(agent.pendingCompletions ?? []), persistedCompletion];
490
534
  clearCurrentTurn(agent);
491
535
  agent.updatedAt = this.now();
536
+ const persisted = await this.persistTerminalState().then(
537
+ () => true,
538
+ () => false,
539
+ );
492
540
  const completion: AgentTurnCompletion = {
541
+ ...persistedCompletion,
493
542
  agent: this.copy(agent),
494
- task: entry.task,
495
- output: "",
496
- error: "Interrupted before execution",
497
543
  };
498
544
  entry.resolve(agent);
499
545
  this.running.delete(id);
500
- await this.notifyTurnComplete(completion);
501
- await this.changed();
546
+ if (persisted) await this.notifyTurnComplete(completion);
502
547
  return this.copy(agent);
503
548
  }
504
549
  }
@@ -584,6 +629,7 @@ export class AgentRegistry {
584
629
  }
585
630
 
586
631
  async shutdown(): Promise<void> {
632
+ this.shutdownController.abort(new Error("Subagent registry is shutting down"));
587
633
  for (const entry of this.queue.splice(0)) {
588
634
  if (entry.agent.capabilityGrant?.state === "active") {
589
635
  entry.agent.capabilityGrant = revokeCapabilityGrant(
@@ -629,7 +675,7 @@ export class AgentRegistry {
629
675
  } catch (error) {
630
676
  shutdownError = error;
631
677
  }
632
- await this.changed();
678
+ await this.changed(true);
633
679
  if (shutdownError) throw shutdownError;
634
680
  }
635
681
 
@@ -666,6 +712,8 @@ export class AgentRegistry {
666
712
  maxToolCalls: agent.maxToolCalls,
667
713
  currentMaxToolCalls: agent.currentMaxToolCalls,
668
714
  currentTask: agent.currentTask,
715
+ currentRunId: agent.currentRunId,
716
+ currentTurnGeneration: agent.currentTurnGeneration,
669
717
  error: agent.error,
670
718
  workspaceMode: agent.workspaceMode,
671
719
  contextTurns: agent.contextTurns,
@@ -712,14 +760,60 @@ export class AgentRegistry {
712
760
  return agent ? this.copy(agent) : undefined;
713
761
  }
714
762
 
715
- markCompletionDelivered(id: string, deliveredAt: number): void {
716
- const agent = this.agents.get(id);
717
- if (!agent?.telemetry) return;
718
- agent.telemetry = {
719
- ...agent.telemetry,
720
- updatedAt: deliveredAt,
721
- timing: { ...agent.telemetry.timing, completionDeliveredAt: deliveredAt },
722
- };
763
+ listPendingCompletions(): AgentTurnCompletion[] {
764
+ return [...this.agents.values()]
765
+ .flatMap((agent) =>
766
+ (agent.pendingCompletions ?? []).map((completion) => ({
767
+ ...completion,
768
+ agent: this.copy(agent),
769
+ })),
770
+ )
771
+ .sort(
772
+ (left, right) =>
773
+ left.createdAt - right.createdAt ||
774
+ left.generation - right.generation ||
775
+ left.completionId.localeCompare(right.completionId),
776
+ );
777
+ }
778
+
779
+ async markCompletionDelivered(completionId: string, deliveredAt: number): Promise<void> {
780
+ const agent = [...this.agents.values()].find((candidate) =>
781
+ candidate.pendingCompletions?.some((completion) => completion.completionId === completionId),
782
+ );
783
+ if (!agent) return;
784
+ const acknowledged = (agent.pendingCompletions ?? []).find(
785
+ (completion) => completion.completionId === completionId,
786
+ );
787
+ if (!acknowledged) return;
788
+ agent.pendingCompletions = (agent.pendingCompletions ?? []).filter(
789
+ (completion) => completion.completionId !== completionId,
790
+ );
791
+ if (agent.telemetry) {
792
+ agent.telemetry = {
793
+ ...agent.telemetry,
794
+ updatedAt: deliveredAt,
795
+ timing: { ...agent.telemetry.timing, completionDeliveredAt: deliveredAt },
796
+ };
797
+ }
798
+ agent.updatedAt = Math.max(agent.updatedAt, deliveredAt);
799
+ try {
800
+ await this.changed(true);
801
+ } catch (error) {
802
+ if (
803
+ !agent.pendingCompletions?.some(
804
+ (completion) => completion.completionId === acknowledged.completionId,
805
+ )
806
+ ) {
807
+ agent.pendingCompletions = [...(agent.pendingCompletions ?? []), acknowledged].sort(
808
+ (left, right) => left.createdAt - right.createdAt || left.generation - right.generation,
809
+ );
810
+ }
811
+ if (agent.telemetry?.timing.completionDeliveredAt === deliveredAt) {
812
+ const { completionDeliveredAt: _discarded, ...timing } = agent.telemetry.timing;
813
+ agent.telemetry = { ...agent.telemetry, timing };
814
+ }
815
+ throw error;
816
+ }
723
817
  }
724
818
 
725
819
  async sweepExpired(): Promise<number> {
@@ -740,6 +834,14 @@ export class AgentRegistry {
740
834
  task: string,
741
835
  limits: TurnLimits & { timeoutMs?: number } = {},
742
836
  ): void {
837
+ if ((agent.pendingCompletions?.length ?? 0) >= MAX_PENDING_COMPLETIONS_PER_AGENT) {
838
+ throw new Error(
839
+ `Agent ${agent.id} has ${MAX_PENDING_COMPLETIONS_PER_AGENT} undelivered completions; wait for delivery before another turn`,
840
+ );
841
+ }
842
+ agent.turnGeneration = (agent.turnGeneration ?? 0) + 1;
843
+ agent.currentTurnGeneration = agent.turnGeneration;
844
+ agent.currentRunId = `run:${agent.id}:${randomUUID()}`;
743
845
  agent.state = "starting";
744
846
  agent.error = undefined;
745
847
  agent.currentTask = task;
@@ -790,17 +892,30 @@ export class AgentRegistry {
790
892
  agent.state = "failed";
791
893
  agent.error = "Capability grant expired or no longer matches the accepted plan";
792
894
  agent.outcome = classifyStructuredOutcome("failed", "capability-grant-invalid");
793
- agent.currentTask = undefined;
794
- agent.currentTimeoutMs = undefined;
795
- agent.updatedAt = this.now();
796
- resolveQueued(agent);
797
- this.running.delete(agent.id);
798
- void this.notifyTurnComplete({
799
- agent: this.copy(agent),
800
- task,
895
+ const persistedCompletion = {
896
+ completionId: `completion:${agent.id}:${randomUUID()}`,
897
+ runId: agent.currentRunId ?? `run:${agent.id}:${randomUUID()}`,
898
+ generation: agent.currentTurnGeneration ?? agent.turnGeneration ?? 1,
899
+ task: truncateUtf8(task, 256).text,
801
900
  output: "",
802
- error: agent.error,
803
- }).then(() => this.changed());
901
+ error: truncateUtf8(agent.error, 512).text,
902
+ createdAt: this.completionCreatedAt(),
903
+ };
904
+ agent.pendingCompletions = [...(agent.pendingCompletions ?? []), persistedCompletion];
905
+ clearCurrentTurn(agent);
906
+ agent.updatedAt = this.now();
907
+ void this.persistTerminalState()
908
+ .then(() =>
909
+ this.notifyTurnComplete({
910
+ ...persistedCompletion,
911
+ agent: this.copy(agent),
912
+ }),
913
+ )
914
+ .catch(() => undefined)
915
+ .finally(() => {
916
+ resolveQueued(agent);
917
+ this.running.delete(agent.id);
918
+ });
804
919
  return;
805
920
  }
806
921
  const controller = new AbortController();
@@ -808,6 +923,8 @@ export class AgentRegistry {
808
923
  agent.state = "running";
809
924
  agent.updatedAt = this.now();
810
925
  const startedAt = this.now();
926
+ const runId = agent.currentRunId ?? `run:${agent.id}:${randomUUID()}`;
927
+ const turnGeneration = agent.currentTurnGeneration ?? agent.turnGeneration ?? 1;
811
928
  const completionKey = `completion:${agent.id}:${randomUUID()}`;
812
929
  const acceptedPlanId = agent.executionPlan?.id;
813
930
  let completionContent = "";
@@ -830,6 +947,8 @@ export class AgentRegistry {
830
947
  ? truncateUtf8(outcome.error, this.maxTurnOutputBytes).text
831
948
  : undefined;
832
949
  agent.history.push({
950
+ runId,
951
+ generation: turnGeneration,
833
952
  task,
834
953
  output,
835
954
  startedAt,
@@ -925,6 +1044,8 @@ export class AgentRegistry {
925
1044
  this.maxTurnOutputBytes,
926
1045
  ).text;
927
1046
  agent.history.push({
1047
+ runId,
1048
+ generation: turnGeneration,
928
1049
  task,
929
1050
  output: "",
930
1051
  startedAt,
@@ -947,12 +1068,16 @@ export class AgentRegistry {
947
1068
  return agent;
948
1069
  })
949
1070
  .finally(async () => {
950
- const turnCompletion: AgentTurnCompletion = {
951
- agent: this.copy(agent),
952
- task,
953
- output: completionOutput,
954
- error: completionError,
1071
+ const persistedCompletion = {
1072
+ completionId: completionKey,
1073
+ runId,
1074
+ generation: turnGeneration,
1075
+ task: truncateUtf8(task, 256).text,
1076
+ output: truncateUtf8(completionOutput, MAX_TOOL_MESSAGE_BYTES).text,
1077
+ error: completionError ? truncateUtf8(completionError, 512).text : undefined,
1078
+ createdAt: this.completionCreatedAt(),
955
1079
  };
1080
+ agent.pendingCompletions = [...(agent.pendingCompletions ?? []), persistedCompletion];
956
1081
  if (agent.parentId) {
957
1082
  const parent = this.agents.get(agent.parentId);
958
1083
  if (parent && parent.state !== "closed") {
@@ -961,12 +1086,19 @@ export class AgentRegistry {
961
1086
  }
962
1087
  clearCurrentTurn(agent);
963
1088
  agent.updatedAt = this.now();
1089
+ const persisted = await this.persistTerminalState().then(
1090
+ () => true,
1091
+ () => false,
1092
+ );
964
1093
  this.controllers.delete(agent.id);
1094
+ const turnCompletion: AgentTurnCompletion = {
1095
+ ...persistedCompletion,
1096
+ agent: this.copy(agent),
1097
+ };
965
1098
  this.running.delete(agent.id);
966
1099
  resolveQueued(agent);
967
1100
  this.pumpQueue();
968
- await this.notifyTurnComplete(turnCompletion);
969
- await this.changed();
1101
+ if (persisted) await this.notifyTurnComplete(turnCompletion);
970
1102
  });
971
1103
  }
972
1104
 
@@ -1032,11 +1164,22 @@ export class AgentRegistry {
1032
1164
  return [...this.agents.values()].filter((agent) => agent.state !== "closed").length;
1033
1165
  }
1034
1166
 
1167
+ private completionCreatedAt(): number {
1168
+ this.lastCompletionAt = Math.max(this.now(), this.lastCompletionAt + 1);
1169
+ return this.lastCompletionAt;
1170
+ }
1171
+
1035
1172
  private evictExpired(): ManagedAgent[] {
1036
1173
  const cutoff = this.now() - this.idleTtlMs;
1037
1174
  const protectedIds = new Set<string>();
1038
1175
  for (const agent of this.agents.values()) {
1039
- if (agent.state !== "running" && agent.state !== "starting") continue;
1176
+ if (
1177
+ agent.state !== "running" &&
1178
+ agent.state !== "starting" &&
1179
+ (agent.pendingCompletions?.length ?? 0) === 0
1180
+ ) {
1181
+ continue;
1182
+ }
1040
1183
  let current: ManagedAgent | undefined = agent;
1041
1184
  while (current) {
1042
1185
  protectedIds.add(current.id);
@@ -1081,6 +1224,25 @@ export class AgentRegistry {
1081
1224
  for (const agent of closed.slice(this.maxAgents)) this.agents.delete(agent.id);
1082
1225
  }
1083
1226
 
1227
+ private async persistTerminalState(): Promise<void> {
1228
+ let failures = 0;
1229
+ for (;;) {
1230
+ try {
1231
+ await this.changed(true);
1232
+ return;
1233
+ } catch (error) {
1234
+ if (this.shutdownController.signal.aborted) throw error;
1235
+ failures++;
1236
+ if (failures === 1) continue;
1237
+ const delay = Math.min(
1238
+ INITIAL_PERSISTENCE_RETRY_DELAY_MS * 2 ** (failures - 2),
1239
+ MAX_PERSISTENCE_RETRY_DELAY_MS,
1240
+ );
1241
+ await waitForPersistenceRetry(delay, this.shutdownController.signal);
1242
+ }
1243
+ }
1244
+ }
1245
+
1084
1246
  private async notifyTurnComplete(completion: AgentTurnCompletion): Promise<void> {
1085
1247
  try {
1086
1248
  await this.options.onTurnComplete?.(completion);
@@ -1089,16 +1251,17 @@ export class AgentRegistry {
1089
1251
  }
1090
1252
  }
1091
1253
 
1092
- private changed(): Promise<void> {
1254
+ private changed(propagateError = false): Promise<void> {
1093
1255
  const snapshot = this.list(true);
1094
1256
  const next = this.changeQueue.then(async () => {
1095
1257
  try {
1096
1258
  await this.options.onChange?.(snapshot);
1097
- } catch {
1098
- // Persistence is best-effort; lifecycle operations must remain usable if storage fails.
1259
+ } catch (error) {
1260
+ if (propagateError) throw error;
1261
+ // Non-terminal persistence remains best-effort so lifecycle controls stay usable.
1099
1262
  }
1100
1263
  });
1101
- this.changeQueue = next;
1264
+ this.changeQueue = next.catch(() => undefined);
1102
1265
  return next;
1103
1266
  }
1104
1267
 
@@ -1115,6 +1278,8 @@ export class AgentRegistry {
1115
1278
  updatedAt: agent.updatedAt,
1116
1279
  historyCount: agent.history.length,
1117
1280
  unreadMessages,
1281
+ turnGeneration: agent.turnGeneration ?? 0,
1282
+ pendingCompletionCount: agent.pendingCompletions?.length ?? 0,
1118
1283
  };
1119
1284
  }
1120
1285
 
@@ -1126,6 +1291,9 @@ export class AgentRegistry {
1126
1291
  currentMailboxMessageIds: agent.currentMailboxMessageIds
1127
1292
  ? [...agent.currentMailboxMessageIds]
1128
1293
  : undefined,
1294
+ pendingCompletions: (agent.pendingCompletions ?? []).map((completion) => ({
1295
+ ...completion,
1296
+ })),
1129
1297
  history: agent.history.map((turn) => ({ ...turn })),
1130
1298
  mailbox: agent.mailbox.map((message) => ({ ...message })),
1131
1299
  contract: agent.contract ? structuredClone(agent.contract) : undefined,
@@ -31,6 +31,6 @@ export function createSpawnPromptGuidelines(
31
31
  "Add another subagent_spawn only for truly independent work with safe workspace concurrency.",
32
32
  noLocalWorkGuidance,
33
33
  'Consume and synthesize available subagent_spawn completion messages; use subagent_manage with action "interrupt" or "close" for agents that are no longer needed.',
34
- '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.',
34
+ 'Completion from subagent_spawn is delivered automatically. Do not poll with subagent_inspect or subagent_mailbox action "read", repeatedly check progress, or duplicate the delegated work.',
35
35
  ];
36
36
  }
@@ -75,7 +75,6 @@ function renderStatefulCall(tool: StatefulRenderTool, args: Record<string, unkno
75
75
  const metadata: string[] = [];
76
76
  if (typeof args.agentId === "string") metadata.push(`id:${safeLine(args.agentId, "", 256)}`);
77
77
  if (args.subtree === true) metadata.push("subtree");
78
- if (args.includeClosed === true) metadata.push("include closed");
79
78
  return new Text(toolHeader(theme, "subagent_manage", args.action, metadata), 0, 0);
80
79
  }
81
80
  const metadata = [`id:${safeLine(args.agentId, "...", 256)}`];
@@ -1,25 +1,18 @@
1
1
  import { StringEnum } from "@earendil-works/pi-ai";
2
2
  import { Type } from "typebox";
3
3
 
4
- const MANAGE_ACTIONS = ["list", "interrupt", "close"] as const;
4
+ const MANAGE_ACTIONS = ["interrupt", "close"] as const;
5
5
  const MAILBOX_ACTIONS = ["send", "read"] as const;
6
6
  const MAX_MAILBOX_MESSAGE_LENGTH = 16 * 1024;
7
7
 
8
8
  export const ManageParamsSchema = Type.Object(
9
9
  {
10
10
  action: StringEnum(MANAGE_ACTIONS, {
11
- description:
12
- "Use list to inspect agents, interrupt to stop active work, or close to release agents.",
11
+ description: "Interrupt active work or close an agent and release its resources.",
13
12
  }),
14
13
  agentId: Type.Optional(
15
14
  Type.String({ minLength: 1, description: "Required for interrupt and close." }),
16
15
  ),
17
- includeClosed: Type.Optional(
18
- Type.Boolean({
19
- default: false,
20
- description: "List closed records as well as retained agents.",
21
- }),
22
- ),
23
16
  subtree: Type.Optional(
24
17
  Type.Boolean({
25
18
  default: false,
@@ -57,9 +50,11 @@ export const MailboxParamsSchema = Type.Object(
57
50
  { additionalProperties: false },
58
51
  );
59
52
 
60
- export type ValidatedManageParams =
61
- | { action: "list"; includeClosed?: boolean }
62
- | { action: "interrupt" | "close"; agentId: string; subtree?: boolean };
53
+ export type ValidatedManageParams = {
54
+ action: "interrupt" | "close";
55
+ agentId: string;
56
+ subtree?: boolean;
57
+ };
63
58
 
64
59
  export type ValidatedMailboxParams =
65
60
  | {
@@ -73,21 +68,19 @@ export type ValidatedMailboxParams =
73
68
 
74
69
  export function validateManageParams(params: unknown): ValidatedManageParams {
75
70
  const values = parameterRecord(params, "subagent_manage");
76
- assertKnownKeys("subagent_manage", values, ["action", "agentId", "includeClosed", "subtree"]);
71
+ assertKnownKeys("subagent_manage", values, ["action", "agentId", "subtree"]);
77
72
  const action = values.action;
73
+ if (action === "list") {
74
+ throw new Error(
75
+ 'subagent_manage no longer supports "list"; use subagent_inspect action "list_runs"',
76
+ );
77
+ }
78
78
  if (
79
79
  typeof action !== "string" ||
80
80
  !MANAGE_ACTIONS.includes(action as (typeof MANAGE_ACTIONS)[number])
81
81
  ) {
82
82
  throw new Error(`subagent_manage action must be one of: ${MANAGE_ACTIONS.join(", ")}`);
83
83
  }
84
- if (action === "list") {
85
- assertOptionalBoolean("subagent_manage", action, values, "includeClosed");
86
- return {
87
- action,
88
- ...(values.includeClosed === undefined ? {} : { includeClosed: values.includeClosed }),
89
- } as ValidatedManageParams;
90
- }
91
84
  assertRequiredString("subagent_manage", action, values, "agentId");
92
85
  assertOptionalBoolean("subagent_manage", action, values, "subtree");
93
86
  return {
package/src/stateful.ts CHANGED
@@ -52,7 +52,7 @@ import {
52
52
  hashSpawnRequest,
53
53
  MAX_SPAWN_IDEMPOTENCY_KEY_LENGTH,
54
54
  } from "./spawn-idempotency.js";
55
- import { formatStatefulAgentLine, summarizeStatefulAgent } from "./stateful-agent-view.js";
55
+ import { summarizeStatefulAgent } from "./stateful-agent-view.js";
56
56
  import { resolveCompletionDelivery, resolveStatefulTransportKind } from "./stateful-config.js";
57
57
  import { createSpawnPromptGuidelines } from "./stateful-guidance.js";
58
58
  import {
@@ -300,10 +300,16 @@ export function registerStatefulSubagents(
300
300
  const reason = error instanceof Error ? error.message : String(error);
301
301
  ctx.ui.notify(`Subagent completion delivery failed: ${reason}`, "warning");
302
302
  },
303
- onDelivered: (completions, deliveredAt) => {
303
+ onAcknowledged: (completions, deliveredAt) => {
304
304
  if (generation !== runtimeGeneration) return;
305
305
  for (const completion of completions) {
306
- nextRegistry.markCompletionDelivered(completion.agent.id, deliveredAt);
306
+ void nextRegistry
307
+ .markCompletionDelivered(completion.completionId, deliveredAt)
308
+ .catch((error: unknown) => {
309
+ if (!ctx.hasUI || generation !== runtimeGeneration) return;
310
+ const reason = error instanceof Error ? error.message : String(error);
311
+ ctx.ui.notify(`Subagent completion acknowledgement failed: ${reason}`, "warning");
312
+ });
307
313
  }
308
314
  },
309
315
  });
@@ -378,6 +384,9 @@ export function registerStatefulSubagents(
378
384
  registry = nextRegistry;
379
385
  persistence = sessionPersistence;
380
386
  completionBroker = sessionBroker;
387
+ for (const completion of nextRegistry.listPendingCompletions()) {
388
+ sessionBroker.enqueue(completion);
389
+ }
381
390
  runtimeLimits = nextLimits;
382
391
  refreshSpawnToolRegistration?.();
383
392
  const sweepEveryMs = Math.max(
@@ -402,6 +411,10 @@ export function registerStatefulSubagents(
402
411
  completionBroker?.onParentTurnStart();
403
412
  });
404
413
 
414
+ pi.on("context", (event) => {
415
+ completionBroker?.onParentContext(event.messages);
416
+ });
417
+
405
418
  pi.on("agent_settled", () => {
406
419
  completionBroker?.onParentSettled();
407
420
  });
@@ -835,8 +848,8 @@ export function registerStatefulSubagents(
835
848
  name: "subagent_manage",
836
849
  label: "Manage Subagents",
837
850
  description:
838
- "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.",
839
- promptSnippet: "List or control retained detached subagents",
851
+ "Interrupt active work while keeping an agent reusable, or close agents and release their resources. Use subagent_inspect for every read-only list, detail, status, and diagnostic operation.",
852
+ promptSnippet: "Interrupt or close retained detached subagents",
840
853
  parameters: ManageParamsSchema,
841
854
  ...createStatefulToolRenderer("manage"),
842
855
  async execute(_id, params, signal): Promise<StatefulActionToolResult> {
@@ -848,20 +861,6 @@ export function registerStatefulSubagents(
848
861
  return value;
849
862
  };
850
863
  const operation = validateManageParams(params);
851
- if (operation.action === "list") {
852
- const agents = ownedRegistry.list(operation.includeClosed);
853
- return {
854
- content: [
855
- {
856
- type: "text",
857
- text: agents.length
858
- ? agents.map(formatStatefulAgentLine).join("\n")
859
- : "No stateful subagents.",
860
- },
861
- ],
862
- details: { agents: agents.map(summarizeStatefulAgent) },
863
- };
864
- }
865
864
  const agentId = operation.agentId;
866
865
  if (operation.action === "interrupt") {
867
866
  if (operation.subtree) {