@tintinweb/pi-subagents 0.9.1 → 0.10.1

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
@@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.10.1] - 2026-06-10
11
+
12
+ ### Added
13
+ - **`disableDefaultAgents` setting** ([#92](https://github.com/tintinweb/pi-subagents/issues/92) — thanks [@TommyC81](https://github.com/TommyC81)). When on, the three built-in default agents (general-purpose, Explore, Plan) are skipped at registration — only user-defined `.pi/agents/*.md` agents are advertised and spawnable. User agents are unaffected, including ones overriding a default by name; with no user agents defined, spawning falls back to the hardcoded generic config. Off by default; toggle via `/agents → Settings → Disable defaults` or `disableDefaultAgents` in `subagents.json`. Like `schedulingEnabled`, the Agent tool's type list reflects the change on the next pi session (tool schema is registered at startup).
14
+
15
+ ### Fixed
16
+ - **Agents with `enabled: false` are no longer advertised in the Agent tool description** ([#92](https://github.com/tintinweb/pi-subagents/issues/92)). `buildTypeListText` listed every registered agent, including disabled ones that `isValidType` then refused to spawn — the LLM was offered types it could never use. The type list now filters through `getAvailableTypes()`, matching the `subagent_type` parameter description.
17
+ - **Agent tool type list no longer built from pre-settings state.** The description text was captured into a variable before persisted settings were applied; it's now built at tool-registration time, after `subagents:settings_loaded`.
18
+ - **Committed work from `isolation: "worktree"` subagents is now preserved** ([#68](https://github.com/tintinweb/pi-subagents/pull/68) — thanks [@rylwin](https://github.com/rylwin)). If an isolated subagent creates its own commit, cleanup previously saw a clean `git status`, treated it as "no changes", and removed the detached worktree — silently discarding the commits. The worktree now records its base SHA at creation, and cleanup creates the expected `pi-agent-*` branch whenever HEAD moved past it, even with a clean tree.
19
+ - **Automatic commits in isolated worktrees skip local Git hooks** ([#68](https://github.com/tintinweb/pi-subagents/pull/68)). The preservation commit at worktree cleanup now uses `--no-verify`, so a failing local pre-commit hook can't abort it (which previously surfaced as `hasChanges: false` — the agent's work lost).
20
+
21
+ ## [0.10.0] - 2026-06-01
22
+
23
+ > **⚠️ Breaking: `extensions:` and `tools:` in agent frontmatter semantics changed.** The `extensions: [...]` array now selects which extensions *load*, not which tool names surface. Agents that previously used the array form will behave differently — see migration below. The `tools:` field also grew new `ext:` and `*` selector forms; existing `tools:` values without these selectors are unchanged.
24
+ > - `extensions: [...]` is now an **extension allowlist applied at load time**, not a tool-name substring filter. Each entry is an extension *name*, a *path* (absolute, `~/`-prefixed, or relative-to-cwd), or `"*"`. **Migration:** `extensions: ["mcp"]` previously loaded *every* extension and then surfaced only tools whose names contained `mcp`. To keep all extensions, use `extensions: true` or `extensions: "*"`. To narrow, name the extensions or point at their files. `"*"` composes: `extensions: "*, /abs/path/extra-ext.ts"` is all defaults plus one path-loaded.
25
+ > - `tools:` now accepts `ext:` selectors and `*`. **Gotcha:** a `tools:` value containing **only** `ext:` entries yields **zero built-in tools** — add `*` (e.g. `tools: "*, ext:foo"`) to keep the built-ins. And **any** `ext:` entry flips extension tools to an explicit allowlist (non-listed extensions stay loaded but expose no tools). A `tools:` with no `ext:` entries is unchanged.
26
+ > - **`extensions:` is the sole loading authority.** `ext:foo` only narrows tool *exposure* within the already-loaded set; it cannot pull an extension in. `extensions: false` + `tools: "ext:foo"` loads nothing and warns that `ext:foo` is orphaned. To expose one extension's tool from an otherwise-narrow agent, name the extension explicitly: `extensions: [foo]` + `tools: "ext:foo/bar"`.
27
+
28
+ > **⚠️ Heads-up — widget glyphs changed (visual only):** turn count now renders as `↻N` (was `⟳N`) and compaction count as `⇊N` (was `↻N`). Fix for [#84](https://github.com/tintinweb/pi-subagents/issues/84) — `⟳` overflowed its cell in common monospace fonts. **No API, behavior, or output-format changes — only the glyphs.** If you grep agent stats lines or pipe widget output through scripts, update your patterns: `⟳` → `↻` (turns), `↻` → `⇊` (compactions).
29
+
30
+ ### Added
31
+ - **`tools:` accepts `ext:` extension-tool selectors and a `*` built-in wildcard.** Entries in the `tools:` CSV are now partitioned: plain names are the built-in allowlist (unchanged); `*` expands to all built-ins (symmetric with `extensions: "*"`); `ext:foo` / `ext:foo/bar` select extension tools. **Any `ext:` entry flips extension tools to an explicit allowlist** — only tools named by an `ext:` selector reach the LLM, and extensions not named stay loaded (their `session_start` etc. handlers still fire) but expose no tools. `ext:foo` exposes all of `foo`'s tools; `ext:foo/bar` narrows `foo` to just `bar` (multiple `ext:foo/x` entries union; a bare `ext:foo` alongside `ext:foo/bar` lets narrowing win). `ext:` is **narrowing-only** — it does not load extensions. `extensions:` remains the sole loading authority; an `ext:foo` against an extension that `extensions:` excluded (including `extensions: false`) is orphaned and warns via `onToolActivity` (`extension-error:ext:foo …`). With no `ext:` entry present, extension-tool behaviour is unchanged. `ext:` is name-only (matched by canonical name, so it composes with path-loaded extensions); paths still go in `extensions:`. `isolated: true` ignores `ext:` selectors.
32
+ - **Stop a running agent from the conversation viewer.** In `/agents → Running agents`, select an agent and press `x` (then `x` again to confirm) to abort it. The two-press guard prevents an accidental kill; the footer shows `x stop` → `x again to STOP`. This works for **background** agents — which a global `Esc` can't unambiguously target — while `Esc` still stops a blocking foreground `Agent` call. Wires the existing `AgentManager.abort(id)` to the viewer (`onStop` callback); the affordance only appears while the agent is `running`/`queued`. Addresses the common "how do I stop a background subagent?" question ([#88](https://github.com/tintinweb/pi-subagents/issues/88)).
33
+
34
+ ### Changed
35
+ - **BREAKING: `extensions: [...]` in agent frontmatter is now a loader-level extension allowlist, not a tool-name filter.** Previously a `string[]` value filtered exposed *tool names* by substring (`t.startsWith(e) || t.includes(e)`) while every discovered extension still loaded and ran its handlers. Now each entry selects an *extension*: a bare name keeps the matching default-discovered extension, a path (absolute, `~/`-prefixed, or relative-to-cwd) loads that extension fresh via `additionalExtensionPaths`, and `"*"` keeps all default-discovered extensions. Entries compose — `["*", "/abs/foo.ts"]` is all defaults plus foo, `["mcp", "/abs/foo.ts"]` is just those two. Excluded extensions no longer bind handlers or register tools (their factory still runs once during `reload()`). Directory extensions (`foo/index.ts`) match by the parent directory name. **Extension names match case-insensitively** (`extensions: [Mcp]` resolves the same as `[mcp]`); tool names within `ext:foo/bar` selectors remain case-sensitive (they're matched against pi-mono's registered identifiers). Unmatched names and failed paths warn via `onToolActivity` but do not abort the subagent (see the heads-up above for migration).
36
+ - **Non-normal subagent outcomes are now stated explicitly in the text delivered to the parent**, so the orchestrator can't mistake a stopped/incomplete agent for a completed one. The foreground `Agent` result, `get_subagent_result`, and the `<task-notification>` summary all append a clear note for `stopped` (user abort) → `(STOPPED BY THE USER before completion — output is partial; the task was NOT finished)`, `aborted` (turn limit) → `(aborted — hit the turn limit before completion; output may be incomplete)`, and `steered` → `(wrapped up at the turn limit — output may be partial)`. `stopped` (human intervention) is kept distinct from `aborted` (turn-budget cutoff); a clean `completed` adds no note. Extracted as `getStatusNote` in `src/status-note.ts`.
37
+ - **`BUILTIN_TOOL_NAMES` is derived from pi's tool factories** (`createCodingTools` + `createReadOnlyTools`) rather than a hardcoded list, so the built-in set tracks pi-mono automatically. Internal; no behavior change (the resolved set is the same seven names).
38
+
39
+ ### Fixed
40
+ - **Turn-count glyph in the agent widget no longer overflows its monospace cell** ([#84](https://github.com/tintinweb/pi-subagents/issues/84) — thanks [@linozen](https://github.com/linozen)). `formatTurns` used `⟳` (U+27F3 CLOCKWISE GAPPED CIRCLE ARROW) from the Miscellaneous Mathematical Symbols-A block, where common monospace fonts (Iosevka Nerd Font Mono, Menlo, SF Mono, JetBrains Mono) draw the glyph visually wider than one cell despite its Neutral East Asian Width — making the next character (the digit) overlap the glyph. Replaced with `↻` (U+21BB CLOCKWISE OPEN CIRCLE ARROW) from the standard Arrows block, which renders cleanly at one cell in those fonts. To avoid colliding with the existing compaction indicator (which previously also used `↻`), the compaction glyph moves to `⇊` (U+21CA DOWNWARDS PAIRED ARROWS) — same Arrows block, also single-cell, visually distinct. Widget vocabulary now reads: `↻5≤30` for turns, `⇊2` for compactions. Pi UI consumers / scripts grepping for the glyph in stats lines must update.
41
+ - **`tools: none` now actually yields zero built-in tools.** `getToolNamesForType` treated an explicit empty `builtinToolNames` (`[]`, produced by `tools: none`) as "unspecified" and fell back to all 7 built-ins. It now distinguishes an omitted field (`undefined` → all built-ins, for default agents) from an explicit empty list (`[]` → zero), consistent with `getConfig`. Same fix makes `tools:` values containing only `ext:` selectors yield zero built-ins as documented.
42
+ - **`tools:` typos no longer silently break tool-calling** ([#75](https://github.com/tintinweb/pi-subagents/issues/75)). Two parts: (a) `all` was previously parsed as a literal tool name, producing a one-element allowlist of the non-existent tool `"all"` — the model then returned an empty response or emitted raw XML tool calls, all with `status: completed` and no error. `parseToolsField` now treats `all` (case-insensitive) as an alias for the `*` wildcard, both standalone and inside a CSV. (b) Plain entries in `tools:` are expected to be built-in names (extension tools route through `ext:`), so an unknown name there is unambiguously a typo. `runAgent` now emits a `tools-error:tool "X" requested by agent "Y" is not a known built-in` event via `onToolActivity` for each unrecognized plain entry — same surfacing channel as the existing `extension-error:` warnings.
43
+ - **Subagents with `extensions: true` now actually expose extension-registered tools (MCP, etc.)** ([#47](https://github.com/tintinweb/pi-subagents/issues/47)). `runAgent` previously passed only the built-in tool names as the `tools:` allowlist to `createAgentSession`, so pi-mono's `allowedToolNames` gate rejected every extension-registered tool at registration — `extensions: true` agents silently got only the 7 built-ins. `runAgent` now enumerates extension tool names from the resource loader after `reload()` and builds the full master allowlist (built-ins + permitted extension tools), so pi-mono's gate admits them from the first instant of the session. `disallowedTools` and the internal `Agent`/`get_subagent_result`/`steer_subagent` exclusions are applied uniformly to built-in and extension tools at construction — no post-construction `setActiveToolsByName` narrowing.
44
+ - **Append-mode subagents no longer defeat the LLM's KV cache** ([#73](https://github.com/tintinweb/pi-subagents/pull/73) — reported by [@jeffutter](https://github.com/jeffutter)). The assembled child prompt placed the per-spawn-varying `<active_agent>` tag and `# Environment` block *before* the ~8k-token inherited parent prompt, and wrapped the parent prompt in `<inherited_system_prompt>` tags. Because KV caches key on a byte-identical prefix, every subagent spawn reprocessed all ~8k shared tokens from scratch (~40s on slower hardware). The parent prompt is now emitted **verbatim at the start** of the prompt (wrapper dropped), so it forms an identical, cacheable prefix with the parent session and across every spawn; the static `<sub_agent_context>` bridge follows, then the varying `<active_agent>` tag and env block. `replace` mode is unchanged (it inherits no parent prefix). The `<active_agent>` tag stays present and is parsed position-independently, so downstream permission resolution is unaffected. Mirrors the fix in [gotgenes/pi-packages#180](https://github.com/gotgenes/pi-packages/issues/180).
45
+
10
46
  ## [0.9.1] - 2026-05-30
11
47
 
12
48
  ### Added
package/README.md CHANGED
@@ -15,7 +15,7 @@ https://github.com/user-attachments/assets/8685261b-9338-4fea-8dfe-1c590d5df543
15
15
  - **Claude Code look & feel** — same tool names, calling conventions, and UI patterns (`Agent`, `get_subagent_result`, `steer_subagent`) — feels native
16
16
  - **Parallel background agents** — spawn multiple agents that run concurrently with automatic queuing (configurable concurrency limit, default 4) and smart group join (consolidated notifications)
17
17
  - **Live widget UI** — persistent above-editor widget with animated spinners, live tool activity, token counts, and colored status icons
18
- - **Conversation viewer** — select any agent in `/agents` to open a live-scrolling overlay of its full conversation (auto-follows new content, scroll up to pause)
18
+ - **Conversation viewer** — select any agent in `/agents` to open a live-scrolling overlay of its full conversation (auto-follows new content, scroll up to pause). Stop a still-running agent from here by pressing `x` (then `x` again to confirm) — works for background agents too
19
19
  - **Custom agent types** — define agents in `.pi/agents/<name>.md` with YAML frontmatter: custom system prompts, model selection, thinking levels, tool restrictions
20
20
  - **Mid-run steering** — inject messages into running agents to redirect their work without restarting
21
21
  - **Session resume** — pick up where an agent left off, preserving full conversation context
@@ -98,29 +98,29 @@ The extension renders a persistent widget above the editor showing all active ag
98
98
 
99
99
  ```
100
100
  ● Agents
101
- ├─ ⠹ Agent Refactor auth module · 5≤30 · 5 tool uses · 33.8k token (62%) · 12.3s
101
+ ├─ ⠹ Agent Refactor auth module · 5≤30 · 5 tool uses · 33.8k token (62%) · 12.3s
102
102
  │ ⎿ editing 2 files…
103
- ├─ ⠹ Explore Find auth files · 3 · 3 tool uses · 12.4k token (8%) · 4.1s
103
+ ├─ ⠹ Explore Find auth files · 3 · 3 tool uses · 12.4k token (8%) · 4.1s
104
104
  │ ⎿ searching…
105
- ├─ ⠹ Agent Long-running task · 42 · 38 tool uses · 91.0k token (84% · 2) · 2m17s
105
+ ├─ ⠹ Agent Long-running task · 42 · 38 tool uses · 91.0k token (84% · 2) · 2m17s
106
106
  │ ⎿ reading…
107
107
  └─ 2 queued
108
108
  ```
109
109
 
110
110
  The token field is annotated with two optional signals inside parens:
111
111
  - **`NN%`** — context-window utilization (color-coded: <70% dim, 70–85% warning, ≥85% error). Omitted when the model has no declared `contextWindow`, or briefly right after compaction.
112
- - **`↻N`** — number of times the session has compacted, when > 0. Stays dim; the percent's color carries urgency.
112
+ - **`⇊N`** — number of times the session has compacted, when > 0. Stays dim; the percent's color carries urgency.
113
113
 
114
114
  Individual agent results render Claude Code-style in the conversation:
115
115
 
116
116
  | State | Example |
117
117
  |-------|---------|
118
- | **Running** | `⠹ 3≤30 · 3 tool uses · 12.4k token (8%)` / `⎿ searching, reading 3 files…` |
119
- | **Completed** | `✓ 8 · 5 tool uses · 33.8k token (62%) · 12.3s` / `⎿ Done` |
120
- | **Wrapped up** | `✓ 50≤50 · 50 tool uses · 89.1k token (84% · 2) · 45.2s` / `⎿ Wrapped up (turn limit)` |
121
- | **Stopped** | `■ 3 · 3 tool uses · 12.4k token (8%)` / `⎿ Stopped` |
122
- | **Error** | `✗ 3 · 3 tool uses · 12.4k token (8%)` / `⎿ Error: timeout` |
123
- | **Aborted** | `✗ 55≤50 · 55 tool uses · 102.3k token (95% · 3)` / `⎿ Aborted (max turns exceeded)` |
118
+ | **Running** | `⠹ 3≤30 · 3 tool uses · 12.4k token (8%)` / `⎿ searching, reading 3 files…` |
119
+ | **Completed** | `✓ 8 · 5 tool uses · 33.8k token (62%) · 12.3s` / `⎿ Done` |
120
+ | **Wrapped up** | `✓ 50≤50 · 50 tool uses · 89.1k token (84% · 2) · 45.2s` / `⎿ Wrapped up (turn limit)` |
121
+ | **Stopped** | `■ 3 · 3 tool uses · 12.4k token (8%)` / `⎿ Stopped` |
122
+ | **Error** | `✗ 3 · 3 tool uses · 12.4k token (8%)` / `⎿ Error: timeout` |
123
+ | **Aborted** | `✗ 55≤50 · 55 tool uses · 102.3k token (95% · 3)` / `⎿ Aborted (max turns exceeded)` |
124
124
 
125
125
  Completed results can be expanded (ctrl+o in pi) to show the full agent output inline.
126
126
 
@@ -128,7 +128,7 @@ Background agent completion notifications render as styled boxes:
128
128
 
129
129
  ```
130
130
  ✓ Find auth files completed
131
- 3 · 3 tool uses · 12.4k token · 4.1s
131
+ 3 · 3 tool uses · 12.4k token · 4.1s
132
132
  ⎿ Found 5 files related to authentication...
133
133
  transcript: .pi/output/agent-abc123.jsonl
134
134
  ```
@@ -194,8 +194,8 @@ All fields are optional — sensible defaults for everything.
194
194
  |-------|---------|-------------|
195
195
  | `description` | filename | Agent description shown in tool listings |
196
196
  | `display_name` | — | Display name for UI (e.g. widget, agent list) |
197
- | `tools` | all 7 | Comma-separated built-in tools: read, bash, edit, write, grep, find, ls. `none` for no tools |
198
- | `extensions` | `true` | Inherit MCP/extension tools. `false` to disable |
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
+ | `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
199
  | `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
200
  | `memory` | — | Persistent agent memory scope: `project`, `local`, or `user`. Auto-detects read-only agents |
201
201
  | `disallowed_tools` | — | Comma-separated tools to deny even if extensions provide them |
@@ -206,11 +206,42 @@ All fields are optional — sensible defaults for everything.
206
206
  | `prompt_mode` | `replace` | `replace`: body is the full system prompt (no AGENTS.md / CLAUDE.md inheritance). `append`: body appended to parent's prompt (agent acts as a "parent twin" — inherits parent's AGENTS.md / CLAUDE.md) |
207
207
  | `inherit_context` | `false` | Fork parent conversation into agent |
208
208
  | `run_in_background` | `false` | Run in background by default |
209
- | `isolated` | `false` | No extension/MCP tools, only built-in |
209
+ | `isolated` | `false` | Hermetic specialist mode: forces `extensions: false` + `skills: false` + drops `ext:` selectors. Only built-in tools. Distinct from `isolation: worktree` (filesystem) |
210
210
  | `enabled` | `true` | Set to `false` to disable an agent (useful for hiding a default agent per-project) |
211
211
 
212
212
  Frontmatter is authoritative. If an agent file sets `model`, `thinking`, `max_turns`, `inherit_context`, `run_in_background`, `isolated`, or `isolation`, those values are locked for that agent. `Agent` tool parameters only fill fields the agent config leaves unspecified.
213
213
 
214
+ ### Tool & extension scoping
215
+
216
+ `extensions:` decides **which extensions load**, `tools:` decides **which tools surface to the LLM**. They compose:
217
+
218
+ ```yaml
219
+ # Default (both omitted): all extensions load, all 7 built-ins surface
220
+
221
+ tools: read, grep, find # narrow to listed built-ins; extensions still load
222
+ tools: "*" # all 7 built-ins (alias: `all`)
223
+ tools: none # zero built-ins (alias: `""`)
224
+ tools: "*, ext:mcp/search" # built-ins plus one extension tool
225
+
226
+ extensions: false # no extensions load
227
+ extensions: [mcp] # only mcp loads
228
+ extensions: ["*", "/abs/foo.ts"] # all defaults plus one path-loaded extension
229
+
230
+ # Specialist: load one extension, expose only one of its tools, keep built-ins
231
+ extensions: [mcp]
232
+ tools: "*, ext:mcp/search"
233
+
234
+ isolated: true # hermetic: built-ins only, no extensions/skills/context
235
+ ```
236
+
237
+ A few rules the examples don't make obvious:
238
+
239
+ - `extensions:` is the sole loading authority. `ext:foo` in `tools:` narrows what surfaces; it can't load `foo` on its own. Mismatches fire `extension-error:…` warnings.
240
+ - 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
+ - Extension names match case-insensitively (`[Mcp]` = `[mcp]`); tool names in `ext:foo/bar` stay case-sensitive.
242
+ - Plain `tools:` typos fail loudly: `tools: reed, grep` fires `tools-error:…` instead of silently producing an under-tooled agent.
243
+ - Array and string forms are equivalent: `[a, b]` == `"a, b"`.
244
+
214
245
  ## Tools
215
246
 
216
247
  ### `Agent`
@@ -265,6 +296,7 @@ Create new agent ← manual wizard or AI-generated
265
296
  Settings ← max concurrency, max turns, grace turns, join mode
266
297
  ```
267
298
 
299
+ - **Running agents** — select one to open its live conversation viewer. While it's still running, press `x` (then `x` again to confirm) to stop/abort it — including **background** agents, which a global Esc can't unambiguously target (Esc still stops a blocking foreground `Agent` call). A stopped agent reports its partial output flagged as incomplete, not as a completion.
268
300
  - **Agent types** — unified list with source indicators: `•` (project), `◦` (global), `✕` (disabled). Select an agent to manage it:
269
301
  - **Default agents** (no override): Eject (export as `.md`), Disable
270
302
  - **Default agents** (ejected/overridden): Edit, Disable, Reset to default, Delete
@@ -333,12 +365,14 @@ When on, each subagent spawn's effective model is validated against pi's own `en
333
365
 
334
366
  ## Persistent Settings
335
367
 
336
- Runtime tuning values set via `/agents` → Settings (max concurrency, default max turns, grace turns, default join mode, scheduling on/off, scope models on/off) persist across pi restarts. Two files, merged on load:
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:
337
369
 
338
370
  - **Global:** `~/.pi/agent/subagents.json` — your machine-wide defaults. Edit by hand; the `/agents` menu never writes here.
339
371
  - **Project:** `<cwd>/.pi/subagents.json` — per-project overrides. Written by `/agents` → Settings.
340
372
 
341
- **Precedence:** project overrides global on any field present in both. Missing fields fall back to the hardcoded defaults (max concurrency `4`, default max turns unlimited, grace turns `5`, join mode `smart`).
373
+ **Precedence:** project overrides global on any field present in both. Missing fields fall back to the hardcoded defaults (max concurrency `4`, default max turns unlimited, grace turns `5`, join mode `smart`, defaults enabled).
374
+
375
+ **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).
342
376
 
343
377
  **Example — global defaults for a beefy machine:**
344
378
 
@@ -475,6 +509,9 @@ Agent({ subagent_type: "refactor", prompt: "...", isolation: "worktree" })
475
509
  The agent gets a full, isolated copy of the repository. On completion:
476
510
  - **No changes:** worktree is cleaned up automatically
477
511
  - **Changes made:** changes are committed to a new branch (`pi-agent-<id>`) and returned in the result
512
+ - **Agent committed its own work:** the branch is created at the agent's HEAD, preserving its commits (uncommitted leftovers are committed on top first)
513
+
514
+ The automatic preservation commit uses `--no-verify`, so local pre-commit hooks can't block it — the commit is local-only and never pushed, and pre-push/server-side hooks still apply.
478
515
 
479
516
  If the worktree cannot be created (not a git repo, no commits, or `git worktree add` fails), the `Agent` tool returns a clear error instead of running unisolated — `isolation: "worktree"` is a strict guarantee, not a hint. Initialize git and commit at least once, or omit `isolation`.
480
517
 
@@ -5,6 +5,55 @@ import type { Model } from "@earendil-works/pi-ai";
5
5
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
6
6
  import { type AgentSession, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
7
  import type { SubagentType, ThinkingLevel } from "./types.js";
8
+ /**
9
+ * Tool names registered by THIS extension. Single source of truth so the
10
+ * registration sites (index.ts) and the subagent exclusion list below can't
11
+ * drift apart. These are our own tools, not pi built-ins, so they can't be
12
+ * derived from pi — but they only need defining once.
13
+ */
14
+ export declare const SUBAGENT_TOOL_NAMES: {
15
+ readonly AGENT: "Agent";
16
+ readonly GET_RESULT: "get_subagent_result";
17
+ readonly STEER: "steer_subagent";
18
+ };
19
+ /**
20
+ * Canonical name of an extension for `extensions: [...]` allowlist matching.
21
+ * Lowercased — extension names match case-insensitively so `extensions: [Mcp]`
22
+ * resolves the same as `[mcp]`. Tool names within `ext:foo/bar` are not affected.
23
+ * Directory extensions (`foo/index.ts`) resolve to the parent directory name;
24
+ * single-file extensions to the basename minus `.ts`/`.js`.
25
+ */
26
+ export declare function extensionCanonicalName(extPath: string): string;
27
+ /**
28
+ * Classify `extensions: string[]` frontmatter entries for the loader-level filter.
29
+ *
30
+ * An entry is a PATH iff it contains a path separator or starts with `~`; otherwise
31
+ * it is a NAME. `"*"` sets the wildcard flag (keep all default-discovered extensions).
32
+ *
33
+ * Path entries are resolved (`~` expanded, made absolute against `cwd`) into `paths`
34
+ * — and their canonical name is also added to `names`. The loader override matches
35
+ * everything by canonical name, so path-loaded extensions are matched via their name
36
+ * rather than their post-staging `Extension.path`.
37
+ */
38
+ export declare function parseExtensionsSpec(entries: string[], cwd: string): {
39
+ names: Set<string>;
40
+ paths: string[];
41
+ wildcard: boolean;
42
+ };
43
+ /**
44
+ * Parse raw `ext:` selector strings (from the `tools:` CSV) into the set of
45
+ * extension names to keep loaded and a per-extension tool-narrowing map.
46
+ *
47
+ * `ext:foo` → `extNames` has `foo`, no narrowing entry (all of foo's tools).
48
+ * `ext:foo/bar` → `extNames` has `foo`, `narrowing.foo` has `bar` (only `bar`).
49
+ * A name lands in `narrowing` only when a `/tool` form is seen, so a bare
50
+ * `ext:foo` alongside `ext:foo/bar` leaves narrowing in effect (narrowing wins).
51
+ * The split is on the first `/`; extension canonical names never contain `/`.
52
+ */
53
+ export declare function parseExtSelectors(entries: string[]): {
54
+ extNames: Set<string>;
55
+ narrowing: Map<string, Set<string>>;
56
+ };
8
57
  /** Normalize max turns. undefined or 0 = unlimited, otherwise minimum 1. */
9
58
  export declare function normalizeMaxTurns(n: number | undefined): number | undefined;
10
59
  /** Get the default max turns value. undefined = unlimited. */
@@ -1,16 +1,119 @@
1
1
  /**
2
2
  * agent-runner.ts — Core execution engine: creates sessions, runs agents, collects results.
3
3
  */
4
+ import { homedir } from "node:os";
5
+ import { basename, dirname, isAbsolute, resolve } from "node:path";
4
6
  import { createAgentSession, DefaultResourceLoader, getAgentDir, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
5
- import { getAgentConfig, getConfig, getMemoryToolNames, getReadOnlyMemoryToolNames, getToolNamesForType } from "./agent-types.js";
7
+ import { BUILTIN_TOOL_NAMES, getAgentConfig, getConfig, getMemoryToolNames, getReadOnlyMemoryToolNames, getToolNamesForType } from "./agent-types.js";
6
8
  import { buildParentContext, extractText } from "./context.js";
7
9
  import { DEFAULT_AGENTS } from "./default-agents.js";
8
10
  import { detectEnv } from "./env.js";
9
11
  import { buildMemoryBlock, buildReadOnlyMemoryBlock } from "./memory.js";
10
12
  import { buildAgentPrompt } from "./prompts.js";
11
13
  import { preloadSkills } from "./skill-loader.js";
14
+ /**
15
+ * Tool names registered by THIS extension. Single source of truth so the
16
+ * registration sites (index.ts) and the subagent exclusion list below can't
17
+ * drift apart. These are our own tools, not pi built-ins, so they can't be
18
+ * derived from pi — but they only need defining once.
19
+ */
20
+ export const SUBAGENT_TOOL_NAMES = {
21
+ AGENT: "Agent",
22
+ GET_RESULT: "get_subagent_result",
23
+ STEER: "steer_subagent",
24
+ };
12
25
  /** Names of tools registered by this extension that subagents must NOT inherit. */
13
- const EXCLUDED_TOOL_NAMES = ["Agent", "get_subagent_result", "steer_subagent"];
26
+ const EXCLUDED_TOOL_NAMES = Object.values(SUBAGENT_TOOL_NAMES);
27
+ /**
28
+ * Canonical name of an extension for `extensions: [...]` allowlist matching.
29
+ * Lowercased — extension names match case-insensitively so `extensions: [Mcp]`
30
+ * resolves the same as `[mcp]`. Tool names within `ext:foo/bar` are not affected.
31
+ * Directory extensions (`foo/index.ts`) resolve to the parent directory name;
32
+ * single-file extensions to the basename minus `.ts`/`.js`.
33
+ */
34
+ export function extensionCanonicalName(extPath) {
35
+ const base = basename(extPath);
36
+ const name = base === "index.ts" || base === "index.js"
37
+ ? basename(dirname(extPath))
38
+ : base.replace(/\.(ts|js)$/, "");
39
+ return name.toLowerCase();
40
+ }
41
+ /**
42
+ * Classify `extensions: string[]` frontmatter entries for the loader-level filter.
43
+ *
44
+ * An entry is a PATH iff it contains a path separator or starts with `~`; otherwise
45
+ * it is a NAME. `"*"` sets the wildcard flag (keep all default-discovered extensions).
46
+ *
47
+ * Path entries are resolved (`~` expanded, made absolute against `cwd`) into `paths`
48
+ * — and their canonical name is also added to `names`. The loader override matches
49
+ * everything by canonical name, so path-loaded extensions are matched via their name
50
+ * rather than their post-staging `Extension.path`.
51
+ */
52
+ export function parseExtensionsSpec(entries, cwd) {
53
+ const names = new Set();
54
+ const paths = [];
55
+ let wildcard = false;
56
+ for (const entry of entries) {
57
+ if (!entry)
58
+ continue;
59
+ if (entry === "*") {
60
+ wildcard = true;
61
+ continue;
62
+ }
63
+ const isPathEntry = entry.includes("/") || entry.includes("\\") || entry.startsWith("~");
64
+ if (!isPathEntry) {
65
+ names.add(entry.toLowerCase());
66
+ continue;
67
+ }
68
+ let p = entry;
69
+ if (p === "~" || p.startsWith("~/") || p.startsWith("~\\")) {
70
+ p = homedir() + p.slice(1);
71
+ }
72
+ const abs = isAbsolute(p) ? p : resolve(cwd, p);
73
+ paths.push(abs);
74
+ names.add(extensionCanonicalName(abs));
75
+ }
76
+ return { names, paths, wildcard };
77
+ }
78
+ /**
79
+ * Parse raw `ext:` selector strings (from the `tools:` CSV) into the set of
80
+ * extension names to keep loaded and a per-extension tool-narrowing map.
81
+ *
82
+ * `ext:foo` → `extNames` has `foo`, no narrowing entry (all of foo's tools).
83
+ * `ext:foo/bar` → `extNames` has `foo`, `narrowing.foo` has `bar` (only `bar`).
84
+ * A name lands in `narrowing` only when a `/tool` form is seen, so a bare
85
+ * `ext:foo` alongside `ext:foo/bar` leaves narrowing in effect (narrowing wins).
86
+ * The split is on the first `/`; extension canonical names never contain `/`.
87
+ */
88
+ export function parseExtSelectors(entries) {
89
+ const extNames = new Set();
90
+ const narrowing = new Map();
91
+ for (const raw of entries) {
92
+ if (!raw)
93
+ continue;
94
+ const body = raw.slice("ext:".length);
95
+ const slash = body.indexOf("/");
96
+ // Extension name matches case-insensitively (matches the loader-side canonical
97
+ // name). Tool names are case-preserved — they're matched against pi-mono's
98
+ // registered identifiers, which are case-sensitive.
99
+ const name = (slash === -1 ? body : body.slice(0, slash)).trim().toLowerCase();
100
+ if (!name)
101
+ continue;
102
+ extNames.add(name);
103
+ if (slash === -1)
104
+ continue;
105
+ const tool = body.slice(slash + 1).trim();
106
+ if (!tool)
107
+ continue;
108
+ let set = narrowing.get(name);
109
+ if (!set) {
110
+ set = new Set();
111
+ narrowing.set(name, set);
112
+ }
113
+ set.add(tool);
114
+ }
115
+ return { extNames, narrowing };
116
+ }
14
117
  /** Default max turns. undefined = unlimited (no turn limit). */
15
118
  let defaultMaxTurns;
16
119
  /** Normalize max turns. undefined or 0 = unlimited, otherwise minimum 1. */
@@ -151,16 +254,46 @@ export async function runAgent(ctx, type, prompt, options) {
151
254
  // Still pass noSkills: true since we don't need the skill loader to load them again.
152
255
  const noSkills = skills === false || Array.isArray(skills);
153
256
  const agentDir = getAgentDir();
154
- // Load extensions/skills: true or string[] → load; false → don't.
257
+ // Extension loading:
258
+ // - true → all default-discovered extensions
259
+ // - false → none (noExtensions)
260
+ // - string[] → loader-level allowlist. Bare names keep the matching
261
+ // default-discovered extension; path entries load that extension fresh;
262
+ // "*" keeps all default-discovered extensions. Excluded extensions never
263
+ // bind handlers or register tools (their factory still runs once).
264
+ //
155
265
  // Suppress AGENTS.md/CLAUDE.md and APPEND_SYSTEM.md — upstream's
156
266
  // buildSystemPrompt() re-appends both AFTER systemPromptOverride, which
157
267
  // would defeat prompt_mode: replace and isolated: true. Parent context, if
158
268
  // wanted, reaches the subagent via prompt_mode: append (parentSystemPrompt
159
269
  // is embedded in systemPromptOverride) or inherit_context (conversation).
270
+ // `ext:` selectors from the `tools:` CSV narrow which extension tools surface to
271
+ // the LLM. They do NOT control loading — `extensions:` is the sole authority for
272
+ // which extensions load. `ext:foo` against an extension that `extensions:` excluded
273
+ // is an orphan and warns after reload. `isolated` means no extension tools at all.
274
+ const { extNames, narrowing } = parseExtSelectors(options.isolated ? [] : (agentConfig?.extSelectors ?? []));
275
+ const noExtensions = extensions === false;
276
+ const extensionsSpec = Array.isArray(extensions)
277
+ ? parseExtensionsSpec(extensions, effectiveCwd)
278
+ : undefined;
279
+ const keepNames = extensionsSpec?.names ?? new Set();
280
+ // The override filters loaded extensions down to `keepNames`. It's only needed
281
+ // when we're neither loading everything (`extensions: true` or a `"*"` wildcard)
282
+ // nor nothing (`noExtensions`).
283
+ const loadAll = extensions === true || extensionsSpec?.wildcard === true;
284
+ const additionalExtensionPaths = extensionsSpec?.paths.length ? extensionsSpec.paths : undefined;
285
+ const extensionsOverride = loadAll || noExtensions
286
+ ? undefined
287
+ : (base) => ({
288
+ ...base,
289
+ extensions: base.extensions.filter((e) => keepNames.has(extensionCanonicalName(e.path))),
290
+ });
160
291
  const loader = new DefaultResourceLoader({
161
292
  cwd: effectiveCwd,
162
293
  agentDir,
163
- noExtensions: extensions === false,
294
+ noExtensions,
295
+ additionalExtensionPaths,
296
+ extensionsOverride,
164
297
  noSkills,
165
298
  noPromptTemplates: true,
166
299
  noThemes: true,
@@ -169,10 +302,94 @@ export async function runAgent(ctx, type, prompt, options) {
169
302
  appendSystemPromptOverride: () => [],
170
303
  });
171
304
  await loader.reload();
305
+ // Plain entries in `tools:` are expected to be built-in names (extension tools
306
+ // go through `ext:`), so an unknown name there is unambiguously a typo. Previously
307
+ // this produced a silently broken agent (#75) — pi-mono accepted the bogus name
308
+ // into the allowlist, then dropped it at registration with no signal back.
309
+ if (agentConfig?.builtinToolNames?.length) {
310
+ const knownBuiltins = new Set(BUILTIN_TOOL_NAMES);
311
+ for (const name of agentConfig.builtinToolNames) {
312
+ if (!knownBuiltins.has(name)) {
313
+ options.onToolActivity?.({
314
+ type: "end",
315
+ toolName: `tools-error:tool "${name}" requested by agent "${type}" is not a known built-in`,
316
+ });
317
+ }
318
+ }
319
+ }
320
+ // A subagent spawns mid-task, so a bad `extensions:`/`ext:` entry warns rather
321
+ // than aborts. Two distinct misconfigurations to catch:
322
+ // - `extensions: [foo]` but no extension named foo was discovered (typo or
323
+ // path that failed to load — path entries fold their canonical name into
324
+ // `keepNames`, so this covers them too).
325
+ // - `tools: ext:foo` but foo isn't in the loaded set (because `extensions:`
326
+ // didn't include it). Since v0.9, `ext:` no longer pulls extensions in;
327
+ // loading is `extensions:`-authoritative.
328
+ if (keepNames.size > 0 || extNames.size > 0) {
329
+ const survivingNames = new Set(loader.getExtensions().extensions.map((e) => extensionCanonicalName(e.path)));
330
+ for (const name of keepNames) {
331
+ if (!survivingNames.has(name)) {
332
+ options.onToolActivity?.({
333
+ type: "end",
334
+ toolName: `extension-error:extension "${name}" requested by agent "${type}" was not loaded`,
335
+ });
336
+ }
337
+ }
338
+ for (const name of extNames) {
339
+ if (!survivingNames.has(name)) {
340
+ options.onToolActivity?.({
341
+ type: "end",
342
+ toolName: `extension-error:ext:${name} referenced by agent "${type}" but extension "${name}" is not loaded (add it to extensions:)`,
343
+ });
344
+ }
345
+ }
346
+ }
172
347
  // Resolve model: explicit option > config.model > parent model
173
348
  const model = options.model ?? resolveDefaultModel(ctx.model, ctx.modelRegistry, agentConfig?.model);
174
349
  // Resolve thinking level: explicit option > agent config > undefined (inherit)
175
350
  const thinkingLevel = options.thinkingLevel ?? agentConfig?.thinking;
351
+ const disallowedSet = agentConfig?.disallowedTools
352
+ ? new Set(agentConfig.disallowedTools)
353
+ : undefined;
354
+ // Enumerate extension-registered tool names from the loaded resource loader.
355
+ // Extensions populate `extension.tools` during `loader.reload()` and the set
356
+ // is stable afterwards — `bindExtensions` does not register new tools.
357
+ //
358
+ // Opt-in flip: when any `ext:` selector is present, extension tools become an
359
+ // explicit allowlist — a loaded extension not named by a selector contributes
360
+ // no tools (its handlers still ran), and `ext:foo/bar` narrows `foo` to `bar`.
361
+ const extensionToolNames = [];
362
+ if (!noExtensions) {
363
+ const optInActive = extNames.size > 0;
364
+ for (const extension of loader.getExtensions().extensions) {
365
+ const canon = extensionCanonicalName(extension.path);
366
+ if (optInActive && !extNames.has(canon))
367
+ continue;
368
+ const narrowed = narrowing.get(canon);
369
+ for (const toolName of extension.tools.keys()) {
370
+ if (narrowed && !narrowed.has(toolName))
371
+ continue;
372
+ extensionToolNames.push(toolName);
373
+ }
374
+ }
375
+ }
376
+ // Build the master tool allowlist applied at session construction.
377
+ // pi-mono's `allowedToolNames` gates BOTH registration and the initial active
378
+ // set, so listing the exact final set here means the session is correctly
379
+ // scoped from the first instant — no post-construction narrowing required.
380
+ const builtinToolNameSet = new Set(toolNames);
381
+ const allowedTools = [...toolNames, ...extensionToolNames].filter((t) => {
382
+ if (EXCLUDED_TOOL_NAMES.includes(t))
383
+ return false;
384
+ if (disallowedSet?.has(t))
385
+ return false;
386
+ if (builtinToolNameSet.has(t))
387
+ return true;
388
+ // Reached only for extension tools. The extension set was already filtered
389
+ // at the loader (extensionsOverride / noExtensions) and at enumeration
390
+ // (`ext:` opt-in flip), so any extension tool in `extensionToolNames` is allowed.
391
+ return !noExtensions;
392
+ });
176
393
  const sessionOpts = {
177
394
  cwd: effectiveCwd,
178
395
  agentDir,
@@ -180,7 +397,7 @@ export async function runAgent(ctx, type, prompt, options) {
180
397
  settingsManager: SettingsManager.create(effectiveCwd, agentDir),
181
398
  modelRegistry: ctx.modelRegistry,
182
399
  model,
183
- tools: toolNames,
400
+ tools: allowedTools,
184
401
  resourceLoader: loader,
185
402
  };
186
403
  if (thinkingLevel) {
@@ -189,37 +406,10 @@ export async function runAgent(ctx, type, prompt, options) {
189
406
  const { session } = await createAgentSession(sessionOpts);
190
407
  const baseSessionName = agentConfig?.name ?? type;
191
408
  session.setSessionName(options.agentId ? `${baseSessionName}#${options.agentId.slice(0, 8)}` : baseSessionName);
192
- // Build disallowed tools set from agent config
193
- const disallowedSet = agentConfig?.disallowedTools
194
- ? new Set(agentConfig.disallowedTools)
195
- : undefined;
196
- // Filter active tools: remove our own tools to prevent nesting,
197
- // apply extension allowlist if specified, and apply disallowedTools denylist
198
- if (extensions !== false) {
199
- const builtinToolNameSet = new Set(toolNames);
200
- const activeTools = session.getActiveToolNames().filter((t) => {
201
- if (EXCLUDED_TOOL_NAMES.includes(t))
202
- return false;
203
- if (disallowedSet?.has(t))
204
- return false;
205
- if (builtinToolNameSet.has(t))
206
- return true;
207
- if (Array.isArray(extensions)) {
208
- return extensions.some(ext => t.startsWith(ext) || t.includes(ext));
209
- }
210
- return true;
211
- });
212
- session.setActiveToolsByName(activeTools);
213
- }
214
- else if (disallowedSet) {
215
- // Even with extensions disabled, apply denylist to built-in tools
216
- const activeTools = session.getActiveToolNames().filter(t => !disallowedSet.has(t));
217
- session.setActiveToolsByName(activeTools);
218
- }
219
409
  // Bind extensions so that session_start fires and extensions can initialize
220
- // (e.g. loading credentials, setting up state). Placed after tool filtering
221
- // so extension-provided skills/prompts from extendResourcesFromExtensions()
222
- // respect the active tool set. All ExtensionBindings fields are optional.
410
+ // (e.g. loading credentials, setting up state). Tool gating already happened
411
+ // at session construction via the `tools:` allowlist above — no separate
412
+ // post-bind filter is needed. All ExtensionBindings fields are optional.
223
413
  await session.bindExtensions({
224
414
  onError: (err) => {
225
415
  options.onToolActivity?.({
@@ -5,8 +5,19 @@
5
5
  * User agents override defaults with the same name. Disabled agents are kept but excluded from spawning.
6
6
  */
7
7
  import type { AgentConfig } from "./types.js";
8
- /** All known built-in tool names. */
8
+ /**
9
+ * All known built-in tool names, derived from pi's own tool factories rather
10
+ * than hardcoded so the set tracks pi-mono if it adds/renames a built-in.
11
+ * `createCodingTools` → read/bash/edit/write; `createReadOnlyTools` →
12
+ * read/grep/find/ls; their de-duplicated union is the 7 built-ins
13
+ * (read, bash, edit, write, grep, find, ls). The `cwd` only binds tool
14
+ * operations we never invoke here — we read each tool's `.name` and discard it.
15
+ */
9
16
  export declare const BUILTIN_TOOL_NAMES: string[];
17
+ /** Check whether default agents are disabled. */
18
+ export declare function isDefaultsDisabled(): boolean;
19
+ /** Set whether default agents are disabled. */
20
+ export declare function setDefaultsDisabled(b: boolean): void;
10
21
  /**
11
22
  * Register agents into the unified registry.
12
23
  * Starts with DEFAULT_AGENTS, then overlays user agents (overrides defaults with same name).