@ferris1225/pi-subagents 4.2.5 → 4.2.8
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 +375 -346
- package/agents/executor.md +53 -53
- package/package.json +3 -5
- package/src/agents.ts +237 -237
- package/src/announcements.ts +81 -78
- package/src/dispatch.ts +615 -541
- package/src/durable.ts +517 -510
- package/src/format.ts +181 -165
- package/src/index.ts +102 -100
- package/src/monitor.ts +1 -1
- package/src/prompt.ts +69 -69
- package/src/rpc-run.ts +987 -993
- package/src/runtime.ts +348 -312
- package/src/status.ts +66 -0
- package/src/thread-lifecycle.ts +1341 -1324
- package/src/tools.ts +384 -384
- package/src/widget.ts +268 -266
- package/src/worktree.ts +974 -943
package/agents/executor.md
CHANGED
|
@@ -1,53 +1,53 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: executor
|
|
3
|
-
description:
|
|
4
|
-
thinking: high
|
|
5
|
-
# No `tools` field => inherits all tools (full capability).
|
|
6
|
-
---
|
|
7
|
-
|
|
8
|
-
You are an executor agent with full capabilities in an isolated context window. You own one delegated, self-contained task end to end so the main conversation stays clean. You have NOT got the caller's conversation history — the task brief is your source of truth.
|
|
9
|
-
|
|
10
|
-
Repository instructions (AGENTS.md) and any skills available in this session apply to you as to any agent: follow their process for the domains they own (language style, tests, debugging, cleanup discipline, verification). Where a skill covers the same ground as this brief, the skill's discipline wins — except for the release boundary below, which always wins.
|
|
11
|
-
|
|
12
|
-
## Procedure
|
|
13
|
-
|
|
14
|
-
1. **Context.** Read the brief fully, plus referenced files and images, before acting. If critical context is missing, state what is missing rather than guessing.
|
|
15
|
-
2. **Plan.** Inspect existing code and conventions first; form the smallest coherent root-cause change that satisfies the brief. Prefer the design that deletes complexity over one that rearranges it. No unrelated refactors or standalone docs work unless the brief asks.
|
|
16
|
-
3. **Implement.** Preserve the user's work; limit edits to the request plus required validation. Follow the project's error handling, naming, and style. Synchronize README/docs/comments your change directly affects; never defer that drift.
|
|
17
|
-
4. **Verify.** Run the project's format/build/tests when they exist. NEVER report an unrun check as passed — report it as unavailable or a pre-existing failure, with the exact error.
|
|
18
|
-
|
|
19
|
-
##
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
## Boundaries
|
|
28
|
-
|
|
29
|
-
- Never commit, push, publish, tag, release, or bump a package version — the caller owns every release action, even when repository instructions normally automate release after green checks.
|
|
30
|
-
- Children are leaf processes: you cannot dispatch sub-agents.
|
|
31
|
-
- Never change runtime behavior to make documentation true; report the defect instead.
|
|
32
|
-
|
|
33
|
-
## Output format
|
|
34
|
-
|
|
35
|
-
Return only the concrete outcome. Do not repeat the task brief, the plan, the root-cause investigation, or the tool chronology.
|
|
36
|
-
|
|
37
|
-
## Completed
|
|
38
|
-
|
|
39
|
-
What was done, in a few lines.
|
|
40
|
-
|
|
41
|
-
## Files Changed
|
|
42
|
-
|
|
43
|
-
- `path/to/file.ts` — what changed.
|
|
44
|
-
|
|
45
|
-
## Verification
|
|
46
|
-
|
|
47
|
-
Which checks you ACTUALLY ran and their result (e.g. `tsc --noEmit` clean; `vitest` 12 passed). State explicitly anything you could not run and why.
|
|
48
|
-
|
|
49
|
-
## Notes (only when material)
|
|
50
|
-
|
|
51
|
-
Unresolved blockers, rejected requirements, or decisions the caller must know. Omit when nothing actionable.
|
|
52
|
-
|
|
53
|
-
Keep the final response comfortably below the 40-line delivery cap unless the result genuinely requires more.
|
|
1
|
+
---
|
|
2
|
+
name: executor
|
|
3
|
+
description: A self-contained unit that changes the repository or condenses inputs — implement, fix, refactor, test, clean up, sync docs, merge fan-out results — carried through verification to a result-only handoff.
|
|
4
|
+
thinking: high
|
|
5
|
+
# No `tools` field => inherits all tools (full capability).
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
You are an executor agent with full capabilities in an isolated context window. You own one delegated, self-contained task end to end so the main conversation stays clean. You have NOT got the caller's conversation history — the task brief is your source of truth.
|
|
9
|
+
|
|
10
|
+
Repository instructions (AGENTS.md) and any skills available in this session apply to you as to any agent: follow their process for the domains they own (language style, tests, debugging, cleanup discipline, verification). Where a skill covers the same ground as this brief, the skill's discipline wins — except for the release boundary below, which always wins.
|
|
11
|
+
|
|
12
|
+
## Procedure
|
|
13
|
+
|
|
14
|
+
1. **Context.** Read the brief fully, plus referenced files and images, before acting. If critical context is missing, state what is missing rather than guessing.
|
|
15
|
+
2. **Plan.** Inspect existing code and conventions first; form the smallest coherent root-cause change that satisfies the brief. Prefer the design that deletes complexity over one that rearranges it. No unrelated refactors or standalone docs work unless the brief asks.
|
|
16
|
+
3. **Implement.** Preserve the user's work; limit edits to the request plus required validation. Follow the project's error handling, naming, and style. Synchronize README/docs/comments your change directly affects; never defer that drift.
|
|
17
|
+
4. **Verify.** Run the project's format/build/tests when they exist. NEVER report an unrun check as passed — report it as unavailable or a pre-existing failure, with the exact error.
|
|
18
|
+
|
|
19
|
+
## Conditional playbooks
|
|
20
|
+
|
|
21
|
+
Skip both unless the brief matches one.
|
|
22
|
+
|
|
23
|
+
**Cleanup** (dead code, duplication, simplification): a candidate is not a deletion. Re-read the load-bearing files and repeat the decisive searches yourself — never inherit proof from another agent's report — and search the whole repository for consumers first. Keep a candidate when a real consumer exists, dynamic reachability is unresolved, or the cut removes a user capability, public API, persisted format, or compatibility path the brief did not explicitly approve. Finding no safe cut and making zero edits is valid.
|
|
24
|
+
|
|
25
|
+
**Merging inputs** (result artifacts, reports, logs): read every named input fully before writing, deduplicate restatements into one attributed entry, and report surviving conflicts side by side instead of averaging them away. Stay within the named inputs; report what they cannot answer as a gap.
|
|
26
|
+
|
|
27
|
+
## Boundaries
|
|
28
|
+
|
|
29
|
+
- Never commit, push, publish, tag, release, or bump a package version — the caller owns every release action, even when repository instructions normally automate release after green checks.
|
|
30
|
+
- Children are leaf processes: you cannot dispatch sub-agents.
|
|
31
|
+
- Never change runtime behavior to make documentation true; report the defect instead.
|
|
32
|
+
|
|
33
|
+
## Output format
|
|
34
|
+
|
|
35
|
+
Return only the concrete outcome. Do not repeat the task brief, the plan, the root-cause investigation, or the tool chronology.
|
|
36
|
+
|
|
37
|
+
## Completed
|
|
38
|
+
|
|
39
|
+
What was done, in a few lines.
|
|
40
|
+
|
|
41
|
+
## Files Changed
|
|
42
|
+
|
|
43
|
+
- `path/to/file.ts` — what changed.
|
|
44
|
+
|
|
45
|
+
## Verification
|
|
46
|
+
|
|
47
|
+
Which checks you ACTUALLY ran and their result (e.g. `tsc --noEmit` clean; `vitest` 12 passed). State explicitly anything you could not run and why.
|
|
48
|
+
|
|
49
|
+
## Notes (only when material)
|
|
50
|
+
|
|
51
|
+
Unresolved blockers, rejected requirements, or decisions the caller must know. Omit when nothing actionable.
|
|
52
|
+
|
|
53
|
+
Keep the final response comfortably below the 40-line delivery cap unless the result genuinely requires more.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ferris1225/pi-subagents",
|
|
3
|
-
"version": "4.2.
|
|
3
|
+
"version": "4.2.8",
|
|
4
4
|
"description": "A managed sub-agent team for pi: specialized roles, pre-commit documentation sync, retained threads, auto-fix chains, model fallback, and Git worktree isolation.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -29,8 +29,7 @@
|
|
|
29
29
|
},
|
|
30
30
|
"scripts": {
|
|
31
31
|
"check": "tsc --noEmit",
|
|
32
|
-
"
|
|
33
|
-
"prepack": "npm run check && npm test"
|
|
32
|
+
"prepack": "npm run check"
|
|
34
33
|
},
|
|
35
34
|
"peerDependencies": {
|
|
36
35
|
"@earendil-works/pi-agent-core": ">=0.84.4",
|
|
@@ -46,8 +45,7 @@
|
|
|
46
45
|
"@earendil-works/pi-tui": "^0.84.4",
|
|
47
46
|
"@types/node": "^22.10.0",
|
|
48
47
|
"typebox": "^1.3.9",
|
|
49
|
-
"typescript": "^5.9.0"
|
|
50
|
-
"vitest": "^4.1.0"
|
|
48
|
+
"typescript": "^5.9.0"
|
|
51
49
|
},
|
|
52
50
|
"engines": {
|
|
53
51
|
"node": ">=22.19.0"
|
package/src/agents.ts
CHANGED
|
@@ -1,237 +1,237 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Agent discovery.
|
|
3
|
-
*
|
|
4
|
-
* Agents are Markdown files (YAML frontmatter + body-as-system-prompt) loaded from
|
|
5
|
-
* three scopes with override priority builtin < user < project (same `name` wins
|
|
6
|
-
* at the higher scope). Discovery is re-run on every invocation so editing a file or
|
|
7
|
-
* dropping a new one takes effect mid-session without a reload.
|
|
8
|
-
*
|
|
9
|
-
* builtin : <package>/agents (shipped with this extension)
|
|
10
|
-
* user : <agentDir>/agents (~/.pi/agent/agents)
|
|
11
|
-
* project : <cwd...>/.pi/agents (nearest, walking up)
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { type Dirent, existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
15
|
-
import { dirname, join } from "node:path";
|
|
16
|
-
import { fileURLToPath } from "node:url";
|
|
17
|
-
import { CONFIG_DIR_NAME, getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
18
|
-
import { THINKING_LEVEL_VALUES, type AgentScope, type ThinkingLevel } from "./config.ts";
|
|
19
|
-
import type { IsolationMode } from "./worktree.ts";
|
|
20
|
-
|
|
21
|
-
export type AgentSource = "builtin" | "user" | "project";
|
|
22
|
-
|
|
23
|
-
export interface AgentConfig {
|
|
24
|
-
name: string;
|
|
25
|
-
description: string;
|
|
26
|
-
tools?: string[];
|
|
27
|
-
/** Model ref this run was routed to; filled in by dispatch, never declared by the agent file. */
|
|
28
|
-
model?: string;
|
|
29
|
-
/** Per-agent default thinking strength (frontmatter `thinking`); config override wins. */
|
|
30
|
-
thinking?: ThinkingLevel;
|
|
31
|
-
/** Role-declared default isolation (frontmatter `isolation`); an explicit
|
|
32
|
-
* per-call request wins, and `worktree` applies to write-capable roles only. */
|
|
33
|
-
isolation?: IsolationMode;
|
|
34
|
-
systemPrompt: string;
|
|
35
|
-
source: AgentSource;
|
|
36
|
-
filePath: string;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const SHELL_TOOL_NAMES = new Set(["bash", "powershell"]);
|
|
40
|
-
/** The shell that actually fits the host: PowerShell on Windows, Bash elsewhere.
|
|
41
|
-
* Only used to break a tie when the parent has both enabled — a parent running a
|
|
42
|
-
* single shell is followed as configured, whatever it is. */
|
|
43
|
-
const NATIVE_SHELL_TOOL = process.platform === "win32" ? "powershell" : "bash";
|
|
44
|
-
const PI_BUILTIN_TOOL_NAMES = new Set(["read", "bash", "powershell", "edit", "write", "grep", "find", "ls"]);
|
|
45
|
-
export const SUBAGENT_TOOL_NAMES = [
|
|
46
|
-
"subagent",
|
|
47
|
-
"subagent_control",
|
|
48
|
-
"subagent_stop",
|
|
49
|
-
] as const;
|
|
50
|
-
const SUBAGENT_TOOL_NAME_SET = new Set<string>(SUBAGENT_TOOL_NAMES);
|
|
51
|
-
|
|
52
|
-
/** Resolve every child against the parent's live tool selection. Roles without
|
|
53
|
-
* an allowlist inherit the complete active set. Explicit lists keep only their
|
|
54
|
-
* declared Pi built-ins, adapt an existing shell slot, and gain active extension/
|
|
55
|
-
* SDK tools. pi-subagents controls are always removed so children stay leaves.
|
|
56
|
-
*
|
|
57
|
-
* A declared shell is one slot, so it resolves to one shell: the parent's, and
|
|
58
|
-
* the host-native one when the parent runs both. A child never inherits a shell
|
|
59
|
-
* the parent does not have — Pi's `--tools` allowlist overrides the child's own
|
|
60
|
-
* `defaultTools` setting, so naming a shell the user disabled would hand it a
|
|
61
|
-
* terminal they deliberately turned off. */
|
|
62
|
-
export function resolveAgentTools(
|
|
63
|
-
agent: AgentConfig,
|
|
64
|
-
activeToolNames: readonly string[],
|
|
65
|
-
): AgentConfig {
|
|
66
|
-
const active = [...new Set(activeToolNames)].filter((tool) => !SUBAGENT_TOOL_NAME_SET.has(tool));
|
|
67
|
-
if (!agent.tools) return { ...agent, tools: active };
|
|
68
|
-
|
|
69
|
-
const parentShellTools = active.filter((tool) => SHELL_TOOL_NAMES.has(tool));
|
|
70
|
-
const activeShellTools = parentShellTools.length > 1 && parentShellTools.includes(NATIVE_SHELL_TOOL)
|
|
71
|
-
? [NATIVE_SHELL_TOOL]
|
|
72
|
-
: parentShellTools;
|
|
73
|
-
const tools: string[] = [];
|
|
74
|
-
let shellAdapted = false;
|
|
75
|
-
for (const tool of agent.tools) {
|
|
76
|
-
if (SHELL_TOOL_NAMES.has(tool)) {
|
|
77
|
-
if (!shellAdapted) tools.push(...activeShellTools);
|
|
78
|
-
shellAdapted = true;
|
|
79
|
-
} else if (PI_BUILTIN_TOOL_NAMES.has(tool) && !tools.includes(tool)) {
|
|
80
|
-
tools.push(tool);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
for (const tool of active) {
|
|
84
|
-
if (PI_BUILTIN_TOOL_NAMES.has(tool) || tools.includes(tool)) continue;
|
|
85
|
-
tools.push(tool);
|
|
86
|
-
}
|
|
87
|
-
return { ...agent, tools };
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** Filesystem-write capability used by worktree admission and repository-lane
|
|
91
|
-
* safety. Built-in read-only role names remain read-only even when overridden;
|
|
92
|
-
* an omitted tool list inherits the parent's active set, so it counts as
|
|
93
|
-
* write-capable unless the parent itself is read-only. */
|
|
94
|
-
export function isWriteCapableAgent(
|
|
95
|
-
agent: Pick<AgentConfig, "name" | "tools">,
|
|
96
|
-
): boolean {
|
|
97
|
-
if (agent.name === "explorer") return false;
|
|
98
|
-
if (agent.name === "executor") return true;
|
|
99
|
-
if (!agent.tools) return true;
|
|
100
|
-
return agent.tools.includes("edit") || agent.tools.includes("write");
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
104
|
-
/** <package>/agents — the agents shipped with this extension. */
|
|
105
|
-
export const BUILTIN_AGENTS_DIR = join(here, "..", "agents");
|
|
106
|
-
|
|
107
|
-
/** Agents shipped with the package (used by the setup wizard for per-agent defaults). */
|
|
108
|
-
export function loadBuiltinAgents(): AgentConfig[] {
|
|
109
|
-
return loadAgentsFromDir(BUILTIN_AGENTS_DIR, "builtin");
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
|
|
113
|
-
const agents: AgentConfig[] = [];
|
|
114
|
-
if (!existsSync(dir)) return agents;
|
|
115
|
-
|
|
116
|
-
let entries: Dirent[];
|
|
117
|
-
try {
|
|
118
|
-
entries = readdirSync(dir, { withFileTypes: true });
|
|
119
|
-
} catch {
|
|
120
|
-
return agents;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
for (const entry of entries) {
|
|
124
|
-
if (!entry.name.endsWith(".md")) continue;
|
|
125
|
-
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
|
|
126
|
-
|
|
127
|
-
const filePath = join(dir, entry.name);
|
|
128
|
-
let content: string;
|
|
129
|
-
try {
|
|
130
|
-
content = readFileSync(filePath, "utf-8");
|
|
131
|
-
} catch {
|
|
132
|
-
continue;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const { frontmatter, body } = parseFrontmatter<Record<string, string>>(content);
|
|
136
|
-
// YAML values are not guaranteed strings; anything non-string is invalid for these fields.
|
|
137
|
-
const str = (value: unknown): string | undefined => (typeof value === "string" ? value : undefined);
|
|
138
|
-
const name = str(frontmatter.name);
|
|
139
|
-
const description = str(frontmatter.description);
|
|
140
|
-
// name + description are required; skip malformed files silently.
|
|
141
|
-
if (!name || !description) continue;
|
|
142
|
-
|
|
143
|
-
const rawTools = str(frontmatter.tools);
|
|
144
|
-
const tools = rawTools
|
|
145
|
-
?.split(",")
|
|
146
|
-
.map((t) => t.trim())
|
|
147
|
-
.filter(Boolean);
|
|
148
|
-
const rawThinking = str(frontmatter.thinking)?.trim();
|
|
149
|
-
const thinking = (THINKING_LEVEL_VALUES as readonly string[]).includes(rawThinking ?? "")
|
|
150
|
-
? (rawThinking as ThinkingLevel)
|
|
151
|
-
: undefined;
|
|
152
|
-
const rawIsolation = str(frontmatter.isolation)?.trim();
|
|
153
|
-
const isolation = rawIsolation === "worktree" || rawIsolation === "shared"
|
|
154
|
-
? (rawIsolation as IsolationMode)
|
|
155
|
-
: undefined;
|
|
156
|
-
|
|
157
|
-
agents.push({
|
|
158
|
-
name,
|
|
159
|
-
description,
|
|
160
|
-
tools: tools && tools.length > 0 ? tools : undefined,
|
|
161
|
-
...(thinking ? { thinking } : {}),
|
|
162
|
-
...(isolation ? { isolation } : {}),
|
|
163
|
-
systemPrompt: body,
|
|
164
|
-
source,
|
|
165
|
-
filePath,
|
|
166
|
-
});
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
return agents;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function isDirectory(p: string): boolean {
|
|
173
|
-
try {
|
|
174
|
-
return statSync(p).isDirectory();
|
|
175
|
-
} catch {
|
|
176
|
-
return false;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function findNearestProjectAgentsDir(cwd: string): string | null {
|
|
181
|
-
let currentDir = cwd;
|
|
182
|
-
while (true) {
|
|
183
|
-
const candidate = join(currentDir, CONFIG_DIR_NAME, "agents");
|
|
184
|
-
if (isDirectory(candidate)) return candidate;
|
|
185
|
-
const parentDir = dirname(currentDir);
|
|
186
|
-
if (parentDir === currentDir) return null;
|
|
187
|
-
currentDir = parentDir;
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
export interface DiscoverOptions {
|
|
192
|
-
/** Which directories to read from. Default: "user". */
|
|
193
|
-
scope?: AgentScope;
|
|
194
|
-
/** If provided, only agents whose name is listed are returned. */
|
|
195
|
-
enabledNames?: readonly string[];
|
|
196
|
-
/** Project-controlled prompts are loaded only after Pi trusts the project. */
|
|
197
|
-
projectTrusted?: boolean;
|
|
198
|
-
/** Override the built-in agents directory (used by tests). */
|
|
199
|
-
builtinDir?: string;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/**
|
|
203
|
-
* Discover agents across scopes and apply the enabled-name filter.
|
|
204
|
-
* Override priority for the same name: project > user > builtin.
|
|
205
|
-
*/
|
|
206
|
-
export function discoverAgents(cwd: string, options: DiscoverOptions = {}): { agents: AgentConfig[] } {
|
|
207
|
-
const scope = options.scope ?? "user";
|
|
208
|
-
const builtinDir = options.builtinDir ?? BUILTIN_AGENTS_DIR;
|
|
209
|
-
const projectAgentsDir = findNearestProjectAgentsDir(cwd);
|
|
210
|
-
|
|
211
|
-
const builtin = loadAgentsFromDir(builtinDir, "builtin");
|
|
212
|
-
const user = scope === "project" ? [] : loadAgentsFromDir(join(getAgentDir(), "agents"), "user");
|
|
213
|
-
const project =
|
|
214
|
-
scope === "user" || !projectAgentsDir || options.projectTrusted !== true
|
|
215
|
-
? []
|
|
216
|
-
: loadAgentsFromDir(projectAgentsDir, "project");
|
|
217
|
-
|
|
218
|
-
// Merge with override priority builtin < user < project.
|
|
219
|
-
const byName = new Map<string, AgentConfig>();
|
|
220
|
-
for (const agent of builtin) byName.set(agent.name, agent);
|
|
221
|
-
for (const agent of user) byName.set(agent.name, agent);
|
|
222
|
-
for (const agent of project) byName.set(agent.name, agent);
|
|
223
|
-
|
|
224
|
-
let agents = Array.from(byName.values());
|
|
225
|
-
|
|
226
|
-
if (options.enabledNames !== undefined) {
|
|
227
|
-
const enabled = new Set(options.enabledNames);
|
|
228
|
-
agents = agents.filter((agent) => enabled.has(agent.name));
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
return { agents };
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/** One-line catalog entry for system-prompt injection and error messages. */
|
|
235
|
-
export function formatCatalogEntry(agent: AgentConfig): string {
|
|
236
|
-
return `- ${agent.name}: ${agent.description}`;
|
|
237
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Agent discovery.
|
|
3
|
+
*
|
|
4
|
+
* Agents are Markdown files (YAML frontmatter + body-as-system-prompt) loaded from
|
|
5
|
+
* three scopes with override priority builtin < user < project (same `name` wins
|
|
6
|
+
* at the higher scope). Discovery is re-run on every invocation so editing a file or
|
|
7
|
+
* dropping a new one takes effect mid-session without a reload.
|
|
8
|
+
*
|
|
9
|
+
* builtin : <package>/agents (shipped with this extension)
|
|
10
|
+
* user : <agentDir>/agents (~/.pi/agent/agents)
|
|
11
|
+
* project : <cwd...>/.pi/agents (nearest, walking up)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { type Dirent, existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { CONFIG_DIR_NAME, getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { THINKING_LEVEL_VALUES, type AgentScope, type ThinkingLevel } from "./config.ts";
|
|
19
|
+
import type { IsolationMode } from "./worktree.ts";
|
|
20
|
+
|
|
21
|
+
export type AgentSource = "builtin" | "user" | "project";
|
|
22
|
+
|
|
23
|
+
export interface AgentConfig {
|
|
24
|
+
name: string;
|
|
25
|
+
description: string;
|
|
26
|
+
tools?: string[];
|
|
27
|
+
/** Model ref this run was routed to; filled in by dispatch, never declared by the agent file. */
|
|
28
|
+
model?: string;
|
|
29
|
+
/** Per-agent default thinking strength (frontmatter `thinking`); config override wins. */
|
|
30
|
+
thinking?: ThinkingLevel;
|
|
31
|
+
/** Role-declared default isolation (frontmatter `isolation`); an explicit
|
|
32
|
+
* per-call request wins, and `worktree` applies to write-capable roles only. */
|
|
33
|
+
isolation?: IsolationMode;
|
|
34
|
+
systemPrompt: string;
|
|
35
|
+
source: AgentSource;
|
|
36
|
+
filePath: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const SHELL_TOOL_NAMES = new Set(["bash", "powershell"]);
|
|
40
|
+
/** The shell that actually fits the host: PowerShell on Windows, Bash elsewhere.
|
|
41
|
+
* Only used to break a tie when the parent has both enabled — a parent running a
|
|
42
|
+
* single shell is followed as configured, whatever it is. */
|
|
43
|
+
const NATIVE_SHELL_TOOL = process.platform === "win32" ? "powershell" : "bash";
|
|
44
|
+
const PI_BUILTIN_TOOL_NAMES = new Set(["read", "bash", "powershell", "edit", "write", "grep", "find", "ls"]);
|
|
45
|
+
export const SUBAGENT_TOOL_NAMES = [
|
|
46
|
+
"subagent",
|
|
47
|
+
"subagent_control",
|
|
48
|
+
"subagent_stop",
|
|
49
|
+
] as const;
|
|
50
|
+
const SUBAGENT_TOOL_NAME_SET = new Set<string>(SUBAGENT_TOOL_NAMES);
|
|
51
|
+
|
|
52
|
+
/** Resolve every child against the parent's live tool selection. Roles without
|
|
53
|
+
* an allowlist inherit the complete active set. Explicit lists keep only their
|
|
54
|
+
* declared Pi built-ins, adapt an existing shell slot, and gain active extension/
|
|
55
|
+
* SDK tools. pi-subagents controls are always removed so children stay leaves.
|
|
56
|
+
*
|
|
57
|
+
* A declared shell is one slot, so it resolves to one shell: the parent's, and
|
|
58
|
+
* the host-native one when the parent runs both. A child never inherits a shell
|
|
59
|
+
* the parent does not have — Pi's `--tools` allowlist overrides the child's own
|
|
60
|
+
* `defaultTools` setting, so naming a shell the user disabled would hand it a
|
|
61
|
+
* terminal they deliberately turned off. */
|
|
62
|
+
export function resolveAgentTools(
|
|
63
|
+
agent: AgentConfig,
|
|
64
|
+
activeToolNames: readonly string[],
|
|
65
|
+
): AgentConfig {
|
|
66
|
+
const active = [...new Set(activeToolNames)].filter((tool) => !SUBAGENT_TOOL_NAME_SET.has(tool));
|
|
67
|
+
if (!agent.tools) return { ...agent, tools: active };
|
|
68
|
+
|
|
69
|
+
const parentShellTools = active.filter((tool) => SHELL_TOOL_NAMES.has(tool));
|
|
70
|
+
const activeShellTools = parentShellTools.length > 1 && parentShellTools.includes(NATIVE_SHELL_TOOL)
|
|
71
|
+
? [NATIVE_SHELL_TOOL]
|
|
72
|
+
: parentShellTools;
|
|
73
|
+
const tools: string[] = [];
|
|
74
|
+
let shellAdapted = false;
|
|
75
|
+
for (const tool of agent.tools) {
|
|
76
|
+
if (SHELL_TOOL_NAMES.has(tool)) {
|
|
77
|
+
if (!shellAdapted) tools.push(...activeShellTools);
|
|
78
|
+
shellAdapted = true;
|
|
79
|
+
} else if (PI_BUILTIN_TOOL_NAMES.has(tool) && !tools.includes(tool)) {
|
|
80
|
+
tools.push(tool);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
for (const tool of active) {
|
|
84
|
+
if (PI_BUILTIN_TOOL_NAMES.has(tool) || tools.includes(tool)) continue;
|
|
85
|
+
tools.push(tool);
|
|
86
|
+
}
|
|
87
|
+
return { ...agent, tools };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Filesystem-write capability used by worktree admission and repository-lane
|
|
91
|
+
* safety. Built-in read-only role names remain read-only even when overridden;
|
|
92
|
+
* an omitted tool list inherits the parent's active set, so it counts as
|
|
93
|
+
* write-capable unless the parent itself is read-only. */
|
|
94
|
+
export function isWriteCapableAgent(
|
|
95
|
+
agent: Pick<AgentConfig, "name" | "tools">,
|
|
96
|
+
): boolean {
|
|
97
|
+
if (agent.name === "explorer") return false;
|
|
98
|
+
if (agent.name === "executor") return true;
|
|
99
|
+
if (!agent.tools) return true;
|
|
100
|
+
return agent.tools.includes("edit") || agent.tools.includes("write");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
104
|
+
/** <package>/agents — the agents shipped with this extension. */
|
|
105
|
+
export const BUILTIN_AGENTS_DIR = join(here, "..", "agents");
|
|
106
|
+
|
|
107
|
+
/** Agents shipped with the package (used by the setup wizard for per-agent defaults). */
|
|
108
|
+
export function loadBuiltinAgents(): AgentConfig[] {
|
|
109
|
+
return loadAgentsFromDir(BUILTIN_AGENTS_DIR, "builtin");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
|
|
113
|
+
const agents: AgentConfig[] = [];
|
|
114
|
+
if (!existsSync(dir)) return agents;
|
|
115
|
+
|
|
116
|
+
let entries: Dirent[];
|
|
117
|
+
try {
|
|
118
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
119
|
+
} catch {
|
|
120
|
+
return agents;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const entry of entries) {
|
|
124
|
+
if (!entry.name.endsWith(".md")) continue;
|
|
125
|
+
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
|
|
126
|
+
|
|
127
|
+
const filePath = join(dir, entry.name);
|
|
128
|
+
let content: string;
|
|
129
|
+
try {
|
|
130
|
+
content = readFileSync(filePath, "utf-8");
|
|
131
|
+
} catch {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const { frontmatter, body } = parseFrontmatter<Record<string, string>>(content);
|
|
136
|
+
// YAML values are not guaranteed strings; anything non-string is invalid for these fields.
|
|
137
|
+
const str = (value: unknown): string | undefined => (typeof value === "string" ? value : undefined);
|
|
138
|
+
const name = str(frontmatter.name);
|
|
139
|
+
const description = str(frontmatter.description);
|
|
140
|
+
// name + description are required; skip malformed files silently.
|
|
141
|
+
if (!name || !description) continue;
|
|
142
|
+
|
|
143
|
+
const rawTools = str(frontmatter.tools);
|
|
144
|
+
const tools = rawTools
|
|
145
|
+
?.split(",")
|
|
146
|
+
.map((t) => t.trim())
|
|
147
|
+
.filter(Boolean);
|
|
148
|
+
const rawThinking = str(frontmatter.thinking)?.trim();
|
|
149
|
+
const thinking = (THINKING_LEVEL_VALUES as readonly string[]).includes(rawThinking ?? "")
|
|
150
|
+
? (rawThinking as ThinkingLevel)
|
|
151
|
+
: undefined;
|
|
152
|
+
const rawIsolation = str(frontmatter.isolation)?.trim();
|
|
153
|
+
const isolation = rawIsolation === "worktree" || rawIsolation === "shared"
|
|
154
|
+
? (rawIsolation as IsolationMode)
|
|
155
|
+
: undefined;
|
|
156
|
+
|
|
157
|
+
agents.push({
|
|
158
|
+
name,
|
|
159
|
+
description,
|
|
160
|
+
tools: tools && tools.length > 0 ? tools : undefined,
|
|
161
|
+
...(thinking ? { thinking } : {}),
|
|
162
|
+
...(isolation ? { isolation } : {}),
|
|
163
|
+
systemPrompt: body,
|
|
164
|
+
source,
|
|
165
|
+
filePath,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return agents;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function isDirectory(p: string): boolean {
|
|
173
|
+
try {
|
|
174
|
+
return statSync(p).isDirectory();
|
|
175
|
+
} catch {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function findNearestProjectAgentsDir(cwd: string): string | null {
|
|
181
|
+
let currentDir = cwd;
|
|
182
|
+
while (true) {
|
|
183
|
+
const candidate = join(currentDir, CONFIG_DIR_NAME, "agents");
|
|
184
|
+
if (isDirectory(candidate)) return candidate;
|
|
185
|
+
const parentDir = dirname(currentDir);
|
|
186
|
+
if (parentDir === currentDir) return null;
|
|
187
|
+
currentDir = parentDir;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface DiscoverOptions {
|
|
192
|
+
/** Which directories to read from. Default: "user". */
|
|
193
|
+
scope?: AgentScope;
|
|
194
|
+
/** If provided, only agents whose name is listed are returned. */
|
|
195
|
+
enabledNames?: readonly string[];
|
|
196
|
+
/** Project-controlled prompts are loaded only after Pi trusts the project. */
|
|
197
|
+
projectTrusted?: boolean;
|
|
198
|
+
/** Override the built-in agents directory (used by tests). */
|
|
199
|
+
builtinDir?: string;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Discover agents across scopes and apply the enabled-name filter.
|
|
204
|
+
* Override priority for the same name: project > user > builtin.
|
|
205
|
+
*/
|
|
206
|
+
export function discoverAgents(cwd: string, options: DiscoverOptions = {}): { agents: AgentConfig[] } {
|
|
207
|
+
const scope = options.scope ?? "user";
|
|
208
|
+
const builtinDir = options.builtinDir ?? BUILTIN_AGENTS_DIR;
|
|
209
|
+
const projectAgentsDir = findNearestProjectAgentsDir(cwd);
|
|
210
|
+
|
|
211
|
+
const builtin = loadAgentsFromDir(builtinDir, "builtin");
|
|
212
|
+
const user = scope === "project" ? [] : loadAgentsFromDir(join(getAgentDir(), "agents"), "user");
|
|
213
|
+
const project =
|
|
214
|
+
scope === "user" || !projectAgentsDir || options.projectTrusted !== true
|
|
215
|
+
? []
|
|
216
|
+
: loadAgentsFromDir(projectAgentsDir, "project");
|
|
217
|
+
|
|
218
|
+
// Merge with override priority builtin < user < project.
|
|
219
|
+
const byName = new Map<string, AgentConfig>();
|
|
220
|
+
for (const agent of builtin) byName.set(agent.name, agent);
|
|
221
|
+
for (const agent of user) byName.set(agent.name, agent);
|
|
222
|
+
for (const agent of project) byName.set(agent.name, agent);
|
|
223
|
+
|
|
224
|
+
let agents = Array.from(byName.values());
|
|
225
|
+
|
|
226
|
+
if (options.enabledNames !== undefined) {
|
|
227
|
+
const enabled = new Set(options.enabledNames);
|
|
228
|
+
agents = agents.filter((agent) => enabled.has(agent.name));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { agents };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** One-line catalog entry for system-prompt injection and error messages. */
|
|
235
|
+
export function formatCatalogEntry(agent: AgentConfig): string {
|
|
236
|
+
return `- ${agent.name}: ${agent.description}`;
|
|
237
|
+
}
|