@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/CHANGELOG.md +36 -0
- package/README.md +54 -17
- package/dist/agent-runner.d.ts +49 -0
- package/dist/agent-runner.js +225 -35
- package/dist/agent-types.d.ts +12 -1
- package/dist/agent-types.js +26 -7
- package/dist/custom-agents.js +21 -1
- package/dist/index.js +46 -24
- package/dist/prompts.d.ts +6 -3
- package/dist/prompts.js +12 -4
- package/dist/settings.d.ts +8 -0
- package/dist/settings.js +5 -0
- package/dist/status-note.d.ts +13 -0
- package/dist/status-note.js +24 -0
- package/dist/types.d.ts +4 -0
- package/dist/ui/agent-widget.d.ts +4 -4
- package/dist/ui/agent-widget.js +6 -6
- package/dist/ui/conversation-viewer.d.ts +9 -1
- package/dist/ui/conversation-viewer.js +35 -2
- package/dist/worktree.d.ts +2 -0
- package/dist/worktree.js +28 -16
- package/package.json +2 -1
- package/src/agent-runner.ts +238 -34
- package/src/agent-types.ts +29 -7
- package/src/custom-agents.ts +23 -1
- package/src/index.ts +46 -26
- package/src/prompts.ts +12 -4
- package/src/settings.ts +12 -0
- package/src/status-note.ts +25 -0
- package/src/types.ts +4 -1
- package/src/ui/agent-widget.ts +6 -6
- package/src/ui/conversation-viewer.ts +32 -1
- package/src/worktree.ts +30 -17
- package/vitest.config.ts +18 -0
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 (
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
if (status) {
|
|
63
|
+
// Changes exist — stage, 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.
|
|
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/"
|
package/src/agent-runner.ts
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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
|
|
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:
|
|
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).
|
|
317
|
-
//
|
|
318
|
-
//
|
|
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?.({
|
package/src/agent-types.ts
CHANGED
|
@@ -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
|
-
/**
|
|
12
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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. */
|
package/src/custom-agents.ts
CHANGED
|
@@ -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
|
|
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.
|