pi-subagents-lite 0.4.1 → 1.0.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.
@@ -1,16 +1,10 @@
1
1
  /**
2
- * agent-runner.ts — Core execution engine: creates sessions, runs agents, collects results.
2
+ * Core execution engine: creates sessions, runs agents, collects results.
3
3
  *
4
- * Forked from upstream pi-subagents. Key modifications:
5
- * - Removed buildParentContext import and inheritContext code path
6
- * - Removed buildMemoryBlock/buildReadOnlyMemoryBlock imports and memory code paths
7
- * - Replaced import { detectEnv } from env.ts with inline git detection via pi.exec()
8
- * - Handles `isolated` parameter internally (sets extensions=false, skills=false)
9
- * - RunOptions: keeps pi: ExtensionAPI, isolated?: boolean. Removes inheritContext, isolation
10
- * - PromptExtras: removed memoryBlock — keeps skillBlocks[] only
11
- * - EXCLUDED_TOOL_NAMES prevents sub-subagent spawning
4
+ * EXCLUDED_TOOL_NAMES prevents sub-subagent spawning.
12
5
  */
13
6
 
7
+ import path from "node:path";
14
8
  import type { Model } from "@earendil-works/pi-ai";
15
9
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
16
10
  import {
@@ -23,20 +17,20 @@ import {
23
17
  SessionManager,
24
18
  SettingsManager,
25
19
  } from "@earendil-works/pi-coding-agent";
26
- import { getAgentConfig, getConfig, getToolNamesForType } from "./agent-types.js";
20
+ import { getAgentConfig, getConfig, getToolNamesForType, BUILTIN_TOOL_NAMES } from "./agent-types.js";
27
21
  import { extractText } from "./context.js";
28
22
  import type { LifetimeUsage } from "./usage.js";
29
23
  import { findModelInRegistry } from "./utils.js";
30
24
  import { DEFAULT_AGENTS } from "./default-agents.js";
31
- import { buildAgentPrompt, type PromptExtras, type SkillMeta } from "./prompts.js";
32
- import { preloadSkills, loadSkillMeta } from "./skill-loader.js";
25
+ import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
26
+ import { preloadSkills, loadSkillMeta, type SkillMeta } from "./skill-loader.js";
33
27
  import { type CompactionInfo, type EnvInfo, SHORT_ID_LENGTH, type SubagentType, type ThinkingLevel } from "./types.js";
34
28
 
35
29
  /** Names of tools registered by this extension that subagents must NOT inherit. */
36
- export const EXCLUDED_TOOL_NAMES = ["Agent"];
30
+ const EXCLUDED_TOOL_NAMES = ["Agent"];
37
31
 
38
- /** Additional turns allowed after the soft limit steer message. */
39
- const GRACE_TURNS = 5;
32
+ /** Default grace turns when not specified in config. */
33
+ const DEFAULT_GRACE_TURNS = 6;
40
34
 
41
35
  /** Timeout for quick git commands (branch detection, repo check). */
42
36
  const GIT_EXEC_TIMEOUT_MS = 5000;
@@ -61,8 +55,6 @@ interface RunOptions {
61
55
  model?: Model<any>;
62
56
  maxTurns?: number;
63
57
  signal?: AbortSignal;
64
- /** When true, agent gets only built-in tools (no extensions, no skills). */
65
- isolated?: boolean;
66
58
  thinkingLevel?: ThinkingLevel;
67
59
  /** Override working directory. */
68
60
  cwd?: string;
@@ -84,6 +76,8 @@ interface RunOptions {
84
76
  * pre-compaction context size estimate. Aborted compactions don't fire.
85
77
  */
86
78
  onCompaction?: (info: CompactionInfo) => void;
79
+ /** Grace turns: extra turns allowed after hitting maxTurns. Defaults to 6. */
80
+ graceTurns?: number;
87
81
  }
88
82
 
89
83
  interface RunResult {
@@ -187,36 +181,166 @@ export function subscribeToSessionEvents(
187
181
  }
188
182
 
189
183
  /**
190
- * Filter active tools: remove extension tools to prevent nesting,
191
- * apply extension allowlist if specified, and apply disallowedTools denylist.
192
- * Returns null when no filtering is needed (isolated mode with no denylist).
184
+ * Extract the extension name from an extension's file path.
185
+ *
186
+ * Handles all distribution methods:
187
+ * - git packages: `.../git/github.com/<user>/<pkg>/...` → "<pkg>"
188
+ * - npm packages: `.../node_modules/[...]pkg/...` → "pkg"
189
+ * - local extensions: `~/.pi/agent/extensions/<name>/...` → "<name>"
190
+ * - direct files: `extensions/<name>.ts` → "<name>"
191
+ *
192
+ * Does NOT depend on internal directory structure (dist/, lib/, src/, etc).
193
+ * Only cares about the package root, which is determined by distribution method.
194
+ */
195
+ function extractExtensionName(extPath: string): string {
196
+ const parts = extPath.split(path.sep);
197
+
198
+ // 1. Git package: .../git/github.com/<user>/<pkg>/...
199
+ // Package name is 3 dirs after 'git' (github.com/user/pkg)
200
+ const gitIdx = parts.indexOf("git");
201
+ if (gitIdx !== -1 && gitIdx + 3 < parts.length) {
202
+ return parts[gitIdx + 3];
203
+ }
204
+
205
+ // 2. npm package: .../node_modules/[...]pkg/...
206
+ const nmIdx = parts.lastIndexOf("node_modules");
207
+ if (nmIdx !== -1 && nmIdx + 1 < parts.length) {
208
+ const next = parts[nmIdx + 1];
209
+ if (next.startsWith("@") && nmIdx + 2 < parts.length) {
210
+ return parts[nmIdx + 2]; // @scope/pkg → pkg
211
+ }
212
+ return next;
213
+ }
214
+
215
+ // 3. Local extension: .../extensions/<name>/... or .../extensions/<name>.ts
216
+ const extIdx = parts.lastIndexOf("extensions");
217
+ if (extIdx !== -1 && extIdx + 1 < parts.length) {
218
+ const afterExt = parts[extIdx + 1];
219
+ // Subdirectory: extensions/tavily/index.ts → tavily
220
+ if (afterExt && !afterExt.includes(".")) {
221
+ return afterExt;
222
+ }
223
+ // Direct file: extensions/review.ts → review
224
+ const file = parts[parts.length - 1];
225
+ return path.basename(file, path.extname(file));
226
+ }
227
+
228
+ // Fallback: parent dir name
229
+ return path.basename(path.dirname(extPath));
230
+ }
231
+
232
+ /**
233
+ * Resolve tool entries (with ext/* syntax) into concrete tool names.
234
+ * Returns a set of resolved tool names.
235
+ */
236
+ function resolveToolEntries(
237
+ entries: string[],
238
+ extToolMap: Map<string, string[]> | undefined,
239
+ notify?: (msg: string) => void,
240
+ ): Set<string> {
241
+ const resolved = new Set<string>();
242
+
243
+ for (const entry of entries) {
244
+ const slashIdx = entry.indexOf("/");
245
+ if (slashIdx !== -1) {
246
+ // ext/* or ext/tool syntax
247
+ const extName = entry.slice(0, slashIdx);
248
+ const toolPart = entry.slice(slashIdx + 1);
249
+ if (toolPart === "*") {
250
+ const extTools = extToolMap?.get(extName);
251
+ if (extTools && extTools.length > 0) {
252
+ for (const t of extTools) resolved.add(t);
253
+ } else {
254
+ notify?.(`extension "${extName}" is not loaded, "${entry}" will have no effect`);
255
+ }
256
+ } else {
257
+ // ext/tool syntax: e.g. "tavily/web_search"
258
+ resolved.add(toolPart);
259
+ }
260
+ } else {
261
+ // Bare tool name
262
+ resolved.add(entry);
263
+ }
264
+ }
265
+
266
+ return resolved;
267
+ }
268
+
269
+ /**
270
+ * Filter active tools: apply tools allowlist/denylist and EXCLUDED_TOOL_NAMES.
271
+ *
272
+ * The `tools` config controls which tool schemas the LLM sees (built-in + extension).
273
+ * The `extensions` config controls which extensions are loaded (hooks + commands).
274
+ * `extensions` does NOT affect tool visibility — that's `tools`'s job.
275
+ *
276
+ * Supports ext/* syntax for both whitelist and blacklist modes.
277
+ *
278
+ * `tools` and `excludeTools` are mutually exclusive. If both set, `tools` wins.
279
+ *
280
+ * Returns null when no filtering is needed, otherwise the filtered tool list.
193
281
  */
194
282
  function filterActiveTools(
195
283
  activeTools: string[],
196
- builtinToolNames: string[],
197
- extensions: true | string[] | false,
198
- disallowedTools?: string[],
284
+ extToolMap: Map<string, string[]> | undefined,
285
+ tools: true | string[] | false | undefined,
286
+ excludeTools: string[] | undefined,
287
+ notify?: (msg: string) => void,
199
288
  ): string[] | null {
200
- const disallowedSet = disallowedTools ? new Set(disallowedTools) : undefined;
201
-
202
- if (extensions === false) {
203
- // Isolated mode only apply denylist to built-in tools
204
- if (!disallowedSet) return null;
205
- const filtered = activeTools.filter(t => !disallowedSet.has(t));
289
+ // Blacklist mode: excludeTools set and tools not set as whitelist
290
+ if (excludeTools && !Array.isArray(tools)) {
291
+ const excludeSet = resolveToolEntries(excludeTools, extToolMap, notify);
292
+ const filtered = activeTools.filter(t =>
293
+ !EXCLUDED_TOOL_NAMES.includes(t) && !excludeSet.has(t)
294
+ );
206
295
  return filtered.length !== activeTools.length ? filtered : null;
207
296
  }
208
297
 
209
- const builtinToolNameSet = new Set(builtinToolNames);
210
- const filtered = activeTools.filter((t) => {
211
- if (EXCLUDED_TOOL_NAMES.includes(t)) return false;
212
- if (disallowedSet?.has(t)) return false;
213
- if (builtinToolNameSet.has(t)) return true;
214
- if (Array.isArray(extensions)) {
215
- return extensions.some(ext => t.startsWith(ext) || t.includes(ext));
298
+ if (Array.isArray(tools)) {
299
+ // Whitelist mode: resolve entries with ext/* expansion
300
+ const allBuiltinSet = new Set(BUILTIN_TOOL_NAMES);
301
+ const allowedTools = resolveToolEntries(tools, extToolMap, notify);
302
+
303
+ // Warn about unknown entries
304
+ for (const entry of tools) {
305
+ const slashIdx = entry.indexOf("/");
306
+ if (slashIdx === -1 && !allBuiltinSet.has(entry)) {
307
+ // Bare name, not a known built-in — check if it's an extension tool
308
+ const toolExts = extToolMap ? [...extToolMap.entries()].filter(([, tools]) => tools.includes(entry)) : [];
309
+ if (toolExts.length === 0) {
310
+ notify?.(`tool "${entry}" not found in any loaded extension`);
311
+ }
312
+ }
216
313
  }
217
- return true;
218
- });
219
- return filtered.length !== activeTools.length ? filtered : null;
314
+
315
+ const visibleSet = new Set<string>();
316
+ for (const t of activeTools) {
317
+ if (EXCLUDED_TOOL_NAMES.includes(t)) continue;
318
+ if (allowedTools.has(t)) {
319
+ visibleSet.add(t);
320
+ }
321
+ }
322
+
323
+ // Warn if a loaded extension has none of its tools in `tools`
324
+ if (extToolMap) {
325
+ for (const [extName, extTools] of extToolMap) {
326
+ const hasAny = extTools.some(t => allowedTools.has(t));
327
+ if (!hasAny) {
328
+ notify?.(`extension "${extName}" is loaded but none of its tools are in tools: [${tools.join(", ")}]`);
329
+ }
330
+ }
331
+ }
332
+
333
+ return [...visibleSet];
334
+ }
335
+
336
+ if (tools === false) {
337
+ return [];
338
+ }
339
+
340
+ // tools: true or undefined — all tools visible (except excluded)
341
+ const hasExcluded = activeTools.some(t => EXCLUDED_TOOL_NAMES.includes(t));
342
+ if (!hasExcluded) return null;
343
+ return activeTools.filter(t => !EXCLUDED_TOOL_NAMES.includes(t));
220
344
  }
221
345
 
222
346
  /** Run a git command via pi.exec, returning stdout on success or null on failure. */
@@ -254,21 +378,32 @@ export async function runAgent(
254
378
  const config = getConfig(type);
255
379
  const agentConfig = getAgentConfig(type);
256
380
 
381
+ // Warn on mutual exclusion violations
382
+ const notify = (msg: string) => {
383
+ if (ctx.ui?.notify) {
384
+ ctx.ui.notify(`[pi-subagents] ${msg}`, "warning");
385
+ } else {
386
+ console.warn(`[pi-subagents] ${msg}`);
387
+ }
388
+ };
389
+ if (agentConfig?.excludeTools && Array.isArray(agentConfig.tools)) {
390
+ notify(`agent "${type}": both tools and exclude_tools set — tools (whitelist) wins`);
391
+ }
392
+ if (agentConfig?.excludeExtensions && Array.isArray(agentConfig.extensions)) {
393
+ notify(`agent "${type}": both extensions and exclude_extensions set — extensions (whitelist) wins`);
394
+ }
395
+
257
396
  // Resolve working directory
258
397
  const effectiveCwd = options.cwd ?? ctx.cwd;
259
398
 
260
399
  const env = await detectEnv(options.pi, effectiveCwd);
261
400
 
262
- // Resolve extensions/skills: isolated overrides to false
263
- // Falls back to agent config (frontmatter) when not set via options (tool injection)
264
- const effectiveIsolated = options.isolated ?? agentConfig?.isolated;
265
- const extensions = effectiveIsolated ? false : config.extensions;
266
- const skills = effectiveIsolated ? false : config.skills;
267
- const preloadSkillsList = effectiveIsolated ? false : agentConfig?.preloadSkills;
401
+ // Resolve extensions/skills from agent config (frontmatter)
402
+ const extensions = config.extensions;
403
+ const skills = config.skills;
404
+ const preloadSkillsList = agentConfig?.preloadSkills;
268
405
 
269
- // Build prompt extras (no memoryBlock — skills only).
270
- // - preloadSkills: force full content into system prompt
271
- // - skills: metadata only (whitelist), agent reads on-demand
406
+ // Build prompt extras (skills only).
272
407
  const extras: PromptExtras = {};
273
408
  if (Array.isArray(preloadSkillsList)) {
274
409
  extras.skillBlocks = preloadSkills(preloadSkillsList, effectiveCwd);
@@ -299,7 +434,10 @@ export async function runAgent(
299
434
  const agentDir = getAgentDir();
300
435
 
301
436
  // Load extensions/skills: true or string[] → load; false → don't.
302
- const loader = new DefaultResourceLoader({
437
+ // When extensions is an array, use extensionsOverride to selectively load
438
+ // only the listed extensions (hooks/commands of excluded ones never fire).
439
+ // When excludeExtensions is set (and extensions is not string[]), filter out those extensions.
440
+ const loaderOpts: ConstructorParameters<typeof DefaultResourceLoader>[0] = {
303
441
  cwd: effectiveCwd,
304
442
  agentDir,
305
443
  noExtensions: extensions === false,
@@ -309,9 +447,44 @@ export async function runAgent(
309
447
  noContextFiles: true,
310
448
  systemPromptOverride: () => systemPrompt,
311
449
  appendSystemPromptOverride: () => [],
312
- });
450
+ };
451
+ const excludeExtSet = agentConfig?.excludeExtensions
452
+ ? new Set(agentConfig.excludeExtensions)
453
+ : undefined;
454
+ if (Array.isArray(extensions)) {
455
+ // Whitelist mode: only load listed extensions
456
+ const allowedNames = new Set(extensions.map(ext => {
457
+ const slashIdx = ext.indexOf("/");
458
+ return slashIdx !== -1 ? ext.slice(0, slashIdx) : ext;
459
+ }));
460
+ loaderOpts.extensionsOverride = (result) => ({
461
+ ...result,
462
+ extensions: result.extensions.filter(ext =>
463
+ allowedNames.has(extractExtensionName(ext.path)),
464
+ ),
465
+ });
466
+ } else if (excludeExtSet) {
467
+ // Blacklist mode: load all except excluded extensions
468
+ loaderOpts.extensionsOverride = (result) => ({
469
+ ...result,
470
+ extensions: result.extensions.filter(ext =>
471
+ !excludeExtSet.has(extractExtensionName(ext.path)),
472
+ ),
473
+ });
474
+ }
475
+ const loader = new DefaultResourceLoader(loaderOpts);
313
476
  await loader.reload();
314
477
 
478
+ // Build extension name → tool names map from loaded extensions.
479
+ // Used by filterActiveTools to resolve extension names in the extensions frontmatter field.
480
+ const extResult = loader.getExtensions();
481
+ const extToolMap = new Map<string, string[]>();
482
+ for (const ext of extResult.extensions) {
483
+ const name = extractExtensionName(ext.path);
484
+ const tools = [...ext.tools.keys()];
485
+ if (tools.length > 0) extToolMap.set(name, tools);
486
+ }
487
+
315
488
  // Resolve model: explicit option > config.model > parent model
316
489
  const model = options.model ?? findModelInRegistry(
317
490
  agentConfig?.model, ctx.modelRegistry, ctx.model,
@@ -341,19 +514,9 @@ export async function runAgent(
341
514
  options.agentId ? `${baseSessionName}#${options.agentId.slice(0, SHORT_ID_LENGTH)}` : baseSessionName,
342
515
  );
343
516
 
344
- // Filter active tools: remove our own tools to prevent nesting,
345
- // apply extension allowlist if specified, and apply disallowedTools denylist
346
- const filteredTools = filterActiveTools(
347
- session.getActiveToolNames(),
348
- toolNames,
349
- extensions,
350
- agentConfig?.disallowedTools,
351
- );
352
- if (filteredTools) {
353
- session.setActiveToolsByName(filteredTools);
354
- }
355
-
356
517
  // Bind extensions so that session_start fires and extensions can initialize
518
+ // This must happen BEFORE tool filtering — extensions like pi-mcp-adapter
519
+ // register tools lazily during session_start, not at extension load time.
357
520
  await session.bindExtensions({
358
521
  onError: (err) => {
359
522
  options.onToolActivity?.({
@@ -363,6 +526,33 @@ export async function runAgent(
363
526
  },
364
527
  });
365
528
 
529
+ // Rebuild extToolMap after session_start — extensions may have registered
530
+ // new tools (e.g., pi-mcp-adapter registers 'mcp' tool at session_start).
531
+ const postBindExtToolMap = new Map<string, string[]>();
532
+ for (const ext of extResult.extensions) {
533
+ const name = extractExtensionName(ext.path);
534
+ const tools = [...ext.tools.keys()];
535
+ if (tools.length > 0) postBindExtToolMap.set(name, tools);
536
+ }
537
+
538
+ // Filter active tools: apply tools allowlist/denylist and EXCLUDED_TOOL_NAMES
539
+ const filteredTools = filterActiveTools(
540
+ session.getActiveToolNames(),
541
+ postBindExtToolMap,
542
+ agentConfig?.tools,
543
+ agentConfig?.excludeTools,
544
+ (msg) => {
545
+ if (ctx.ui?.notify) {
546
+ ctx.ui.notify(`[pi-subagents] ${msg}`, "warning");
547
+ } else {
548
+ console.warn(`[pi-subagents] ${msg}`);
549
+ }
550
+ },
551
+ );
552
+ if (filteredTools) {
553
+ session.setActiveToolsByName(filteredTools);
554
+ }
555
+
366
556
  options.onSessionCreated?.(session);
367
557
 
368
558
  // Track turns for graceful max_turns enforcement
@@ -370,6 +560,7 @@ export async function runAgent(
370
560
  const maxTurns = normalizeMaxTurns(options.maxTurns ?? agentConfig?.maxTurns);
371
561
  let softLimitReached = false;
372
562
  let aborted = false;
563
+ const graceTurns = options.graceTurns ?? DEFAULT_GRACE_TURNS;
373
564
 
374
565
  const unsubEvents = subscribeToSessionEvents(session, options);
375
566
 
@@ -381,7 +572,7 @@ export async function runAgent(
381
572
  if (!softLimitReached && turnCount >= maxTurns) {
382
573
  softLimitReached = true;
383
574
  session.steer("You have reached your turn limit. Wrap up immediately — provide your final answer now.");
384
- } else if (softLimitReached && turnCount >= maxTurns + GRACE_TURNS) {
575
+ } else if (softLimitReached && turnCount >= maxTurns + graceTurns) {
385
576
  aborted = true;
386
577
  session.abort();
387
578
  }
@@ -3,24 +3,36 @@
3
3
  *
4
4
  * Merges embedded default agents with user-defined agents from .pi/agents/*.md.
5
5
  * User agents override defaults with the same name. Disabled agents are kept but excluded from spawning.
6
- *
7
- * Trimmed from upstream: removed getMemoryToolNames(), getReadOnlyMemoryToolNames(),
8
- * MEMORY_TOOL_NAMES, READONLY_MEMORY_TOOL_NAMES (memory feature cut).
9
6
  */
10
7
 
8
+ import { scanAgentFilesInDir, mergeAgents } from "./agent-discovery.js";
11
9
  import { DEFAULT_AGENTS } from "./default-agents.js";
12
10
  import type { AgentConfig } from "./types.js";
13
11
 
14
- /** All known built-in tool names. */
15
- const BUILTIN_TOOL_NAMES: string[] = ["read", "bash", "edit", "write", "grep", "find", "ls"];
12
+ /**
13
+ * All tool names that Pi can provide to a session.
14
+ *
15
+ * Note: only `read`, `bash`, `edit`, `write` are active by default.
16
+ * `grep` must be explicitly activated via setActiveToolsByName().
17
+ * `find` and `ls` were removed — they're thin wrappers over bash commands
18
+ * that add ~180 tokens/turn with no real benefit.
19
+ */
20
+ export const BUILTIN_TOOL_NAMES: string[] = ["read", "bash", "edit", "write", "grep"];
16
21
 
17
22
  /** Unified runtime registry of all agents (defaults + user-defined). */
18
23
  const agents = new Map<string, AgentConfig>();
19
24
 
25
+ /**
26
+ * Directories to scan for agent .md files at startup and on-demand.
27
+ * Set by setAgentScanDirs() during session_start.
28
+ */
29
+ let userAgentDir = "";
30
+ let projectAgentDir = "";
31
+
20
32
  /**
21
33
  * Register agents into the unified registry.
22
34
  * Starts with DEFAULT_AGENTS, then overlays user agents (overrides defaults with same name).
23
- * Disabled agents (enabled === false) are kept in the registry but excluded from spawning.
35
+ * Hidden agents (hidden === true) are kept in the registry but excluded from spawning.
24
36
  */
25
37
  export function registerAgents(userAgents: Map<string, AgentConfig>): void {
26
38
  agents.clear();
@@ -36,6 +48,37 @@ export function registerAgents(userAgents: Map<string, AgentConfig>): void {
36
48
  }
37
49
  }
38
50
 
51
+ /**
52
+ * Set the agent scan directories for on-demand discovery.
53
+ * Called during session_start alongside scanAndRegisterAgents.
54
+ */
55
+ export function setAgentScanDirs(userDir: string, projectDir: string): void {
56
+ userAgentDir = userDir;
57
+ projectAgentDir = projectDir;
58
+ }
59
+
60
+ /**
61
+ * Scan the known agent directories and register any newly discovered agents
62
+ * that aren't already in the registry. Returns the number of new agents added.
63
+ */
64
+ export async function discoverNewAgents(): Promise<number> {
65
+ const [userAgents, projectAgents] = await Promise.all([
66
+ scanAgentFilesInDir(userAgentDir, "user"),
67
+ scanAgentFilesInDir(projectAgentDir, "project"),
68
+ ]);
69
+
70
+ const merged = mergeAgents(DEFAULT_AGENTS, userAgents, projectAgents);
71
+
72
+ let count = 0;
73
+ for (const [name, config] of merged) {
74
+ if (!agents.has(name)) {
75
+ agents.set(name, config);
76
+ count++;
77
+ }
78
+ }
79
+ return count;
80
+ }
81
+
39
82
  /** Resolve a type name case-insensitively. Also matches displayName. Returns the canonical key or undefined. */
40
83
  export function resolveType(name: string): string | undefined {
41
84
  if (!name) return undefined;
@@ -54,14 +97,14 @@ export function getAgentConfig(name: string): AgentConfig | undefined {
54
97
  return key ? agents.get(key) : undefined;
55
98
  }
56
99
 
57
- /** Get all enabled type names (for spawning and tool descriptions). */
100
+ /** Get all visible type names (for spawning and tool descriptions). */
58
101
  export function getAvailableTypes(): string[] {
59
102
  return [...agents.entries()]
60
- .filter(([_, config]) => config.enabled !== false)
103
+ .filter(([_, config]) => config.hidden !== true)
61
104
  .map(([name]) => name);
62
105
  }
63
106
 
64
- /** Get all type names including disabled (for UI listing). */
107
+ /** Get all type names including hidden (for UI listing). */
65
108
  export function getAllTypes(): string[] {
66
109
  return [...agents.keys()];
67
110
  }
@@ -69,16 +112,18 @@ export function getAllTypes(): string[] {
69
112
  /** Get built-in tool names for a type (case-insensitive). */
70
113
  export function getToolNamesForType(type: string): string[] {
71
114
  const config = getAgentConfig(type);
72
- return config?.builtinToolNames?.length
73
- ? config.builtinToolNames
115
+ return config?.registeredTools?.length
116
+ ? config.registeredTools
74
117
  : [...BUILTIN_TOOL_NAMES];
75
118
  }
76
119
 
77
120
  /** Resolved config shape returned by getConfig. */
78
- interface ResolvedAgentConfig {
121
+ export interface ResolvedAgentConfig {
79
122
  displayName: string;
80
123
  description: string;
81
- builtinToolNames: string[];
124
+ registeredTools: string[];
125
+ /** Controls tool schema visibility. true = all, string[] = listed, false = none. */
126
+ tools?: true | string[] | false;
82
127
  extensions: true | string[] | false;
83
128
  skills: true | string[] | false;
84
129
  }
@@ -87,7 +132,8 @@ function toResolved(config: AgentConfig): ResolvedAgentConfig {
87
132
  return {
88
133
  displayName: config.displayName ?? config.name,
89
134
  description: config.description,
90
- builtinToolNames: config.builtinToolNames ?? BUILTIN_TOOL_NAMES,
135
+ registeredTools: config.registeredTools ?? BUILTIN_TOOL_NAMES,
136
+ tools: config.tools,
91
137
  extensions: config.extensions,
92
138
  skills: config.skills,
93
139
  };
@@ -98,20 +144,20 @@ export function getConfig(type: string): ResolvedAgentConfig {
98
144
  const resolvedKey = resolveType(type);
99
145
  const config = resolvedKey ? agents.get(resolvedKey) : undefined;
100
146
 
101
- // If config exists and is enabled, use it; otherwise fall back to general-purpose
102
- const activeConfig = config?.enabled !== false
147
+ // If config exists and is not hidden, use it; otherwise fall back to general-purpose
148
+ const activeConfig = config?.hidden !== true
103
149
  ? config
104
150
  : agents.get("general-purpose");
105
151
 
106
- if (activeConfig && activeConfig.enabled !== false) {
152
+ if (activeConfig && activeConfig.hidden !== true) {
107
153
  return toResolved(activeConfig);
108
154
  }
109
155
 
110
- // Absolute fallback — general-purpose was disabled or missing
156
+ // Absolute fallback — general-purpose was hidden or missing
111
157
  return {
112
158
  displayName: "Agent",
113
159
  description: "General-purpose agent for complex, multi-step tasks",
114
- builtinToolNames: BUILTIN_TOOL_NAMES,
160
+ registeredTools: BUILTIN_TOOL_NAMES,
115
161
  extensions: true,
116
162
  skills: true,
117
163
  };
package/src/config-io.ts CHANGED
@@ -14,7 +14,7 @@ const CONFIG_PATH = path.join(CONFIG_DIR, "subagents-lite.json");
14
14
 
15
15
  /** Default configuration — used when config file doesn't exist or is invalid. */
16
16
  export const DEFAULT_CONFIG: SubagentsConfig = {
17
- agent: { default: null, forceBackground: false },
17
+ agent: { default: null, forceBackground: false, graceTurns: 6 },
18
18
  concurrency: { default: 4 },
19
19
  };
20
20
 
@@ -24,10 +24,8 @@ export function loadConfig(): SubagentsConfig {
24
24
  const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
25
25
  return JSON.parse(raw) as SubagentsConfig;
26
26
  } catch {
27
- // File doesn't exist or is invalid return defaults
27
+ return { ...DEFAULT_CONFIG, agent: { ...DEFAULT_CONFIG.agent }, concurrency: { ...DEFAULT_CONFIG.concurrency } };
28
28
  }
29
-
30
- return { ...DEFAULT_CONFIG, agent: { ...DEFAULT_CONFIG.agent }, concurrency: { ...DEFAULT_CONFIG.concurrency } };
31
29
  }
32
30
 
33
31
  /** Write config to disk with atomic rename. */
package/src/context.ts CHANGED
@@ -1,18 +1,21 @@
1
1
  /**
2
- * context.ts — Extract parent conversation context for subagent inheritance.
2
+ * context.ts — Message content extraction and conversation snapshot formatting.
3
3
  *
4
- * Contains extractText (for message content) and buildSnapshotMarkdown
5
- * (for agent session snapshot viewer). buildParentContext was removed
6
- * when inherit_context was cut.
4
+ * extractText: pull text from message content blocks.
5
+ * buildSnapshotMarkdown: format agent conversation as markdown for snapshot viewer.
7
6
  */
8
7
 
9
8
  import { summarizeToolArgs } from "./output-file.js";
10
9
 
10
+ function isTextBlock(c: unknown): c is { type: "text"; text: string } {
11
+ return typeof c === "object" && c !== null && (c as Record<string, unknown>).type === "text";
12
+ }
13
+
11
14
  /** Extract text from a message content block array. */
12
15
  export function extractText(content: unknown[]): string {
13
16
  return content
14
- .filter((c: any) => c.type === "text")
15
- .map((c: any) => c.text ?? "")
17
+ .filter(isTextBlock)
18
+ .map((c) => c.text)
16
19
  .join("\n");
17
20
  }
18
21
 
@@ -7,7 +7,7 @@
7
7
 
8
8
  import type { AgentConfig } from "./types.js";
9
9
 
10
- const READ_ONLY_TOOLS = ["read", "bash", "grep", "find", "ls"];
10
+ const READ_ONLY_TOOLS = ["read", "bash", "grep"];
11
11
 
12
12
  export const DEFAULT_AGENTS: Map<string, AgentConfig> = new Map([
13
13
  [
@@ -16,7 +16,7 @@ export const DEFAULT_AGENTS: Map<string, AgentConfig> = new Map([
16
16
  name: "general-purpose",
17
17
  displayName: "Agent",
18
18
  description: "General-purpose agent for complex, multi-step tasks",
19
- // builtinToolNames omitted — means "all available tools" (resolved at lookup time)
19
+ // registeredTools omitted — means "all available tools" (resolved at lookup time)
20
20
  extensions: true,
21
21
  skills: true,
22
22
  systemPrompt: "",
@@ -29,7 +29,7 @@ export const DEFAULT_AGENTS: Map<string, AgentConfig> = new Map([
29
29
  name: "Explore",
30
30
  displayName: "Explore",
31
31
  description: "Fast codebase exploration agent (read-only)",
32
- builtinToolNames: READ_ONLY_TOOLS,
32
+ registeredTools: READ_ONLY_TOOLS,
33
33
  extensions: true,
34
34
  skills: true,
35
35
  model: "anthropic/claude-haiku-4-5-20251001",