@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/CHANGELOG.md +14 -6
- package/README.md +218 -128
- package/docs/ARCHITECTURE.md +168 -132
- package/docs/COST-ACCOUNTING.md +116 -66
- package/docs/RELEASING.md +32 -32
- package/docs/SECURITY.md +125 -97
- package/docs/UX.md +158 -141
- package/package.json +2 -2
- package/skills/subagent/SKILL.md +142 -121
- package/src/agents.ts +282 -288
- package/src/backends/pi.ts +164 -94
- package/src/child-preflight.ts +166 -0
- package/src/config.ts +254 -252
- package/src/dispatch-preflight.ts +87 -0
- package/src/dispatch-routing.ts +56 -0
- package/src/extension.ts +368 -187
- package/src/format.ts +436 -365
- package/src/jev-router.ts +1036 -0
- package/src/orchestrator.ts +303 -312
- package/src/persistence.ts +643 -335
- package/src/policy.ts +562 -561
- package/src/process-lock.ts +730 -687
- package/src/protocol.ts +320 -290
- package/src/registry.ts +730 -632
- package/src/routing-policy.ts +268 -0
- package/src/routing-types.ts +217 -0
- package/src/runner.ts +1299 -850
- package/src/schema.ts +189 -189
- package/src/startup-check.ts +481 -0
- package/src/types.ts +208 -198
- package/src/usage.ts +316 -274
- package/src/context-policy.ts +0 -169
- package/src/model-policy.ts +0 -169
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
function
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
:
|
|
190
|
-
|
|
191
|
-
:
|
|
192
|
-
|
|
193
|
-
const
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
:
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
+
}
|