pi-subagents-lite 0.4.1 → 1.0.0

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,17 +17,17 @@ 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
32
  /** Additional turns allowed after the soft limit steer message. */
39
33
  const GRACE_TURNS = 5;
@@ -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;
@@ -187,36 +179,166 @@ export function subscribeToSessionEvents(
187
179
  }
188
180
 
189
181
  /**
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).
182
+ * Extract the extension name from an extension's file path.
183
+ *
184
+ * Handles all distribution methods:
185
+ * - git packages: `.../git/github.com/<user>/<pkg>/...` → "<pkg>"
186
+ * - npm packages: `.../node_modules/[...]pkg/...` → "pkg"
187
+ * - local extensions: `~/.pi/agent/extensions/<name>/...` → "<name>"
188
+ * - direct files: `extensions/<name>.ts` → "<name>"
189
+ *
190
+ * Does NOT depend on internal directory structure (dist/, lib/, src/, etc).
191
+ * Only cares about the package root, which is determined by distribution method.
192
+ */
193
+ function extractExtensionName(extPath: string): string {
194
+ const parts = extPath.split(path.sep);
195
+
196
+ // 1. Git package: .../git/github.com/<user>/<pkg>/...
197
+ // Package name is 3 dirs after 'git' (github.com/user/pkg)
198
+ const gitIdx = parts.indexOf("git");
199
+ if (gitIdx !== -1 && gitIdx + 3 < parts.length) {
200
+ return parts[gitIdx + 3];
201
+ }
202
+
203
+ // 2. npm package: .../node_modules/[...]pkg/...
204
+ const nmIdx = parts.lastIndexOf("node_modules");
205
+ if (nmIdx !== -1 && nmIdx + 1 < parts.length) {
206
+ const next = parts[nmIdx + 1];
207
+ if (next.startsWith("@") && nmIdx + 2 < parts.length) {
208
+ return parts[nmIdx + 2]; // @scope/pkg → pkg
209
+ }
210
+ return next;
211
+ }
212
+
213
+ // 3. Local extension: .../extensions/<name>/... or .../extensions/<name>.ts
214
+ const extIdx = parts.lastIndexOf("extensions");
215
+ if (extIdx !== -1 && extIdx + 1 < parts.length) {
216
+ const afterExt = parts[extIdx + 1];
217
+ // Subdirectory: extensions/tavily/index.ts → tavily
218
+ if (afterExt && !afterExt.includes(".")) {
219
+ return afterExt;
220
+ }
221
+ // Direct file: extensions/review.ts → review
222
+ const file = parts[parts.length - 1];
223
+ return path.basename(file, path.extname(file));
224
+ }
225
+
226
+ // Fallback: parent dir name
227
+ return path.basename(path.dirname(extPath));
228
+ }
229
+
230
+ /**
231
+ * Resolve tool entries (with ext/* syntax) into concrete tool names.
232
+ * Returns a set of resolved tool names.
233
+ */
234
+ function resolveToolEntries(
235
+ entries: string[],
236
+ extToolMap: Map<string, string[]> | undefined,
237
+ notify?: (msg: string) => void,
238
+ ): Set<string> {
239
+ const resolved = new Set<string>();
240
+
241
+ for (const entry of entries) {
242
+ const slashIdx = entry.indexOf("/");
243
+ if (slashIdx !== -1) {
244
+ // ext/* or ext/tool syntax
245
+ const extName = entry.slice(0, slashIdx);
246
+ const toolPart = entry.slice(slashIdx + 1);
247
+ if (toolPart === "*") {
248
+ const extTools = extToolMap?.get(extName);
249
+ if (extTools && extTools.length > 0) {
250
+ for (const t of extTools) resolved.add(t);
251
+ } else {
252
+ notify?.(`extension "${extName}" is not loaded, "${entry}" will have no effect`);
253
+ }
254
+ } else {
255
+ // ext/tool syntax: e.g. "tavily/web_search"
256
+ resolved.add(toolPart);
257
+ }
258
+ } else {
259
+ // Bare tool name
260
+ resolved.add(entry);
261
+ }
262
+ }
263
+
264
+ return resolved;
265
+ }
266
+
267
+ /**
268
+ * Filter active tools: apply tools allowlist/denylist and EXCLUDED_TOOL_NAMES.
269
+ *
270
+ * The `tools` config controls which tool schemas the LLM sees (built-in + extension).
271
+ * The `extensions` config controls which extensions are loaded (hooks + commands).
272
+ * `extensions` does NOT affect tool visibility — that's `tools`'s job.
273
+ *
274
+ * Supports ext/* syntax for both whitelist and blacklist modes.
275
+ *
276
+ * `tools` and `excludeTools` are mutually exclusive. If both set, `tools` wins.
277
+ *
278
+ * Returns null when no filtering is needed, otherwise the filtered tool list.
193
279
  */
194
280
  function filterActiveTools(
195
281
  activeTools: string[],
196
- builtinToolNames: string[],
197
- extensions: true | string[] | false,
198
- disallowedTools?: string[],
282
+ extToolMap: Map<string, string[]> | undefined,
283
+ tools: true | string[] | false | undefined,
284
+ excludeTools: string[] | undefined,
285
+ notify?: (msg: string) => void,
199
286
  ): 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));
287
+ // Blacklist mode: excludeTools set and tools not set as whitelist
288
+ if (excludeTools && !Array.isArray(tools)) {
289
+ const excludeSet = resolveToolEntries(excludeTools, extToolMap, notify);
290
+ const filtered = activeTools.filter(t =>
291
+ !EXCLUDED_TOOL_NAMES.includes(t) && !excludeSet.has(t)
292
+ );
206
293
  return filtered.length !== activeTools.length ? filtered : null;
207
294
  }
208
295
 
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));
296
+ if (Array.isArray(tools)) {
297
+ // Whitelist mode: resolve entries with ext/* expansion
298
+ const allBuiltinSet = new Set(BUILTIN_TOOL_NAMES);
299
+ const allowedTools = resolveToolEntries(tools, extToolMap, notify);
300
+
301
+ // Warn about unknown entries
302
+ for (const entry of tools) {
303
+ const slashIdx = entry.indexOf("/");
304
+ if (slashIdx === -1 && !allBuiltinSet.has(entry)) {
305
+ // Bare name, not a known built-in — check if it's an extension tool
306
+ const toolExts = extToolMap ? [...extToolMap.entries()].filter(([, tools]) => tools.includes(entry)) : [];
307
+ if (toolExts.length === 0) {
308
+ notify?.(`tool "${entry}" not found in any loaded extension`);
309
+ }
310
+ }
216
311
  }
217
- return true;
218
- });
219
- return filtered.length !== activeTools.length ? filtered : null;
312
+
313
+ const visibleSet = new Set<string>();
314
+ for (const t of activeTools) {
315
+ if (EXCLUDED_TOOL_NAMES.includes(t)) continue;
316
+ if (allowedTools.has(t)) {
317
+ visibleSet.add(t);
318
+ }
319
+ }
320
+
321
+ // Warn if a loaded extension has none of its tools in `tools`
322
+ if (extToolMap) {
323
+ for (const [extName, extTools] of extToolMap) {
324
+ const hasAny = extTools.some(t => allowedTools.has(t));
325
+ if (!hasAny) {
326
+ notify?.(`extension "${extName}" is loaded but none of its tools are in tools: [${tools.join(", ")}]`);
327
+ }
328
+ }
329
+ }
330
+
331
+ return [...visibleSet];
332
+ }
333
+
334
+ if (tools === false) {
335
+ return [];
336
+ }
337
+
338
+ // tools: true or undefined — all tools visible (except excluded)
339
+ const hasExcluded = activeTools.some(t => EXCLUDED_TOOL_NAMES.includes(t));
340
+ if (!hasExcluded) return null;
341
+ return activeTools.filter(t => !EXCLUDED_TOOL_NAMES.includes(t));
220
342
  }
221
343
 
222
344
  /** Run a git command via pi.exec, returning stdout on success or null on failure. */
@@ -254,21 +376,32 @@ export async function runAgent(
254
376
  const config = getConfig(type);
255
377
  const agentConfig = getAgentConfig(type);
256
378
 
379
+ // Warn on mutual exclusion violations
380
+ const notify = (msg: string) => {
381
+ if (ctx.ui?.notify) {
382
+ ctx.ui.notify(`[pi-subagents] ${msg}`, "warning");
383
+ } else {
384
+ console.warn(`[pi-subagents] ${msg}`);
385
+ }
386
+ };
387
+ if (agentConfig?.excludeTools && Array.isArray(agentConfig.tools)) {
388
+ notify(`agent "${type}": both tools and exclude_tools set — tools (whitelist) wins`);
389
+ }
390
+ if (agentConfig?.excludeExtensions && Array.isArray(agentConfig.extensions)) {
391
+ notify(`agent "${type}": both extensions and exclude_extensions set — extensions (whitelist) wins`);
392
+ }
393
+
257
394
  // Resolve working directory
258
395
  const effectiveCwd = options.cwd ?? ctx.cwd;
259
396
 
260
397
  const env = await detectEnv(options.pi, effectiveCwd);
261
398
 
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;
399
+ // Resolve extensions/skills from agent config (frontmatter)
400
+ const extensions = config.extensions;
401
+ const skills = config.skills;
402
+ const preloadSkillsList = agentConfig?.preloadSkills;
268
403
 
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
404
+ // Build prompt extras (skills only).
272
405
  const extras: PromptExtras = {};
273
406
  if (Array.isArray(preloadSkillsList)) {
274
407
  extras.skillBlocks = preloadSkills(preloadSkillsList, effectiveCwd);
@@ -299,7 +432,10 @@ export async function runAgent(
299
432
  const agentDir = getAgentDir();
300
433
 
301
434
  // Load extensions/skills: true or string[] → load; false → don't.
302
- const loader = new DefaultResourceLoader({
435
+ // When extensions is an array, use extensionsOverride to selectively load
436
+ // only the listed extensions (hooks/commands of excluded ones never fire).
437
+ // When excludeExtensions is set (and extensions is not string[]), filter out those extensions.
438
+ const loaderOpts: ConstructorParameters<typeof DefaultResourceLoader>[0] = {
303
439
  cwd: effectiveCwd,
304
440
  agentDir,
305
441
  noExtensions: extensions === false,
@@ -309,9 +445,44 @@ export async function runAgent(
309
445
  noContextFiles: true,
310
446
  systemPromptOverride: () => systemPrompt,
311
447
  appendSystemPromptOverride: () => [],
312
- });
448
+ };
449
+ const excludeExtSet = agentConfig?.excludeExtensions
450
+ ? new Set(agentConfig.excludeExtensions)
451
+ : undefined;
452
+ if (Array.isArray(extensions)) {
453
+ // Whitelist mode: only load listed extensions
454
+ const allowedNames = new Set(extensions.map(ext => {
455
+ const slashIdx = ext.indexOf("/");
456
+ return slashIdx !== -1 ? ext.slice(0, slashIdx) : ext;
457
+ }));
458
+ loaderOpts.extensionsOverride = (result) => ({
459
+ ...result,
460
+ extensions: result.extensions.filter(ext =>
461
+ allowedNames.has(extractExtensionName(ext.path)),
462
+ ),
463
+ });
464
+ } else if (excludeExtSet) {
465
+ // Blacklist mode: load all except excluded extensions
466
+ loaderOpts.extensionsOverride = (result) => ({
467
+ ...result,
468
+ extensions: result.extensions.filter(ext =>
469
+ !excludeExtSet.has(extractExtensionName(ext.path)),
470
+ ),
471
+ });
472
+ }
473
+ const loader = new DefaultResourceLoader(loaderOpts);
313
474
  await loader.reload();
314
475
 
476
+ // Build extension name → tool names map from loaded extensions.
477
+ // Used by filterActiveTools to resolve extension names in the extensions frontmatter field.
478
+ const extResult = loader.getExtensions();
479
+ const extToolMap = new Map<string, string[]>();
480
+ for (const ext of extResult.extensions) {
481
+ const name = extractExtensionName(ext.path);
482
+ const tools = [...ext.tools.keys()];
483
+ if (tools.length > 0) extToolMap.set(name, tools);
484
+ }
485
+
315
486
  // Resolve model: explicit option > config.model > parent model
316
487
  const model = options.model ?? findModelInRegistry(
317
488
  agentConfig?.model, ctx.modelRegistry, ctx.model,
@@ -341,19 +512,9 @@ export async function runAgent(
341
512
  options.agentId ? `${baseSessionName}#${options.agentId.slice(0, SHORT_ID_LENGTH)}` : baseSessionName,
342
513
  );
343
514
 
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
515
  // Bind extensions so that session_start fires and extensions can initialize
516
+ // This must happen BEFORE tool filtering — extensions like pi-mcp-adapter
517
+ // register tools lazily during session_start, not at extension load time.
357
518
  await session.bindExtensions({
358
519
  onError: (err) => {
359
520
  options.onToolActivity?.({
@@ -363,6 +524,33 @@ export async function runAgent(
363
524
  },
364
525
  });
365
526
 
527
+ // Rebuild extToolMap after session_start — extensions may have registered
528
+ // new tools (e.g., pi-mcp-adapter registers 'mcp' tool at session_start).
529
+ const postBindExtToolMap = new Map<string, string[]>();
530
+ for (const ext of extResult.extensions) {
531
+ const name = extractExtensionName(ext.path);
532
+ const tools = [...ext.tools.keys()];
533
+ if (tools.length > 0) postBindExtToolMap.set(name, tools);
534
+ }
535
+
536
+ // Filter active tools: apply tools allowlist/denylist and EXCLUDED_TOOL_NAMES
537
+ const filteredTools = filterActiveTools(
538
+ session.getActiveToolNames(),
539
+ postBindExtToolMap,
540
+ agentConfig?.tools,
541
+ agentConfig?.excludeTools,
542
+ (msg) => {
543
+ if (ctx.ui?.notify) {
544
+ ctx.ui.notify(`[pi-subagents] ${msg}`, "warning");
545
+ } else {
546
+ console.warn(`[pi-subagents] ${msg}`);
547
+ }
548
+ },
549
+ );
550
+ if (filteredTools) {
551
+ session.setActiveToolsByName(filteredTools);
552
+ }
553
+
366
554
  options.onSessionCreated?.(session);
367
555
 
368
556
  // Track turns for graceful max_turns enforcement
@@ -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
@@ -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",
package/src/index.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  * - Config mutations update cache + atomic write to disk
16
16
  *
17
17
  * Commands:
18
- * - /agents: Management menu with 5 sub-menus
18
+ * - /agents: Management menu (model settings, concurrency, running agents, debug)
19
19
  *
20
20
  * Events:
21
21
  * - tool_call: Inject model into Agent tool calls
@@ -33,10 +33,10 @@ import type {
33
33
  } from "@earendil-works/pi-coding-agent";
34
34
  import type { SessionModelOverrides, SubagentsConfig } from "./model-precedence.js";
35
35
  import { DEFAULT_AGENTS } from "./default-agents.js";
36
- import { registerAgents, getAvailableTypes } from "./agent-types.js";
36
+ import { registerAgents, getAvailableTypes, setAgentScanDirs } from "./agent-types.js";
37
37
  import { scanAgentFilesInDir, mergeAgents } from "./agent-discovery.js";
38
38
  import { AgentManager } from "./agent-manager.js";
39
- import { AgentWidget, buildStatsParts, formatMs, getDisplayName, type AgentActivity, type UICtx } from "./ui/agent-widget.js";
39
+ import { AgentWidget, buildStatsParts, formatMs, getDisplayName, type AgentActivity, type Theme, type UICtx } from "./ui/agent-widget.js";
40
40
  import { showAgentsMainMenu } from "./menus.js";
41
41
  import { loadConfig, DEFAULT_CONFIG } from "./config-io.js";
42
42
  import { executeAgentTool, toolCallListener, backgroundAgentIds, scheduleNudge } from "./tool-execution.js";
@@ -111,6 +111,9 @@ async function scanAndRegisterAgents(ctx: ExtensionContext): Promise<void> {
111
111
  const userAgentDir = path.join(homeDir, ".pi", "agent", "agents");
112
112
  const projectAgentDir = path.join(ctx.cwd, ".pi", "agents");
113
113
 
114
+ // Store scan dirs for on-demand discovery (agents added during the session)
115
+ setAgentScanDirs(userAgentDir, projectAgentDir);
116
+
114
117
  const [userAgents, projectAgents] = await Promise.all([
115
118
  scanAgentFilesInDir(userAgentDir, "user"),
116
119
  scanAgentFilesInDir(projectAgentDir, "project"),
@@ -134,7 +137,7 @@ async function loadConfigAndRegisterAgents(ctx: ExtensionContext): Promise<void>
134
137
  // ============================================================================
135
138
 
136
139
  /** Build the stats line for an agent result card. Used by both renderers. */
137
- function buildStatsLine(d: Record<string, unknown>, theme: any): string {
140
+ function buildStatsLine(d: Record<string, unknown>, theme: Theme): string {
138
141
  const parts = buildStatsParts({
139
142
  toolUses: (d.toolUses as number) ?? 0,
140
143
  turnCount: d.turnCount as number | undefined,