@ian-pascoe/pi-minimal-subagents 0.7.2 → 0.9.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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ian-pascoe/pi-minimal-subagents",
3
- "version": "0.7.2",
3
+ "version": "0.9.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": {
@@ -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,
@@ -543,7 +570,7 @@ export class MinimalSubagentsCoordinator {
543
570
  const runtime = this.runtimes.get(agent.agent_id);
544
571
  try {
545
572
  if (agent.active_turn_id) await this.cancelActiveTurn(agent);
546
- runtime?.dispose();
573
+ await runtime?.dispose();
547
574
  this.runtimes.delete(agent.agent_id);
548
575
  if (agent.session_file) {
549
576
  await this.dependencies.sessions.trashSession(agent);
@@ -595,7 +622,7 @@ export class MinimalSubagentsCoordinator {
595
622
  await Promise.allSettled(
596
623
  abandonedRuntimes.map((runtime) => (runtime.isRunning ? runtime.abort() : Promise.resolve())),
597
624
  );
598
- for (const runtime of abandonedRuntimes) runtime.dispose();
625
+ for (const runtime of abandonedRuntimes) await runtime.dispose();
599
626
  this.runtimes.clear();
600
627
  this.runtimeInitializations.clear();
601
628
  this.pendingAgentIds.clear();
@@ -865,7 +892,7 @@ export class MinimalSubagentsCoordinator {
865
892
  await Promise.allSettled(this.runtimeInitializations.values());
866
893
  await Promise.allSettled(this.backgroundOperations);
867
894
  await Promise.allSettled(this.recipientQueues.values());
868
- for (const runtime of this.runtimes.values()) runtime.dispose();
895
+ for (const runtime of this.runtimes.values()) await runtime.dispose();
869
896
  this.runtimes.clear();
870
897
  }
871
898
 
@@ -883,7 +910,7 @@ export class MinimalSubagentsCoordinator {
883
910
  const runtime = await this.ensureRuntime(agent);
884
911
  if (this.agents.get(agentId) !== agent || agent.active_turn_id !== turnId) {
885
912
  if (!this.agents.has(agentId) || !this.acceptingOperations) {
886
- runtime.dispose();
913
+ await runtime.dispose();
887
914
  this.runtimes.delete(agentId);
888
915
  }
889
916
  return;
@@ -916,13 +943,13 @@ export class MinimalSubagentsCoordinator {
916
943
  const openingForTurn = agent.active_turn_id !== undefined;
917
944
  const initialization = this.dependencies.sessions
918
945
  .openRuntime(agent)
919
- .then((runtime) => {
946
+ .then(async (runtime) => {
920
947
  if (this.agents.get(agent.agent_id) !== agent) {
921
- runtime.dispose();
948
+ await runtime.dispose();
922
949
  throw new Error(`Minimal subagents runtime replaced while opening ${agent.agent_id}`);
923
950
  }
924
951
  if (!runtime.sessionLeafId) {
925
- runtime.dispose();
952
+ await runtime.dispose();
926
953
  throw new Error(
927
954
  `Minimal subagents session restoration: no selected session leaf for ${agent.agent_id}`,
928
955
  );
@@ -80,6 +80,7 @@ import { MinimalSubagentsUiController } from "./minimal-subagents-ui.js";
80
80
  import type {
81
81
  AgentSessionFactory,
82
82
  CallerSnapshot,
83
+ ChildSessionObserver,
83
84
  CoordinatorNotification,
84
85
  ForkSnapshot,
85
86
  PersistedAgent,
@@ -182,7 +183,7 @@ function createRootConversationEndpoint(
182
183
  }
183
184
 
184
185
  function shouldSurfaceNotification(notification: CoordinatorNotification): boolean {
185
- return ["failure", "interruption", "unavailable", "fork-clone-failure"].includes(
186
+ return ["failure", "interruption", "unavailable", "fork-clone-failure", "tool-warning"].includes(
186
187
  notification.type,
187
188
  );
188
189
  }
@@ -192,7 +193,8 @@ function notificationLevel(notification: CoordinatorNotification): "info" | "war
192
193
  if (
193
194
  notification.type === "cancellation" ||
194
195
  notification.type === "interruption" ||
195
- notification.type === "unavailable"
196
+ notification.type === "unavailable" ||
197
+ notification.type === "tool-warning"
196
198
  ) {
197
199
  return "warning";
198
200
  }
@@ -584,6 +586,19 @@ export class MinimalSubagentsLifecycleController {
584
586
  projectTrusted: context.isProjectTrusted(),
585
587
  maxSubagentDepth: minimalSubagentsConfig.maxSubagentDepth,
586
588
  onChildSessionActivity: () => activeCoordinator?.scheduleDeliveryReconciliation(),
589
+ observeSession: (session, agentId, resourceInputs) => {
590
+ let observer: ChildSessionObserver | undefined;
591
+ this.pi.events.emit("pi-minimal-subagents:observe-session", {
592
+ rootSessionId,
593
+ agentId,
594
+ session,
595
+ resourceInputs,
596
+ attach: (attached: ChildSessionObserver) => {
597
+ observer = attached;
598
+ },
599
+ });
600
+ return observer;
601
+ },
587
602
  getCoordinatorTools: (callerId) => {
588
603
  const coordinator = requireActiveCoordinator();
589
604
  return createCoordinatorToolDefinitions({
@@ -605,6 +620,7 @@ export class MinimalSubagentsLifecycleController {
605
620
  sessions: sessionFactory,
606
621
  root: createRootConversationEndpoint(this.pi, context),
607
622
  maxSubagentDepth: minimalSubagentsConfig.maxSubagentDepth,
623
+ toolsets: minimalSubagentsConfig.toolsets,
608
624
  registry: {
609
625
  rootSessionId,
610
626
  append: (registryEvent) => this.pi.appendEntry(REGISTRY_ENTRY_TYPE, registryEvent),
@@ -17,6 +17,7 @@ import {
17
17
  SettingsManager,
18
18
  sessionEntryToContextMessages,
19
19
  type AgentSessionEvent,
20
+ type Extension,
20
21
  type SessionEntry,
21
22
  type ToolDefinition,
22
23
  } from "@earendil-works/pi-coding-agent";
@@ -53,6 +54,7 @@ import { addMinimalSubagentsUsage } from "./minimal-subagents-usage.js";
53
54
  import type {
54
55
  AgentSessionFactory,
55
56
  ChildAgentRuntime,
57
+ ChildSessionObserver,
56
58
  ChildAgentTranscriptSnapshot,
57
59
  CoordinatorMessage,
58
60
  PersistedAgent,
@@ -67,6 +69,7 @@ const PI_BUILTIN_ORDINARY_TOOL_NAMES = new Set([
67
69
  "find",
68
70
  "ls",
69
71
  "bash",
72
+ "powershell",
70
73
  "edit",
71
74
  "write",
72
75
  ]);
@@ -129,11 +132,21 @@ function installChildToolCapabilityPolicy(
129
132
  session: AgentSession,
130
133
  allowedToolNames: readonly string[],
131
134
  runtimeToolAdapters: readonly RuntimeToolAdapter[],
135
+ preserveGrantedTools = true,
132
136
  ): void {
133
137
  const applyActiveTools = session.setActiveToolsByName.bind(session);
134
138
  session.setActiveToolsByName = (requestedToolNames) => {
139
+ const permitted = resolveChildActiveToolNames(
140
+ allowedToolNames,
141
+ requestedToolNames,
142
+ runtimeToolAdapters,
143
+ );
144
+ // Inside an exposure wrapper, ordinary tools may intentionally be hidden. The
145
+ // outer policy restores grants before that wrapper applies its own selection.
135
146
  applyActiveTools(
136
- resolveChildActiveToolNames(allowedToolNames, requestedToolNames, runtimeToolAdapters),
147
+ preserveGrantedTools
148
+ ? permitted
149
+ : permitted.filter((name) => requestedToolNames.includes(name)),
137
150
  );
138
151
  };
139
152
  session.setActiveToolsByName(session.getActiveToolNames());
@@ -169,6 +182,15 @@ export interface PiAgentSessionFactoryOptions {
169
182
  sessionFileTrash?: SessionFileTrashCapability;
170
183
  getCoordinatorTools: (callerId: string) => ToolDefinition[];
171
184
  onChildSessionActivity?: () => void;
185
+ observeSession?: (
186
+ session: AgentSession,
187
+ agentId: string,
188
+ resourceInputs: {
189
+ agentDir: string;
190
+ extensions: readonly Pick<Extension, "path" | "resolvedPath" | "sourceInfo" | "hidden">[];
191
+ flagValues: ReadonlyMap<string, boolean | string>;
192
+ },
193
+ ) => ChildSessionObserver | undefined;
172
194
  }
173
195
 
174
196
  /** Build one child prompt using the active delegation depth rather than persisted launch state. */
@@ -483,7 +505,7 @@ function collectChildTurnOutcome(
483
505
  error: "No terminal assistant response",
484
506
  };
485
507
  }
486
- if (finalAssistant.stopReason === "aborted") {
508
+ if (aborted || finalAssistant.stopReason === "aborted") {
487
509
  return {
488
510
  status: "cancelled",
489
511
  output: assistantText(finalAssistant),
@@ -543,6 +565,7 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
543
565
  private readonly modelRuntime: ModelRuntime,
544
566
  private readonly modelById: ReadonlyMap<string, Model<any>>,
545
567
  onSessionActivity?: () => void,
568
+ private readonly observer?: ChildSessionObserver,
546
569
  ) {
547
570
  // Keep this child-only; AgentSession.setSteeringMode would overwrite the user's global setting.
548
571
  session.agent.steeringMode = "all";
@@ -602,12 +625,16 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
602
625
 
603
626
  async abort(): Promise<void> {
604
627
  this.aborted = true;
605
- await this.session.abort();
628
+ await Promise.all([this.observer?.abort(), this.session.abort()]);
606
629
  }
607
630
 
608
- dispose(): void {
631
+ async dispose(): Promise<void> {
609
632
  this.unsubscribe();
610
- this.session.dispose();
633
+ try {
634
+ await this.observer?.dispose();
635
+ } finally {
636
+ this.session.dispose();
637
+ }
611
638
  }
612
639
 
613
640
  getRuntimeProfile(): RuntimeProfile | undefined {
@@ -704,7 +731,20 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
704
731
 
705
732
  private async captureTurn(operation: () => Promise<void>): Promise<RuntimeTurnOutcome> {
706
733
  this.aborted = false;
707
- return captureChildTurnOutcome(this.session, operation, () => this.aborted);
734
+ this.observer?.beginTurn();
735
+ return captureChildTurnOutcome(
736
+ this.session,
737
+ async () => {
738
+ try {
739
+ await operation();
740
+ if (!this.aborted) await this.observer?.finishTurn();
741
+ } catch (error) {
742
+ await this.observer?.abort();
743
+ throw error;
744
+ }
745
+ },
746
+ () => this.aborted,
747
+ );
708
748
  }
709
749
 
710
750
  private async compactImportedContext(
@@ -1154,15 +1194,17 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
1154
1194
  session.dispose();
1155
1195
  throw new Error(`Minimal subagents child tool loading failed: ${missingTools.join(", ")}`);
1156
1196
  }
1157
- // The inner policy filters names added by extension wrappers such as Pi CodeMode.
1158
- installChildToolCapabilityPolicy(session, allowedToolNames, runtimeToolAdapters);
1197
+ // The inner policy bounds extension-selected exposure without restoring hidden ordinary tools.
1198
+ installChildToolCapabilityPolicy(session, allowedToolNames, runtimeToolAdapters, false);
1159
1199
  await session.bindExtensions({ mode: "print" });
1160
1200
  // The outer policy filters names before extension wrappers build their own tool catalogues.
1161
1201
  installChildToolCapabilityPolicy(session, allowedToolNames, runtimeToolAdapters);
1162
- const activeNames = new Set(session.getActiveToolNames());
1202
+ // Exposure policy may route granted Coordinator Tools through another tool;
1203
+ // require their definitions to remain registered, not necessarily direct.
1204
+ const registeredNames = new Set(session.getAllTools().map((tool) => tool.name));
1163
1205
  const missingCoordinatorTools = coordinatorTools
1164
1206
  .map((tool) => tool.name)
1165
- .filter((toolName) => !activeNames.has(toolName));
1207
+ .filter((toolName) => !registeredNames.has(toolName));
1166
1208
  if (missingCoordinatorTools.length > 0) {
1167
1209
  session.dispose();
1168
1210
  throw new Error(
@@ -1174,6 +1216,18 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
1174
1216
  modelRuntime,
1175
1217
  this.modelById,
1176
1218
  this.options.onChildSessionActivity,
1219
+ this.options.observeSession?.(session, agent.agent_id, {
1220
+ agentDir: this.options.agentDir,
1221
+ extensions: resourceLoader
1222
+ .getExtensions()
1223
+ .extensions.map(({ path, resolvedPath, sourceInfo, hidden }) => ({
1224
+ path,
1225
+ resolvedPath,
1226
+ sourceInfo,
1227
+ hidden,
1228
+ })),
1229
+ flagValues: new Map(resourceLoader.getExtensions().runtime.flagValues),
1230
+ }),
1177
1231
  );
1178
1232
  }
1179
1233
 
@@ -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. */
@@ -218,6 +224,14 @@ export interface CoordinatorMessage {
218
224
  };
219
225
  }
220
226
 
227
+ /** Optional review work attached by the root and awaited by the existing child owner. */
228
+ export interface ChildSessionObserver {
229
+ beginTurn(): void;
230
+ finishTurn(): Promise<void>;
231
+ abort(): Promise<void>;
232
+ dispose(): Promise<void>;
233
+ }
234
+
221
235
  /** Process-local adapter around one SDK-created Pi child session. */
222
236
  export interface ChildAgentRuntime {
223
237
  readonly sessionLeafId: string | undefined;
@@ -232,7 +246,7 @@ export interface ChildAgentRuntime {
232
246
  /** Queue one typed coordinator message into the child session. */
233
247
  queueCoordinatorMessage(message: CoordinatorMessage): Promise<void>;
234
248
  abort(): Promise<void>;
235
- dispose(): void;
249
+ dispose(): void | Promise<void>;
236
250
  /** Return the live Runtime Profile, or undefined when the SDK session has no model. */
237
251
  getRuntimeProfile(): RuntimeProfile | undefined;
238
252
  /** Return the effective ordinary tools after child extensions apply runtime adapters. */
@@ -380,7 +394,8 @@ export interface CoordinatorNotification {
380
394
  | "interruption"
381
395
  | "restoration"
382
396
  | "unavailable"
383
- | "fork-clone-failure";
397
+ | "fork-clone-failure"
398
+ | "tool-warning";
384
399
  agentId: string;
385
400
  message: string;
386
401
  }
@@ -391,6 +406,7 @@ export interface CoordinatorDependencies {
391
406
  sessions: AgentSessionFactory;
392
407
  root: RootConversationEndpoint;
393
408
  maxSubagentDepth?: number;
409
+ toolsets?: MinimalSubagentsToolsets;
394
410
  now?: () => Date;
395
411
  automaticDeliveryGraceMs?: number;
396
412
  notify?: (notification: CoordinatorNotification) => void;