pi-subagents 0.8.2 → 0.8.4

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/render.ts CHANGED
@@ -28,6 +28,21 @@ function computeWidgetHash(jobs: AsyncJobState[]): string {
28
28
  ).join("|");
29
29
  }
30
30
 
31
+ function extractOutputTarget(task: string): string | undefined {
32
+ const writeToMatch = task.match(/\[Write to:\s*([^\]\n]+)\]/i);
33
+ if (writeToMatch?.[1]?.trim()) return writeToMatch[1].trim();
34
+ const findingsMatch = task.match(/Write your findings to:\s*(\S+)/i);
35
+ if (findingsMatch?.[1]?.trim()) return findingsMatch[1].trim();
36
+ const outputMatch = task.match(/[Oo]utput(?:\s+to)?\s*:\s*(\S+)/i);
37
+ if (outputMatch?.[1]?.trim()) return outputMatch[1].trim();
38
+ return undefined;
39
+ }
40
+
41
+ function hasEmptyTextOutputWithoutOutputTarget(task: string, output: string): boolean {
42
+ if (output.trim()) return false;
43
+ return !extractOutputTarget(task);
44
+ }
45
+
31
46
  /**
32
47
  * Render the async jobs widget
33
48
  */
@@ -158,11 +173,18 @@ export function renderSubagentResult(
158
173
  const hasRunning = d.progress?.some((p) => p.status === "running")
159
174
  || d.results.some((r) => r.progress?.status === "running");
160
175
  const ok = d.results.filter((r) => r.progress?.status === "completed" || (r.exitCode === 0 && r.progress?.status !== "running")).length;
176
+ const hasEmptyWithoutTarget = d.results.some((r) =>
177
+ r.exitCode === 0
178
+ && r.progress?.status !== "running"
179
+ && hasEmptyTextOutputWithoutOutputTarget(r.task, getFinalOutput(r.messages)),
180
+ );
161
181
  const icon = hasRunning
162
182
  ? theme.fg("warning", "...")
163
- : ok === d.results.length
164
- ? theme.fg("success", "ok")
165
- : theme.fg("error", "X");
183
+ : hasEmptyWithoutTarget
184
+ ? theme.fg("warning", "")
185
+ : ok === d.results.length
186
+ ? theme.fg("success", "ok")
187
+ : theme.fg("error", "X");
166
188
 
167
189
  const totalSummary =
168
190
  d.progressSummary ||
@@ -205,14 +227,19 @@ export function renderSubagentResult(
205
227
  const result = d.results[i];
206
228
  const isFailed = result && result.exitCode !== 0 && result.progress?.status !== "running";
207
229
  const isComplete = result && result.exitCode === 0 && result.progress?.status !== "running";
230
+ const isEmptyWithoutTarget = Boolean(result)
231
+ && Boolean(isComplete)
232
+ && hasEmptyTextOutputWithoutOutputTarget(result.task, getFinalOutput(result.messages));
208
233
  const isCurrent = i === (d.currentStepIndex ?? d.results.length);
209
234
  const icon = isFailed
210
235
  ? theme.fg("error", "✗")
211
- : isComplete
212
- ? theme.fg("success", "")
213
- : isCurrent && hasRunning
214
- ? theme.fg("warning", "")
215
- : theme.fg("dim", "○");
236
+ : isEmptyWithoutTarget
237
+ ? theme.fg("warning", "")
238
+ : isComplete
239
+ ? theme.fg("success", "")
240
+ : isCurrent && hasRunning
241
+ ? theme.fg("warning", "●")
242
+ : theme.fg("dim", "○");
216
243
  return `${icon} ${agent}`;
217
244
  })
218
245
  .join(theme.fg("dim", " → "))
@@ -259,28 +286,27 @@ export function renderSubagentResult(
259
286
  const rProg = r.progress || progressFromArray || r.progressSummary;
260
287
  const rRunning = rProg?.status === "running";
261
288
 
262
- // Step header with status
289
+ const resultOutput = getFinalOutput(r.messages);
263
290
  const statusIcon = rRunning
264
291
  ? theme.fg("warning", "●")
265
- : r.exitCode === 0
266
- ? theme.fg("success", "")
267
- : theme.fg("error", "✗");
292
+ : r.exitCode !== 0
293
+ ? theme.fg("error", "")
294
+ : hasEmptyTextOutputWithoutOutputTarget(r.task, resultOutput)
295
+ ? theme.fg("warning", "⚠")
296
+ : theme.fg("success", "✓");
268
297
  const stats = rProg ? ` | ${rProg.toolCount} tools, ${formatDuration(rProg.durationMs)}` : "";
269
- // Show model if available (full provider/model format)
270
298
  const modelDisplay = r.model ? theme.fg("dim", ` (${r.model})`) : "";
271
299
  const stepHeader = rRunning
272
300
  ? `${statusIcon} Step ${i + 1}: ${theme.bold(theme.fg("warning", r.agent))}${modelDisplay}${stats}`
273
301
  : `${statusIcon} Step ${i + 1}: ${theme.bold(r.agent)}${modelDisplay}${stats}`;
274
302
  c.addChild(new Text(stepHeader, 0, 0));
275
303
 
276
- // Task (truncated)
277
304
  const taskPreview = r.task.slice(0, 120) + (r.task.length > 120 ? "..." : "");
278
305
  c.addChild(new Text(theme.fg("dim", ` task: ${taskPreview}`), 0, 0));
279
306
 
280
- // Output target (extract from task)
281
- const outputMatch = r.task.match(/[Oo]utput(?:\s+to)?\s+([^\s]+\.(?:md|txt|json))/);
282
- if (outputMatch) {
283
- c.addChild(new Text(theme.fg("dim", ` output: ${outputMatch[1]}`), 0, 0));
307
+ const outputTarget = extractOutputTarget(r.task);
308
+ if (outputTarget) {
309
+ c.addChild(new Text(theme.fg("dim", ` output: ${outputTarget}`), 0, 0));
284
310
  }
285
311
 
286
312
  if (r.skills?.length) {
package/schemas.ts CHANGED
@@ -11,6 +11,7 @@ export const TaskItem = Type.Object({
11
11
  agent: Type.String(),
12
12
  task: Type.String(),
13
13
  cwd: Type.Optional(Type.String()),
14
+ model: Type.Optional(Type.String({ description: "Override model for this task (e.g. 'google/gemini-3-pro')" })),
14
15
  skill: Type.Optional(SkillOverride),
15
16
  });
16
17
 
@@ -71,13 +72,13 @@ export const SubagentParams = Type.Object({
71
72
  })),
72
73
  // Agent/chain configuration for create/update (nested to avoid conflicts with execution fields)
73
74
  config: Type.Optional(Type.Any({
74
- description: "Agent or chain config for create/update. Agent: name, description, scope ('user'|'project', default 'user'), systemPrompt, model, tools (comma-separated), skills (comma-separated), thinking, output, reads, progress. Chain: name, description, scope, steps (array of {agent, task?, output?, reads?, model?, skills?, progress?}). Presence of 'steps' creates a chain instead of an agent."
75
+ description: "Agent or chain config for create/update. Agent: name, description, scope ('user'|'project', default 'user'), systemPrompt, model, tools (comma-separated), extensions (comma-separated), skills (comma-separated), thinking, output, reads, progress. Chain: name, description, scope, steps (array of {agent, task?, output?, reads?, model?, skills?, progress?}). Presence of 'steps' creates a chain instead of an agent."
75
76
  })),
76
77
  tasks: Type.Optional(Type.Array(TaskItem, { description: "PARALLEL mode: [{agent, task}, ...]" })),
77
78
  chain: Type.Optional(Type.Array(ChainItem, { description: "CHAIN mode: sequential pipeline where each step's response becomes {previous} for the next. Use {task}, {previous}, {chain_dir} in task templates." })),
78
- chainDir: Type.Optional(Type.String({ description: "Persistent directory for chain artifacts. Default: /tmp/pi-chain-runs/ (auto-cleaned after 24h)" })),
79
+ chainDir: Type.Optional(Type.String({ description: "Persistent directory for chain artifacts. Default: <tmpdir>/pi-chain-runs/ (auto-cleaned after 24h)" })),
79
80
  async: Type.Optional(Type.Boolean({ description: "Run in background (default: false, or per config)" })),
80
- agentScope: Type.Optional(Type.String({ description: "Agent discovery scope: 'user', 'project', or 'both' (default: 'user')" })),
81
+ agentScope: Type.Optional(Type.String({ description: "Agent discovery scope: 'user', 'project', or 'both' (default: 'both'; project wins on name collisions)" })),
81
82
  cwd: Type.Optional(Type.String()),
82
83
  maxOutput: MaxOutputSchema,
83
84
  artifacts: Type.Optional(Type.Boolean({ description: "Write debug artifacts (default: true)" })),
@@ -89,7 +90,7 @@ export const SubagentParams = Type.Object({
89
90
  // Clarification TUI
90
91
  clarify: Type.Optional(Type.Boolean({ description: "Show TUI to preview/edit before execution (default: true for chains, false for single/parallel). Implies sync mode." })),
91
92
  // Solo agent overrides
92
- output: Type.Optional(Type.Any({ description: "Override output file for single agent (string), or false to disable (uses agent default if omitted)" })),
93
+ output: Type.Optional(Type.Any({ description: "Override output file for single agent (string), or false to disable (uses agent default if omitted). Absolute paths are used as-is; relative paths resolve against cwd." })),
93
94
  skill: Type.Optional(SkillOverride),
94
95
  model: Type.Optional(Type.String({ description: "Override model for single agent (e.g. 'anthropic/claude-sonnet-4')" })),
95
96
  });
package/settings.ts CHANGED
@@ -3,11 +3,12 @@
3
3
  */
4
4
 
5
5
  import * as fs from "node:fs";
6
+ import * as os from "node:os";
6
7
  import * as path from "node:path";
7
8
  import type { AgentConfig } from "./agents.js";
8
9
  import { normalizeSkillInput } from "./skills.js";
9
10
 
10
- const CHAIN_RUNS_DIR = "/tmp/pi-chain-runs";
11
+ const CHAIN_RUNS_DIR = path.join(os.tmpdir(), "pi-chain-runs");
11
12
  const CHAIN_DIR_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
12
13
 
13
14
  // =============================================================================
@@ -358,6 +359,8 @@ export interface ParallelTaskResult {
358
359
  output: string;
359
360
  exitCode: number;
360
361
  error?: string;
362
+ outputTargetPath?: string;
363
+ outputTargetExists?: boolean;
361
364
  }
362
365
 
363
366
  /**
@@ -368,7 +371,20 @@ export function aggregateParallelOutputs(results: ParallelTaskResult[]): string
368
371
  return results
369
372
  .map((r, i) => {
370
373
  const header = `=== Parallel Task ${i + 1} (${r.agent}) ===`;
371
- return `${header}\n${r.output}`;
374
+ const hasTextOutput = Boolean(r.output?.trim());
375
+ const status = r.exitCode !== 0
376
+ ? `⚠️ FAILED (exit code ${r.exitCode})${r.error ? `: ${r.error}` : ""}`
377
+ : r.error
378
+ ? `⚠️ WARNING: ${r.error}`
379
+ : !hasTextOutput && r.outputTargetPath && r.outputTargetExists === false
380
+ ? `⚠️ EMPTY OUTPUT (expected output file missing: ${r.outputTargetPath})`
381
+ : !hasTextOutput && !r.outputTargetPath
382
+ ? "⚠️ EMPTY OUTPUT (no textual response returned)"
383
+ : "";
384
+ const body = status
385
+ ? (hasTextOutput ? `${status}\n${r.output}` : status)
386
+ : r.output;
387
+ return `${header}\n${body}`;
372
388
  })
373
389
  .join("\n\n");
374
390
  }
@@ -0,0 +1,55 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ export function resolveSingleOutputPath(
5
+ output: string | false | undefined,
6
+ runtimeCwd: string,
7
+ requestedCwd?: string,
8
+ ): string | undefined {
9
+ if (typeof output !== "string" || !output) return undefined;
10
+ if (path.isAbsolute(output)) return output;
11
+ const baseCwd = requestedCwd
12
+ ? (path.isAbsolute(requestedCwd) ? requestedCwd : path.resolve(runtimeCwd, requestedCwd))
13
+ : runtimeCwd;
14
+ return path.resolve(baseCwd, output);
15
+ }
16
+
17
+ export function injectSingleOutputInstruction(task: string, outputPath: string | undefined): string {
18
+ if (!outputPath) return task;
19
+ return `${task}\n\n---\n**Output:** Write your findings to: ${outputPath}`;
20
+ }
21
+
22
+ export function persistSingleOutput(
23
+ outputPath: string | undefined,
24
+ fullOutput: string,
25
+ ): { savedPath?: string; error?: string } {
26
+ if (!outputPath) return {};
27
+ try {
28
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
29
+ fs.writeFileSync(outputPath, fullOutput, "utf-8");
30
+ return { savedPath: outputPath };
31
+ } catch (err) {
32
+ return { error: err instanceof Error ? err.message : String(err) };
33
+ }
34
+ }
35
+
36
+ export function finalizeSingleOutput(params: {
37
+ fullOutput: string;
38
+ truncatedOutput?: string;
39
+ outputPath?: string;
40
+ exitCode: number;
41
+ }): { displayOutput: string; savedPath?: string; saveError?: string } {
42
+ let displayOutput = params.truncatedOutput || params.fullOutput;
43
+ if (params.outputPath && params.exitCode === 0) {
44
+ const save = persistSingleOutput(params.outputPath, params.fullOutput);
45
+ if (save.savedPath) {
46
+ displayOutput += `\n\n📄 Output saved to: ${save.savedPath}`;
47
+ return { displayOutput, savedPath: save.savedPath };
48
+ }
49
+ if (save.error) {
50
+ displayOutput += `\n\n⚠️ Failed to save output to: ${params.outputPath}\n${save.error}`;
51
+ return { displayOutput, saveError: save.error };
52
+ }
53
+ }
54
+ return { displayOutput };
55
+ }
package/skills.ts CHANGED
@@ -5,12 +5,24 @@
5
5
  import * as fs from "node:fs";
6
6
  import * as os from "node:os";
7
7
  import * as path from "node:path";
8
+ import { loadSkills, type Skill } from "@mariozechner/pi-coding-agent";
9
+
10
+ export type SkillSource =
11
+ | "project"
12
+ | "user"
13
+ | "project-package"
14
+ | "user-package"
15
+ | "project-settings"
16
+ | "user-settings"
17
+ | "extension"
18
+ | "builtin"
19
+ | "unknown";
8
20
 
9
21
  export interface ResolvedSkill {
10
22
  name: string;
11
23
  path: string;
12
24
  content: string;
13
- source: "project" | "user";
25
+ source: SkillSource;
14
26
  }
15
27
 
16
28
  interface SkillCacheEntry {
@@ -18,9 +30,35 @@ interface SkillCacheEntry {
18
30
  skill: ResolvedSkill;
19
31
  }
20
32
 
33
+ interface CachedSkillEntry {
34
+ name: string;
35
+ filePath: string;
36
+ source: SkillSource;
37
+ description?: string;
38
+ order: number;
39
+ }
40
+
21
41
  const skillCache = new Map<string, SkillCacheEntry>();
22
42
  const MAX_CACHE_SIZE = 50;
23
43
 
44
+ let loadSkillsCache: { cwd: string; skills: CachedSkillEntry[]; timestamp: number } | null = null;
45
+ const LOAD_SKILLS_CACHE_TTL_MS = 5000;
46
+
47
+ const CONFIG_DIR = ".pi";
48
+ const AGENT_DIR = path.join(os.homedir(), ".pi", "agent");
49
+
50
+ const SOURCE_PRIORITY: Record<SkillSource, number> = {
51
+ project: 700,
52
+ "project-settings": 650,
53
+ "project-package": 600,
54
+ user: 300,
55
+ "user-settings": 250,
56
+ "user-package": 200,
57
+ extension: 150,
58
+ builtin: 100,
59
+ unknown: 0,
60
+ };
61
+
24
62
  function stripSkillFrontmatter(content: string): string {
25
63
  const normalized = content.replace(/\r\n/g, "\n");
26
64
  if (!normalized.startsWith("---")) return normalized;
@@ -31,27 +69,187 @@ function stripSkillFrontmatter(content: string): string {
31
69
  return normalized.slice(endIndex + 4).trim();
32
70
  }
33
71
 
34
- export function resolveSkillPath(
35
- skillName: string,
36
- cwd: string,
37
- ): { path: string; source: "project" | "user" } | undefined {
38
- const projectPath = path.resolve(cwd, ".pi", "skills", skillName, "SKILL.md");
39
- if (fs.existsSync(projectPath)) {
40
- return { path: projectPath, source: "project" };
72
+ function isWithinPath(filePath: string, dir: string): boolean {
73
+ const relative = path.relative(dir, filePath);
74
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
75
+ }
76
+
77
+ function getPackageSkillPaths(packageRoot: string): string[] {
78
+ const pkgJsonPath = path.join(packageRoot, "package.json");
79
+ try {
80
+ const content = fs.readFileSync(pkgJsonPath, "utf-8");
81
+ const pkg = JSON.parse(content);
82
+ const piSkills = pkg?.pi?.skills;
83
+ if (!Array.isArray(piSkills)) return [];
84
+ return piSkills
85
+ .filter((s: unknown) => typeof s === "string")
86
+ .map((s: string) => path.resolve(packageRoot, s));
87
+ } catch {
88
+ return [];
41
89
  }
90
+ }
91
+
92
+ function collectPackageSkillPaths(cwd: string): string[] {
93
+ const dirs = [
94
+ path.join(cwd, CONFIG_DIR, "npm", "node_modules"),
95
+ path.join(AGENT_DIR, "npm", "node_modules"),
96
+ ];
97
+ const results: string[] = [];
42
98
 
43
- const userPath = path.join(os.homedir(), ".pi", "agent", "skills", skillName, "SKILL.md");
44
- if (fs.existsSync(userPath)) {
45
- return { path: userPath, source: "user" };
99
+ for (const dir of dirs) {
100
+ if (!fs.existsSync(dir)) continue;
101
+ let entries: fs.Dirent[];
102
+ try {
103
+ entries = fs.readdirSync(dir, { withFileTypes: true });
104
+ } catch {
105
+ continue;
106
+ }
107
+
108
+ for (const entry of entries) {
109
+ if (entry.name.startsWith(".")) continue;
110
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
111
+
112
+ if (entry.name.startsWith("@")) {
113
+ const scopeDir = path.join(dir, entry.name);
114
+ let scopeEntries: fs.Dirent[];
115
+ try {
116
+ scopeEntries = fs.readdirSync(scopeDir, { withFileTypes: true });
117
+ } catch {
118
+ continue;
119
+ }
120
+ for (const scopeEntry of scopeEntries) {
121
+ if (scopeEntry.name.startsWith(".")) continue;
122
+ if (!scopeEntry.isDirectory() && !scopeEntry.isSymbolicLink()) continue;
123
+ const pkgRoot = path.join(scopeDir, scopeEntry.name);
124
+ results.push(...getPackageSkillPaths(pkgRoot));
125
+ }
126
+ continue;
127
+ }
128
+
129
+ const pkgRoot = path.join(dir, entry.name);
130
+ results.push(...getPackageSkillPaths(pkgRoot));
131
+ }
46
132
  }
47
133
 
48
- return undefined;
134
+ return results;
135
+ }
136
+
137
+ function collectSettingsSkillPaths(cwd: string): string[] {
138
+ const results: string[] = [];
139
+ const settingsFiles = [
140
+ { file: path.join(cwd, CONFIG_DIR, "settings.json"), base: path.join(cwd, CONFIG_DIR) },
141
+ { file: path.join(AGENT_DIR, "settings.json"), base: AGENT_DIR },
142
+ ];
143
+
144
+ for (const { file, base } of settingsFiles) {
145
+ try {
146
+ const content = fs.readFileSync(file, "utf-8");
147
+ const settings = JSON.parse(content);
148
+ const skills = settings?.skills;
149
+ if (!Array.isArray(skills)) continue;
150
+ for (const entry of skills) {
151
+ if (typeof entry !== "string") continue;
152
+ let resolved = entry;
153
+ if (resolved.startsWith("~/")) {
154
+ resolved = path.join(os.homedir(), resolved.slice(2));
155
+ } else if (!path.isAbsolute(resolved)) {
156
+ resolved = path.resolve(base, resolved);
157
+ }
158
+ results.push(resolved);
159
+ }
160
+ } catch {}
161
+ }
162
+
163
+ return results;
164
+ }
165
+
166
+ function buildSkillPaths(cwd: string): string[] {
167
+ const defaultSkillPaths = [
168
+ path.join(cwd, CONFIG_DIR, "skills"),
169
+ path.join(AGENT_DIR, "skills"),
170
+ ];
171
+ const packagePaths = collectPackageSkillPaths(cwd);
172
+ const settingsPaths = collectSettingsSkillPaths(cwd);
173
+ return [...new Set([...defaultSkillPaths, ...packagePaths, ...settingsPaths])];
174
+ }
175
+
176
+ function inferSkillSource(rawSource: unknown, filePath: string, cwd: string): SkillSource {
177
+ const source = typeof rawSource === "string" ? rawSource : "";
178
+ const projectRoot = path.resolve(cwd, CONFIG_DIR);
179
+ const isProjectScoped = isWithinPath(filePath, projectRoot);
180
+ const isUserScoped = isWithinPath(filePath, AGENT_DIR);
181
+
182
+ if (source === "project") return "project";
183
+ if (source === "user") return "user";
184
+ if (source === "settings") {
185
+ if (isProjectScoped) return "project-settings";
186
+ if (isUserScoped) return "user-settings";
187
+ return "unknown";
188
+ }
189
+ if (source === "package") {
190
+ if (isProjectScoped) return "project-package";
191
+ if (isUserScoped) return "user-package";
192
+ return "unknown";
193
+ }
194
+ if (source === "extension") return "extension";
195
+ if (source === "builtin") return "builtin";
196
+
197
+ if (isProjectScoped) return "project";
198
+ if (isUserScoped) return "user";
199
+ return "unknown";
200
+ }
201
+
202
+ function chooseHigherPrioritySkill(existing: CachedSkillEntry | undefined, candidate: CachedSkillEntry): CachedSkillEntry {
203
+ if (!existing) return candidate;
204
+ const existingPriority = SOURCE_PRIORITY[existing.source] ?? 0;
205
+ const candidatePriority = SOURCE_PRIORITY[candidate.source] ?? 0;
206
+ if (candidatePriority > existingPriority) return candidate;
207
+ if (candidatePriority < existingPriority) return existing;
208
+ return candidate.order < existing.order ? candidate : existing;
209
+ }
210
+
211
+ function getCachedSkills(cwd: string): CachedSkillEntry[] {
212
+ const now = Date.now();
213
+ if (loadSkillsCache && loadSkillsCache.cwd === cwd && now - loadSkillsCache.timestamp < LOAD_SKILLS_CACHE_TTL_MS) {
214
+ return loadSkillsCache.skills;
215
+ }
216
+
217
+ const skillPaths = buildSkillPaths(cwd);
218
+ const loaded = loadSkills({ cwd, skillPaths, includeDefaults: false });
219
+ const dedupedByName = new Map<string, CachedSkillEntry>();
220
+
221
+ for (let i = 0; i < loaded.skills.length; i++) {
222
+ const skill = loaded.skills[i] as Skill;
223
+ const entry: CachedSkillEntry = {
224
+ name: skill.name,
225
+ filePath: skill.filePath,
226
+ source: inferSkillSource((skill as { source?: unknown }).source, skill.filePath, cwd),
227
+ description: skill.description,
228
+ order: i,
229
+ };
230
+ const current = dedupedByName.get(entry.name);
231
+ dedupedByName.set(entry.name, chooseHigherPrioritySkill(current, entry));
232
+ }
233
+
234
+ const skills = [...dedupedByName.values()].sort((a, b) => a.order - b.order);
235
+ loadSkillsCache = { cwd, skills, timestamp: now };
236
+ return skills;
237
+ }
238
+
239
+ export function resolveSkillPath(
240
+ skillName: string,
241
+ cwd: string,
242
+ ): { path: string; source: SkillSource } | undefined {
243
+ const skills = getCachedSkills(cwd);
244
+ const skill = skills.find((s) => s.name === skillName);
245
+ if (!skill) return undefined;
246
+ return { path: skill.filePath, source: skill.source };
49
247
  }
50
248
 
51
249
  export function readSkill(
52
250
  skillName: string,
53
251
  skillPath: string,
54
- source: "project" | "user",
252
+ source: SkillSource,
55
253
  ): ResolvedSkill | undefined {
56
254
  try {
57
255
  const stat = fs.statSync(skillPath);
@@ -123,84 +321,27 @@ export function normalizeSkillInput(
123
321
  if (input === false) return false;
124
322
  if (input === true || input === undefined) return undefined;
125
323
  if (Array.isArray(input)) {
126
- // Deduplicate while preserving order
127
324
  return [...new Set(input.map((s) => s.trim()).filter((s) => s.length > 0))];
128
325
  }
129
- // Deduplicate while preserving order
130
326
  return [...new Set(input.split(",").map((s) => s.trim()).filter((s) => s.length > 0))];
131
327
  }
132
328
 
133
- function isDirectory(p: string): boolean {
134
- try {
135
- return fs.statSync(p).isDirectory();
136
- } catch {
137
- return false;
138
- }
139
- }
140
-
141
329
  export function discoverAvailableSkills(cwd: string): Array<{
142
330
  name: string;
143
- source: "project" | "user";
331
+ source: SkillSource;
144
332
  description?: string;
145
333
  }> {
146
- const skills: Array<{ name: string; source: "project" | "user"; description?: string }> = [];
147
- const seen = new Set<string>();
148
-
149
- const scanDir = (dir: string, source: "project" | "user") => {
150
- if (!fs.existsSync(dir)) return;
151
-
152
- try {
153
- const entries = fs.readdirSync(dir, { withFileTypes: true });
154
- for (const entry of entries) {
155
- const fullPath = path.join(dir, entry.name);
156
-
157
- const isDir = entry.isDirectory() || (entry.isSymbolicLink() && isDirectory(fullPath));
158
- if (!isDir) continue;
159
-
160
- const skillPath = path.join(fullPath, "SKILL.md");
161
- if (!fs.existsSync(skillPath)) continue;
162
-
163
- if (source === "project" || !seen.has(entry.name)) {
164
- let description: string | undefined;
165
- try {
166
- const content = fs.readFileSync(skillPath, "utf-8");
167
- if (content.startsWith("---")) {
168
- const endIndex = content.indexOf("\n---", 3);
169
- if (endIndex !== -1) {
170
- const fmBlock = content.slice(0, endIndex);
171
- const match = fmBlock.match(/description:\s*(.+)/);
172
- if (match) {
173
- let desc = match[1].trim();
174
- if (
175
- (desc.startsWith("\"") && desc.endsWith("\"")) ||
176
- (desc.startsWith("'") && desc.endsWith("'"))
177
- ) {
178
- desc = desc.slice(1, -1);
179
- }
180
- description = desc;
181
- }
182
- }
183
- }
184
- } catch {}
185
-
186
- if (source === "project" && seen.has(entry.name)) {
187
- const idx = skills.findIndex((s) => s.name === entry.name);
188
- if (idx !== -1) skills.splice(idx, 1);
189
- }
190
-
191
- skills.push({ name: entry.name, source, description });
192
- seen.add(entry.name);
193
- }
194
- }
195
- } catch {}
196
- };
197
-
198
- scanDir(path.join(os.homedir(), ".pi", "agent", "skills"), "user");
199
- scanDir(path.resolve(cwd, ".pi", "skills"), "project");
200
-
201
- return skills.sort((a, b) => a.name.localeCompare(b.name));
334
+ const skills = getCachedSkills(cwd);
335
+ return skills
336
+ .map((s) => ({
337
+ name: s.name,
338
+ source: s.source,
339
+ description: s.description,
340
+ }))
341
+ .sort((a, b) => a.name.localeCompare(b.name));
202
342
  }
203
343
 
204
344
  export function clearSkillCache(): void {
205
345
  skillCache.clear();
346
+ loadSkillsCache = null;
206
347
  }