@ian-pascoe/pi-minimal-subagents 0.12.0 → 0.14.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.
@@ -2,10 +2,14 @@ import { randomUUID } from "node:crypto";
2
2
  import {
3
3
  assembleImportedContext,
4
4
  boundRecentAgentText,
5
+ buildActiveTurnProgress,
6
+ buildInheritedContextTaskPrompt,
5
7
  buildRecentAgentActivity,
6
8
  contextContainsImages,
9
+ selectActiveTurnTranscript,
7
10
  selectChildAgentTranscript,
8
11
  } from "./minimal-subagents-context.js";
12
+
9
13
  import {
10
14
  canAgentContractSpawn,
11
15
  DEFAULT_MAX_SUBAGENT_DEPTH,
@@ -19,6 +23,7 @@ import {
19
23
  claimDeliveryLedgerTurn,
20
24
  createDeliveryLedger,
21
25
  deliveryLedgerSnapshot,
26
+ deliveryTurnKey,
22
27
  findCoordinationDelivery,
23
28
  findTerminalDelivery,
24
29
  isDeliveryLedgerTurnClaimed,
@@ -38,7 +43,9 @@ import { withTroubleshootingHint } from "./troubleshooting-skill.js";
38
43
  import { addCoordinatorMessageEnvelope } from "./minimal-subagents-message-envelope.js";
39
44
  import { unavailableAgent } from "./minimal-subagents-sessions.js";
40
45
  import { createRegistryEvent } from "./minimal-subagents-registry.js";
46
+ import type { MinimalSubagentsModelRole } from "./minimal-subagents-config.js";
41
47
  import type {
48
+ ActiveTurnProgress,
42
49
  AgentDetail,
43
50
  AgentMessageDisposition,
44
51
  AgentMessageResult,
@@ -70,6 +77,16 @@ const RESERVED_AGENT_IDS = new Set(["root", "parent"]);
70
77
  const RECENT_MESSAGE_LIMIT = 20;
71
78
  const DEFAULT_AUTOMATIC_DELIVERY_GRACE_MS = 1_000;
72
79
 
80
+ /** Assistant messages a waiting parent's live view renders from the child's running turn. */
81
+ const ACTIVE_TURN_TRANSCRIPT_ASSISTANT_MESSAGES = 3;
82
+
83
+ /** A direct child's running turn as its waiting parent may observe it. */
84
+ interface ObservedActiveTurn {
85
+ turnId: string;
86
+ startedAtMs: number;
87
+ runtime: ChildAgentRuntime;
88
+ }
89
+
73
90
  interface MessageParameters {
74
91
  agent_id?: string;
75
92
  message: string;
@@ -94,6 +111,16 @@ interface PendingParentMessage {
94
111
  cancelGrace?: () => void;
95
112
  }
96
113
 
114
+ /** One terminal result queued to its parent, until Delivery Evidence settles it or Pi discards it. */
115
+ interface TerminalHandOff {
116
+ /** Hand-off order, compared with the root's last ended message at a turn boundary. */
117
+ order: number;
118
+ /** Whether the result entered Pi's clearable steer queue instead of starting a root turn. */
119
+ queued: boolean;
120
+ /** Coordination Messages batched into the same steer. */
121
+ coordinationDeliveryIds: readonly string[];
122
+ }
123
+
97
124
  function agentDeliveryKey(agentId: string, turnId: string): string {
98
125
  return `${agentId}\u0000${turnId}`;
99
126
  }
@@ -147,9 +174,18 @@ export class MinimalSubagentsCoordinator {
147
174
  private readonly waiters = new Map<string, Set<TurnWaiter>>();
148
175
  private readonly pendingParentMessages = new Map<string, PendingParentMessage[]>();
149
176
  private readonly recipientQueues = new Map<string, Promise<unknown>>();
150
- private readonly automaticDeliveryKeys = new Set<string>();
177
+ /** In-flight automatic result hand-offs, each with the lifecycle epoch that owns it. */
178
+ private readonly automaticDeliveryKeys = new Map<string, number>();
151
179
  private readonly automaticCoordinationDeliveryIds = new Set<string>();
152
180
  private readonly waitHandedDeliveryIds = new Set<string>();
181
+ /** Terminal results already queued to their parent, until Delivery Evidence settles them. */
182
+ private readonly handedTerminalKeys = new Map<string, TerminalHandOff>();
183
+ /** Monotonic order of hand-offs, compared against the root's last ended message. */
184
+ private handOffOrder = 0;
185
+ /** The latest hand-off order a root `message_end` has seen; later hand-offs may postdate Pi's queue snapshot. */
186
+ private rootMessageEndOrder = 0;
187
+ /** Root results Pi discarded, awaiting the root's next run; each keeps its Delivery Ledger sequence. */
188
+ private readonly discardedRootResultKeys = new Set<string>();
153
189
  private readonly backgroundOperations = new Set<Promise<void>>();
154
190
  private acceptingOperations = true;
155
191
  private lifecycleEpoch = 0;
@@ -197,16 +233,20 @@ export class MinimalSubagentsCoordinator {
197
233
  this.validateFriendlyId(friendlyId);
198
234
  const agentId = this.buildChildAgentId(callerId, friendlyId);
199
235
  if (this.tombstones.has(agentId)) {
200
- throw new Error(`Minimal subagents agent ID is tombstoned: ${agentId}`);
236
+ throw new Error(
237
+ `Minimal subagents agent ID ${agentId} belonged to a deleted agent and cannot be reused; choose a different agent_id`,
238
+ );
201
239
  }
202
240
  if (this.agents.has(agentId) || this.pendingAgentIds.has(agentId)) {
203
241
  throw new Error(`Minimal subagents duplicate agent ID: ${agentId}`);
204
242
  }
205
243
 
206
- const sessionContext = parameters.session_context ?? "inherit";
244
+ const sessionContext = parameters.session_context ?? "omit";
207
245
  const projectContext = parameters.project_context ?? "inherit";
208
- const model = parameters.model ?? caller.model;
209
- const requestedThinking = parameters.thinking_level ?? caller.thinkingLevel;
246
+ const role = parameters.role === undefined ? undefined : this.requireModelRole(parameters.role);
247
+ const model = parameters.model ?? role?.model ?? caller.model;
248
+ const requestedThinking =
249
+ parameters.thinking_level ?? role?.thinkingLevel ?? caller.thinkingLevel;
210
250
  const thinkingLevel = this.dependencies.sessions.resolveThinkingLevel(model, requestedThinking);
211
251
  const {
212
252
  ordinaryTools,
@@ -218,7 +258,7 @@ export class MinimalSubagentsCoordinator {
218
258
  toolsets: this.dependencies.toolsets,
219
259
  });
220
260
  const committedMessages = structuredClone(caller.messages);
221
- const imported = assembleImportedContext(sessionContext, committedMessages);
261
+ const imported = assembleImportedContext(sessionContext, committedMessages, callerId);
222
262
  if (
223
263
  contextContainsImages(imported.messages) &&
224
264
  !this.dependencies.sessions.modelSupportsImages(model)
@@ -251,6 +291,8 @@ export class MinimalSubagentsCoordinator {
251
291
  missing_dependencies: [],
252
292
  recent_messages: [],
253
293
  };
294
+ // Added after `delegation`, matching the key order of replayed Launch Contracts.
295
+ if (role) agent.launch_contract.role = role.name;
254
296
  this.pendingAgentIds.add(agentId);
255
297
  let identity: ReturnType<AgentSessionFactory["createIdentity"]>;
256
298
  try {
@@ -296,11 +338,12 @@ export class MinimalSubagentsCoordinator {
296
338
  createRegistryEvent(this.dependencies.registry.rootSessionId, "agent-created", { agent }),
297
339
  );
298
340
  const turnId = this.beginTurn(agent);
299
- if (toolWarnings.length > 0) {
341
+ const warnings = [...new Set(toolWarnings)];
342
+ if (warnings.length > 0) {
300
343
  this.dependencies.notify?.({
301
344
  type: "tool-warning",
302
345
  agentId,
303
- message: `Minimal subagents tool warnings for ${agentId}:\n- ${[...new Set(toolWarnings)].join("\n- ")}`,
346
+ message: `Minimal subagents tool warnings for ${agentId}:\n- ${warnings.join("\n- ")}`,
304
347
  });
305
348
  }
306
349
  this.dependencies.notify?.({
@@ -313,13 +356,38 @@ export class MinimalSubagentsCoordinator {
313
356
  this.initializeAndRunPrompt(
314
357
  agentId,
315
358
  turnId,
316
- parameters.task,
359
+ imported.messages.length > 0
360
+ ? buildInheritedContextTaskPrompt(parameters.task, agentId, callerId)
361
+ : parameters.task,
317
362
  imported.compact,
318
363
  caller.model,
319
364
  caller.thinkingLevel,
320
365
  ),
321
366
  );
322
- return { agent_id: agentId, turn_id: turnId, status: "running" };
367
+ const result: SpawnResult = {
368
+ agent_id: agentId,
369
+ turn_id: turnId,
370
+ status: "running",
371
+ model: agent.launch_contract.model,
372
+ thinking_level: agent.launch_contract.thinking_level,
373
+ tools: [...agent.launch_contract.ordinary_tools],
374
+ delegation: agent.launch_contract.delegation ?? "none",
375
+ };
376
+ if (warnings.length > 0) result.warnings = warnings;
377
+ return result;
378
+ }
379
+
380
+ private requireModelRole(name: string): MinimalSubagentsModelRole {
381
+ const roles = this.dependencies.modelRoles ?? [];
382
+ const role = roles.find((candidate) => candidate.name === name);
383
+ if (role) return role;
384
+ throw new Error(
385
+ `Minimal subagents spawn validation: unknown role ${JSON.stringify(name)}; ${
386
+ roles.length === 0
387
+ ? "no model roles are configured"
388
+ : `configured roles: ${roles.map((candidate) => candidate.name).join(", ")}`
389
+ }`,
390
+ );
323
391
  }
324
392
 
325
393
  /** Capture immutable launch defaults for a nested caller from its active child runtime. */
@@ -365,10 +433,12 @@ export class MinimalSubagentsCoordinator {
365
433
  this.queuePendingParentMessage(targetId, message, delivery);
366
434
  return { agent_id: targetId, message_id: messageId, disposition: "queued" };
367
435
  }
368
- const disposition = await this.enqueueRecipientDelivery(targetId, async () =>
436
+ const { disposition, turnId } = await this.enqueueRecipientDelivery(targetId, async () =>
369
437
  this.deliverExplicitMessage(message, targetId, delivery),
370
438
  );
371
- return { agent_id: targetId, message_id: messageId, disposition };
439
+ const result: AgentMessageResult = { agent_id: targetId, message_id: messageId, disposition };
440
+ if (turnId) result.turn_id = turnId;
441
+ return result;
372
442
  } catch (error) {
373
443
  const deliveryError = error instanceof Error ? error.message : String(error);
374
444
  if (this.isCoordinationDeliveryCurrent(delivery)) {
@@ -423,7 +493,9 @@ export class MinimalSubagentsCoordinator {
423
493
  if (pendingMessage) return Promise.resolve(pendingMessage);
424
494
  if (agent.active_turn_id !== turnId) {
425
495
  return Promise.reject(
426
- new Error(`Minimal subagents wait: turn ${turnId} is no longer retained for ${agentId}`),
496
+ new Error(
497
+ `Minimal subagents wait: turn ${turnId} is unknown or no longer retained for ${agentId}; omit turn_id to wait for its oldest observable turn you have neither claimed nor received`,
498
+ ),
427
499
  );
428
500
  }
429
501
 
@@ -460,11 +532,11 @@ export class MinimalSubagentsCoordinator {
460
532
  });
461
533
  }
462
534
 
463
- /** Return direct-child status authorized for one root or child caller. */
535
+ /** Return direct-child status for a child caller; the root may inspect any descendant. */
464
536
  status(callerId: string, agentId?: string): StatusResult {
465
537
  this.assertCallerExists(callerId);
466
538
  if (agentId !== undefined) {
467
- this.assertCallerTargetsDirectChild(callerId, agentId, "status");
539
+ if (callerId !== "root") this.assertCallerTargetsDirectChild(callerId, agentId, "status");
468
540
  return { agent: this.buildAgentDetail(this.requireAgent(agentId), false) };
469
541
  }
470
542
  return {
@@ -482,6 +554,56 @@ export class MinimalSubagentsCoordinator {
482
554
  };
483
555
  }
484
556
 
557
+ /**
558
+ * Find a direct child's running turn for its parent's live wait view. Only the direct parent
559
+ * observes it, and a requested `turnId` must be the active turn.
560
+ */
561
+ private observeActiveTurn(
562
+ callerId: string,
563
+ agentId: string,
564
+ turnId: string | undefined,
565
+ ): ObservedActiveTurn | undefined {
566
+ const agent = this.agents.get(agentId);
567
+ if (!agent || agent.parent_id !== callerId) return undefined;
568
+ const activeTurnId = agent.active_turn_id;
569
+ const startedAt = agent.active_turn_started_at;
570
+ if (!activeTurnId || !startedAt || (turnId !== undefined && turnId !== activeTurnId)) {
571
+ return undefined;
572
+ }
573
+ const runtime = this.runtimes.get(agentId);
574
+ if (!runtime) return undefined;
575
+ return { turnId: activeTurnId, startedAtMs: Date.parse(startedAt), runtime };
576
+ }
577
+
578
+ /** Count a direct child's running-turn tool calls for its waiting parent. */
579
+ previewActiveTurn(
580
+ callerId: string,
581
+ agentId: string,
582
+ turnId?: string,
583
+ ): ActiveTurnProgress | undefined {
584
+ const turn = this.observeActiveTurn(callerId, agentId, turnId);
585
+ if (!turn) return undefined;
586
+ return {
587
+ turn_id: turn.turnId,
588
+ ...buildActiveTurnProgress(turn.runtime.snapshotActivityMessages(), turn.startedAtMs),
589
+ };
590
+ }
591
+
592
+ /** Return a direct child's Active Turn Tail, with its tool definitions, for its waiting parent. */
593
+ inspectActiveTurnTranscript(
594
+ callerId: string,
595
+ agentId: string,
596
+ turnId?: string,
597
+ ): ChildAgentTranscriptSnapshot | undefined {
598
+ const turn = this.observeActiveTurn(callerId, agentId, turnId);
599
+ if (!turn) return undefined;
600
+ return selectActiveTurnTranscript(
601
+ this.inspectTranscript(agentId),
602
+ turn.startedAtMs,
603
+ ACTIVE_TURN_TRANSCRIPT_ASSISTANT_MESSAGES,
604
+ );
605
+ }
606
+
485
607
  /** Lazily inspect one Child Session Transcript without restoring a missing runtime. */
486
608
  inspectTranscript(agentId: string): ChildAgentTranscriptSnapshot {
487
609
  const agent = this.requireAgent(agentId);
@@ -528,15 +650,18 @@ export class MinimalSubagentsCoordinator {
528
650
  this.assertCallerCanManageAgent(callerId, agentId, "cancel");
529
651
  const target = this.requireUsableAgent(agentId, "cancel");
530
652
  const affected = recursive ? [target, ...this.descendantsOf(agentId)] : [target];
653
+ const affectedAgentIds: string[] = [];
531
654
  const cancelledTurnIds: string[] = [];
532
655
  for (const agent of affected) {
533
656
  const cancelledTurnId = await this.cancelActiveTurn(agent);
534
- if (cancelledTurnId) cancelledTurnIds.push(cancelledTurnId);
657
+ if (!cancelledTurnId) continue;
658
+ affectedAgentIds.push(agent.agent_id);
659
+ cancelledTurnIds.push(cancelledTurnId);
535
660
  }
536
661
  return {
537
662
  agent_id: agentId,
538
663
  recursive,
539
- affected_agent_ids: affected.map((agent) => agent.agent_id),
664
+ affected_agent_ids: affectedAgentIds,
540
665
  cancelled_turn_ids: cancelledTurnIds,
541
666
  };
542
667
  }
@@ -633,6 +758,8 @@ export class MinimalSubagentsCoordinator {
633
758
  this.waiters.clear();
634
759
  this.waitHandedDeliveryIds.clear();
635
760
  this.automaticDeliveryKeys.clear();
761
+ this.handedTerminalKeys.clear();
762
+ this.discardedRootResultKeys.clear();
636
763
  this.automaticCoordinationDeliveryIds.clear();
637
764
  this.deliveryLedger = createDeliveryLedger({
638
765
  deliveries: snapshot.deliveries,
@@ -777,6 +904,102 @@ export class MinimalSubagentsCoordinator {
777
904
  await Promise.allSettled(scheduled);
778
905
  }
779
906
 
907
+ /** Note that a root message ended; results handed before it reached Pi's queue before the next turn boundary. */
908
+ recordRootMessageEnd(): void {
909
+ this.rootMessageEndOrder = this.handOffOrder;
910
+ }
911
+
912
+ /**
913
+ * Release results Pi discarded from the root's steer queue, as Esc does.
914
+ *
915
+ * Call only at a root turn boundary whose pending-message snapshot is empty. Pi persists a
916
+ * consumed steer before that boundary, so a queued result handed before the turn's last message
917
+ * either has Delivery Evidence and settles, or was discarded. A discarded result is selectable
918
+ * by a default wait again at once, with its batched Coordination Messages; automatic fallback
919
+ * re-sends it when the root's next run starts (`deliverDiscardedRootResults`).
920
+ */
921
+ requeueDiscardedRootResults(): void {
922
+ if (!this.acceptingOperations) return;
923
+ const candidates = this.deliveryLedger.terminalDeliveries
924
+ .filter((delivery) => {
925
+ if (delivery.destination_agent_id !== "root") return false;
926
+ const handOff = this.handedTerminalKeys.get(
927
+ deliveryTurnKey(delivery.source_agent_id, delivery.source_turn_id),
928
+ );
929
+ return handOff?.queued === true && handOff.order <= this.rootMessageEndOrder;
930
+ })
931
+ .sort(
932
+ (left, right) =>
933
+ (left.sequence ?? Number.MAX_SAFE_INTEGER) - (right.sequence ?? Number.MAX_SAFE_INTEGER),
934
+ );
935
+
936
+ for (const delivery of candidates) {
937
+ const handedKey = deliveryTurnKey(delivery.source_agent_id, delivery.source_turn_id);
938
+ const handOff = this.handedTerminalKeys.get(handedKey);
939
+ if (!handOff) continue;
940
+ const batched = handOff.coordinationDeliveryIds.flatMap((deliveryId) => {
941
+ const item = findCoordinationDelivery(this.deliveryLedger, deliveryId);
942
+ return item ? [item] : [];
943
+ });
944
+ if (this.hasDeliveryEvidence(delivery)) {
945
+ for (const item of batched) {
946
+ if (this.hasCoordinationDeliveryEvidence(item)) this.settleCoordinationDelivery(item);
947
+ }
948
+ this.settleDelivery(delivery);
949
+ continue;
950
+ }
951
+
952
+ this.handedTerminalKeys.delete(handedKey);
953
+ this.discardedRootResultKeys.add(handedKey);
954
+ for (const item of batched) {
955
+ this.waitHandedDeliveryIds.delete(item.delivery_id);
956
+ this.queuePendingParentMessage("root", item.message, item);
957
+ }
958
+ }
959
+ }
960
+
961
+ /** Whether a root result waits in Pi's clearable steer queue, so turn boundaries must be watched. */
962
+ hasQueuedRootHandOffs(): boolean {
963
+ for (const handOff of this.handedTerminalKeys.values()) if (handOff.queued) return true;
964
+ return false;
965
+ }
966
+
967
+ /**
968
+ * Queue results Pi discarded into a root run that just started, in Delivery Ledger sequence.
969
+ *
970
+ * Recovery never starts a root turn by itself, so Esc stays respected: the results wait, still
971
+ * selectable by a default wait, until the user or another delivery starts the root's next run.
972
+ */
973
+ deliverDiscardedRootResults(): void {
974
+ if (
975
+ this.discardedRootResultKeys.size === 0 ||
976
+ !this.acceptingOperations ||
977
+ this.dependencies.root.isIdle()
978
+ )
979
+ return;
980
+ const discarded = this.deliveryLedger.terminalDeliveries
981
+ .filter((delivery) =>
982
+ this.discardedRootResultKeys.has(
983
+ deliveryTurnKey(delivery.source_agent_id, delivery.source_turn_id),
984
+ ),
985
+ )
986
+ .sort(
987
+ (left, right) =>
988
+ (left.sequence ?? Number.MAX_SAFE_INTEGER) - (right.sequence ?? Number.MAX_SAFE_INTEGER),
989
+ );
990
+ this.discardedRootResultKeys.clear();
991
+ for (const delivery of discarded) {
992
+ const result = delivery.result ?? this.agents.get(delivery.source_agent_id)?.latest_result;
993
+ if (
994
+ delivery.path !== "message" ||
995
+ result?.status !== "completed" ||
996
+ result.turn_id !== delivery.source_turn_id
997
+ )
998
+ continue;
999
+ this.trackBackgroundOperation(this.deliverAutomaticResult(result, delivery, true));
1000
+ }
1001
+ }
1002
+
780
1003
  /** Clone complete child leaves for root fork ownership without ever sharing source session paths. */
781
1004
  async prepareFork(sourceRootSessionFile: string): Promise<ForkSnapshot> {
782
1005
  const activeRootChildren = this.childrenOf("root");
@@ -1070,17 +1293,27 @@ export class MinimalSubagentsCoordinator {
1070
1293
  if (result.status !== "completed") this.removeSettledEmptyTurnClaim(agent.agent_id, turnId);
1071
1294
  }
1072
1295
 
1296
+ /**
1297
+ * Hand one terminal result to its parent through automatic fallback.
1298
+ *
1299
+ * By default it waits its turn in the recipient queue, then the wait-claim grace period. A
1300
+ * `resume` hand-off re-sends a root result Pi discarded: its grace period has passed and its
1301
+ * sequence precedes everything still queued, so it is handed at once, synchronously up to Pi.
1302
+ */
1073
1303
  private async deliverAutomaticResult(
1074
1304
  result: TurnResult,
1075
1305
  delivery: PersistedDelivery,
1306
+ resume = false,
1076
1307
  ): Promise<void> {
1077
1308
  const deliveryKey = agentDeliveryKey(delivery.source_agent_id, delivery.source_turn_id);
1309
+ const handedKey = deliveryTurnKey(delivery.source_agent_id, delivery.source_turn_id);
1078
1310
  if (this.automaticDeliveryKeys.has(deliveryKey)) return;
1079
- this.automaticDeliveryKeys.add(deliveryKey);
1080
- const graceMs = this.deliveryGraceMs();
1311
+ const epoch = this.lifecycleEpoch;
1312
+ this.automaticDeliveryKeys.set(deliveryKey, epoch);
1313
+ const graceMs = resume ? 0 : this.deliveryGraceMs();
1081
1314
  let batchedCoordinationDeliveries: PersistedCoordinationDelivery[] = [];
1082
1315
  try {
1083
- await this.enqueueRecipientDelivery(delivery.destination_agent_id, async () => {
1316
+ const handOff = async () => {
1084
1317
  if (delivery.destination_agent_id !== "root") {
1085
1318
  await this.ensureRuntime(
1086
1319
  this.requireUsableAgent(delivery.destination_agent_id, "message"),
@@ -1088,6 +1321,8 @@ export class MinimalSubagentsCoordinator {
1088
1321
  if (!this.isTerminalDeliveryCurrent(delivery)) return;
1089
1322
  }
1090
1323
  if (graceMs > 0) await new Promise((resolve) => setTimeout(resolve, graceMs));
1324
+ // A branch change during the grace period abandons this hand-off; restore replays it.
1325
+ if (epoch !== this.lifecycleEpoch) return;
1091
1326
  if (!this.acceptingOperations || this.shouldStopAutomaticTerminalDelivery(delivery)) return;
1092
1327
  if (this.hasDeliveryEvidence(delivery)) {
1093
1328
  this.settleDelivery(delivery);
@@ -1113,6 +1348,13 @@ export class MinimalSubagentsCoordinator {
1113
1348
  for (const batchedDelivery of batchedCoordinationDeliveries) {
1114
1349
  this.waitHandedDeliveryIds.add(batchedDelivery.delivery_id);
1115
1350
  }
1351
+ // Installed before delivery so a settlement inside it removes the marker. Delivery up to
1352
+ // Pi's `sendMessage` is synchronous, so the idle check predicts a steer versus a new turn.
1353
+ this.handedTerminalKeys.set(handedKey, {
1354
+ order: ++this.handOffOrder,
1355
+ queued: delivery.destination_agent_id === "root" && !this.dependencies.root.isIdle(),
1356
+ coordinationDeliveryIds: batchedCoordinationDeliveries.map((item) => item.delivery_id),
1357
+ });
1116
1358
  await this.deliverToRecipient(
1117
1359
  delivery.destination_agent_id,
1118
1360
  combineCoordinatorMessages([
@@ -1123,8 +1365,14 @@ export class MinimalSubagentsCoordinator {
1123
1365
  this.isTerminalDeliveryCurrent(delivery) &&
1124
1366
  batchedCoordinationDeliveries.every((item) => this.isCoordinationDeliveryCurrent(item)),
1125
1367
  );
1126
- });
1368
+ };
1369
+ await (resume
1370
+ ? handOff()
1371
+ : this.enqueueRecipientDelivery(delivery.destination_agent_id, handOff));
1127
1372
  } catch (error) {
1373
+ // A hand-off abandoned by a branch change must not touch the restored branch's state.
1374
+ if (epoch !== this.lifecycleEpoch) return;
1375
+ this.handedTerminalKeys.delete(handedKey);
1128
1376
  for (const batchedDelivery of batchedCoordinationDeliveries) {
1129
1377
  this.waitHandedDeliveryIds.delete(batchedDelivery.delivery_id);
1130
1378
  }
@@ -1144,7 +1392,10 @@ export class MinimalSubagentsCoordinator {
1144
1392
  }),
1145
1393
  );
1146
1394
  } finally {
1147
- this.automaticDeliveryKeys.delete(deliveryKey);
1395
+ // Restore's replay may own the key again; release it only from the call that holds it.
1396
+ if (this.automaticDeliveryKeys.get(deliveryKey) === epoch) {
1397
+ this.automaticDeliveryKeys.delete(deliveryKey);
1398
+ }
1148
1399
  }
1149
1400
  }
1150
1401
 
@@ -1230,6 +1481,7 @@ export class MinimalSubagentsCoordinator {
1230
1481
  sourceAgentId,
1231
1482
  destinationAgentId,
1232
1483
  waitHandedDeliveryIds: this.waitHandedDeliveryIds,
1484
+ handedTurnKeys: this.handedTerminalKeys,
1233
1485
  activeTurnId: agent?.active_turn_id,
1234
1486
  latestResultTurnId: agent?.latest_result?.turn_id,
1235
1487
  });
@@ -1315,24 +1567,27 @@ export class MinimalSubagentsCoordinator {
1315
1567
  message: CoordinatorMessage,
1316
1568
  targetId: string,
1317
1569
  delivery: PersistedCoordinationDelivery,
1318
- ): Promise<AgentMessageDisposition> {
1570
+ ): Promise<{ disposition: AgentMessageDisposition; turnId?: string }> {
1319
1571
  this.waitHandedDeliveryIds.add(delivery.delivery_id);
1320
1572
  try {
1321
- await this.deliverToRecipient(targetId, message, () =>
1573
+ const startedTurnId = await this.deliverToRecipient(targetId, message, () =>
1322
1574
  this.isCoordinationDeliveryCurrent(delivery),
1323
1575
  );
1324
- return "queued";
1576
+ return startedTurnId
1577
+ ? { disposition: "started-turn", turnId: startedTurnId }
1578
+ : { disposition: "queued" };
1325
1579
  } catch (error) {
1326
1580
  this.waitHandedDeliveryIds.delete(delivery.delivery_id);
1327
1581
  throw error;
1328
1582
  }
1329
1583
  }
1330
1584
 
1585
+ /** Deliver one message, returning the turn ID when it starts a new turn on an idle child. */
1331
1586
  private async deliverToRecipient(
1332
1587
  targetId: string,
1333
1588
  message: CoordinatorMessage,
1334
1589
  isCurrentDelivery: () => boolean = () => true,
1335
- ): Promise<void> {
1590
+ ): Promise<string | undefined> {
1336
1591
  if (!isCurrentDelivery()) {
1337
1592
  throw new Error("Minimal subagents delivery abandoned after session branch change");
1338
1593
  }
@@ -1352,7 +1607,7 @@ export class MinimalSubagentsCoordinator {
1352
1607
  const visibleMessage = addCoordinatorMessageEnvelope(message);
1353
1608
  if (target.active_turn_id || runtime.isRunning) {
1354
1609
  await runtime.queueCoordinatorMessage(visibleMessage);
1355
- return;
1610
+ return undefined;
1356
1611
  }
1357
1612
  const turnId = this.beginTurn(target);
1358
1613
  const runMessage = runtime
@@ -1374,6 +1629,7 @@ export class MinimalSubagentsCoordinator {
1374
1629
  }
1375
1630
  });
1376
1631
  this.trackBackgroundOperation(runMessage);
1632
+ return turnId;
1377
1633
  }
1378
1634
 
1379
1635
  private trackBackgroundOperation(operation: Promise<void>): void {
@@ -1441,6 +1697,19 @@ export class MinimalSubagentsCoordinator {
1441
1697
  }),
1442
1698
  );
1443
1699
  }
1700
+ this.dropStaleHandedTerminalKeys();
1701
+ }
1702
+
1703
+ /** Forget handed markers whose terminal delivery left the ledger by settlement, pruning, or deletion. */
1704
+ private dropStaleHandedTerminalKeys(): void {
1705
+ const pending = new Set(
1706
+ this.deliveryLedger.terminalDeliveries.map((delivery) =>
1707
+ deliveryTurnKey(delivery.source_agent_id, delivery.source_turn_id),
1708
+ ),
1709
+ );
1710
+ for (const key of this.handedTerminalKeys.keys()) {
1711
+ if (!pending.has(key)) this.handedTerminalKeys.delete(key);
1712
+ }
1444
1713
  }
1445
1714
 
1446
1715
  private isTerminalDeliveryCurrent(delivery: PersistedDelivery): boolean {
@@ -1516,6 +1785,7 @@ export class MinimalSubagentsCoordinator {
1516
1785
  delivery.source_agent_id,
1517
1786
  delivery.source_turn_id,
1518
1787
  ).ledger;
1788
+ this.dropStaleHandedTerminalKeys();
1519
1789
  this.dependencies.registry.append(
1520
1790
  createRegistryEvent(this.dependencies.registry.rootSessionId, "delivery-settled", {
1521
1791
  source_agent_id: delivery.source_agent_id,
@@ -1527,9 +1797,6 @@ export class MinimalSubagentsCoordinator {
1527
1797
 
1528
1798
  private buildAgentSummary(agent: PersistedAgent, includeDescendants = true): AgentSummary {
1529
1799
  const directChildren = this.childrenOf(agent.agent_id);
1530
- const children = includeDescendants
1531
- ? directChildren.map((child) => this.buildAgentSummary(child))
1532
- : [];
1533
1800
  const elapsed = agent.active_turn_started_at
1534
1801
  ? Math.max(0, this.now().getTime() - new Date(agent.active_turn_started_at).getTime())
1535
1802
  : undefined;
@@ -1538,7 +1805,7 @@ export class MinimalSubagentsCoordinator {
1538
1805
  model: agent.launch_contract.model,
1539
1806
  thinking_level: agent.launch_contract.thinking_level,
1540
1807
  };
1541
- return {
1808
+ const summary: AgentSummary = {
1542
1809
  agent_id: agent.agent_id,
1543
1810
  parent_id: agent.parent_id,
1544
1811
  state: agent.active_turn_id ? "running" : "idle",
@@ -1553,8 +1820,11 @@ export class MinimalSubagentsCoordinator {
1553
1820
  latest_activity_at: agent.latest_activity_at ?? agent.created_at,
1554
1821
  task: agent.task,
1555
1822
  child_count: directChildren.length,
1556
- children,
1557
1823
  };
1824
+ if (includeDescendants) {
1825
+ summary.children = directChildren.map((child) => this.buildAgentSummary(child));
1826
+ }
1827
+ return summary;
1558
1828
  }
1559
1829
 
1560
1830
  private buildAgentDetail(agent: PersistedAgent, includeDescendants = true): AgentDetail {
@@ -1581,6 +1851,7 @@ export class MinimalSubagentsCoordinator {
1581
1851
  private pruneDeliveryStateForDeletedAgent(agentId: string): void {
1582
1852
  const previousCoordinationDeliveries = this.deliveryLedger.coordinationDeliveries;
1583
1853
  this.deliveryLedger = pruneDeliveryLedgerAgents(this.deliveryLedger, [agentId]).ledger;
1854
+ this.dropStaleHandedTerminalKeys();
1584
1855
  for (const delivery of previousCoordinationDeliveries) {
1585
1856
  if (!this.isCoordinationDeliveryCurrent(delivery)) {
1586
1857
  this.waitHandedDeliveryIds.delete(delivery.delivery_id);
@@ -45,16 +45,19 @@ export interface AddedCoordinationDelivery extends DeliveryLedgerTransition {
45
45
  delivery: PersistedCoordinationDelivery;
46
46
  }
47
47
 
48
- /** Describes caller-visible inputs used to select the oldest observable source turn. */
48
+ /** Describes caller-visible inputs used to select the oldest observable source turn the destination has neither claimed nor received. */
49
49
  export interface SelectObservableDeliveryTurnOptions {
50
50
  sourceAgentId: string;
51
51
  destinationAgentId: string;
52
52
  waitHandedDeliveryIds: ReadonlySet<string>;
53
+ /** `deliveryTurnKey`s of terminal results already queued to the destination automatically. */
54
+ handedTurnKeys?: { has(key: string): boolean };
53
55
  activeTurnId?: string;
54
56
  latestResultTurnId?: string;
55
57
  }
56
58
 
57
- function deliveryTurnKey(sourceAgentId: string, sourceTurnId: string): string {
59
+ /** Compound source-agent and source-turn key shared by claims and process-local handed markers. */
60
+ export function deliveryTurnKey(sourceAgentId: string, sourceTurnId: string): string {
58
61
  return `${sourceAgentId}\u0000${sourceTurnId}`;
59
62
  }
60
63
 
@@ -420,19 +423,20 @@ export function releaseEmptyDeliveryLedgerTurn(
420
423
  : releaseDeliveryLedgerTurn(ledger, sourceAgentId, sourceTurnId);
421
424
  }
422
425
 
423
- /** Select the oldest claimed, then oldest sequenced, observable source turn. */
426
+ /**
427
+ * Select the oldest sequenced observable source turn whose terminal result the caller has neither
428
+ * claimed nor been handed automatically, falling back to the active, then latest, turn.
429
+ */
424
430
  export function selectObservableDeliveryTurn(
425
431
  ledger: DeliveryLedger,
426
432
  options: SelectObservableDeliveryTurnOptions,
427
433
  ): string | undefined {
428
- const candidates = new Map<string, { claimed: boolean; sequence: number }>();
434
+ const candidates = new Map<string, number>();
429
435
  const addCandidate = (turnId: string, sequence: number) => {
430
- const candidate = {
431
- claimed: ledger.waitClaimedTurns.includes(deliveryTurnKey(options.sourceAgentId, turnId)),
432
- sequence,
433
- };
436
+ const turnKey = deliveryTurnKey(options.sourceAgentId, turnId);
437
+ if (ledger.waitClaimedTurns.includes(turnKey) || options.handedTurnKeys?.has(turnKey)) return;
434
438
  const existing = candidates.get(turnId);
435
- if (!existing || sequence < existing.sequence) candidates.set(turnId, candidate);
439
+ if (existing === undefined || sequence < existing) candidates.set(turnId, sequence);
436
440
  };
437
441
  for (const delivery of ledger.coordinationDeliveries) {
438
442
  if (
@@ -451,10 +455,7 @@ export function selectObservableDeliveryTurn(
451
455
  addCandidate(delivery.source_turn_id, delivery.sequence ?? Number.MAX_SAFE_INTEGER);
452
456
  }
453
457
  }
454
- const retained = [...candidates.entries()].sort((left, right) => {
455
- if (left[1].claimed !== right[1].claimed) return left[1].claimed ? -1 : 1;
456
- return left[1].sequence - right[1].sequence;
457
- })[0]?.[0];
458
+ const retained = [...candidates.entries()].sort((left, right) => left[1] - right[1])[0]?.[0];
458
459
  return retained ?? options.activeTurnId ?? options.latestResultTurnId;
459
460
  }
460
461