@ian-pascoe/pi-minimal-subagents 0.1.1 → 0.2.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
@@ -130,11 +130,64 @@ children: `subagent`, `agent_message`, `subagent_wait`, `subagent_status`,
130
130
  only the three adjacent-coordination tools: `agent_message`, `subagent_wait`,
131
131
  and `subagent_status`.
132
132
 
133
- Deleting a child first uses the optional `trash` command when available and
134
- falls back to unlinking its session file. Each Child Agent has a persistent
135
- JSONL session. Append-only Root Agent Registry entries retain hierarchy and
136
- Delivery Evidence across reloads. Forking cancels and drains active work, then
137
- 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.
138
191
 
139
192
  ## Status and TUI
140
193
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ian-pascoe/pi-minimal-subagents",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "private": false,
5
5
  "description": "Persistent nested subagents with bounded delegation for Pi",
6
6
  "keywords": [
@@ -36,18 +36,7 @@ export function buildEligibleModelIds(input: {
36
36
  const source = scopeConfigured
37
37
  ? input.scopedModels.map((entry) => entry.model)
38
38
  : input.availableModels;
39
- const seen = new Set<string>();
40
- const result: string[] = [];
41
-
42
- for (const model of source) {
43
- const canonicalId = `${model.provider}/${model.id}`;
44
- if (!seen.has(canonicalId)) {
45
- seen.add(canonicalId);
46
- result.push(canonicalId);
47
- }
48
- }
49
-
50
- return result;
39
+ return [...new Set(source.map(({ provider, id }) => `${provider}/${id}`))];
51
40
  }
52
41
 
53
42
  /** Supplies inherited tools, the ancestor ceiling, and runtime availability for exact tool resolution. */
@@ -73,6 +62,13 @@ export function resolveOrdinaryToolSelection(
73
62
  ? MODIFY_TOOL_BUNDLE
74
63
  : selection;
75
64
  const uniqueRequested = [...new Set(requested)];
65
+ const coordinatorTools = new Set<string>(COORDINATOR_TOOL_NAMES);
66
+ const requestedCoordinatorTools = uniqueRequested.filter((name) => coordinatorTools.has(name));
67
+ if (requestedCoordinatorTools.length > 0) {
68
+ throw new Error(
69
+ `Minimal subagents ordinary tool selection: coordinator tools are injected separately and must not appear in tools: ${requestedCoordinatorTools.join(", ")}`,
70
+ );
71
+ }
76
72
  const available = new Set(context.availableTools);
77
73
  const ceiling = new Set(context.capabilityCeiling);
78
74
  const missing = uniqueRequested.filter((name) => !available.has(name));
@@ -1,8 +1,34 @@
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";
1
5
  import { DEFAULT_MAX_SUBAGENT_DEPTH, THINKING_LEVELS } from "./minimal-subagents-capabilities.js";
2
6
 
3
7
  const MODEL_ROLE_NAME_MAX_LENGTH = 64;
4
8
  const MODEL_ROLE_HINT_MAX_LENGTH = 500;
5
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
+
6
32
  type ModelRoleThinkingLevel = (typeof THINKING_LEVELS)[number];
7
33
 
8
34
  /** Describes one user-authored advisory model role shown to subagent callers. */
@@ -20,71 +46,142 @@ 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: MinimalSubagentsSettingsDocumentInput;
55
+ projectSettings: MinimalSubagentsSettingsDocumentInput;
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" };
110
+ }
111
+
112
+ function isExpandedModelRoleWireValue(
113
+ value: ModelRoleWireValue,
114
+ ): value is Extract<ModelRoleWireValue, { kind: "expanded" | "malformed-expanded" }> {
115
+ return value.kind === "expanded" || value.kind === "malformed-expanded";
39
116
  }
40
117
 
41
- function isRecord(value: unknown): value is Record<string, unknown> {
42
- return typeof value === "object" && value !== null && !Array.isArray(value);
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
+ };
43
127
  }
44
128
 
45
129
  function readMinimalSubagentsSettings(
46
- settings: unknown,
130
+ settings: MinimalSubagentsSettingsDocumentInput,
47
131
  scope: SettingsScope,
48
132
  warnings: string[],
49
- ): Record<string, unknown> {
50
- if (!isRecord(settings) || settings.minimalSubagents === undefined) return {};
51
- if (isRecord(settings.minimalSubagents)) return settings.minimalSubagents;
133
+ ): ParsedMinimalSubagentsSettings {
134
+ if (!Value.Check(SettingsDocumentSchema, settings)) return {};
135
+ const minimalSubagents = settings.minimalSubagents;
136
+ if (minimalSubagents === undefined) return {};
137
+ if (Value.Check(MinimalSubagentsSettingsSchema, minimalSubagents)) {
138
+ const parsed: ParsedMinimalSubagentsSettings = {};
139
+ if (minimalSubagents.maxSubagentDepth !== undefined) {
140
+ parsed.maxSubagentDepth = parseMaxSubagentDepthWireValue(minimalSubagents.maxSubagentDepth);
141
+ }
142
+ if (minimalSubagents.modelRoles !== undefined) {
143
+ parsed.modelRoles = parseModelRolesWireValue(minimalSubagents.modelRoles);
144
+ }
145
+ return parsed;
146
+ }
52
147
  warnings.push(`${scope} minimalSubagents: expected an object`);
53
148
  return {};
54
149
  }
55
150
 
56
151
  function mergeModelRoleEntries(
57
- globalValue: unknown,
58
- projectValue: unknown,
152
+ globalValue: ModelRolesWireValue | undefined,
153
+ projectValue: ModelRolesWireValue | undefined,
59
154
  warnings: string[],
60
155
  ): Map<string, ScopedSettingValue> {
61
156
  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");
157
+ if (globalValue?.kind === "entries") {
158
+ for (const [name, value] of globalValue.entries) {
159
+ entries.set(name, { scope: "global", value });
69
160
  }
161
+ } else if (globalValue?.kind === "invalid") {
162
+ warnings.push("global minimalSubagents.modelRoles: expected an object or null");
70
163
  }
71
- if (projectValue === null) return new Map();
164
+ if (projectValue?.kind === "reset") return new Map();
72
165
  if (projectValue === undefined) return entries;
73
- if (!isRecord(projectValue)) {
166
+ if (projectValue.kind === "invalid") {
74
167
  warnings.push("project minimalSubagents.modelRoles: expected an object or null");
75
168
  return entries;
76
169
  }
170
+ if (projectValue.kind !== "entries") return entries;
77
171
 
78
- for (const [name, value] of Object.entries(projectValue)) {
79
- if (value === null) {
172
+ for (const [name, value] of projectValue.entries) {
173
+ if (value.kind === "delete") {
80
174
  entries.delete(name);
81
175
  continue;
82
176
  }
83
177
  const inherited = entries.get(name)?.value;
84
- entries.set(name, {
85
- scope: "project",
86
- value: isRecord(inherited) && isRecord(value) ? { ...inherited, ...value } : value,
87
- });
178
+ const mergedValue =
179
+ inherited !== undefined &&
180
+ isExpandedModelRoleWireValue(inherited) &&
181
+ isExpandedModelRoleWireValue(value)
182
+ ? parseModelRoleWireValue({ ...inherited.fields, ...value.fields })
183
+ : value;
184
+ entries.set(name, { scope: "project", value: mergedValue });
88
185
  }
89
186
  return entries;
90
187
  }
@@ -142,69 +239,78 @@ function parseModelRoles(
142
239
  }
143
240
 
144
241
  const value = entry.value;
145
- if (isRecord(value)) {
146
- const unknownFields = Object.keys(value).filter((key) => key !== "model" && key !== "hint");
147
- if (unknownFields.length > 0) {
148
- warnings.push(`${path}: unknown field: ${unknownFields.join(", ")}`);
242
+ const expandedRoleObject = isExpandedModelRoleWireValue(value) ? value.fields : undefined;
243
+ if (expandedRoleObject !== undefined) {
244
+ const invalidFields = Object.keys(expandedRoleObject).filter(
245
+ (key) => key !== "model" && key !== "hint",
246
+ );
247
+ if (invalidFields.length > 0) {
248
+ warnings.push(`${path}: unknown field: ${invalidFields.join(", ")}`);
149
249
  continue;
150
250
  }
151
- } else if (typeof value !== "string") {
251
+ } else if (value.kind !== "shorthand") {
152
252
  warnings.push(`${path}: expected a model string or expanded role object`);
153
253
  continue;
154
254
  }
155
255
 
156
- const model = typeof value === "string" ? value : value.model;
157
- if (typeof model !== "string" || model.length === 0 || model !== model.trim()) {
256
+ const modelValue =
257
+ expandedRoleObject === undefined && value.kind === "shorthand"
258
+ ? value.model
259
+ : expandedRoleObject?.model;
260
+ if (
261
+ !Value.Check(ShorthandModelRoleSchema, modelValue) ||
262
+ modelValue.length === 0 ||
263
+ modelValue !== modelValue.trim()
264
+ ) {
158
265
  warnings.push(`${path}: model must be a non-empty trimmed string`);
159
266
  continue;
160
267
  }
161
- const resolvedModel = resolveModelRoleReference(model, eligibleModels, path, warnings);
268
+ const resolvedModel = resolveModelRoleReference(modelValue, eligibleModels, path, warnings);
162
269
  if (resolvedModel === undefined) continue;
163
270
 
164
- const hint = isRecord(value) ? value.hint : undefined;
271
+ const hintValue = expandedRoleObject?.hint;
165
272
  if (
166
- hint !== undefined &&
167
- (typeof hint !== "string" ||
168
- hint.length === 0 ||
169
- hint !== hint.trim() ||
170
- /[\r\n]/.test(hint) ||
171
- hint.length > MODEL_ROLE_HINT_MAX_LENGTH)
273
+ hintValue !== undefined &&
274
+ (!Value.Check(ShorthandModelRoleSchema, hintValue) ||
275
+ hintValue.length === 0 ||
276
+ hintValue !== hintValue.trim() ||
277
+ /[\r\n]/.test(hintValue) ||
278
+ hintValue.length > MODEL_ROLE_HINT_MAX_LENGTH)
172
279
  ) {
173
280
  warnings.push(`${path}.hint: expected trimmed single-line text up to 500 characters`);
174
281
  continue;
175
282
  }
176
- roles.push({
283
+ const role: MinimalSubagentsModelRole = {
177
284
  name,
178
285
  model: resolvedModel.model,
179
- ...(resolvedModel.thinkingLevel === undefined
180
- ? {}
181
- : { thinkingLevel: resolvedModel.thinkingLevel }),
182
- ...(hint === undefined ? {} : { hint }),
183
- });
286
+ };
287
+ if (resolvedModel.thinkingLevel !== undefined) {
288
+ role.thinkingLevel = resolvedModel.thinkingLevel;
289
+ }
290
+ if (hintValue !== undefined && Value.Check(ShorthandModelRoleSchema, hintValue)) {
291
+ role.hint = hintValue;
292
+ }
293
+ roles.push(role);
184
294
  }
185
295
  return roles;
186
296
  }
187
297
 
188
298
  function resolveMaxSubagentDepth(
189
- globalValue: unknown,
190
- projectValue: unknown,
299
+ globalValue: MaxSubagentDepthWireValue | undefined,
300
+ projectValue: MaxSubagentDepthWireValue | undefined,
191
301
  warnings: string[],
192
302
  ): number {
193
303
  let resolvedDepth = DEFAULT_MAX_SUBAGENT_DEPTH;
194
- if (globalValue !== undefined && globalValue !== null) {
195
- if (Number.isSafeInteger(globalValue) && Number(globalValue) > 0) {
196
- resolvedDepth = Number(globalValue);
197
- } else {
198
- warnings.push(
199
- "global minimalSubagents.maxSubagentDepth: expected a positive safe integer or null",
200
- );
201
- }
304
+ if (globalValue?.kind === "depth") {
305
+ resolvedDepth = globalValue.value;
306
+ } else if (globalValue?.kind === "invalid") {
307
+ warnings.push(
308
+ "global minimalSubagents.maxSubagentDepth: expected a positive safe integer or null",
309
+ );
202
310
  }
203
311
  if (projectValue === undefined) return resolvedDepth;
204
- if (projectValue === null) return DEFAULT_MAX_SUBAGENT_DEPTH;
205
- if (Number.isSafeInteger(projectValue) && Number(projectValue) > 0) {
206
- return Number(projectValue);
207
- }
312
+ if (projectValue.kind === "reset") return DEFAULT_MAX_SUBAGENT_DEPTH;
313
+ if (projectValue.kind === "depth") return projectValue.value;
208
314
  warnings.push(
209
315
  "project minimalSubagents.maxSubagentDepth: expected a positive safe integer or null",
210
316
  );
@@ -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
  }