@ian-pascoe/pi-minimal-subagents 0.7.1 → 0.8.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
@@ -154,6 +154,64 @@ child runtimes. A deliberately non-settling agent can therefore delay reload
154
154
  indefinitely. The new limit controls restored tool availability and future
155
155
  spawn attempts; the root retains recursive hierarchy management.
156
156
 
157
+ ## Toolsets
158
+
159
+ Configure ordinary tools using case-sensitive minimatch patterns, with the same
160
+ syntax as CodeMode Exposure Patterns. These are lists of pattern strings, not
161
+ CodeMode exposure-rule objects. For example:
162
+
163
+ ```json
164
+ {
165
+ "minimalSubagents": {
166
+ "baseToolset": ["context_*"],
167
+ "readToolset": ["read", "grep", "find", "ls"],
168
+ "modifyToolset": ["bash", "edit", "write", "lsp", "dap"]
169
+ }
170
+ }
171
+ ```
172
+
173
+ The defaults are `baseToolset: []`,
174
+ `readToolset: ["read", "grep", "find", "ls"]`, and
175
+ `modifyToolset: ["bash", "edit", "write"]`. Each configured array replaces that
176
+ key's inherited value; `[]` clears it. Omitted keys inherit the global value or
177
+ built-in default. Only trusted project settings apply.
178
+
179
+ Tool Presets are cumulative:
180
+
181
+ - `tools: "read"`: Base Toolset + `readToolset`.
182
+ - `tools: "modify"`: Base Toolset + `readToolset` + `modifyToolset`.
183
+ - `tools: "none"` or `tools: []`: Base Toolset only.
184
+ - `tools: ["read"]`: Base Toolset + exactly `read`; arrays do not expand patterns
185
+ or presets.
186
+ - Omitted `tools`: Base Toolset + the caller's inherited ordinary tools.
187
+
188
+ Patterns select from permitted ordinary tool names, including inactive tools
189
+ registered at the root. Their matches are unioned in pattern order, retaining
190
+ registry order within each pattern and removing duplicates at first occurrence.
191
+ Each pattern is independent: a negated minimatch pattern matches its complement;
192
+ it does not subtract earlier matches or implement CodeMode's last-rule-wins
193
+ exposure policy. Coordinator Tools remain separately controlled by delegation.
194
+
195
+ Invalid configuration entries and patterns matching no permitted ordinary tools
196
+ warn and are skipped. Configured tools unavailable in child resources are also
197
+ skipped with a warning, rather than blocking launch. Explicit and inherited tool
198
+ requests remain strict. A child never gains capabilities beyond its parent's
199
+ ceiling, including when a restored parent predates a newly configured Base
200
+ Toolset. Use status to inspect the concrete grant if an optional plugin is absent.
201
+
202
+ Pattern expansion happens when a Child Agent is created. `/reload` applies
203
+ settings to future launches without changing existing Launch Contracts. If a tool
204
+ in an existing contract later disappears, normal restoration dependency checks
205
+ still apply; the saved grant is not silently rewritten. Toolsets configure names,
206
+ not tool operations: granting `lsp`, `dap`, or another multifunction tool grants
207
+ that tool's available operations, regardless of preset name.
208
+
209
+ CodeMode still controls whether a granted tool is direct, CodeMode-only, or both.
210
+ If a child needs CodeMode-only tools, also grant `codemode_*` in its toolsets so
211
+ it has the tools needed to discover and call them. This does not bypass CodeMode's
212
+ own restrictions on nested calls. Exposure rules also apply to injected
213
+ Coordinator Tools; delegation still determines which Coordinator Tools are granted.
214
+
157
215
  ## Capabilities and persistence
158
216
 
159
217
  Child sessions are persistent Pi sessions. Their launch contracts bound model,
@@ -172,13 +230,11 @@ text, reasoning, tool calls, and tool results. It includes the current streaming
172
230
  assistant message but omits image data. Timeout Wait Events include the same
173
231
  detailed status snapshot.
174
232
 
175
- The `subagent` `tools` argument distinguishes capability presets from exact
176
- lists: `"read"` grants `read`, `grep`, `find`, and `ls`; `"modify"` adds
177
- `bash`, `edit`, and `write`; an array such as `["read"]` grants exactly the
178
- named ordinary tool and does not expand a preset. Coordinator tools are
179
- injected separately according to delegation and must not appear in `tools`;
180
- misuse returns an actionable error. Use the string preset when a child needs
181
- the complete discovery bundle. Child sessions load the Root Agent's
233
+ The `subagent` `tools` argument distinguishes configurable Tool Presets from
234
+ exact lists; every selection also receives the permitted Base Toolset described
235
+ above. Coordinator tools are injected separately according to delegation and
236
+ must not appear in explicit `tools` arrays; misuse returns an actionable error.
237
+ Child sessions load the Root Agent's
182
238
  configured settings and extensions, excluding the recursive
183
239
  `pi-minimal-subagents` entrypoint. Project Context controls only project-scoped
184
240
  AGENTS instructions and skills; omitting it retains user instructions and
@@ -226,8 +282,14 @@ Deleting a child first verifies its session header and persistent identity,
226
282
  then uses the optional `trash` command when available and falls back to
227
283
  unlinking its session file. Deletion prunes pending delivery state and retained
228
284
  recent-message projections sourced from the complete deleted subtree. Restore
229
- and clone perform the same ownership check and reopen the recorded child-session
230
- leaf.
285
+ and clone perform the same ownership check against the recorded child-session
286
+ leaf. Restoration loads metadata and validates saved sessions without starting
287
+ child runtimes or their extension services (including MCP Servers). Status,
288
+ saved transcripts, settled-result waits, cancellation of idle children, and
289
+ session management do not start runtimes. A message or an undelivered child-bound
290
+ result opens only its recipient's runtime on demand; saved Delivery Evidence
291
+ prevents already-delivered work from reopening it. Runtime initialization errors
292
+ are reported when that child is first needed.
231
293
 
232
294
  Registry replay and Delivery Evidence are scoped to the Root Agent's active
233
295
  session-tree branch. Registry writes use V2 records with complete field,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ian-pascoe/pi-minimal-subagents",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "private": false,
5
5
  "description": "Persistent nested subagents with bounded delegation for Pi",
6
6
  "keywords": [
@@ -31,6 +31,7 @@
31
31
  "provenance": true
32
32
  },
33
33
  "dependencies": {
34
+ "minimatch": "^10.2.6",
34
35
  "proper-lockfile": "^4.1.2"
35
36
  },
36
37
  "devDependencies": {
@@ -126,10 +126,11 @@ export function reconcileCoordinatorToolAccess(
126
126
  activeToolNames: readonly string[],
127
127
  enabled: boolean,
128
128
  ): string[] {
129
- const ordinaryToolNames = activeToolNames.filter(
130
- (toolName) => !COORDINATOR_TOOL_NAME_SET.has(toolName),
129
+ const missing = new Set<string>(COORDINATOR_TOOL_NAMES);
130
+ const retained = activeToolNames.filter(
131
+ (toolName) => !COORDINATOR_TOOL_NAME_SET.has(toolName) || (enabled && missing.delete(toolName)),
131
132
  );
132
- return enabled ? [...ordinaryToolNames, ...COORDINATOR_TOOL_NAMES] : ordinaryToolNames;
133
+ return enabled ? [...retained, ...missing] : retained;
133
134
  }
134
135
 
135
136
  /** Resolve branch state over settings and include read-only Coordinator Tool activation. */
@@ -1,4 +1,9 @@
1
- import type { DelegationMode, ToolSelection } from "./minimal-subagents-types.js";
1
+ import { Minimatch } from "minimatch";
2
+ import type {
3
+ DelegationMode,
4
+ MinimalSubagentsToolsets,
5
+ ToolSelection,
6
+ } from "./minimal-subagents-types.js";
2
7
 
3
8
  /** Lists Pi thinking levels in increasing effort order for schema validation and clamping. */
4
9
  export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
@@ -14,8 +19,12 @@ export const COORDINATOR_TOOL_NAMES = [
14
19
  "subagent_delete",
15
20
  ] as const;
16
21
 
17
- const READ_TOOL_BUNDLE = ["read", "grep", "find", "ls"];
18
- const MODIFY_TOOL_BUNDLE = [...READ_TOOL_BUNDLE, "bash", "edit", "write"];
22
+ /** Preserves the built-in presets when their settings are omitted. */
23
+ export const DEFAULT_TOOLSETS: MinimalSubagentsToolsets = {
24
+ baseToolset: [],
25
+ readToolset: ["read", "grep", "find", "ls"],
26
+ modifyToolset: ["bash", "edit", "write"],
27
+ };
19
28
  interface ModelReference {
20
29
  provider: string;
21
30
  id: string;
@@ -43,23 +52,16 @@ export function buildEligibleModelIds(input: {
43
52
  export interface ToolResolutionContext {
44
53
  ordinaryTools: readonly string[];
45
54
  capabilityCeiling: readonly string[];
55
+ toolsets?: MinimalSubagentsToolsets;
46
56
  }
47
57
 
48
- /** Resolve an exact ordinary-tool contract and reject missing or over-ceiling capabilities. */
58
+ /** Expand configured presets within the ceiling while keeping explicit requests strict. */
49
59
  export function resolveOrdinaryToolSelection(
50
60
  selection: ToolSelection | undefined,
51
61
  context: ToolResolutionContext,
52
- ): string[] {
62
+ ) {
53
63
  const requested =
54
- selection === undefined
55
- ? [...context.ordinaryTools]
56
- : selection === "none"
57
- ? []
58
- : selection === "read"
59
- ? READ_TOOL_BUNDLE
60
- : selection === "modify"
61
- ? MODIFY_TOOL_BUNDLE
62
- : selection;
64
+ selection === undefined ? context.ordinaryTools : Array.isArray(selection) ? selection : [];
63
65
  const uniqueRequested = [...new Set(requested)];
64
66
  const coordinatorTools = new Set<string>(COORDINATOR_TOOL_NAMES);
65
67
  const requestedCoordinatorTools = uniqueRequested.filter((name) => coordinatorTools.has(name));
@@ -76,7 +78,29 @@ export function resolveOrdinaryToolSelection(
76
78
  throw new Error(`Minimal subagents capability ceiling exceeded: ${exceeded.join(", ")}`);
77
79
  }
78
80
 
79
- return uniqueRequested;
81
+ const toolsets = context.toolsets ?? DEFAULT_TOOLSETS;
82
+ const keys: (keyof MinimalSubagentsToolsets)[] = ["baseToolset"];
83
+ if (selection === "read" || selection === "modify") keys.push("readToolset");
84
+ if (selection === "modify") keys.push("modifyToolset");
85
+ const permitted = excludeCoordinatorTools(context.capabilityCeiling);
86
+ const warnings: string[] = [];
87
+ const configured = keys.flatMap((key) =>
88
+ toolsets[key].flatMap((pattern) => {
89
+ const matcher = new Minimatch(pattern);
90
+ const matches = permitted.filter((name) => matcher.match(name));
91
+ if (matches.length === 0) {
92
+ warnings.push(
93
+ `minimalSubagents.${key}: ${JSON.stringify(pattern)} matched no permitted ordinary tools (unavailable, outside the caller's capability ceiling, or Coordinator Tools); skipped`,
94
+ );
95
+ }
96
+ return matches;
97
+ }),
98
+ );
99
+ return {
100
+ ordinaryTools: [...new Set([...configured, ...uniqueRequested])],
101
+ requiredTools: uniqueRequested,
102
+ warnings,
103
+ };
80
104
  }
81
105
 
82
106
  /** Return an agent's hierarchy depth where the interactive root is depth zero. */
@@ -1,7 +1,13 @@
1
1
  import type { SettingsManager } from "@earendil-works/pi-coding-agent";
2
+ import { Minimatch } from "minimatch";
2
3
  import { type Static, Type } from "typebox";
3
4
  import { Value } from "typebox/value";
4
- import { DEFAULT_MAX_SUBAGENT_DEPTH, THINKING_LEVELS } from "./minimal-subagents-capabilities.js";
5
+ import {
6
+ DEFAULT_MAX_SUBAGENT_DEPTH,
7
+ DEFAULT_TOOLSETS,
8
+ THINKING_LEVELS,
9
+ } from "./minimal-subagents-capabilities.js";
10
+ import type { MinimalSubagentsToolsets } from "./minimal-subagents-types.js";
5
11
 
6
12
  const MODEL_ROLE_NAME_MAX_LENGTH = 64;
7
13
  const MODEL_ROLE_HINT_MAX_LENGTH = 500;
@@ -13,12 +19,17 @@ const MinimalSubagentsSettingsSchema = Type.Object({
13
19
  enabled: Type.Optional(Type.Unknown()),
14
20
  maxSubagentDepth: Type.Optional(Type.Unknown()),
15
21
  modelRoles: Type.Optional(Type.Unknown()),
22
+ baseToolset: Type.Optional(Type.Unknown()),
23
+ readToolset: Type.Optional(Type.Unknown()),
24
+ modifyToolset: Type.Optional(Type.Unknown()),
16
25
  });
17
26
  const ModelRoleObjectSchema = Type.Object({
18
27
  model: Type.Optional(Type.Unknown()),
19
28
  hint: Type.Optional(Type.Unknown()),
20
29
  });
21
30
  const EnabledSettingSchema = Type.Boolean();
31
+ const ToolsetSchema = Type.Array(Type.Unknown());
32
+ const ToolPatternSchema = Type.String({ minLength: 1 });
22
33
  const PositiveSafeIntegerSchema = Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER });
23
34
  const MaxSubagentDepthSettingSchema = Type.Union([PositiveSafeIntegerSchema, Type.Null()]);
24
35
  const ShorthandModelRoleSchema = Type.String();
@@ -58,6 +69,7 @@ export interface ResolvedMinimalSubagentsConfig {
58
69
  maxSubagentDepth: number;
59
70
  subagentAccess: ResolvedSubagentAccessSettings;
60
71
  modelRoles: MinimalSubagentsModelRole[];
72
+ toolsets: MinimalSubagentsToolsets;
61
73
  warnings: string[];
62
74
  }
63
75
 
@@ -88,7 +100,7 @@ interface ScopedSettingValue {
88
100
  value: ModelRoleWireValue;
89
101
  }
90
102
 
91
- interface ParsedMinimalSubagentsSettings {
103
+ interface ParsedMinimalSubagentsSettings extends Partial<MinimalSubagentsToolsets> {
92
104
  enabled?: boolean;
93
105
  maxSubagentDepth?: MaxSubagentDepthWireValue;
94
106
  modelRoles?: ModelRolesWireValue;
@@ -170,6 +182,32 @@ function readMinimalSubagentsSettings(
170
182
  if (minimalSubagents.modelRoles !== undefined) {
171
183
  parsed.modelRoles = parseModelRolesWireValue(minimalSubagents.modelRoles);
172
184
  }
185
+ for (const key of ["baseToolset", "readToolset", "modifyToolset"] as const) {
186
+ const value = minimalSubagents[key];
187
+ if (value === undefined) continue;
188
+ const path = `${scope} minimalSubagents.${key}`;
189
+ if (!Value.Check(ToolsetSchema, value)) {
190
+ warnings.push(`${path}: expected an array of patterns`);
191
+ continue;
192
+ }
193
+ const patterns: string[] = [];
194
+ for (const [index, pattern] of value.entries()) {
195
+ if (!Value.Check(ToolPatternSchema, pattern)) {
196
+ warnings.push(`${path}[${index}]: expected a non-empty string`);
197
+ continue;
198
+ }
199
+ try {
200
+ if (new Minimatch(pattern).makeRe() !== false) {
201
+ patterns.push(pattern);
202
+ continue;
203
+ }
204
+ } catch {
205
+ // Invalid minimatch patterns are nonblocking configuration warnings.
206
+ }
207
+ warnings.push(`${path}[${index}]: invalid minimatch pattern`);
208
+ }
209
+ parsed[key] = patterns;
210
+ }
173
211
  return parsed;
174
212
  }
175
213
  warnings.push(`${scope} minimalSubagents: expected an object`);
@@ -384,6 +422,14 @@ export function resolveMinimalSubagentsConfig(
384
422
  maxSubagentDepth,
385
423
  subagentAccess: resolveSubagentAccessSettings(globalConfig.enabled, projectConfig.enabled),
386
424
  modelRoles: parseModelRoles(modelRoleEntries, input.eligibleModelIds, warnings),
425
+ toolsets: {
426
+ baseToolset: projectConfig.baseToolset ??
427
+ globalConfig.baseToolset ?? [...DEFAULT_TOOLSETS.baseToolset],
428
+ readToolset: projectConfig.readToolset ??
429
+ globalConfig.readToolset ?? [...DEFAULT_TOOLSETS.readToolset],
430
+ modifyToolset: projectConfig.modifyToolset ??
431
+ globalConfig.modifyToolset ?? [...DEFAULT_TOOLSETS.modifyToolset],
432
+ },
387
433
  warnings,
388
434
  };
389
435
  }
@@ -207,9 +207,14 @@ export class MinimalSubagentsCoordinator {
207
207
  const model = parameters.model ?? caller.model;
208
208
  const requestedThinking = parameters.thinking_level ?? caller.thinkingLevel;
209
209
  const thinkingLevel = this.dependencies.sessions.resolveThinkingLevel(model, requestedThinking);
210
- const ordinaryTools = resolveOrdinaryToolSelection(parameters.tools, {
210
+ const {
211
+ ordinaryTools,
212
+ requiredTools,
213
+ warnings: toolWarnings,
214
+ } = resolveOrdinaryToolSelection(parameters.tools, {
211
215
  ordinaryTools: excludeCoordinatorTools(caller.ordinaryTools),
212
216
  capabilityCeiling: excludeCoordinatorTools(caller.capabilityCeiling),
217
+ toolsets: this.dependencies.toolsets,
213
218
  });
214
219
  const committedMessages = structuredClone(caller.messages);
215
220
  const imported = assembleImportedContext(sessionContext, committedMessages);
@@ -251,9 +256,24 @@ export class MinimalSubagentsCoordinator {
251
256
  const missingDependencies =
252
257
  await this.dependencies.sessions.resolveLaunchMissingDependencies(agent);
253
258
  this.assertAccepting();
254
- if (missingDependencies.length > 0) {
259
+ const optionalMissing = new Set(
260
+ missingDependencies.filter(
261
+ (name) => name !== model && ordinaryTools.includes(name) && !requiredTools.includes(name),
262
+ ),
263
+ );
264
+ const requiredMissing = missingDependencies.filter((name) => !optionalMissing.has(name));
265
+ if (requiredMissing.length > 0) {
255
266
  throw new Error(
256
- `Minimal subagents launch dependencies unavailable: ${missingDependencies.join(", ")}`,
267
+ `Minimal subagents launch dependencies unavailable: ${requiredMissing.join(", ")}`,
268
+ );
269
+ }
270
+ if (optionalMissing.size > 0) {
271
+ agent.launch_contract.ordinary_tools = ordinaryTools.filter(
272
+ (name) => !optionalMissing.has(name),
273
+ );
274
+ agent.capability_ceiling = [...agent.launch_contract.ordinary_tools];
275
+ toolWarnings.push(
276
+ `Configured tools unavailable in child resources; skipped: ${[...optionalMissing].join(", ")}`,
257
277
  );
258
278
  }
259
279
  identity = this.dependencies.sessions.createIdentity(agent, imported.messages);
@@ -273,6 +293,13 @@ export class MinimalSubagentsCoordinator {
273
293
  createRegistryEvent(this.dependencies.registry.rootSessionId, "agent-created", { agent }),
274
294
  );
275
295
  const turnId = this.beginTurn(agent);
296
+ if (toolWarnings.length > 0) {
297
+ this.dependencies.notify?.({
298
+ type: "tool-warning",
299
+ agentId,
300
+ message: `Minimal subagents tool warnings for ${agentId}:\n- ${[...new Set(toolWarnings)].join("\n- ")}`,
301
+ });
302
+ }
276
303
  this.dependencies.notify?.({
277
304
  type: "spawn",
278
305
  agentId,
@@ -655,19 +682,6 @@ export class MinimalSubagentsCoordinator {
655
682
  });
656
683
  continue;
657
684
  }
658
- const runtime = await this.dependencies.sessions.openRuntime(agent);
659
- if (restoreEpoch !== this.lifecycleEpoch) {
660
- runtime.dispose();
661
- return;
662
- }
663
- if (!runtime.sessionLeafId) {
664
- runtime.dispose();
665
- throw new Error(
666
- `Minimal subagents session restoration: no selected session leaf for ${agent.agent_id}`,
667
- );
668
- }
669
- this.runtimes.set(agent.agent_id, runtime);
670
- agent.session_leaf_id = runtime.sessionLeafId;
671
685
  agent.availability = "available";
672
686
  if (previousAvailability !== "available")
673
687
  agent.latest_activity_at = this.now().toISOString();
@@ -926,6 +940,7 @@ export class MinimalSubagentsCoordinator {
926
940
  new Error(agent.clone_error ?? `No persistent session exists for ${agent.agent_id}`),
927
941
  );
928
942
  }
943
+ const openingForTurn = agent.active_turn_id !== undefined;
929
944
  const initialization = this.dependencies.sessions
930
945
  .openRuntime(agent)
931
946
  .then((runtime) => {
@@ -933,9 +948,29 @@ export class MinimalSubagentsCoordinator {
933
948
  runtime.dispose();
934
949
  throw new Error(`Minimal subagents runtime replaced while opening ${agent.agent_id}`);
935
950
  }
951
+ if (!runtime.sessionLeafId) {
952
+ runtime.dispose();
953
+ throw new Error(
954
+ `Minimal subagents session restoration: no selected session leaf for ${agent.agent_id}`,
955
+ );
956
+ }
936
957
  this.runtimes.set(agent.agent_id, runtime);
958
+ agent.session_leaf_id = runtime.sessionLeafId;
937
959
  return runtime;
938
960
  })
961
+ .catch((error) => {
962
+ if (!openingForTurn && this.agents.get(agent.agent_id) === agent) {
963
+ agent.availability = "unavailable";
964
+ agent.latest_activity_at = this.now().toISOString();
965
+ agent.unavailable_reason = error instanceof Error ? error.message : String(error);
966
+ this.dependencies.notify?.({
967
+ type: "unavailable",
968
+ agentId: agent.agent_id,
969
+ message: `${agent.agent_id} unavailable: ${agent.unavailable_reason}`,
970
+ });
971
+ }
972
+ throw error;
973
+ })
939
974
  .finally(() => {
940
975
  if (this.runtimeInitializations.get(agent.agent_id) === initialization) {
941
976
  this.runtimeInitializations.delete(agent.agent_id);
@@ -1307,6 +1342,7 @@ export class MinimalSubagentsCoordinator {
1307
1342
  }
1308
1343
  const target = this.requireUsableAgent(targetId, "message");
1309
1344
  const runtime = this.runtimes.get(targetId) ?? (await this.ensureRuntime(target));
1345
+ this.assertAccepting();
1310
1346
  if (!isCurrentDelivery()) {
1311
1347
  throw new Error("Minimal subagents delivery abandoned after session branch change");
1312
1348
  }
@@ -1430,34 +1466,45 @@ export class MinimalSubagentsCoordinator {
1430
1466
  }
1431
1467
 
1432
1468
  private hasDeliveryEvidence(delivery: PersistedDelivery): boolean {
1433
- if (delivery.destination_agent_id === "root") {
1434
- return this.dependencies.root.hasDeliveryEvidence(
1435
- delivery.source_agent_id,
1436
- delivery.source_turn_id,
1437
- );
1438
- }
1439
- return (
1440
- this.runtimes
1441
- .get(delivery.destination_agent_id)
1442
- ?.hasDeliveryEvidence(delivery.source_agent_id, delivery.source_turn_id) ?? false
1469
+ return this.hasRecipientDeliveryEvidence(
1470
+ delivery.destination_agent_id,
1471
+ delivery.source_agent_id,
1472
+ delivery.source_turn_id,
1443
1473
  );
1444
1474
  }
1445
1475
 
1446
1476
  private hasCoordinationDeliveryEvidence(delivery: PersistedCoordinationDelivery): boolean {
1447
- const sourceAgentId = delivery.message.details.source_agent_id;
1448
- const sourceTurnId = delivery.message.details.source_turn_id;
1449
- if (delivery.destination_agent_id === "root") {
1450
- return this.dependencies.root.hasDeliveryEvidence(
1451
- sourceAgentId,
1452
- sourceTurnId,
1453
- delivery.delivery_id,
1477
+ return this.hasRecipientDeliveryEvidence(
1478
+ delivery.destination_agent_id,
1479
+ delivery.message.details.source_agent_id,
1480
+ delivery.message.details.source_turn_id,
1481
+ delivery.delivery_id,
1482
+ );
1483
+ }
1484
+
1485
+ private hasRecipientDeliveryEvidence(
1486
+ targetId: string,
1487
+ sourceAgentId: string,
1488
+ sourceTurnId: string,
1489
+ deliveryId?: string,
1490
+ ): boolean {
1491
+ const endpoint = targetId === "root" ? this.dependencies.root : this.runtimes.get(targetId);
1492
+ if (endpoint) return endpoint.hasDeliveryEvidence(sourceAgentId, sourceTurnId, deliveryId);
1493
+ const agent = this.agents.get(targetId);
1494
+ if (!agent) return false;
1495
+ try {
1496
+ return (
1497
+ this.dependencies.sessions.hasDeliveryEvidence?.(
1498
+ agent,
1499
+ sourceAgentId,
1500
+ sourceTurnId,
1501
+ deliveryId,
1502
+ ) ?? false
1454
1503
  );
1504
+ } catch {
1505
+ // Unverified sessions cannot prove delivery; runtime opening reports their failure.
1506
+ return false;
1455
1507
  }
1456
- return (
1457
- this.runtimes
1458
- .get(delivery.destination_agent_id)
1459
- ?.hasDeliveryEvidence(sourceAgentId, sourceTurnId, delivery.delivery_id) ?? false
1460
- );
1461
1508
  }
1462
1509
 
1463
1510
  private settleDelivery(delivery: PersistedDelivery): void {
@@ -1518,7 +1565,9 @@ export class MinimalSubagentsCoordinator {
1518
1565
  capability_ceiling: [...agent.capability_ceiling],
1519
1566
  spawn_entry_id: agent.spawn_entry_id,
1520
1567
  recent_messages: structuredClone(agent.recent_messages),
1521
- recent_activity: buildRecentAgentActivity(runtime?.snapshotActivityMessages() ?? []),
1568
+ recent_activity: buildRecentAgentActivity(
1569
+ runtime?.snapshotActivityMessages() ?? this.inspectTranscript(agent.agent_id).messages,
1570
+ ),
1522
1571
  latest_result: agent.latest_result ? structuredClone(agent.latest_result) : undefined,
1523
1572
  missing_dependencies: [...agent.missing_dependencies],
1524
1573
  unavailable_reason: agent.unavailable_reason,
@@ -182,7 +182,7 @@ function createRootConversationEndpoint(
182
182
  }
183
183
 
184
184
  function shouldSurfaceNotification(notification: CoordinatorNotification): boolean {
185
- return ["failure", "interruption", "unavailable", "fork-clone-failure"].includes(
185
+ return ["failure", "interruption", "unavailable", "fork-clone-failure", "tool-warning"].includes(
186
186
  notification.type,
187
187
  );
188
188
  }
@@ -192,7 +192,8 @@ function notificationLevel(notification: CoordinatorNotification): "info" | "war
192
192
  if (
193
193
  notification.type === "cancellation" ||
194
194
  notification.type === "interruption" ||
195
- notification.type === "unavailable"
195
+ notification.type === "unavailable" ||
196
+ notification.type === "tool-warning"
196
197
  ) {
197
198
  return "warning";
198
199
  }
@@ -605,6 +606,7 @@ export class MinimalSubagentsLifecycleController {
605
606
  sessions: sessionFactory,
606
607
  root: createRootConversationEndpoint(this.pi, context),
607
608
  maxSubagentDepth: minimalSubagentsConfig.maxSubagentDepth,
609
+ toolsets: minimalSubagentsConfig.toolsets,
608
610
  registry: {
609
611
  rootSessionId,
610
612
  append: (registryEvent) => this.pi.appendEntry(REGISTRY_ENTRY_TYPE, registryEvent),
@@ -67,6 +67,7 @@ const PI_BUILTIN_ORDINARY_TOOL_NAMES = new Set([
67
67
  "find",
68
68
  "ls",
69
69
  "bash",
70
+ "powershell",
70
71
  "edit",
71
72
  "write",
72
73
  ]);
@@ -129,11 +130,21 @@ function installChildToolCapabilityPolicy(
129
130
  session: AgentSession,
130
131
  allowedToolNames: readonly string[],
131
132
  runtimeToolAdapters: readonly RuntimeToolAdapter[],
133
+ preserveGrantedTools = true,
132
134
  ): void {
133
135
  const applyActiveTools = session.setActiveToolsByName.bind(session);
134
136
  session.setActiveToolsByName = (requestedToolNames) => {
137
+ const permitted = resolveChildActiveToolNames(
138
+ allowedToolNames,
139
+ requestedToolNames,
140
+ runtimeToolAdapters,
141
+ );
142
+ // Inside an exposure wrapper, ordinary tools may intentionally be hidden. The
143
+ // outer policy restores grants before that wrapper applies its own selection.
135
144
  applyActiveTools(
136
- resolveChildActiveToolNames(allowedToolNames, requestedToolNames, runtimeToolAdapters),
145
+ preserveGrantedTools
146
+ ? permitted
147
+ : permitted.filter((name) => requestedToolNames.includes(name)),
137
148
  );
138
149
  };
139
150
  session.setActiveToolsByName(session.getActiveToolNames());
@@ -826,24 +837,49 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
826
837
  stat.ctimeMs,
827
838
  ]);
828
839
  if (this.savedTranscript?.key === key) return this.savedTranscript.snapshot;
840
+ const manager = this.readSession(agent);
841
+ const snapshot = selectChildAgentTranscript(
842
+ manager.getBranch(agent.session_leaf_id).flatMap(sessionEntryToContextMessages),
843
+ );
844
+ this.savedTranscript = { key, snapshot };
845
+ return snapshot;
846
+ }
847
+
848
+ /** Inspect durable evidence without starting child extensions or changing the saved branch. */
849
+ hasDeliveryEvidence(
850
+ agent: PersistedAgent,
851
+ sourceAgentId: string,
852
+ sourceTurnId: string,
853
+ deliveryId?: string,
854
+ ): boolean {
855
+ return findDeliveryEvidence(
856
+ this.readSession(agent).getBranch(agent.session_leaf_id),
857
+ sourceAgentId,
858
+ sourceTurnId,
859
+ deliveryId,
860
+ );
861
+ }
862
+
863
+ private readSession(agent: PersistedAgent): SessionManager {
864
+ if (!agent.session_file || !agent.session_id || !agent.session_leaf_id) {
865
+ throw new Error(`Child Session Position is unavailable for ${agent.agent_id}.`);
866
+ }
867
+ const sessionFile = canonicalPath(agent.session_file);
829
868
  const entries = parseSessionEntries(readFileSync(sessionFile, "utf8"));
830
869
  if (entries[0]?.type !== "session")
831
870
  throw new Error(`Invalid child session file: ${sessionFile}`);
832
871
  // SessionManager.open can migrate/rewrite files; an in-memory reader cannot write them.
833
872
  const manager = SessionManager.inMemory(this.options.cwd, undefined, entries);
834
873
  verifyChildSessionIdentity(manager, agent, this.options.rootSessionId);
835
- const snapshot = selectChildAgentTranscript(
836
- manager.getBranch(agent.session_leaf_id).flatMap(sessionEntryToContextMessages),
837
- );
838
- this.savedTranscript = { key, snapshot };
839
- return snapshot;
874
+ return manager;
840
875
  }
841
876
 
842
877
  resolveLaunchMissingDependencies(agent: PersistedAgent): Promise<string[]> {
843
878
  return this.findMissingDependencies(agent, false);
844
879
  }
845
880
 
846
- resolveRestorationMissingDependencies(agent: PersistedAgent): Promise<string[]> {
881
+ async resolveRestorationMissingDependencies(agent: PersistedAgent): Promise<string[]> {
882
+ this.readSession(agent);
847
883
  return this.findMissingDependencies(agent, this.options.modelScopeRestricted);
848
884
  }
849
885
 
@@ -1129,15 +1165,17 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
1129
1165
  session.dispose();
1130
1166
  throw new Error(`Minimal subagents child tool loading failed: ${missingTools.join(", ")}`);
1131
1167
  }
1132
- // The inner policy filters names added by extension wrappers such as Pi CodeMode.
1133
- installChildToolCapabilityPolicy(session, allowedToolNames, runtimeToolAdapters);
1168
+ // The inner policy bounds extension-selected exposure without restoring hidden ordinary tools.
1169
+ installChildToolCapabilityPolicy(session, allowedToolNames, runtimeToolAdapters, false);
1134
1170
  await session.bindExtensions({ mode: "print" });
1135
1171
  // The outer policy filters names before extension wrappers build their own tool catalogues.
1136
1172
  installChildToolCapabilityPolicy(session, allowedToolNames, runtimeToolAdapters);
1137
- const activeNames = new Set(session.getActiveToolNames());
1173
+ // Exposure policy may route granted Coordinator Tools through another tool;
1174
+ // require their definitions to remain registered, not necessarily direct.
1175
+ const registeredNames = new Set(session.getAllTools().map((tool) => tool.name));
1138
1176
  const missingCoordinatorTools = coordinatorTools
1139
1177
  .map((tool) => tool.name)
1140
- .filter((toolName) => !activeNames.has(toolName));
1178
+ .filter((toolName) => !registeredNames.has(toolName));
1141
1179
  if (missingCoordinatorTools.length > 0) {
1142
1180
  session.dispose();
1143
1181
  throw new Error(
@@ -15,12 +15,12 @@ const ToolSelectionSchema = Type.Union(
15
15
  Type.Array(Type.String({ minLength: 1 }), {
16
16
  uniqueItems: true,
17
17
  description:
18
- "Exact ordinary tool names. Coordinator tools are injected separately and must not appear here. Arrays are not bundle names; use the string preset `read` or `modify` for bundled capabilities.",
18
+ "Exact ordinary tool names, plus configured base tools. No pattern or preset expansion in arrays; use the string preset `read` or `modify`. Coordinator tools are injected separately and must not appear here.",
19
19
  }),
20
20
  ],
21
21
  {
22
22
  description:
23
- 'Use the string preset "read" for read, grep, find, and ls; use "modify" for the read bundle plus bash, edit, and write. An array grants exactly those named tools (ordinary tools only); coordinator tools are injected separately.',
23
+ '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.',
24
24
  },
25
25
  );
26
26
  const FRIENDLY_AGENT_ID_PATTERN = "^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$";
@@ -6,8 +6,14 @@ import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
6
6
  export type SessionContextMode = "inherit" | "compact" | "omit";
7
7
  /** Controls whether child resource discovery includes project instructions, skills, and prompts. */
8
8
  export type ProjectContextMode = "inherit" | "omit";
9
- /** Selects inherited, bundled, absent, or explicitly named ordinary child tools. */
9
+ /** Selects inherited, preset, base-only, or explicitly named ordinary child tools. */
10
10
  export type ToolSelection = "none" | "read" | "modify" | string[];
11
+ /** Configures additive ordinary-tool patterns for the base and cumulative presets. */
12
+ export interface MinimalSubagentsToolsets {
13
+ baseToolset: readonly string[];
14
+ readToolset: readonly string[];
15
+ modifyToolset: readonly string[];
16
+ }
11
17
  /** Controls whether a child must work directly or may explicitly fan out one bounded level. */
12
18
  export type DelegationMode = "none" | "fanout";
13
19
  /** Reports whether a persistent agent currently owns an active turn. */
@@ -261,6 +267,13 @@ export interface AgentSessionFactory {
261
267
  openRuntime(agent: PersistedAgent): Promise<ChildAgentRuntime>;
262
268
  /** Read verified saved history independently of runtime restoration dependencies. */
263
269
  readTranscript?(agent: PersistedAgent): ChildAgentTranscriptSnapshot;
270
+ /** Read verified selected-branch evidence without opening a runtime. */
271
+ hasDeliveryEvidence?(
272
+ agent: PersistedAgent,
273
+ sourceAgentId: string,
274
+ sourceTurnId: string,
275
+ deliveryId?: string,
276
+ ): boolean;
264
277
  resolveLaunchMissingDependencies(agent: PersistedAgent): Promise<string[]>;
265
278
  resolveRestorationMissingDependencies(agent: PersistedAgent): Promise<string[]>;
266
279
  resolveThinkingLevel(modelId: string, requested: ThinkingLevel): ThinkingLevel;
@@ -373,7 +386,8 @@ export interface CoordinatorNotification {
373
386
  | "interruption"
374
387
  | "restoration"
375
388
  | "unavailable"
376
- | "fork-clone-failure";
389
+ | "fork-clone-failure"
390
+ | "tool-warning";
377
391
  agentId: string;
378
392
  message: string;
379
393
  }
@@ -384,6 +398,7 @@ export interface CoordinatorDependencies {
384
398
  sessions: AgentSessionFactory;
385
399
  root: RootConversationEndpoint;
386
400
  maxSubagentDepth?: number;
401
+ toolsets?: MinimalSubagentsToolsets;
387
402
  now?: () => Date;
388
403
  automaticDeliveryGraceMs?: number;
389
404
  notify?: (notification: CoordinatorNotification) => void;