@bacnh85/pi-subagent 0.12.3 → 0.13.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/CHANGELOG.md CHANGED
@@ -1,5 +1,62 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.0 (2026-07-31)
4
+
5
+ ### Subagents inherit parent extensions & tools by default
6
+
7
+ Subagents can now use the same tools the main agent has — including extension
8
+ tools like `web_search`, `serena_*`, `munin_*`, `obsidian`, and `notebooklm`.
9
+ Previously children were restricted to the 7 Pi built-in tools (`read, grep,
10
+ find, ls, bash, edit, write`) and could not load extensions, which made
11
+ delegation far less capable than the main agent.
12
+
13
+ This follows the **Claude Code model**: subagents inherit the parent's tool set,
14
+ with a small denylist (`subagent` — recursive delegation is always prevented)
15
+ and per-agent restriction via an explicit `tools:` line.
16
+
17
+ **Tool resolution:**
18
+ - Agent **omits** `tools:` → inherits **all parent tools** (minus denylist).
19
+ `worker` and `general-purpose` now do this.
20
+ - Agent **specifies** `tools:` → restricted to that list, validated against
21
+ built-ins ∪ parent tools.
22
+ - `sandbox: read-only` / `readOnly` → filters the effective set to read-only.
23
+ - Denied tools (`subagent`) are **silently stripped**, never errored — whether
24
+ explicitly listed or inherited. Inheritance must not crash on a tool the
25
+ child cannot have (the inherited set always includes `subagent`).
26
+
27
+ **Smart lean optimization:** extensions are only loaded when the effective tool
28
+ set contains at least one non-built-in tool. Recon agents with a built-in-only
29
+ `tools:` line (scout, tester, planner, reviewer) stay cheap — zero extension
30
+ overhead, same fast cold-start.
31
+
32
+ **Per-child extension loader.** Children that need extensions get a FRESH
33
+ `DefaultResourceLoader` each run (extensions only: no skills, prompt templates,
34
+ AGENTS.md, or themes). The loader must NOT be cached/shared: extensions capture
35
+ the ExtensionAPI at factory-load time, and its actions delegate to the runtime
36
+ the factory was given (pi.getAllTools() → runtime.getAllTools()). A shared
37
+ loader's runtime is never the one any single child binds — children then hit
38
+ the runtime's throwing "Extension runtime not initialized" stubs on the first
39
+ provider request (pi-model-tools' before_provider_request calls pi.getAllTools())
40
+ or stale-ctx errors after the first child's dispose invalidates the shared
41
+ runtime. A per-child loader keeps every captured `pi` pointing at a runtime the
42
+ child both binds and owns. reload() per child re-reads extension files +
43
+ re-runs factories; acceptable for short-lived children.
44
+
45
+ Extension load errors in children are logged, not fatal. Project-extension trust
46
+ is inherited from the parent (children never prompt).
47
+
48
+ ### Bundled agent changes
49
+
50
+ - `worker` and `general-purpose`: removed the explicit `tools:` line so they
51
+ inherit all parent tools.
52
+ - `scout`, `tester`, `planner`, `reviewer`: unchanged (still lean + restricted).
53
+
54
+ ## 0.12.4 (2026-07-30)
55
+
56
+ ### Improvements
57
+
58
+ - Widen Pi peer dependency range to <0.84.0 for Pi 0.83.0 compatibility.
59
+
3
60
  ## 0.12.3 (2026-07-30)
4
61
 
5
62
  ### Improvements
package/README.md CHANGED
@@ -16,13 +16,15 @@ Requires Node.js >= 20.18.
16
16
  | --- | --- | --- | --- |
17
17
  | `scout` | `zai-coding-cn/glm-5-turbo` → `nvidia/openai/gpt-oss-20b` → `opencode-go/deepseek-v4-flash` | off | read, grep, find, ls |
18
18
  | `tester` | `zai-coding-cn/glm-5-turbo` → `nvidia/openai/gpt-oss-20b` → `opencode-go/deepseek-v4-flash` | off | read, bash, grep, find, ls |
19
- | `worker` | `zai-coding-cn/glm-5.1` → `nvidia/mistralai/mistral-small-4-119b-2603` → `openrouter/nvidia/nemotron-3-super-120b-a12b:free` → `opencode-go/deepseek-v4-flash` | medium | read, bash, edit, write, grep, find, ls |
20
- | `general-purpose` | `zai-coding-cn/glm-5.1` → `nvidia/mistralai/mistral-small-4-119b-2603` → `openrouter/nvidia/nemotron-3-super-120b-a12b:free` → `opencode-go/deepseek-v4-flash` | medium | read, bash, edit, write, grep, find, ls |
19
+ | `worker` | `zai-coding-cn/glm-5.1` → `nvidia/mistralai/mistral-small-4-119b-2603` → `openrouter/nvidia/nemotron-3-super-120b-a12b:free` → `opencode-go/deepseek-v4-flash` | medium | **inherits all parent tools** |
20
+ | `general-purpose` | `zai-coding-cn/glm-5.1` → `nvidia/mistralai/mistral-small-4-119b-2603` → `openrouter/nvidia/nemotron-3-super-120b-a12b:free` → `opencode-go/deepseek-v4-flash` | medium | **inherits all parent tools** |
21
21
  | `planner` | `zai-coding-cn/glm-5.2` → `openrouter/nvidia/nemotron-3-ultra-550b-a55b:free` → `opencode-go/deepseek-v4-pro` | high | read, grep, find, ls |
22
22
  | `reviewer` | `zai-coding-cn/glm-5.2` → `openrouter/nvidia/nemotron-3-ultra-550b-a55b:free` → `opencode-go/deepseek-v4-pro` | high | read, grep, find, ls |
23
23
 
24
24
  Each role uses the first authenticated preference available through Pi's model registry, then falls back to the authenticated parent model. Chains are **free-first** to conserve the metered opencode-go budget: **zai-coding-cn** (free GLM, primary) → free **nvidia** NIM and **openrouter** `:free` models → **opencode-go** (paid DeepSeek, last resort — one per role: `deepseek-v4-flash` for fast/strong-coding, `deepseek-v4-pro` for deep reasoning). opencode-go's GLM models cost ~$1.40/$4.40 per M versus zai-coding-cn's free GLM, so GLM stays on zai-coding-cn. Fallback models were live-verified on 2026-07-24; `nvidia/moonshotai/kimi-k2.6` and `nvidia/z-ai/glm-5.2` return 404/timeout on the user's account and were removed — non-rate-limit failures kill the subagent instead of advancing the chain. User/project agent files remain stronger overrides and may set legacy `model`, ordered `models`, and `thinking`.
25
25
 
26
+ **Tool inheritance.** Agents without an explicit `tools:` line (worker, general-purpose) inherit every tool the parent session has — including extension tools like `web_search`, `serena_*`, `munin_*`, `obsidian`, and `notebooklm`. Agents with an explicit `tools:` list (scout, tester, planner, reviewer) are restricted to those tools and, when the list contains only built-ins, run in a lean loader with no extension overhead. To force an agent lean even while inheriting, set `tools: read, bash, edit, write, grep, find, ls`. The `subagent` tool itself is always denied to children (no recursive delegation).
27
+
26
28
  ## Agent files
27
29
 
28
30
  Create `~/.pi/agent/agents/*.md` or `.pi/agents/*.md`:
@@ -45,7 +47,7 @@ Agent definitions are cached with file-signature invalidation; `/subagent reload
45
47
 
46
48
  ## Context and limits
47
49
 
48
- Children use in-memory SDK sessions with no extensions, skills, prompt templates, or automatic `AGENTS.md` loading. The optional `instructions` argument passes a bounded 16 KB task/repository contract. Only Pi built-in tools are available; Serena, FFF, web, and Munin are not available in lean children.
50
+ Children use in-memory SDK sessions. Agents that inherit parent tools load the parent's extensions (web, Serena, Munin, …) into the child; agents restricted to built-in tools run in a lean loader with no extensions, skills, prompt templates, or automatic `AGENTS.md` loading. The optional `instructions` argument passes a bounded 16 KB task/repository contract.
49
51
 
50
52
  Threads are session-memory only and are cleared when Pi replaces or reloads the session. Timeout and parent cancellation propagate to child sessions. Subagents cannot recursively invoke `subagent`.
51
53
 
@@ -1,7 +1,6 @@
1
1
  ---
2
2
  name: general-purpose
3
3
  description: General-purpose sub-agent for any delegated task. Use when no specialized agent fits. Good for complex research, multi-step operations, and code modifications.
4
- tools: read, bash, edit, write, grep, find, ls
5
4
  models:
6
5
  - zai-coding-cn/glm-5.1
7
6
  - nvidia/mistralai/mistral-small-4-119b-2603
@@ -43,6 +43,7 @@ import {
43
43
  normalizeTimeout,
44
44
  resolveSafeCwd,
45
45
  validateAgentTools,
46
+ needsExtensions,
46
47
  truncateParallelOutput,
47
48
  validateExecutionRequest,
48
49
  READ_ONLY_TOOLS,
@@ -170,7 +171,9 @@ export default function (pi: ExtensionAPI) {
170
171
  : " (parent fallback)";
171
172
  const thinkingInfo = agent.thinking ? `, thinking: ${agent.thinking}` : "";
172
173
  const sandboxInfo = agent.sandbox ? `, sandbox: ${agent.sandbox}` : "";
173
- return `- **${agent.name}**: ${agent.description}${modelInfo}${thinkingInfo}${sandboxInfo}`;
174
+ // ponytail: one-line inheritance hint; the model picks agents by description, this just sets expectations.
175
+ const toolsInfo = agent.tools ? `, tools: ${agent.tools.join(", ")}` : ", tools: inherits all parent tools";
176
+ return `- **${agent.name}**: ${agent.description}${modelInfo}${thinkingInfo}${sandboxInfo}${toolsInfo}`;
174
177
  })
175
178
  .join("\n");
176
179
  return {
@@ -179,6 +182,8 @@ export default function (pi: ExtensionAPI) {
179
182
  `\n\n## Available Subagents\n${catalog}\n\n` +
180
183
  "The subagent tool can delegate tasks to these specialized agents with isolated context. " +
181
184
  "Use for read-heavy exploration, parallel analysis, or work that would flood the main context.\n" +
185
+ "Agents marked `inherits all parent tools` can use web, Serena, Munin, and other extensions the main agent has; " +
186
+ "agents with an explicit tool list are leaner and restricted to those tools.\n" +
182
187
  "Prefer **scout** and **tester** for cheap routine work. " +
183
188
  "Prefer **worker** or **general-purpose** for normal coding. " +
184
189
  "Prefer **planner** and **reviewer** for consequential reasoning. " +
@@ -206,6 +211,7 @@ export default function (pi: ExtensionAPI) {
206
211
  timeout: request.timeout,
207
212
  instructions: request.instructions,
208
213
  signal: request.signal,
214
+ readOnly: request.readOnly,
209
215
  onMessage: (result) => threadStore.updateThread(thread.id, { result }),
210
216
  onProgress: (progress) => { threadStore.updateProgress(thread.id, progress); request.onProgress?.(progress); },
211
217
  }).then((result) => {
@@ -465,6 +471,12 @@ export default function (pi: ExtensionAPI) {
465
471
  const modelRuntime = (modelRegistry as any).runtime;
466
472
  const authStorage = (modelRegistry as any).authStorage;
467
473
 
474
+ // Parent session's registered tool names. Agents that omit `tools` inherit
475
+ // the full set (minus the denylist); agents with an explicit `tools` line
476
+ // are validated against built-ins ∪ this set.
477
+ const parentToolNames = pi.getAllTools().map((t) => t.name);
478
+ const projectTrusted = ctx.isProjectTrusted();
479
+
468
480
  // Helper: resolve a safe child working directory.
469
481
  function resolveChildCwd(childCwd: string | undefined): string {
470
482
  const safe = resolveSafeCwd({ workspaceRoot, childCwd, allowExternalCwd });
@@ -474,21 +486,22 @@ export default function (pi: ExtensionAPI) {
474
486
  return safe.path;
475
487
  }
476
488
 
477
- // Helper: validate and normalise tools for an agent.
478
- function resolveChildTools(agentTools: string[] | undefined, sandbox?: string, readOnly?: boolean): string[] {
479
- const defaultTools = ["read", "bash", "edit", "write", "grep", "find", "ls"];
480
- let rawTools = agentTools ?? defaultTools;
489
+ // Helper: validate and normalise tools for an agent. Returns the effective
490
+ // tool list and whether extensions must be loaded (any non-built-in tool).
491
+ function resolveChildTools(agentTools: string[] | undefined, sandbox?: string, readOnly?: boolean): { tools: string[]; loadExtensions: boolean } {
492
+ // Omitted tools => inherit all parent tools (Claude Code model).
493
+ let rawTools = agentTools ?? parentToolNames;
481
494
  // sandbox overrides tools: silently strip mutation tools, not an error
482
495
  if (sandbox === "read-only") {
483
496
  rawTools = rawTools.filter(t => READ_ONLY_TOOLS.includes(t));
484
497
  if (rawTools.length === 0) rawTools = [...READ_ONLY_TOOLS];
485
498
  }
486
499
  const effectiveReadOnly = readOnly || sandbox === "read-only";
487
- const result = validateAgentTools({ tools: rawTools, readOnly: effectiveReadOnly });
500
+ const result = validateAgentTools({ tools: rawTools, readOnly: effectiveReadOnly, availableTools: parentToolNames });
488
501
  if (result.errors.length > 0) {
489
502
  throw new Error(`Tool validation errors: ${result.errors.join("; ")}`);
490
503
  }
491
- return result.tools;
504
+ return { tools: result.tools, loadExtensions: needsExtensions(result.tools) };
492
505
  }
493
506
 
494
507
  // Helper: normalise timeout.
@@ -550,10 +563,13 @@ export default function (pi: ExtensionAPI) {
550
563
 
551
564
  // Security: validate tools, timeout, and cwd (wrapped in try/catch).
552
565
  let tools: string[];
566
+ let loadExtensions: boolean;
553
567
  let effectiveTimeoutMs: number | undefined;
554
568
  let safeCwd: string;
555
569
  try {
556
- tools = resolveChildTools(agent.tools, agent.sandbox, isReadOnly);
570
+ const resolved = resolveChildTools(agent.tools, agent.sandbox, isReadOnly);
571
+ tools = resolved.tools;
572
+ loadExtensions = resolved.loadExtensions;
557
573
  effectiveTimeoutMs = resolveChildTimeout(timeoutMs, params.timeout);
558
574
  safeCwd = resolveChildCwd(cwd);
559
575
  } catch (err: unknown) {
@@ -646,6 +662,8 @@ export default function (pi: ExtensionAPI) {
646
662
  thinkingLevel: agent.thinking,
647
663
  onMessage: onProgress,
648
664
  onProgress: onActivity,
665
+ loadExtensions,
666
+ projectTrusted,
649
667
  });
650
668
 
651
669
  if (result.errorMessage && isRateLimitError(result.errorMessage)) {
@@ -19,6 +19,8 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core";
19
19
  import {
20
20
  createAgentSession,
21
21
  createExtensionRuntime,
22
+ DefaultResourceLoader,
23
+ getAgentDir,
22
24
  type ResourceLoader,
23
25
  SessionManager,
24
26
  SettingsManager,
@@ -46,6 +48,54 @@ export interface UsageStats {
46
48
  export const DEFAULT_INACTIVITY_TIMEOUT_MS = 3 * 60 * 1000;
47
49
  export const HARD_TIMEOUT_MS = 20 * 60 * 1000;
48
50
 
51
+ // ---------------------------------------------------------------------------
52
+ // Extension resource loader
53
+ // ---------------------------------------------------------------------------
54
+
55
+ /**
56
+ * Build a FRESH lean DefaultResourceLoader for this child — no skills, prompt
57
+ * templates, context files (AGENTS.md), or themes. Extensions are loaded per
58
+ * child so their factories run against a runtime that THIS child's session will
59
+ * bind.
60
+ *
61
+ * Do NOT cache/share this loader. Extensions capture the ExtensionAPI (`pi`)
62
+ * at factory load time, and its actions delegate to the runtime passed to the
63
+ * factory (see createExtensionAPI: pi.getAllTools() → runtime.getAllTools()).
64
+ * If extensions were loaded once against a shared/cached runtime, that runtime
65
+ * is never the one any single child binds: a session binds the runtime its own
66
+ * getExtensions() returned (AgentSession constructor), while the factory-captured
67
+ * pi still points at the shared runtime. A child then hits the runtime's throwing
68
+ * "Extension runtime not initialized" stubs on the first provider request
69
+ * (pi-model-tools' before_provider_request calls pi.getAllTools()), or stale-ctx
70
+ * errors when the shared runtime is invalidated by the first child's dispose.
71
+ * A per-child loader keeps every captured `pi` pointing at a runtime this child
72
+ * both binds and owns.
73
+ *
74
+ * Project-extension trust is resolved from the parent (`projectTrusted`) so a
75
+ * child never prompts for trust (it has no UI). reload() per child re-reads
76
+ * extension files + re-runs factories; acceptable for short-lived children.
77
+ */
78
+ async function getExtensionLoader(cwd: string, projectTrusted: boolean): Promise<ResourceLoader> {
79
+ const loader = new DefaultResourceLoader({
80
+ cwd,
81
+ agentDir: getAgentDir(),
82
+ noSkills: true,
83
+ noPromptTemplates: true,
84
+ noContextFiles: true,
85
+ noThemes: true,
86
+ });
87
+ await loader.reload({ resolveProjectTrust: async () => projectTrusted });
88
+ // Log (don't throw on) extension load errors — a misbehaving extension must
89
+ // not crash the child; the agent simply won't have that tool.
90
+ const loadErrors = loader.getExtensions().errors;
91
+ if (loadErrors.length > 0) {
92
+ process.stderr.write(
93
+ `[pi-subagent] extension load warnings in child loader: ${loadErrors.map((e) => `${e.path}: ${e.error}`).join("; ")}\n`,
94
+ );
95
+ }
96
+ return loader;
97
+ }
98
+
49
99
  export interface SubAgentProgress {
50
100
  label: string;
51
101
  at: number;
@@ -97,23 +147,40 @@ export async function runSubAgent(options: {
97
147
  onProgress?: (progress: SubAgentProgress) => void;
98
148
  timeoutMs?: number;
99
149
  hardTimeoutMs?: number;
150
+ /**
151
+ * When true, build a DefaultResourceLoader so the child inherits the parent's
152
+ * extensions (and thus extension tools: web, serena, munin, …). When false or
153
+ * unset, the lean empty-loader stub is used (no extensions).
154
+ */
155
+ loadExtensions?: boolean;
156
+ /**
157
+ * Whether the parent session trusts the project. Forwarded to the loader's
158
+ * resolveProjectTrust so children inherit the parent's trust decision and
159
+ * never prompt for project-extension trust (children have no UI). Defaults true.
160
+ */
161
+ projectTrusted?: boolean;
100
162
  }): Promise<SubAgentResult> {
101
163
  const {
102
164
  cwd, systemPrompt, task, tools, model, modelRuntime, authStorage, modelRegistry, signal,
103
165
  agentName = "subagent", thinkingLevel = "off", onMessage, onProgress,
104
166
  timeoutMs = DEFAULT_INACTIVITY_TIMEOUT_MS, hardTimeoutMs = HARD_TIMEOUT_MS,
167
+ loadExtensions = false, projectTrusted = true,
105
168
  } = options;
106
169
  const result: SubAgentResult = {
107
170
  agent: agentName, task, exitCode: 0, messages: [], stderr: "",
108
171
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
109
172
  model: `${model.provider}/${model.id}`, status: undefined,
110
173
  };
111
- const resourceLoader: ResourceLoader = {
112
- getExtensions: () => ({ extensions: [], errors: [], runtime: createExtensionRuntime() }),
113
- getSkills: () => ({ skills: [], diagnostics: [] }), getPrompts: () => ({ prompts: [], diagnostics: [] }),
114
- getThemes: () => ({ themes: [], diagnostics: [] }), getAgentsFiles: () => ({ agentsFiles: [] }),
115
- getSystemPrompt: () => systemPrompt, getAppendSystemPrompt: () => [], extendResources: () => {}, reload: async () => {},
116
- };
174
+ const resourceLoader: ResourceLoader = loadExtensions
175
+ ? await getExtensionLoader(cwd, projectTrusted)
176
+ : {
177
+ getExtensions: () => ({ extensions: [], errors: [], runtime: createExtensionRuntime() }),
178
+ getSkills: () => ({ skills: [], diagnostics: [] }), getPrompts: () => ({ prompts: [], diagnostics: [] }),
179
+ getThemes: () => ({ themes: [], diagnostics: [] }), getAgentsFiles: () => ({ agentsFiles: [] }),
180
+ getSystemPrompt: () => systemPrompt, getSystemPromptSource: () => undefined,
181
+ getAppendSystemPrompt: () => [], getAppendSystemPromptSources: () => [],
182
+ extendResources: () => {}, reload: async () => {},
183
+ };
117
184
  const settingsManager = SettingsManager.inMemory({ compaction: { enabled: false }, retry: { enabled: true, maxRetries: 1 } });
118
185
  const startedAt = Date.now();
119
186
  let inactivityDeadline = startedAt + timeoutMs;
@@ -12,8 +12,12 @@ import * as path from "node:path";
12
12
  // Constants
13
13
  // ---------------------------------------------------------------------------
14
14
 
15
- /** Tools that child agents may use. The subagent tool is never included. */
16
- export const ALLOWED_CHILD_TOOLS = [
15
+ /**
16
+ * Pi's built-in tools. Agents whose effective tool set is a subset of these
17
+ * run in the cheap lean loader (no extensions loaded). Any other tool name
18
+ * (web_search, serena_*, munin_*, …) requires loading the parent's extensions.
19
+ */
20
+ export const BUILTIN_TOOLS = [
17
21
  "read",
18
22
  "grep",
19
23
  "find",
@@ -23,6 +27,16 @@ export const ALLOWED_CHILD_TOOLS = [
23
27
  "write",
24
28
  ] as const;
25
29
 
30
+ /** Backwards-compatible alias. */
31
+ export const ALLOWED_CHILD_TOOLS = BUILTIN_TOOLS;
32
+
33
+ /**
34
+ * Tools never available to child agents, regardless of inheritance.
35
+ * `subagent` is the only delegation primitive today; if another extension ever
36
+ * adds a delegation/spawn tool, it must be added here too (recursion guard).
37
+ */
38
+ export const DENIED_CHILD_TOOLS = new Set(["subagent"]);
39
+
26
40
  export const READ_ONLY_TOOLS: readonly string[] = ["read", "grep", "find", "ls"];
27
41
  export const MUTATION_TOOLS: readonly string[] = ["edit", "write"];
28
42
  export const EXECUTION_TOOLS: readonly string[] = ["bash"];
@@ -208,6 +222,14 @@ export interface ValidateToolsOptions {
208
222
  tools: string[];
209
223
  /** When true, only read-only tools are permitted. Mutation/execution tools are rejected. */
210
224
  readOnly?: boolean;
225
+ /**
226
+ * Parent session's registered tool names. When provided, a tool is accepted if
227
+ * it is a built-in OR in this set (and not in DENIED_CHILD_TOOLS) — this is
228
+ * how children inherit extension tools (web, serena, munin, …). When omitted,
229
+ * only BUILTIN_TOOLS are accepted (lean/legacy mode).
230
+ * Callers must pass a stable snapshot and not mutate it afterwards.
231
+ */
232
+ availableTools?: readonly string[];
211
233
  }
212
234
 
213
235
  export interface ValidateToolsResult {
@@ -224,8 +246,20 @@ export interface ValidateToolsResult {
224
246
  * Deduplicates tool names.
225
247
  * When `readOnly` is true, only READ_ONLY_TOOLS are permitted.
226
248
  */
249
+ /**
250
+ * Whether a tool set contains any extension tool (non-built-in). When true the
251
+ * runner loads the parent's extensions into the child; when false it uses the
252
+ * lean empty-loader stub, saving system-prompt tokens. Recon agents with an
253
+ * explicit built-in-only `tools` line stay cheap automatically.
254
+ */
255
+ export function needsExtensions(tools: readonly string[]): boolean {
256
+ const builtins = new Set<string>(BUILTIN_TOOLS);
257
+ return tools.some((t) => !builtins.has(t));
258
+ }
259
+
227
260
  export function validateAgentTools(options: ValidateToolsOptions): ValidateToolsResult {
228
- const { readOnly = false } = options;
261
+ const { readOnly = false, availableTools } = options;
262
+ const extensionTools = availableTools ? new Set(availableTools) : undefined;
229
263
  const seen = new Set<string>();
230
264
  const tools: string[] = [];
231
265
  const errors: string[] = [];
@@ -234,16 +268,23 @@ export function validateAgentTools(options: ValidateToolsOptions): ValidateTools
234
268
  const tool = raw.trim();
235
269
  if (!tool) continue;
236
270
 
237
- // Reject subagent regardless of casing (Pi tool names are case-sensitive
238
- // but "subagent" should never pass through).
239
- if (tool.toLowerCase() === "subagent") {
240
- errors.push(`Tool "subagent" is not allowed in child agents (recursive delegation is prevented).`);
271
+ // Silently strip denied tools (e.g. "subagent") regardless of casing. They
272
+ // are never available to children — whether explicitly listed in an agent's
273
+ // `tools:` line or inherited via parentToolNames. This matches Claude Code,
274
+ // which removes denied tools from subagents "even when listed in the tools
275
+ // field". No error: inheritance must not crash on a tool the child can't have.
276
+ if (DENIED_CHILD_TOOLS.has(tool.toLowerCase())) {
241
277
  continue;
242
278
  }
243
279
 
244
- // Check against allowlist
245
- if (!ALLOWED_CHILD_TOOLS.includes(tool as any)) {
246
- errors.push(`Unknown tool "${tool}". Allowed tools: ${ALLOWED_CHILD_TOOLS.join(", ")}.`);
280
+ // Check against allowlist: built-ins, plus inherited extension tools when availableTools given.
281
+ const isBuiltin = BUILTIN_TOOLS.includes(tool as any);
282
+ const isExtension = extensionTools?.has(tool) ?? false;
283
+ if (!isBuiltin && !isExtension) {
284
+ const allowed = extensionTools
285
+ ? "parent tools"
286
+ : BUILTIN_TOOLS.join(", ");
287
+ errors.push(`Unknown tool "${tool}". Allowed tools: ${allowed}.`);
247
288
  continue;
248
289
  }
249
290
 
@@ -5,6 +5,7 @@ import { resolveModel } from "./model.ts";
5
5
  import {
6
6
  isRateLimitError,
7
7
  validateAgentTools,
8
+ needsExtensions,
8
9
  normalizeTimeout,
9
10
  resolveSafeCwd,
10
11
  MAX_INSTRUCTIONS_LENGTH,
@@ -39,6 +40,8 @@ export async function runNamedAgent(options: {
39
40
  timeout?: number;
40
41
  instructions?: string;
41
42
  signal?: AbortSignal;
43
+ /** When true, only read-only tools are permitted regardless of agent.sandbox. */
44
+ readOnly?: boolean;
42
45
  onMessage?: (result: SubAgentResult) => void;
43
46
  onProgress?: (progress: SubAgentProgress) => void;
44
47
  }): Promise<SubAgentResult> {
@@ -52,17 +55,25 @@ export async function runNamedAgent(options: {
52
55
  // Security: validate and normalise timeout.
53
56
  const effectiveTimeoutMs = normalizeTimeout({ requested: options.timeout }).timeoutMs;
54
57
 
55
- // Security: validate tools against allowlist.
56
- let rawTools = options.agent.tools ?? ["read", "bash", "edit", "write", "grep", "find", "ls"];
58
+ // Parent tool names — agents without an explicit `tools` line inherit them.
59
+ const parentToolNames = (options.ctx as any).getAllTools?.()?.map((t: { name: string }) => t.name) as string[] | undefined;
60
+
61
+ // Security: validate tools against allowlist (built-ins ∪ inherited parent tools).
62
+ // readOnly from the caller (e.g. pi-review) is enforced even when agent.sandbox
63
+ // is unset — never trust a caller-supplied tool list without the read-only filter.
64
+ const effectiveReadOnly = options.readOnly === true || options.agent.sandbox === "read-only";
65
+ let rawTools = options.agent.tools ?? parentToolNames ?? ["read", "bash", "edit", "write", "grep", "find", "ls"];
57
66
  // Enforce read-only sandbox: strip mutating and execution tools
58
- if (options.agent.sandbox === "read-only") {
67
+ if (effectiveReadOnly) {
59
68
  rawTools = rawTools.filter(t => READ_ONLY_TOOLS.includes(t));
60
69
  if (rawTools.length === 0) rawTools = [...READ_ONLY_TOOLS];
61
70
  }
62
- const toolValidation = validateAgentTools({ tools: rawTools, readOnly: options.agent.sandbox === "read-only" });
71
+ const toolValidation = validateAgentTools({ tools: rawTools, readOnly: effectiveReadOnly, availableTools: parentToolNames });
63
72
  if (toolValidation.errors.length > 0) {
64
73
  throw new Error(`Tool validation errors for agent "${options.agent.name}": ${toolValidation.errors.join("; ")}`);
65
74
  }
75
+ const loadExtensions = needsExtensions(toolValidation.tools);
76
+ const projectTrusted = options.ctx.isProjectTrusted();
66
77
 
67
78
  // Security: validate cwd (service caller must provide valid cwd).
68
79
  // The service path uses the same policy as the tool path.
@@ -119,6 +130,8 @@ export async function runNamedAgent(options: {
119
130
  thinkingLevel: options.agent.thinking,
120
131
  onMessage: options.onMessage,
121
132
  onProgress: options.onProgress,
133
+ loadExtensions,
134
+ projectTrusted,
122
135
  });
123
136
 
124
137
  if (result.errorMessage && isRateLimitError(result.errorMessage)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-subagent",
3
- "version": "0.12.3",
3
+ "version": "0.13.0",
4
4
  "description": "In-process subagents for Pi with isolated SDK sessions, parallel and chained delegation, and inspectable threads.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -56,17 +56,17 @@
56
56
  "check": "npm run typecheck && npm test"
57
57
  },
58
58
  "peerDependencies": {
59
- "@earendil-works/pi-coding-agent": ">=0.80.0 <0.83.0",
60
- "@earendil-works/pi-ai": ">=0.80.0 <0.83.0",
61
- "@earendil-works/pi-agent-core": ">=0.80.0 <0.83.0",
62
- "@earendil-works/pi-tui": ">=0.80.0 <0.83.0",
59
+ "@earendil-works/pi-coding-agent": ">=0.80.0 <0.84.0",
60
+ "@earendil-works/pi-ai": ">=0.80.0 <0.84.0",
61
+ "@earendil-works/pi-agent-core": ">=0.80.0 <0.84.0",
62
+ "@earendil-works/pi-tui": ">=0.80.0 <0.84.0",
63
63
  "typebox": ">=1.3.0 <2.0.0"
64
64
  },
65
65
  "devDependencies": {
66
- "@earendil-works/pi-agent-core": "^0.82.0",
67
- "@earendil-works/pi-ai": "^0.82.0",
68
- "@earendil-works/pi-coding-agent": "^0.82.0",
69
- "@earendil-works/pi-tui": "^0.82.0",
66
+ "@earendil-works/pi-agent-core": "^0.83.0",
67
+ "@earendil-works/pi-ai": "^0.83.0",
68
+ "@earendil-works/pi-coding-agent": "^0.83.0",
69
+ "@earendil-works/pi-tui": "^0.83.0",
70
70
  "@types/mocha": "^10.0.10",
71
71
  "@types/node": "^20.19.43",
72
72
  "mocha": "^10.8.2",