@cr1ms0n/pi-subagent 0.8.8 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/agents.ts CHANGED
@@ -1,288 +1,282 @@
1
- import * as fs from "node:fs";
2
- import * as os from "node:os";
3
- import * as path from "node:path";
4
- import type { TaskProfile } from "./types.js";
5
- import { isThinkingLevel, type ThinkingLevel } from "./thinking.js";
6
- import { isPlausibleSchema } from "./structured.js";
7
-
8
- /**
9
- * Named agent files: reusable subagent personas discovered from the same
10
- * conventional locations the ecosystem uses for skills.
11
- *
12
- * Discovery roots (highest precedence first — same name in a higher root wins):
13
- * 1. <cwd>/.pi/agents/<name>.md project (authoritative)
14
- * 2. <cwd>/.agents/agents/<name>.md shared cross-tool workspace
15
- * 3. $PI_CODING_AGENT_DIR/agents/<name>.md global (default ~/.pi/agent/agents/)
16
- *
17
- * Format: YAML frontmatter + markdown body. The body becomes the child's
18
- * appended system prompt. Frontmatter fields use the same snake_case names as
19
- * the tool parameters they default.
20
- *
21
- * Model routing is intentionally excluded from agent files. Agent frontmatter
22
- * may still contain legacy model/fallback fields for compatibility, but the
23
- * model policy is the only source used for new spawns.
24
- */
25
-
26
- const PROFILES = ["explore", "review", "general"] as const;
27
-
28
- export interface AgentDefinition {
29
- /** Agent CLI backend this persona runs on (pi | codex | claude). */
30
- backend?: "pi" | "codex" | "claude";
31
- /** Agent name (the file name without extension). */
32
- name: string;
33
- /** One-line routing description shown to the orchestrating model. */
34
- description: string;
35
- /** Markdown body — appended to the child's system prompt. */
36
- systemPrompt?: string;
37
- /** File the definition was loaded from. */
38
- source: string;
39
- /** Which discovery root supplied it. */
40
- scope: "project" | "shared" | "global";
41
- model?: string;
42
- thinking?: ThinkingLevel;
43
- profile?: TaskProfile;
44
- tools?: string[];
45
- maxTurns?: number;
46
- maxCost?: number;
47
- timeoutMs?: number;
48
- graceTurns?: number;
49
- fallbackModels?: string[];
50
- maxRetries?: number;
51
- isolation?: "shared" | "worktree";
52
- /** JSON Schema the persona's final result must satisfy (inline JSON or @file.json). */
53
- outputSchema?: Record<string, unknown>;
54
- /**
55
- * Which agents this persona may spawn as children.
56
- * `false` disables nesting; `"*"` unrestricted; string[] is an allowlist.
57
- * Absent means unrestricted (same as `"*"`).
58
- */
59
- spawns?: false | "*" | string[];
60
- }
61
-
62
- /** Agent names are file-name-safe identifiers; anything else is skipped. */
63
-
64
- const MAX_AGENT_FILE_BYTES = 64 * 1024;
65
- const NAME_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
66
-
67
- /**
68
- * Resolve the Pi agent directory: `$PI_CODING_AGENT_DIR` (with `~` expansion)
69
- * else `~/.pi/agent`. Exported so other configuration readers (for example the
70
- * Remote Context allowlist in `context-policy.ts`) share one path convention
71
- * instead of inventing a second one.
72
- */
73
- export function piAgentDir(): string {
74
- const envDir = process.env.PI_CODING_AGENT_DIR?.trim();
75
- if (envDir) {
76
- return envDir.startsWith("~") ? path.join(os.homedir(), envDir.slice(1)) : envDir;
77
- }
78
- return path.join(os.homedir(), ".pi", "agent");
79
- }
80
-
81
- export function discoveryRoots(cwd: string): Array<{ dir: string; scope: AgentDefinition["scope"] }> {
82
- return [
83
- { dir: path.join(cwd, ".pi", "agents"), scope: "project" },
84
- { dir: path.join(cwd, ".agents", "agents"), scope: "shared" },
85
- { dir: path.join(piAgentDir(), "agents"), scope: "global" },
86
- ];
87
- }
88
-
89
- // ── Frontmatter parsing (flat YAML subset; no dependency) ───────────────────
90
-
91
- function stripQuotes(value: string): string {
92
- const trimmed = value.trim();
93
- if (trimmed.length >= 2 && ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'")))) {
94
- return trimmed.slice(1, -1);
95
- }
96
- return trimmed;
97
- }
98
-
99
- /** `[a, b]` or `a, b` → string array. */
100
- function parseList(value: string): string[] {
101
- const inner = value.trim().startsWith("[") && value.trim().endsWith("]")
102
- ? value.trim().slice(1, -1)
103
- : value;
104
- return inner.split(",").map((item) => stripQuotes(item)).filter(Boolean);
105
- }
106
-
107
- function parseNumber(value: string): number | undefined {
108
- const parsed = Number(value.trim());
109
- return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
110
- }
111
-
112
- /** Frontmatter `spawns:` — false | * | list. Invalid forms degrade to unrestricted (absent). */
113
- function parseSpawns(value: string): AgentDefinition["spawns"] | undefined {
114
- const trimmed = stripQuotes(value.trim());
115
- if (!trimmed) return undefined;
116
- const lower = trimmed.toLowerCase();
117
- if (lower === "false" || lower === "off" || lower === "none") return false;
118
- if (trimmed === "*" || lower === "true" || lower === "any") return "*";
119
- const list = parseList(trimmed).map((item) => item.toLowerCase()).filter((item) => NAME_PATTERN.test(item));
120
- // Non-empty list only; garbage becomes unrestricted (same as absent frontmatter).
121
- return list.length > 0 ? list : undefined;
122
- }
123
-
124
- /**
125
- * Shared 64KB / regular-file / readable guard for `@path` references.
126
- * Symlinks and oversized/missing files return undefined (callers degrade open).
127
- */
128
- function readGuardedRelativeFile(agentFile: string, relativePath: string): string | undefined {
129
- if (!relativePath || relativePath.includes("\0")) return undefined;
130
- const file = path.resolve(path.dirname(agentFile), relativePath);
131
- try {
132
- const stat = fs.lstatSync(file);
133
- if (stat.isSymbolicLink() || !stat.isFile() || stat.size > MAX_AGENT_FILE_BYTES) return undefined;
134
- return fs.readFileSync(file, "utf8");
135
- } catch {
136
- return undefined;
137
- }
138
- }
139
-
140
- /** `output_schema` value: inline single-line JSON, or `@relative/path.json`. */
141
- function parseSchemaValue(value: string, agentFile: string): Record<string, unknown> | undefined {
142
- let text = value.trim();
143
- if (text.startsWith("@")) {
144
- const loaded = readGuardedRelativeFile(agentFile, text.slice(1));
145
- if (loaded === undefined) return undefined;
146
- text = loaded;
147
- }
148
- try {
149
- const parsed = JSON.parse(text);
150
- return isPlausibleSchema(parsed) ? parsed : undefined;
151
- } catch {
152
- return undefined;
153
- }
154
- }
155
-
156
- /** Expand one-level `@include relative/path.md` body lines; missing/bad refs stay verbatim. */
157
- function expandIncludes(body: string, agentFile: string): string {
158
- const lines = body.split(/\r?\n/);
159
- let changed = false;
160
- for (let i = 0; i < lines.length; i++) {
161
- const match = /^\s*@include\s+(\S+)\s*$/.exec(lines[i]!);
162
- if (!match) continue;
163
- const loaded = readGuardedRelativeFile(agentFile, match[1]!);
164
- if (loaded === undefined) continue;
165
- lines[i] = loaded.replace(/\r?\n$/, "");
166
- changed = true;
167
- }
168
- return changed ? lines.join("\n") : body;
169
- }
170
-
171
- export function parseAgentFile(name: string, raw: string, source: string, scope: AgentDefinition["scope"]): AgentDefinition | undefined {
172
- let frontmatter: Record<string, string> = {};
173
- let body = raw;
174
- const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(raw);
175
- if (match) {
176
- body = raw.slice(match[0].length);
177
- for (const line of match[1]!.split(/\r?\n/)) {
178
- const separator = line.indexOf(":");
179
- if (separator <= 0 || /^\s*#/.test(line)) continue;
180
- const key = line.slice(0, separator).trim();
181
- const value = line.slice(separator + 1).trim();
182
- if (key && value) frontmatter[key] = value;
183
- }
184
- }
185
-
186
- const thinking = isThinkingLevel(frontmatter.thinking) ? frontmatter.thinking : undefined;
187
- const profile = frontmatter.profile && (PROFILES as readonly string[]).includes(frontmatter.profile)
188
- ? (frontmatter.profile as TaskProfile)
189
- : undefined;
190
- const isolation = frontmatter.isolation === "worktree" ? "worktree" as const
191
- : frontmatter.isolation === "shared" ? "shared" as const
192
- : undefined;
193
- const spawns = frontmatter.spawns !== undefined ? parseSpawns(frontmatter.spawns) : undefined;
194
- const backend = frontmatter.backend === "codex" ? "codex" as const
195
- : frontmatter.backend === "claude" ? "claude" as const
196
- : frontmatter.backend === "pi" ? "pi" as const
197
- : undefined;
198
-
199
- const expanded = expandIncludes(body, source);
200
- const systemPrompt = expanded.trim() || undefined;
201
- const definition: AgentDefinition = {
202
- name,
203
- description: stripQuotes(frontmatter.description ?? "").slice(0, 200) || name,
204
- systemPrompt,
205
- source,
206
- scope,
207
- model: frontmatter.model ? stripQuotes(frontmatter.model) : undefined,
208
- thinking,
209
- profile,
210
- tools: frontmatter.tools ? parseList(frontmatter.tools) : undefined,
211
- maxTurns: frontmatter.max_turns ? parseNumber(frontmatter.max_turns) : undefined,
212
- maxCost: frontmatter.max_cost ? parseNumber(frontmatter.max_cost) : undefined,
213
- timeoutMs: frontmatter.timeout_ms ? parseNumber(frontmatter.timeout_ms) : undefined,
214
- graceTurns: frontmatter.grace_turns ? parseNumber(frontmatter.grace_turns) : undefined,
215
- fallbackModels: frontmatter.fallback_models ? parseList(frontmatter.fallback_models) : undefined,
216
- maxRetries: frontmatter.max_retries ? parseNumber(frontmatter.max_retries) : undefined,
217
- isolation,
218
- backend,
219
- outputSchema: frontmatter.output_schema ? parseSchemaValue(frontmatter.output_schema, source) : undefined,
220
- spawns,
221
- };
222
- return definition;
223
- }
224
-
225
- // ── Discovery ────────────────────────────────────────────────────────────────
226
-
227
- /**
228
- * Discover agent definitions across the conventional roots. Synchronous by
229
- * design: it runs at session start and on tool execute, reads a handful of
230
- * small files, and failure of any root is silent (missing dirs are normal).
231
- * Symlinked agent files are skipped (matching skill-loading conservatism).
232
- */
233
- export function discoverAgents(cwd: string): Map<string, AgentDefinition> {
234
- const catalog = new Map<string, AgentDefinition>();
235
- // Iterate lowest precedence first so higher roots overwrite.
236
- for (const root of [...discoveryRoots(cwd)].reverse()) {
237
- let entries: fs.Dirent[];
238
- try {
239
- entries = fs.readdirSync(root.dir, { withFileTypes: true });
240
- } catch {
241
- continue;
242
- }
243
- for (const entry of entries) {
244
- if (!entry.isFile() || entry.isSymbolicLink() || !entry.name.endsWith(".md")) continue;
245
- const name = entry.name.slice(0, -3);
246
- if (!NAME_PATTERN.test(name)) continue;
247
- const file = path.join(root.dir, entry.name);
248
- try {
249
- const stat = fs.lstatSync(file);
250
- if (!stat.isFile() || stat.size > MAX_AGENT_FILE_BYTES) continue;
251
- const raw = fs.readFileSync(file, "utf8");
252
- const definition = parseAgentFile(name.toLowerCase(), raw, file, root.scope);
253
- if (definition) catalog.set(definition.name, definition);
254
- } catch {
255
- /* unreadable file: skip */
256
- }
257
- }
258
- }
259
- return catalog;
260
- }
261
-
262
- /** Case-insensitive lookup with a helpful error listing available names. */
263
- export function resolveAgent(
264
- catalog: Map<string, AgentDefinition>,
265
- name: string,
266
- ): { agent?: AgentDefinition; error?: string } {
267
- const agent = catalog.get(name.toLowerCase());
268
- if (agent) return { agent };
269
- const available = [...catalog.keys()].sort();
270
- return {
271
- error: available.length
272
- ? `Unknown agent "${name}". Available agents: ${available.join(", ")}`
273
- : `Unknown agent "${name}". No agent files found (define them in .pi/agents/<name>.md).`,
274
- };
275
- }
276
-
277
- /** One line per agent for the tool guidelines / status output. */
278
- export function describeCatalog(catalog: Map<string, AgentDefinition>): string[] {
279
- return [...catalog.values()]
280
- .sort((a, b) => a.name.localeCompare(b.name))
281
- .map((agent) => {
282
- const traits = [
283
- agent.profile,
284
- agent.isolation === "worktree" ? "worktree" : "",
285
- ].filter(Boolean).join(", ");
286
- return `${agent.name}: ${agent.description}${traits ? ` (${traits})` : ""}`;
287
- });
288
- }
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import type { TaskProfile } from "./types.js";
5
+ import { isThinkingLevel, type ThinkingLevel } from "./thinking.js";
6
+ import { isPlausibleSchema } from "./structured.js";
7
+
8
+ /**
9
+ * Named agent files: reusable subagent personas discovered from the same
10
+ * conventional locations the ecosystem uses for skills.
11
+ *
12
+ * Discovery roots (highest precedence first — same name in a higher root wins):
13
+ * 1. <cwd>/.pi/agents/<name>.md project (authoritative)
14
+ * 2. <cwd>/.agents/agents/<name>.md shared cross-tool workspace
15
+ * 3. $PI_CODING_AGENT_DIR/agents/<name>.md global (default ~/.pi/agent/agents/)
16
+ *
17
+ * Format: YAML frontmatter + markdown body. The body becomes the child's
18
+ * appended system prompt. Frontmatter fields use the same snake_case names as
19
+ * the tool parameters they default.
20
+ *
21
+ * Model routing is intentionally excluded from agent files. Agent frontmatter
22
+ * may still contain legacy model/fallback fields for compatibility, but the
23
+ * model policy is the only source used for new spawns.
24
+ */
25
+
26
+ const PROFILES = ["explore", "review", "general"] as const;
27
+
28
+ export interface AgentDefinition {
29
+ /** Agent CLI backend this persona runs on (pi | codex | claude). */
30
+ backend?: "pi" | "codex" | "claude";
31
+ /** Agent name (the file name without extension). */
32
+ name: string;
33
+ /** One-line routing description shown to the orchestrating model. */
34
+ description: string;
35
+ /** Markdown body — appended to the child's system prompt. */
36
+ systemPrompt?: string;
37
+ /** File the definition was loaded from. */
38
+ source: string;
39
+ /** Which discovery root supplied it. */
40
+ scope: "project" | "shared" | "global";
41
+ model?: string;
42
+ thinking?: ThinkingLevel;
43
+ profile?: TaskProfile;
44
+ tools?: string[];
45
+ maxTurns?: number;
46
+ maxCost?: number;
47
+ timeoutMs?: number;
48
+ graceTurns?: number;
49
+ fallbackModels?: string[];
50
+ maxRetries?: number;
51
+ isolation?: "shared" | "worktree";
52
+ /** JSON Schema the persona's final result must satisfy (inline JSON or @file.json). */
53
+ outputSchema?: Record<string, unknown>;
54
+ /**
55
+ * Which agents this persona may spawn as children.
56
+ * `false` disables nesting; `"*"` unrestricted; string[] is an allowlist.
57
+ * Absent means unrestricted (same as `"*"`).
58
+ */
59
+ spawns?: false | "*" | string[];
60
+ }
61
+
62
+ /** Agent names are file-name-safe identifiers; anything else is skipped. */
63
+
64
+ const MAX_AGENT_FILE_BYTES = 64 * 1024;
65
+ const NAME_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
66
+
67
+ function agentDir(): string {
68
+ const envDir = process.env.PI_CODING_AGENT_DIR?.trim();
69
+ if (envDir) {
70
+ return envDir.startsWith("~") ? path.join(os.homedir(), envDir.slice(1)) : envDir;
71
+ }
72
+ return path.join(os.homedir(), ".pi", "agent");
73
+ }
74
+
75
+ export function discoveryRoots(cwd: string): Array<{ dir: string; scope: AgentDefinition["scope"] }> {
76
+ return [
77
+ { dir: path.join(cwd, ".pi", "agents"), scope: "project" },
78
+ { dir: path.join(cwd, ".agents", "agents"), scope: "shared" },
79
+ { dir: path.join(agentDir(), "agents"), scope: "global" },
80
+ ];
81
+ }
82
+
83
+ // ── Frontmatter parsing (flat YAML subset; no dependency) ───────────────────
84
+
85
+ function stripQuotes(value: string): string {
86
+ const trimmed = value.trim();
87
+ if (trimmed.length >= 2 && ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'")))) {
88
+ return trimmed.slice(1, -1);
89
+ }
90
+ return trimmed;
91
+ }
92
+
93
+ /** `[a, b]` or `a, b` string array. */
94
+ function parseList(value: string): string[] {
95
+ const inner = value.trim().startsWith("[") && value.trim().endsWith("]")
96
+ ? value.trim().slice(1, -1)
97
+ : value;
98
+ return inner.split(",").map((item) => stripQuotes(item)).filter(Boolean);
99
+ }
100
+
101
+ function parseNumber(value: string): number | undefined {
102
+ const parsed = Number(value.trim());
103
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
104
+ }
105
+
106
+ /** Frontmatter `spawns:` — false | * | list. Invalid forms degrade to unrestricted (absent). */
107
+ function parseSpawns(value: string): AgentDefinition["spawns"] | undefined {
108
+ const trimmed = stripQuotes(value.trim());
109
+ if (!trimmed) return undefined;
110
+ const lower = trimmed.toLowerCase();
111
+ if (lower === "false" || lower === "off" || lower === "none") return false;
112
+ if (trimmed === "*" || lower === "true" || lower === "any") return "*";
113
+ const list = parseList(trimmed).map((item) => item.toLowerCase()).filter((item) => NAME_PATTERN.test(item));
114
+ // Non-empty list only; garbage becomes unrestricted (same as absent frontmatter).
115
+ return list.length > 0 ? list : undefined;
116
+ }
117
+
118
+ /**
119
+ * Shared 64KB / regular-file / readable guard for `@path` references.
120
+ * Symlinks and oversized/missing files return undefined (callers degrade open).
121
+ */
122
+ function readGuardedRelativeFile(agentFile: string, relativePath: string): string | undefined {
123
+ if (!relativePath || relativePath.includes("\0")) return undefined;
124
+ const file = path.resolve(path.dirname(agentFile), relativePath);
125
+ try {
126
+ const stat = fs.lstatSync(file);
127
+ if (stat.isSymbolicLink() || !stat.isFile() || stat.size > MAX_AGENT_FILE_BYTES) return undefined;
128
+ return fs.readFileSync(file, "utf8");
129
+ } catch {
130
+ return undefined;
131
+ }
132
+ }
133
+
134
+ /** `output_schema` value: inline single-line JSON, or `@relative/path.json`. */
135
+ function parseSchemaValue(value: string, agentFile: string): Record<string, unknown> | undefined {
136
+ let text = value.trim();
137
+ if (text.startsWith("@")) {
138
+ const loaded = readGuardedRelativeFile(agentFile, text.slice(1));
139
+ if (loaded === undefined) return undefined;
140
+ text = loaded;
141
+ }
142
+ try {
143
+ const parsed = JSON.parse(text);
144
+ return isPlausibleSchema(parsed) ? parsed : undefined;
145
+ } catch {
146
+ return undefined;
147
+ }
148
+ }
149
+
150
+ /** Expand one-level `@include relative/path.md` body lines; missing/bad refs stay verbatim. */
151
+ function expandIncludes(body: string, agentFile: string): string {
152
+ const lines = body.split(/\r?\n/);
153
+ let changed = false;
154
+ for (let i = 0; i < lines.length; i++) {
155
+ const match = /^\s*@include\s+(\S+)\s*$/.exec(lines[i]!);
156
+ if (!match) continue;
157
+ const loaded = readGuardedRelativeFile(agentFile, match[1]!);
158
+ if (loaded === undefined) continue;
159
+ lines[i] = loaded.replace(/\r?\n$/, "");
160
+ changed = true;
161
+ }
162
+ return changed ? lines.join("\n") : body;
163
+ }
164
+
165
+ export function parseAgentFile(name: string, raw: string, source: string, scope: AgentDefinition["scope"]): AgentDefinition | undefined {
166
+ let frontmatter: Record<string, string> = {};
167
+ let body = raw;
168
+ const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(raw);
169
+ if (match) {
170
+ body = raw.slice(match[0].length);
171
+ for (const line of match[1]!.split(/\r?\n/)) {
172
+ const separator = line.indexOf(":");
173
+ if (separator <= 0 || /^\s*#/.test(line)) continue;
174
+ const key = line.slice(0, separator).trim();
175
+ const value = line.slice(separator + 1).trim();
176
+ if (key && value) frontmatter[key] = value;
177
+ }
178
+ }
179
+
180
+ const thinking = isThinkingLevel(frontmatter.thinking) ? frontmatter.thinking : undefined;
181
+ const profile = frontmatter.profile && (PROFILES as readonly string[]).includes(frontmatter.profile)
182
+ ? (frontmatter.profile as TaskProfile)
183
+ : undefined;
184
+ const isolation = frontmatter.isolation === "worktree" ? "worktree" as const
185
+ : frontmatter.isolation === "shared" ? "shared" as const
186
+ : undefined;
187
+ const spawns = frontmatter.spawns !== undefined ? parseSpawns(frontmatter.spawns) : undefined;
188
+ const backend = frontmatter.backend === "codex" ? "codex" as const
189
+ : frontmatter.backend === "claude" ? "claude" as const
190
+ : frontmatter.backend === "pi" ? "pi" as const
191
+ : undefined;
192
+
193
+ const expanded = expandIncludes(body, source);
194
+ const systemPrompt = expanded.trim() || undefined;
195
+ const definition: AgentDefinition = {
196
+ name,
197
+ description: stripQuotes(frontmatter.description ?? "").slice(0, 200) || name,
198
+ systemPrompt,
199
+ source,
200
+ scope,
201
+ model: frontmatter.model ? stripQuotes(frontmatter.model) : undefined,
202
+ thinking,
203
+ profile,
204
+ tools: frontmatter.tools ? parseList(frontmatter.tools) : undefined,
205
+ maxTurns: frontmatter.max_turns ? parseNumber(frontmatter.max_turns) : undefined,
206
+ maxCost: frontmatter.max_cost ? parseNumber(frontmatter.max_cost) : undefined,
207
+ timeoutMs: frontmatter.timeout_ms ? parseNumber(frontmatter.timeout_ms) : undefined,
208
+ graceTurns: frontmatter.grace_turns ? parseNumber(frontmatter.grace_turns) : undefined,
209
+ fallbackModels: frontmatter.fallback_models ? parseList(frontmatter.fallback_models) : undefined,
210
+ maxRetries: frontmatter.max_retries ? parseNumber(frontmatter.max_retries) : undefined,
211
+ isolation,
212
+ backend,
213
+ outputSchema: frontmatter.output_schema ? parseSchemaValue(frontmatter.output_schema, source) : undefined,
214
+ spawns,
215
+ };
216
+ return definition;
217
+ }
218
+
219
+ // ── Discovery ────────────────────────────────────────────────────────────────
220
+
221
+ /**
222
+ * Discover agent definitions across the conventional roots. Synchronous by
223
+ * design: it runs at session start and on tool execute, reads a handful of
224
+ * small files, and failure of any root is silent (missing dirs are normal).
225
+ * Symlinked agent files are skipped (matching skill-loading conservatism).
226
+ */
227
+ export function discoverAgents(cwd: string): Map<string, AgentDefinition> {
228
+ const catalog = new Map<string, AgentDefinition>();
229
+ // Iterate lowest precedence first so higher roots overwrite.
230
+ for (const root of [...discoveryRoots(cwd)].reverse()) {
231
+ let entries: fs.Dirent[];
232
+ try {
233
+ entries = fs.readdirSync(root.dir, { withFileTypes: true });
234
+ } catch {
235
+ continue;
236
+ }
237
+ for (const entry of entries) {
238
+ if (!entry.isFile() || entry.isSymbolicLink() || !entry.name.endsWith(".md")) continue;
239
+ const name = entry.name.slice(0, -3);
240
+ if (!NAME_PATTERN.test(name)) continue;
241
+ const file = path.join(root.dir, entry.name);
242
+ try {
243
+ const stat = fs.lstatSync(file);
244
+ if (!stat.isFile() || stat.size > MAX_AGENT_FILE_BYTES) continue;
245
+ const raw = fs.readFileSync(file, "utf8");
246
+ const definition = parseAgentFile(name.toLowerCase(), raw, file, root.scope);
247
+ if (definition) catalog.set(definition.name, definition);
248
+ } catch {
249
+ /* unreadable file: skip */
250
+ }
251
+ }
252
+ }
253
+ return catalog;
254
+ }
255
+
256
+ /** Case-insensitive lookup with a helpful error listing available names. */
257
+ export function resolveAgent(
258
+ catalog: Map<string, AgentDefinition>,
259
+ name: string,
260
+ ): { agent?: AgentDefinition; error?: string } {
261
+ const agent = catalog.get(name.toLowerCase());
262
+ if (agent) return { agent };
263
+ const available = [...catalog.keys()].sort();
264
+ return {
265
+ error: available.length
266
+ ? `Unknown agent "${name}". Available agents: ${available.join(", ")}`
267
+ : `Unknown agent "${name}". No agent files found (define them in .pi/agents/<name>.md).`,
268
+ };
269
+ }
270
+
271
+ /** One line per agent for the tool guidelines / status output. */
272
+ export function describeCatalog(catalog: Map<string, AgentDefinition>): string[] {
273
+ return [...catalog.values()]
274
+ .sort((a, b) => a.name.localeCompare(b.name))
275
+ .map((agent) => {
276
+ const traits = [
277
+ agent.profile,
278
+ agent.isolation === "worktree" ? "worktree" : "",
279
+ ].filter(Boolean).join(", ");
280
+ return `${agent.name}: ${agent.description}${traits ? ` (${traits})` : ""}`;
281
+ });
282
+ }