@tintinweb/pi-subagents 0.9.1 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/worktree.js CHANGED
@@ -16,9 +16,12 @@ import { join } from "node:path";
16
16
  */
17
17
  export function createWorktree(cwd, agentId) {
18
18
  // Verify we're in a git repo with at least one commit (HEAD must exist)
19
+ let baseSha;
19
20
  try {
20
21
  execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd, stdio: "pipe", timeout: 5000 });
21
- execFileSync("git", ["rev-parse", "HEAD"], { cwd, stdio: "pipe", timeout: 5000 });
22
+ baseSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd, stdio: "pipe", timeout: 5000 })
23
+ .toString()
24
+ .trim();
22
25
  }
23
26
  catch {
24
27
  return undefined;
@@ -33,7 +36,7 @@ export function createWorktree(cwd, agentId) {
33
36
  stdio: "pipe",
34
37
  timeout: 30000,
35
38
  });
36
- return { path: worktreePath, branch };
39
+ return { path: worktreePath, branch, baseSha };
37
40
  }
38
41
  catch {
39
42
  // If worktree creation fails, return undefined (agent runs in normal cwd)
@@ -56,21 +59,30 @@ export function cleanupWorktree(cwd, worktree, agentDescription) {
56
59
  stdio: "pipe",
57
60
  timeout: 10000,
58
61
  }).toString().trim();
59
- if (!status) {
60
- // No changesremove worktree
61
- removeWorktree(cwd, worktree.path);
62
- return { hasChanges: false };
62
+ if (status) {
63
+ // Changes existstage, commit, and create a branch
64
+ execFileSync("git", ["add", "-A"], { cwd: worktree.path, stdio: "pipe", timeout: 10000 });
65
+ // Truncate description for commit message (no shell sanitization needed — execFileSync uses argv)
66
+ const safeDesc = agentDescription.slice(0, 200);
67
+ const commitMsg = `pi-agent: ${safeDesc}`;
68
+ execFileSync("git", ["commit", "--no-verify", "-m", commitMsg], {
69
+ cwd: worktree.path,
70
+ stdio: "pipe",
71
+ timeout: 10000,
72
+ });
73
+ }
74
+ else {
75
+ const currentSha = execFileSync("git", ["rev-parse", "HEAD"], {
76
+ cwd: worktree.path,
77
+ stdio: "pipe",
78
+ timeout: 5000,
79
+ }).toString().trim();
80
+ if (currentSha === worktree.baseSha) {
81
+ // No changes — remove worktree
82
+ removeWorktree(cwd, worktree.path);
83
+ return { hasChanges: false };
84
+ }
63
85
  }
64
- // Changes exist — stage, commit, and create a branch
65
- execFileSync("git", ["add", "-A"], { cwd: worktree.path, stdio: "pipe", timeout: 10000 });
66
- // Truncate description for commit message (no shell sanitization needed — execFileSync uses argv)
67
- const safeDesc = agentDescription.slice(0, 200);
68
- const commitMsg = `pi-agent: ${safeDesc}`;
69
- execFileSync("git", ["commit", "-m", commitMsg], {
70
- cwd: worktree.path,
71
- stdio: "pipe",
72
- timeout: 10000,
73
- });
74
86
  // Create a branch pointing to the worktree's HEAD.
75
87
  // If the branch already exists, append a suffix to avoid overwriting previous work.
76
88
  let branchName = worktree.branch;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tintinweb/pi-subagents",
3
- "version": "0.9.1",
3
+ "version": "0.10.1",
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",
@@ -35,6 +35,7 @@
35
35
  "prepublishOnly": "npm run lint && npm run typecheck && npm run test && npm run build",
36
36
  "test": "vitest run",
37
37
  "test:watch": "vitest",
38
+ "test:e2e": "vitest run e2e --reporter=verbose",
38
39
  "typecheck": "tsc --noEmit",
39
40
  "lint": "biome check src/ test/",
40
41
  "lint:fix": "biome check --fix src/ test/"
@@ -2,8 +2,10 @@
2
2
  * agent-runner.ts — Core execution engine: creates sessions, runs agents, collects results.
3
3
  */
4
4
 
5
+ import { homedir } from "node:os";
6
+ import { basename, dirname, isAbsolute, resolve } from "node:path";
5
7
  import type { Model } from "@earendil-works/pi-ai";
6
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
8
+ import type { ExtensionContext, LoadExtensionsResult } from "@earendil-works/pi-coding-agent";
7
9
  import {
8
10
  type AgentSession,
9
11
  type AgentSessionEvent,
@@ -14,7 +16,7 @@ import {
14
16
  SessionManager,
15
17
  SettingsManager,
16
18
  } from "@earendil-works/pi-coding-agent";
17
- import { getAgentConfig, getConfig, getMemoryToolNames, getReadOnlyMemoryToolNames, getToolNamesForType } from "./agent-types.js";
19
+ import { BUILTIN_TOOL_NAMES, getAgentConfig, getConfig, getMemoryToolNames, getReadOnlyMemoryToolNames, getToolNamesForType } from "./agent-types.js";
18
20
  import { buildParentContext, extractText } from "./context.js";
19
21
  import { DEFAULT_AGENTS } from "./default-agents.js";
20
22
  import { detectEnv } from "./env.js";
@@ -23,8 +25,114 @@ import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
23
25
  import { preloadSkills } from "./skill-loader.js";
24
26
  import type { SubagentType, ThinkingLevel } from "./types.js";
25
27
 
28
+ /**
29
+ * Tool names registered by THIS extension. Single source of truth so the
30
+ * registration sites (index.ts) and the subagent exclusion list below can't
31
+ * drift apart. These are our own tools, not pi built-ins, so they can't be
32
+ * derived from pi — but they only need defining once.
33
+ */
34
+ export const SUBAGENT_TOOL_NAMES = {
35
+ AGENT: "Agent",
36
+ GET_RESULT: "get_subagent_result",
37
+ STEER: "steer_subagent",
38
+ } as const;
39
+
26
40
  /** Names of tools registered by this extension that subagents must NOT inherit. */
27
- const EXCLUDED_TOOL_NAMES = ["Agent", "get_subagent_result", "steer_subagent"];
41
+ const EXCLUDED_TOOL_NAMES: string[] = Object.values(SUBAGENT_TOOL_NAMES);
42
+
43
+ /**
44
+ * Canonical name of an extension for `extensions: [...]` allowlist matching.
45
+ * Lowercased — extension names match case-insensitively so `extensions: [Mcp]`
46
+ * resolves the same as `[mcp]`. Tool names within `ext:foo/bar` are not affected.
47
+ * Directory extensions (`foo/index.ts`) resolve to the parent directory name;
48
+ * single-file extensions to the basename minus `.ts`/`.js`.
49
+ */
50
+ export function extensionCanonicalName(extPath: string): string {
51
+ const base = basename(extPath);
52
+ const name = base === "index.ts" || base === "index.js"
53
+ ? basename(dirname(extPath))
54
+ : base.replace(/\.(ts|js)$/, "");
55
+ return name.toLowerCase();
56
+ }
57
+
58
+ /**
59
+ * Classify `extensions: string[]` frontmatter entries for the loader-level filter.
60
+ *
61
+ * An entry is a PATH iff it contains a path separator or starts with `~`; otherwise
62
+ * it is a NAME. `"*"` sets the wildcard flag (keep all default-discovered extensions).
63
+ *
64
+ * Path entries are resolved (`~` expanded, made absolute against `cwd`) into `paths`
65
+ * — and their canonical name is also added to `names`. The loader override matches
66
+ * everything by canonical name, so path-loaded extensions are matched via their name
67
+ * rather than their post-staging `Extension.path`.
68
+ */
69
+ export function parseExtensionsSpec(
70
+ entries: string[],
71
+ cwd: string,
72
+ ): { names: Set<string>; paths: string[]; wildcard: boolean } {
73
+ const names = new Set<string>();
74
+ const paths: string[] = [];
75
+ let wildcard = false;
76
+ for (const entry of entries) {
77
+ if (!entry) continue;
78
+ if (entry === "*") {
79
+ wildcard = true;
80
+ continue;
81
+ }
82
+ const isPathEntry = entry.includes("/") || entry.includes("\\") || entry.startsWith("~");
83
+ if (!isPathEntry) {
84
+ names.add(entry.toLowerCase());
85
+ continue;
86
+ }
87
+ let p = entry;
88
+ if (p === "~" || p.startsWith("~/") || p.startsWith("~\\")) {
89
+ p = homedir() + p.slice(1);
90
+ }
91
+ const abs = isAbsolute(p) ? p : resolve(cwd, p);
92
+ paths.push(abs);
93
+ names.add(extensionCanonicalName(abs));
94
+ }
95
+ return { names, paths, wildcard };
96
+ }
97
+
98
+ /**
99
+ * Parse raw `ext:` selector strings (from the `tools:` CSV) into the set of
100
+ * extension names to keep loaded and a per-extension tool-narrowing map.
101
+ *
102
+ * `ext:foo` → `extNames` has `foo`, no narrowing entry (all of foo's tools).
103
+ * `ext:foo/bar` → `extNames` has `foo`, `narrowing.foo` has `bar` (only `bar`).
104
+ * A name lands in `narrowing` only when a `/tool` form is seen, so a bare
105
+ * `ext:foo` alongside `ext:foo/bar` leaves narrowing in effect (narrowing wins).
106
+ * The split is on the first `/`; extension canonical names never contain `/`.
107
+ */
108
+ export function parseExtSelectors(entries: string[]): {
109
+ extNames: Set<string>;
110
+ narrowing: Map<string, Set<string>>;
111
+ } {
112
+ const extNames = new Set<string>();
113
+ const narrowing = new Map<string, Set<string>>();
114
+ for (const raw of entries) {
115
+ if (!raw) continue;
116
+ const body = raw.slice("ext:".length);
117
+ const slash = body.indexOf("/");
118
+ // Extension name matches case-insensitively (matches the loader-side canonical
119
+ // name). Tool names are case-preserved — they're matched against pi-mono's
120
+ // registered identifiers, which are case-sensitive.
121
+ const name = (slash === -1 ? body : body.slice(0, slash)).trim().toLowerCase();
122
+ if (!name) continue;
123
+ extNames.add(name);
124
+ if (slash === -1) continue;
125
+ const tool = body.slice(slash + 1).trim();
126
+ if (!tool) continue;
127
+ let set = narrowing.get(name);
128
+ if (!set) {
129
+ set = new Set();
130
+ narrowing.set(name, set);
131
+ }
132
+ set.add(tool);
133
+ }
134
+ return { extNames, narrowing };
135
+ }
28
136
 
29
137
  /** Default max turns. undefined = unlimited (no turn limit). */
30
138
  let defaultMaxTurns: number | undefined;
@@ -239,16 +347,51 @@ export async function runAgent(
239
347
 
240
348
  const agentDir = getAgentDir();
241
349
 
242
- // Load extensions/skills: true or string[] → load; false → don't.
350
+ // Extension loading:
351
+ // - true → all default-discovered extensions
352
+ // - false → none (noExtensions)
353
+ // - string[] → loader-level allowlist. Bare names keep the matching
354
+ // default-discovered extension; path entries load that extension fresh;
355
+ // "*" keeps all default-discovered extensions. Excluded extensions never
356
+ // bind handlers or register tools (their factory still runs once).
357
+ //
243
358
  // Suppress AGENTS.md/CLAUDE.md and APPEND_SYSTEM.md — upstream's
244
359
  // buildSystemPrompt() re-appends both AFTER systemPromptOverride, which
245
360
  // would defeat prompt_mode: replace and isolated: true. Parent context, if
246
361
  // wanted, reaches the subagent via prompt_mode: append (parentSystemPrompt
247
362
  // is embedded in systemPromptOverride) or inherit_context (conversation).
363
+ // `ext:` selectors from the `tools:` CSV narrow which extension tools surface to
364
+ // the LLM. They do NOT control loading — `extensions:` is the sole authority for
365
+ // which extensions load. `ext:foo` against an extension that `extensions:` excluded
366
+ // is an orphan and warns after reload. `isolated` means no extension tools at all.
367
+ const { extNames, narrowing } = parseExtSelectors(
368
+ options.isolated ? [] : (agentConfig?.extSelectors ?? []),
369
+ );
370
+ const noExtensions = extensions === false;
371
+
372
+ const extensionsSpec = Array.isArray(extensions)
373
+ ? parseExtensionsSpec(extensions, effectiveCwd)
374
+ : undefined;
375
+ 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
+ const loadAll = extensions === true || extensionsSpec?.wildcard === true;
380
+ const additionalExtensionPaths = extensionsSpec?.paths.length ? extensionsSpec.paths : undefined;
381
+ const extensionsOverride: ((base: LoadExtensionsResult) => LoadExtensionsResult) | undefined =
382
+ loadAll || noExtensions
383
+ ? undefined
384
+ : (base) => ({
385
+ ...base,
386
+ extensions: base.extensions.filter((e) => keepNames.has(extensionCanonicalName(e.path))),
387
+ });
388
+
248
389
  const loader = new DefaultResourceLoader({
249
390
  cwd: effectiveCwd,
250
391
  agentDir,
251
- noExtensions: extensions === false,
392
+ noExtensions,
393
+ additionalExtensionPaths,
394
+ extensionsOverride,
252
395
  noSkills,
253
396
  noPromptTemplates: true,
254
397
  noThemes: true,
@@ -258,6 +401,52 @@ export async function runAgent(
258
401
  });
259
402
  await loader.reload();
260
403
 
404
+ // Plain entries in `tools:` are expected to be built-in names (extension tools
405
+ // go through `ext:`), so an unknown name there is unambiguously a typo. Previously
406
+ // this produced a silently broken agent (#75) — pi-mono accepted the bogus name
407
+ // into the allowlist, then dropped it at registration with no signal back.
408
+ if (agentConfig?.builtinToolNames?.length) {
409
+ const knownBuiltins = new Set(BUILTIN_TOOL_NAMES);
410
+ for (const name of agentConfig.builtinToolNames) {
411
+ if (!knownBuiltins.has(name)) {
412
+ options.onToolActivity?.({
413
+ type: "end",
414
+ toolName: `tools-error:tool "${name}" requested by agent "${type}" is not a known built-in`,
415
+ });
416
+ }
417
+ }
418
+ }
419
+
420
+ // A subagent spawns mid-task, so a bad `extensions:`/`ext:` entry warns rather
421
+ // than aborts. Two distinct misconfigurations to catch:
422
+ // - `extensions: [foo]` but no extension named foo was discovered (typo or
423
+ // path that failed to load — path entries fold their canonical name into
424
+ // `keepNames`, so this covers them too).
425
+ // - `tools: ext:foo` but foo isn't in the loaded set (because `extensions:`
426
+ // didn't include it). Since v0.9, `ext:` no longer pulls extensions in;
427
+ // loading is `extensions:`-authoritative.
428
+ if (keepNames.size > 0 || extNames.size > 0) {
429
+ const survivingNames = new Set(
430
+ loader.getExtensions().extensions.map((e) => extensionCanonicalName(e.path)),
431
+ );
432
+ for (const name of keepNames) {
433
+ if (!survivingNames.has(name)) {
434
+ options.onToolActivity?.({
435
+ type: "end",
436
+ toolName: `extension-error:extension "${name}" requested by agent "${type}" was not loaded`,
437
+ });
438
+ }
439
+ }
440
+ for (const name of extNames) {
441
+ if (!survivingNames.has(name)) {
442
+ options.onToolActivity?.({
443
+ type: "end",
444
+ toolName: `extension-error:ext:${name} referenced by agent "${type}" but extension "${name}" is not loaded (add it to extensions:)`,
445
+ });
446
+ }
447
+ }
448
+ }
449
+
261
450
  // Resolve model: explicit option > config.model > parent model
262
451
  const model = options.model ?? resolveDefaultModel(
263
452
  ctx.model, ctx.modelRegistry, agentConfig?.model,
@@ -266,6 +455,46 @@ export async function runAgent(
266
455
  // Resolve thinking level: explicit option > agent config > undefined (inherit)
267
456
  const thinkingLevel = options.thinkingLevel ?? agentConfig?.thinking;
268
457
 
458
+ const disallowedSet = agentConfig?.disallowedTools
459
+ ? new Set(agentConfig.disallowedTools)
460
+ : undefined;
461
+
462
+ // Enumerate extension-registered tool names from the loaded resource loader.
463
+ // Extensions populate `extension.tools` during `loader.reload()` and the set
464
+ // is stable afterwards — `bindExtensions` does not register new tools.
465
+ //
466
+ // Opt-in flip: when any `ext:` selector is present, extension tools become an
467
+ // explicit allowlist — a loaded extension not named by a selector contributes
468
+ // no tools (its handlers still ran), and `ext:foo/bar` narrows `foo` to `bar`.
469
+ const extensionToolNames: string[] = [];
470
+ if (!noExtensions) {
471
+ const optInActive = extNames.size > 0;
472
+ for (const extension of loader.getExtensions().extensions) {
473
+ const canon = extensionCanonicalName(extension.path);
474
+ if (optInActive && !extNames.has(canon)) continue;
475
+ const narrowed = narrowing.get(canon);
476
+ for (const toolName of extension.tools.keys()) {
477
+ if (narrowed && !narrowed.has(toolName)) continue;
478
+ extensionToolNames.push(toolName);
479
+ }
480
+ }
481
+ }
482
+
483
+ // Build the master tool allowlist applied at session construction.
484
+ // pi-mono's `allowedToolNames` gates BOTH registration and the initial active
485
+ // set, so listing the exact final set here means the session is correctly
486
+ // scoped from the first instant — no post-construction narrowing required.
487
+ const builtinToolNameSet = new Set(toolNames);
488
+ const allowedTools = [...toolNames, ...extensionToolNames].filter((t) => {
489
+ if (EXCLUDED_TOOL_NAMES.includes(t)) return false;
490
+ if (disallowedSet?.has(t)) return false;
491
+ if (builtinToolNameSet.has(t)) return true;
492
+ // Reached only for extension tools. The extension set was already filtered
493
+ // at the loader (extensionsOverride / noExtensions) and at enumeration
494
+ // (`ext:` opt-in flip), so any extension tool in `extensionToolNames` is allowed.
495
+ return !noExtensions;
496
+ });
497
+
269
498
  const sessionOpts: Parameters<typeof createAgentSession>[0] = {
270
499
  cwd: effectiveCwd,
271
500
  agentDir,
@@ -273,7 +502,7 @@ export async function runAgent(
273
502
  settingsManager: SettingsManager.create(effectiveCwd, agentDir),
274
503
  modelRegistry: ctx.modelRegistry,
275
504
  model,
276
- tools: toolNames,
505
+ tools: allowedTools,
277
506
  resourceLoader: loader,
278
507
  };
279
508
  if (thinkingLevel) {
@@ -287,35 +516,10 @@ export async function runAgent(
287
516
  options.agentId ? `${baseSessionName}#${options.agentId.slice(0, 8)}` : baseSessionName,
288
517
  );
289
518
 
290
- // Build disallowed tools set from agent config
291
- const disallowedSet = agentConfig?.disallowedTools
292
- ? new Set(agentConfig.disallowedTools)
293
- : undefined;
294
-
295
- // Filter active tools: remove our own tools to prevent nesting,
296
- // apply extension allowlist if specified, and apply disallowedTools denylist
297
- if (extensions !== false) {
298
- const builtinToolNameSet = new Set(toolNames);
299
- const activeTools = session.getActiveToolNames().filter((t) => {
300
- if (EXCLUDED_TOOL_NAMES.includes(t)) return false;
301
- if (disallowedSet?.has(t)) return false;
302
- if (builtinToolNameSet.has(t)) return true;
303
- if (Array.isArray(extensions)) {
304
- return extensions.some(ext => t.startsWith(ext) || t.includes(ext));
305
- }
306
- return true;
307
- });
308
- session.setActiveToolsByName(activeTools);
309
- } else if (disallowedSet) {
310
- // Even with extensions disabled, apply denylist to built-in tools
311
- const activeTools = session.getActiveToolNames().filter(t => !disallowedSet.has(t));
312
- session.setActiveToolsByName(activeTools);
313
- }
314
-
315
519
  // Bind extensions so that session_start fires and extensions can initialize
316
- // (e.g. loading credentials, setting up state). Placed after tool filtering
317
- // so extension-provided skills/prompts from extendResourcesFromExtensions()
318
- // respect the active tool set. All ExtensionBindings fields are optional.
520
+ // (e.g. loading credentials, setting up state). Tool gating already happened
521
+ // at session construction via the `tools:` allowlist above — no separate
522
+ // post-bind filter is needed. All ExtensionBindings fields are optional.
319
523
  await session.bindExtensions({
320
524
  onError: (err) => {
321
525
  options.onToolActivity?.({
@@ -5,15 +5,34 @@
5
5
  * User agents override defaults with the same name. Disabled agents are kept but excluded from spawning.
6
6
  */
7
7
 
8
+ import { createCodingTools, createReadOnlyTools } from "@earendil-works/pi-coding-agent";
8
9
  import { DEFAULT_AGENTS } from "./default-agents.js";
9
10
  import type { AgentConfig } from "./types.js";
10
11
 
11
- /** All known built-in tool names. */
12
- export const BUILTIN_TOOL_NAMES: string[] = ["read", "bash", "edit", "write", "grep", "find", "ls"];
12
+ /**
13
+ * All known built-in tool names, derived from pi's own tool factories rather
14
+ * than hardcoded so the set tracks pi-mono if it adds/renames a built-in.
15
+ * `createCodingTools` → read/bash/edit/write; `createReadOnlyTools` →
16
+ * read/grep/find/ls; their de-duplicated union is the 7 built-ins
17
+ * (read, bash, edit, write, grep, find, ls). The `cwd` only binds tool
18
+ * operations we never invoke here — we read each tool's `.name` and discard it.
19
+ */
20
+ export const BUILTIN_TOOL_NAMES: string[] = [
21
+ ...new Set([...createCodingTools("."), ...createReadOnlyTools(".")].map((t) => t.name)),
22
+ ];
13
23
 
14
24
  /** Unified runtime registry of all agents (defaults + user-defined). */
15
25
  const agents = new Map<string, AgentConfig>();
16
26
 
27
+ /** When true, DEFAULT_AGENTS are skipped during registration. */
28
+ let disableDefaults = false;
29
+
30
+ /** Check whether default agents are disabled. */
31
+ export function isDefaultsDisabled(): boolean { return disableDefaults; }
32
+
33
+ /** Set whether default agents are disabled. */
34
+ export function setDefaultsDisabled(b: boolean): void { disableDefaults = b; }
35
+
17
36
  /**
18
37
  * Register agents into the unified registry.
19
38
  * Starts with DEFAULT_AGENTS, then overlays user agents (overrides defaults with same name).
@@ -22,9 +41,11 @@ const agents = new Map<string, AgentConfig>();
22
41
  export function registerAgents(userAgents: Map<string, AgentConfig>): void {
23
42
  agents.clear();
24
43
 
25
- // Start with defaults
26
- for (const [name, config] of DEFAULT_AGENTS) {
27
- agents.set(name, config);
44
+ // Start with defaults (unless disabled via settings)
45
+ if (!disableDefaults) {
46
+ for (const [name, config] of DEFAULT_AGENTS) {
47
+ agents.set(name, config);
48
+ }
28
49
  }
29
50
 
30
51
  // Overlay user agents (overrides defaults with same name)
@@ -112,8 +133,9 @@ export function getToolNamesForType(type: string): string[] {
112
133
  const key = resolveKey(type);
113
134
  const raw = key ? agents.get(key) : undefined;
114
135
  const config = raw?.enabled !== false ? raw : undefined;
115
- const names = config?.builtinToolNames?.length ? config.builtinToolNames : [...BUILTIN_TOOL_NAMES];
116
- return names;
136
+ // `undefined` (definition omitted the field) all built-ins; an explicit `[]`
137
+ // (`tools: none` or a `tools:` with only `ext:` entries) → zero built-ins.
138
+ return config?.builtinToolNames ?? [...BUILTIN_TOOL_NAMES];
117
139
  }
118
140
 
119
141
  /** Get config for a type (case-insensitive, returns a SubagentTypeConfig-compatible object). Falls back to general-purpose. */
@@ -50,11 +50,14 @@ function loadFromDir(dir: string, agents: Map<string, AgentConfig>, source: "pro
50
50
 
51
51
  const { frontmatter: fm, body } = parseFrontmatter<Record<string, unknown>>(content);
52
52
 
53
+ const { builtinToolNames, extSelectors } = parseToolsField(fm.tools);
54
+
53
55
  agents.set(name, {
54
56
  name,
55
57
  displayName: str(fm.display_name),
56
58
  description: str(fm.description) ?? name,
57
- builtinToolNames: csvList(fm.tools, BUILTIN_TOOL_NAMES),
59
+ builtinToolNames,
60
+ extSelectors,
58
61
  disallowedTools: csvListOptional(fm.disallowed_tools),
59
62
  extensions: inheritField(fm.extensions ?? fm.inherit_extensions),
60
63
  skills: inheritField(fm.skills ?? fm.inherit_skills),
@@ -107,6 +110,25 @@ function csvList(val: unknown, defaults: string[]): string[] {
107
110
  return parseCsvField(val) ?? [];
108
111
  }
109
112
 
113
+ /**
114
+ * Partition the `tools:` CSV into the built-in tool allowlist and raw `ext:` selectors.
115
+ * `*` (and the case-insensitive alias `all`, for `tools: all`) expands to all
116
+ * built-ins; plain entries are built-in names; `ext:` entries are extension-tool
117
+ * selectors parsed later by the runner. omitted → all built-ins, no selectors.
118
+ * `tools:` present with only `ext:` entries → zero built-ins (use `*`).
119
+ */
120
+ function parseToolsField(val: unknown): { builtinToolNames: string[]; extSelectors: string[] | undefined } {
121
+ const entries = csvList(val, BUILTIN_TOOL_NAMES);
122
+ const isWildcard = (e: string) => e === "*" || e.toLowerCase() === "all";
123
+ const hasWildcard = entries.some(isWildcard);
124
+ const plain = entries.filter(e => !isWildcard(e) && !e.startsWith("ext:"));
125
+ const extEntries = entries.filter(e => e.startsWith("ext:"));
126
+ return {
127
+ builtinToolNames: hasWildcard ? [...new Set([...BUILTIN_TOOL_NAMES, ...plain])] : plain,
128
+ extSelectors: extEntries.length > 0 ? extEntries : undefined,
129
+ };
130
+ }
131
+
110
132
  /**
111
133
  * Parse an optional comma-separated list field.
112
134
  * omitted → undefined; "none"/empty → undefined; csv → listed items.