@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.
@@ -7,6 +7,8 @@
7
7
  */
8
8
 
9
9
  import { randomUUID } from "node:crypto";
10
+ import { statSync } from "node:fs";
11
+ import { isAbsolute } from "node:path";
10
12
  import type { Model } from "@earendil-works/pi-ai";
11
13
  import type { AgentSession, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
14
  import { resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js";
@@ -22,6 +24,28 @@ export type CompactionInfo = { reason: "manual" | "threshold" | "overflow"; toke
22
24
  /** Default max concurrent background agents. */
23
25
  const DEFAULT_MAX_CONCURRENT = 4;
24
26
 
27
+ /**
28
+ * Validate a caller-supplied SpawnOptions.cwd. `undefined`/`null` mean "unset"
29
+ * (parent cwd). Anything else must be an absolute path to an existing
30
+ * directory — curated errors instead of TypeErrors from path/fs internals
31
+ * (RPC callers send arbitrary JSON: null, numbers, file paths).
32
+ */
33
+ function assertValidSpawnCwd(cwd: unknown): asserts cwd is string | undefined | null {
34
+ if (cwd == null) return;
35
+ if (typeof cwd !== "string" || !isAbsolute(cwd)) {
36
+ throw new Error(`SpawnOptions.cwd must be an absolute path: "${String(cwd)}"`);
37
+ }
38
+ let isDirectory = false;
39
+ try {
40
+ isDirectory = statSync(cwd).isDirectory();
41
+ } catch {
42
+ throw new Error(`SpawnOptions.cwd does not exist: "${cwd}"`);
43
+ }
44
+ if (!isDirectory) {
45
+ throw new Error(`SpawnOptions.cwd is not a directory: "${cwd}"`);
46
+ }
47
+ }
48
+
25
49
  interface SpawnArgs {
26
50
  pi: ExtensionAPI;
27
51
  ctx: ExtensionContext;
@@ -46,6 +70,15 @@ interface SpawnOptions {
46
70
  bypassQueue?: boolean;
47
71
  /** Isolation mode — "worktree" creates a temp git worktree for the agent. */
48
72
  isolation?: IsolationMode;
73
+ /**
74
+ * Working directory for the agent (absolute path). Default: parent session
75
+ * cwd. The agent's tools operate here, but .pi config (extensions, skills,
76
+ * settings, memory) still loads from the parent session's project — the
77
+ * target directory's `.pi` extensions never execute. With isolation:
78
+ * "worktree", the worktree is created FROM this directory and the result
79
+ * branch lands in that repo.
80
+ */
81
+ cwd?: string;
49
82
  /** Resolved invocation snapshot captured for UI display. */
50
83
  invocation?: AgentInvocation;
51
84
  /** Parent abort signal — when aborted, the subagent is also stopped. */
@@ -71,6 +104,9 @@ export class AgentManager {
71
104
  private onStart?: OnAgentStart;
72
105
  private onCompact?: OnAgentCompact;
73
106
  private maxConcurrent: number;
107
+ /** Base repos worktrees were created from — so dispose() can prune them all,
108
+ * not just the parent repo (caller-supplied cwd can target other repos). */
109
+ private worktreeRepos = new Set<string>();
74
110
 
75
111
  /** Queue of background agents waiting to start. */
76
112
  private queue: { id: string; args: SpawnArgs }[] = [];
@@ -114,6 +150,11 @@ export class AgentManager {
114
150
  prompt: string,
115
151
  options: SpawnOptions,
116
152
  ): string {
153
+ // Validate before the queue branch — a queued spawn should fail at the
154
+ // call, not minutes later at drain. Throw (not warn): programmatic callers
155
+ // can fix and retry; the RPC layer converts throws into error envelopes.
156
+ assertValidSpawnCwd(options.cwd);
157
+
117
158
  const id = randomUUID().slice(0, 17);
118
159
  const abortController = new AbortController();
119
160
  const record: AgentRecord = {
@@ -151,12 +192,21 @@ export class AgentManager {
151
192
 
152
193
  /** Actually start an agent (called immediately or from queue drain). */
153
194
  private startAgent(id: string, record: AgentRecord, { pi, ctx, type, prompt, options }: SpawnArgs) {
195
+ // Re-validate a caller-supplied cwd: queued spawns can start minutes after
196
+ // spawn()'s check, and the directory may be gone by then (TOCTOU). Same
197
+ // curated errors; drainQueue parks a throw on the record as an error.
198
+ assertValidSpawnCwd(options.cwd);
199
+ // Single resolution point for the caller-supplied cwd — the worktree base
200
+ // repo and both cleanup calls below MUST agree on this value forever.
201
+ const customCwd = options.cwd ?? undefined; // null (RPC "unset") → undefined
202
+ const baseCwd = customCwd ?? ctx.cwd;
203
+
154
204
  // Worktree isolation: try to create a temporary git worktree. Strict —
155
205
  // fail loud if not possible (no silent fallback to main tree). Done
156
206
  // BEFORE state mutation so a throw doesn't leave the record half-running.
157
207
  let worktreeCwd: string | undefined;
158
208
  if (options.isolation === "worktree") {
159
- const wt = createWorktree(ctx.cwd, id);
209
+ const wt = createWorktree(baseCwd, id);
160
210
  if (!wt) {
161
211
  throw new Error(
162
212
  'Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' +
@@ -164,7 +214,14 @@ export class AgentManager {
164
214
  );
165
215
  }
166
216
  record.worktree = wt;
167
- worktreeCwd = wt.path;
217
+ // workPath preserves subdirectory scoping for caller-supplied cwds: a
218
+ // cwd deep in a monorepo maps to the same subdir inside the copy, not
219
+ // the copied repo's root. Plain worktree spawns keep the historical
220
+ // behavior (agent at the copy's root) — moving them to workPath would
221
+ // also move .pi config discovery when the parent session sits in a repo
222
+ // subdirectory, silently dropping extensions/skills.
223
+ worktreeCwd = customCwd !== undefined ? wt.workPath : wt.path;
224
+ this.worktreeRepos.add(baseCwd);
168
225
  }
169
226
 
170
227
  record.status = "running";
@@ -189,7 +246,13 @@ export class AgentManager {
189
246
  isolated: options.isolated,
190
247
  inheritContext: options.inheritContext,
191
248
  thinkingLevel: options.thinkingLevel,
192
- cwd: worktreeCwd,
249
+ // Worktree wins for the working dir (the agent must run in the copy —
250
+ // which, with a custom cwd, was created from that target). Config stays
251
+ // with the parent project when a caller-supplied cwd is in play; it must
252
+ // stay undefined otherwise so plain worktree runs keep resolving config
253
+ // (incl. relative extension paths and memory) inside the worktree copy.
254
+ cwd: worktreeCwd ?? customCwd,
255
+ configCwd: customCwd !== undefined ? ctx.cwd : undefined,
193
256
  signal: record.abortController!.signal,
194
257
  onToolActivity: (activity) => {
195
258
  if (activity.type === "end") record.toolUses++;
@@ -237,11 +300,14 @@ export class AgentManager {
237
300
 
238
301
  // Clean up worktree if used
239
302
  if (record.worktree) {
240
- const wtResult = cleanupWorktree(ctx.cwd, record.worktree, options.description);
303
+ const wtResult = cleanupWorktree(baseCwd, record.worktree, options.description);
241
304
  record.worktreeResult = wtResult;
242
305
  if (wtResult.hasChanges && wtResult.branch) {
306
+ // With a caller-supplied cwd the branch lives in THAT repo, not the
307
+ // parent session's — say so, or the orchestrator merges in the wrong repo.
308
+ const repoNote = customCwd !== undefined ? ` in \`${baseCwd}\`` : "";
243
309
  record.result = (record.result ?? "") +
244
- `\n\n---\nChanges saved to branch \`${wtResult.branch}\`. Merge with: \`git merge ${wtResult.branch}\``;
310
+ `\n\n---\nChanges saved to branch \`${wtResult.branch}\`${repoNote}. Merge with: \`git merge ${wtResult.branch}\`${customCwd !== undefined ? ` (run in \`${baseCwd}\`)` : ""}`;
245
311
  }
246
312
  }
247
313
 
@@ -271,7 +337,7 @@ export class AgentManager {
271
337
  // Best-effort worktree cleanup on error
272
338
  if (record.worktree) {
273
339
  try {
274
- const wtResult = cleanupWorktree(ctx.cwd, record.worktree, options.description);
340
+ const wtResult = cleanupWorktree(baseCwd, record.worktree, options.description);
275
341
  record.worktreeResult = wtResult;
276
342
  } catch { /* ignore cleanup errors */ }
277
343
  }
@@ -479,5 +545,10 @@ export class AgentManager {
479
545
  this.agents.clear();
480
546
  // Prune any orphaned git worktrees (crash recovery)
481
547
  try { pruneWorktrees(process.cwd()); } catch { /* ignore */ }
548
+ // Also prune repos that caller-supplied cwds created worktrees in — a clean
549
+ // exit with in-flight agents would otherwise leave stale registrations there.
550
+ for (const repo of this.worktreeRepos) {
551
+ try { pruneWorktrees(repo); } catch { /* ignore */ }
552
+ }
482
553
  }
483
554
  }
@@ -206,6 +206,20 @@ export interface RunOptions {
206
206
  thinkingLevel?: ThinkingLevel;
207
207
  /** Override working directory (e.g. for worktree isolation). */
208
208
  cwd?: string;
209
+ /**
210
+ * Where .pi config is discovered (project extensions, skills, pi settings,
211
+ * agent memory). Default: same as the working directory. The manager sets
212
+ * this to the parent session's cwd when `SpawnOptions.cwd` points the
213
+ * working directory elsewhere — the agent works *there* but carries the
214
+ * parent project's config (the target's `.pi` extensions never execute).
215
+ *
216
+ * WARNING for future callers: if you pass `cwd` pointing at a directory the
217
+ * user didn't open, you almost certainly must pass `configCwd` too —
218
+ * omitting it makes the target's `.pi` extensions execute in this process.
219
+ * (Worktree isolation is the one intentional exception: its copy IS the
220
+ * parent's repo, so config resolving inside it is correct.)
221
+ */
222
+ configCwd?: string;
209
223
  /** Called on tool start/end with activity info. */
210
224
  onToolActivity?: (activity: ToolActivity) => void;
211
225
  /** Called on streaming text deltas from the assistant response. */
@@ -285,6 +299,9 @@ export async function runAgent(
285
299
 
286
300
  // Resolve working directory: worktree override > parent cwd
287
301
  const effectiveCwd = options.cwd ?? ctx.cwd;
302
+ // Filesystem work happens in effectiveCwd; config discovery in configCwd.
303
+ // They differ only for SpawnOptions.cwd spawns (config stays with the parent).
304
+ const configCwd = options.configCwd ?? effectiveCwd;
288
305
 
289
306
  const env = await detectEnv(options.pi, effectiveCwd);
290
307
 
@@ -296,11 +313,14 @@ export async function runAgent(
296
313
 
297
314
  // Resolve extensions/skills: isolated overrides to false
298
315
  const extensions = options.isolated ? false : config.extensions;
316
+ // Nulling excludes under isolated also suppresses the orphaned-exclude warning —
317
+ // isolation is an intentional override, not a misconfiguration.
318
+ const excludeExtensions = options.isolated ? undefined : config.excludeExtensions;
299
319
  const skills = options.isolated ? false : config.skills;
300
320
 
301
321
  // Skill preloading: when skills is string[], preload their content into prompt
302
322
  if (Array.isArray(skills)) {
303
- const loaded = preloadSkills(skills, effectiveCwd);
323
+ const loaded = preloadSkills(skills, configCwd);
304
324
  if (loaded.length > 0) {
305
325
  extras.skillBlocks = loaded;
306
326
  }
@@ -320,12 +340,12 @@ export async function runAgent(
320
340
  // Read-write memory: add any missing memory tool names (read/write/edit)
321
341
  const extraNames = getMemoryToolNames(existingNames);
322
342
  if (extraNames.length > 0) toolNames = [...toolNames, ...extraNames];
323
- extras.memoryBlock = buildMemoryBlock(agentConfig.name, agentConfig.memory, effectiveCwd);
343
+ extras.memoryBlock = buildMemoryBlock(agentConfig.name, agentConfig.memory, configCwd);
324
344
  } else {
325
345
  // Read-only memory: only add read tool name, use read-only prompt
326
346
  const extraNames = getReadOnlyMemoryToolNames(existingNames);
327
347
  if (extraNames.length > 0) toolNames = [...toolNames, ...extraNames];
328
- extras.memoryBlock = buildReadOnlyMemoryBlock(agentConfig.name, agentConfig.memory, effectiveCwd);
348
+ extras.memoryBlock = buildReadOnlyMemoryBlock(agentConfig.name, agentConfig.memory, configCwd);
329
349
  }
330
350
  }
331
351
 
@@ -370,24 +390,41 @@ export async function runAgent(
370
390
  const noExtensions = extensions === false;
371
391
 
372
392
  const extensionsSpec = Array.isArray(extensions)
373
- ? parseExtensionsSpec(extensions, effectiveCwd)
393
+ ? parseExtensionsSpec(extensions, configCwd)
374
394
  : undefined;
375
395
  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`).
396
+ // `exclude_extensions:` is a denylist applied AFTER the include set exclude wins.
397
+ // Plain canonical names only (case-insensitive). Note: excluded extensions'
398
+ // factories still run once during reload() (see comment above) — exclusion
399
+ // suppresses handler binding and tool registration; it is not a sandbox.
400
+ const excludeNames = new Set((excludeExtensions ?? []).map((n) => n.toLowerCase()));
401
+ const hasExcludes = excludeNames.size > 0;
402
+ // The override filters loaded extensions down to `keepNames` minus `excludeNames`.
403
+ // It's only needed when we're neither loading everything without excludes
404
+ // (`extensions: true` or a `"*"` wildcard) nor nothing (`noExtensions`).
379
405
  const loadAll = extensions === true || extensionsSpec?.wildcard === true;
380
406
  const additionalExtensionPaths = extensionsSpec?.paths.length ? extensionsSpec.paths : undefined;
407
+ // Pre-filter discovered set, captured by the override — the exclude-typo warning
408
+ // must compare against this, not the surviving set (absence from survivors is
409
+ // an exclude *succeeding*).
410
+ let discoveredNames: Set<string> | undefined;
381
411
  const extensionsOverride: ((base: LoadExtensionsResult) => LoadExtensionsResult) | undefined =
382
- loadAll || noExtensions
412
+ noExtensions || (loadAll && !hasExcludes)
383
413
  ? undefined
384
- : (base) => ({
385
- ...base,
386
- extensions: base.extensions.filter((e) => keepNames.has(extensionCanonicalName(e.path))),
387
- });
414
+ : (base) => {
415
+ discoveredNames = new Set(base.extensions.map((e) => extensionCanonicalName(e.path)));
416
+ return {
417
+ ...base,
418
+ extensions: base.extensions.filter((e) => {
419
+ const name = extensionCanonicalName(e.path);
420
+ if (excludeNames.has(name)) return false; // exclude wins
421
+ return loadAll || keepNames.has(name);
422
+ }),
423
+ };
424
+ };
388
425
 
389
426
  const loader = new DefaultResourceLoader({
390
- cwd: effectiveCwd,
427
+ cwd: configCwd,
391
428
  agentDir,
392
429
  noExtensions,
393
430
  additionalExtensionPaths,
@@ -425,6 +462,27 @@ export async function runAgent(
425
462
  // - `tools: ext:foo` but foo isn't in the loaded set (because `extensions:`
426
463
  // didn't include it). Since v0.9, `ext:` no longer pulls extensions in;
427
464
  // loading is `extensions:`-authoritative.
465
+ // An exclude_extensions: alongside extensions: false is contradictory — nothing
466
+ // loads, so there is nothing to exclude.
467
+ if (hasExcludes && noExtensions) {
468
+ options.onToolActivity?.({
469
+ type: "end",
470
+ toolName: `extension-error:exclude_extensions has no effect for agent "${type}" — extensions: false loads nothing`,
471
+ });
472
+ }
473
+ // Exclude typo check: compares against the PRE-filter discovered set (an excluded
474
+ // name absent from the surviving set is the exclude working as intended). Also
475
+ // flags path-like and "*" entries — excludes are plain names only.
476
+ if (hasExcludes && discoveredNames) {
477
+ for (const name of excludeNames) {
478
+ if (!discoveredNames.has(name)) {
479
+ options.onToolActivity?.({
480
+ type: "end",
481
+ toolName: `extension-error:exclude_extensions: "${name}" for agent "${type}" did not match any discovered extension`,
482
+ });
483
+ }
484
+ }
485
+ }
428
486
  if (keepNames.size > 0 || extNames.size > 0) {
429
487
  const survivingNames = new Set(
430
488
  loader.getExtensions().extensions.map((e) => extensionCanonicalName(e.path)),
@@ -433,7 +491,9 @@ export async function runAgent(
433
491
  if (!survivingNames.has(name)) {
434
492
  options.onToolActivity?.({
435
493
  type: "end",
436
- toolName: `extension-error:extension "${name}" requested by agent "${type}" was not loaded`,
494
+ toolName: excludeNames.has(name)
495
+ ? `extension-error:extension "${name}" is in both extensions: and exclude_extensions: for agent "${type}" — exclude wins`
496
+ : `extension-error:extension "${name}" requested by agent "${type}" was not loaded`,
437
497
  });
438
498
  }
439
499
  }
@@ -441,7 +501,7 @@ export async function runAgent(
441
501
  if (!survivingNames.has(name)) {
442
502
  options.onToolActivity?.({
443
503
  type: "end",
444
- toolName: `extension-error:ext:${name} referenced by agent "${type}" but extension "${name}" is not loaded (add it to extensions:)`,
504
+ toolName: `extension-error:ext:${name} referenced by agent "${type}" but extension "${name}" is not loaded (check extensions:/exclude_extensions:)`,
445
505
  });
446
506
  }
447
507
  }
@@ -499,7 +559,7 @@ export async function runAgent(
499
559
  cwd: effectiveCwd,
500
560
  agentDir,
501
561
  sessionManager: SessionManager.inMemory(effectiveCwd),
502
- settingsManager: SettingsManager.create(effectiveCwd, agentDir),
562
+ settingsManager: SettingsManager.create(configCwd, agentDir),
503
563
  modelRegistry: ctx.modelRegistry,
504
564
  model,
505
565
  tools: allowedTools,
@@ -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;
@@ -80,7 +83,7 @@ export interface AgentRecord {
80
83
  /** Steering messages queued before the session was ready. */
81
84
  pendingSteers?: string[];
82
85
  /** Worktree info if the agent is running in an isolated worktree. */
83
- worktree?: { path: string; branch: string; baseSha: string };
86
+ worktree?: { path: string; branch: string; baseSha: string; workPath: string };
84
87
  /** Worktree cleanup result after agent completion. */
85
88
  worktreeResult?: { hasChanges: boolean; branch?: string };
86
89
  /** The tool_use_id from the original Agent tool call. */