@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 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:
@@ -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. */
@@ -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(ctx.cwd, id);
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
- worktreeCwd = wt.path;
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
- cwd: worktreeCwd,
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(ctx.cwd, record.worktree, options.description);
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}\`. Merge with: \`git merge ${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(ctx.cwd, record.worktree, options.description);
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
  }
@@ -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. */
@@ -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, effectiveCwd);
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, effectiveCwd);
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, effectiveCwd);
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, effectiveCwd)
283
+ ? parseExtensionsSpec(extensions, configCwd)
278
284
  : undefined;
279
285
  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`).
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
- const extensionsOverride = loadAll || noExtensions
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
- ...base,
289
- extensions: base.extensions.filter((e) => keepNames.has(extensionCanonicalName(e.path))),
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: effectiveCwd,
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: `extension-error:extension "${name}" requested by agent "${type}" was not loaded`,
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 (add it to extensions:)`,
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(effectiveCwd, agentDir),
444
+ settingsManager: SettingsManager.create(configCwd, agentDir),
398
445
  modelRegistry: ctx.modelRegistry,
399
446
  model,
400
447
  tools: allowedTools,
@@ -54,6 +54,7 @@ export declare function getConfig(type: string): {
54
54
  description: string;
55
55
  builtinToolNames: string[];
56
56
  extensions: true | string[] | false;
57
+ excludeExtensions?: string[];
57
58
  skills: true | string[] | false;
58
59
  promptMode: "replace" | "append";
59
60
  };
@@ -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
  };
@@ -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),