@ian-pascoe/pi-minimal-subagents 0.13.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.
package/README.md CHANGED
@@ -39,9 +39,13 @@ 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.
43
44
  3. `agent_message` continues an idle child: the result reports
44
- `disposition: "started-turn"` with the new `turn_id` to wait on.
45
+ `disposition: "started-turn"` with the new `turn_id`. A plain
46
+ `subagent_wait({ agent_id })` targets that new turn, not the first result;
47
+ pass `turn_id` to re-read a turn whose result is still retained, claimed or
48
+ not.
45
49
  4. `subagent_status` inspects children; `subagent_cancel` stops work but keeps
46
50
  the session; `subagent_delete` removes it. Deleted IDs cannot be reused.
47
51
 
@@ -93,10 +97,10 @@ source branch, and a new branch without an override follows settings.
93
97
 
94
98
  ## Model roles
95
99
 
96
- `minimalSubagents.modelRoles` gives the parent agent advisory names for
100
+ `minimalSubagents.modelRoles` gives the parent agent named, advisory choices of
97
101
  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.
102
+ classification, and does not route launches: the parent names the role it wants
103
+ with the optional `role` argument of `subagent`.
100
104
 
101
105
  ```json
102
106
  {
@@ -113,8 +117,9 @@ ordinary `model` and `thinking_level` arguments separately.
113
117
  ```
114
118
 
115
119
  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.
120
+ or `max`) is the role's `thinking_level`, not part of the canonical model.
121
+ Unsuffixed roles leave thinking selection independent: the child uses the
122
+ caller's thinking level unless `thinking_level` is passed.
118
123
  Role names and hints are trimmed, single-line text. Names may be up to 64
119
124
  characters and hints up to 500 characters. Models use canonical
120
125
  `provider/model` IDs and must be available under the effective `enabledModels`
@@ -125,6 +130,27 @@ preference. A thinking level pinned in `enabledModels` neither supplies nor
125
130
  constrains a role preference, and normal spawn-time model-capability clamping
126
131
  still applies.
127
132
 
133
+ ### Launching with a role
134
+
135
+ `subagent({ task, role: "design" })` launches the child with the `design` role's
136
+ model and, when the role has a suffix, its thinking level. An explicit `model` or
137
+ `thinking_level` overrides the role's value independently, so
138
+ `{ role: "design", thinking_level: "low" }` keeps the role's model with a
139
+ different thinking level. The role is recorded even when explicit `model` and
140
+ `thinking_level` override all of its values. An unknown `role` fails before any
141
+ child is created and lists the configured role names. The Launch Contract
142
+ records the resolved `model` and `thinking_level` together with the `role` used
143
+ (`role` is absent when none was named), and `subagent_status` and the expanded
144
+ `subagent` result show it. Recording the name does not tie the child to the role: later settings
145
+ changes never alter an existing Launch Contract.
146
+
147
+ Roles are read when the session starts, so editing `modelRoles` mid-session
148
+ changes nothing until a reload. The `role` parameter is a plain string rather
149
+ than an enum of the configured names, so the tool definition stays
150
+ byte-identical when `modelRoles` changes. Only the role list in the system
151
+ prompt (the `subagent` prompt guidelines) changes, and only at reload. The
152
+ `model` enum is separate: it already follows the eligible models.
153
+
128
154
  Global and project roles merge by name in settings order. Expanded role
129
155
  objects merge by field; a project string replaces the whole global entry. A
130
156
  project can remove one inherited role with `null`, or clear all inherited
@@ -310,10 +336,13 @@ intermediate Wait Event containing a Coordination Message before the child turn
310
336
  settles. That event claims only its message, so later unconsumed messages and the
311
337
  terminal result retain automatic fallback. If the turn has already settled, a
312
338
  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
339
+ again for the same settled turn returns the same result while it is retained. Pass
340
+ optional `turn_id` to address a retained turn exactly, including one whose
341
+ result you already claimed (a claimed result stays retained until Delivery
342
+ Evidence settles it). Without it, waits skip claimed turns and turns whose
343
+ result was already delivered to you automatically, and select the oldest
344
+ remaining observable turn, falling back to the active, then latest, turn. A
345
+ caller may have only one outstanding wait for the same source turn; a
317
346
  concurrent duplicate is rejected instead of competing for one Wait Event.
318
347
  When `timeout_ms` expires, the wait returns an observational `event: "timeout"`
319
348
  with the requested turn identity and the same detailed Child Agent status used
@@ -321,20 +350,26 @@ by targeted `subagent_status`. It removes only the waiter, leaving the child
321
350
  running and all pending delivery unclaimed. Abort signals remain errors.
322
351
 
323
352
  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.
353
+ globally increasing sequence, and wait ownership before delivery. Existing items
354
+ retain their sequence; gaps from skipped malformed records are valid. Claims can
355
+ name only active, latest, or retained turns. Wait-returned messages retain
356
+ individual delivery evidence; terminal wait ownership remains durable across
357
+ reloads, forks, and newer turns. Automatic fallback retains its ordered queue
358
+ reservation while batching queued messages from one source turn into one Pi
359
+ steer. Root-bound messages remain batchable while the root turn is active; a
360
+ pending terminal result absorbs them. If Pi discards a root-bound steer before
361
+ consuming it, as Esc does, the next root turn boundary reporting an empty Pi
362
+ queue releases the result: a default wait selects it again at once, and
363
+ automatic fallback re-sends it, ahead of newer results, into the root run if it
364
+ is still going, or, after an abort such as Esc, when the root's next run starts.
365
+ Recovery never starts a root turn by itself. Child sessions drain all available
366
+ steers before the next model call. Destination-session Delivery Evidence still
367
+ settles each batched ledger item independently, preventing duplicate delivery
368
+ and unbounded checkpoint growth. The pure Delivery Ledger state machine retains
369
+ at most 20 pending wait-only terminal results per source agent; Coordination
370
+ Messages are not removed by that terminal-retention limit. Delivered messages
371
+ include stable delivery, source-agent, and source-turn identities in persisted
372
+ details.
338
373
 
339
374
  Deleting a child first verifies its session header and persistent identity,
340
375
  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.0",
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,
@@ -109,6 +111,16 @@ interface PendingParentMessage {
109
111
  cancelGrace?: () => void;
110
112
  }
111
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
+
112
124
  function agentDeliveryKey(agentId: string, turnId: string): string {
113
125
  return `${agentId}\u0000${turnId}`;
114
126
  }
@@ -162,9 +174,18 @@ export class MinimalSubagentsCoordinator {
162
174
  private readonly waiters = new Map<string, Set<TurnWaiter>>();
163
175
  private readonly pendingParentMessages = new Map<string, PendingParentMessage[]>();
164
176
  private readonly recipientQueues = new Map<string, Promise<unknown>>();
165
- 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>();
166
179
  private readonly automaticCoordinationDeliveryIds = new Set<string>();
167
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>();
168
189
  private readonly backgroundOperations = new Set<Promise<void>>();
169
190
  private acceptingOperations = true;
170
191
  private lifecycleEpoch = 0;
@@ -222,8 +243,10 @@ export class MinimalSubagentsCoordinator {
222
243
 
223
244
  const sessionContext = parameters.session_context ?? "omit";
224
245
  const projectContext = parameters.project_context ?? "inherit";
225
- const model = parameters.model ?? caller.model;
226
- 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;
227
250
  const thinkingLevel = this.dependencies.sessions.resolveThinkingLevel(model, requestedThinking);
228
251
  const {
229
252
  ordinaryTools,
@@ -268,6 +291,8 @@ export class MinimalSubagentsCoordinator {
268
291
  missing_dependencies: [],
269
292
  recent_messages: [],
270
293
  };
294
+ // Added after `delegation`, matching the key order of replayed Launch Contracts.
295
+ if (role) agent.launch_contract.role = role.name;
271
296
  this.pendingAgentIds.add(agentId);
272
297
  let identity: ReturnType<AgentSessionFactory["createIdentity"]>;
273
298
  try {
@@ -352,6 +377,19 @@ export class MinimalSubagentsCoordinator {
352
377
  return result;
353
378
  }
354
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
+ );
391
+ }
392
+
355
393
  /** Capture immutable launch defaults for a nested caller from its active child runtime. */
356
394
  snapshotChildCaller(agentId: string, spawnEntryId: string): CallerSnapshot {
357
395
  const agent = this.requireAgent(agentId);
@@ -456,7 +494,7 @@ export class MinimalSubagentsCoordinator {
456
494
  if (agent.active_turn_id !== turnId) {
457
495
  return Promise.reject(
458
496
  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`,
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`,
460
498
  ),
461
499
  );
462
500
  }
@@ -720,6 +758,8 @@ export class MinimalSubagentsCoordinator {
720
758
  this.waiters.clear();
721
759
  this.waitHandedDeliveryIds.clear();
722
760
  this.automaticDeliveryKeys.clear();
761
+ this.handedTerminalKeys.clear();
762
+ this.discardedRootResultKeys.clear();
723
763
  this.automaticCoordinationDeliveryIds.clear();
724
764
  this.deliveryLedger = createDeliveryLedger({
725
765
  deliveries: snapshot.deliveries,
@@ -864,6 +904,102 @@ export class MinimalSubagentsCoordinator {
864
904
  await Promise.allSettled(scheduled);
865
905
  }
866
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
+
867
1003
  /** Clone complete child leaves for root fork ownership without ever sharing source session paths. */
868
1004
  async prepareFork(sourceRootSessionFile: string): Promise<ForkSnapshot> {
869
1005
  const activeRootChildren = this.childrenOf("root");
@@ -1157,17 +1293,27 @@ export class MinimalSubagentsCoordinator {
1157
1293
  if (result.status !== "completed") this.removeSettledEmptyTurnClaim(agent.agent_id, turnId);
1158
1294
  }
1159
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
+ */
1160
1303
  private async deliverAutomaticResult(
1161
1304
  result: TurnResult,
1162
1305
  delivery: PersistedDelivery,
1306
+ resume = false,
1163
1307
  ): Promise<void> {
1164
1308
  const deliveryKey = agentDeliveryKey(delivery.source_agent_id, delivery.source_turn_id);
1309
+ const handedKey = deliveryTurnKey(delivery.source_agent_id, delivery.source_turn_id);
1165
1310
  if (this.automaticDeliveryKeys.has(deliveryKey)) return;
1166
- this.automaticDeliveryKeys.add(deliveryKey);
1167
- const graceMs = this.deliveryGraceMs();
1311
+ const epoch = this.lifecycleEpoch;
1312
+ this.automaticDeliveryKeys.set(deliveryKey, epoch);
1313
+ const graceMs = resume ? 0 : this.deliveryGraceMs();
1168
1314
  let batchedCoordinationDeliveries: PersistedCoordinationDelivery[] = [];
1169
1315
  try {
1170
- await this.enqueueRecipientDelivery(delivery.destination_agent_id, async () => {
1316
+ const handOff = async () => {
1171
1317
  if (delivery.destination_agent_id !== "root") {
1172
1318
  await this.ensureRuntime(
1173
1319
  this.requireUsableAgent(delivery.destination_agent_id, "message"),
@@ -1175,6 +1321,8 @@ export class MinimalSubagentsCoordinator {
1175
1321
  if (!this.isTerminalDeliveryCurrent(delivery)) return;
1176
1322
  }
1177
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;
1178
1326
  if (!this.acceptingOperations || this.shouldStopAutomaticTerminalDelivery(delivery)) return;
1179
1327
  if (this.hasDeliveryEvidence(delivery)) {
1180
1328
  this.settleDelivery(delivery);
@@ -1200,6 +1348,13 @@ export class MinimalSubagentsCoordinator {
1200
1348
  for (const batchedDelivery of batchedCoordinationDeliveries) {
1201
1349
  this.waitHandedDeliveryIds.add(batchedDelivery.delivery_id);
1202
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
+ });
1203
1358
  await this.deliverToRecipient(
1204
1359
  delivery.destination_agent_id,
1205
1360
  combineCoordinatorMessages([
@@ -1210,8 +1365,14 @@ export class MinimalSubagentsCoordinator {
1210
1365
  this.isTerminalDeliveryCurrent(delivery) &&
1211
1366
  batchedCoordinationDeliveries.every((item) => this.isCoordinationDeliveryCurrent(item)),
1212
1367
  );
1213
- });
1368
+ };
1369
+ await (resume
1370
+ ? handOff()
1371
+ : this.enqueueRecipientDelivery(delivery.destination_agent_id, handOff));
1214
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);
1215
1376
  for (const batchedDelivery of batchedCoordinationDeliveries) {
1216
1377
  this.waitHandedDeliveryIds.delete(batchedDelivery.delivery_id);
1217
1378
  }
@@ -1231,7 +1392,10 @@ export class MinimalSubagentsCoordinator {
1231
1392
  }),
1232
1393
  );
1233
1394
  } finally {
1234
- 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
+ }
1235
1399
  }
1236
1400
  }
1237
1401
 
@@ -1317,6 +1481,7 @@ export class MinimalSubagentsCoordinator {
1317
1481
  sourceAgentId,
1318
1482
  destinationAgentId,
1319
1483
  waitHandedDeliveryIds: this.waitHandedDeliveryIds,
1484
+ handedTurnKeys: this.handedTerminalKeys,
1320
1485
  activeTurnId: agent?.active_turn_id,
1321
1486
  latestResultTurnId: agent?.latest_result?.turn_id,
1322
1487
  });
@@ -1532,6 +1697,19 @@ export class MinimalSubagentsCoordinator {
1532
1697
  }),
1533
1698
  );
1534
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
+ }
1535
1713
  }
1536
1714
 
1537
1715
  private isTerminalDeliveryCurrent(delivery: PersistedDelivery): boolean {
@@ -1607,6 +1785,7 @@ export class MinimalSubagentsCoordinator {
1607
1785
  delivery.source_agent_id,
1608
1786
  delivery.source_turn_id,
1609
1787
  ).ledger;
1788
+ this.dropStaleHandedTerminalKeys();
1610
1789
  this.dependencies.registry.append(
1611
1790
  createRegistryEvent(this.dependencies.registry.rootSessionId, "delivery-settled", {
1612
1791
  source_agent_id: delivery.source_agent_id,
@@ -1672,6 +1851,7 @@ export class MinimalSubagentsCoordinator {
1672
1851
  private pruneDeliveryStateForDeletedAgent(agentId: string): void {
1673
1852
  const previousCoordinationDeliveries = this.deliveryLedger.coordinationDeliveries;
1674
1853
  this.deliveryLedger = pruneDeliveryLedgerAgents(this.deliveryLedger, [agentId]).ledger;
1854
+ this.dropStaleHandedTerminalKeys();
1675
1855
  for (const delivery of previousCoordinationDeliveries) {
1676
1856
  if (!this.isCoordinationDeliveryCurrent(delivery)) {
1677
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
 
@@ -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()),
@@ -219,6 +221,7 @@ const CoordinatorAgentDetailOutputSchema = Type.Object({
219
221
  ...CoordinatorAgentSummaryOutputSchema.properties,
220
222
  session_file: Type.Optional(Type.String()),
221
223
  launch_contract: Type.Object({
224
+ role: Type.Optional(Type.String()),
222
225
  session_context: Type.String(),
223
226
  project_context: Type.String(),
224
227
  model: 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);
@@ -770,6 +772,7 @@ function renderDetailedStatusAgent(
770
772
  const launchValues = [
771
773
  `session context ${launchContract.session_context ?? "inherit"}`,
772
774
  `project context ${launchContract.project_context ?? "inherit"}`,
775
+ ...(launchContract.role ? [`role ${launchContract.role}`] : []),
773
776
  `model ${launchContract.model ?? agent.model ?? "unknown"}`,
774
777
  `thinking ${launchContract.thinking_level ?? agent.thinking_level ?? "unknown"}`,
775
778
  `delegation ${launchContract.delegation ?? "none"}`,
@@ -503,7 +503,9 @@ export function findDeliveryEvidence(
503
503
  ))
504
504
  );
505
505
  }
506
- if (waitToolResult && details.event === "message") return false;
506
+ // A message or timeout wait result never carried the turn's terminal result.
507
+ if (waitToolResult && (details.event === "message" || details.event === "timeout"))
508
+ return false;
507
509
  return details.source_agent_id === sourceAgentId && details.source_turn_id === sourceTurnId;
508
510
  });
509
511
  }
@@ -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),
@@ -82,8 +82,11 @@ function buildModelRolePromptGuidelines(
82
82
  return ` - ${role.name} → model=${role.model}${thinkingGuidance}${role.hint ? ` — ${role.hint}` : ""}`;
83
83
  });
84
84
  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.",
85
+ [
86
+ "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:",
87
+ ...roleLines,
88
+ ].join("\n"),
89
+ "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
90
  ];
88
91
  }
89
92
 
@@ -310,7 +313,7 @@ export function createCoordinatorToolDefinitions(
310
313
  name: "subagent_wait",
311
314
  label: "Subagent Wait",
312
315
  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.",
316
+ "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 the same result. Timeout returns event=timeout with detailed child status and never cancels the child.",
314
317
  promptSnippet: "Wait for one direct child's exact turn",
315
318
  parameters: options.schemas.subagent_wait,
316
319
  annotations: {
@@ -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;
@@ -199,6 +202,8 @@ export interface DeleteResult {
199
202
 
200
203
  /** Persists immutable context, model, thinking, and ordinary-tool launch choices. */
201
204
  export interface LaunchContract extends RuntimeProfile {
205
+ /** Configured model role the caller named at launch; absent when none was used. */
206
+ role?: string;
202
207
  session_context: SessionContextMode;
203
208
  project_context: ProjectContextMode;
204
209
  tools: ToolSelection | undefined;
@@ -424,6 +429,7 @@ export interface CoordinatorDependencies {
424
429
  root: RootConversationEndpoint;
425
430
  maxSubagentDepth?: number;
426
431
  toolsets?: MinimalSubagentsToolsets;
432
+ modelRoles?: readonly MinimalSubagentsModelRole[];
427
433
  now?: () => Date;
428
434
  automaticDeliveryGraceMs?: number;
429
435
  notify?: (notification: CoordinatorNotification) => void;