@bacnh85/pi-subagent 0.10.1 → 0.12.2

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.
@@ -16,242 +16,242 @@ export type AgentScope = "user" | "project" | "both";
16
16
  export type AgentColor = "red" | "blue" | "green" | "yellow" | "purple" | "orange" | "pink" | "cyan";
17
17
 
18
18
  export interface AgentConfig {
19
- name: string;
20
- description: string;
21
- tools?: string[];
22
- model?: string;
23
- models?: string[];
24
- thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
25
- sandbox?: "read-only" | "workspace-write";
26
- color?: AgentColor;
27
- systemPrompt: string;
28
- source: "user" | "project" | "bundled";
29
- filePath: string;
19
+ name: string;
20
+ description: string;
21
+ tools?: string[];
22
+ model?: string;
23
+ models?: string[];
24
+ thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
25
+ sandbox?: "read-only" | "workspace-write";
26
+ color?: AgentColor;
27
+ systemPrompt: string;
28
+ source: "user" | "project" | "bundled";
29
+ filePath: string;
30
30
  }
31
31
 
32
32
  export function getModelCandidates(agent: Pick<AgentConfig, "model" | "models">): string[] {
33
- return [...new Set([agent.model, ...(agent.models ?? [])].filter((model): model is string => Boolean(model)))];
33
+ return [...new Set([agent.model, ...(agent.models ?? [])].filter((model): model is string => Boolean(model)))];
34
34
  }
35
35
 
36
36
  export interface AgentDiscoveryResult {
37
- agents: AgentConfig[];
38
- projectAgentsDir: string | null;
39
- diagnostics: AgentDiscoveryDiagnostic[];
37
+ agents: AgentConfig[];
38
+ projectAgentsDir: string | null;
39
+ diagnostics: AgentDiscoveryDiagnostic[];
40
40
  }
41
41
 
42
42
  export interface AgentDiscoveryDiagnostic {
43
- filePath: string;
44
- issue: string;
45
- /** 'warn' for recoverable issues, 'error' for file-skip issues. */
46
- severity: "warn" | "error";
43
+ filePath: string;
44
+ issue: string;
45
+ /** 'warn' for recoverable issues, 'error' for file-skip issues. */
46
+ severity: "warn" | "error";
47
47
  }
48
48
 
49
49
  interface AgentCache {
50
- userDir: string;
51
- projectDir: string | null;
52
- bundledDir: string;
53
- scope: AgentScope;
54
- agents: AgentConfig[];
55
- projectAgentsDir: string | null;
56
- diagnostics: AgentDiscoveryDiagnostic[];
57
- /** File-level signature per directory (name:mtime:size for each .md file) */
58
- dirSignatures: Map<string, string>;
50
+ userDir: string;
51
+ projectDir: string | null;
52
+ bundledDir: string;
53
+ scope: AgentScope;
54
+ agents: AgentConfig[];
55
+ projectAgentsDir: string | null;
56
+ diagnostics: AgentDiscoveryDiagnostic[];
57
+ /** File-level signature per directory (name:mtime:size for each .md file) */
58
+ dirSignatures: Map<string, string>;
59
59
  }
60
60
 
61
61
  let _cache: AgentCache | null = null;
62
62
 
63
63
  /** Clear the agent cache (call on /reload). */
64
64
  export function invalidateAgentCache(): void {
65
- _cache = null;
65
+ _cache = null;
66
66
  }
67
67
 
68
68
  function loadAgentsFromDir(
69
- dir: string,
70
- source: "user" | "project" | "bundled",
71
- diagnostics: AgentDiscoveryDiagnostic[],
69
+ dir: string,
70
+ source: "user" | "project" | "bundled",
71
+ diagnostics: AgentDiscoveryDiagnostic[],
72
72
  ): AgentConfig[] {
73
- const agents: AgentConfig[] = [];
74
-
75
- if (!fs.existsSync(dir)) return agents;
76
-
77
- let entries: fs.Dirent[];
78
- try {
79
- entries = fs.readdirSync(dir, { withFileTypes: true });
80
- } catch (err) {
81
- diagnostics.push({
82
- filePath: dir,
83
- issue: `Cannot read directory: ${err instanceof Error ? err.message : String(err)}`,
84
- severity: "warn",
85
- });
86
- return agents;
87
- }
88
-
89
- for (const entry of entries) {
90
- if (!entry.name.endsWith(".md")) continue;
91
- if (!entry.isFile() && !entry.isSymbolicLink()) {
92
- diagnostics.push({
93
- filePath: path.join(dir, entry.name),
94
- issue: `Not a regular file or symlink, skipping.`,
95
- severity: "warn",
96
- });
97
- continue;
98
- }
99
-
100
- const filePath = path.join(dir, entry.name);
101
- let content: string;
102
- try {
103
- content = fs.readFileSync(filePath, "utf-8");
104
- } catch (err) {
105
- diagnostics.push({
106
- filePath,
107
- issue: `Cannot read file: ${err instanceof Error ? err.message : String(err)}`,
108
- severity: "error",
109
- });
110
- continue;
111
- }
112
-
113
-
114
-
115
- let frontmatter: Record<string, unknown>;
116
- let body: string;
117
- try {
118
- const parsed = parseFrontmatter<Record<string, unknown>>(content);
119
- frontmatter = parsed.frontmatter;
120
- body = parsed.body;
121
- } catch (err) {
122
- diagnostics.push({
123
- filePath,
124
- issue: `Failed to parse YAML frontmatter: ${err instanceof Error ? err.message : String(err)}`,
125
- severity: "error",
126
- });
127
- continue;
128
- }
129
-
130
- if (typeof frontmatter.name !== "string" || typeof frontmatter.description !== "string") {
131
- if (typeof frontmatter.name !== "string" && typeof frontmatter.description !== "string") {
132
- diagnostics.push({
133
- filePath,
134
- issue: `Missing both "name" and "description" in frontmatter. Agent file skipped.`,
135
- severity: "error",
136
- });
137
- } else if (typeof frontmatter.name !== "string") {
138
- diagnostics.push({
139
- filePath,
140
- issue: `Missing "name" in frontmatter. Agent file skipped.`,
141
- severity: "error",
142
- });
143
- } else {
144
- diagnostics.push({
145
- filePath,
146
- issue: `Missing "description" in frontmatter. Agent file skipped.`,
147
- severity: "error",
148
- });
149
- }
150
- continue;
151
- }
152
-
153
- if (!frontmatter.name.trim()) {
154
- diagnostics.push({
155
- filePath,
156
- issue: `"name" in frontmatter is empty. Agent file skipped.`,
157
- severity: "error",
158
- });
159
- continue;
160
- }
161
-
162
- const tools =
163
- typeof frontmatter.tools === "string"
164
- ? frontmatter.tools.split(",").map((t) => t.trim()).filter(Boolean)
165
- : Array.isArray(frontmatter.tools)
166
- ? (frontmatter.tools as unknown[]).filter((t): t is string => typeof t === "string")
167
- : undefined;
168
- const model = typeof frontmatter.model === "string" ? frontmatter.model.trim() || undefined : undefined;
169
- const models =
170
- typeof frontmatter.models === "string"
171
- ? frontmatter.models.split(",").map((item) => item.trim()).filter(Boolean)
172
- : Array.isArray(frontmatter.models)
173
- ? (frontmatter.models as unknown[]).filter((item): item is string => typeof item === "string" && Boolean(item.trim())).map((item) => item.trim())
174
- : undefined;
175
-
176
- if (frontmatter.models !== undefined && typeof frontmatter.models !== "string" && !Array.isArray(frontmatter.models)) {
177
- diagnostics.push({ filePath, issue: `"models" must be a YAML array or comma-separated string. Ignoring.`, severity: "warn" });
178
- }
179
- if (Array.isArray(frontmatter.models)) {
180
- for (const item of frontmatter.models) {
181
- if (typeof item === "string" && item.trim()) continue;
182
- diagnostics.push({ filePath, issue: `"models" entries must be non-empty strings. Ignoring invalid entry.`, severity: "warn" });
183
- }
184
- }
185
- for (const modelName of getModelCandidates({ model, models })) {
186
- if (modelName.includes("/")) continue;
187
- diagnostics.push({
188
- filePath,
189
- issue: `Model "${modelName}" does not include a provider prefix (e.g., "anthropic/claude-sonnet-4-20250514"). Resolution may fail.`,
190
- severity: "warn",
191
- });
192
- }
193
-
194
- if (typeof frontmatter.thinking === "string" && frontmatter.thinking) {
195
- const validLevels = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
196
- if (!validLevels.includes(frontmatter.thinking)) {
197
- diagnostics.push({
198
- filePath,
199
- issue: `Invalid thinking level "${frontmatter.thinking}". Valid values: ${validLevels.join(", ")}. Using default.`,
200
- severity: "warn",
201
- });
202
- }
203
- }
204
-
205
- if (typeof frontmatter.sandbox === "string" && frontmatter.sandbox) {
206
- const validSandboxes = ["read-only", "workspace-write"];
207
- if (!validSandboxes.includes(frontmatter.sandbox)) {
208
- diagnostics.push({
209
- filePath,
210
- issue: `Invalid sandbox mode "${frontmatter.sandbox}". Valid values: ${validSandboxes.join(", ")}. Ignoring.`,
211
- severity: "warn",
212
- });
213
- }
214
- }
215
-
216
- const VALID_COLORS = ["red", "blue", "green", "yellow", "purple", "orange", "pink", "cyan"] as const;
217
- if (typeof frontmatter.color === "string" && frontmatter.color && !VALID_COLORS.includes(frontmatter.color as any)) {
218
- diagnostics.push({
219
- filePath,
220
- issue: `Invalid color "${frontmatter.color}". Valid values: ${VALID_COLORS.join(", ")}. Ignoring.`,
221
- severity: "warn",
222
- });
223
- }
224
-
225
- agents.push({
226
- name: frontmatter.name,
227
- description: frontmatter.description,
228
- tools: tools && tools.length > 0 ? tools : undefined,
229
- model,
230
- models: models && models.length > 0 ? models : undefined,
231
- thinking: typeof frontmatter.thinking === "string" && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(frontmatter.thinking)
232
- ? frontmatter.thinking as AgentConfig["thinking"]
233
- : undefined,
234
- sandbox: typeof frontmatter.sandbox === "string" && ["read-only", "workspace-write"].includes(frontmatter.sandbox)
235
- ? frontmatter.sandbox as "read-only" | "workspace-write"
236
- : undefined,
237
- color: typeof frontmatter.color === "string" && VALID_COLORS.includes(frontmatter.color as any)
238
- ? frontmatter.color as AgentColor
239
- : undefined,
240
- systemPrompt: body,
241
- source,
242
- filePath,
243
- });
244
- }
245
-
246
- return agents;
73
+ const agents: AgentConfig[] = [];
74
+
75
+ if (!fs.existsSync(dir)) return agents;
76
+
77
+ let entries: fs.Dirent[];
78
+ try {
79
+ entries = fs.readdirSync(dir, { withFileTypes: true });
80
+ } catch (err) {
81
+ diagnostics.push({
82
+ filePath: dir,
83
+ issue: `Cannot read directory: ${err instanceof Error ? err.message : String(err)}`,
84
+ severity: "warn",
85
+ });
86
+ return agents;
87
+ }
88
+
89
+ for (const entry of entries) {
90
+ if (!entry.name.endsWith(".md")) continue;
91
+ if (!entry.isFile() && !entry.isSymbolicLink()) {
92
+ diagnostics.push({
93
+ filePath: path.join(dir, entry.name),
94
+ issue: `Not a regular file or symlink, skipping.`,
95
+ severity: "warn",
96
+ });
97
+ continue;
98
+ }
99
+
100
+ const filePath = path.join(dir, entry.name);
101
+ let content: string;
102
+ try {
103
+ content = fs.readFileSync(filePath, "utf-8");
104
+ } catch (err) {
105
+ diagnostics.push({
106
+ filePath,
107
+ issue: `Cannot read file: ${err instanceof Error ? err.message : String(err)}`,
108
+ severity: "error",
109
+ });
110
+ continue;
111
+ }
112
+
113
+
114
+
115
+ let frontmatter: Record<string, unknown>;
116
+ let body: string;
117
+ try {
118
+ const parsed = parseFrontmatter<Record<string, unknown>>(content);
119
+ frontmatter = parsed.frontmatter;
120
+ body = parsed.body;
121
+ } catch (err) {
122
+ diagnostics.push({
123
+ filePath,
124
+ issue: `Failed to parse YAML frontmatter: ${err instanceof Error ? err.message : String(err)}`,
125
+ severity: "error",
126
+ });
127
+ continue;
128
+ }
129
+
130
+ if (typeof frontmatter.name !== "string" || typeof frontmatter.description !== "string") {
131
+ if (typeof frontmatter.name !== "string" && typeof frontmatter.description !== "string") {
132
+ diagnostics.push({
133
+ filePath,
134
+ issue: `Missing both "name" and "description" in frontmatter. Agent file skipped.`,
135
+ severity: "error",
136
+ });
137
+ } else if (typeof frontmatter.name !== "string") {
138
+ diagnostics.push({
139
+ filePath,
140
+ issue: `Missing "name" in frontmatter. Agent file skipped.`,
141
+ severity: "error",
142
+ });
143
+ } else {
144
+ diagnostics.push({
145
+ filePath,
146
+ issue: `Missing "description" in frontmatter. Agent file skipped.`,
147
+ severity: "error",
148
+ });
149
+ }
150
+ continue;
151
+ }
152
+
153
+ if (!frontmatter.name.trim()) {
154
+ diagnostics.push({
155
+ filePath,
156
+ issue: `"name" in frontmatter is empty. Agent file skipped.`,
157
+ severity: "error",
158
+ });
159
+ continue;
160
+ }
161
+
162
+ const tools =
163
+ typeof frontmatter.tools === "string"
164
+ ? frontmatter.tools.split(",").map((t) => t.trim()).filter(Boolean)
165
+ : Array.isArray(frontmatter.tools)
166
+ ? (frontmatter.tools as unknown[]).filter((t): t is string => typeof t === "string")
167
+ : undefined;
168
+ const model = typeof frontmatter.model === "string" ? frontmatter.model.trim() || undefined : undefined;
169
+ const models =
170
+ typeof frontmatter.models === "string"
171
+ ? frontmatter.models.split(",").map((item) => item.trim()).filter(Boolean)
172
+ : Array.isArray(frontmatter.models)
173
+ ? (frontmatter.models as unknown[]).filter((item): item is string => typeof item === "string" && Boolean(item.trim())).map((item) => item.trim())
174
+ : undefined;
175
+
176
+ if (frontmatter.models !== undefined && typeof frontmatter.models !== "string" && !Array.isArray(frontmatter.models)) {
177
+ diagnostics.push({ filePath, issue: `"models" must be a YAML array or comma-separated string. Ignoring.`, severity: "warn" });
178
+ }
179
+ if (Array.isArray(frontmatter.models)) {
180
+ for (const item of frontmatter.models) {
181
+ if (typeof item === "string" && item.trim()) continue;
182
+ diagnostics.push({ filePath, issue: `"models" entries must be non-empty strings. Ignoring invalid entry.`, severity: "warn" });
183
+ }
184
+ }
185
+ for (const modelName of getModelCandidates({ model, models })) {
186
+ if (modelName.includes("/")) continue;
187
+ diagnostics.push({
188
+ filePath,
189
+ issue: `Model "${modelName}" does not include a provider prefix (e.g., "anthropic/claude-sonnet-4-20250514"). Resolution may fail.`,
190
+ severity: "warn",
191
+ });
192
+ }
193
+
194
+ if (typeof frontmatter.thinking === "string" && frontmatter.thinking) {
195
+ const validLevels = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
196
+ if (!validLevels.includes(frontmatter.thinking)) {
197
+ diagnostics.push({
198
+ filePath,
199
+ issue: `Invalid thinking level "${frontmatter.thinking}". Valid values: ${validLevels.join(", ")}. Using default.`,
200
+ severity: "warn",
201
+ });
202
+ }
203
+ }
204
+
205
+ if (typeof frontmatter.sandbox === "string" && frontmatter.sandbox) {
206
+ const validSandboxes = ["read-only", "workspace-write"];
207
+ if (!validSandboxes.includes(frontmatter.sandbox)) {
208
+ diagnostics.push({
209
+ filePath,
210
+ issue: `Invalid sandbox mode "${frontmatter.sandbox}". Valid values: ${validSandboxes.join(", ")}. Ignoring.`,
211
+ severity: "warn",
212
+ });
213
+ }
214
+ }
215
+
216
+ const VALID_COLORS = ["red", "blue", "green", "yellow", "purple", "orange", "pink", "cyan"] as const;
217
+ if (typeof frontmatter.color === "string" && frontmatter.color && !VALID_COLORS.includes(frontmatter.color as any)) {
218
+ diagnostics.push({
219
+ filePath,
220
+ issue: `Invalid color "${frontmatter.color}". Valid values: ${VALID_COLORS.join(", ")}. Ignoring.`,
221
+ severity: "warn",
222
+ });
223
+ }
224
+
225
+ agents.push({
226
+ name: frontmatter.name,
227
+ description: frontmatter.description,
228
+ tools: tools && tools.length > 0 ? tools : undefined,
229
+ model,
230
+ models: models && models.length > 0 ? models : undefined,
231
+ thinking: typeof frontmatter.thinking === "string" && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(frontmatter.thinking)
232
+ ? frontmatter.thinking as AgentConfig["thinking"]
233
+ : undefined,
234
+ sandbox: typeof frontmatter.sandbox === "string" && ["read-only", "workspace-write"].includes(frontmatter.sandbox)
235
+ ? frontmatter.sandbox as "read-only" | "workspace-write"
236
+ : undefined,
237
+ color: typeof frontmatter.color === "string" && VALID_COLORS.includes(frontmatter.color as any)
238
+ ? frontmatter.color as AgentColor
239
+ : undefined,
240
+ systemPrompt: body,
241
+ source,
242
+ filePath,
243
+ });
244
+ }
245
+
246
+ return agents;
247
247
  }
248
248
 
249
249
  function isDirectory(p: string): boolean {
250
- try {
251
- return fs.statSync(p).isDirectory();
252
- } catch {
253
- return false;
254
- }
250
+ try {
251
+ return fs.statSync(p).isDirectory();
252
+ } catch {
253
+ return false;
254
+ }
255
255
  }
256
256
 
257
257
  /** Build a stable signature for agent .md files in a directory.
@@ -259,35 +259,35 @@ function isDirectory(p: string): boolean {
259
259
  * list of `name:mtimeMs:size` entries that catches both content
260
260
  * edits and add/remove/rename operations. */
261
261
  function dirSignature(dir: string): string {
262
- try {
263
- const entries = fs.readdirSync(dir, { withFileTypes: true })
264
- .filter((e) => e.name.endsWith(".md") && (e.isFile() || e.isSymbolicLink()))
265
- .map((e) => {
266
- const file = path.join(dir, e.name);
267
- try {
268
- const st = fs.statSync(file);
269
- return `${e.name}:${st.mtimeMs}:${st.size}`;
270
- } catch {
271
- return `${e.name}:broken`;
272
- }
273
- })
274
- .sort();
275
- return `exists:${entries.join("|")}`;
276
- } catch {
277
- return "missing";
278
- }
262
+ try {
263
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
264
+ .filter((e) => e.name.endsWith(".md") && (e.isFile() || e.isSymbolicLink()))
265
+ .map((e) => {
266
+ const file = path.join(dir, e.name);
267
+ try {
268
+ const st = fs.statSync(file);
269
+ return `${e.name}:${st.mtimeMs}:${st.size}`;
270
+ } catch {
271
+ return `${e.name}:broken`;
272
+ }
273
+ })
274
+ .sort();
275
+ return `exists:${entries.join("|")}`;
276
+ } catch {
277
+ return "missing";
278
+ }
279
279
  }
280
280
 
281
281
  function findNearestProjectAgentsDir(cwd: string): string | null {
282
- let currentDir = cwd;
283
- while (true) {
284
- const candidate = path.join(currentDir, CONFIG_DIR_NAME, "agents");
285
- if (isDirectory(candidate)) return candidate;
286
-
287
- const parentDir = path.dirname(currentDir);
288
- if (parentDir === currentDir) return null;
289
- currentDir = parentDir;
290
- }
282
+ let currentDir = cwd;
283
+ while (true) {
284
+ const candidate = path.join(currentDir, CONFIG_DIR_NAME, "agents");
285
+ if (isDirectory(candidate)) return candidate;
286
+
287
+ const parentDir = path.dirname(currentDir);
288
+ if (parentDir === currentDir) return null;
289
+ currentDir = parentDir;
290
+ }
291
291
  }
292
292
 
293
293
  /**
@@ -297,81 +297,81 @@ function findNearestProjectAgentsDir(cwd: string): string | null {
297
297
  * @param bundledAgentsDir - Path to skill-bundled agents directory.
298
298
  */
299
299
  export function discoverAgents(
300
- cwd: string,
301
- scope: AgentScope,
302
- bundledAgentsDir: string,
300
+ cwd: string,
301
+ scope: AgentScope,
302
+ bundledAgentsDir: string,
303
303
  ): AgentDiscoveryResult {
304
- const userDir = path.join(getAgentDir(), "agents");
305
- const projectAgentsDir = findNearestProjectAgentsDir(cwd);
306
-
307
- // Check cache (with file-signature invalidation so editing agent .md files auto-detects changes)
308
- if (
309
- _cache &&
310
- _cache.userDir === userDir &&
311
- _cache.projectDir === projectAgentsDir &&
312
- _cache.bundledDir === bundledAgentsDir &&
313
- _cache.scope === scope
314
- ) {
315
- let stale = false;
316
- for (const [dir, cachedSig] of _cache.dirSignatures) {
317
- if (dirSignature(dir) !== cachedSig) {
318
- stale = true;
319
- break;
320
- }
321
- }
322
- if (!stale) {
323
- return { agents: _cache.agents, projectAgentsDir: _cache.projectAgentsDir, diagnostics: _cache.diagnostics };
324
- }
325
- // Cache is stale — rebuild below
326
- _cache = null;
327
- }
328
-
329
- const diagnostics: AgentDiscoveryDiagnostic[] = [];
330
-
331
- const userAgents = scope === "project" ? [] : loadAgentsFromDir(userDir, "user", diagnostics);
332
- const projectAgents =
333
- scope === "user" || !projectAgentsDir ? [] : loadAgentsFromDir(projectAgentsDir, "project", diagnostics);
334
- const bundledAgents = loadAgentsFromDir(bundledAgentsDir, "bundled", diagnostics);
335
-
336
- const agentMap = new Map<string, AgentConfig>();
337
-
338
- // Priority: bundled < user < project (higher index = higher priority)
339
- for (const agent of bundledAgents) agentMap.set(agent.name, agent);
340
- if (scope === "both" || scope === "user") {
341
- for (const agent of userAgents) agentMap.set(agent.name, agent);
342
- }
343
- if (scope === "both" || scope === "project") {
344
- for (const agent of projectAgents) agentMap.set(agent.name, agent);
345
- }
346
-
347
- const agents = Array.from(agentMap.values());
348
-
349
- const dirSignatures = new Map<string, string>();
350
- for (const dir of [userDir, projectAgentsDir, bundledAgentsDir]) {
351
- if (!dir) continue;
352
- dirSignatures.set(dir, dirSignature(dir));
353
- }
354
-
355
- _cache = {
356
- userDir,
357
- projectDir: projectAgentsDir,
358
- bundledDir: bundledAgentsDir,
359
- scope,
360
- agents,
361
- projectAgentsDir,
362
- diagnostics,
363
- dirSignatures,
364
- };
365
-
366
- return { agents, projectAgentsDir, diagnostics };
304
+ const userDir = path.join(getAgentDir(), "agents");
305
+ const projectAgentsDir = findNearestProjectAgentsDir(cwd);
306
+
307
+ // Check cache (with file-signature invalidation so editing agent .md files auto-detects changes)
308
+ if (
309
+ _cache &&
310
+ _cache.userDir === userDir &&
311
+ _cache.projectDir === projectAgentsDir &&
312
+ _cache.bundledDir === bundledAgentsDir &&
313
+ _cache.scope === scope
314
+ ) {
315
+ let stale = false;
316
+ for (const [dir, cachedSig] of _cache.dirSignatures) {
317
+ if (dirSignature(dir) !== cachedSig) {
318
+ stale = true;
319
+ break;
320
+ }
321
+ }
322
+ if (!stale) {
323
+ return { agents: _cache.agents, projectAgentsDir: _cache.projectAgentsDir, diagnostics: _cache.diagnostics };
324
+ }
325
+ // Cache is stale — rebuild below
326
+ _cache = null;
327
+ }
328
+
329
+ const diagnostics: AgentDiscoveryDiagnostic[] = [];
330
+
331
+ const userAgents = scope === "project" ? [] : loadAgentsFromDir(userDir, "user", diagnostics);
332
+ const projectAgents =
333
+ scope === "user" || !projectAgentsDir ? [] : loadAgentsFromDir(projectAgentsDir, "project", diagnostics);
334
+ const bundledAgents = loadAgentsFromDir(bundledAgentsDir, "bundled", diagnostics);
335
+
336
+ const agentMap = new Map<string, AgentConfig>();
337
+
338
+ // Priority: bundled < user < project (higher index = higher priority)
339
+ for (const agent of bundledAgents) agentMap.set(agent.name, agent);
340
+ if (scope === "both" || scope === "user") {
341
+ for (const agent of userAgents) agentMap.set(agent.name, agent);
342
+ }
343
+ if (scope === "both" || scope === "project") {
344
+ for (const agent of projectAgents) agentMap.set(agent.name, agent);
345
+ }
346
+
347
+ const agents = Array.from(agentMap.values());
348
+
349
+ const dirSignatures = new Map<string, string>();
350
+ for (const dir of [userDir, projectAgentsDir, bundledAgentsDir]) {
351
+ if (!dir) continue;
352
+ dirSignatures.set(dir, dirSignature(dir));
353
+ }
354
+
355
+ _cache = {
356
+ userDir,
357
+ projectDir: projectAgentsDir,
358
+ bundledDir: bundledAgentsDir,
359
+ scope,
360
+ agents,
361
+ projectAgentsDir,
362
+ diagnostics,
363
+ dirSignatures,
364
+ };
365
+
366
+ return { agents, projectAgentsDir, diagnostics };
367
367
  }
368
368
 
369
369
  export function formatAgentList(agents: AgentConfig[], maxItems: number): { text: string; remaining: number } {
370
- if (agents.length === 0) return { text: "none", remaining: 0 };
371
- const listed = agents.slice(0, maxItems);
372
- const remaining = agents.length - listed.length;
373
- return {
374
- text: listed.map((a) => `${a.name} (${a.source}): ${a.description}`).join("; "),
375
- remaining,
376
- };
370
+ if (agents.length === 0) return { text: "none", remaining: 0 };
371
+ const listed = agents.slice(0, maxItems);
372
+ const remaining = agents.length - listed.length;
373
+ return {
374
+ text: listed.map((a) => `${a.name} (${a.source}): ${a.description}`).join("; "),
375
+ remaining,
376
+ };
377
377
  }