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.
- package/README.md +128 -28
- package/package.json +6 -3
- package/src/agent-discovery.ts +11 -17
- package/src/agent-manager.ts +3 -17
- package/src/agent-runner.ts +248 -60
- package/src/agent-types.ts +65 -19
- package/src/config-io.ts +1 -3
- package/src/context.ts +9 -6
- package/src/default-agents.ts +3 -3
- package/src/index.ts +7 -4
- package/src/menus.ts +7 -7
- package/src/model-selector.ts +1 -7
- package/src/output-file.ts +3 -6
- package/src/prompts.ts +2 -3
- package/src/result-viewer.ts +1 -4
- package/src/skill-loader.ts +0 -2
- package/src/tool-execution.ts +8 -13
- package/src/types.ts +20 -22
- package/src/ui/agent-widget.ts +2 -5
- package/src/utils.ts +4 -5
package/src/agent-runner.ts
CHANGED
|
@@ -1,16 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Core execution engine: creates sessions, runs agents, collects results.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
|
263
|
-
|
|
264
|
-
const
|
|
265
|
-
const
|
|
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 (
|
|
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
|
-
|
|
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
|
package/src/agent-types.ts
CHANGED
|
@@ -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
|
-
/**
|
|
15
|
-
|
|
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
|
-
*
|
|
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
|
|
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.
|
|
103
|
+
.filter(([_, config]) => config.hidden !== true)
|
|
61
104
|
.map(([name]) => name);
|
|
62
105
|
}
|
|
63
106
|
|
|
64
|
-
/** Get all type names including
|
|
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?.
|
|
73
|
-
? config.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
102
|
-
const activeConfig = config?.
|
|
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.
|
|
152
|
+
if (activeConfig && activeConfig.hidden !== true) {
|
|
107
153
|
return toResolved(activeConfig);
|
|
108
154
|
}
|
|
109
155
|
|
|
110
|
-
// Absolute fallback — general-purpose was
|
|
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
|
-
|
|
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
|
-
|
|
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 —
|
|
2
|
+
* context.ts — Message content extraction and conversation snapshot formatting.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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(
|
|
15
|
-
.map((c
|
|
17
|
+
.filter(isTextBlock)
|
|
18
|
+
.map((c) => c.text)
|
|
16
19
|
.join("\n");
|
|
17
20
|
}
|
|
18
21
|
|
package/src/default-agents.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import type { AgentConfig } from "./types.js";
|
|
9
9
|
|
|
10
|
-
const READ_ONLY_TOOLS = ["read", "bash", "grep"
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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:
|
|
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,
|