@ian-pascoe/pi-minimal-subagents 0.1.0 → 0.2.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
@@ -45,15 +45,15 @@ Project values override global values. Run `/reload` after editing either file.
45
45
  `minimalSubagents.modelRoles` gives the parent agent advisory names for
46
46
  eligible models. The extension defines no roles itself, performs no task
47
47
  classification, and does not route launches. The parent still passes the
48
- ordinary `model` argument and chooses `thinking_level` independently.
48
+ ordinary `model` and `thinking_level` arguments separately.
49
49
 
50
50
  ```json
51
51
  {
52
52
  "minimalSubagents": {
53
53
  "modelRoles": {
54
- "budget": "opencode-go/glm-5.2",
54
+ "budget": "opencode-go/glm-5.2:low",
55
55
  "design": {
56
- "model": "opencode-go/kimi-k3",
56
+ "model": "opencode-go/kimi-k3:high",
57
57
  "hint": "UI design, visual critique, and frontend polish"
58
58
  }
59
59
  }
@@ -61,10 +61,18 @@ ordinary `model` argument and chooses `thinking_level` independently.
61
61
  }
62
62
  ```
63
63
 
64
+ A recognized final suffix (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`,
65
+ or `max`) is a preferred `thinking_level`, not part of the canonical model
66
+ passed to `subagent`. Unsuffixed roles leave thinking selection independent.
64
67
  Role names and hints are trimmed, single-line text. Names may be up to 64
65
68
  characters and hints up to 500 characters. Models use canonical
66
69
  `provider/model` IDs and must be available under the effective `enabledModels`
67
- scope. Thinking-level suffixes such as `:xhigh` are invalid here.
70
+ scope. The resolver matches the complete authored model ID first, so real
71
+ colon-bearing IDs—including IDs ending in `:high`—remain exact model IDs;
72
+ only an otherwise-unmatched recognized final suffix is treated as a thinking
73
+ preference. A thinking level pinned in `enabledModels` neither supplies nor
74
+ constrains a role preference, and normal spawn-time model-capability clamping
75
+ still applies.
68
76
 
69
77
  Global and project roles merge by name in settings order. Expanded role
70
78
  objects merge by field; a project string replaces the whole global entry. A
@@ -122,8 +130,68 @@ children: `subagent`, `agent_message`, `subagent_wait`, `subagent_status`,
122
130
  only the three adjacent-coordination tools: `agent_message`, `subagent_wait`,
123
131
  and `subagent_status`.
124
132
 
125
- Deleting a child first uses the optional `trash` command when available and
126
- falls back to unlinking its session file. Each Child Agent has a persistent
127
- JSONL session. Append-only Root Agent Registry entries retain hierarchy and
128
- Delivery Evidence across reloads. Forking cancels and drains active work, then
129
- clones child session leaves so the fork receives an independent hierarchy.
133
+ The `subagent` `tools` argument distinguishes capability presets from exact
134
+ lists: `"read"` grants `read`, `grep`, `find`, and `ls`; `"modify"` adds
135
+ `bash`, `edit`, and `write`; an array such as `["read"]` grants exactly the
136
+ named ordinary tool and does not expand a preset. Coordinator tools are
137
+ injected separately according to delegation and must not appear in `tools`;
138
+ misuse returns an actionable error. Use the string preset when a child needs
139
+ the complete read-only discovery bundle.
140
+
141
+ `agent_message` reports whether a message was delivered through an active
142
+ parent wait, queued for the recipient, or failed. `subagent_wait` can return an
143
+ intermediate Wait Event containing a Coordination Message before the child turn
144
+ settles; call it again for the terminal turn result. Pass optional `turn_id` to
145
+ address an older retained turn exactly. Without it, waits select the oldest
146
+ observable claimed or pending turn before the active/latest turn. A caller may
147
+ have only one outstanding wait for the same source turn; a concurrent duplicate
148
+ is rejected instead of competing for one Wait Event.
149
+
150
+ The persisted Delivery Ledger records Coordination Messages, terminal results,
151
+ globally increasing sequence, and wait ownership before delivery. Existing
152
+ items retain their sequence; gaps from skipped malformed records are valid.
153
+ Claims can name only active, latest, or retained turns. Once a wait returns an
154
+ intermediate message, that wait path owns the rest of the source turn across
155
+ reloads, forks, and newer turns. Automatic fallback retains its ordered queue
156
+ reservation, treats idle notifications as advisory, and rechecks actual
157
+ recipient idleness before injecting a message. Destination-session Delivery
158
+ Evidence settles and compacts ledger items, preventing duplicate delivery and
159
+ unbounded checkpoint growth. The pure Delivery Ledger state machine retains at
160
+ most 20 pending wait-only terminal results per source agent; Coordination
161
+ Messages are not removed by that terminal-retention limit. Delivered messages
162
+ include stable delivery, source-agent, and source-turn identities in persisted
163
+ details.
164
+
165
+ Deleting a child first verifies its session header and persistent identity,
166
+ then uses the optional `trash` command when available and falls back to
167
+ unlinking its session file. Deletion prunes pending delivery state and retained
168
+ recent-message projections sourced from the complete deleted subtree. Restore
169
+ and clone perform the same ownership check and reopen the recorded child-session
170
+ leaf.
171
+
172
+ Registry replay and Delivery Evidence are scoped to the Root Agent's active
173
+ session-tree branch. Registry writes use V2 records with complete field,
174
+ identity, sequence, hierarchy, adjacency, destination, ordinary-tool ceiling,
175
+ and coordinator-tool exclusion validation. Every available V2 agent has a
176
+ selected leaf; only unavailable recovery placeholders may omit it. Valid V1
177
+ records and checkpoints migrate during replay; invalid owned records are
178
+ skipped with semantic diagnostic codes rather than disabling the extension.
179
+ Persisted message activity carries an explicit `recorded_at` from the
180
+ coordinator clock.
181
+
182
+ `/tree` abandons old process-local work and restores the selected branch. Fork
183
+ preparation is read-only; only confirmed fork shutdown interrupts work and
184
+ clones the selected branch, so another extension can cancel a fork without
185
+ freezing coordinator tools. Each clone records a new generation-specific
186
+ identity/provenance pair, and the destination appends ownership for that clone's
187
+ current session ID rather than reusing inherited ownership. If a
188
+ process-local fork handoff is lost, recovery reads only the destination's
189
+ selected branch and proceeds only when its canonical `parentSession` proves the
190
+ source file; it never substitutes the source session's newer head.
191
+
192
+ ## Status and TUI
193
+
194
+ Visible Child Agent rows show the canonical `provider/model:thinking` Runtime
195
+ Profile. Status uses the live Runtime Profile while a runtime exists and falls
196
+ back to the immutable Launch Contract otherwise. Live changes are observational:
197
+ they do not rewrite persistence or change nested spawn defaults.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ian-pascoe/pi-minimal-subagents",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "description": "Persistent nested subagents with bounded delegation for Pi",
6
6
  "keywords": [
@@ -29,10 +29,6 @@
29
29
  "access": "public",
30
30
  "provenance": true
31
31
  },
32
- "scripts": {
33
- "test": "vitest run --config ../../vitest.config.ts --root .",
34
- "typecheck": "tsc --noEmit -p tsconfig.json"
35
- },
36
32
  "peerDependencies": {
37
33
  "@earendil-works/pi-agent-core": "*",
38
34
  "@earendil-works/pi-ai": "*",
@@ -47,5 +43,9 @@
47
43
  "extensions": [
48
44
  "./src/index.ts"
49
45
  ]
46
+ },
47
+ "scripts": {
48
+ "test": "vitest run --config ../../vitest.config.ts --root .",
49
+ "typecheck": "tsc --noEmit -p tsconfig.json"
50
50
  }
51
- }
51
+ }
@@ -16,8 +16,6 @@ export const COORDINATOR_TOOL_NAMES = [
16
16
 
17
17
  const READ_TOOL_BUNDLE = ["read", "grep", "find", "ls"];
18
18
  const MODIFY_TOOL_BUNDLE = [...READ_TOOL_BUNDLE, "bash", "edit", "write"];
19
- const THINKING_SUFFIX_PATTERN = /:(?:off|minimal|low|medium|high|xhigh|max)$/;
20
-
21
19
  interface ModelReference {
22
20
  provider: string;
23
21
  id: string;
@@ -28,11 +26,6 @@ interface ScopedModelReference {
28
26
  thinkingLevel?: string;
29
27
  }
30
28
 
31
- /** Remove a recognized Pi thinking suffix without changing model IDs containing other colons. */
32
- export function stripThinkingSuffix(modelPattern: string): string {
33
- return modelPattern.replace(THINKING_SUFFIX_PATTERN, "");
34
- }
35
-
36
29
  /** Build the authenticated runtime model enum from Pi's already-resolved model scope. */
37
30
  export function buildEligibleModelIds(input: {
38
31
  availableModels: readonly ModelReference[];
@@ -47,7 +40,7 @@ export function buildEligibleModelIds(input: {
47
40
  const result: string[] = [];
48
41
 
49
42
  for (const model of source) {
50
- const canonicalId = stripThinkingSuffix(`${model.provider}/${model.id}`);
43
+ const canonicalId = `${model.provider}/${model.id}`;
51
44
  if (!seen.has(canonicalId)) {
52
45
  seen.add(canonicalId);
53
46
  result.push(canonicalId);
@@ -80,6 +73,13 @@ export function resolveOrdinaryToolSelection(
80
73
  ? MODIFY_TOOL_BUNDLE
81
74
  : selection;
82
75
  const uniqueRequested = [...new Set(requested)];
76
+ const coordinatorTools = new Set<string>(COORDINATOR_TOOL_NAMES);
77
+ const requestedCoordinatorTools = uniqueRequested.filter((name) => coordinatorTools.has(name));
78
+ if (requestedCoordinatorTools.length > 0) {
79
+ throw new Error(
80
+ `Minimal subagents ordinary tool selection: coordinator tools are injected separately and must not appear in tools: ${requestedCoordinatorTools.join(", ")}`,
81
+ );
82
+ }
83
83
  const available = new Set(context.availableTools);
84
84
  const ceiling = new Set(context.capabilityCeiling);
85
85
  const missing = uniqueRequested.filter((name) => !available.has(name));
@@ -1,15 +1,41 @@
1
- import {
2
- DEFAULT_MAX_SUBAGENT_DEPTH,
3
- stripThinkingSuffix,
4
- } from "./minimal-subagents-capabilities.js";
1
+ import type { JsonValue } from "@earendil-works/pi-ai";
2
+ import type { SettingsManager } from "@earendil-works/pi-coding-agent";
3
+ import { type Static, Type } from "typebox";
4
+ import { Value } from "typebox/value";
5
+ import { DEFAULT_MAX_SUBAGENT_DEPTH, THINKING_LEVELS } from "./minimal-subagents-capabilities.js";
5
6
 
6
7
  const MODEL_ROLE_NAME_MAX_LENGTH = 64;
7
8
  const MODEL_ROLE_HINT_MAX_LENGTH = 500;
8
9
 
10
+ const JsonValueSchema = Type.Unsafe<JsonValue>({});
11
+ const SettingsDocumentSchema = Type.Object({
12
+ minimalSubagents: Type.Optional(JsonValueSchema),
13
+ });
14
+ const MinimalSubagentsSettingsSchema = Type.Object({
15
+ maxSubagentDepth: Type.Optional(JsonValueSchema),
16
+ modelRoles: Type.Optional(JsonValueSchema),
17
+ });
18
+ const JsonObjectSchema = Type.Record(Type.String(), JsonValueSchema);
19
+ const PositiveSafeIntegerSchema = Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER });
20
+ const MaxSubagentDepthSettingSchema = Type.Union([PositiveSafeIntegerSchema, Type.Null()]);
21
+ const ShorthandModelRoleSchema = Type.String();
22
+ const ExpandedModelRoleSchema = Type.Object(
23
+ {
24
+ model: Type.String(),
25
+ hint: Type.Optional(Type.String()),
26
+ },
27
+ { additionalProperties: false },
28
+ );
29
+ const ModelRoleEntriesSchema = Type.Record(Type.String(), JsonValueSchema);
30
+ const ModelRolesSettingSchema = Type.Union([ModelRoleEntriesSchema, Type.Null()]);
31
+
32
+ type ModelRoleThinkingLevel = (typeof THINKING_LEVELS)[number];
33
+
9
34
  /** Describes one user-authored advisory model role shown to subagent callers. */
10
35
  export interface MinimalSubagentsModelRole {
11
36
  name: string;
12
37
  model: string;
38
+ thinkingLevel?: ModelRoleThinkingLevel;
13
39
  hint?: string;
14
40
  }
15
41
 
@@ -20,75 +46,190 @@ export interface ResolvedMinimalSubagentsConfig {
20
46
  warnings: string[];
21
47
  }
22
48
 
49
+ interface MinimalSubagentsSettingsDocument {
50
+ minimalSubagents?: JsonValue;
51
+ }
52
+
23
53
  interface MinimalSubagentsConfigInput {
24
- globalSettings: unknown;
25
- projectSettings: unknown;
54
+ globalSettings: MinimalSubagentsSettingsDocument;
55
+ projectSettings: MinimalSubagentsSettingsDocument;
26
56
  eligibleModelIds: readonly string[];
27
57
  }
28
58
 
59
+ type PiSettingsDocument = ReturnType<SettingsManager["getGlobalSettings"]>;
60
+ type MinimalSubagentsSettingsDocumentInput = PiSettingsDocument | MinimalSubagentsSettingsDocument;
61
+
29
62
  interface MinimalSubagentsSettingsReader {
30
- getGlobalSettings(): unknown;
31
- getProjectSettings(): unknown;
63
+ getGlobalSettings(): MinimalSubagentsSettingsDocumentInput;
64
+ getProjectSettings(): MinimalSubagentsSettingsDocumentInput;
32
65
  }
33
66
 
34
67
  type SettingsScope = "global" | "project";
35
68
 
36
69
  interface ScopedSettingValue {
37
70
  scope: SettingsScope;
38
- value: unknown;
71
+ value: ModelRoleWireValue;
72
+ }
73
+
74
+ interface ParsedMinimalSubagentsSettings {
75
+ maxSubagentDepth?: MaxSubagentDepthWireValue;
76
+ modelRoles?: ModelRolesWireValue;
77
+ }
78
+
79
+ type MaxSubagentDepthWireValue =
80
+ | { kind: "depth"; value: number }
81
+ | { kind: "reset" }
82
+ | { kind: "invalid" };
83
+
84
+ type ModelRoleWireValue =
85
+ | { kind: "delete" }
86
+ | { kind: "shorthand"; model: string }
87
+ | { kind: "expanded"; fields: Static<typeof ExpandedModelRoleSchema> }
88
+ | { kind: "malformed-expanded"; fields: Record<string, JsonValue> }
89
+ | { kind: "invalid" };
90
+
91
+ type ModelRolesWireValue =
92
+ | { kind: "reset" }
93
+ | { kind: "entries"; entries: ReadonlyMap<string, ModelRoleWireValue> }
94
+ | { kind: "invalid" };
95
+
96
+ function parseMaxSubagentDepthWireValue(value: JsonValue): MaxSubagentDepthWireValue {
97
+ if (!Value.Check(MaxSubagentDepthSettingSchema, value)) return { kind: "invalid" };
98
+ return value === null ? { kind: "reset" } : { kind: "depth", value };
99
+ }
100
+
101
+ function parseModelRoleWireValue(value: JsonValue): ModelRoleWireValue {
102
+ if (value === null) return { kind: "delete" };
103
+ if (Value.Check(ShorthandModelRoleSchema, value)) return { kind: "shorthand", model: value };
104
+ if (Value.Check(JsonObjectSchema, value)) {
105
+ return Value.Check(ExpandedModelRoleSchema, value)
106
+ ? { kind: "expanded", fields: value }
107
+ : { kind: "malformed-expanded", fields: value };
108
+ }
109
+ return { kind: "invalid" };
39
110
  }
40
111
 
41
- function isRecord(value: unknown): value is Record<string, unknown> {
42
- return typeof value === "object" && value !== null && !Array.isArray(value);
112
+ function isExpandedModelRoleWireValue(
113
+ value: ModelRoleWireValue,
114
+ ): value is Extract<ModelRoleWireValue, { kind: "expanded" | "malformed-expanded" }> {
115
+ return value.kind === "expanded" || value.kind === "malformed-expanded";
116
+ }
117
+
118
+ function parseModelRolesWireValue(value: JsonValue): ModelRolesWireValue {
119
+ if (!Value.Check(ModelRolesSettingSchema, value)) return { kind: "invalid" };
120
+ if (value === null) return { kind: "reset" };
121
+ return {
122
+ kind: "entries",
123
+ entries: new Map(
124
+ Object.entries(value).map(([name, roleValue]) => [name, parseModelRoleWireValue(roleValue)]),
125
+ ),
126
+ };
127
+ }
128
+
129
+ function parsePiSettingsDocument(
130
+ settings: MinimalSubagentsSettingsDocumentInput,
131
+ ): MinimalSubagentsSettingsDocument {
132
+ if (!Value.Check(SettingsDocumentSchema, settings)) return {};
133
+ const parsed: MinimalSubagentsSettingsDocument = {};
134
+ if (settings.minimalSubagents !== undefined) {
135
+ parsed.minimalSubagents = settings.minimalSubagents;
136
+ }
137
+ return parsed;
43
138
  }
44
139
 
45
140
  function readMinimalSubagentsSettings(
46
- settings: unknown,
141
+ settings: MinimalSubagentsSettingsDocument,
47
142
  scope: SettingsScope,
48
143
  warnings: string[],
49
- ): Record<string, unknown> {
50
- if (!isRecord(settings) || settings.minimalSubagents === undefined) return {};
51
- if (isRecord(settings.minimalSubagents)) return settings.minimalSubagents;
144
+ ): ParsedMinimalSubagentsSettings {
145
+ if (!Value.Check(SettingsDocumentSchema, settings)) return {};
146
+ const minimalSubagents = settings.minimalSubagents;
147
+ if (minimalSubagents === undefined) return {};
148
+ if (Value.Check(MinimalSubagentsSettingsSchema, minimalSubagents)) {
149
+ const parsed: ParsedMinimalSubagentsSettings = {};
150
+ if (minimalSubagents.maxSubagentDepth !== undefined) {
151
+ parsed.maxSubagentDepth = parseMaxSubagentDepthWireValue(minimalSubagents.maxSubagentDepth);
152
+ }
153
+ if (minimalSubagents.modelRoles !== undefined) {
154
+ parsed.modelRoles = parseModelRolesWireValue(minimalSubagents.modelRoles);
155
+ }
156
+ return parsed;
157
+ }
52
158
  warnings.push(`${scope} minimalSubagents: expected an object`);
53
159
  return {};
54
160
  }
55
161
 
56
162
  function mergeModelRoleEntries(
57
- globalValue: unknown,
58
- projectValue: unknown,
163
+ globalValue: ModelRolesWireValue | undefined,
164
+ projectValue: ModelRolesWireValue | undefined,
59
165
  warnings: string[],
60
166
  ): Map<string, ScopedSettingValue> {
61
167
  const entries = new Map<string, ScopedSettingValue>();
62
- if (globalValue !== undefined) {
63
- if (isRecord(globalValue)) {
64
- for (const [name, value] of Object.entries(globalValue)) {
65
- entries.set(name, { scope: "global", value });
66
- }
67
- } else if (globalValue !== null) {
68
- warnings.push("global minimalSubagents.modelRoles: expected an object or null");
168
+ if (globalValue?.kind === "entries") {
169
+ for (const [name, value] of globalValue.entries) {
170
+ entries.set(name, { scope: "global", value });
69
171
  }
172
+ } else if (globalValue?.kind === "invalid") {
173
+ warnings.push("global minimalSubagents.modelRoles: expected an object or null");
70
174
  }
71
- if (projectValue === null) return new Map();
175
+ if (projectValue?.kind === "reset") return new Map();
72
176
  if (projectValue === undefined) return entries;
73
- if (!isRecord(projectValue)) {
177
+ if (projectValue.kind === "invalid") {
74
178
  warnings.push("project minimalSubagents.modelRoles: expected an object or null");
75
179
  return entries;
76
180
  }
181
+ if (projectValue.kind !== "entries") return entries;
77
182
 
78
- for (const [name, value] of Object.entries(projectValue)) {
79
- if (value === null) {
183
+ for (const [name, value] of projectValue.entries) {
184
+ if (value.kind === "delete") {
80
185
  entries.delete(name);
81
186
  continue;
82
187
  }
83
188
  const inherited = entries.get(name)?.value;
84
- entries.set(name, {
85
- scope: "project",
86
- value: isRecord(inherited) && isRecord(value) ? { ...inherited, ...value } : value,
87
- });
189
+ const mergedValue =
190
+ inherited !== undefined &&
191
+ isExpandedModelRoleWireValue(inherited) &&
192
+ isExpandedModelRoleWireValue(value)
193
+ ? parseModelRoleWireValue({ ...inherited.fields, ...value.fields })
194
+ : value;
195
+ entries.set(name, { scope: "project", value: mergedValue });
88
196
  }
89
197
  return entries;
90
198
  }
91
199
 
200
+ interface ResolvedModelRoleReference {
201
+ model: string;
202
+ thinkingLevel?: ModelRoleThinkingLevel;
203
+ }
204
+
205
+ function resolveThinkingLevelSuffix(suffix: string): ModelRoleThinkingLevel | undefined {
206
+ return THINKING_LEVELS.find((thinkingLevel) => thinkingLevel === suffix);
207
+ }
208
+
209
+ function resolveModelRoleReference(
210
+ model: string,
211
+ eligibleModels: ReadonlySet<string>,
212
+ path: string,
213
+ warnings: string[],
214
+ ): ResolvedModelRoleReference | undefined {
215
+ if (eligibleModels.has(model)) return { model };
216
+
217
+ const separatorIndex = model.lastIndexOf(":");
218
+ if (separatorIndex >= 0) {
219
+ const prefix = model.slice(0, separatorIndex);
220
+ const suffix = model.slice(separatorIndex + 1);
221
+ if (eligibleModels.has(prefix)) {
222
+ const thinkingLevel = resolveThinkingLevelSuffix(suffix);
223
+ if (thinkingLevel !== undefined) return { model: prefix, thinkingLevel };
224
+ warnings.push(`${path}: unknown thinking level suffix: ${suffix}`);
225
+ return undefined;
226
+ }
227
+ }
228
+
229
+ warnings.push(`${path}: model is not eligible: ${model}`);
230
+ return undefined;
231
+ }
232
+
92
233
  function parseModelRoles(
93
234
  entries: ReadonlyMap<string, ScopedSettingValue>,
94
235
  eligibleModelIds: readonly string[],
@@ -109,70 +250,78 @@ function parseModelRoles(
109
250
  }
110
251
 
111
252
  const value = entry.value;
112
- if (isRecord(value)) {
113
- const unknownFields = Object.keys(value).filter((key) => key !== "model" && key !== "hint");
114
- if (unknownFields.length > 0) {
115
- warnings.push(`${path}: unknown field: ${unknownFields.join(", ")}`);
253
+ const expandedRoleObject = isExpandedModelRoleWireValue(value) ? value.fields : undefined;
254
+ if (expandedRoleObject !== undefined) {
255
+ const invalidFields = Object.keys(expandedRoleObject).filter(
256
+ (key) => key !== "model" && key !== "hint",
257
+ );
258
+ if (invalidFields.length > 0) {
259
+ warnings.push(`${path}: unknown field: ${invalidFields.join(", ")}`);
116
260
  continue;
117
261
  }
118
- } else if (typeof value !== "string") {
262
+ } else if (value.kind !== "shorthand") {
119
263
  warnings.push(`${path}: expected a model string or expanded role object`);
120
264
  continue;
121
265
  }
122
266
 
123
- const model = typeof value === "string" ? value : value.model;
124
- if (typeof model !== "string" || model.length === 0 || model !== model.trim()) {
267
+ const modelValue =
268
+ expandedRoleObject === undefined && value.kind === "shorthand"
269
+ ? value.model
270
+ : expandedRoleObject?.model;
271
+ if (
272
+ !Value.Check(ShorthandModelRoleSchema, modelValue) ||
273
+ modelValue.length === 0 ||
274
+ modelValue !== modelValue.trim()
275
+ ) {
125
276
  warnings.push(`${path}: model must be a non-empty trimmed string`);
126
277
  continue;
127
278
  }
128
- if (stripThinkingSuffix(model) !== model) {
129
- warnings.push(
130
- `${path}: thinking level suffixes are not allowed; choose thinking_level per spawn`,
131
- );
132
- continue;
133
- }
134
- if (!eligibleModels.has(model)) {
135
- warnings.push(`${path}: model is not eligible: ${model}`);
136
- continue;
137
- }
279
+ const resolvedModel = resolveModelRoleReference(modelValue, eligibleModels, path, warnings);
280
+ if (resolvedModel === undefined) continue;
138
281
 
139
- const hint = isRecord(value) ? value.hint : undefined;
282
+ const hintValue = expandedRoleObject?.hint;
140
283
  if (
141
- hint !== undefined &&
142
- (typeof hint !== "string" ||
143
- hint.length === 0 ||
144
- hint !== hint.trim() ||
145
- /[\r\n]/.test(hint) ||
146
- hint.length > MODEL_ROLE_HINT_MAX_LENGTH)
284
+ hintValue !== undefined &&
285
+ (!Value.Check(ShorthandModelRoleSchema, hintValue) ||
286
+ hintValue.length === 0 ||
287
+ hintValue !== hintValue.trim() ||
288
+ /[\r\n]/.test(hintValue) ||
289
+ hintValue.length > MODEL_ROLE_HINT_MAX_LENGTH)
147
290
  ) {
148
291
  warnings.push(`${path}.hint: expected trimmed single-line text up to 500 characters`);
149
292
  continue;
150
293
  }
151
- roles.push({ name, model, ...(hint === undefined ? {} : { hint }) });
294
+ const role: MinimalSubagentsModelRole = {
295
+ name,
296
+ model: resolvedModel.model,
297
+ };
298
+ if (resolvedModel.thinkingLevel !== undefined) {
299
+ role.thinkingLevel = resolvedModel.thinkingLevel;
300
+ }
301
+ if (hintValue !== undefined && Value.Check(ShorthandModelRoleSchema, hintValue)) {
302
+ role.hint = hintValue;
303
+ }
304
+ roles.push(role);
152
305
  }
153
306
  return roles;
154
307
  }
155
308
 
156
309
  function resolveMaxSubagentDepth(
157
- globalValue: unknown,
158
- projectValue: unknown,
310
+ globalValue: MaxSubagentDepthWireValue | undefined,
311
+ projectValue: MaxSubagentDepthWireValue | undefined,
159
312
  warnings: string[],
160
313
  ): number {
161
314
  let resolvedDepth = DEFAULT_MAX_SUBAGENT_DEPTH;
162
- if (globalValue !== undefined && globalValue !== null) {
163
- if (Number.isSafeInteger(globalValue) && Number(globalValue) > 0) {
164
- resolvedDepth = Number(globalValue);
165
- } else {
166
- warnings.push(
167
- "global minimalSubagents.maxSubagentDepth: expected a positive safe integer or null",
168
- );
169
- }
315
+ if (globalValue?.kind === "depth") {
316
+ resolvedDepth = globalValue.value;
317
+ } else if (globalValue?.kind === "invalid") {
318
+ warnings.push(
319
+ "global minimalSubagents.maxSubagentDepth: expected a positive safe integer or null",
320
+ );
170
321
  }
171
322
  if (projectValue === undefined) return resolvedDepth;
172
- if (projectValue === null) return DEFAULT_MAX_SUBAGENT_DEPTH;
173
- if (Number.isSafeInteger(projectValue) && Number(projectValue) > 0) {
174
- return Number(projectValue);
175
- }
323
+ if (projectValue.kind === "reset") return DEFAULT_MAX_SUBAGENT_DEPTH;
324
+ if (projectValue.kind === "depth") return projectValue.value;
176
325
  warnings.push(
177
326
  "project minimalSubagents.maxSubagentDepth: expected a positive safe integer or null",
178
327
  );
@@ -210,8 +359,8 @@ export function resolveMinimalSubagentsSettings(
210
359
  eligibleModelIds: readonly string[],
211
360
  ): ResolvedMinimalSubagentsConfig {
212
361
  return resolveMinimalSubagentsConfig({
213
- globalSettings: settings.getGlobalSettings(),
214
- projectSettings: settings.getProjectSettings(),
362
+ globalSettings: parsePiSettingsDocument(settings.getGlobalSettings()),
363
+ projectSettings: parsePiSettingsDocument(settings.getProjectSettings()),
215
364
  eligibleModelIds,
216
365
  });
217
366
  }
@@ -11,11 +11,17 @@ export function snapshotCommittedContext(
11
11
  return structuredClone(committed);
12
12
  }
13
13
 
14
+ /** Carries the selected caller messages and whether child preparation should compact them. */
15
+ export interface ImportedSubagentContext {
16
+ messages: AgentMessage[];
17
+ compact: boolean;
18
+ }
19
+
14
20
  /** Select the imported message snapshot and defer expensive compact preparation to the child turn. */
15
21
  export function assembleImportedContext(
16
22
  mode: SessionContextMode,
17
23
  committedMessages: AgentMessage[],
18
- ): { messages: AgentMessage[]; compact: boolean } {
24
+ ): ImportedSubagentContext {
19
25
  if (mode === "omit") return { messages: [], compact: false };
20
26
  return { messages: committedMessages, compact: mode === "compact" };
21
27
  }