@ian-pascoe/pi-minimal-subagents 0.13.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -39,9 +39,15 @@ From this package checkout, load the source directly with
39
39
  tool-resolution `warnings`. Spawns run sequentially, so a `subagent_wait` or
40
40
  `agent_message` in the same tool batch can target the new child.
41
41
  2. `subagent_wait` claims the turn's result, or leave it unclaimed and the final
42
- response arrives automatically as a steer message.
42
+ response arrives automatically as a steer message. Without `turn_id`, a wait
43
+ skips turns whose result you already claimed or received automatically, and
44
+ never repeats an automatically delivered result: it reports
45
+ `already_delivered: true` instead of the output.
43
46
  3. `agent_message` continues an idle child: the result reports
44
- `disposition: "started-turn"` with the new `turn_id` to wait on.
47
+ `disposition: "started-turn"` with the new `turn_id`. A plain
48
+ `subagent_wait({ agent_id })` targets that new turn, not the first result;
49
+ pass `turn_id` to re-read a turn whose result is still retained, claimed or
50
+ not.
45
51
  4. `subagent_status` inspects children; `subagent_cancel` stops work but keeps
46
52
  the session; `subagent_delete` removes it. Deleted IDs cannot be reused.
47
53
 
@@ -93,10 +99,10 @@ source branch, and a new branch without an override follows settings.
93
99
 
94
100
  ## Model roles
95
101
 
96
- `minimalSubagents.modelRoles` gives the parent agent advisory names for
102
+ `minimalSubagents.modelRoles` gives the parent agent named, advisory choices of
97
103
  eligible models. The extension defines no roles itself, performs no task
98
- classification, and does not route launches. The parent still passes the
99
- ordinary `model` and `thinking_level` arguments separately.
104
+ classification, and does not route launches: the parent names the role it wants
105
+ with the optional `role` argument of `subagent`.
100
106
 
101
107
  ```json
102
108
  {
@@ -113,8 +119,9 @@ ordinary `model` and `thinking_level` arguments separately.
113
119
  ```
114
120
 
115
121
  A recognized final suffix (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`,
116
- or `max`) is a preferred `thinking_level`, not part of the canonical model
117
- passed to `subagent`. Unsuffixed roles leave thinking selection independent.
122
+ or `max`) is the role's `thinking_level`, not part of the canonical model.
123
+ Unsuffixed roles leave thinking selection independent: the child uses the
124
+ caller's thinking level unless `thinking_level` is passed.
118
125
  Role names and hints are trimmed, single-line text. Names may be up to 64
119
126
  characters and hints up to 500 characters. Models use canonical
120
127
  `provider/model` IDs and must be available under the effective `enabledModels`
@@ -125,6 +132,27 @@ preference. A thinking level pinned in `enabledModels` neither supplies nor
125
132
  constrains a role preference, and normal spawn-time model-capability clamping
126
133
  still applies.
127
134
 
135
+ ### Launching with a role
136
+
137
+ `subagent({ task, role: "design" })` launches the child with the `design` role's
138
+ model and, when the role has a suffix, its thinking level. An explicit `model` or
139
+ `thinking_level` overrides the role's value independently, so
140
+ `{ role: "design", thinking_level: "low" }` keeps the role's model with a
141
+ different thinking level. The role is recorded even when explicit `model` and
142
+ `thinking_level` override all of its values. An unknown `role` fails before any
143
+ child is created and lists the configured role names. The Launch Contract
144
+ records the resolved `model` and `thinking_level` together with the `role` used
145
+ (`role` is absent when none was named), and `subagent_status` and the expanded
146
+ `subagent` result show it. Recording the name does not tie the child to the role: later settings
147
+ changes never alter an existing Launch Contract.
148
+
149
+ Roles are read when the session starts, so editing `modelRoles` mid-session
150
+ changes nothing until a reload. The `role` parameter is a plain string rather
151
+ than an enum of the configured names, so the tool definition stays
152
+ byte-identical when `modelRoles` changes. Only the role list in the system
153
+ prompt (the `subagent` prompt guidelines) changes, and only at reload. The
154
+ `model` enum is separate: it already follows the eligible models.
155
+
128
156
  Global and project roles merge by name in settings order. Expanded role
129
157
  objects merge by field; a project string replaces the whole global entry. A
130
158
  project can remove one inherited role with `null`, or clear all inherited
@@ -310,10 +338,17 @@ intermediate Wait Event containing a Coordination Message before the child turn
310
338
  settles. That event claims only its message, so later unconsumed messages and the
311
339
  terminal result retain automatic fallback. If the turn has already settled, a
312
340
  wait returns its terminal result with queued messages in `messages`; waiting
313
- again for the same settled turn returns the same result. Pass
314
- optional `turn_id` to address an older retained turn exactly. Without it, waits
315
- select the oldest observable claimed or pending turn before the active/latest
316
- turn. A caller may have only one outstanding wait for the same source turn; a
341
+ again for a settled turn you claimed returns the same result while it is
342
+ retained. Pass optional `turn_id` to address a retained turn exactly, including
343
+ one whose result you already claimed (a claimed result stays retained until
344
+ Delivery Evidence settles it). Without it, waits skip claimed turns and turns
345
+ whose result was already delivered to you automatically, and select the oldest
346
+ remaining observable turn, falling back to the active, then latest, turn. When
347
+ that fallback reaches a result already delivered to you automatically, whether
348
+ still queued or settled, the wait returns `event: "turn"` with
349
+ `already_delivered: true`, the turn identity, and status, but no output; it does
350
+ not claim the turn, and `turn_id` rereads the full result. A
351
+ caller may have only one outstanding wait for the same source turn; a
317
352
  concurrent duplicate is rejected instead of competing for one Wait Event.
318
353
  When `timeout_ms` expires, the wait returns an observational `event: "timeout"`
319
354
  with the requested turn identity and the same detailed Child Agent status used
@@ -321,20 +356,26 @@ by targeted `subagent_status`. It removes only the waiter, leaving the child
321
356
  running and all pending delivery unclaimed. Abort signals remain errors.
322
357
 
323
358
  The persisted Delivery Ledger records Coordination Messages, terminal results,
324
- globally increasing sequence, and wait ownership before delivery. Existing
325
- items retain their sequence; gaps from skipped malformed records are valid.
326
- Claims can name only active, latest, or retained turns. Wait-returned messages
327
- retain individual delivery evidence; terminal wait ownership remains durable
328
- across reloads, forks, and newer turns. Automatic fallback retains its ordered
329
- queue reservation while batching queued messages from one source turn into one
330
- Pi steer. Root-bound messages remain batchable while the root turn is active; a
331
- pending terminal result absorbs them. Child sessions drain all available steers
332
- before the next model call. Destination-session Delivery Evidence still settles
333
- each batched ledger item independently, preventing duplicate delivery and
334
- unbounded checkpoint growth. The pure Delivery Ledger state machine retains at
335
- most 20 pending wait-only terminal results per source agent; Coordination Messages
336
- are not removed by that terminal-retention limit. Delivered messages include
337
- stable delivery, source-agent, and source-turn identities in persisted details.
359
+ globally increasing sequence, and wait ownership before delivery. Existing items
360
+ retain their sequence; gaps from skipped malformed records are valid. Claims can
361
+ name only active, latest, or retained turns. Wait-returned messages retain
362
+ individual delivery evidence; terminal wait ownership remains durable across
363
+ reloads, forks, and newer turns. Automatic fallback retains its ordered queue
364
+ reservation while batching queued messages from one source turn into one Pi
365
+ steer. Root-bound messages remain batchable while the root turn is active; a
366
+ pending terminal result absorbs them. If Pi discards a root-bound steer before
367
+ consuming it, as Esc does, the next root turn boundary reporting an empty Pi
368
+ queue releases the result: a default wait selects it again at once, and
369
+ automatic fallback re-sends it, ahead of newer results, into the root run if it
370
+ is still going, or, after an abort such as Esc, when the root's next run starts.
371
+ Recovery never starts a root turn by itself. Child sessions drain all available
372
+ steers before the next model call. Destination-session Delivery Evidence still
373
+ settles each batched ledger item independently, preventing duplicate delivery
374
+ and unbounded checkpoint growth. The pure Delivery Ledger state machine retains
375
+ at most 20 pending wait-only terminal results per source agent; Coordination
376
+ Messages are not removed by that terminal-retention limit. Delivered messages
377
+ include stable delivery, source-agent, and source-turn identities in persisted
378
+ details.
338
379
 
339
380
  Deleting a child first verifies its session header and persistent identity,
340
381
  then uses the optional `trash` command when available and falls back to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ian-pascoe/pi-minimal-subagents",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
4
4
  "private": false,
5
5
  "description": "Persistent nested subagents with bounded delegation for Pi",
6
6
  "keywords": [
@@ -53,7 +53,7 @@
53
53
  ]
54
54
  },
55
55
  "scripts": {
56
- "test": "vitest run --config ../../vitest.config.ts --root .",
56
+ "test": "vitest run",
57
57
  "typecheck": "tsc --noEmit -p tsconfig.json"
58
58
  }
59
59
  }
@@ -45,6 +45,16 @@ const ModelRolesSettingSchema = Type.Union([ModelRoleEntriesSchema, Type.Null()]
45
45
 
46
46
  type ModelRoleThinkingLevel = (typeof THINKING_LEVELS)[number];
47
47
 
48
+ /** Whether `name` is trimmed single-line text of at most 64 characters, as every role name must be. */
49
+ export function isModelRoleName(name: string): boolean {
50
+ return (
51
+ name.length > 0 &&
52
+ name === name.trim() &&
53
+ !/[\r\n]/.test(name) &&
54
+ name.length <= MODEL_ROLE_NAME_MAX_LENGTH
55
+ );
56
+ }
57
+
48
58
  /** Describes one user-authored advisory model role shown to subagent callers. */
49
59
  export interface MinimalSubagentsModelRole {
50
60
  name: string;
@@ -294,12 +304,7 @@ function parseModelRoles(
294
304
  const roles: MinimalSubagentsModelRole[] = [];
295
305
  for (const [name, entry] of entries) {
296
306
  const path = `${entry.scope} minimalSubagents.modelRoles.${name}`;
297
- if (
298
- name.length === 0 ||
299
- name !== name.trim() ||
300
- /[\r\n]/.test(name) ||
301
- name.length > MODEL_ROLE_NAME_MAX_LENGTH
302
- ) {
307
+ if (!isModelRoleName(name)) {
303
308
  warnings.push(`${path}: role name must be trimmed single-line text up to 64 characters`);
304
309
  continue;
305
310
  }
@@ -23,6 +23,7 @@ import {
23
23
  claimDeliveryLedgerTurn,
24
24
  createDeliveryLedger,
25
25
  deliveryLedgerSnapshot,
26
+ deliveryTurnKey,
26
27
  findCoordinationDelivery,
27
28
  findTerminalDelivery,
28
29
  isDeliveryLedgerTurnClaimed,
@@ -42,6 +43,7 @@ import { withTroubleshootingHint } from "./troubleshooting-skill.js";
42
43
  import { addCoordinatorMessageEnvelope } from "./minimal-subagents-message-envelope.js";
43
44
  import { unavailableAgent } from "./minimal-subagents-sessions.js";
44
45
  import { createRegistryEvent } from "./minimal-subagents-registry.js";
46
+ import type { MinimalSubagentsModelRole } from "./minimal-subagents-config.js";
45
47
  import type {
46
48
  ActiveTurnProgress,
47
49
  AgentDetail,
@@ -67,6 +69,7 @@ import type {
67
69
  StatusResult,
68
70
  TurnResult,
69
71
  WaitMessageResult,
72
+ WaitDeliveredTurnResult,
70
73
  WaitResult,
71
74
  } from "./minimal-subagents-types.js";
72
75
 
@@ -109,6 +112,16 @@ interface PendingParentMessage {
109
112
  cancelGrace?: () => void;
110
113
  }
111
114
 
115
+ /** One terminal result queued to its parent, until Delivery Evidence settles it or Pi discards it. */
116
+ interface TerminalHandOff {
117
+ /** Hand-off order, compared with the root's last ended message at a turn boundary. */
118
+ order: number;
119
+ /** Whether the result entered Pi's clearable steer queue instead of starting a root turn. */
120
+ queued: boolean;
121
+ /** Coordination Messages batched into the same steer. */
122
+ coordinationDeliveryIds: readonly string[];
123
+ }
124
+
112
125
  function agentDeliveryKey(agentId: string, turnId: string): string {
113
126
  return `${agentId}\u0000${turnId}`;
114
127
  }
@@ -133,6 +146,27 @@ function terminalWaitResult(result: TurnResult, messages: WaitMessageResult[] =
133
146
  return messages.length === 0 ? terminal : { ...terminal, messages: structuredClone(messages) };
134
147
  }
135
148
 
149
+ /** A terminal Wait Event naming an already delivered result without repeating its output. */
150
+ function alreadyDeliveredWaitResult(
151
+ result: TurnResult,
152
+ messages: WaitMessageResult[],
153
+ ): WaitDeliveredTurnResult {
154
+ const delivered: WaitDeliveredTurnResult = {
155
+ event: "turn",
156
+ agent_id: result.agent_id,
157
+ turn_id: result.turn_id,
158
+ status: result.status,
159
+ already_delivered: true,
160
+ };
161
+ if (messages.length > 0) delivered.messages = structuredClone(messages);
162
+ return delivered;
163
+ }
164
+
165
+ /** The message ID of an automatic result message, which is also its Delivery Evidence key. */
166
+ function automaticResultMessageId(sourceAgentId: string, sourceTurnId: string): string {
167
+ return `result:${sourceAgentId}:${sourceTurnId}`;
168
+ }
169
+
136
170
  function combineCoordinatorMessages(messages: readonly CoordinatorMessage[]): CoordinatorMessage {
137
171
  const latest = messages.at(-1);
138
172
  if (!latest) throw new Error("Minimal subagents message batch must not be empty");
@@ -162,9 +196,18 @@ export class MinimalSubagentsCoordinator {
162
196
  private readonly waiters = new Map<string, Set<TurnWaiter>>();
163
197
  private readonly pendingParentMessages = new Map<string, PendingParentMessage[]>();
164
198
  private readonly recipientQueues = new Map<string, Promise<unknown>>();
165
- private readonly automaticDeliveryKeys = new Set<string>();
199
+ /** In-flight automatic result hand-offs, each with the lifecycle epoch that owns it. */
200
+ private readonly automaticDeliveryKeys = new Map<string, number>();
166
201
  private readonly automaticCoordinationDeliveryIds = new Set<string>();
167
202
  private readonly waitHandedDeliveryIds = new Set<string>();
203
+ /** Terminal results already queued to their parent, until Delivery Evidence settles them. */
204
+ private readonly handedTerminalKeys = new Map<string, TerminalHandOff>();
205
+ /** Monotonic order of hand-offs, compared against the root's last ended message. */
206
+ private handOffOrder = 0;
207
+ /** The latest hand-off order a root `message_end` has seen; later hand-offs may postdate Pi's queue snapshot. */
208
+ private rootMessageEndOrder = 0;
209
+ /** Root results Pi discarded, awaiting the root's next run; each keeps its Delivery Ledger sequence. */
210
+ private readonly discardedRootResultKeys = new Set<string>();
168
211
  private readonly backgroundOperations = new Set<Promise<void>>();
169
212
  private acceptingOperations = true;
170
213
  private lifecycleEpoch = 0;
@@ -222,8 +265,10 @@ export class MinimalSubagentsCoordinator {
222
265
 
223
266
  const sessionContext = parameters.session_context ?? "omit";
224
267
  const projectContext = parameters.project_context ?? "inherit";
225
- const model = parameters.model ?? caller.model;
226
- const requestedThinking = parameters.thinking_level ?? caller.thinkingLevel;
268
+ const role = parameters.role === undefined ? undefined : this.requireModelRole(parameters.role);
269
+ const model = parameters.model ?? role?.model ?? caller.model;
270
+ const requestedThinking =
271
+ parameters.thinking_level ?? role?.thinkingLevel ?? caller.thinkingLevel;
227
272
  const thinkingLevel = this.dependencies.sessions.resolveThinkingLevel(model, requestedThinking);
228
273
  const {
229
274
  ordinaryTools,
@@ -268,6 +313,8 @@ export class MinimalSubagentsCoordinator {
268
313
  missing_dependencies: [],
269
314
  recent_messages: [],
270
315
  };
316
+ // Added after `delegation`, matching the key order of replayed Launch Contracts.
317
+ if (role) agent.launch_contract.role = role.name;
271
318
  this.pendingAgentIds.add(agentId);
272
319
  let identity: ReturnType<AgentSessionFactory["createIdentity"]>;
273
320
  try {
@@ -352,6 +399,19 @@ export class MinimalSubagentsCoordinator {
352
399
  return result;
353
400
  }
354
401
 
402
+ private requireModelRole(name: string): MinimalSubagentsModelRole {
403
+ const roles = this.dependencies.modelRoles ?? [];
404
+ const role = roles.find((candidate) => candidate.name === name);
405
+ if (role) return role;
406
+ throw new Error(
407
+ `Minimal subagents spawn validation: unknown role ${JSON.stringify(name)}; ${
408
+ roles.length === 0
409
+ ? "no model roles are configured"
410
+ : `configured roles: ${roles.map((candidate) => candidate.name).join(", ")}`
411
+ }`,
412
+ );
413
+ }
414
+
355
415
  /** Capture immutable launch defaults for a nested caller from its active child runtime. */
356
416
  snapshotChildCaller(agentId: string, spawnEntryId: string): CallerSnapshot {
357
417
  const agent = this.requireAgent(agentId);
@@ -448,6 +508,11 @@ export class MinimalSubagentsCoordinator {
448
508
  (agent.latest_result?.turn_id === turnId ? agent.latest_result : undefined);
449
509
  if (retainedResult) {
450
510
  const messages = this.drainPendingParentMessages(callerId, agentId, turnId);
511
+ // A default wait falls back to the latest turn, whose result may already have been delivered
512
+ // automatically; repeating its output would deliver it twice. An explicit turn_id rereads it.
513
+ if (requestedTurnId === undefined && this.wasAlreadyDelivered(callerId, retainedResult)) {
514
+ return Promise.resolve(alreadyDeliveredWaitResult(retainedResult, messages));
515
+ }
451
516
  this.claimTerminalDelivery(callerId, retainedResult);
452
517
  return Promise.resolve(terminalWaitResult(retainedResult, messages));
453
518
  }
@@ -456,7 +521,7 @@ export class MinimalSubagentsCoordinator {
456
521
  if (agent.active_turn_id !== turnId) {
457
522
  return Promise.reject(
458
523
  new Error(
459
- `Minimal subagents wait: turn ${turnId} is unknown or no longer retained for ${agentId}; omit turn_id to wait for its oldest observable turn`,
524
+ `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`,
460
525
  ),
461
526
  );
462
527
  }
@@ -720,6 +785,8 @@ export class MinimalSubagentsCoordinator {
720
785
  this.waiters.clear();
721
786
  this.waitHandedDeliveryIds.clear();
722
787
  this.automaticDeliveryKeys.clear();
788
+ this.handedTerminalKeys.clear();
789
+ this.discardedRootResultKeys.clear();
723
790
  this.automaticCoordinationDeliveryIds.clear();
724
791
  this.deliveryLedger = createDeliveryLedger({
725
792
  deliveries: snapshot.deliveries,
@@ -864,6 +931,102 @@ export class MinimalSubagentsCoordinator {
864
931
  await Promise.allSettled(scheduled);
865
932
  }
866
933
 
934
+ /** Note that a root message ended; results handed before it reached Pi's queue before the next turn boundary. */
935
+ recordRootMessageEnd(): void {
936
+ this.rootMessageEndOrder = this.handOffOrder;
937
+ }
938
+
939
+ /**
940
+ * Release results Pi discarded from the root's steer queue, as Esc does.
941
+ *
942
+ * Call only at a root turn boundary whose pending-message snapshot is empty. Pi persists a
943
+ * consumed steer before that boundary, so a queued result handed before the turn's last message
944
+ * either has Delivery Evidence and settles, or was discarded. A discarded result is selectable
945
+ * by a default wait again at once, with its batched Coordination Messages; automatic fallback
946
+ * re-sends it when the root's next run starts (`deliverDiscardedRootResults`).
947
+ */
948
+ requeueDiscardedRootResults(): void {
949
+ if (!this.acceptingOperations) return;
950
+ const candidates = this.deliveryLedger.terminalDeliveries
951
+ .filter((delivery) => {
952
+ if (delivery.destination_agent_id !== "root") return false;
953
+ const handOff = this.handedTerminalKeys.get(
954
+ deliveryTurnKey(delivery.source_agent_id, delivery.source_turn_id),
955
+ );
956
+ return handOff?.queued === true && handOff.order <= this.rootMessageEndOrder;
957
+ })
958
+ .sort(
959
+ (left, right) =>
960
+ (left.sequence ?? Number.MAX_SAFE_INTEGER) - (right.sequence ?? Number.MAX_SAFE_INTEGER),
961
+ );
962
+
963
+ for (const delivery of candidates) {
964
+ const handedKey = deliveryTurnKey(delivery.source_agent_id, delivery.source_turn_id);
965
+ const handOff = this.handedTerminalKeys.get(handedKey);
966
+ if (!handOff) continue;
967
+ const batched = handOff.coordinationDeliveryIds.flatMap((deliveryId) => {
968
+ const item = findCoordinationDelivery(this.deliveryLedger, deliveryId);
969
+ return item ? [item] : [];
970
+ });
971
+ if (this.hasDeliveryEvidence(delivery)) {
972
+ for (const item of batched) {
973
+ if (this.hasCoordinationDeliveryEvidence(item)) this.settleCoordinationDelivery(item);
974
+ }
975
+ this.settleDelivery(delivery);
976
+ continue;
977
+ }
978
+
979
+ this.handedTerminalKeys.delete(handedKey);
980
+ this.discardedRootResultKeys.add(handedKey);
981
+ for (const item of batched) {
982
+ this.waitHandedDeliveryIds.delete(item.delivery_id);
983
+ this.queuePendingParentMessage("root", item.message, item);
984
+ }
985
+ }
986
+ }
987
+
988
+ /** Whether a root result waits in Pi's clearable steer queue, so turn boundaries must be watched. */
989
+ hasQueuedRootHandOffs(): boolean {
990
+ for (const handOff of this.handedTerminalKeys.values()) if (handOff.queued) return true;
991
+ return false;
992
+ }
993
+
994
+ /**
995
+ * Queue results Pi discarded into a root run that just started, in Delivery Ledger sequence.
996
+ *
997
+ * Recovery never starts a root turn by itself, so Esc stays respected: the results wait, still
998
+ * selectable by a default wait, until the user or another delivery starts the root's next run.
999
+ */
1000
+ deliverDiscardedRootResults(): void {
1001
+ if (
1002
+ this.discardedRootResultKeys.size === 0 ||
1003
+ !this.acceptingOperations ||
1004
+ this.dependencies.root.isIdle()
1005
+ )
1006
+ return;
1007
+ const discarded = this.deliveryLedger.terminalDeliveries
1008
+ .filter((delivery) =>
1009
+ this.discardedRootResultKeys.has(
1010
+ deliveryTurnKey(delivery.source_agent_id, delivery.source_turn_id),
1011
+ ),
1012
+ )
1013
+ .sort(
1014
+ (left, right) =>
1015
+ (left.sequence ?? Number.MAX_SAFE_INTEGER) - (right.sequence ?? Number.MAX_SAFE_INTEGER),
1016
+ );
1017
+ this.discardedRootResultKeys.clear();
1018
+ for (const delivery of discarded) {
1019
+ const result = delivery.result ?? this.agents.get(delivery.source_agent_id)?.latest_result;
1020
+ if (
1021
+ delivery.path !== "message" ||
1022
+ result?.status !== "completed" ||
1023
+ result.turn_id !== delivery.source_turn_id
1024
+ )
1025
+ continue;
1026
+ this.trackBackgroundOperation(this.deliverAutomaticResult(result, delivery, true));
1027
+ }
1028
+ }
1029
+
867
1030
  /** Clone complete child leaves for root fork ownership without ever sharing source session paths. */
868
1031
  async prepareFork(sourceRootSessionFile: string): Promise<ForkSnapshot> {
869
1032
  const activeRootChildren = this.childrenOf("root");
@@ -1157,17 +1320,27 @@ export class MinimalSubagentsCoordinator {
1157
1320
  if (result.status !== "completed") this.removeSettledEmptyTurnClaim(agent.agent_id, turnId);
1158
1321
  }
1159
1322
 
1323
+ /**
1324
+ * Hand one terminal result to its parent through automatic fallback.
1325
+ *
1326
+ * By default it waits its turn in the recipient queue, then the wait-claim grace period. A
1327
+ * `resume` hand-off re-sends a root result Pi discarded: its grace period has passed and its
1328
+ * sequence precedes everything still queued, so it is handed at once, synchronously up to Pi.
1329
+ */
1160
1330
  private async deliverAutomaticResult(
1161
1331
  result: TurnResult,
1162
1332
  delivery: PersistedDelivery,
1333
+ resume = false,
1163
1334
  ): Promise<void> {
1164
1335
  const deliveryKey = agentDeliveryKey(delivery.source_agent_id, delivery.source_turn_id);
1336
+ const handedKey = deliveryTurnKey(delivery.source_agent_id, delivery.source_turn_id);
1165
1337
  if (this.automaticDeliveryKeys.has(deliveryKey)) return;
1166
- this.automaticDeliveryKeys.add(deliveryKey);
1167
- const graceMs = this.deliveryGraceMs();
1338
+ const epoch = this.lifecycleEpoch;
1339
+ this.automaticDeliveryKeys.set(deliveryKey, epoch);
1340
+ const graceMs = resume ? 0 : this.deliveryGraceMs();
1168
1341
  let batchedCoordinationDeliveries: PersistedCoordinationDelivery[] = [];
1169
1342
  try {
1170
- await this.enqueueRecipientDelivery(delivery.destination_agent_id, async () => {
1343
+ const handOff = async () => {
1171
1344
  if (delivery.destination_agent_id !== "root") {
1172
1345
  await this.ensureRuntime(
1173
1346
  this.requireUsableAgent(delivery.destination_agent_id, "message"),
@@ -1175,6 +1348,8 @@ export class MinimalSubagentsCoordinator {
1175
1348
  if (!this.isTerminalDeliveryCurrent(delivery)) return;
1176
1349
  }
1177
1350
  if (graceMs > 0) await new Promise((resolve) => setTimeout(resolve, graceMs));
1351
+ // A branch change during the grace period abandons this hand-off; restore replays it.
1352
+ if (epoch !== this.lifecycleEpoch) return;
1178
1353
  if (!this.acceptingOperations || this.shouldStopAutomaticTerminalDelivery(delivery)) return;
1179
1354
  if (this.hasDeliveryEvidence(delivery)) {
1180
1355
  this.settleDelivery(delivery);
@@ -1187,7 +1362,7 @@ export class MinimalSubagentsCoordinator {
1187
1362
  source_agent_id: delivery.source_agent_id,
1188
1363
  destination_agent_id: delivery.destination_agent_id,
1189
1364
  source_turn_id: result.turn_id,
1190
- message_id: `result:${delivery.source_agent_id}:${result.turn_id}`,
1365
+ message_id: automaticResultMessageId(delivery.source_agent_id, result.turn_id),
1191
1366
  status: result.status,
1192
1367
  elapsed_ms: result.elapsed_ms,
1193
1368
  usage: result.usage,
@@ -1200,6 +1375,13 @@ export class MinimalSubagentsCoordinator {
1200
1375
  for (const batchedDelivery of batchedCoordinationDeliveries) {
1201
1376
  this.waitHandedDeliveryIds.add(batchedDelivery.delivery_id);
1202
1377
  }
1378
+ // Installed before delivery so a settlement inside it removes the marker. Delivery up to
1379
+ // Pi's `sendMessage` is synchronous, so the idle check predicts a steer versus a new turn.
1380
+ this.handedTerminalKeys.set(handedKey, {
1381
+ order: ++this.handOffOrder,
1382
+ queued: delivery.destination_agent_id === "root" && !this.dependencies.root.isIdle(),
1383
+ coordinationDeliveryIds: batchedCoordinationDeliveries.map((item) => item.delivery_id),
1384
+ });
1203
1385
  await this.deliverToRecipient(
1204
1386
  delivery.destination_agent_id,
1205
1387
  combineCoordinatorMessages([
@@ -1210,8 +1392,14 @@ export class MinimalSubagentsCoordinator {
1210
1392
  this.isTerminalDeliveryCurrent(delivery) &&
1211
1393
  batchedCoordinationDeliveries.every((item) => this.isCoordinationDeliveryCurrent(item)),
1212
1394
  );
1213
- });
1395
+ };
1396
+ await (resume
1397
+ ? handOff()
1398
+ : this.enqueueRecipientDelivery(delivery.destination_agent_id, handOff));
1214
1399
  } catch (error) {
1400
+ // A hand-off abandoned by a branch change must not touch the restored branch's state.
1401
+ if (epoch !== this.lifecycleEpoch) return;
1402
+ this.handedTerminalKeys.delete(handedKey);
1215
1403
  for (const batchedDelivery of batchedCoordinationDeliveries) {
1216
1404
  this.waitHandedDeliveryIds.delete(batchedDelivery.delivery_id);
1217
1405
  }
@@ -1231,7 +1419,10 @@ export class MinimalSubagentsCoordinator {
1231
1419
  }),
1232
1420
  );
1233
1421
  } finally {
1234
- this.automaticDeliveryKeys.delete(deliveryKey);
1422
+ // Restore's replay may own the key again; release it only from the call that holds it.
1423
+ if (this.automaticDeliveryKeys.get(deliveryKey) === epoch) {
1424
+ this.automaticDeliveryKeys.delete(deliveryKey);
1425
+ }
1235
1426
  }
1236
1427
  }
1237
1428
 
@@ -1317,6 +1508,7 @@ export class MinimalSubagentsCoordinator {
1317
1508
  sourceAgentId,
1318
1509
  destinationAgentId,
1319
1510
  waitHandedDeliveryIds: this.waitHandedDeliveryIds,
1511
+ handedTurnKeys: this.handedTerminalKeys,
1320
1512
  activeTurnId: agent?.active_turn_id,
1321
1513
  latestResultTurnId: agent?.latest_result?.turn_id,
1322
1514
  });
@@ -1532,6 +1724,19 @@ export class MinimalSubagentsCoordinator {
1532
1724
  }),
1533
1725
  );
1534
1726
  }
1727
+ this.dropStaleHandedTerminalKeys();
1728
+ }
1729
+
1730
+ /** Forget handed markers whose terminal delivery left the ledger by settlement, pruning, or deletion. */
1731
+ private dropStaleHandedTerminalKeys(): void {
1732
+ const pending = new Set(
1733
+ this.deliveryLedger.terminalDeliveries.map((delivery) =>
1734
+ deliveryTurnKey(delivery.source_agent_id, delivery.source_turn_id),
1735
+ ),
1736
+ );
1737
+ for (const key of this.handedTerminalKeys.keys()) {
1738
+ if (!pending.has(key)) this.handedTerminalKeys.delete(key);
1739
+ }
1535
1740
  }
1536
1741
 
1537
1742
  private isTerminalDeliveryCurrent(delivery: PersistedDelivery): boolean {
@@ -1559,6 +1764,22 @@ export class MinimalSubagentsCoordinator {
1559
1764
  );
1560
1765
  }
1561
1766
 
1767
+ /**
1768
+ * Whether this result was already delivered to the caller automatically: it is handed, or the
1769
+ * caller's branch holds its automatic result message. A wait's own tool result does not count.
1770
+ */
1771
+ private wasAlreadyDelivered(callerId: string, result: TurnResult): boolean {
1772
+ return (
1773
+ this.handedTerminalKeys.has(deliveryTurnKey(result.agent_id, result.turn_id)) ||
1774
+ this.hasRecipientDeliveryEvidence(
1775
+ callerId,
1776
+ result.agent_id,
1777
+ result.turn_id,
1778
+ automaticResultMessageId(result.agent_id, result.turn_id),
1779
+ )
1780
+ );
1781
+ }
1782
+
1562
1783
  private hasDeliveryEvidence(delivery: PersistedDelivery): boolean {
1563
1784
  return this.hasRecipientDeliveryEvidence(
1564
1785
  delivery.destination_agent_id,
@@ -1607,6 +1828,7 @@ export class MinimalSubagentsCoordinator {
1607
1828
  delivery.source_agent_id,
1608
1829
  delivery.source_turn_id,
1609
1830
  ).ledger;
1831
+ this.dropStaleHandedTerminalKeys();
1610
1832
  this.dependencies.registry.append(
1611
1833
  createRegistryEvent(this.dependencies.registry.rootSessionId, "delivery-settled", {
1612
1834
  source_agent_id: delivery.source_agent_id,
@@ -1672,6 +1894,7 @@ export class MinimalSubagentsCoordinator {
1672
1894
  private pruneDeliveryStateForDeletedAgent(agentId: string): void {
1673
1895
  const previousCoordinationDeliveries = this.deliveryLedger.coordinationDeliveries;
1674
1896
  this.deliveryLedger = pruneDeliveryLedgerAgents(this.deliveryLedger, [agentId]).ledger;
1897
+ this.dropStaleHandedTerminalKeys();
1675
1898
  for (const delivery of previousCoordinationDeliveries) {
1676
1899
  if (!this.isCoordinationDeliveryCurrent(delivery)) {
1677
1900
  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
 
@@ -17,6 +17,7 @@ import {
17
17
  type SessionShutdownEvent,
18
18
  type SessionStartEvent,
19
19
  type SessionTreeEvent,
20
+ type TurnEndEvent,
20
21
  } from "@earendil-works/pi-coding-agent";
21
22
  import { isKeyRelease, isKeyRepeat, matchesKey } from "@earendil-works/pi-tui";
22
23
  import {
@@ -182,9 +183,12 @@ function rootCallerSnapshot(pi: ExtensionAPI, context: ExtensionContext): Caller
182
183
  function createRootConversationEndpoint(
183
184
  pi: ExtensionAPI,
184
185
  context: ExtensionContext,
186
+ onSteer: () => void,
185
187
  ): RootConversationEndpoint {
186
188
  return {
187
189
  async queueCoordinatorMessage(message) {
190
+ // A running root takes the message as a clearable steer; an idle root starts a turn with it.
191
+ if (!context.isIdle()) onSteer();
188
192
  pi.sendMessage(
189
193
  {
190
194
  customType: message.customType,
@@ -517,6 +521,7 @@ export class MinimalSubagentsLifecycleController {
517
521
  private uiController: MinimalSubagentsUiController | undefined;
518
522
  private statusPanelController: MinimalSubagentsStatusPanelController | undefined;
519
523
  private viewerShortcut: ReturnType<typeof installViewerShortcut> | undefined;
524
+ private unwatchRootTurnEnds: (() => void) | undefined;
520
525
  private accessSession: ActiveSubagentAccessSession | undefined;
521
526
  private preparedFork:
522
527
  | { sourceSessionFile: string; selectedBranchSnapshot: RegistrySnapshot }
@@ -528,7 +533,10 @@ export class MinimalSubagentsLifecycleController {
528
533
  private readonly effects: MinimalSubagentsLifecycleEffects,
529
534
  ) {}
530
535
 
531
- /** Register stable tools, renderers, and the six Pi lifecycle event handlers. */
536
+ /**
537
+ * Register stable tools, renderers, and the six static Pi lifecycle event handlers. `turn_end` is
538
+ * registered only while a result waits in the root's steer queue (`watchRootTurnEnds`).
539
+ */
532
540
  register(): void {
533
541
  const rootTools = createCoordinatorToolDefinitions({
534
542
  coordinator: this.coordinatorOperations,
@@ -552,6 +560,7 @@ export class MinimalSubagentsLifecycleController {
552
560
  this.pi.on("session_before_fork", (event, context) => this.prepareSessionFork(event, context));
553
561
  this.pi.on("session_tree", (event, context) => this.restoreSessionTree(event, context));
554
562
  this.pi.on("message_end", (event, context) => this.reconcileMessageDelivery(event, context));
563
+ this.pi.on("agent_start", () => this.deliverDiscardedRootResults());
555
564
  this.pi.on("session_shutdown", (event, context) => this.shutdownSession(event, context));
556
565
  }
557
566
 
@@ -645,9 +654,10 @@ export class MinimalSubagentsLifecycleController {
645
654
  });
646
655
  activeCoordinator = new MinimalSubagentsCoordinator({
647
656
  sessions: sessionFactory,
648
- root: createRootConversationEndpoint(this.pi, context),
657
+ root: createRootConversationEndpoint(this.pi, context, () => this.watchRootTurnEnds()),
649
658
  maxSubagentDepth: minimalSubagentsConfig.maxSubagentDepth,
650
659
  toolsets: minimalSubagentsConfig.toolsets,
660
+ modelRoles: minimalSubagentsConfig.modelRoles,
651
661
  registry: {
652
662
  rootSessionId,
653
663
  append: (registryEvent) => this.pi.appendEntry(REGISTRY_ENTRY_TYPE, registryEvent),
@@ -925,16 +935,63 @@ export class MinimalSubagentsLifecycleController {
925
935
  _context: ExtensionContext,
926
936
  ): Promise<void> {
927
937
  if (!this.coordinator) return;
938
+ this.coordinator.recordRootMessageEnd();
928
939
  if (event.message.role === "toolResult" || event.message.role === "custom") {
929
940
  await this.coordinator.reconcileDeliveries();
930
941
  this.uiController?.refresh();
931
942
  }
932
943
  }
933
944
 
945
+ /**
946
+ * Watch root turn boundaries while a result waits in the root's steer queue. Pi builds a boundary
947
+ * preview for every `turn_end` while any handler listens, so the handler is removed again once no
948
+ * queued result remains.
949
+ */
950
+ private watchRootTurnEnds(): void {
951
+ this.unwatchRootTurnEnds ??= this.pi.on("turn_end", (event, context) =>
952
+ this.requeueDiscardedRootResults(event, context),
953
+ );
954
+ }
955
+
956
+ private stopWatchingRootTurnEnds(): void {
957
+ this.unwatchRootTurnEnds?.();
958
+ this.unwatchRootTurnEnds = undefined;
959
+ }
960
+
961
+ /**
962
+ * Pi gives extensions no queue-cleared event, so Esc's discarded steers are found here: the turn
963
+ * boundary's pending-message preview is empty. Returns nothing, so the transcript, system prompt,
964
+ * and tools are unchanged.
965
+ *
966
+ * A queue clear without an abort (dequeue, RPC `clear_queue`) leaves the run going, so released
967
+ * results are steered back at once and Pi's poll after this boundary takes them in the same run.
968
+ * Esc aborts the run, so its released results wait for the root's next run.
969
+ */
970
+ private requeueDiscardedRootResults(event: TurnEndEvent, context: ExtensionContext): void {
971
+ if (!this.coordinator) {
972
+ this.stopWatchingRootTurnEnds();
973
+ return;
974
+ }
975
+ if (event.context.pendingMessages.length === 0) {
976
+ this.coordinator.requeueDiscardedRootResults();
977
+ if (context.signal && !context.signal.aborted) {
978
+ this.coordinator.deliverDiscardedRootResults();
979
+ }
980
+ this.uiController?.refresh();
981
+ }
982
+ if (!this.coordinator.hasQueuedRootHandOffs()) this.stopWatchingRootTurnEnds();
983
+ }
984
+
985
+ /** Re-send discarded results into a root run that just started; recovery never starts one. */
986
+ private deliverDiscardedRootResults(): void {
987
+ this.coordinator?.deliverDiscardedRootResults();
988
+ }
989
+
934
990
  private async shutdownSession(
935
991
  event: SessionShutdownEvent,
936
992
  context: ExtensionContext,
937
993
  ): Promise<void> {
994
+ this.stopWatchingRootTurnEnds();
938
995
  this.viewerShortcut?.dispose();
939
996
  this.viewerShortcut = undefined;
940
997
  this.statusPanelController?.dispose();
@@ -56,6 +56,7 @@ const ToolSelectionWireSchema = Type.Union([
56
56
 
57
57
  /** Parses persisted immutable Launch Contracts without coercion. */
58
58
  export const RegistryLaunchContractWireSchema = Type.Object({
59
+ role: Type.Optional(NonEmptyStringSchema),
59
60
  session_context: Type.Union([
60
61
  Type.Literal("inherit"),
61
62
  Type.Literal("compact"),
@@ -1,6 +1,7 @@
1
1
  import type { JsonValue } from "@earendil-works/pi-ai";
2
2
  import { Value } from "typebox/value";
3
3
  import { COORDINATOR_TOOL_NAMES } from "./minimal-subagents-capabilities.js";
4
+ import { isModelRoleName } from "./minimal-subagents-config.js";
4
5
  import { normalizeMinimalSubagentsUsage } from "./minimal-subagents-usage.js";
5
6
  import {
6
7
  createDeliveryLedger,
@@ -304,6 +305,7 @@ function parseRegistryLaunchContract(
304
305
  ): LaunchContract | undefined {
305
306
  if (
306
307
  !isCanonicalModelId(value.model) ||
308
+ (value.role !== undefined && !isModelRoleName(value.role)) ||
307
309
  !isUniqueNonEmptyStringArray(value.ordinary_tools) ||
308
310
  !excludesCoordinatorToolNames(value.ordinary_tools) ||
309
311
  (Array.isArray(value.tools) &&
@@ -320,6 +322,7 @@ function parseRegistryLaunchContract(
320
322
  ordinary_tools: [...value.ordinary_tools],
321
323
  };
322
324
  if (value.delegation !== undefined) launchContract.delegation = value.delegation;
325
+ if (value.role !== undefined) launchContract.role = value.role;
323
326
  return launchContract;
324
327
  }
325
328
 
@@ -23,6 +23,7 @@ const RenderUsageSchema = Type.Object({
23
23
  });
24
24
 
25
25
  const RenderLaunchContractSchema = Type.Object({
26
+ role: Type.Optional(Type.String()),
26
27
  session_context: Type.Optional(Type.String()),
27
28
  project_context: Type.Optional(Type.String()),
28
29
  model: Type.Optional(Type.String()),
@@ -87,6 +88,7 @@ const SpawnCallArgumentsSchema = Type.Object({
87
88
  agent_id: Type.Optional(Type.String()),
88
89
  session_context: Type.Optional(Type.String()),
89
90
  project_context: Type.Optional(Type.String()),
91
+ role: Type.Optional(Type.String()),
90
92
  model: Type.Optional(Type.String()),
91
93
  thinking_level: Type.Optional(Type.String()),
92
94
  delegation: Type.Optional(Type.String()),
@@ -144,6 +146,7 @@ const WaitTurnDetailsSchema = Type.Object({
144
146
  error: Type.Optional(Type.String()),
145
147
  elapsed_ms: Type.Optional(Type.Number()),
146
148
  usage: Type.Optional(RenderUsageSchema),
149
+ already_delivered: Type.Optional(Type.Boolean()),
147
150
  messages: Type.Optional(Type.Array(WaitMessageDetailsSchema)),
148
151
  });
149
152
  const WaitTimeoutDetailsSchema = Type.Object({
@@ -219,6 +222,7 @@ const CoordinatorAgentDetailOutputSchema = Type.Object({
219
222
  ...CoordinatorAgentSummaryOutputSchema.properties,
220
223
  session_file: Type.Optional(Type.String()),
221
224
  launch_contract: Type.Object({
225
+ role: Type.Optional(Type.String()),
222
226
  session_context: Type.String(),
223
227
  project_context: Type.String(),
224
228
  model: Type.String(),
@@ -297,6 +301,13 @@ export const CoordinatorToolOutputSchemas = {
297
301
  messages: Type.Optional(Type.Array(CoordinatorWaitMessageOutputSchema)),
298
302
  ...CoordinatorWaitSourceOutputSchema,
299
303
  }),
304
+ Type.Object({
305
+ ...Type.Pick(CoordinatorTurnOutputSchema, ["agent_id", "turn_id", "status"]).properties,
306
+ event: Type.Literal("turn"),
307
+ already_delivered: Type.Literal(true),
308
+ messages: Type.Optional(Type.Array(CoordinatorWaitMessageOutputSchema)),
309
+ ...CoordinatorWaitSourceOutputSchema,
310
+ }),
300
311
  Type.Object({
301
312
  event: Type.Literal("timeout"),
302
313
  agent_id: Type.String(),
@@ -445,6 +445,7 @@ function spawnCallLaunchSummary(args: SpawnCallArguments): string[] {
445
445
  ? undefined
446
446
  : `tools ${Array.isArray(args.tools) ? args.tools.join(", ") || "none" : args.tools}`;
447
447
  return [
448
+ args.role ? `role ${args.role}` : undefined,
448
449
  profile,
449
450
  tools,
450
451
  args.session_context && args.session_context !== "omit"
@@ -507,6 +508,7 @@ function renderSpawnResult(
507
508
  `delegation ${launchContract?.delegation ?? args.delegation ?? "none"}`,
508
509
  `session context ${launchContract?.session_context ?? args.session_context ?? "omit"}`,
509
510
  `project context ${launchContract?.project_context ?? args.project_context ?? "inherit"}`,
511
+ launchContract?.role ? `role ${launchContract.role}` : undefined,
510
512
  resolvedModel ? `model ${resolvedModel}` : undefined,
511
513
  resolvedThinking ? `thinking ${resolvedThinking}` : undefined,
512
514
  ].filter((value): value is string => value !== undefined);
@@ -580,6 +582,9 @@ function renderWaitProgress(
580
582
  return container;
581
583
  }
582
584
 
585
+ /** Shown instead of the output of a result already delivered to the parent automatically. */
586
+ const ALREADY_DELIVERED_TEXT = "Already delivered automatically; wait with turn_id to reread it.";
587
+
583
588
  /** The collapsed body of a settled wait: what the child said, or why it stopped. */
584
589
  function collapsedWaitBody(
585
590
  details: WaitRenderDetails,
@@ -591,6 +596,9 @@ function collapsedWaitBody(
591
596
  const activity = latestProgressActivity(details.agent.recent_activity ?? []);
592
597
  return activity.length > 0 ? new ActivityLines(activity, theme) : undefined;
593
598
  }
599
+ if (details.already_delivered) {
600
+ return collapsedTextPreview(theme.fg("muted", ALREADY_DELIVERED_TEXT), theme);
601
+ }
594
602
  const output = details.output ?? "";
595
603
  if (status === "completed") {
596
604
  return output ? collapsedMarkdownPreview(output, theme) : undefined;
@@ -667,7 +675,9 @@ function renderWaitResult(
667
675
  );
668
676
  }
669
677
  const output = details.output ?? "";
670
- if (status === "completed") {
678
+ if (details.already_delivered) {
679
+ appendTextSection(container, theme, "Output", ALREADY_DELIVERED_TEXT);
680
+ } else if (status === "completed") {
671
681
  if (output.length > 0) {
672
682
  appendComponentSection(
673
683
  container,
@@ -770,6 +780,7 @@ function renderDetailedStatusAgent(
770
780
  const launchValues = [
771
781
  `session context ${launchContract.session_context ?? "inherit"}`,
772
782
  `project context ${launchContract.project_context ?? "inherit"}`,
783
+ ...(launchContract.role ? [`role ${launchContract.role}`] : []),
773
784
  `model ${launchContract.model ?? agent.model ?? "unknown"}`,
774
785
  `thinking ${launchContract.thinking_level ?? agent.thinking_level ?? "unknown"}`,
775
786
  `delegation ${launchContract.delegation ?? "none"}`,
@@ -36,6 +36,7 @@ const DeliveryEvidenceMessageSchema = Type.Object({
36
36
  /** Parses durable custom-message and wait-tool Delivery Evidence details. */
37
37
  export const DeliveryEvidenceDetailsSchema = Type.Object({
38
38
  event: Type.Optional(Type.String()),
39
+ already_delivered: Type.Optional(Type.Boolean()),
39
40
  source_agent_id: Type.String(),
40
41
  source_turn_id: Type.String(),
41
42
  delivery_id: Type.Optional(Type.String()),
@@ -503,7 +503,14 @@ export function findDeliveryEvidence(
503
503
  ))
504
504
  );
505
505
  }
506
- if (waitToolResult && details.event === "message") return false;
506
+ // A message, timeout, or already-delivered wait result never carried the turn's terminal result.
507
+ if (
508
+ waitToolResult &&
509
+ (details.event === "message" ||
510
+ details.event === "timeout" ||
511
+ details.already_delivered === true)
512
+ )
513
+ return false;
507
514
  return details.source_agent_id === sourceAgentId && details.source_turn_id === sourceTurnId;
508
515
  });
509
516
  }
@@ -26,6 +26,13 @@ const ToolSelectionSchema = Type.Union(
26
26
  'Configurable presets: "read" adds readToolset; "modify" adds readToolset plus modifyToolset. Defaults: read, grep, find, ls; modify additionally grants bash, edit, write. Configured baseToolset applies to all selections, including "none" and exact arrays. Omit to inherit the caller\'s active ordinary tools plus base. All grants stay within the caller\'s capability ceiling; coordinator tools are injected separately.',
27
27
  },
28
28
  );
29
+ // Deliberately a plain string rather than an enum of the configured roles: settings can change, and
30
+ // the tool definition must stay byte-identical for the session so provider prompt caches survive.
31
+ const ModelRoleSchema = Type.String({
32
+ minLength: 1,
33
+ description:
34
+ "Name of a configured model role (minimalSubagents.modelRoles) listed in the Subagent tool guidelines. Resolves to the role's model and, when the role has one, its thinking level. An explicit model or thinking_level overrides the role's value. An unknown role is rejected with the configured role names.",
35
+ });
29
36
  const FRIENDLY_AGENT_ID_PATTERN = "^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$";
30
37
  const CANONICAL_AGENT_ID_PATTERN =
31
38
  "^(?:root\\.)?[A-Za-z0-9][A-Za-z0-9_-]{0,63}(?:\\.[A-Za-z0-9][A-Za-z0-9_-]{0,63})*$";
@@ -55,6 +62,7 @@ export function createCoordinatorToolSchemas(modelIds: readonly string[]) {
55
62
  ),
56
63
  session_context: Type.Optional(SessionContextSchema),
57
64
  project_context: Type.Optional(ProjectContextSchema),
65
+ role: Type.Optional(ModelRoleSchema),
58
66
  model: Type.Optional(explicitModelSchema),
59
67
  thinking_level: Type.Optional(ThinkingLevelSchema),
60
68
  tools: Type.Optional(ToolSelectionSchema),
@@ -38,6 +38,7 @@ import type {
38
38
  DeleteResult,
39
39
  SpawnResult,
40
40
  StatusResult,
41
+ WaitDeliveredTurnResult,
41
42
  WaitResult,
42
43
  } from "./minimal-subagents-types.js";
43
44
 
@@ -82,8 +83,11 @@ function buildModelRolePromptGuidelines(
82
83
  return ` - ${role.name} → model=${role.model}${thinkingGuidance}${role.hint ? ` — ${role.hint}` : ""}`;
83
84
  });
84
85
  return [
85
- ["Configured model roles are guidance, not constraints:", ...roleLines].join("\n"),
86
- "Choose a model based on the task. A listed thinking_level is a preference, not a constraint. Callers choose thinking_level independently for roles without one.",
86
+ [
87
+ "Configured model roles are guidance, not constraints. Pass role to launch with a role's model and thinking_level; an explicit model or thinking_level overrides the role's value:",
88
+ ...roleLines,
89
+ ].join("\n"),
90
+ "Choose a role or model based on the task. A listed thinking_level is a preference, not a constraint. Callers choose thinking_level independently for roles without one.",
87
91
  ];
88
92
  }
89
93
 
@@ -194,6 +198,22 @@ function structuredToolResult<TDetails extends CoordinatorToolResultDetails>(
194
198
  };
195
199
  }
196
200
 
201
+ /**
202
+ * One line saying an already-delivered result is not repeated and how to reread it, followed by
203
+ * any Coordination Messages the wait drained, which the parent has not seen yet.
204
+ */
205
+ function alreadyDeliveredContent(
206
+ result: WaitDeliveredTurnResult,
207
+ ): AgentToolResult<WaitResult>["content"] {
208
+ const notice = `Result of ${result.agent_id} turn ${result.turn_id} (${result.status}) was already delivered automatically; call subagent_wait with turn_id "${result.turn_id}" to reread it.`;
209
+ if (!result.messages) return [{ type: "text", text: notice }];
210
+ const messages = truncateHead(JSON.stringify({ messages: result.messages }, null, 2), {
211
+ maxBytes: DEFAULT_MAX_BYTES,
212
+ maxLines: DEFAULT_MAX_LINES,
213
+ });
214
+ return [{ type: "text", text: `${notice}\n${messages.content}` }];
215
+ }
216
+
197
217
  /**
198
218
  * Report a failed operation that still carries declared output: the model sees an error and
199
219
  * codemode scripts receive `structuredContent` instead of a data-less rejection.
@@ -310,7 +330,7 @@ export function createCoordinatorToolDefinitions(
310
330
  name: "subagent_wait",
311
331
  label: "Subagent Wait",
312
332
  description:
313
- "Wait for one direct child's oldest observable turn, or select an exact retained turn_id. An active child may first return event=message; later unconsumed items still fall back automatically. A settled turn returns event=turn, with any queued messages in messages; waiting again returns the same result. Timeout returns event=timeout with detailed child status and never cancels the child.",
333
+ "Wait for one direct child's oldest observable turn whose result you have neither claimed nor received, or select an exact retained turn_id. An active child may first return event=message; later unconsumed items still fall back automatically. A settled turn returns event=turn, with any queued messages in messages; waiting again returns a result you claimed in full. Without turn_id, a result already delivered to you automatically returns event=turn with already_delivered=true and no output; pass its turn_id to reread it. Timeout returns event=timeout with detailed child status and never cancels the child.",
314
334
  promptSnippet: "Wait for one direct child's exact turn",
315
335
  parameters: options.schemas.subagent_wait,
316
336
  annotations: {
@@ -355,7 +375,10 @@ export function createCoordinatorToolDefinitions(
355
375
  source_turn_id: result.turn_id,
356
376
  };
357
377
  return {
358
- ...structuredToolResult(result),
378
+ content:
379
+ "already_delivered" in result
380
+ ? alreadyDeliveredContent(result)
381
+ : structuredToolResult(result).content,
359
382
  details,
360
383
  structuredContent: JSON.parse(JSON.stringify(details)),
361
384
  };
@@ -1,6 +1,7 @@
1
1
  import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core";
2
2
  import type { Usage } from "@earendil-works/pi-ai";
3
3
  import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
4
+ import type { MinimalSubagentsModelRole } from "./minimal-subagents-config.js";
4
5
 
5
6
  /** Controls how much committed caller conversation enters a new child session. */
6
7
  export type SessionContextMode = "inherit" | "compact" | "omit";
@@ -35,6 +36,8 @@ export interface SpawnParameters {
35
36
  agent_id?: string;
36
37
  session_context?: SessionContextMode;
37
38
  project_context?: ProjectContextMode;
39
+ /** Configured model role whose model and thinking level apply unless overridden below. */
40
+ role?: string;
38
41
  model?: string;
39
42
  thinking_level?: ThinkingLevel;
40
43
  tools?: ToolSelection;
@@ -95,6 +98,19 @@ export interface WaitTurnResult extends TurnResult {
95
98
  messages?: WaitMessageResult[];
96
99
  }
97
100
 
101
+ /**
102
+ * Reports that a default wait selected a terminal turn whose result was already delivered
103
+ * automatically, handed or settled, so the output is not repeated; an explicit `turn_id` rereads it.
104
+ */
105
+ export interface WaitDeliveredTurnResult extends Pick<
106
+ TurnResult,
107
+ "agent_id" | "turn_id" | "status"
108
+ > {
109
+ event: "turn";
110
+ already_delivered: true;
111
+ messages?: WaitMessageResult[];
112
+ }
113
+
98
114
  /** Reports an observational wait timeout with the current detailed child status. */
99
115
  export interface WaitTimeoutResult {
100
116
  event: "timeout";
@@ -107,7 +123,11 @@ export interface WaitTimeoutResult {
107
123
  }
108
124
 
109
125
  /** Reports one message, terminal turn, or timeout returned by subagent_wait. */
110
- export type WaitResult = WaitMessageResult | WaitTurnResult | WaitTimeoutResult;
126
+ export type WaitResult =
127
+ | WaitMessageResult
128
+ | WaitTurnResult
129
+ | WaitDeliveredTurnResult
130
+ | WaitTimeoutResult;
111
131
 
112
132
  /** Provides bounded hierarchy, usage, and best-known Runtime Profile data for one persistent agent. */
113
133
  export interface AgentSummary extends RuntimeProfile {
@@ -199,6 +219,8 @@ export interface DeleteResult {
199
219
 
200
220
  /** Persists immutable context, model, thinking, and ordinary-tool launch choices. */
201
221
  export interface LaunchContract extends RuntimeProfile {
222
+ /** Configured model role the caller named at launch; absent when none was used. */
223
+ role?: string;
202
224
  session_context: SessionContextMode;
203
225
  project_context: ProjectContextMode;
204
226
  tools: ToolSelection | undefined;
@@ -424,6 +446,7 @@ export interface CoordinatorDependencies {
424
446
  root: RootConversationEndpoint;
425
447
  maxSubagentDepth?: number;
426
448
  toolsets?: MinimalSubagentsToolsets;
449
+ modelRoles?: readonly MinimalSubagentsModelRole[];
427
450
  now?: () => Date;
428
451
  automaticDeliveryGraceMs?: number;
429
452
  notify?: (notification: CoordinatorNotification) => void;