@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 +72 -0
- package/README.md +24 -3
- package/agent-format.md +2 -1
- package/agents/general-purpose.md +0 -1
- package/extensions/agents.ts +4 -4
- package/extensions/index.ts +27 -8
- package/extensions/render.ts +10 -0
- package/extensions/runner.ts +199 -14
- package/extensions/security.ts +51 -10
- package/extensions/service.ts +18 -4
- package/package.json +1 -1
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 |
|
|
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 |
|
|
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.
|
|
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
|
package/extensions/agents.ts
CHANGED
|
@@ -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
|
package/extensions/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
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)) {
|
package/extensions/render.ts
CHANGED
|
@@ -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
|
|
package/extensions/runner.ts
CHANGED
|
@@ -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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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];
|
package/extensions/security.ts
CHANGED
|
@@ -12,8 +12,12 @@ import * as path from "node:path";
|
|
|
12
12
|
// Constants
|
|
13
13
|
// ---------------------------------------------------------------------------
|
|
14
14
|
|
|
15
|
-
/**
|
|
16
|
-
|
|
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
|
-
//
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
246
|
-
|
|
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
|
|
package/extensions/service.ts
CHANGED
|
@@ -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
|
-
//
|
|
56
|
-
|
|
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 (
|
|
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:
|
|
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