@tintinweb/pi-subagents 0.10.1 → 0.10.3
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 +14 -0
- package/README.md +23 -1
- package/dist/agent-manager.d.ts +12 -0
- package/dist/agent-manager.js +70 -6
- package/dist/agent-runner.d.ts +14 -0
- package/dist/agent-runner.js +63 -16
- package/dist/agent-types.d.ts +1 -0
- package/dist/agent-types.js +2 -0
- package/dist/custom-agents.js +1 -0
- package/dist/index.js +104 -7
- package/dist/settings.d.ts +13 -0
- package/dist/settings.js +6 -0
- package/dist/types.d.ts +4 -0
- package/dist/ui/conversation-viewer.d.ts +5 -1
- package/dist/ui/conversation-viewer.js +10 -5
- package/dist/ui/viewer-keys.d.ts +20 -0
- package/dist/ui/viewer-keys.js +17 -0
- package/dist/worktree.d.ts +8 -1
- package/dist/worktree.js +12 -3
- package/examples/agent-tool-description.md +42 -0
- package/package.json +1 -1
- package/src/agent-manager.ts +77 -6
- package/src/agent-runner.ts +76 -16
- package/src/agent-types.ts +3 -0
- package/src/custom-agents.ts +1 -0
- package/src/index.ts +106 -8
- package/src/settings.ts +19 -0
- package/src/types.ts +4 -1
- package/src/ui/conversation-viewer.ts +9 -4
- package/src/ui/viewer-keys.ts +39 -0
- package/src/worktree.ts +20 -4
package/src/agent-manager.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { randomUUID } from "node:crypto";
|
|
10
|
+
import { statSync } from "node:fs";
|
|
11
|
+
import { isAbsolute } from "node:path";
|
|
10
12
|
import type { Model } from "@earendil-works/pi-ai";
|
|
11
13
|
import type { AgentSession, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
14
|
import { resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js";
|
|
@@ -22,6 +24,28 @@ export type CompactionInfo = { reason: "manual" | "threshold" | "overflow"; toke
|
|
|
22
24
|
/** Default max concurrent background agents. */
|
|
23
25
|
const DEFAULT_MAX_CONCURRENT = 4;
|
|
24
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Validate a caller-supplied SpawnOptions.cwd. `undefined`/`null` mean "unset"
|
|
29
|
+
* (parent cwd). Anything else must be an absolute path to an existing
|
|
30
|
+
* directory — curated errors instead of TypeErrors from path/fs internals
|
|
31
|
+
* (RPC callers send arbitrary JSON: null, numbers, file paths).
|
|
32
|
+
*/
|
|
33
|
+
function assertValidSpawnCwd(cwd: unknown): asserts cwd is string | undefined | null {
|
|
34
|
+
if (cwd == null) return;
|
|
35
|
+
if (typeof cwd !== "string" || !isAbsolute(cwd)) {
|
|
36
|
+
throw new Error(`SpawnOptions.cwd must be an absolute path: "${String(cwd)}"`);
|
|
37
|
+
}
|
|
38
|
+
let isDirectory = false;
|
|
39
|
+
try {
|
|
40
|
+
isDirectory = statSync(cwd).isDirectory();
|
|
41
|
+
} catch {
|
|
42
|
+
throw new Error(`SpawnOptions.cwd does not exist: "${cwd}"`);
|
|
43
|
+
}
|
|
44
|
+
if (!isDirectory) {
|
|
45
|
+
throw new Error(`SpawnOptions.cwd is not a directory: "${cwd}"`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
25
49
|
interface SpawnArgs {
|
|
26
50
|
pi: ExtensionAPI;
|
|
27
51
|
ctx: ExtensionContext;
|
|
@@ -46,6 +70,15 @@ interface SpawnOptions {
|
|
|
46
70
|
bypassQueue?: boolean;
|
|
47
71
|
/** Isolation mode — "worktree" creates a temp git worktree for the agent. */
|
|
48
72
|
isolation?: IsolationMode;
|
|
73
|
+
/**
|
|
74
|
+
* Working directory for the agent (absolute path). Default: parent session
|
|
75
|
+
* cwd. The agent's tools operate here, but .pi config (extensions, skills,
|
|
76
|
+
* settings, memory) still loads from the parent session's project — the
|
|
77
|
+
* target directory's `.pi` extensions never execute. With isolation:
|
|
78
|
+
* "worktree", the worktree is created FROM this directory and the result
|
|
79
|
+
* branch lands in that repo.
|
|
80
|
+
*/
|
|
81
|
+
cwd?: string;
|
|
49
82
|
/** Resolved invocation snapshot captured for UI display. */
|
|
50
83
|
invocation?: AgentInvocation;
|
|
51
84
|
/** Parent abort signal — when aborted, the subagent is also stopped. */
|
|
@@ -71,6 +104,9 @@ export class AgentManager {
|
|
|
71
104
|
private onStart?: OnAgentStart;
|
|
72
105
|
private onCompact?: OnAgentCompact;
|
|
73
106
|
private maxConcurrent: number;
|
|
107
|
+
/** Base repos worktrees were created from — so dispose() can prune them all,
|
|
108
|
+
* not just the parent repo (caller-supplied cwd can target other repos). */
|
|
109
|
+
private worktreeRepos = new Set<string>();
|
|
74
110
|
|
|
75
111
|
/** Queue of background agents waiting to start. */
|
|
76
112
|
private queue: { id: string; args: SpawnArgs }[] = [];
|
|
@@ -114,6 +150,11 @@ export class AgentManager {
|
|
|
114
150
|
prompt: string,
|
|
115
151
|
options: SpawnOptions,
|
|
116
152
|
): string {
|
|
153
|
+
// Validate before the queue branch — a queued spawn should fail at the
|
|
154
|
+
// call, not minutes later at drain. Throw (not warn): programmatic callers
|
|
155
|
+
// can fix and retry; the RPC layer converts throws into error envelopes.
|
|
156
|
+
assertValidSpawnCwd(options.cwd);
|
|
157
|
+
|
|
117
158
|
const id = randomUUID().slice(0, 17);
|
|
118
159
|
const abortController = new AbortController();
|
|
119
160
|
const record: AgentRecord = {
|
|
@@ -151,12 +192,21 @@ export class AgentManager {
|
|
|
151
192
|
|
|
152
193
|
/** Actually start an agent (called immediately or from queue drain). */
|
|
153
194
|
private startAgent(id: string, record: AgentRecord, { pi, ctx, type, prompt, options }: SpawnArgs) {
|
|
195
|
+
// Re-validate a caller-supplied cwd: queued spawns can start minutes after
|
|
196
|
+
// spawn()'s check, and the directory may be gone by then (TOCTOU). Same
|
|
197
|
+
// curated errors; drainQueue parks a throw on the record as an error.
|
|
198
|
+
assertValidSpawnCwd(options.cwd);
|
|
199
|
+
// Single resolution point for the caller-supplied cwd — the worktree base
|
|
200
|
+
// repo and both cleanup calls below MUST agree on this value forever.
|
|
201
|
+
const customCwd = options.cwd ?? undefined; // null (RPC "unset") → undefined
|
|
202
|
+
const baseCwd = customCwd ?? ctx.cwd;
|
|
203
|
+
|
|
154
204
|
// Worktree isolation: try to create a temporary git worktree. Strict —
|
|
155
205
|
// fail loud if not possible (no silent fallback to main tree). Done
|
|
156
206
|
// BEFORE state mutation so a throw doesn't leave the record half-running.
|
|
157
207
|
let worktreeCwd: string | undefined;
|
|
158
208
|
if (options.isolation === "worktree") {
|
|
159
|
-
const wt = createWorktree(
|
|
209
|
+
const wt = createWorktree(baseCwd, id);
|
|
160
210
|
if (!wt) {
|
|
161
211
|
throw new Error(
|
|
162
212
|
'Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' +
|
|
@@ -164,7 +214,14 @@ export class AgentManager {
|
|
|
164
214
|
);
|
|
165
215
|
}
|
|
166
216
|
record.worktree = wt;
|
|
167
|
-
|
|
217
|
+
// workPath preserves subdirectory scoping for caller-supplied cwds: a
|
|
218
|
+
// cwd deep in a monorepo maps to the same subdir inside the copy, not
|
|
219
|
+
// the copied repo's root. Plain worktree spawns keep the historical
|
|
220
|
+
// behavior (agent at the copy's root) — moving them to workPath would
|
|
221
|
+
// also move .pi config discovery when the parent session sits in a repo
|
|
222
|
+
// subdirectory, silently dropping extensions/skills.
|
|
223
|
+
worktreeCwd = customCwd !== undefined ? wt.workPath : wt.path;
|
|
224
|
+
this.worktreeRepos.add(baseCwd);
|
|
168
225
|
}
|
|
169
226
|
|
|
170
227
|
record.status = "running";
|
|
@@ -189,7 +246,13 @@ export class AgentManager {
|
|
|
189
246
|
isolated: options.isolated,
|
|
190
247
|
inheritContext: options.inheritContext,
|
|
191
248
|
thinkingLevel: options.thinkingLevel,
|
|
192
|
-
|
|
249
|
+
// Worktree wins for the working dir (the agent must run in the copy —
|
|
250
|
+
// which, with a custom cwd, was created from that target). Config stays
|
|
251
|
+
// with the parent project when a caller-supplied cwd is in play; it must
|
|
252
|
+
// stay undefined otherwise so plain worktree runs keep resolving config
|
|
253
|
+
// (incl. relative extension paths and memory) inside the worktree copy.
|
|
254
|
+
cwd: worktreeCwd ?? customCwd,
|
|
255
|
+
configCwd: customCwd !== undefined ? ctx.cwd : undefined,
|
|
193
256
|
signal: record.abortController!.signal,
|
|
194
257
|
onToolActivity: (activity) => {
|
|
195
258
|
if (activity.type === "end") record.toolUses++;
|
|
@@ -237,11 +300,14 @@ export class AgentManager {
|
|
|
237
300
|
|
|
238
301
|
// Clean up worktree if used
|
|
239
302
|
if (record.worktree) {
|
|
240
|
-
const wtResult = cleanupWorktree(
|
|
303
|
+
const wtResult = cleanupWorktree(baseCwd, record.worktree, options.description);
|
|
241
304
|
record.worktreeResult = wtResult;
|
|
242
305
|
if (wtResult.hasChanges && wtResult.branch) {
|
|
306
|
+
// With a caller-supplied cwd the branch lives in THAT repo, not the
|
|
307
|
+
// parent session's — say so, or the orchestrator merges in the wrong repo.
|
|
308
|
+
const repoNote = customCwd !== undefined ? ` in \`${baseCwd}\`` : "";
|
|
243
309
|
record.result = (record.result ?? "") +
|
|
244
|
-
`\n\n---\nChanges saved to branch \`${wtResult.branch}
|
|
310
|
+
`\n\n---\nChanges saved to branch \`${wtResult.branch}\`${repoNote}. Merge with: \`git merge ${wtResult.branch}\`${customCwd !== undefined ? ` (run in \`${baseCwd}\`)` : ""}`;
|
|
245
311
|
}
|
|
246
312
|
}
|
|
247
313
|
|
|
@@ -271,7 +337,7 @@ export class AgentManager {
|
|
|
271
337
|
// Best-effort worktree cleanup on error
|
|
272
338
|
if (record.worktree) {
|
|
273
339
|
try {
|
|
274
|
-
const wtResult = cleanupWorktree(
|
|
340
|
+
const wtResult = cleanupWorktree(baseCwd, record.worktree, options.description);
|
|
275
341
|
record.worktreeResult = wtResult;
|
|
276
342
|
} catch { /* ignore cleanup errors */ }
|
|
277
343
|
}
|
|
@@ -479,5 +545,10 @@ export class AgentManager {
|
|
|
479
545
|
this.agents.clear();
|
|
480
546
|
// Prune any orphaned git worktrees (crash recovery)
|
|
481
547
|
try { pruneWorktrees(process.cwd()); } catch { /* ignore */ }
|
|
548
|
+
// Also prune repos that caller-supplied cwds created worktrees in — a clean
|
|
549
|
+
// exit with in-flight agents would otherwise leave stale registrations there.
|
|
550
|
+
for (const repo of this.worktreeRepos) {
|
|
551
|
+
try { pruneWorktrees(repo); } catch { /* ignore */ }
|
|
552
|
+
}
|
|
482
553
|
}
|
|
483
554
|
}
|
package/src/agent-runner.ts
CHANGED
|
@@ -206,6 +206,20 @@ export interface RunOptions {
|
|
|
206
206
|
thinkingLevel?: ThinkingLevel;
|
|
207
207
|
/** Override working directory (e.g. for worktree isolation). */
|
|
208
208
|
cwd?: string;
|
|
209
|
+
/**
|
|
210
|
+
* Where .pi config is discovered (project extensions, skills, pi settings,
|
|
211
|
+
* agent memory). Default: same as the working directory. The manager sets
|
|
212
|
+
* this to the parent session's cwd when `SpawnOptions.cwd` points the
|
|
213
|
+
* working directory elsewhere — the agent works *there* but carries the
|
|
214
|
+
* parent project's config (the target's `.pi` extensions never execute).
|
|
215
|
+
*
|
|
216
|
+
* WARNING for future callers: if you pass `cwd` pointing at a directory the
|
|
217
|
+
* user didn't open, you almost certainly must pass `configCwd` too —
|
|
218
|
+
* omitting it makes the target's `.pi` extensions execute in this process.
|
|
219
|
+
* (Worktree isolation is the one intentional exception: its copy IS the
|
|
220
|
+
* parent's repo, so config resolving inside it is correct.)
|
|
221
|
+
*/
|
|
222
|
+
configCwd?: string;
|
|
209
223
|
/** Called on tool start/end with activity info. */
|
|
210
224
|
onToolActivity?: (activity: ToolActivity) => void;
|
|
211
225
|
/** Called on streaming text deltas from the assistant response. */
|
|
@@ -285,6 +299,9 @@ export async function runAgent(
|
|
|
285
299
|
|
|
286
300
|
// Resolve working directory: worktree override > parent cwd
|
|
287
301
|
const effectiveCwd = options.cwd ?? ctx.cwd;
|
|
302
|
+
// Filesystem work happens in effectiveCwd; config discovery in configCwd.
|
|
303
|
+
// They differ only for SpawnOptions.cwd spawns (config stays with the parent).
|
|
304
|
+
const configCwd = options.configCwd ?? effectiveCwd;
|
|
288
305
|
|
|
289
306
|
const env = await detectEnv(options.pi, effectiveCwd);
|
|
290
307
|
|
|
@@ -296,11 +313,14 @@ export async function runAgent(
|
|
|
296
313
|
|
|
297
314
|
// Resolve extensions/skills: isolated overrides to false
|
|
298
315
|
const extensions = options.isolated ? false : config.extensions;
|
|
316
|
+
// Nulling excludes under isolated also suppresses the orphaned-exclude warning —
|
|
317
|
+
// isolation is an intentional override, not a misconfiguration.
|
|
318
|
+
const excludeExtensions = options.isolated ? undefined : config.excludeExtensions;
|
|
299
319
|
const skills = options.isolated ? false : config.skills;
|
|
300
320
|
|
|
301
321
|
// Skill preloading: when skills is string[], preload their content into prompt
|
|
302
322
|
if (Array.isArray(skills)) {
|
|
303
|
-
const loaded = preloadSkills(skills,
|
|
323
|
+
const loaded = preloadSkills(skills, configCwd);
|
|
304
324
|
if (loaded.length > 0) {
|
|
305
325
|
extras.skillBlocks = loaded;
|
|
306
326
|
}
|
|
@@ -320,12 +340,12 @@ export async function runAgent(
|
|
|
320
340
|
// Read-write memory: add any missing memory tool names (read/write/edit)
|
|
321
341
|
const extraNames = getMemoryToolNames(existingNames);
|
|
322
342
|
if (extraNames.length > 0) toolNames = [...toolNames, ...extraNames];
|
|
323
|
-
extras.memoryBlock = buildMemoryBlock(agentConfig.name, agentConfig.memory,
|
|
343
|
+
extras.memoryBlock = buildMemoryBlock(agentConfig.name, agentConfig.memory, configCwd);
|
|
324
344
|
} else {
|
|
325
345
|
// Read-only memory: only add read tool name, use read-only prompt
|
|
326
346
|
const extraNames = getReadOnlyMemoryToolNames(existingNames);
|
|
327
347
|
if (extraNames.length > 0) toolNames = [...toolNames, ...extraNames];
|
|
328
|
-
extras.memoryBlock = buildReadOnlyMemoryBlock(agentConfig.name, agentConfig.memory,
|
|
348
|
+
extras.memoryBlock = buildReadOnlyMemoryBlock(agentConfig.name, agentConfig.memory, configCwd);
|
|
329
349
|
}
|
|
330
350
|
}
|
|
331
351
|
|
|
@@ -370,24 +390,41 @@ export async function runAgent(
|
|
|
370
390
|
const noExtensions = extensions === false;
|
|
371
391
|
|
|
372
392
|
const extensionsSpec = Array.isArray(extensions)
|
|
373
|
-
? parseExtensionsSpec(extensions,
|
|
393
|
+
? parseExtensionsSpec(extensions, configCwd)
|
|
374
394
|
: undefined;
|
|
375
395
|
const keepNames = extensionsSpec?.names ?? new Set<string>();
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
//
|
|
396
|
+
// `exclude_extensions:` is a denylist applied AFTER the include set — exclude wins.
|
|
397
|
+
// Plain canonical names only (case-insensitive). Note: excluded extensions'
|
|
398
|
+
// factories still run once during reload() (see comment above) — exclusion
|
|
399
|
+
// suppresses handler binding and tool registration; it is not a sandbox.
|
|
400
|
+
const excludeNames = new Set((excludeExtensions ?? []).map((n) => n.toLowerCase()));
|
|
401
|
+
const hasExcludes = excludeNames.size > 0;
|
|
402
|
+
// The override filters loaded extensions down to `keepNames` minus `excludeNames`.
|
|
403
|
+
// It's only needed when we're neither loading everything without excludes
|
|
404
|
+
// (`extensions: true` or a `"*"` wildcard) nor nothing (`noExtensions`).
|
|
379
405
|
const loadAll = extensions === true || extensionsSpec?.wildcard === true;
|
|
380
406
|
const additionalExtensionPaths = extensionsSpec?.paths.length ? extensionsSpec.paths : undefined;
|
|
407
|
+
// Pre-filter discovered set, captured by the override — the exclude-typo warning
|
|
408
|
+
// must compare against this, not the surviving set (absence from survivors is
|
|
409
|
+
// an exclude *succeeding*).
|
|
410
|
+
let discoveredNames: Set<string> | undefined;
|
|
381
411
|
const extensionsOverride: ((base: LoadExtensionsResult) => LoadExtensionsResult) | undefined =
|
|
382
|
-
|
|
412
|
+
noExtensions || (loadAll && !hasExcludes)
|
|
383
413
|
? undefined
|
|
384
|
-
: (base) =>
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
414
|
+
: (base) => {
|
|
415
|
+
discoveredNames = new Set(base.extensions.map((e) => extensionCanonicalName(e.path)));
|
|
416
|
+
return {
|
|
417
|
+
...base,
|
|
418
|
+
extensions: base.extensions.filter((e) => {
|
|
419
|
+
const name = extensionCanonicalName(e.path);
|
|
420
|
+
if (excludeNames.has(name)) return false; // exclude wins
|
|
421
|
+
return loadAll || keepNames.has(name);
|
|
422
|
+
}),
|
|
423
|
+
};
|
|
424
|
+
};
|
|
388
425
|
|
|
389
426
|
const loader = new DefaultResourceLoader({
|
|
390
|
-
cwd:
|
|
427
|
+
cwd: configCwd,
|
|
391
428
|
agentDir,
|
|
392
429
|
noExtensions,
|
|
393
430
|
additionalExtensionPaths,
|
|
@@ -425,6 +462,27 @@ export async function runAgent(
|
|
|
425
462
|
// - `tools: ext:foo` but foo isn't in the loaded set (because `extensions:`
|
|
426
463
|
// didn't include it). Since v0.9, `ext:` no longer pulls extensions in;
|
|
427
464
|
// loading is `extensions:`-authoritative.
|
|
465
|
+
// An exclude_extensions: alongside extensions: false is contradictory — nothing
|
|
466
|
+
// loads, so there is nothing to exclude.
|
|
467
|
+
if (hasExcludes && noExtensions) {
|
|
468
|
+
options.onToolActivity?.({
|
|
469
|
+
type: "end",
|
|
470
|
+
toolName: `extension-error:exclude_extensions has no effect for agent "${type}" — extensions: false loads nothing`,
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
// Exclude typo check: compares against the PRE-filter discovered set (an excluded
|
|
474
|
+
// name absent from the surviving set is the exclude working as intended). Also
|
|
475
|
+
// flags path-like and "*" entries — excludes are plain names only.
|
|
476
|
+
if (hasExcludes && discoveredNames) {
|
|
477
|
+
for (const name of excludeNames) {
|
|
478
|
+
if (!discoveredNames.has(name)) {
|
|
479
|
+
options.onToolActivity?.({
|
|
480
|
+
type: "end",
|
|
481
|
+
toolName: `extension-error:exclude_extensions: "${name}" for agent "${type}" did not match any discovered extension`,
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
428
486
|
if (keepNames.size > 0 || extNames.size > 0) {
|
|
429
487
|
const survivingNames = new Set(
|
|
430
488
|
loader.getExtensions().extensions.map((e) => extensionCanonicalName(e.path)),
|
|
@@ -433,7 +491,9 @@ export async function runAgent(
|
|
|
433
491
|
if (!survivingNames.has(name)) {
|
|
434
492
|
options.onToolActivity?.({
|
|
435
493
|
type: "end",
|
|
436
|
-
toolName:
|
|
494
|
+
toolName: excludeNames.has(name)
|
|
495
|
+
? `extension-error:extension "${name}" is in both extensions: and exclude_extensions: for agent "${type}" — exclude wins`
|
|
496
|
+
: `extension-error:extension "${name}" requested by agent "${type}" was not loaded`,
|
|
437
497
|
});
|
|
438
498
|
}
|
|
439
499
|
}
|
|
@@ -441,7 +501,7 @@ export async function runAgent(
|
|
|
441
501
|
if (!survivingNames.has(name)) {
|
|
442
502
|
options.onToolActivity?.({
|
|
443
503
|
type: "end",
|
|
444
|
-
toolName: `extension-error:ext:${name} referenced by agent "${type}" but extension "${name}" is not loaded (
|
|
504
|
+
toolName: `extension-error:ext:${name} referenced by agent "${type}" but extension "${name}" is not loaded (check extensions:/exclude_extensions:)`,
|
|
445
505
|
});
|
|
446
506
|
}
|
|
447
507
|
}
|
|
@@ -499,7 +559,7 @@ export async function runAgent(
|
|
|
499
559
|
cwd: effectiveCwd,
|
|
500
560
|
agentDir,
|
|
501
561
|
sessionManager: SessionManager.inMemory(effectiveCwd),
|
|
502
|
-
settingsManager: SettingsManager.create(
|
|
562
|
+
settingsManager: SettingsManager.create(configCwd, agentDir),
|
|
503
563
|
modelRegistry: ctx.modelRegistry,
|
|
504
564
|
model,
|
|
505
565
|
tools: allowedTools,
|
package/src/agent-types.ts
CHANGED
|
@@ -144,6 +144,7 @@ export function getConfig(type: string): {
|
|
|
144
144
|
description: string;
|
|
145
145
|
builtinToolNames: string[];
|
|
146
146
|
extensions: true | string[] | false;
|
|
147
|
+
excludeExtensions?: string[];
|
|
147
148
|
skills: true | string[] | false;
|
|
148
149
|
promptMode: "replace" | "append";
|
|
149
150
|
} {
|
|
@@ -155,6 +156,7 @@ export function getConfig(type: string): {
|
|
|
155
156
|
description: config.description,
|
|
156
157
|
builtinToolNames: config.builtinToolNames ?? BUILTIN_TOOL_NAMES,
|
|
157
158
|
extensions: config.extensions,
|
|
159
|
+
excludeExtensions: config.excludeExtensions,
|
|
158
160
|
skills: config.skills,
|
|
159
161
|
promptMode: config.promptMode,
|
|
160
162
|
};
|
|
@@ -168,6 +170,7 @@ export function getConfig(type: string): {
|
|
|
168
170
|
description: gp.description,
|
|
169
171
|
builtinToolNames: gp.builtinToolNames ?? BUILTIN_TOOL_NAMES,
|
|
170
172
|
extensions: gp.extensions,
|
|
173
|
+
excludeExtensions: gp.excludeExtensions,
|
|
171
174
|
skills: gp.skills,
|
|
172
175
|
promptMode: gp.promptMode,
|
|
173
176
|
};
|
package/src/custom-agents.ts
CHANGED
|
@@ -60,6 +60,7 @@ function loadFromDir(dir: string, agents: Map<string, AgentConfig>, source: "pro
|
|
|
60
60
|
extSelectors,
|
|
61
61
|
disallowedTools: csvListOptional(fm.disallowed_tools),
|
|
62
62
|
extensions: inheritField(fm.extensions ?? fm.inherit_extensions),
|
|
63
|
+
excludeExtensions: csvListOptional(fm.exclude_extensions),
|
|
63
64
|
skills: inheritField(fm.skills ?? fm.inherit_skills),
|
|
64
65
|
model: str(fm.model),
|
|
65
66
|
thinking: str(fm.thinking) as ThinkingLevel | undefined,
|
package/src/index.ts
CHANGED
|
@@ -27,7 +27,7 @@ import { type ModelRegistry, resolveModel } from "./model-resolver.js";
|
|
|
27
27
|
import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./output-file.js";
|
|
28
28
|
import { SubagentScheduler } from "./schedule.js";
|
|
29
29
|
import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
|
|
30
|
-
import { applyAndEmitLoaded, type SubagentsSettings, saveAndEmitChanged } from "./settings.js";
|
|
30
|
+
import { applyAndEmitLoaded, type SubagentsSettings, saveAndEmitChanged, type ToolDescriptionMode } from "./settings.js";
|
|
31
31
|
import { getStatusNote } from "./status-note.js";
|
|
32
32
|
import { type AgentConfig, type AgentInvocation, type AgentRecord, type JoinMode, type NotificationDetails, type SubagentType } from "./types.js";
|
|
33
33
|
import {
|
|
@@ -533,6 +533,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
533
533
|
reloadCustomAgents(); // re-register with new setting
|
|
534
534
|
}
|
|
535
535
|
|
|
536
|
+
// ---- Agent tool description mode ----
|
|
537
|
+
// "full" (default) keeps the rich Claude Code-style description; "compact"
|
|
538
|
+
// swaps in a ~75% smaller one for small/local models (#91). Read once at
|
|
539
|
+
// tool registration — flipping it applies on the next pi session.
|
|
540
|
+
let toolDescriptionMode: ToolDescriptionMode = "full";
|
|
541
|
+
function getToolDescriptionMode(): ToolDescriptionMode { return toolDescriptionMode; }
|
|
542
|
+
function setToolDescriptionMode(mode: ToolDescriptionMode): void { toolDescriptionMode = mode; }
|
|
543
|
+
|
|
536
544
|
// ---- Batch tracking for smart join mode ----
|
|
537
545
|
// Collects background agent IDs spawned in the current turn for smart grouping.
|
|
538
546
|
// Uses a debounced timer: each new agent resets the 100ms window so that all
|
|
@@ -604,6 +612,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
604
612
|
}).join("\n");
|
|
605
613
|
};
|
|
606
614
|
|
|
615
|
+
/** First sentence of an agent description — for the compact type list. */
|
|
616
|
+
const firstSentence = (text: string): string => {
|
|
617
|
+
const match = text.match(/^.*?[.!?](?=\s|$)/s);
|
|
618
|
+
return (match ? match[0] : text).replace(/\s+/g, " ").trim();
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
/** Compact type list: one line per agent, first sentence only. */
|
|
622
|
+
const buildCompactTypeListText = () =>
|
|
623
|
+
getAvailableTypes().map((name) => {
|
|
624
|
+
const cfg = getAgentConfig(name);
|
|
625
|
+
return `- ${name}: ${firstSentence(cfg?.description ?? name)} (Tools: ${formatToolsSuffix(cfg)})`;
|
|
626
|
+
}).join("\n");
|
|
627
|
+
|
|
607
628
|
/** Derive a short model label from a model string. */
|
|
608
629
|
function getModelLabelFromConfig(model: string): string {
|
|
609
630
|
// Strip provider prefix (e.g. "anthropic/claude-sonnet-4-6" → "claude-sonnet-4-6")
|
|
@@ -624,6 +645,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
624
645
|
setSchedulingEnabled,
|
|
625
646
|
setScopeModels: setScopeModelsEnabled,
|
|
626
647
|
setDisableDefaultAgents: setDisableDefaultAgents,
|
|
648
|
+
setToolDescriptionMode: setToolDescriptionMode,
|
|
627
649
|
},
|
|
628
650
|
(event, payload) => pi.events.emit(event, payload),
|
|
629
651
|
);
|
|
@@ -652,10 +674,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
652
674
|
? `\n- Use \`schedule\` only when the user explicitly asked for scheduled / recurring / delayed execution (e.g. "every Monday", "in an hour"). Don't auto-schedule from vague intent like "monitor X" — run once now or ask.`
|
|
653
675
|
: "";
|
|
654
676
|
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
677
|
+
// Compact Agent tool description (#91, `toolDescriptionMode: "compact"`) —
|
|
678
|
+
// the same load-bearing facts as the full version at ~75% fewer tokens, for
|
|
679
|
+
// small/local models. Per-option details live in the param descriptions.
|
|
680
|
+
const compactAgentToolDescription = `Launch an autonomous agent for complex, multi-step tasks. Agent types:
|
|
681
|
+
${buildCompactTypeListText()}
|
|
682
|
+
|
|
683
|
+
Custom agents: .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.md (global).
|
|
684
|
+
|
|
685
|
+
Notes:
|
|
686
|
+
- description: 3-5 words (shown in UI). Prompts must be self-contained — the agent has not seen this conversation.
|
|
687
|
+
- Parallel work: one message, multiple Agent calls, run_in_background: true on each. You are notified when background agents finish — never poll or sleep.
|
|
688
|
+
- The result is not shown to the user — summarize it for them. Verify an agent's claimed code changes before reporting work done.
|
|
689
|
+
- resume continues a previous agent by ID; steer_subagent messages a running one.
|
|
690
|
+
- isolation: "worktree" runs the agent in an isolated git worktree; changes land on a branch.`;
|
|
691
|
+
|
|
692
|
+
const fullAgentToolDescription = `Launch a new agent to handle complex, multi-step tasks autonomously. Each agent type has specific capabilities and tools available to it.
|
|
659
693
|
|
|
660
694
|
Available agent types and the tools they have access to:
|
|
661
695
|
${buildTypeListText()}
|
|
@@ -696,7 +730,59 @@ Provide clear, detailed prompts so the agent can work autonomously. Brief it lik
|
|
|
696
730
|
|
|
697
731
|
Terse command-style prompts produce shallow, generic work.
|
|
698
732
|
|
|
699
|
-
**Never delegate understanding.** Don't write "based on your findings, fix the bug" or "based on the research, implement it." Those phrases push synthesis onto the agent instead of doing it yourself. Write prompts that prove you understood: include file paths, line numbers, what specifically to change
|
|
733
|
+
**Never delegate understanding.** Don't write "based on your findings, fix the bug" or "based on the research, implement it." Those phrases push synthesis onto the agent instead of doing it yourself. Write prompts that prove you understood: include file paths, line numbers, what specifically to change.`;
|
|
734
|
+
|
|
735
|
+
// `toolDescriptionMode: "custom"` — user-authored description with live
|
|
736
|
+
// dynamic parts. Project file wins over global; missing/empty falls back to
|
|
737
|
+
// "full" (a stale fallback beats a blank tool description). Only the prose
|
|
738
|
+
// is customizable — the parameter schema stays code-owned.
|
|
739
|
+
const renderToolDescriptionTemplate = (template: string): string => {
|
|
740
|
+
const vars: Record<string, () => string> = {
|
|
741
|
+
typeList: buildTypeListText,
|
|
742
|
+
compactTypeList: buildCompactTypeListText,
|
|
743
|
+
agentDir: getAgentDir,
|
|
744
|
+
scheduleGuideline: () => scheduleGuideline,
|
|
745
|
+
};
|
|
746
|
+
// Replacement callback (not a string) — agent descriptions may contain `$&` etc.
|
|
747
|
+
return template.replace(/\{\{(\w+)\}\}/g, (raw, name: string) => {
|
|
748
|
+
if (vars[name]) return vars[name]();
|
|
749
|
+
console.warn(`[pi-subagents] agent-tool-description.md: unknown placeholder ${raw} left as-is`);
|
|
750
|
+
return raw;
|
|
751
|
+
});
|
|
752
|
+
};
|
|
753
|
+
|
|
754
|
+
const loadCustomToolDescription = (): string | undefined => {
|
|
755
|
+
for (const path of [
|
|
756
|
+
join(process.cwd(), ".pi", "agent-tool-description.md"),
|
|
757
|
+
join(getAgentDir(), "agent-tool-description.md"),
|
|
758
|
+
]) {
|
|
759
|
+
try {
|
|
760
|
+
if (!existsSync(path)) continue;
|
|
761
|
+
const text = readFileSync(path, "utf-8").trim();
|
|
762
|
+
if (text) return renderToolDescriptionTemplate(text);
|
|
763
|
+
console.warn(`[pi-subagents] ${path} is empty — ignoring`);
|
|
764
|
+
} catch (err) {
|
|
765
|
+
console.warn(`[pi-subagents] failed to read ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
return undefined;
|
|
769
|
+
};
|
|
770
|
+
|
|
771
|
+
const agentToolDescription = (() => {
|
|
772
|
+
const mode = getToolDescriptionMode();
|
|
773
|
+
if (mode === "compact") return compactAgentToolDescription;
|
|
774
|
+
if (mode === "custom") {
|
|
775
|
+
const custom = loadCustomToolDescription();
|
|
776
|
+
if (custom) return custom;
|
|
777
|
+
console.warn('[pi-subagents] toolDescriptionMode is "custom" but no agent-tool-description.md found — using "full"');
|
|
778
|
+
}
|
|
779
|
+
return fullAgentToolDescription;
|
|
780
|
+
})();
|
|
781
|
+
|
|
782
|
+
pi.registerTool(defineTool({
|
|
783
|
+
name: SUBAGENT_TOOL_NAMES.AGENT,
|
|
784
|
+
label: "Agent",
|
|
785
|
+
description: agentToolDescription,
|
|
700
786
|
promptSnippet: "Launch autonomous sub-agents for complex multi-step tasks",
|
|
701
787
|
promptGuidelines: [
|
|
702
788
|
"Use Agent with specialized agents when the task matches an agent type's description. Subagents are valuable for parallelizing independent queries or for protecting the main context window from excessive results, but should not be used excessively when not needed. Importantly, avoid duplicating work that subagents are already doing — if you delegate research to a subagent, do not also perform the same searches yourself.",
|
|
@@ -1494,12 +1580,12 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1494
1580
|
const activity = agentActivity.get(record.id);
|
|
1495
1581
|
|
|
1496
1582
|
await ctx.ui.custom<undefined>(
|
|
1497
|
-
(tui, theme,
|
|
1583
|
+
(tui, theme, keybindings, done) => {
|
|
1498
1584
|
return new ConversationViewer(tui, session, record, activity, theme, done, () => {
|
|
1499
1585
|
if (manager.abort(record.id)) {
|
|
1500
1586
|
ctx.ui.notify(`Stopped "${record.description}".`, "info");
|
|
1501
1587
|
}
|
|
1502
|
-
});
|
|
1588
|
+
}, keybindings);
|
|
1503
1589
|
},
|
|
1504
1590
|
{
|
|
1505
1591
|
overlay: true,
|
|
@@ -1601,6 +1687,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1601
1687
|
fmFields.push(`prompt_mode: ${cfg.promptMode}`);
|
|
1602
1688
|
if (cfg.extensions === false) fmFields.push("extensions: false");
|
|
1603
1689
|
else if (Array.isArray(cfg.extensions)) fmFields.push(`extensions: ${cfg.extensions.join(", ")}`);
|
|
1690
|
+
if (cfg.excludeExtensions?.length) fmFields.push(`exclude_extensions: ${cfg.excludeExtensions.join(", ")}`);
|
|
1604
1691
|
if (cfg.skills === false) fmFields.push("skills: false");
|
|
1605
1692
|
else if (Array.isArray(cfg.skills)) fmFields.push(`skills: ${cfg.skills.join(", ")}`);
|
|
1606
1693
|
if (cfg.disallowedTools?.length) fmFields.push(`disallowed_tools: ${cfg.disallowedTools.join(", ")}`);
|
|
@@ -1869,6 +1956,7 @@ ${systemPrompt}
|
|
|
1869
1956
|
schedulingEnabled: isSchedulingEnabled(),
|
|
1870
1957
|
scopeModels: isScopeModelsEnabled(),
|
|
1871
1958
|
disableDefaultAgents: isDefaultsDisabled(),
|
|
1959
|
+
toolDescriptionMode: getToolDescriptionMode(),
|
|
1872
1960
|
};
|
|
1873
1961
|
}
|
|
1874
1962
|
|
|
@@ -1930,6 +2018,13 @@ ${systemPrompt}
|
|
|
1930
2018
|
currentValue: isDefaultsDisabled() ? "on" : "off",
|
|
1931
2019
|
values: ["on", "off"],
|
|
1932
2020
|
},
|
|
2021
|
+
{
|
|
2022
|
+
id: "toolDescriptionMode",
|
|
2023
|
+
label: "Tool description",
|
|
2024
|
+
description: "Agent tool description sent to the LLM: full (rich, default), compact (~75% fewer tokens, for small/local models), or custom (.pi/agent-tool-description.md with {{placeholders}})",
|
|
2025
|
+
currentValue: getToolDescriptionMode(),
|
|
2026
|
+
values: ["full", "compact", "custom"],
|
|
2027
|
+
},
|
|
1933
2028
|
];
|
|
1934
2029
|
}
|
|
1935
2030
|
|
|
@@ -1978,6 +2073,9 @@ ${systemPrompt}
|
|
|
1978
2073
|
const enabled = value === "on";
|
|
1979
2074
|
setDisableDefaultAgents(enabled);
|
|
1980
2075
|
notifyApplied(ctx, `Default agents ${enabled ? "disabled" : "enabled"}. Tool spec change takes effect on next pi session.`);
|
|
2076
|
+
} else if (id === "toolDescriptionMode") {
|
|
2077
|
+
setToolDescriptionMode(value as ToolDescriptionMode);
|
|
2078
|
+
notifyApplied(ctx, `Tool description set to ${value}. Takes effect on next pi session.`);
|
|
1981
2079
|
}
|
|
1982
2080
|
}
|
|
1983
2081
|
|
package/src/settings.ts
CHANGED
|
@@ -55,8 +55,21 @@ export interface SubagentsSettings {
|
|
|
55
55
|
* Defaults to false.
|
|
56
56
|
*/
|
|
57
57
|
disableDefaultAgents?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Which Agent tool description the LLM sees. "full" (default) is the rich
|
|
60
|
+
* Claude Code-style prompt; "compact" is a ~75% smaller version (one-line
|
|
61
|
+
* agent type list, terse usage notes) for small/local models where tool-spec
|
|
62
|
+
* tokens are expensive; "custom" reads `.pi/agent-tool-description.md`
|
|
63
|
+
* (project, falling back to `<agentDir>/agent-tool-description.md`) with
|
|
64
|
+
* `{{placeholder}}` substitution — a missing/empty file falls back to "full".
|
|
65
|
+
* The mode is read once at tool registration — changing it applies on the
|
|
66
|
+
* next pi session.
|
|
67
|
+
*/
|
|
68
|
+
toolDescriptionMode?: ToolDescriptionMode;
|
|
58
69
|
}
|
|
59
70
|
|
|
71
|
+
export type ToolDescriptionMode = "full" | "compact" | "custom";
|
|
72
|
+
|
|
60
73
|
/** Setter hooks used by applySettings to wire persisted values into in-memory state. */
|
|
61
74
|
export interface SettingsAppliers {
|
|
62
75
|
setMaxConcurrent: (n: number) => void;
|
|
@@ -66,12 +79,14 @@ export interface SettingsAppliers {
|
|
|
66
79
|
setSchedulingEnabled: (b: boolean) => void;
|
|
67
80
|
setScopeModels: (enabled: boolean) => void;
|
|
68
81
|
setDisableDefaultAgents: (b: boolean) => void;
|
|
82
|
+
setToolDescriptionMode: (mode: ToolDescriptionMode) => void;
|
|
69
83
|
}
|
|
70
84
|
|
|
71
85
|
/** Emit callback — a subset of `pi.events.emit` to keep helpers testable. */
|
|
72
86
|
export type SettingsEmit = (event: string, payload: unknown) => void;
|
|
73
87
|
|
|
74
88
|
const VALID_JOIN_MODES: ReadonlySet<string> = new Set<JoinMode>(["async", "group", "smart"]);
|
|
89
|
+
const VALID_TOOL_DESCRIPTION_MODES: ReadonlySet<string> = new Set<ToolDescriptionMode>(["full", "compact", "custom"]);
|
|
75
90
|
|
|
76
91
|
// Sanity ceilings — prevent hand-edited configs from asking for values that
|
|
77
92
|
// make no operational sense (e.g. 1e6 concurrent subagents). Permissive enough
|
|
@@ -118,6 +133,9 @@ function sanitize(raw: unknown): SubagentsSettings {
|
|
|
118
133
|
if (typeof r.disableDefaultAgents === "boolean") {
|
|
119
134
|
out.disableDefaultAgents = r.disableDefaultAgents;
|
|
120
135
|
}
|
|
136
|
+
if (typeof r.toolDescriptionMode === "string" && VALID_TOOL_DESCRIPTION_MODES.has(r.toolDescriptionMode)) {
|
|
137
|
+
out.toolDescriptionMode = r.toolDescriptionMode as ToolDescriptionMode;
|
|
138
|
+
}
|
|
121
139
|
return out;
|
|
122
140
|
}
|
|
123
141
|
|
|
@@ -175,6 +193,7 @@ export function applySettings(s: SubagentsSettings, appliers: SettingsAppliers):
|
|
|
175
193
|
if (typeof s.schedulingEnabled === "boolean") appliers.setSchedulingEnabled(s.schedulingEnabled);
|
|
176
194
|
if (typeof s.scopeModels === "boolean") appliers.setScopeModels(s.scopeModels);
|
|
177
195
|
if (typeof s.disableDefaultAgents === "boolean") appliers.setDisableDefaultAgents(s.disableDefaultAgents);
|
|
196
|
+
if (s.toolDescriptionMode) appliers.setToolDescriptionMode(s.toolDescriptionMode);
|
|
178
197
|
}
|
|
179
198
|
|
|
180
199
|
/**
|
package/src/types.ts
CHANGED
|
@@ -33,6 +33,9 @@ export interface AgentConfig {
|
|
|
33
33
|
disallowedTools?: string[];
|
|
34
34
|
/** true = inherit all, string[] = only listed, false = none */
|
|
35
35
|
extensions: true | string[] | false;
|
|
36
|
+
/** Extension-name denylist applied after the `extensions:` include set. Exclude wins.
|
|
37
|
+
* Plain canonical names only (case-insensitive); no paths, no wildcard. */
|
|
38
|
+
excludeExtensions?: string[];
|
|
36
39
|
/** true = inherit all, string[] = only listed, false = none */
|
|
37
40
|
skills: true | string[] | false;
|
|
38
41
|
model?: string;
|
|
@@ -80,7 +83,7 @@ export interface AgentRecord {
|
|
|
80
83
|
/** Steering messages queued before the session was ready. */
|
|
81
84
|
pendingSteers?: string[];
|
|
82
85
|
/** Worktree info if the agent is running in an isolated worktree. */
|
|
83
|
-
worktree?: { path: string; branch: string; baseSha: string };
|
|
86
|
+
worktree?: { path: string; branch: string; baseSha: string; workPath: string };
|
|
84
87
|
/** Worktree cleanup result after agent completion. */
|
|
85
88
|
worktreeResult?: { hasChanges: boolean; branch?: string };
|
|
86
89
|
/** The tool_use_id from the original Agent tool call. */
|