@tintinweb/pi-subagents 0.10.1 → 0.10.2

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,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.10.2] - 2026-06-10
11
+
12
+ ### Added
13
+ - **`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.
14
+ - **`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.
15
+
16
+ ### Fixed
17
+ - **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).
18
+
10
19
  ## [0.10.1] - 2026-06-10
11
20
 
12
21
  ### 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
@@ -206,6 +206,9 @@ export async function runAgent(ctx, type, prompt, options) {
206
206
  const extras = {};
207
207
  // Resolve extensions/skills: isolated overrides to false
208
208
  const extensions = options.isolated ? false : config.extensions;
209
+ // Nulling excludes under isolated also suppresses the orphaned-exclude warning —
210
+ // isolation is an intentional override, not a misconfiguration.
211
+ const excludeExtensions = options.isolated ? undefined : config.excludeExtensions;
209
212
  const skills = options.isolated ? false : config.skills;
210
213
  // Skill preloading: when skills is string[], preload their content into prompt
211
214
  if (Array.isArray(skills)) {
@@ -277,17 +280,35 @@ export async function runAgent(ctx, type, prompt, options) {
277
280
  ? parseExtensionsSpec(extensions, effectiveCwd)
278
281
  : undefined;
279
282
  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
+ // `exclude_extensions:` is a denylist applied AFTER the include set exclude wins.
284
+ // Plain canonical names only (case-insensitive). Note: excluded extensions'
285
+ // factories still run once during reload() (see comment above) — exclusion
286
+ // suppresses handler binding and tool registration; it is not a sandbox.
287
+ const excludeNames = new Set((excludeExtensions ?? []).map((n) => n.toLowerCase()));
288
+ const hasExcludes = excludeNames.size > 0;
289
+ // The override filters loaded extensions down to `keepNames` minus `excludeNames`.
290
+ // It's only needed when we're neither loading everything without excludes
291
+ // (`extensions: true` or a `"*"` wildcard) nor nothing (`noExtensions`).
283
292
  const loadAll = extensions === true || extensionsSpec?.wildcard === true;
284
293
  const additionalExtensionPaths = extensionsSpec?.paths.length ? extensionsSpec.paths : undefined;
285
- const extensionsOverride = loadAll || noExtensions
294
+ // Pre-filter discovered set, captured by the override — the exclude-typo warning
295
+ // must compare against this, not the surviving set (absence from survivors is
296
+ // an exclude *succeeding*).
297
+ let discoveredNames;
298
+ const extensionsOverride = noExtensions || (loadAll && !hasExcludes)
286
299
  ? undefined
287
- : (base) => ({
288
- ...base,
289
- extensions: base.extensions.filter((e) => keepNames.has(extensionCanonicalName(e.path))),
290
- });
300
+ : (base) => {
301
+ discoveredNames = new Set(base.extensions.map((e) => extensionCanonicalName(e.path)));
302
+ return {
303
+ ...base,
304
+ extensions: base.extensions.filter((e) => {
305
+ const name = extensionCanonicalName(e.path);
306
+ if (excludeNames.has(name))
307
+ return false; // exclude wins
308
+ return loadAll || keepNames.has(name);
309
+ }),
310
+ };
311
+ };
291
312
  const loader = new DefaultResourceLoader({
292
313
  cwd: effectiveCwd,
293
314
  agentDir,
@@ -325,13 +346,36 @@ export async function runAgent(ctx, type, prompt, options) {
325
346
  // - `tools: ext:foo` but foo isn't in the loaded set (because `extensions:`
326
347
  // didn't include it). Since v0.9, `ext:` no longer pulls extensions in;
327
348
  // loading is `extensions:`-authoritative.
349
+ // An exclude_extensions: alongside extensions: false is contradictory — nothing
350
+ // loads, so there is nothing to exclude.
351
+ if (hasExcludes && noExtensions) {
352
+ options.onToolActivity?.({
353
+ type: "end",
354
+ toolName: `extension-error:exclude_extensions has no effect for agent "${type}" — extensions: false loads nothing`,
355
+ });
356
+ }
357
+ // Exclude typo check: compares against the PRE-filter discovered set (an excluded
358
+ // name absent from the surviving set is the exclude working as intended). Also
359
+ // flags path-like and "*" entries — excludes are plain names only.
360
+ if (hasExcludes && discoveredNames) {
361
+ for (const name of excludeNames) {
362
+ if (!discoveredNames.has(name)) {
363
+ options.onToolActivity?.({
364
+ type: "end",
365
+ toolName: `extension-error:exclude_extensions: "${name}" for agent "${type}" did not match any discovered extension`,
366
+ });
367
+ }
368
+ }
369
+ }
328
370
  if (keepNames.size > 0 || extNames.size > 0) {
329
371
  const survivingNames = new Set(loader.getExtensions().extensions.map((e) => extensionCanonicalName(e.path)));
330
372
  for (const name of keepNames) {
331
373
  if (!survivingNames.has(name)) {
332
374
  options.onToolActivity?.({
333
375
  type: "end",
334
- toolName: `extension-error:extension "${name}" requested by agent "${type}" was not loaded`,
376
+ toolName: excludeNames.has(name)
377
+ ? `extension-error:extension "${name}" is in both extensions: and exclude_extensions: for agent "${type}" — exclude wins`
378
+ : `extension-error:extension "${name}" requested by agent "${type}" was not loaded`,
335
379
  });
336
380
  }
337
381
  }
@@ -339,7 +383,7 @@ export async function runAgent(ctx, type, prompt, options) {
339
383
  if (!survivingNames.has(name)) {
340
384
  options.onToolActivity?.({
341
385
  type: "end",
342
- toolName: `extension-error:ext:${name} referenced by agent "${type}" but extension "${name}" is not loaded (add it to extensions:)`,
386
+ toolName: `extension-error:ext:${name} referenced by agent "${type}" but extension "${name}" is not loaded (check extensions:/exclude_extensions:)`,
343
387
  });
344
388
  }
345
389
  }
@@ -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),
package/dist/index.js CHANGED
@@ -473,6 +473,13 @@ export default function (pi) {
473
473
  setDefaultsDisabled(b);
474
474
  reloadCustomAgents(); // re-register with new setting
475
475
  }
476
+ // ---- Agent tool description mode ----
477
+ // "full" (default) keeps the rich Claude Code-style description; "compact"
478
+ // swaps in a ~75% smaller one for small/local models (#91). Read once at
479
+ // tool registration — flipping it applies on the next pi session.
480
+ let toolDescriptionMode = "full";
481
+ function getToolDescriptionMode() { return toolDescriptionMode; }
482
+ function setToolDescriptionMode(mode) { toolDescriptionMode = mode; }
476
483
  // ---- Batch tracking for smart join mode ----
477
484
  // Collects background agent IDs spawned in the current turn for smart grouping.
478
485
  // Uses a debounced timer: each new agent resets the 100ms window so that all
@@ -539,6 +546,16 @@ export default function (pi) {
539
546
  return `- ${name}: ${cfg?.description ?? name}${modelSuffix}${toolsSuffix}`;
540
547
  }).join("\n");
541
548
  };
549
+ /** First sentence of an agent description — for the compact type list. */
550
+ const firstSentence = (text) => {
551
+ const match = text.match(/^.*?[.!?](?=\s|$)/s);
552
+ return (match ? match[0] : text).replace(/\s+/g, " ").trim();
553
+ };
554
+ /** Compact type list: one line per agent, first sentence only. */
555
+ const buildCompactTypeListText = () => getAvailableTypes().map((name) => {
556
+ const cfg = getAgentConfig(name);
557
+ return `- ${name}: ${firstSentence(cfg?.description ?? name)} (Tools: ${formatToolsSuffix(cfg)})`;
558
+ }).join("\n");
542
559
  /** Derive a short model label from a model string. */
543
560
  function getModelLabelFromConfig(model) {
544
561
  // Strip provider prefix (e.g. "anthropic/claude-sonnet-4-6" → "claude-sonnet-4-6")
@@ -557,6 +574,7 @@ export default function (pi) {
557
574
  setSchedulingEnabled,
558
575
  setScopeModels: setScopeModelsEnabled,
559
576
  setDisableDefaultAgents: setDisableDefaultAgents,
577
+ setToolDescriptionMode: setToolDescriptionMode,
560
578
  }, (event, payload) => pi.events.emit(event, payload));
561
579
  // ---- Agent tool ----
562
580
  // Schedule param + its guideline are gated on `schedulingEnabled` (read once
@@ -575,10 +593,21 @@ export default function (pi) {
575
593
  const scheduleGuideline = isSchedulingEnabled()
576
594
  ? `\n- Use \`schedule\` only when the user explicitly asked for scheduled / recurring / delayed execution (e.g. "every Monday", "in an hour"). Don't auto-schedule from vague intent like "monitor X" — run once now or ask.`
577
595
  : "";
578
- pi.registerTool(defineTool({
579
- name: SUBAGENT_TOOL_NAMES.AGENT,
580
- label: "Agent",
581
- description: `Launch a new agent to handle complex, multi-step tasks autonomously. Each agent type has specific capabilities and tools available to it.
596
+ // Compact Agent tool description (#91, `toolDescriptionMode: "compact"`) —
597
+ // the same load-bearing facts as the full version at ~75% fewer tokens, for
598
+ // small/local models. Per-option details live in the param descriptions.
599
+ const compactAgentToolDescription = `Launch an autonomous agent for complex, multi-step tasks. Agent types:
600
+ ${buildCompactTypeListText()}
601
+
602
+ Custom agents: .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.md (global).
603
+
604
+ Notes:
605
+ - description: 3-5 words (shown in UI). Prompts must be self-contained — the agent has not seen this conversation.
606
+ - Parallel work: one message, multiple Agent calls, run_in_background: true on each. You are notified when background agents finish — never poll or sleep.
607
+ - The result is not shown to the user — summarize it for them. Verify an agent's claimed code changes before reporting work done.
608
+ - resume continues a previous agent by ID; steer_subagent messages a running one.
609
+ - isolation: "worktree" runs the agent in an isolated git worktree; changes land on a branch.`;
610
+ const fullAgentToolDescription = `Launch a new agent to handle complex, multi-step tasks autonomously. Each agent type has specific capabilities and tools available to it.
582
611
 
583
612
  Available agent types and the tools they have access to:
584
613
  ${buildTypeListText()}
@@ -619,7 +648,61 @@ Provide clear, detailed prompts so the agent can work autonomously. Brief it lik
619
648
 
620
649
  Terse command-style prompts produce shallow, generic work.
621
650
 
622
- **Never delegate understanding.** Don't write "based on your findings, fix the bug" or "based on the research, implement it." Those phrases push synthesis onto the agent instead of doing it yourself. Write prompts that prove you understood: include file paths, line numbers, what specifically to change.`,
651
+ **Never delegate understanding.** Don't write "based on your findings, fix the bug" or "based on the research, implement it." Those phrases push synthesis onto the agent instead of doing it yourself. Write prompts that prove you understood: include file paths, line numbers, what specifically to change.`;
652
+ // `toolDescriptionMode: "custom"` — user-authored description with live
653
+ // dynamic parts. Project file wins over global; missing/empty falls back to
654
+ // "full" (a stale fallback beats a blank tool description). Only the prose
655
+ // is customizable — the parameter schema stays code-owned.
656
+ const renderToolDescriptionTemplate = (template) => {
657
+ const vars = {
658
+ typeList: buildTypeListText,
659
+ compactTypeList: buildCompactTypeListText,
660
+ agentDir: getAgentDir,
661
+ scheduleGuideline: () => scheduleGuideline,
662
+ };
663
+ // Replacement callback (not a string) — agent descriptions may contain `$&` etc.
664
+ return template.replace(/\{\{(\w+)\}\}/g, (raw, name) => {
665
+ if (vars[name])
666
+ return vars[name]();
667
+ console.warn(`[pi-subagents] agent-tool-description.md: unknown placeholder ${raw} left as-is`);
668
+ return raw;
669
+ });
670
+ };
671
+ const loadCustomToolDescription = () => {
672
+ for (const path of [
673
+ join(process.cwd(), ".pi", "agent-tool-description.md"),
674
+ join(getAgentDir(), "agent-tool-description.md"),
675
+ ]) {
676
+ try {
677
+ if (!existsSync(path))
678
+ continue;
679
+ const text = readFileSync(path, "utf-8").trim();
680
+ if (text)
681
+ return renderToolDescriptionTemplate(text);
682
+ console.warn(`[pi-subagents] ${path} is empty — ignoring`);
683
+ }
684
+ catch (err) {
685
+ console.warn(`[pi-subagents] failed to read ${path}: ${err instanceof Error ? err.message : String(err)}`);
686
+ }
687
+ }
688
+ return undefined;
689
+ };
690
+ const agentToolDescription = (() => {
691
+ const mode = getToolDescriptionMode();
692
+ if (mode === "compact")
693
+ return compactAgentToolDescription;
694
+ if (mode === "custom") {
695
+ const custom = loadCustomToolDescription();
696
+ if (custom)
697
+ return custom;
698
+ console.warn('[pi-subagents] toolDescriptionMode is "custom" but no agent-tool-description.md found — using "full"');
699
+ }
700
+ return fullAgentToolDescription;
701
+ })();
702
+ pi.registerTool(defineTool({
703
+ name: SUBAGENT_TOOL_NAMES.AGENT,
704
+ label: "Agent",
705
+ description: agentToolDescription,
623
706
  promptSnippet: "Launch autonomous sub-agents for complex multi-step tasks",
624
707
  promptGuidelines: [
625
708
  "Use Agent with specialized agents when the task matches an agent type's description. Subagents are valuable for parallelizing independent queries or for protecting the main context window from excessive results, but should not be used excessively when not needed. Importantly, avoid duplicating work that subagents are already doing — if you delegate research to a subagent, do not also perform the same searches yourself.",
@@ -1326,12 +1409,12 @@ Terse command-style prompts produce shallow, generic work.
1326
1409
  const { ConversationViewer, VIEWPORT_HEIGHT_PCT } = await import("./ui/conversation-viewer.js");
1327
1410
  const session = record.session;
1328
1411
  const activity = agentActivity.get(record.id);
1329
- await ctx.ui.custom((tui, theme, _keybindings, done) => {
1412
+ await ctx.ui.custom((tui, theme, keybindings, done) => {
1330
1413
  return new ConversationViewer(tui, session, record, activity, theme, done, () => {
1331
1414
  if (manager.abort(record.id)) {
1332
1415
  ctx.ui.notify(`Stopped "${record.description}".`, "info");
1333
1416
  }
1334
- });
1417
+ }, keybindings);
1335
1418
  }, {
1336
1419
  overlay: true,
1337
1420
  overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` },
@@ -1439,6 +1522,8 @@ Terse command-style prompts produce shallow, generic work.
1439
1522
  fmFields.push("extensions: false");
1440
1523
  else if (Array.isArray(cfg.extensions))
1441
1524
  fmFields.push(`extensions: ${cfg.extensions.join(", ")}`);
1525
+ if (cfg.excludeExtensions?.length)
1526
+ fmFields.push(`exclude_extensions: ${cfg.excludeExtensions.join(", ")}`);
1442
1527
  if (cfg.skills === false)
1443
1528
  fmFields.push("skills: false");
1444
1529
  else if (Array.isArray(cfg.skills))
@@ -1704,6 +1789,7 @@ ${systemPrompt}
1704
1789
  schedulingEnabled: isSchedulingEnabled(),
1705
1790
  scopeModels: isScopeModelsEnabled(),
1706
1791
  disableDefaultAgents: isDefaultsDisabled(),
1792
+ toolDescriptionMode: getToolDescriptionMode(),
1707
1793
  };
1708
1794
  }
1709
1795
  const NUMERIC_IDS = new Set(["maxConcurrent", "defaultMaxTurns", "graceTurns"]);
@@ -1762,6 +1848,13 @@ ${systemPrompt}
1762
1848
  currentValue: isDefaultsDisabled() ? "on" : "off",
1763
1849
  values: ["on", "off"],
1764
1850
  },
1851
+ {
1852
+ id: "toolDescriptionMode",
1853
+ label: "Tool description",
1854
+ description: "Agent tool description sent to the LLM: full (rich, default), compact (~75% fewer tokens, for small/local models), or custom (.pi/agent-tool-description.md with {{placeholders}})",
1855
+ currentValue: getToolDescriptionMode(),
1856
+ values: ["full", "compact", "custom"],
1857
+ },
1765
1858
  ];
1766
1859
  }
1767
1860
  function applyValue(id, value) {
@@ -1816,6 +1909,10 @@ ${systemPrompt}
1816
1909
  setDisableDefaultAgents(enabled);
1817
1910
  notifyApplied(ctx, `Default agents ${enabled ? "disabled" : "enabled"}. Tool spec change takes effect on next pi session.`);
1818
1911
  }
1912
+ else if (id === "toolDescriptionMode") {
1913
+ setToolDescriptionMode(value);
1914
+ notifyApplied(ctx, `Tool description set to ${value}. Takes effect on next pi session.`);
1915
+ }
1819
1916
  }
1820
1917
  let list;
1821
1918
  // Track current selection index directly (SettingsList doesn't expose it).
@@ -47,7 +47,19 @@ export interface SubagentsSettings {
47
47
  * Defaults to false.
48
48
  */
49
49
  disableDefaultAgents?: boolean;
50
+ /**
51
+ * Which Agent tool description the LLM sees. "full" (default) is the rich
52
+ * Claude Code-style prompt; "compact" is a ~75% smaller version (one-line
53
+ * agent type list, terse usage notes) for small/local models where tool-spec
54
+ * tokens are expensive; "custom" reads `.pi/agent-tool-description.md`
55
+ * (project, falling back to `<agentDir>/agent-tool-description.md`) with
56
+ * `{{placeholder}}` substitution — a missing/empty file falls back to "full".
57
+ * The mode is read once at tool registration — changing it applies on the
58
+ * next pi session.
59
+ */
60
+ toolDescriptionMode?: ToolDescriptionMode;
50
61
  }
62
+ export type ToolDescriptionMode = "full" | "compact" | "custom";
51
63
  /** Setter hooks used by applySettings to wire persisted values into in-memory state. */
52
64
  export interface SettingsAppliers {
53
65
  setMaxConcurrent: (n: number) => void;
@@ -57,6 +69,7 @@ export interface SettingsAppliers {
57
69
  setSchedulingEnabled: (b: boolean) => void;
58
70
  setScopeModels: (enabled: boolean) => void;
59
71
  setDisableDefaultAgents: (b: boolean) => void;
72
+ setToolDescriptionMode: (mode: ToolDescriptionMode) => void;
60
73
  }
61
74
  /** Emit callback — a subset of `pi.events.emit` to keep helpers testable. */
62
75
  export type SettingsEmit = (event: string, payload: unknown) => void;
package/dist/settings.js CHANGED
@@ -5,6 +5,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
5
  import { dirname, join } from "node:path";
6
6
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
7
7
  const VALID_JOIN_MODES = new Set(["async", "group", "smart"]);
8
+ const VALID_TOOL_DESCRIPTION_MODES = new Set(["full", "compact", "custom"]);
8
9
  // Sanity ceilings — prevent hand-edited configs from asking for values that
9
10
  // make no operational sense (e.g. 1e6 concurrent subagents). Permissive enough
10
11
  // that any realistic power-user setting passes through.
@@ -44,6 +45,9 @@ function sanitize(raw) {
44
45
  if (typeof r.disableDefaultAgents === "boolean") {
45
46
  out.disableDefaultAgents = r.disableDefaultAgents;
46
47
  }
48
+ if (typeof r.toolDescriptionMode === "string" && VALID_TOOL_DESCRIPTION_MODES.has(r.toolDescriptionMode)) {
49
+ out.toolDescriptionMode = r.toolDescriptionMode;
50
+ }
47
51
  return out;
48
52
  }
49
53
  function globalPath() {
@@ -105,6 +109,8 @@ export function applySettings(s, appliers) {
105
109
  appliers.setScopeModels(s.scopeModels);
106
110
  if (typeof s.disableDefaultAgents === "boolean")
107
111
  appliers.setDisableDefaultAgents(s.disableDefaultAgents);
112
+ if (s.toolDescriptionMode)
113
+ appliers.setToolDescriptionMode(s.toolDescriptionMode);
108
114
  }
109
115
  /**
110
116
  * Format the user-facing toast for a settings mutation. Pure function —
package/dist/types.d.ts CHANGED
@@ -26,6 +26,9 @@ export interface AgentConfig {
26
26
  disallowedTools?: string[];
27
27
  /** true = inherit all, string[] = only listed, false = none */
28
28
  extensions: true | string[] | false;
29
+ /** Extension-name denylist applied after the `extensions:` include set. Exclude wins.
30
+ * Plain canonical names only (case-insensitive); no paths, no wildcard. */
31
+ excludeExtensions?: string[];
29
32
  /** true = inherit all, string[] = only listed, false = none */
30
33
  skills: true | string[] | false;
31
34
  model?: string;
@@ -9,6 +9,7 @@ import { type Component, type TUI } from "@earendil-works/pi-tui";
9
9
  import type { AgentRecord } from "../types.js";
10
10
  import type { Theme } from "./agent-widget.js";
11
11
  import { type AgentActivity } from "./agent-widget.js";
12
+ import { type ViewerKeybindings } from "./viewer-keys.js";
12
13
  /** Height ceiling shared by the overlay's `maxHeight` and the viewer's internal viewport cap. */
13
14
  export declare const VIEWPORT_HEIGHT_PCT = 70;
14
15
  export declare class ConversationViewer implements Component {
@@ -27,9 +28,12 @@ export declare class ConversationViewer implements Component {
27
28
  private closed;
28
29
  /** Two-press confirm guard for the stop key, so a stray key can't kill the agent. */
29
30
  private stopArmed;
31
+ private keys;
30
32
  constructor(tui: TUI, session: AgentSession, record: AgentRecord, activity: AgentActivity | undefined, theme: Theme, done: (result: undefined) => void,
31
33
  /** Abort the agent shown here. Omitted → no stop affordance (e.g. read-only history). */
32
- onStop?: (() => void) | undefined);
34
+ onStop?: (() => void) | undefined,
35
+ /** User keybindings from `ctx.ui.custom()`. Omitted → hardcoded defaults. */
36
+ keybindings?: ViewerKeybindings);
33
37
  handleInput(data: string): void;
34
38
  render(width: number): string[];
35
39
  /** Stoppable only when a stop handler exists and the agent is still active. */
@@ -8,6 +8,7 @@ import { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@ea
8
8
  import { extractText } from "../context.js";
9
9
  import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
10
10
  import { buildInvocationTags, describeActivity, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
11
+ import { createViewerKeys } from "./viewer-keys.js";
11
12
  /** Base lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
12
13
  const CHROME_LINES_BASE = 6;
13
14
  const MIN_VIEWPORT = 3;
@@ -28,9 +29,12 @@ export class ConversationViewer {
28
29
  closed = false;
29
30
  /** Two-press confirm guard for the stop key, so a stray key can't kill the agent. */
30
31
  stopArmed = false;
32
+ keys;
31
33
  constructor(tui, session, record, activity, theme, done,
32
34
  /** Abort the agent shown here. Omitted → no stop affordance (e.g. read-only history). */
33
- onStop) {
35
+ onStop,
36
+ /** User keybindings from `ctx.ui.custom()`. Omitted → hardcoded defaults. */
37
+ keybindings) {
34
38
  this.tui = tui;
35
39
  this.session = session;
36
40
  this.record = record;
@@ -38,6 +42,7 @@ export class ConversationViewer {
38
42
  this.theme = theme;
39
43
  this.done = done;
40
44
  this.onStop = onStop;
45
+ this.keys = createViewerKeys(keybindings);
41
46
  this.unsubscribe = session.subscribe(() => {
42
47
  if (this.closed)
43
48
  return;
@@ -70,19 +75,19 @@ export class ConversationViewer {
70
75
  const totalLines = this.buildContentLines(this.lastInnerW).length;
71
76
  const viewportHeight = this.viewportHeight();
72
77
  const maxScroll = Math.max(0, totalLines - viewportHeight);
73
- if (matchesKey(data, "up") || matchesKey(data, "k")) {
78
+ if (this.keys.scrollUp(data)) {
74
79
  this.scrollOffset = Math.max(0, this.scrollOffset - 1);
75
80
  this.autoScroll = this.scrollOffset >= maxScroll;
76
81
  }
77
- else if (matchesKey(data, "down") || matchesKey(data, "j")) {
82
+ else if (this.keys.scrollDown(data)) {
78
83
  this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
79
84
  this.autoScroll = this.scrollOffset >= maxScroll;
80
85
  }
81
- else if (matchesKey(data, "pageUp") || matchesKey(data, "shift+up")) {
86
+ else if (this.keys.pageUp(data)) {
82
87
  this.scrollOffset = Math.max(0, this.scrollOffset - viewportHeight);
83
88
  this.autoScroll = false;
84
89
  }
85
- else if (matchesKey(data, "pageDown") || matchesKey(data, "shift+down")) {
90
+ else if (this.keys.pageDown(data)) {
86
91
  this.scrollOffset = Math.min(maxScroll, this.scrollOffset + viewportHeight);
87
92
  this.autoScroll = this.scrollOffset >= maxScroll;
88
93
  }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * viewer-keys.ts — Scroll key matchers for the conversation viewer.
3
+ *
4
+ * Resolves `tui.select.*` through the user's keybindings when pi provides a
5
+ * manager, falling back to the previous hardcoded keys otherwise. The viewer's
6
+ * k/j and shift+arrow aliases always work alongside whatever is bound.
7
+ */
8
+ /** The `tui.select.*` keybinding ids the viewer resolves. */
9
+ export type ViewerScrollKeybinding = "tui.select.up" | "tui.select.down" | "tui.select.pageUp" | "tui.select.pageDown";
10
+ /** Structural subset of pi-tui's `KeybindingsManager` (which satisfies it). */
11
+ export interface ViewerKeybindings {
12
+ matches(data: string, keybinding: ViewerScrollKeybinding): boolean;
13
+ }
14
+ export interface ViewerKeys {
15
+ scrollUp(data: string): boolean;
16
+ scrollDown(data: string): boolean;
17
+ pageUp(data: string): boolean;
18
+ pageDown(data: string): boolean;
19
+ }
20
+ export declare function createViewerKeys(keybindings?: ViewerKeybindings): ViewerKeys;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * viewer-keys.ts — Scroll key matchers for the conversation viewer.
3
+ *
4
+ * Resolves `tui.select.*` through the user's keybindings when pi provides a
5
+ * manager, falling back to the previous hardcoded keys otherwise. The viewer's
6
+ * k/j and shift+arrow aliases always work alongside whatever is bound.
7
+ */
8
+ import { matchesKey } from "@earendil-works/pi-tui";
9
+ export function createViewerKeys(keybindings) {
10
+ const matches = (data, id, fallback) => keybindings ? keybindings.matches(data, id) : matchesKey(data, fallback);
11
+ return {
12
+ scrollUp: (data) => matches(data, "tui.select.up", "up") || matchesKey(data, "k"),
13
+ scrollDown: (data) => matches(data, "tui.select.down", "down") || matchesKey(data, "j"),
14
+ pageUp: (data) => matches(data, "tui.select.pageUp", "pageUp") || matchesKey(data, "shift+up"),
15
+ pageDown: (data) => matches(data, "tui.select.pageDown", "pageDown") || matchesKey(data, "shift+down"),
16
+ };
17
+ }
@@ -0,0 +1,42 @@
1
+ Launch a new agent to handle complex, multi-step tasks autonomously. Each agent type has specific capabilities and tools available to it.
2
+
3
+ Available agent types and the tools they have access to:
4
+ {{typeList}}
5
+
6
+ Custom agents can be defined in .pi/agents/<name>.md (project) or {{agentDir}}/agents/<name>.md (global) — they are picked up automatically. Project-level agents override global ones. Creating a .md file with the same name as a default agent overrides it.
7
+
8
+ When using the Agent tool, specify a subagent_type parameter to select which agent type to use.
9
+
10
+ ## When not to use
11
+
12
+ If the target is already known, use a direct tool — `read` for a known path, `grep`/`find` for a specific symbol or string. Reserve this tool for open-ended questions that span the codebase, or tasks that match an available agent type.
13
+
14
+ ## Usage notes
15
+
16
+ - Always include a short (3-5 word) description summarizing what the agent will do (shown in UI).
17
+ - When you launch multiple agents for independent work, send them in a single message with multiple tool uses, with run_in_background: true on each, so they run concurrently. If the user specifies that they want agents run "in parallel", you MUST send a single message with multiple tool calls. Foreground calls run sequentially — only one executes at a time.
18
+ - When the agent is done, it returns a single message back to you. The result is not visible to the user — to show the user, send a text message with a concise summary.
19
+ - Trust but verify: an agent's summary describes what it intended to do, not necessarily what it did. When an agent writes or edits code, check the actual changes before reporting work as done.
20
+ - Use run_in_background for work you don't need immediately. You will be notified when it completes — do NOT poll or sleep waiting for it. Continue with other work or respond to the user instead.
21
+ - Foreground vs background: use foreground (default) when you need the agent's results before you can proceed. Use background when you have genuinely independent work to do in parallel.
22
+ - Use resume with an agent ID to continue a previous agent's work. A new (non-resume) Agent call starts a fresh agent with no memory of prior runs, so the prompt must be self-contained.
23
+ - Use steer_subagent to send mid-run messages to a running background agent.
24
+ - Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, etc.), since it is not aware of the user's intent.
25
+ - If an agent's description says it should be used proactively, try to use it without the user having to ask for it first.
26
+ - Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet").
27
+ - Use thinking to control extended thinking level.
28
+ - Use inherit_context if the agent needs the parent conversation history.
29
+ - Use isolation: "worktree" to run the agent in an isolated git worktree (safe parallel file modifications). The worktree is automatically cleaned up if the agent makes no changes; otherwise the path and branch are returned in the result.{{scheduleGuideline}}
30
+
31
+ ## Writing the prompt
32
+
33
+ Provide clear, detailed prompts so the agent can work autonomously. Brief it like a smart colleague who just walked into the room — it hasn't seen this conversation, doesn't know what you've tried, doesn't understand why this task matters.
34
+ - Explain what you're trying to accomplish and why.
35
+ - Describe what you've already learned or ruled out.
36
+ - Give enough context about the surrounding problem that the agent can make judgment calls rather than just following a narrow instruction.
37
+ - If you need a short response, say so ("report in under 200 words").
38
+ - Lookups: hand over the exact command. Investigations: hand over the question — prescribed steps become dead weight when the premise is wrong.
39
+
40
+ Terse command-style prompts produce shallow, generic work.
41
+
42
+ **Never delegate understanding.** Don't write "based on your findings, fix the bug" or "based on the research, implement it." Those phrases push synthesis onto the agent instead of doing it yourself. Write prompts that prove you understood: include file paths, line numbers, what specifically to change.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tintinweb/pi-subagents",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
4
4
  "description": "A pi extension extension that brings smart Claude Code-style autonomous sub-agents to pi.",
5
5
  "author": "tintinweb",
6
6
  "license": "MIT",
@@ -296,6 +296,9 @@ export async function runAgent(
296
296
 
297
297
  // Resolve extensions/skills: isolated overrides to false
298
298
  const extensions = options.isolated ? false : config.extensions;
299
+ // Nulling excludes under isolated also suppresses the orphaned-exclude warning —
300
+ // isolation is an intentional override, not a misconfiguration.
301
+ const excludeExtensions = options.isolated ? undefined : config.excludeExtensions;
299
302
  const skills = options.isolated ? false : config.skills;
300
303
 
301
304
  // Skill preloading: when skills is string[], preload their content into prompt
@@ -373,18 +376,35 @@ export async function runAgent(
373
376
  ? parseExtensionsSpec(extensions, effectiveCwd)
374
377
  : undefined;
375
378
  const keepNames = extensionsSpec?.names ?? new Set<string>();
376
- // The override filters loaded extensions down to `keepNames`. It's only needed
377
- // when we're neither loading everything (`extensions: true` or a `"*"` wildcard)
378
- // nor nothing (`noExtensions`).
379
+ // `exclude_extensions:` is a denylist applied AFTER the include set exclude wins.
380
+ // Plain canonical names only (case-insensitive). Note: excluded extensions'
381
+ // factories still run once during reload() (see comment above) — exclusion
382
+ // suppresses handler binding and tool registration; it is not a sandbox.
383
+ const excludeNames = new Set((excludeExtensions ?? []).map((n) => n.toLowerCase()));
384
+ const hasExcludes = excludeNames.size > 0;
385
+ // The override filters loaded extensions down to `keepNames` minus `excludeNames`.
386
+ // It's only needed when we're neither loading everything without excludes
387
+ // (`extensions: true` or a `"*"` wildcard) nor nothing (`noExtensions`).
379
388
  const loadAll = extensions === true || extensionsSpec?.wildcard === true;
380
389
  const additionalExtensionPaths = extensionsSpec?.paths.length ? extensionsSpec.paths : undefined;
390
+ // Pre-filter discovered set, captured by the override — the exclude-typo warning
391
+ // must compare against this, not the surviving set (absence from survivors is
392
+ // an exclude *succeeding*).
393
+ let discoveredNames: Set<string> | undefined;
381
394
  const extensionsOverride: ((base: LoadExtensionsResult) => LoadExtensionsResult) | undefined =
382
- loadAll || noExtensions
395
+ noExtensions || (loadAll && !hasExcludes)
383
396
  ? undefined
384
- : (base) => ({
385
- ...base,
386
- extensions: base.extensions.filter((e) => keepNames.has(extensionCanonicalName(e.path))),
387
- });
397
+ : (base) => {
398
+ discoveredNames = new Set(base.extensions.map((e) => extensionCanonicalName(e.path)));
399
+ return {
400
+ ...base,
401
+ extensions: base.extensions.filter((e) => {
402
+ const name = extensionCanonicalName(e.path);
403
+ if (excludeNames.has(name)) return false; // exclude wins
404
+ return loadAll || keepNames.has(name);
405
+ }),
406
+ };
407
+ };
388
408
 
389
409
  const loader = new DefaultResourceLoader({
390
410
  cwd: effectiveCwd,
@@ -425,6 +445,27 @@ export async function runAgent(
425
445
  // - `tools: ext:foo` but foo isn't in the loaded set (because `extensions:`
426
446
  // didn't include it). Since v0.9, `ext:` no longer pulls extensions in;
427
447
  // loading is `extensions:`-authoritative.
448
+ // An exclude_extensions: alongside extensions: false is contradictory — nothing
449
+ // loads, so there is nothing to exclude.
450
+ if (hasExcludes && noExtensions) {
451
+ options.onToolActivity?.({
452
+ type: "end",
453
+ toolName: `extension-error:exclude_extensions has no effect for agent "${type}" — extensions: false loads nothing`,
454
+ });
455
+ }
456
+ // Exclude typo check: compares against the PRE-filter discovered set (an excluded
457
+ // name absent from the surviving set is the exclude working as intended). Also
458
+ // flags path-like and "*" entries — excludes are plain names only.
459
+ if (hasExcludes && discoveredNames) {
460
+ for (const name of excludeNames) {
461
+ if (!discoveredNames.has(name)) {
462
+ options.onToolActivity?.({
463
+ type: "end",
464
+ toolName: `extension-error:exclude_extensions: "${name}" for agent "${type}" did not match any discovered extension`,
465
+ });
466
+ }
467
+ }
468
+ }
428
469
  if (keepNames.size > 0 || extNames.size > 0) {
429
470
  const survivingNames = new Set(
430
471
  loader.getExtensions().extensions.map((e) => extensionCanonicalName(e.path)),
@@ -433,7 +474,9 @@ export async function runAgent(
433
474
  if (!survivingNames.has(name)) {
434
475
  options.onToolActivity?.({
435
476
  type: "end",
436
- toolName: `extension-error:extension "${name}" requested by agent "${type}" was not loaded`,
477
+ toolName: excludeNames.has(name)
478
+ ? `extension-error:extension "${name}" is in both extensions: and exclude_extensions: for agent "${type}" — exclude wins`
479
+ : `extension-error:extension "${name}" requested by agent "${type}" was not loaded`,
437
480
  });
438
481
  }
439
482
  }
@@ -441,7 +484,7 @@ export async function runAgent(
441
484
  if (!survivingNames.has(name)) {
442
485
  options.onToolActivity?.({
443
486
  type: "end",
444
- toolName: `extension-error:ext:${name} referenced by agent "${type}" but extension "${name}" is not loaded (add it to extensions:)`,
487
+ toolName: `extension-error:ext:${name} referenced by agent "${type}" but extension "${name}" is not loaded (check extensions:/exclude_extensions:)`,
445
488
  });
446
489
  }
447
490
  }
@@ -144,6 +144,7 @@ export function getConfig(type: string): {
144
144
  description: string;
145
145
  builtinToolNames: string[];
146
146
  extensions: true | string[] | false;
147
+ excludeExtensions?: string[];
147
148
  skills: true | string[] | false;
148
149
  promptMode: "replace" | "append";
149
150
  } {
@@ -155,6 +156,7 @@ export function getConfig(type: string): {
155
156
  description: config.description,
156
157
  builtinToolNames: config.builtinToolNames ?? BUILTIN_TOOL_NAMES,
157
158
  extensions: config.extensions,
159
+ excludeExtensions: config.excludeExtensions,
158
160
  skills: config.skills,
159
161
  promptMode: config.promptMode,
160
162
  };
@@ -168,6 +170,7 @@ export function getConfig(type: string): {
168
170
  description: gp.description,
169
171
  builtinToolNames: gp.builtinToolNames ?? BUILTIN_TOOL_NAMES,
170
172
  extensions: gp.extensions,
173
+ excludeExtensions: gp.excludeExtensions,
171
174
  skills: gp.skills,
172
175
  promptMode: gp.promptMode,
173
176
  };
@@ -60,6 +60,7 @@ function loadFromDir(dir: string, agents: Map<string, AgentConfig>, source: "pro
60
60
  extSelectors,
61
61
  disallowedTools: csvListOptional(fm.disallowed_tools),
62
62
  extensions: inheritField(fm.extensions ?? fm.inherit_extensions),
63
+ excludeExtensions: csvListOptional(fm.exclude_extensions),
63
64
  skills: inheritField(fm.skills ?? fm.inherit_skills),
64
65
  model: str(fm.model),
65
66
  thinking: str(fm.thinking) as ThinkingLevel | undefined,
package/src/index.ts CHANGED
@@ -27,7 +27,7 @@ import { type ModelRegistry, resolveModel } from "./model-resolver.js";
27
27
  import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./output-file.js";
28
28
  import { SubagentScheduler } from "./schedule.js";
29
29
  import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
30
- import { applyAndEmitLoaded, type SubagentsSettings, saveAndEmitChanged } from "./settings.js";
30
+ import { applyAndEmitLoaded, type SubagentsSettings, saveAndEmitChanged, type ToolDescriptionMode } from "./settings.js";
31
31
  import { getStatusNote } from "./status-note.js";
32
32
  import { type AgentConfig, type AgentInvocation, type AgentRecord, type JoinMode, type NotificationDetails, type SubagentType } from "./types.js";
33
33
  import {
@@ -533,6 +533,14 @@ export default function (pi: ExtensionAPI) {
533
533
  reloadCustomAgents(); // re-register with new setting
534
534
  }
535
535
 
536
+ // ---- Agent tool description mode ----
537
+ // "full" (default) keeps the rich Claude Code-style description; "compact"
538
+ // swaps in a ~75% smaller one for small/local models (#91). Read once at
539
+ // tool registration — flipping it applies on the next pi session.
540
+ let toolDescriptionMode: ToolDescriptionMode = "full";
541
+ function getToolDescriptionMode(): ToolDescriptionMode { return toolDescriptionMode; }
542
+ function setToolDescriptionMode(mode: ToolDescriptionMode): void { toolDescriptionMode = mode; }
543
+
536
544
  // ---- Batch tracking for smart join mode ----
537
545
  // Collects background agent IDs spawned in the current turn for smart grouping.
538
546
  // Uses a debounced timer: each new agent resets the 100ms window so that all
@@ -604,6 +612,19 @@ export default function (pi: ExtensionAPI) {
604
612
  }).join("\n");
605
613
  };
606
614
 
615
+ /** First sentence of an agent description — for the compact type list. */
616
+ const firstSentence = (text: string): string => {
617
+ const match = text.match(/^.*?[.!?](?=\s|$)/s);
618
+ return (match ? match[0] : text).replace(/\s+/g, " ").trim();
619
+ };
620
+
621
+ /** Compact type list: one line per agent, first sentence only. */
622
+ const buildCompactTypeListText = () =>
623
+ getAvailableTypes().map((name) => {
624
+ const cfg = getAgentConfig(name);
625
+ return `- ${name}: ${firstSentence(cfg?.description ?? name)} (Tools: ${formatToolsSuffix(cfg)})`;
626
+ }).join("\n");
627
+
607
628
  /** Derive a short model label from a model string. */
608
629
  function getModelLabelFromConfig(model: string): string {
609
630
  // Strip provider prefix (e.g. "anthropic/claude-sonnet-4-6" → "claude-sonnet-4-6")
@@ -624,6 +645,7 @@ export default function (pi: ExtensionAPI) {
624
645
  setSchedulingEnabled,
625
646
  setScopeModels: setScopeModelsEnabled,
626
647
  setDisableDefaultAgents: setDisableDefaultAgents,
648
+ setToolDescriptionMode: setToolDescriptionMode,
627
649
  },
628
650
  (event, payload) => pi.events.emit(event, payload),
629
651
  );
@@ -652,10 +674,22 @@ export default function (pi: ExtensionAPI) {
652
674
  ? `\n- Use \`schedule\` only when the user explicitly asked for scheduled / recurring / delayed execution (e.g. "every Monday", "in an hour"). Don't auto-schedule from vague intent like "monitor X" — run once now or ask.`
653
675
  : "";
654
676
 
655
- pi.registerTool(defineTool({
656
- name: SUBAGENT_TOOL_NAMES.AGENT,
657
- label: "Agent",
658
- description: `Launch a new agent to handle complex, multi-step tasks autonomously. Each agent type has specific capabilities and tools available to it.
677
+ // Compact Agent tool description (#91, `toolDescriptionMode: "compact"`) —
678
+ // the same load-bearing facts as the full version at ~75% fewer tokens, for
679
+ // small/local models. Per-option details live in the param descriptions.
680
+ const compactAgentToolDescription = `Launch an autonomous agent for complex, multi-step tasks. Agent types:
681
+ ${buildCompactTypeListText()}
682
+
683
+ Custom agents: .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.md (global).
684
+
685
+ Notes:
686
+ - description: 3-5 words (shown in UI). Prompts must be self-contained — the agent has not seen this conversation.
687
+ - Parallel work: one message, multiple Agent calls, run_in_background: true on each. You are notified when background agents finish — never poll or sleep.
688
+ - The result is not shown to the user — summarize it for them. Verify an agent's claimed code changes before reporting work done.
689
+ - resume continues a previous agent by ID; steer_subagent messages a running one.
690
+ - isolation: "worktree" runs the agent in an isolated git worktree; changes land on a branch.`;
691
+
692
+ const fullAgentToolDescription = `Launch a new agent to handle complex, multi-step tasks autonomously. Each agent type has specific capabilities and tools available to it.
659
693
 
660
694
  Available agent types and the tools they have access to:
661
695
  ${buildTypeListText()}
@@ -696,7 +730,59 @@ Provide clear, detailed prompts so the agent can work autonomously. Brief it lik
696
730
 
697
731
  Terse command-style prompts produce shallow, generic work.
698
732
 
699
- **Never delegate understanding.** Don't write "based on your findings, fix the bug" or "based on the research, implement it." Those phrases push synthesis onto the agent instead of doing it yourself. Write prompts that prove you understood: include file paths, line numbers, what specifically to change.`,
733
+ **Never delegate understanding.** Don't write "based on your findings, fix the bug" or "based on the research, implement it." Those phrases push synthesis onto the agent instead of doing it yourself. Write prompts that prove you understood: include file paths, line numbers, what specifically to change.`;
734
+
735
+ // `toolDescriptionMode: "custom"` — user-authored description with live
736
+ // dynamic parts. Project file wins over global; missing/empty falls back to
737
+ // "full" (a stale fallback beats a blank tool description). Only the prose
738
+ // is customizable — the parameter schema stays code-owned.
739
+ const renderToolDescriptionTemplate = (template: string): string => {
740
+ const vars: Record<string, () => string> = {
741
+ typeList: buildTypeListText,
742
+ compactTypeList: buildCompactTypeListText,
743
+ agentDir: getAgentDir,
744
+ scheduleGuideline: () => scheduleGuideline,
745
+ };
746
+ // Replacement callback (not a string) — agent descriptions may contain `$&` etc.
747
+ return template.replace(/\{\{(\w+)\}\}/g, (raw, name: string) => {
748
+ if (vars[name]) return vars[name]();
749
+ console.warn(`[pi-subagents] agent-tool-description.md: unknown placeholder ${raw} left as-is`);
750
+ return raw;
751
+ });
752
+ };
753
+
754
+ const loadCustomToolDescription = (): string | undefined => {
755
+ for (const path of [
756
+ join(process.cwd(), ".pi", "agent-tool-description.md"),
757
+ join(getAgentDir(), "agent-tool-description.md"),
758
+ ]) {
759
+ try {
760
+ if (!existsSync(path)) continue;
761
+ const text = readFileSync(path, "utf-8").trim();
762
+ if (text) return renderToolDescriptionTemplate(text);
763
+ console.warn(`[pi-subagents] ${path} is empty — ignoring`);
764
+ } catch (err) {
765
+ console.warn(`[pi-subagents] failed to read ${path}: ${err instanceof Error ? err.message : String(err)}`);
766
+ }
767
+ }
768
+ return undefined;
769
+ };
770
+
771
+ const agentToolDescription = (() => {
772
+ const mode = getToolDescriptionMode();
773
+ if (mode === "compact") return compactAgentToolDescription;
774
+ if (mode === "custom") {
775
+ const custom = loadCustomToolDescription();
776
+ if (custom) return custom;
777
+ console.warn('[pi-subagents] toolDescriptionMode is "custom" but no agent-tool-description.md found — using "full"');
778
+ }
779
+ return fullAgentToolDescription;
780
+ })();
781
+
782
+ pi.registerTool(defineTool({
783
+ name: SUBAGENT_TOOL_NAMES.AGENT,
784
+ label: "Agent",
785
+ description: agentToolDescription,
700
786
  promptSnippet: "Launch autonomous sub-agents for complex multi-step tasks",
701
787
  promptGuidelines: [
702
788
  "Use Agent with specialized agents when the task matches an agent type's description. Subagents are valuable for parallelizing independent queries or for protecting the main context window from excessive results, but should not be used excessively when not needed. Importantly, avoid duplicating work that subagents are already doing — if you delegate research to a subagent, do not also perform the same searches yourself.",
@@ -1494,12 +1580,12 @@ Terse command-style prompts produce shallow, generic work.
1494
1580
  const activity = agentActivity.get(record.id);
1495
1581
 
1496
1582
  await ctx.ui.custom<undefined>(
1497
- (tui, theme, _keybindings, done) => {
1583
+ (tui, theme, keybindings, done) => {
1498
1584
  return new ConversationViewer(tui, session, record, activity, theme, done, () => {
1499
1585
  if (manager.abort(record.id)) {
1500
1586
  ctx.ui.notify(`Stopped "${record.description}".`, "info");
1501
1587
  }
1502
- });
1588
+ }, keybindings);
1503
1589
  },
1504
1590
  {
1505
1591
  overlay: true,
@@ -1601,6 +1687,7 @@ Terse command-style prompts produce shallow, generic work.
1601
1687
  fmFields.push(`prompt_mode: ${cfg.promptMode}`);
1602
1688
  if (cfg.extensions === false) fmFields.push("extensions: false");
1603
1689
  else if (Array.isArray(cfg.extensions)) fmFields.push(`extensions: ${cfg.extensions.join(", ")}`);
1690
+ if (cfg.excludeExtensions?.length) fmFields.push(`exclude_extensions: ${cfg.excludeExtensions.join(", ")}`);
1604
1691
  if (cfg.skills === false) fmFields.push("skills: false");
1605
1692
  else if (Array.isArray(cfg.skills)) fmFields.push(`skills: ${cfg.skills.join(", ")}`);
1606
1693
  if (cfg.disallowedTools?.length) fmFields.push(`disallowed_tools: ${cfg.disallowedTools.join(", ")}`);
@@ -1869,6 +1956,7 @@ ${systemPrompt}
1869
1956
  schedulingEnabled: isSchedulingEnabled(),
1870
1957
  scopeModels: isScopeModelsEnabled(),
1871
1958
  disableDefaultAgents: isDefaultsDisabled(),
1959
+ toolDescriptionMode: getToolDescriptionMode(),
1872
1960
  };
1873
1961
  }
1874
1962
 
@@ -1930,6 +2018,13 @@ ${systemPrompt}
1930
2018
  currentValue: isDefaultsDisabled() ? "on" : "off",
1931
2019
  values: ["on", "off"],
1932
2020
  },
2021
+ {
2022
+ id: "toolDescriptionMode",
2023
+ label: "Tool description",
2024
+ description: "Agent tool description sent to the LLM: full (rich, default), compact (~75% fewer tokens, for small/local models), or custom (.pi/agent-tool-description.md with {{placeholders}})",
2025
+ currentValue: getToolDescriptionMode(),
2026
+ values: ["full", "compact", "custom"],
2027
+ },
1933
2028
  ];
1934
2029
  }
1935
2030
 
@@ -1978,6 +2073,9 @@ ${systemPrompt}
1978
2073
  const enabled = value === "on";
1979
2074
  setDisableDefaultAgents(enabled);
1980
2075
  notifyApplied(ctx, `Default agents ${enabled ? "disabled" : "enabled"}. Tool spec change takes effect on next pi session.`);
2076
+ } else if (id === "toolDescriptionMode") {
2077
+ setToolDescriptionMode(value as ToolDescriptionMode);
2078
+ notifyApplied(ctx, `Tool description set to ${value}. Takes effect on next pi session.`);
1981
2079
  }
1982
2080
  }
1983
2081
 
package/src/settings.ts CHANGED
@@ -55,8 +55,21 @@ export interface SubagentsSettings {
55
55
  * Defaults to false.
56
56
  */
57
57
  disableDefaultAgents?: boolean;
58
+ /**
59
+ * Which Agent tool description the LLM sees. "full" (default) is the rich
60
+ * Claude Code-style prompt; "compact" is a ~75% smaller version (one-line
61
+ * agent type list, terse usage notes) for small/local models where tool-spec
62
+ * tokens are expensive; "custom" reads `.pi/agent-tool-description.md`
63
+ * (project, falling back to `<agentDir>/agent-tool-description.md`) with
64
+ * `{{placeholder}}` substitution — a missing/empty file falls back to "full".
65
+ * The mode is read once at tool registration — changing it applies on the
66
+ * next pi session.
67
+ */
68
+ toolDescriptionMode?: ToolDescriptionMode;
58
69
  }
59
70
 
71
+ export type ToolDescriptionMode = "full" | "compact" | "custom";
72
+
60
73
  /** Setter hooks used by applySettings to wire persisted values into in-memory state. */
61
74
  export interface SettingsAppliers {
62
75
  setMaxConcurrent: (n: number) => void;
@@ -66,12 +79,14 @@ export interface SettingsAppliers {
66
79
  setSchedulingEnabled: (b: boolean) => void;
67
80
  setScopeModels: (enabled: boolean) => void;
68
81
  setDisableDefaultAgents: (b: boolean) => void;
82
+ setToolDescriptionMode: (mode: ToolDescriptionMode) => void;
69
83
  }
70
84
 
71
85
  /** Emit callback — a subset of `pi.events.emit` to keep helpers testable. */
72
86
  export type SettingsEmit = (event: string, payload: unknown) => void;
73
87
 
74
88
  const VALID_JOIN_MODES: ReadonlySet<string> = new Set<JoinMode>(["async", "group", "smart"]);
89
+ const VALID_TOOL_DESCRIPTION_MODES: ReadonlySet<string> = new Set<ToolDescriptionMode>(["full", "compact", "custom"]);
75
90
 
76
91
  // Sanity ceilings — prevent hand-edited configs from asking for values that
77
92
  // make no operational sense (e.g. 1e6 concurrent subagents). Permissive enough
@@ -118,6 +133,9 @@ function sanitize(raw: unknown): SubagentsSettings {
118
133
  if (typeof r.disableDefaultAgents === "boolean") {
119
134
  out.disableDefaultAgents = r.disableDefaultAgents;
120
135
  }
136
+ if (typeof r.toolDescriptionMode === "string" && VALID_TOOL_DESCRIPTION_MODES.has(r.toolDescriptionMode)) {
137
+ out.toolDescriptionMode = r.toolDescriptionMode as ToolDescriptionMode;
138
+ }
121
139
  return out;
122
140
  }
123
141
 
@@ -175,6 +193,7 @@ export function applySettings(s: SubagentsSettings, appliers: SettingsAppliers):
175
193
  if (typeof s.schedulingEnabled === "boolean") appliers.setSchedulingEnabled(s.schedulingEnabled);
176
194
  if (typeof s.scopeModels === "boolean") appliers.setScopeModels(s.scopeModels);
177
195
  if (typeof s.disableDefaultAgents === "boolean") appliers.setDisableDefaultAgents(s.disableDefaultAgents);
196
+ if (s.toolDescriptionMode) appliers.setToolDescriptionMode(s.toolDescriptionMode);
178
197
  }
179
198
 
180
199
  /**
package/src/types.ts CHANGED
@@ -33,6 +33,9 @@ export interface AgentConfig {
33
33
  disallowedTools?: string[];
34
34
  /** true = inherit all, string[] = only listed, false = none */
35
35
  extensions: true | string[] | false;
36
+ /** Extension-name denylist applied after the `extensions:` include set. Exclude wins.
37
+ * Plain canonical names only (case-insensitive); no paths, no wildcard. */
38
+ excludeExtensions?: string[];
36
39
  /** true = inherit all, string[] = only listed, false = none */
37
40
  skills: true | string[] | false;
38
41
  model?: string;
@@ -12,6 +12,7 @@ import type { AgentRecord } from "../types.js";
12
12
  import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
13
13
  import type { Theme } from "./agent-widget.js";
14
14
  import { type AgentActivity, buildInvocationTags, describeActivity, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
15
+ import { createViewerKeys, type ViewerKeybindings, type ViewerKeys } from "./viewer-keys.js";
15
16
 
16
17
  /** Base lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
17
18
  const CHROME_LINES_BASE = 6;
@@ -27,6 +28,7 @@ export class ConversationViewer implements Component {
27
28
  private closed = false;
28
29
  /** Two-press confirm guard for the stop key, so a stray key can't kill the agent. */
29
30
  private stopArmed = false;
31
+ private keys: ViewerKeys;
30
32
 
31
33
  constructor(
32
34
  private tui: TUI,
@@ -37,7 +39,10 @@ export class ConversationViewer implements Component {
37
39
  private done: (result: undefined) => void,
38
40
  /** Abort the agent shown here. Omitted → no stop affordance (e.g. read-only history). */
39
41
  private onStop?: () => void,
42
+ /** User keybindings from `ctx.ui.custom()`. Omitted → hardcoded defaults. */
43
+ keybindings?: ViewerKeybindings,
40
44
  ) {
45
+ this.keys = createViewerKeys(keybindings);
41
46
  this.unsubscribe = session.subscribe(() => {
42
47
  if (this.closed) return;
43
48
  this.tui.requestRender();
@@ -71,16 +76,16 @@ export class ConversationViewer implements Component {
71
76
  const viewportHeight = this.viewportHeight();
72
77
  const maxScroll = Math.max(0, totalLines - viewportHeight);
73
78
 
74
- if (matchesKey(data, "up") || matchesKey(data, "k")) {
79
+ if (this.keys.scrollUp(data)) {
75
80
  this.scrollOffset = Math.max(0, this.scrollOffset - 1);
76
81
  this.autoScroll = this.scrollOffset >= maxScroll;
77
- } else if (matchesKey(data, "down") || matchesKey(data, "j")) {
82
+ } else if (this.keys.scrollDown(data)) {
78
83
  this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
79
84
  this.autoScroll = this.scrollOffset >= maxScroll;
80
- } else if (matchesKey(data, "pageUp") || matchesKey(data, "shift+up")) {
85
+ } else if (this.keys.pageUp(data)) {
81
86
  this.scrollOffset = Math.max(0, this.scrollOffset - viewportHeight);
82
87
  this.autoScroll = false;
83
- } else if (matchesKey(data, "pageDown") || matchesKey(data, "shift+down")) {
88
+ } else if (this.keys.pageDown(data)) {
84
89
  this.scrollOffset = Math.min(maxScroll, this.scrollOffset + viewportHeight);
85
90
  this.autoScroll = this.scrollOffset >= maxScroll;
86
91
  } else if (matchesKey(data, "home")) {
@@ -0,0 +1,39 @@
1
+ /**
2
+ * viewer-keys.ts — Scroll key matchers for the conversation viewer.
3
+ *
4
+ * Resolves `tui.select.*` through the user's keybindings when pi provides a
5
+ * manager, falling back to the previous hardcoded keys otherwise. The viewer's
6
+ * k/j and shift+arrow aliases always work alongside whatever is bound.
7
+ */
8
+
9
+ import { type KeyId, matchesKey } from "@earendil-works/pi-tui";
10
+
11
+ /** The `tui.select.*` keybinding ids the viewer resolves. */
12
+ export type ViewerScrollKeybinding =
13
+ | "tui.select.up"
14
+ | "tui.select.down"
15
+ | "tui.select.pageUp"
16
+ | "tui.select.pageDown";
17
+
18
+ /** Structural subset of pi-tui's `KeybindingsManager` (which satisfies it). */
19
+ export interface ViewerKeybindings {
20
+ matches(data: string, keybinding: ViewerScrollKeybinding): boolean;
21
+ }
22
+
23
+ export interface ViewerKeys {
24
+ scrollUp(data: string): boolean;
25
+ scrollDown(data: string): boolean;
26
+ pageUp(data: string): boolean;
27
+ pageDown(data: string): boolean;
28
+ }
29
+
30
+ export function createViewerKeys(keybindings?: ViewerKeybindings): ViewerKeys {
31
+ const matches = (data: string, id: ViewerScrollKeybinding, fallback: KeyId): boolean =>
32
+ keybindings ? keybindings.matches(data, id) : matchesKey(data, fallback);
33
+ return {
34
+ scrollUp: (data) => matches(data, "tui.select.up", "up") || matchesKey(data, "k"),
35
+ scrollDown: (data) => matches(data, "tui.select.down", "down") || matchesKey(data, "j"),
36
+ pageUp: (data) => matches(data, "tui.select.pageUp", "pageUp") || matchesKey(data, "shift+up"),
37
+ pageDown: (data) => matches(data, "tui.select.pageDown", "pageDown") || matchesKey(data, "shift+down"),
38
+ };
39
+ }