@tintinweb/pi-subagents 0.10.1 → 0.10.3
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 +14 -0
- package/README.md +23 -1
- package/dist/agent-manager.d.ts +12 -0
- package/dist/agent-manager.js +70 -6
- package/dist/agent-runner.d.ts +14 -0
- package/dist/agent-runner.js +63 -16
- package/dist/agent-types.d.ts +1 -0
- package/dist/agent-types.js +2 -0
- package/dist/custom-agents.js +1 -0
- package/dist/index.js +104 -7
- package/dist/settings.d.ts +13 -0
- package/dist/settings.js +6 -0
- package/dist/types.d.ts +4 -0
- package/dist/ui/conversation-viewer.d.ts +5 -1
- package/dist/ui/conversation-viewer.js +10 -5
- package/dist/ui/viewer-keys.d.ts +20 -0
- package/dist/ui/viewer-keys.js +17 -0
- package/dist/worktree.d.ts +8 -1
- package/dist/worktree.js +12 -3
- package/examples/agent-tool-description.md +42 -0
- package/package.json +1 -1
- package/src/agent-manager.ts +77 -6
- package/src/agent-runner.ts +76 -16
- package/src/agent-types.ts +3 -0
- package/src/custom-agents.ts +1 -0
- package/src/index.ts +106 -8
- package/src/settings.ts +19 -0
- package/src/types.ts +4 -1
- package/src/ui/conversation-viewer.ts +9 -4
- package/src/ui/viewer-keys.ts +39 -0
- package/src/worktree.ts +20 -4
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.10.3] - 2026-06-12
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **`SpawnOptions.cwd` — spawn a subagent in a different working directory** ([#96](https://github.com/tintinweb/pi-subagents/issues/96) — thanks [@madeleineostoja](https://github.com/madeleineostoja)). For RPC/programmatic callers (not exposed on the `Agent` tool — the LLM-visible surface is unchanged). The agent's tools operate in the target directory and the prompt's environment block describes it, but **`.pi` config keeps loading from the parent session's project** (new `RunOptions.configCwd` split): the target's `.pi` extensions never execute, and its agents/skills/settings/memory are not picked up — spawning into an untrusted directory sends a worker there with the parent's toolbox, rather than "opening pi there." Composes with `isolation: "worktree"`: the worktree is created *from* the target directory's repo, the agent works at the equivalent subdirectory inside the copy (a monorepo-package cwd keeps its scoping instead of silently widening to the repo root — new `WorktreeInfo.workPath`), and the resulting `pi-agent-*` branch lands in that repo, with the completion message naming it so the orchestrator merges in the right place. Validation is strict, typed, and early — non-strings, relative paths, nonexistent paths, and files all throw curated errors at `spawn()` (before queueing) and are re-checked at queue drain, surfacing as RPC error envelopes (`null` is treated as unset). On dispose, worktree registrations are pruned in every repo that received one; only a hard crash can leave a stale entry (then: `git worktree prune` in the target repo).
|
|
14
|
+
|
|
15
|
+
## [0.10.2] - 2026-06-10
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- **`exclude_extensions:` agent frontmatter — extension denylist for subagents** ([#94](https://github.com/tintinweb/pi-subagents/issues/94) — thanks [@ramhaidar](https://github.com/ramhaidar)). Applied after the `extensions:` include set; exclude wins, including over `tools: ext:` selectors (an excluded extension never loads, so its `ext:` reference becomes the usual orphan warning). The key use case: `extensions: true` + `exclude_extensions: pi-notify` — all extensions except a noisy one, without hand-maintaining an allowlist. Plain canonical names only (case-insensitive); paths, `*`, and unmatched names fire `extension-error:…` warnings (warn-not-abort, as with `extensions:` mismatches); `extensions: false` + an exclude warns that the exclude has no effect. **Not a sandbox:** excluded extensions' factory code still executes once during loading — exclusion suppresses handler binding and tool registration, not load-time side effects. The negation syntax `extensions: ["*", "!name"]` was deliberately rejected: an unquoted `!name` is a YAML tag and silently mis-parses.
|
|
19
|
+
- **`toolDescriptionMode` setting — opt-in compact Agent tool description** ([#91](https://github.com/tintinweb/pi-subagents/issues/91) — thanks [@tiberiuichim](https://github.com/tiberiuichim)). The full Claude Code-style description costs ~1,400 tokens with the default agents and grows with each custom agent (the type list embeds full agent descriptions) — significant for small/local models. `toolDescriptionMode: "compact"` (via `/agents → Settings → Tool description` or `subagents.json`) swaps in a ~75% smaller description: one-line type list (first sentence of each agent description), terse usage notes, per-option details left to the parameter descriptions. Default `"full"` is byte-identical to before — the rich description's guardrails are deliberately load-bearing and stay the default. A third mode, `"custom"`, registers a user-authored description from `<cwd>/.pi/agent-tool-description.md` (project) or `<agentDir>/agent-tool-description.md` (global; project wins), with `{{placeholder}}` substitution keeping the dynamic parts live — `{{typeList}}`, `{{compactTypeList}}`, `{{agentDir}}`, `{{scheduleGuideline}}` — so a hand-written description can't drift out of sync with the registered agents (the advertised-vs-spawnable staleness [#92](https://github.com/tintinweb/pi-subagents/issues/92) just fixed). Unknown placeholders are left verbatim with a stderr warning; a missing/empty file falls back to `"full"`. Only the prose is customizable — the parameter schema stays code-owned. A ready-made starting point ships at `examples/agent-tool-description.md`, reproducing the full description exactly (CI-enforced byte-identical, so the example can't go stale). Like `schedulingEnabled`, the mode is read at tool registration — changing it applies on the next pi session. The issue's original ask (move the description to a skill) isn't possible in pi: tools must register their description in the tool schema for the model to call them; skills are lazily-loaded instructions, not tool registrations.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- **Conversation viewer honors custom `tui.select.*` keybindings** ([#99](https://github.com/tintinweb/pi-subagents/issues/99) — thanks [@owenniles](https://github.com/owenniles)). The viewer hardcoded its scroll keys and discarded the `KeybindingsManager` pi injects into `ctx.ui.custom()`, so user bindings (e.g. emacs-style `ctrl+p`/`ctrl+n` on `tui.select.up`/`down`) worked in pi core selectors but not here. Scrolling now resolves through `tui.select.up`/`down`/`pageUp`/`pageDown`; the viewer-specific `k`/`j` and `shift+arrow` aliases still work alongside, and behavior without custom bindings is unchanged (the `tui.select.*` defaults are the previously hardcoded keys).
|
|
23
|
+
|
|
10
24
|
## [0.10.1] - 2026-06-10
|
|
11
25
|
|
|
12
26
|
### Added
|
package/README.md
CHANGED
|
@@ -196,6 +196,7 @@ All fields are optional — sensible defaults for everything.
|
|
|
196
196
|
| `display_name` | — | Display name for UI (e.g. widget, agent list) |
|
|
197
197
|
| `tools` | all 7 | Which tools the agent can call. Built-in names (`read, grep, …`), `*` / `all` (all built-ins), `none`, and `ext:<extension>` / `ext:<extension>/<tool>` selectors for extension tools. See [Tool & extension scoping](#tool--extension-scoping) below |
|
|
198
198
|
| `extensions` | `true` | Which extensions to load for the agent. `true` (all defaults), `false` (none), or an explicit list: `[mcp, "/abs/path.ts", "*"]`. See [Tool & extension scoping](#tool--extension-scoping) below |
|
|
199
|
+
| `exclude_extensions` | — | Extension denylist applied after `extensions:` — exclude wins. Plain names only (case-insensitive), no paths or `*`. Useful with `extensions: true` to drop one extension (e.g. `pi-notify`) |
|
|
199
200
|
| `skills` | `true` | Inherit skills from parent. Can be a comma-separated list of skill names to preload (see [Skill Preloading](#skill-preloading) for discovery locations) |
|
|
200
201
|
| `memory` | — | Persistent agent memory scope: `project`, `local`, or `user`. Auto-detects read-only agents |
|
|
201
202
|
| `disallowed_tools` | — | Comma-separated tools to deny even if extensions provide them |
|
|
@@ -227,6 +228,8 @@ extensions: false # no extensions load
|
|
|
227
228
|
extensions: [mcp] # only mcp loads
|
|
228
229
|
extensions: ["*", "/abs/foo.ts"] # all defaults plus one path-loaded extension
|
|
229
230
|
|
|
231
|
+
exclude_extensions: pi-notify # everything except pi-notify (with extensions: true)
|
|
232
|
+
|
|
230
233
|
# Specialist: load one extension, expose only one of its tools, keep built-ins
|
|
231
234
|
extensions: [mcp]
|
|
232
235
|
tools: "*, ext:mcp/search"
|
|
@@ -240,6 +243,8 @@ A few rules the examples don't make obvious:
|
|
|
240
243
|
- Any `ext:` entry flips extension tools to an explicit allowlist — unnamed extensions still load (handlers fire) but expose no tools. So `tools: "*, ext:mcp/search"` exposes only `search` from `mcp`, nothing from any other extension.
|
|
241
244
|
- Extension names match case-insensitively (`[Mcp]` = `[mcp]`); tool names in `ext:foo/bar` stay case-sensitive.
|
|
242
245
|
- Plain `tools:` typos fail loudly: `tools: reed, grep` fires `tools-error:…` instead of silently producing an under-tooled agent.
|
|
246
|
+
- `exclude_extensions:` wins over `extensions:` and over `ext:` selectors — an excluded extension never loads and a `tools: ext:` entry can't pull it back. Plain names only (no paths, no `*`); a name matching nothing fires an `extension-error:…` warning.
|
|
247
|
+
- `exclude_extensions:` is **not a sandbox**: excluded extensions' factory code still executes once during loading — exclusion suppresses their handlers and tools, not their load-time side effects. Don't rely on it to contain an untrusted extension.
|
|
243
248
|
- Array and string forms are equivalent: `[a, b]` == `"a, b"`.
|
|
244
249
|
|
|
245
250
|
## Tools
|
|
@@ -365,7 +370,7 @@ When on, each subagent spawn's effective model is validated against pi's own `en
|
|
|
365
370
|
|
|
366
371
|
## Persistent Settings
|
|
367
372
|
|
|
368
|
-
Runtime tuning values set via `/agents` → Settings (max concurrency, default max turns, grace turns, default join mode, scheduling on/off, scope models on/off, disable defaults on/off) persist across pi restarts. Two files, merged on load:
|
|
373
|
+
Runtime tuning values set via `/agents` → Settings (max concurrency, default max turns, grace turns, default join mode, scheduling on/off, scope models on/off, disable defaults on/off, tool description full/compact/custom) persist across pi restarts. Two files, merged on load:
|
|
369
374
|
|
|
370
375
|
- **Global:** `~/.pi/agent/subagents.json` — your machine-wide defaults. Edit by hand; the `/agents` menu never writes here.
|
|
371
376
|
- **Project:** `<cwd>/.pi/subagents.json` — per-project overrides. Written by `/agents` → Settings.
|
|
@@ -374,6 +379,21 @@ Runtime tuning values set via `/agents` → Settings (max concurrency, default m
|
|
|
374
379
|
|
|
375
380
|
**Disable defaults** (`disableDefaultAgents`, default `false`): when on, the three built-in agents (general-purpose, Explore, Plan) are not registered — only your `.pi/agents/*.md` agents are advertised and spawnable. User-defined agents are unaffected, including ones that override a default by name. The Agent tool's type list updates on the next pi session (the tool schema is registered at startup).
|
|
376
381
|
|
|
382
|
+
**Tool description** (`toolDescriptionMode`, default `"full"`): which Agent tool description the LLM sees. `"full"` is the rich Claude Code-style prompt (~1,400 tokens with the default agents); `"compact"` is ~75% smaller — one-line agent type list, terse usage notes — for small/local models where tool-spec tokens are expensive. Per-option details stay in the parameter descriptions in every mode (the parameter schema is never customizable). Applies on the next pi session.
|
|
383
|
+
|
|
384
|
+
`"custom"` registers your own description from `<cwd>/.pi/agent-tool-description.md` (project) or `<agentDir>/agent-tool-description.md` (global; project wins). The file is read once at tool registration, so edits also apply on the next pi session. Dynamic parts stay live via placeholders — a static agent list would go stale the moment you add a custom agent:
|
|
385
|
+
|
|
386
|
+
```markdown
|
|
387
|
+
Launch an autonomous agent. Available types:
|
|
388
|
+
{{typeList}}
|
|
389
|
+
|
|
390
|
+
Custom agents live in .pi/agents/ or {{agentDir}}/agents/.
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
Placeholders: `{{typeList}}` (full per-agent descriptions), `{{compactTypeList}}` (first sentence each), `{{agentDir}}`, `{{scheduleGuideline}}` (expands with its own leading newline + `- ` bullet when scheduling is on — place it directly after your last rule line; empty when scheduling is off). Unknown placeholders are left verbatim with a stderr warning; a missing or empty file falls back to `"full"` with a warning. Note the usual trust umbrella: a project-level file shapes the orchestrator's prompt, same as project agents and extensions do.
|
|
394
|
+
|
|
395
|
+
**Starting point:** copy [`examples/agent-tool-description.md`](examples/agent-tool-description.md) — it reproduces the default full description exactly (a CI test keeps it in sync), so you can trim from a known-good baseline instead of writing from scratch.
|
|
396
|
+
|
|
377
397
|
**Example — global defaults for a beefy machine:**
|
|
378
398
|
|
|
379
399
|
```bash
|
|
@@ -463,6 +483,8 @@ pi.events.emit("subagents:rpc:spawn", {
|
|
|
463
483
|
|
|
464
484
|
`options.model` accepts either a `Model` object (e.g. `ctx.model`) or a `"provider/modelId"` string — strings are resolved against `ctx.modelRegistry` at the RPC boundary, so cross-extension callers can forward serializable values without losing auth context.
|
|
465
485
|
|
|
486
|
+
`options.cwd` (absolute path to an existing directory — anything else returns an error envelope; `null` means unset) runs the agent in a different working directory than the parent session. Its tools operate there and the prompt's environment block describes it, but **`.pi` config still loads from the parent session's project** — the target directory's `.pi` extensions never execute, and its agents/skills/settings are not picked up. Combined with `isolation: "worktree"`, the worktree is created *from* the target directory's repo, the agent works at the equivalent subdirectory inside the copy (a monorepo-package cwd stays scoped to that package), and the resulting `pi-agent-*` branch lands in that repo — the completion message names it. On session end, worktree registrations are pruned in every repo that received one; only a hard crash can leave a stale entry (then: `git worktree prune` in the target repo). Agents with `memory:` keep reading/writing the parent project's memory.
|
|
487
|
+
|
|
466
488
|
### Stop
|
|
467
489
|
|
|
468
490
|
Stop a running agent by ID:
|
package/dist/agent-manager.d.ts
CHANGED
|
@@ -32,6 +32,15 @@ interface SpawnOptions {
|
|
|
32
32
|
bypassQueue?: boolean;
|
|
33
33
|
/** Isolation mode — "worktree" creates a temp git worktree for the agent. */
|
|
34
34
|
isolation?: IsolationMode;
|
|
35
|
+
/**
|
|
36
|
+
* Working directory for the agent (absolute path). Default: parent session
|
|
37
|
+
* cwd. The agent's tools operate here, but .pi config (extensions, skills,
|
|
38
|
+
* settings, memory) still loads from the parent session's project — the
|
|
39
|
+
* target directory's `.pi` extensions never execute. With isolation:
|
|
40
|
+
* "worktree", the worktree is created FROM this directory and the result
|
|
41
|
+
* branch lands in that repo.
|
|
42
|
+
*/
|
|
43
|
+
cwd?: string;
|
|
35
44
|
/** Resolved invocation snapshot captured for UI display. */
|
|
36
45
|
invocation?: AgentInvocation;
|
|
37
46
|
/** Parent abort signal — when aborted, the subagent is also stopped. */
|
|
@@ -60,6 +69,9 @@ export declare class AgentManager {
|
|
|
60
69
|
private onStart?;
|
|
61
70
|
private onCompact?;
|
|
62
71
|
private maxConcurrent;
|
|
72
|
+
/** Base repos worktrees were created from — so dispose() can prune them all,
|
|
73
|
+
* not just the parent repo (caller-supplied cwd can target other repos). */
|
|
74
|
+
private worktreeRepos;
|
|
63
75
|
/** Queue of background agents waiting to start. */
|
|
64
76
|
private queue;
|
|
65
77
|
/** Number of currently running background agents. */
|
package/dist/agent-manager.js
CHANGED
|
@@ -6,11 +6,36 @@
|
|
|
6
6
|
* Foreground agents bypass the queue (they block the parent anyway).
|
|
7
7
|
*/
|
|
8
8
|
import { randomUUID } from "node:crypto";
|
|
9
|
+
import { statSync } from "node:fs";
|
|
10
|
+
import { isAbsolute } from "node:path";
|
|
9
11
|
import { resumeAgent, runAgent } from "./agent-runner.js";
|
|
10
12
|
import { addUsage } from "./usage.js";
|
|
11
13
|
import { cleanupWorktree, createWorktree, pruneWorktrees, } from "./worktree.js";
|
|
12
14
|
/** Default max concurrent background agents. */
|
|
13
15
|
const DEFAULT_MAX_CONCURRENT = 4;
|
|
16
|
+
/**
|
|
17
|
+
* Validate a caller-supplied SpawnOptions.cwd. `undefined`/`null` mean "unset"
|
|
18
|
+
* (parent cwd). Anything else must be an absolute path to an existing
|
|
19
|
+
* directory — curated errors instead of TypeErrors from path/fs internals
|
|
20
|
+
* (RPC callers send arbitrary JSON: null, numbers, file paths).
|
|
21
|
+
*/
|
|
22
|
+
function assertValidSpawnCwd(cwd) {
|
|
23
|
+
if (cwd == null)
|
|
24
|
+
return;
|
|
25
|
+
if (typeof cwd !== "string" || !isAbsolute(cwd)) {
|
|
26
|
+
throw new Error(`SpawnOptions.cwd must be an absolute path: "${String(cwd)}"`);
|
|
27
|
+
}
|
|
28
|
+
let isDirectory = false;
|
|
29
|
+
try {
|
|
30
|
+
isDirectory = statSync(cwd).isDirectory();
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
throw new Error(`SpawnOptions.cwd does not exist: "${cwd}"`);
|
|
34
|
+
}
|
|
35
|
+
if (!isDirectory) {
|
|
36
|
+
throw new Error(`SpawnOptions.cwd is not a directory: "${cwd}"`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
14
39
|
export class AgentManager {
|
|
15
40
|
agents = new Map();
|
|
16
41
|
cleanupInterval;
|
|
@@ -18,6 +43,9 @@ export class AgentManager {
|
|
|
18
43
|
onStart;
|
|
19
44
|
onCompact;
|
|
20
45
|
maxConcurrent;
|
|
46
|
+
/** Base repos worktrees were created from — so dispose() can prune them all,
|
|
47
|
+
* not just the parent repo (caller-supplied cwd can target other repos). */
|
|
48
|
+
worktreeRepos = new Set();
|
|
21
49
|
/** Queue of background agents waiting to start. */
|
|
22
50
|
queue = [];
|
|
23
51
|
/** Number of currently running background agents. */
|
|
@@ -45,6 +73,10 @@ export class AgentManager {
|
|
|
45
73
|
* If the concurrency limit is reached, the agent is queued.
|
|
46
74
|
*/
|
|
47
75
|
spawn(pi, ctx, type, prompt, options) {
|
|
76
|
+
// Validate before the queue branch — a queued spawn should fail at the
|
|
77
|
+
// call, not minutes later at drain. Throw (not warn): programmatic callers
|
|
78
|
+
// can fix and retry; the RPC layer converts throws into error envelopes.
|
|
79
|
+
assertValidSpawnCwd(options.cwd);
|
|
48
80
|
const id = randomUUID().slice(0, 17);
|
|
49
81
|
const abortController = new AbortController();
|
|
50
82
|
const record = {
|
|
@@ -79,18 +111,33 @@ export class AgentManager {
|
|
|
79
111
|
}
|
|
80
112
|
/** Actually start an agent (called immediately or from queue drain). */
|
|
81
113
|
startAgent(id, record, { pi, ctx, type, prompt, options }) {
|
|
114
|
+
// Re-validate a caller-supplied cwd: queued spawns can start minutes after
|
|
115
|
+
// spawn()'s check, and the directory may be gone by then (TOCTOU). Same
|
|
116
|
+
// curated errors; drainQueue parks a throw on the record as an error.
|
|
117
|
+
assertValidSpawnCwd(options.cwd);
|
|
118
|
+
// Single resolution point for the caller-supplied cwd — the worktree base
|
|
119
|
+
// repo and both cleanup calls below MUST agree on this value forever.
|
|
120
|
+
const customCwd = options.cwd ?? undefined; // null (RPC "unset") → undefined
|
|
121
|
+
const baseCwd = customCwd ?? ctx.cwd;
|
|
82
122
|
// Worktree isolation: try to create a temporary git worktree. Strict —
|
|
83
123
|
// fail loud if not possible (no silent fallback to main tree). Done
|
|
84
124
|
// BEFORE state mutation so a throw doesn't leave the record half-running.
|
|
85
125
|
let worktreeCwd;
|
|
86
126
|
if (options.isolation === "worktree") {
|
|
87
|
-
const wt = createWorktree(
|
|
127
|
+
const wt = createWorktree(baseCwd, id);
|
|
88
128
|
if (!wt) {
|
|
89
129
|
throw new Error('Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' +
|
|
90
130
|
'Initialize git and commit at least once, or omit `isolation`.');
|
|
91
131
|
}
|
|
92
132
|
record.worktree = wt;
|
|
93
|
-
|
|
133
|
+
// workPath preserves subdirectory scoping for caller-supplied cwds: a
|
|
134
|
+
// cwd deep in a monorepo maps to the same subdir inside the copy, not
|
|
135
|
+
// the copied repo's root. Plain worktree spawns keep the historical
|
|
136
|
+
// behavior (agent at the copy's root) — moving them to workPath would
|
|
137
|
+
// also move .pi config discovery when the parent session sits in a repo
|
|
138
|
+
// subdirectory, silently dropping extensions/skills.
|
|
139
|
+
worktreeCwd = customCwd !== undefined ? wt.workPath : wt.path;
|
|
140
|
+
this.worktreeRepos.add(baseCwd);
|
|
94
141
|
}
|
|
95
142
|
record.status = "running";
|
|
96
143
|
record.startedAt = Date.now();
|
|
@@ -113,7 +160,13 @@ export class AgentManager {
|
|
|
113
160
|
isolated: options.isolated,
|
|
114
161
|
inheritContext: options.inheritContext,
|
|
115
162
|
thinkingLevel: options.thinkingLevel,
|
|
116
|
-
|
|
163
|
+
// Worktree wins for the working dir (the agent must run in the copy —
|
|
164
|
+
// which, with a custom cwd, was created from that target). Config stays
|
|
165
|
+
// with the parent project when a caller-supplied cwd is in play; it must
|
|
166
|
+
// stay undefined otherwise so plain worktree runs keep resolving config
|
|
167
|
+
// (incl. relative extension paths and memory) inside the worktree copy.
|
|
168
|
+
cwd: worktreeCwd ?? customCwd,
|
|
169
|
+
configCwd: customCwd !== undefined ? ctx.cwd : undefined,
|
|
117
170
|
signal: record.abortController.signal,
|
|
118
171
|
onToolActivity: (activity) => {
|
|
119
172
|
if (activity.type === "end")
|
|
@@ -162,11 +215,14 @@ export class AgentManager {
|
|
|
162
215
|
}
|
|
163
216
|
// Clean up worktree if used
|
|
164
217
|
if (record.worktree) {
|
|
165
|
-
const wtResult = cleanupWorktree(
|
|
218
|
+
const wtResult = cleanupWorktree(baseCwd, record.worktree, options.description);
|
|
166
219
|
record.worktreeResult = wtResult;
|
|
167
220
|
if (wtResult.hasChanges && wtResult.branch) {
|
|
221
|
+
// With a caller-supplied cwd the branch lives in THAT repo, not the
|
|
222
|
+
// parent session's — say so, or the orchestrator merges in the wrong repo.
|
|
223
|
+
const repoNote = customCwd !== undefined ? ` in \`${baseCwd}\`` : "";
|
|
168
224
|
record.result = (record.result ?? "") +
|
|
169
|
-
`\n\n---\nChanges saved to branch \`${wtResult.branch}
|
|
225
|
+
`\n\n---\nChanges saved to branch \`${wtResult.branch}\`${repoNote}. Merge with: \`git merge ${wtResult.branch}\`${customCwd !== undefined ? ` (run in \`${baseCwd}\`)` : ""}`;
|
|
170
226
|
}
|
|
171
227
|
}
|
|
172
228
|
if (options.isBackground) {
|
|
@@ -198,7 +254,7 @@ export class AgentManager {
|
|
|
198
254
|
// Best-effort worktree cleanup on error
|
|
199
255
|
if (record.worktree) {
|
|
200
256
|
try {
|
|
201
|
-
const wtResult = cleanupWorktree(
|
|
257
|
+
const wtResult = cleanupWorktree(baseCwd, record.worktree, options.description);
|
|
202
258
|
record.worktreeResult = wtResult;
|
|
203
259
|
}
|
|
204
260
|
catch { /* ignore cleanup errors */ }
|
|
@@ -387,5 +443,13 @@ export class AgentManager {
|
|
|
387
443
|
pruneWorktrees(process.cwd());
|
|
388
444
|
}
|
|
389
445
|
catch { /* ignore */ }
|
|
446
|
+
// Also prune repos that caller-supplied cwds created worktrees in — a clean
|
|
447
|
+
// exit with in-flight agents would otherwise leave stale registrations there.
|
|
448
|
+
for (const repo of this.worktreeRepos) {
|
|
449
|
+
try {
|
|
450
|
+
pruneWorktrees(repo);
|
|
451
|
+
}
|
|
452
|
+
catch { /* ignore */ }
|
|
453
|
+
}
|
|
390
454
|
}
|
|
391
455
|
}
|
package/dist/agent-runner.d.ts
CHANGED
|
@@ -82,6 +82,20 @@ export interface RunOptions {
|
|
|
82
82
|
thinkingLevel?: ThinkingLevel;
|
|
83
83
|
/** Override working directory (e.g. for worktree isolation). */
|
|
84
84
|
cwd?: string;
|
|
85
|
+
/**
|
|
86
|
+
* Where .pi config is discovered (project extensions, skills, pi settings,
|
|
87
|
+
* agent memory). Default: same as the working directory. The manager sets
|
|
88
|
+
* this to the parent session's cwd when `SpawnOptions.cwd` points the
|
|
89
|
+
* working directory elsewhere — the agent works *there* but carries the
|
|
90
|
+
* parent project's config (the target's `.pi` extensions never execute).
|
|
91
|
+
*
|
|
92
|
+
* WARNING for future callers: if you pass `cwd` pointing at a directory the
|
|
93
|
+
* user didn't open, you almost certainly must pass `configCwd` too —
|
|
94
|
+
* omitting it makes the target's `.pi` extensions execute in this process.
|
|
95
|
+
* (Worktree isolation is the one intentional exception: its copy IS the
|
|
96
|
+
* parent's repo, so config resolving inside it is correct.)
|
|
97
|
+
*/
|
|
98
|
+
configCwd?: string;
|
|
85
99
|
/** Called on tool start/end with activity info. */
|
|
86
100
|
onToolActivity?: (activity: ToolActivity) => void;
|
|
87
101
|
/** Called on streaming text deltas from the assistant response. */
|
package/dist/agent-runner.js
CHANGED
|
@@ -199,6 +199,9 @@ export async function runAgent(ctx, type, prompt, options) {
|
|
|
199
199
|
const agentConfig = getAgentConfig(type);
|
|
200
200
|
// Resolve working directory: worktree override > parent cwd
|
|
201
201
|
const effectiveCwd = options.cwd ?? ctx.cwd;
|
|
202
|
+
// Filesystem work happens in effectiveCwd; config discovery in configCwd.
|
|
203
|
+
// They differ only for SpawnOptions.cwd spawns (config stays with the parent).
|
|
204
|
+
const configCwd = options.configCwd ?? effectiveCwd;
|
|
202
205
|
const env = await detectEnv(options.pi, effectiveCwd);
|
|
203
206
|
// Get parent system prompt for append-mode agents
|
|
204
207
|
const parentSystemPrompt = ctx.getSystemPrompt();
|
|
@@ -206,10 +209,13 @@ export async function runAgent(ctx, type, prompt, options) {
|
|
|
206
209
|
const extras = {};
|
|
207
210
|
// Resolve extensions/skills: isolated overrides to false
|
|
208
211
|
const extensions = options.isolated ? false : config.extensions;
|
|
212
|
+
// Nulling excludes under isolated also suppresses the orphaned-exclude warning —
|
|
213
|
+
// isolation is an intentional override, not a misconfiguration.
|
|
214
|
+
const excludeExtensions = options.isolated ? undefined : config.excludeExtensions;
|
|
209
215
|
const skills = options.isolated ? false : config.skills;
|
|
210
216
|
// Skill preloading: when skills is string[], preload their content into prompt
|
|
211
217
|
if (Array.isArray(skills)) {
|
|
212
|
-
const loaded = preloadSkills(skills,
|
|
218
|
+
const loaded = preloadSkills(skills, configCwd);
|
|
213
219
|
if (loaded.length > 0) {
|
|
214
220
|
extras.skillBlocks = loaded;
|
|
215
221
|
}
|
|
@@ -227,14 +233,14 @@ export async function runAgent(ctx, type, prompt, options) {
|
|
|
227
233
|
const extraNames = getMemoryToolNames(existingNames);
|
|
228
234
|
if (extraNames.length > 0)
|
|
229
235
|
toolNames = [...toolNames, ...extraNames];
|
|
230
|
-
extras.memoryBlock = buildMemoryBlock(agentConfig.name, agentConfig.memory,
|
|
236
|
+
extras.memoryBlock = buildMemoryBlock(agentConfig.name, agentConfig.memory, configCwd);
|
|
231
237
|
}
|
|
232
238
|
else {
|
|
233
239
|
// Read-only memory: only add read tool name, use read-only prompt
|
|
234
240
|
const extraNames = getReadOnlyMemoryToolNames(existingNames);
|
|
235
241
|
if (extraNames.length > 0)
|
|
236
242
|
toolNames = [...toolNames, ...extraNames];
|
|
237
|
-
extras.memoryBlock = buildReadOnlyMemoryBlock(agentConfig.name, agentConfig.memory,
|
|
243
|
+
extras.memoryBlock = buildReadOnlyMemoryBlock(agentConfig.name, agentConfig.memory, configCwd);
|
|
238
244
|
}
|
|
239
245
|
}
|
|
240
246
|
// Build system prompt from agent config
|
|
@@ -274,22 +280,40 @@ export async function runAgent(ctx, type, prompt, options) {
|
|
|
274
280
|
const { extNames, narrowing } = parseExtSelectors(options.isolated ? [] : (agentConfig?.extSelectors ?? []));
|
|
275
281
|
const noExtensions = extensions === false;
|
|
276
282
|
const extensionsSpec = Array.isArray(extensions)
|
|
277
|
-
? parseExtensionsSpec(extensions,
|
|
283
|
+
? parseExtensionsSpec(extensions, configCwd)
|
|
278
284
|
: undefined;
|
|
279
285
|
const keepNames = extensionsSpec?.names ?? new Set();
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
//
|
|
286
|
+
// `exclude_extensions:` is a denylist applied AFTER the include set — exclude wins.
|
|
287
|
+
// Plain canonical names only (case-insensitive). Note: excluded extensions'
|
|
288
|
+
// factories still run once during reload() (see comment above) — exclusion
|
|
289
|
+
// suppresses handler binding and tool registration; it is not a sandbox.
|
|
290
|
+
const excludeNames = new Set((excludeExtensions ?? []).map((n) => n.toLowerCase()));
|
|
291
|
+
const hasExcludes = excludeNames.size > 0;
|
|
292
|
+
// The override filters loaded extensions down to `keepNames` minus `excludeNames`.
|
|
293
|
+
// It's only needed when we're neither loading everything without excludes
|
|
294
|
+
// (`extensions: true` or a `"*"` wildcard) nor nothing (`noExtensions`).
|
|
283
295
|
const loadAll = extensions === true || extensionsSpec?.wildcard === true;
|
|
284
296
|
const additionalExtensionPaths = extensionsSpec?.paths.length ? extensionsSpec.paths : undefined;
|
|
285
|
-
|
|
297
|
+
// Pre-filter discovered set, captured by the override — the exclude-typo warning
|
|
298
|
+
// must compare against this, not the surviving set (absence from survivors is
|
|
299
|
+
// an exclude *succeeding*).
|
|
300
|
+
let discoveredNames;
|
|
301
|
+
const extensionsOverride = noExtensions || (loadAll && !hasExcludes)
|
|
286
302
|
? undefined
|
|
287
|
-
: (base) =>
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
303
|
+
: (base) => {
|
|
304
|
+
discoveredNames = new Set(base.extensions.map((e) => extensionCanonicalName(e.path)));
|
|
305
|
+
return {
|
|
306
|
+
...base,
|
|
307
|
+
extensions: base.extensions.filter((e) => {
|
|
308
|
+
const name = extensionCanonicalName(e.path);
|
|
309
|
+
if (excludeNames.has(name))
|
|
310
|
+
return false; // exclude wins
|
|
311
|
+
return loadAll || keepNames.has(name);
|
|
312
|
+
}),
|
|
313
|
+
};
|
|
314
|
+
};
|
|
291
315
|
const loader = new DefaultResourceLoader({
|
|
292
|
-
cwd:
|
|
316
|
+
cwd: configCwd,
|
|
293
317
|
agentDir,
|
|
294
318
|
noExtensions,
|
|
295
319
|
additionalExtensionPaths,
|
|
@@ -325,13 +349,36 @@ export async function runAgent(ctx, type, prompt, options) {
|
|
|
325
349
|
// - `tools: ext:foo` but foo isn't in the loaded set (because `extensions:`
|
|
326
350
|
// didn't include it). Since v0.9, `ext:` no longer pulls extensions in;
|
|
327
351
|
// loading is `extensions:`-authoritative.
|
|
352
|
+
// An exclude_extensions: alongside extensions: false is contradictory — nothing
|
|
353
|
+
// loads, so there is nothing to exclude.
|
|
354
|
+
if (hasExcludes && noExtensions) {
|
|
355
|
+
options.onToolActivity?.({
|
|
356
|
+
type: "end",
|
|
357
|
+
toolName: `extension-error:exclude_extensions has no effect for agent "${type}" — extensions: false loads nothing`,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
// Exclude typo check: compares against the PRE-filter discovered set (an excluded
|
|
361
|
+
// name absent from the surviving set is the exclude working as intended). Also
|
|
362
|
+
// flags path-like and "*" entries — excludes are plain names only.
|
|
363
|
+
if (hasExcludes && discoveredNames) {
|
|
364
|
+
for (const name of excludeNames) {
|
|
365
|
+
if (!discoveredNames.has(name)) {
|
|
366
|
+
options.onToolActivity?.({
|
|
367
|
+
type: "end",
|
|
368
|
+
toolName: `extension-error:exclude_extensions: "${name}" for agent "${type}" did not match any discovered extension`,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
328
373
|
if (keepNames.size > 0 || extNames.size > 0) {
|
|
329
374
|
const survivingNames = new Set(loader.getExtensions().extensions.map((e) => extensionCanonicalName(e.path)));
|
|
330
375
|
for (const name of keepNames) {
|
|
331
376
|
if (!survivingNames.has(name)) {
|
|
332
377
|
options.onToolActivity?.({
|
|
333
378
|
type: "end",
|
|
334
|
-
toolName:
|
|
379
|
+
toolName: excludeNames.has(name)
|
|
380
|
+
? `extension-error:extension "${name}" is in both extensions: and exclude_extensions: for agent "${type}" — exclude wins`
|
|
381
|
+
: `extension-error:extension "${name}" requested by agent "${type}" was not loaded`,
|
|
335
382
|
});
|
|
336
383
|
}
|
|
337
384
|
}
|
|
@@ -339,7 +386,7 @@ export async function runAgent(ctx, type, prompt, options) {
|
|
|
339
386
|
if (!survivingNames.has(name)) {
|
|
340
387
|
options.onToolActivity?.({
|
|
341
388
|
type: "end",
|
|
342
|
-
toolName: `extension-error:ext:${name} referenced by agent "${type}" but extension "${name}" is not loaded (
|
|
389
|
+
toolName: `extension-error:ext:${name} referenced by agent "${type}" but extension "${name}" is not loaded (check extensions:/exclude_extensions:)`,
|
|
343
390
|
});
|
|
344
391
|
}
|
|
345
392
|
}
|
|
@@ -394,7 +441,7 @@ export async function runAgent(ctx, type, prompt, options) {
|
|
|
394
441
|
cwd: effectiveCwd,
|
|
395
442
|
agentDir,
|
|
396
443
|
sessionManager: SessionManager.inMemory(effectiveCwd),
|
|
397
|
-
settingsManager: SettingsManager.create(
|
|
444
|
+
settingsManager: SettingsManager.create(configCwd, agentDir),
|
|
398
445
|
modelRegistry: ctx.modelRegistry,
|
|
399
446
|
model,
|
|
400
447
|
tools: allowedTools,
|
package/dist/agent-types.d.ts
CHANGED
package/dist/agent-types.js
CHANGED
|
@@ -127,6 +127,7 @@ export function getConfig(type) {
|
|
|
127
127
|
description: config.description,
|
|
128
128
|
builtinToolNames: config.builtinToolNames ?? BUILTIN_TOOL_NAMES,
|
|
129
129
|
extensions: config.extensions,
|
|
130
|
+
excludeExtensions: config.excludeExtensions,
|
|
130
131
|
skills: config.skills,
|
|
131
132
|
promptMode: config.promptMode,
|
|
132
133
|
};
|
|
@@ -139,6 +140,7 @@ export function getConfig(type) {
|
|
|
139
140
|
description: gp.description,
|
|
140
141
|
builtinToolNames: gp.builtinToolNames ?? BUILTIN_TOOL_NAMES,
|
|
141
142
|
extensions: gp.extensions,
|
|
143
|
+
excludeExtensions: gp.excludeExtensions,
|
|
142
144
|
skills: gp.skills,
|
|
143
145
|
promptMode: gp.promptMode,
|
|
144
146
|
};
|
package/dist/custom-agents.js
CHANGED
|
@@ -52,6 +52,7 @@ function loadFromDir(dir, agents, source) {
|
|
|
52
52
|
extSelectors,
|
|
53
53
|
disallowedTools: csvListOptional(fm.disallowed_tools),
|
|
54
54
|
extensions: inheritField(fm.extensions ?? fm.inherit_extensions),
|
|
55
|
+
excludeExtensions: csvListOptional(fm.exclude_extensions),
|
|
55
56
|
skills: inheritField(fm.skills ?? fm.inherit_skills),
|
|
56
57
|
model: str(fm.model),
|
|
57
58
|
thinking: str(fm.thinking),
|