@bacnh85/pi-subagent 0.12.4 → 0.14.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,77 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.14.0 (2026-08-05)
4
+
5
+ ### Git worktree isolation (`sandbox: worktree`)
6
+
7
+ Agents can now run in an isolated **git worktree** instead of the parent's
8
+ working tree — the safe way to run parallel implementation agents that edit
9
+ files. Set `sandbox: worktree` in agent frontmatter (see `agent-format.md`).
10
+
11
+ - Child file mutations land in `.pi-worktrees/<id>` under the repo root; the
12
+ main checkout stays untouched, so two parallel `worker` agents can never
13
+ clobber each other's edits.
14
+ - On completion, a unified diff of the child's changes is returned as
15
+ `result.patch` and shown in the thread viewer as a `🌿 worktree` badge.
16
+ - **Merging is explicit** — the parent receives the diff and applies it via
17
+ `apply_patch` / cherry-pick / discard; nothing is auto-merged.
18
+ - The worktree is removed in a `finally` on success, error, or abort.
19
+ - Falls back to in-process execution with a warning when the cwd is not a git
20
+ repo (`ponytail`: isolation optimization, not a hard requirement).
21
+ - Wired through the `subagent` tool, the service path (`pi-subagent:run` for
22
+ pi-review), and `runner.ts`'s `runSubAgent` (new `sandbox` + `exec` options).
23
+
24
+ ## 0.13.0 (2026-07-31)
25
+
26
+ ### Subagents inherit parent extensions & tools by default
27
+
28
+ Subagents can now use the same tools the main agent has — including extension
29
+ tools like `web_search`, `serena_*`, `munin_*`, `obsidian`, and `notebooklm`.
30
+ Previously children were restricted to the 7 Pi built-in tools (`read, grep,
31
+ find, ls, bash, edit, write`) and could not load extensions, which made
32
+ delegation far less capable than the main agent.
33
+
34
+ This follows the **Claude Code model**: subagents inherit the parent's tool set,
35
+ with a small denylist (`subagent` — recursive delegation is always prevented)
36
+ and per-agent restriction via an explicit `tools:` line.
37
+
38
+ **Tool resolution:**
39
+ - Agent **omits** `tools:` → inherits **all parent tools** (minus denylist).
40
+ `worker` and `general-purpose` now do this.
41
+ - Agent **specifies** `tools:` → restricted to that list, validated against
42
+ built-ins ∪ parent tools.
43
+ - `sandbox: read-only` / `readOnly` → filters the effective set to read-only.
44
+ - Denied tools (`subagent`) are **silently stripped**, never errored — whether
45
+ explicitly listed or inherited. Inheritance must not crash on a tool the
46
+ child cannot have (the inherited set always includes `subagent`).
47
+
48
+ **Smart lean optimization:** extensions are only loaded when the effective tool
49
+ set contains at least one non-built-in tool. Recon agents with a built-in-only
50
+ `tools:` line (scout, tester, planner, reviewer) stay cheap — zero extension
51
+ overhead, same fast cold-start.
52
+
53
+ **Per-child extension loader.** Children that need extensions get a FRESH
54
+ `DefaultResourceLoader` each run (extensions only: no skills, prompt templates,
55
+ AGENTS.md, or themes). The loader must NOT be cached/shared: extensions capture
56
+ the ExtensionAPI at factory-load time, and its actions delegate to the runtime
57
+ the factory was given (pi.getAllTools() → runtime.getAllTools()). A shared
58
+ loader's runtime is never the one any single child binds — children then hit
59
+ the runtime's throwing "Extension runtime not initialized" stubs on the first
60
+ provider request (pi-model-tools' before_provider_request calls pi.getAllTools())
61
+ or stale-ctx errors after the first child's dispose invalidates the shared
62
+ runtime. A per-child loader keeps every captured `pi` pointing at a runtime the
63
+ child both binds and owns. reload() per child re-reads extension files +
64
+ re-runs factories; acceptable for short-lived children.
65
+
66
+ Extension load errors in children are logged, not fatal. Project-extension trust
67
+ is inherited from the parent (children never prompt).
68
+
69
+ ### Bundled agent changes
70
+
71
+ - `worker` and `general-purpose`: removed the explicit `tools:` line so they
72
+ inherit all parent tools.
73
+ - `scout`, `tester`, `planner`, `reviewer`: unchanged (still lean + restricted).
74
+
3
75
  ## 0.12.4 (2026-07-30)
4
76
 
5
77
  ### 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
 
@@ -82,6 +84,25 @@ Child agent tools are validated against a fixed allowlist:
82
84
 
83
85
  Unknown or misspelled tool names produce clear diagnostics. Duplicate tool names are deduplicated.
84
86
 
87
+ ### Git worktree isolation (`sandbox: worktree`)
88
+
89
+ Set `sandbox: worktree` in an agent's frontmatter to run it in an isolated git
90
+ worktree (`.pi-worktrees/<id>` under the repo root) instead of the parent's
91
+ working tree. This is the safe way to run **parallel implementation** agents:
92
+ two `worker` agents editing the same files can no longer clobber each other —
93
+ each writes into its own checkout.
94
+
95
+ - All file mutations land in the worktree; the main checkout stays untouched.
96
+ - On completion, a unified diff of the child's changes is returned in the
97
+ result and shown in the thread viewer as a `🌿 worktree` badge.
98
+ - **Merging is explicit**: the parent receives the diff and applies it via
99
+ `apply_patch` / cherry-pick / discard. Nothing is auto-merged.
100
+ - The worktree is removed on completion (success, error, or abort).
101
+ - Requires git; when the cwd is not a git repo, the agent falls back to
102
+ in-process execution with a warning (`ponytail`: isolation optimization,
103
+ not a hard requirement).
104
+
105
+
85
106
  ### Timeouts
86
107
 
87
108
  Every child execution receives a timeout:
package/agent-format.md CHANGED
@@ -24,7 +24,7 @@ models: # Optional ordered fallbacks; comma form also accepted
24
24
  - provider/fast-model
25
25
  - provider/backup-model
26
26
  thinking: low # Optional: off|minimal|low|medium|high|xhigh|max.
27
- sandbox: read-only # Optional: read-only | workspace-write. Auto-derives tool restrictions.
27
+ sandbox: read-only # Optional: read-only | workspace-write | worktree. Auto-derives tool restrictions.
28
28
  color: cyan # Optional: red|blue|green|yellow|purple|orange|pink|cyan.
29
29
  ---
30
30
  ```
@@ -33,6 +33,7 @@ color: cyan # Optional: red|blue|green|yellow|purple|orange|pink|c
33
33
 
34
34
  - `read-only`: Restricts tools to `read`, `grep`, `find`, `ls`. Overrides any `tools` field.
35
35
  - `workspace-write` (default): Uses the agent's `tools` list or defaults to all tools.
36
+ - `worktree`: Runs the agent in an isolated git worktree (`.pi-worktrees/<id>` under the repo root). All file mutations land in the worktree; the main checkout is untouched. On completion, a unified diff of the changes is returned in the result (visible in the thread viewer as a `🌿 worktree` badge) — the parent merges explicitly via `apply_patch`/cherry-pick; nothing is applied automatically. Falls back to in-process execution when the cwd is not a git repo (with a warning). Requires git.
36
37
 
37
38
  ### `color`
38
39
 
@@ -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
@@ -22,7 +22,7 @@ export interface AgentConfig {
22
22
  model?: string;
23
23
  models?: string[];
24
24
  thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
25
- sandbox?: "read-only" | "workspace-write";
25
+ sandbox?: "read-only" | "workspace-write" | "worktree";
26
26
  color?: AgentColor;
27
27
  systemPrompt: string;
28
28
  source: "user" | "project" | "bundled";
@@ -203,7 +203,7 @@ function loadAgentsFromDir(
203
203
  }
204
204
 
205
205
  if (typeof frontmatter.sandbox === "string" && frontmatter.sandbox) {
206
- const validSandboxes = ["read-only", "workspace-write"];
206
+ const validSandboxes = ["read-only", "workspace-write", "worktree"];
207
207
  if (!validSandboxes.includes(frontmatter.sandbox)) {
208
208
  diagnostics.push({
209
209
  filePath,
@@ -231,8 +231,8 @@ function loadAgentsFromDir(
231
231
  thinking: typeof frontmatter.thinking === "string" && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(frontmatter.thinking)
232
232
  ? frontmatter.thinking as AgentConfig["thinking"]
233
233
  : undefined,
234
- sandbox: typeof frontmatter.sandbox === "string" && ["read-only", "workspace-write"].includes(frontmatter.sandbox)
235
- ? frontmatter.sandbox as "read-only" | "workspace-write"
234
+ sandbox: typeof frontmatter.sandbox === "string" && ["read-only", "workspace-write", "worktree"].includes(frontmatter.sandbox)
235
+ ? frontmatter.sandbox as "read-only" | "workspace-write" | "worktree"
236
236
  : undefined,
237
237
  color: typeof frontmatter.color === "string" && VALID_COLORS.includes(frontmatter.color as any)
238
238
  ? frontmatter.color as AgentColor
@@ -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) {
@@ -631,6 +647,7 @@ export default function (pi: ExtensionAPI) {
631
647
 
632
648
  const result = await runSubAgent({
633
649
  cwd: safeCwd,
650
+ sandbox: agent.sandbox === "worktree" ? "worktree" : undefined,
634
651
  systemPrompt: params.instructions
635
652
  ? `${agent.systemPrompt}\n\n## Task Contract\n${params.instructions.slice(0, MAX_INSTRUCTIONS_LENGTH)}`
636
653
  : agent.systemPrompt,
@@ -646,6 +663,8 @@ export default function (pi: ExtensionAPI) {
646
663
  thinkingLevel: agent.thinking,
647
664
  onMessage: onProgress,
648
665
  onProgress: onActivity,
666
+ loadExtensions,
667
+ projectTrusted,
649
668
  });
650
669
 
651
670
  if (result.errorMessage && isRateLimitError(result.errorMessage)) {
@@ -236,6 +236,15 @@ export function renderSingleResult(
236
236
  container.addChild(new Spacer(1));
237
237
  container.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
238
238
  }
239
+ if (result.patch) {
240
+ container.addChild(new Spacer(1));
241
+ container.addChild(new Text(theme.fg("success", "🌿 worktree"), 0, 0));
242
+ const patchLines = result.patch.split("\n").length;
243
+ container.addChild(new Text(theme.fg("dim", `${patchLines} diff lines — merge explicitly via apply_patch/cherry-pick`), 0, 0));
244
+ if (result.patch !== "(no changes)") {
245
+ container.addChild(new Text(theme.fg("muted", result.patch.slice(0, 2000)), 0, 0));
246
+ }
247
+ }
239
248
  return container;
240
249
  }
241
250
 
@@ -258,6 +267,7 @@ export function renderSingleResult(
258
267
  }
259
268
  const usageStr = formatUsageStats(result.usage, result.model);
260
269
  if (usageStr) text += `\n${theme.fg("dim", usageStr)}`;
270
+ if (result.patch) text += `\n${theme.fg("success", "🌿 worktree")} (${result.patch.split("\n").length} diff lines)`;
261
271
  return new Text(text, 0, 0);
262
272
  }
263
273
 
@@ -16,9 +16,12 @@
16
16
 
17
17
  import type { Message, Model } from "@earendil-works/pi-ai";
18
18
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
19
+ import * as path from "node:path";
19
20
  import {
20
21
  createAgentSession,
21
22
  createExtensionRuntime,
23
+ DefaultResourceLoader,
24
+ getAgentDir,
22
25
  type ResourceLoader,
23
26
  SessionManager,
24
27
  SettingsManager,
@@ -46,6 +49,54 @@ export interface UsageStats {
46
49
  export const DEFAULT_INACTIVITY_TIMEOUT_MS = 3 * 60 * 1000;
47
50
  export const HARD_TIMEOUT_MS = 20 * 60 * 1000;
48
51
 
52
+ // ---------------------------------------------------------------------------
53
+ // Extension resource loader
54
+ // ---------------------------------------------------------------------------
55
+
56
+ /**
57
+ * Build a FRESH lean DefaultResourceLoader for this child — no skills, prompt
58
+ * templates, context files (AGENTS.md), or themes. Extensions are loaded per
59
+ * child so their factories run against a runtime that THIS child's session will
60
+ * bind.
61
+ *
62
+ * Do NOT cache/share this loader. Extensions capture the ExtensionAPI (`pi`)
63
+ * at factory load time, and its actions delegate to the runtime passed to the
64
+ * factory (see createExtensionAPI: pi.getAllTools() → runtime.getAllTools()).
65
+ * If extensions were loaded once against a shared/cached runtime, that runtime
66
+ * is never the one any single child binds: a session binds the runtime its own
67
+ * getExtensions() returned (AgentSession constructor), while the factory-captured
68
+ * pi still points at the shared runtime. A child then hits the runtime's throwing
69
+ * "Extension runtime not initialized" stubs on the first provider request
70
+ * (pi-model-tools' before_provider_request calls pi.getAllTools()), or stale-ctx
71
+ * errors when the shared runtime is invalidated by the first child's dispose.
72
+ * A per-child loader keeps every captured `pi` pointing at a runtime this child
73
+ * both binds and owns.
74
+ *
75
+ * Project-extension trust is resolved from the parent (`projectTrusted`) so a
76
+ * child never prompts for trust (it has no UI). reload() per child re-reads
77
+ * extension files + re-runs factories; acceptable for short-lived children.
78
+ */
79
+ async function getExtensionLoader(cwd: string, projectTrusted: boolean): Promise<ResourceLoader> {
80
+ const loader = new DefaultResourceLoader({
81
+ cwd,
82
+ agentDir: getAgentDir(),
83
+ noSkills: true,
84
+ noPromptTemplates: true,
85
+ noContextFiles: true,
86
+ noThemes: true,
87
+ });
88
+ await loader.reload({ resolveProjectTrust: async () => projectTrusted });
89
+ // Log (don't throw on) extension load errors — a misbehaving extension must
90
+ // not crash the child; the agent simply won't have that tool.
91
+ const loadErrors = loader.getExtensions().errors;
92
+ if (loadErrors.length > 0) {
93
+ process.stderr.write(
94
+ `[pi-subagent] extension load warnings in child loader: ${loadErrors.map((e) => `${e.path}: ${e.error}`).join("; ")}\n`,
95
+ );
96
+ }
97
+ return loader;
98
+ }
99
+
49
100
  export interface SubAgentProgress {
50
101
  label: string;
51
102
  at: number;
@@ -65,6 +116,8 @@ export interface SubAgentResult {
65
116
  model?: string;
66
117
  stopReason?: string;
67
118
  errorMessage?: string;
119
+ /** Unified diff of changes made in an isolated worktree (sandbox: "worktree"). */
120
+ patch?: string;
68
121
  /** Canonical result status (added in 0.6.0). */
69
122
  status?: SubagentStatus;
70
123
  }
@@ -85,6 +138,8 @@ export async function runSubAgent(options: {
85
138
  task: string;
86
139
  tools: string[];
87
140
  model: Model<any>;
141
+ /** "worktree" runs the child in an isolated git worktree; the resulting diff is returned as result.patch. */
142
+ sandbox?: "worktree";
88
143
  /** Pi 0.80.10's canonical credential/model runtime. */
89
144
  modelRuntime?: unknown;
90
145
  /** Legacy Pi SDK session options retained for 0.80.6 tests and hosts. */
@@ -97,25 +152,42 @@ export async function runSubAgent(options: {
97
152
  onProgress?: (progress: SubAgentProgress) => void;
98
153
  timeoutMs?: number;
99
154
  hardTimeoutMs?: number;
155
+ /** Exec used for the git worktree lifecycle (add/remove/diff). Defaults to a child_process spawn when unset. */
156
+ exec?: (command: string, args: string[], options?: { cwd?: string; timeout?: number }) => Promise<{ code: number; stdout: string; stderr: string }>;
157
+ /**
158
+ * When true, build a DefaultResourceLoader so the child inherits the parent's
159
+ * extensions (and thus extension tools: web, serena, munin, …). When false or
160
+ * unset, the lean empty-loader stub is used (no extensions).
161
+ */
162
+ loadExtensions?: boolean;
163
+ /**
164
+ * Whether the parent session trusts the project. Forwarded to the loader's
165
+ * resolveProjectTrust so children inherit the parent's trust decision and
166
+ * never prompt for project-extension trust (children have no UI). Defaults true.
167
+ */
168
+ projectTrusted?: boolean;
100
169
  }): Promise<SubAgentResult> {
101
170
  const {
102
171
  cwd, systemPrompt, task, tools, model, modelRuntime, authStorage, modelRegistry, signal,
103
172
  agentName = "subagent", thinkingLevel = "off", onMessage, onProgress,
104
173
  timeoutMs = DEFAULT_INACTIVITY_TIMEOUT_MS, hardTimeoutMs = HARD_TIMEOUT_MS,
174
+ loadExtensions = false, projectTrusted = true, sandbox, exec,
105
175
  } = options;
106
176
  const result: SubAgentResult = {
107
177
  agent: agentName, task, exitCode: 0, messages: [], stderr: "",
108
178
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
109
179
  model: `${model.provider}/${model.id}`, status: undefined,
110
180
  };
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, getSystemPromptSource: () => undefined,
116
- getAppendSystemPrompt: () => [], getAppendSystemPromptSources: () => [],
117
- extendResources: () => {}, reload: async () => {},
118
- };
181
+ const resourceLoader: ResourceLoader = loadExtensions
182
+ ? await getExtensionLoader(cwd, projectTrusted)
183
+ : {
184
+ getExtensions: () => ({ extensions: [], errors: [], runtime: createExtensionRuntime() }),
185
+ getSkills: () => ({ skills: [], diagnostics: [] }), getPrompts: () => ({ prompts: [], diagnostics: [] }),
186
+ getThemes: () => ({ themes: [], diagnostics: [] }), getAgentsFiles: () => ({ agentsFiles: [] }),
187
+ getSystemPrompt: () => systemPrompt, getSystemPromptSource: () => undefined,
188
+ getAppendSystemPrompt: () => [], getAppendSystemPromptSources: () => [],
189
+ extendResources: () => {}, reload: async () => {},
190
+ };
119
191
  const settingsManager = SettingsManager.inMemory({ compaction: { enabled: false }, retry: { enabled: true, maxRetries: 1 } });
120
192
  const startedAt = Date.now();
121
193
  let inactivityDeadline = startedAt + timeoutMs;
@@ -145,12 +217,36 @@ export async function runSubAgent(options: {
145
217
  result.status = classifyStopReason(result.stopReason, !timedOut, timedOut);
146
218
  return result;
147
219
  }
148
- const { session } = await createAgentSession({
149
- cwd, model, thinkingLevel, resourceLoader, tools, sessionManager: SessionManager.inMemory(cwd), settingsManager,
150
- ...(modelRuntime ? { modelRuntime } : {}),
151
- // Pi 0.80.10 owns credentials in ModelRuntime; older SDKs still accept these.
152
- ...(authStorage ? { authStorage, modelRegistry } : {}),
153
- } as any);
220
+
221
+ // Isolated git worktree: the child edits a sibling checkout; the resulting
222
+ // diff is returned as result.patch and the worktree is removed on exit.
223
+ // ponytail: falls back to the in-process cwd when git is unavailable — the
224
+ // worktree is an isolation optimization, not a hard requirement.
225
+ let worktreeDir: string | undefined;
226
+ let childCwd = cwd;
227
+ if (sandbox === "worktree") {
228
+ const wt = await createWorktree(cwd, exec);
229
+ if (wt.ok) {
230
+ worktreeDir = wt.path;
231
+ childCwd = wt.path!;
232
+ } else if (wt.error) {
233
+ result.stderr = `Worktree unavailable (${wt.error}); running in workspace.`;
234
+ }
235
+ }
236
+
237
+ let session: Awaited<ReturnType<typeof createAgentSession>>["session"];
238
+ try {
239
+ const created = await createAgentSession({
240
+ cwd: childCwd, model, thinkingLevel, resourceLoader, tools, sessionManager: SessionManager.inMemory(childCwd), settingsManager,
241
+ ...(modelRuntime ? { modelRuntime } : {}),
242
+ // Pi 0.80.10 owns credentials in ModelRuntime; older SDKs still accept these.
243
+ ...(authStorage ? { authStorage, modelRegistry } : {}),
244
+ } as any);
245
+ session = created.session;
246
+ } catch (error) {
247
+ if (worktreeDir) await removeWorktree(cwd, worktreeDir, exec);
248
+ throw error;
249
+ }
154
250
  let unsubscribe: (() => void) | undefined;
155
251
  let removeAbort: (() => void) | undefined;
156
252
  try {
@@ -192,10 +288,17 @@ export async function runSubAgent(options: {
192
288
  else if (combinedSignal.aborted) { result.stopReason = "aborted"; result.errorMessage ||= "Sub-agent aborted"; }
193
289
  result.status = classifyStopReason(result.stopReason, result.stopReason === "aborted", result.stopReason === "timeout");
194
290
  result.exitCode = result.status === "success" || result.status === "partial" ? 0 : 1;
291
+ if (worktreeDir) {
292
+ // Capture the child's changes as a unified diff before tearing down.
293
+ const diff = await captureWorktreeDiff(cwd, worktreeDir, exec);
294
+ if (diff.ok) result.patch = diff.diff;
295
+ else if (diff.error) result.stderr = result.stderr ? `${result.stderr}; diff unavailable (${diff.error})` : `Diff unavailable (${diff.error})`;
296
+ }
195
297
  return result;
196
298
  } finally {
197
299
  unsubscribe?.(); removeAbort?.();
198
300
  try { session.dispose(); } catch { /* best effort */ }
301
+ if (worktreeDir) await removeWorktree(cwd, worktreeDir, exec);
199
302
  }
200
303
  } catch (error) {
201
304
  result.exitCode = 1;
@@ -212,6 +315,88 @@ export async function runSubAgent(options: {
212
315
  // Helpers
213
316
  // ---------------------------------------------------------------------------
214
317
 
318
+ /** Minimal exec fallback (child_process spawn) used when no exec is injected. */
319
+ async function defaultExec(
320
+ command: string,
321
+ args: string[],
322
+ options?: { cwd?: string; timeout?: number },
323
+ ): Promise<{ code: number; stdout: string; stderr: string }> {
324
+ const { spawn } = await import("node:child_process");
325
+ return new Promise((resolve) => {
326
+ const child = spawn(command, args, { cwd: options?.cwd, stdio: ["ignore", "pipe", "pipe"] });
327
+ // Decode each chunk as a complete UTF-8 stream (Buffer.toString per chunk
328
+ // would corrupt multi-byte sequences straddling chunk boundaries).
329
+ child.stdout.setEncoding("utf8");
330
+ child.stderr.setEncoding("utf8");
331
+ let stdout = ""; let stderr = "";
332
+ const timer = options?.timeout ? setTimeout(() => child.kill("SIGKILL"), options.timeout) : undefined;
333
+ child.stdout.on("data", (d) => { stdout += d; });
334
+ child.stderr.on("data", (d) => { stderr += d; });
335
+ child.on("error", (err) => { if (timer) clearTimeout(timer); resolve({ code: 1, stdout, stderr: String(err.message ?? err) }); });
336
+ child.on("close", (code) => { if (timer) clearTimeout(timer); resolve({ code: code ?? 1, stdout, stderr }); });
337
+ });
338
+ }
339
+
340
+ async function runGit(
341
+ cwd: string,
342
+ args: string[],
343
+ exec?: (command: string, args: string[], options?: { cwd?: string; timeout?: number }) => Promise<{ code: number; stdout: string; stderr: string }>,
344
+ ): Promise<{ ok: boolean; stdout: string; stderr: string }> {
345
+ const run = exec ?? defaultExec;
346
+ const res = await run("git", args, { cwd, timeout: 30_000 });
347
+ return { ok: res.code === 0, stdout: res.stdout, stderr: res.stderr };
348
+ }
349
+
350
+ /** Create a detached git worktree at .pi-worktrees/<rand> under the repo root. */
351
+ export async function createWorktree(
352
+ cwd: string,
353
+ exec?: (command: string, args: string[], options?: { cwd?: string; timeout?: number }) => Promise<{ code: number; stdout: string; stderr: string }>,
354
+ ): Promise<{ ok: boolean; path?: string; error?: string }> {
355
+ try {
356
+ const root = await runGit(cwd, ["rev-parse", "--show-toplevel"], exec);
357
+ if (!root.ok) return { ok: false, error: root.stderr.trim() || "not a git repo" };
358
+ const repoRoot = root.stdout.trim();
359
+ if (!repoRoot) return { ok: false, error: "empty git root" };
360
+ const id = `pi-subagent-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
361
+ const wtPath = path.join(repoRoot, ".pi-worktrees", id);
362
+ const add = await runGit(repoRoot, ["worktree", "add", "--detach", wtPath, "HEAD"], exec);
363
+ if (!add.ok) return { ok: false, error: add.stderr.trim() || "git worktree add failed" };
364
+ return { ok: true, path: wtPath };
365
+ } catch (error) {
366
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
367
+ }
368
+ }
369
+
370
+ /** Unified diff of ALL changes in the worktree vs HEAD (tracked edits, staged
371
+ * changes, AND new untracked files). Stages first so untracked files are
372
+ * captured — the worktree is removed right after, so mutating its index is
373
+ * safe. A single `diff --cached HEAD` avoids duplicate hunks from combining
374
+ * `diff HEAD` + `diff --cached`. */
375
+ export async function captureWorktreeDiff(
376
+ repoRoot: string,
377
+ worktreeDir: string,
378
+ exec?: (command: string, args: string[], options?: { cwd?: string; timeout?: number }) => Promise<{ code: number; stdout: string; stderr: string }>,
379
+ ): Promise<{ ok: boolean; diff?: string; error?: string }> {
380
+ const add = await runGit(worktreeDir, ["add", "-A"], exec);
381
+ if (!add.ok) return { ok: false, error: add.stderr.trim() || "git add -A failed" };
382
+ const diff = await runGit(worktreeDir, ["diff", "--cached", "HEAD"], exec);
383
+ if (!diff.ok) return { ok: false, error: diff.stderr.trim() || "git diff --cached HEAD failed" };
384
+ const text = diff.stdout.trim();
385
+ return { ok: true, diff: text || "(no changes)" };
386
+ }
387
+
388
+ /** Remove a worktree and its metadata, best-effort. */
389
+ export async function removeWorktree(
390
+ repoRoot: string,
391
+ worktreeDir: string,
392
+ exec?: (command: string, args: string[], options?: { cwd?: string; timeout?: number }) => Promise<{ code: number; stdout: string; stderr: string }>,
393
+ ): Promise<void> {
394
+ try {
395
+ await runGit(repoRoot, ["worktree", "remove", "--force", worktreeDir], exec);
396
+ } catch { /* best effort */ }
397
+ }
398
+
399
+
215
400
  export function getFinalOutput(messages: Message[]): string {
216
401
  for (let i = messages.length - 1; i >= 0; i--) {
217
402
  const msg = messages[i];
@@ -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.
@@ -106,6 +117,7 @@ export async function runNamedAgent(options: {
106
117
 
107
118
  const result = await runSubAgent({
108
119
  cwd: safeCwd.path,
120
+ sandbox: options.agent.sandbox === "worktree" ? "worktree" : undefined,
109
121
  systemPrompt: contract ? `${options.agent.systemPrompt}\n\n## Task Contract\n${contract}` : options.agent.systemPrompt,
110
122
  task: options.task,
111
123
  tools: toolValidation.tools,
@@ -119,6 +131,8 @@ export async function runNamedAgent(options: {
119
131
  thinkingLevel: options.agent.thinking,
120
132
  onMessage: options.onMessage,
121
133
  onProgress: options.onProgress,
134
+ loadExtensions,
135
+ projectTrusted,
122
136
  });
123
137
 
124
138
  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.4",
3
+ "version": "0.14.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",