pi-subagents 0.13.4 → 0.14.1

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
@@ -20,7 +20,6 @@ function getTermWidth(): number {
20
20
  return process.stdout.columns || 120;
21
21
  }
22
22
 
23
- // Grapheme segmenter for proper Unicode handling (shared instance)
24
23
  const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
25
24
 
26
25
  /**
@@ -35,42 +34,38 @@ const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
35
34
  function truncLine(text: string, maxWidth: number): string {
36
35
  if (visibleWidth(text) <= maxWidth) return text;
37
36
 
38
- const targetWidth = maxWidth - 1; // Room for single ellipsis character
37
+ const targetWidth = maxWidth - 1;
39
38
  let result = "";
40
39
  let currentWidth = 0;
41
- let activeStyles: string[] = []; // Track ALL active styles (not just last)
40
+ let activeStyles: string[] = [];
42
41
  let i = 0;
43
42
 
44
43
  while (i < text.length) {
45
- // Check for ANSI escape code
46
44
  const ansiMatch = text.slice(i).match(/^\x1b\[[0-9;]*m/);
47
45
  if (ansiMatch) {
48
46
  const code = ansiMatch[0];
49
47
  result += code;
50
48
 
51
49
  if (code === "\x1b[0m" || code === "\x1b[m") {
52
- activeStyles = []; // Reset clears all styles
50
+ activeStyles = [];
53
51
  } else {
54
- activeStyles.push(code); // Stack styles (bold + color, etc.)
52
+ activeStyles.push(code);
55
53
  }
56
54
  i += code.length;
57
55
  continue;
58
56
  }
59
57
 
60
- // Find end of non-ANSI text segment
61
58
  let end = i;
62
59
  while (end < text.length && !text.slice(end).match(/^\x1b\[[0-9;]*m/)) {
63
60
  end++;
64
61
  }
65
62
 
66
- // Segment into graphemes for proper Unicode handling
67
63
  const textPortion = text.slice(i, end);
68
64
  for (const seg of segmenter.segment(textPortion)) {
69
65
  const grapheme = seg.segment;
70
66
  const graphemeWidth = visibleWidth(grapheme);
71
67
 
72
68
  if (currentWidth + graphemeWidth > targetWidth) {
73
- // Re-apply all active styles before ellipsis to preserve background/colors
74
69
  return result + activeStyles.join("") + "…";
75
70
  }
76
71
 
@@ -80,16 +75,11 @@ function truncLine(text: string, maxWidth: number): string {
80
75
  i = end;
81
76
  }
82
77
 
83
- // Reached end without exceeding width (shouldn't happen given initial check)
84
78
  return result + activeStyles.join("") + "…";
85
79
  }
86
80
 
87
- // Track last rendered widget state to avoid no-op re-renders
88
81
  let lastWidgetHash = "";
89
82
 
90
- /**
91
- * Compute a simple hash of job states for change detection
92
- */
93
83
  function computeWidgetHash(jobs: AsyncJobState[]): string {
94
84
  return jobs.slice(0, MAX_WIDGET_JOBS).map(job =>
95
85
  `${job.asyncId}:${job.status}:${job.currentStep}:${job.updatedAt}:${job.totalTokens?.total ?? 0}`
@@ -124,13 +114,11 @@ export function renderWidget(ctx: ExtensionContext, jobs: AsyncJobState[]): void
124
114
  return;
125
115
  }
126
116
 
127
- // Check if anything changed since last render
128
- // Always re-render if any displayed job is running (output tail updates constantly)
129
117
  const displayedJobs = jobs.slice(0, MAX_WIDGET_JOBS);
130
118
  const hasRunningJobs = displayedJobs.some(job => job.status === "running");
131
119
  const newHash = computeWidgetHash(jobs);
132
120
  if (!hasRunningJobs && newHash === lastWidgetHash) {
133
- return; // Skip re-render, nothing changed
121
+ return;
134
122
  }
135
123
  lastWidgetHash = newHash;
136
124
 
@@ -194,12 +182,12 @@ export function renderSubagentResult(
194
182
  const r = d.results[0];
195
183
  const isRunning = r.progress?.status === "running";
196
184
  const icon = isRunning
197
- ? theme.fg("warning", "...")
185
+ ? theme.fg("warning", "running")
198
186
  : r.detached
199
- ? theme.fg("warning", "")
187
+ ? theme.fg("warning", "detached")
200
188
  : r.exitCode === 0
201
189
  ? theme.fg("success", "ok")
202
- : theme.fg("error", "X");
190
+ : theme.fg("error", "failed");
203
191
  const contextBadge = d.context === "fork" ? theme.fg("warning", " [fork]") : "";
204
192
  const output = r.truncation?.text || getSingleResultOutput(r);
205
193
 
@@ -265,7 +253,7 @@ export function renderSubagentResult(
265
253
  c.addChild(new Text(truncLine(theme.fg("dim", `Skills: ${r.skills.join(", ")}`), w), 0, 0));
266
254
  }
267
255
  if (r.skillsWarning) {
268
- c.addChild(new Text(truncLine(theme.fg("warning", `⚠️ ${r.skillsWarning}`), w), 0, 0));
256
+ c.addChild(new Text(truncLine(theme.fg("warning", `Warning: ${r.skillsWarning}`), w), 0, 0));
269
257
  }
270
258
  if (r.attemptedModels && r.attemptedModels.length > 1) {
271
259
  c.addChild(new Text(truncLine(theme.fg("dim", `Fallbacks: ${r.attemptedModels.join(" → ")}`), w), 0, 0));
@@ -291,12 +279,12 @@ export function renderSubagentResult(
291
279
  && hasEmptyTextOutputWithoutOutputTarget(r.task, getSingleResultOutput(r)),
292
280
  );
293
281
  const icon = hasRunning
294
- ? theme.fg("warning", "...")
282
+ ? theme.fg("warning", "running")
295
283
  : hasEmptyWithoutTarget
296
- ? theme.fg("warning", "")
284
+ ? theme.fg("warning", "warning")
297
285
  : ok === d.results.length
298
286
  ? theme.fg("success", "ok")
299
- : theme.fg("error", "X");
287
+ : theme.fg("error", "failed");
300
288
 
301
289
  const totalSummary =
302
290
  d.progressSummary ||
@@ -323,17 +311,11 @@ export function renderSubagentResult(
323
311
 
324
312
  const modeLabel = d.mode;
325
313
  const contextBadge = d.context === "fork" ? theme.fg("warning", " [fork]") : "";
326
- // For parallel-in-chain, show task count (results) for consistency with step display
327
- // For sequential chains, show logical step count
328
314
  const hasParallelInChain = d.chainAgents?.some((a) => a.startsWith("["));
329
315
  const totalCount = hasParallelInChain ? d.results.length : (d.totalSteps ?? d.results.length);
330
316
  const currentStep = d.currentStepIndex !== undefined ? d.currentStepIndex + 1 : ok + 1;
331
317
  const stepInfo = hasRunning ? ` ${currentStep}/${totalCount}` : ` ${ok}/${totalCount}`;
332
318
 
333
- // Build chain visualization: "scout → planner" with status icons
334
- // Note: Only works correctly for sequential chains. Chains with parallel steps
335
- // (indicated by "[agent1+agent2]" format) have multiple results per step,
336
- // breaking the 1:1 mapping between chainAgents and results.
337
319
  const chainVis = d.chainAgents?.length && !hasParallelInChain
338
320
  ? d.chainAgents
339
321
  .map((agent, i) => {
@@ -345,14 +327,14 @@ export function renderSubagentResult(
345
327
  && hasEmptyTextOutputWithoutOutputTarget(result.task, getSingleResultOutput(result));
346
328
  const isCurrent = i === (d.currentStepIndex ?? d.results.length);
347
329
  const stepIcon = isFailed
348
- ? theme.fg("error", "")
330
+ ? theme.fg("error", "failed")
349
331
  : isEmptyWithoutTarget
350
- ? theme.fg("warning", "")
332
+ ? theme.fg("warning", "warning")
351
333
  : isComplete
352
- ? theme.fg("success", "")
334
+ ? theme.fg("success", "done")
353
335
  : isCurrent && hasRunning
354
- ? theme.fg("warning", "")
355
- : theme.fg("dim", "");
336
+ ? theme.fg("warning", "running")
337
+ : theme.fg("dim", "pending");
356
338
  return `${stepIcon} ${agent}`;
357
339
  })
358
340
  .join(theme.fg("dim", " → "))
@@ -367,15 +349,10 @@ export function renderSubagentResult(
367
349
  0,
368
350
  ),
369
351
  );
370
- // Show chain visualization
371
352
  if (chainVis) {
372
353
  c.addChild(new Text(truncLine(` ${chainVis}`, w), 0, 0));
373
354
  }
374
355
 
375
- // === STATIC STEP LAYOUT (like clarification UI) ===
376
- // Each step gets a fixed section with task/output/status
377
- // Note: For chains with parallel steps, chainAgents indices don't map 1:1 to results
378
- // (parallel steps produce multiple results). Fall back to result-based iteration.
379
356
  const useResultsDirectly = hasParallelInChain || !d.chainAgents?.length;
380
357
  const stepsToShow = useResultsDirectly ? d.results.length : d.chainAgents!.length;
381
358
 
@@ -388,9 +365,8 @@ export function renderSubagentResult(
388
365
  : (d.chainAgents![i] || r?.agent || `step-${i + 1}`);
389
366
 
390
367
  if (!r) {
391
- // Pending step
392
368
  c.addChild(new Text(truncLine(theme.fg("dim", ` Step ${i + 1}: ${agentName}`), w), 0, 0));
393
- c.addChild(new Text(theme.fg("dim", ` status: pending`), 0, 0));
369
+ c.addChild(new Text(theme.fg("dim", ` status: pending`), 0, 0));
394
370
  c.addChild(new Spacer(1));
395
371
  continue;
396
372
  }
@@ -402,12 +378,12 @@ export function renderSubagentResult(
402
378
 
403
379
  const resultOutput = getSingleResultOutput(r);
404
380
  const statusIcon = rRunning
405
- ? theme.fg("warning", "")
381
+ ? theme.fg("warning", "running")
406
382
  : r.exitCode !== 0
407
- ? theme.fg("error", "")
383
+ ? theme.fg("error", "failed")
408
384
  : hasEmptyTextOutputWithoutOutputTarget(r.task, resultOutput)
409
- ? theme.fg("warning", "")
410
- : theme.fg("success", "");
385
+ ? theme.fg("warning", "warning")
386
+ : theme.fg("success", "done");
411
387
  const stats = rProg ? ` | ${rProg.toolCount} tools, ${formatDuration(rProg.durationMs)}` : "";
412
388
  const modelDisplay = r.model ? theme.fg("dim", ` (${r.model})`) : "";
413
389
  const stepHeader = rRunning
@@ -430,7 +406,7 @@ export function renderSubagentResult(
430
406
  c.addChild(new Text(truncLine(theme.fg("dim", ` skills: ${r.skills.join(", ")}`), w), 0, 0));
431
407
  }
432
408
  if (r.skillsWarning) {
433
- c.addChild(new Text(truncLine(theme.fg("warning", ` ⚠️ ${r.skillsWarning}`), w), 0, 0));
409
+ c.addChild(new Text(truncLine(theme.fg("warning", ` Warning: ${r.skillsWarning}`), w), 0, 0));
434
410
  }
435
411
  if (r.attemptedModels && r.attemptedModels.length > 1) {
436
412
  c.addChild(new Text(truncLine(theme.fg("dim", ` fallbacks: ${r.attemptedModels.join(" → ")}`), w), 0, 0));
@@ -440,7 +416,6 @@ export function renderSubagentResult(
440
416
  if (rProg.skills?.length) {
441
417
  c.addChild(new Text(truncLine(theme.fg("accent", ` skills: ${rProg.skills.join(", ")}`), w), 0, 0));
442
418
  }
443
- // Current tool for running step
444
419
  if (rProg.currentTool) {
445
420
  const maxToolArgsLen = Math.max(50, w - 20);
446
421
  const toolArgsPreview = rProg.currentToolArgs
@@ -453,7 +428,6 @@ export function renderSubagentResult(
453
428
  : rProg.currentTool;
454
429
  c.addChild(new Text(truncLine(theme.fg("warning", ` > ${toolLine}`), w), 0, 0));
455
430
  }
456
- // Recent tools
457
431
  if (rProg.recentTools?.length) {
458
432
  for (const t of rProg.recentTools.slice(-3)) {
459
433
  const maxArgsLen = Math.max(40, w - 30);
@@ -463,7 +437,6 @@ export function renderSubagentResult(
463
437
  c.addChild(new Text(truncLine(theme.fg("dim", ` ${t.tool}: ${argsPreview}`), w), 0, 0));
464
438
  }
465
439
  }
466
- // Recent output - let truncLine handle truncation entirely
467
440
  const recentLines = (rProg.recentOutput ?? []).slice(-5);
468
441
  for (const line of recentLines) {
469
442
  c.addChild(new Text(truncLine(theme.fg("dim", ` ${line}`), w), 0, 0));
package/schemas.ts CHANGED
@@ -83,7 +83,7 @@ export const SubagentParams = Type.Object({
83
83
  enum: ["fresh", "fork"],
84
84
  description: "'fresh' (default) or 'fork' to branch from parent session",
85
85
  })),
86
- chainDir: Type.Optional(Type.String({ description: "Persistent directory for chain artifacts. Default: <tmpdir>/pi-chain-runs/ (auto-cleaned after 24h)" })),
86
+ chainDir: Type.Optional(Type.String({ description: "Persistent directory for chain artifacts. Default: a user-scoped temp directory under <tmpdir>/ (auto-cleaned after 24h)" })),
87
87
  async: Type.Optional(Type.Boolean({ description: "Run in background (default: false, or per config)" })),
88
88
  agentScope: Type.Optional(Type.String({ description: "Agent discovery scope: 'user', 'project', or 'both' (default: 'both'; project wins on name collisions)" })),
89
89
  cwd: Type.Optional(Type.String()),
package/settings.ts CHANGED
@@ -3,12 +3,10 @@
3
3
  */
4
4
 
5
5
  import * as fs from "node:fs";
6
- import * as os from "node:os";
7
6
  import * as path from "node:path";
8
7
  import type { AgentConfig } from "./agents.ts";
9
8
  import { normalizeSkillInput } from "./skills.ts";
10
-
11
- const CHAIN_RUNS_DIR = path.join(os.tmpdir(), "pi-chain-runs");
9
+ import { CHAIN_RUNS_DIR } from "./types.ts";
12
10
  const CHAIN_DIR_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
13
11
 
14
12
  // =============================================================================
@@ -100,7 +98,9 @@ export function createChainDir(runId: string, baseDir?: string): string {
100
98
  export function removeChainDir(chainDir: string): void {
101
99
  try {
102
100
  fs.rmSync(chainDir, { recursive: true });
103
- } catch {}
101
+ } catch {
102
+ // Chain cleanup is best-effort. Runs can already have cleaned their temp dir.
103
+ }
104
104
  }
105
105
 
106
106
  export function cleanupOldChainDirs(): void {
@@ -110,6 +110,8 @@ export function cleanupOldChainDirs(): void {
110
110
  try {
111
111
  dirs = fs.readdirSync(CHAIN_RUNS_DIR);
112
112
  } catch {
113
+ // Startup cleanup is best-effort. If the scoped temp root is unreadable,
114
+ // skip cleanup instead of failing extension startup.
113
115
  return;
114
116
  }
115
117
 
package/single-output.ts CHANGED
@@ -84,11 +84,11 @@ export function finalizeSingleOutput(params: {
84
84
  }): { displayOutput: string; savedPath?: string; saveError?: string } {
85
85
  let displayOutput = params.truncatedOutput || params.fullOutput;
86
86
  if (params.exitCode === 0 && params.savedPath) {
87
- displayOutput += `\n\n📄 Output saved to: ${params.savedPath}`;
87
+ displayOutput += `\n\nOutput saved to: ${params.savedPath}`;
88
88
  return { displayOutput, savedPath: params.savedPath };
89
89
  }
90
90
  if (params.exitCode === 0 && params.saveError && params.outputPath) {
91
- displayOutput += `\n\n⚠️ Failed to save output to: ${params.outputPath}\n${params.saveError}`;
91
+ displayOutput += `\n\nFailed to save output to: ${params.outputPath}\n${params.saveError}`;
92
92
  return { displayOutput, saveError: params.saveError };
93
93
  }
94
94
  return { displayOutput };
package/skills.ts CHANGED
@@ -38,6 +38,11 @@ interface CachedSkillEntry {
38
38
  order: number;
39
39
  }
40
40
 
41
+ interface SkillSearchPath {
42
+ path: string;
43
+ source: SkillSource;
44
+ }
45
+
41
46
  const skillCache = new Map<string, SkillCacheEntry>();
42
47
  const MAX_CACHE_SIZE = 50;
43
48
 
@@ -74,22 +79,45 @@ function isWithinPath(filePath: string, dir: string): boolean {
74
79
  return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
75
80
  }
76
81
 
77
- function getPackageSkillPaths(packageRoot: string): string[] {
78
- const pkgJsonPath = path.join(packageRoot, "package.json");
82
+ function readOptionalJsonFile(filePath: string, label: string): unknown {
83
+ try {
84
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
85
+ } catch (error) {
86
+ const code = typeof error === "object" && error !== null && "code" in error
87
+ ? (error as { code?: unknown }).code
88
+ : undefined;
89
+ if (code === "ENOENT") return null;
90
+ const message = error instanceof Error ? error.message : String(error);
91
+ throw new Error(`Failed to read ${label} '${filePath}': ${message}`, {
92
+ cause: error instanceof Error ? error : undefined,
93
+ });
94
+ }
95
+ }
96
+
97
+ function readJsonFileBestEffort(filePath: string): unknown {
79
98
  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));
99
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
87
100
  } catch {
88
- // Package scanning is opportunistic; ignore malformed/missing package metadata.
89
- return [];
101
+ // Package scans over installed dependencies are opportunistic.
102
+ return null;
90
103
  }
91
104
  }
92
105
 
106
+ function extractSkillPathsFromPackageRoot(packageRoot: string, source: SkillSource, bestEffort = false): SkillSearchPath[] {
107
+ const packageJsonPath = path.join(packageRoot, "package.json");
108
+ const pkg = bestEffort
109
+ ? readJsonFileBestEffort(packageJsonPath)
110
+ : readOptionalJsonFile(packageJsonPath, "package manifest");
111
+ if (!pkg || typeof pkg !== "object" || Array.isArray(pkg)) return [];
112
+ const pi = (pkg as { pi?: unknown }).pi;
113
+ if (!pi || typeof pi !== "object" || Array.isArray(pi)) return [];
114
+ const skills = (pi as { skills?: unknown }).skills;
115
+ if (!Array.isArray(skills)) return [];
116
+ return skills
117
+ .filter((entry): entry is string => typeof entry === "string")
118
+ .map((entry) => ({ path: path.resolve(packageRoot, entry), source }));
119
+ }
120
+
93
121
  let cachedGlobalNpmRoot: string | null = null;
94
122
 
95
123
  function getGlobalNpmRoot(): string | null {
@@ -104,27 +132,25 @@ function getGlobalNpmRoot(): string | null {
104
132
  }
105
133
  }
106
134
 
107
- function collectPackageSkillPaths(cwd: string): string[] {
108
- const dirs = [
109
- path.join(cwd, CONFIG_DIR, "npm", "node_modules"),
110
- path.join(AGENT_DIR, "npm", "node_modules"),
135
+ function collectInstalledPackageSkillPaths(cwd: string): SkillSearchPath[] {
136
+ const dirs: SkillSearchPath[] = [
137
+ { path: path.join(cwd, CONFIG_DIR, "npm", "node_modules"), source: "project-package" },
138
+ { path: path.join(AGENT_DIR, "npm", "node_modules"), source: "user-package" },
111
139
  ];
112
-
113
- // Add global npm root if available (where pi installs global packages)
140
+
114
141
  const globalRoot = getGlobalNpmRoot();
115
142
  if (globalRoot) {
116
- dirs.push(globalRoot);
143
+ dirs.push({ path: globalRoot, source: "user-package" });
117
144
  }
118
-
119
- const results: string[] = [];
145
+
146
+ const results: SkillSearchPath[] = [];
120
147
 
121
148
  for (const dir of dirs) {
122
- if (!fs.existsSync(dir)) continue;
149
+ if (!fs.existsSync(dir.path)) continue;
123
150
  let entries: fs.Dirent[];
124
151
  try {
125
- entries = fs.readdirSync(dir, { withFileTypes: true });
152
+ entries = fs.readdirSync(dir.path, { withFileTypes: true });
126
153
  } catch {
127
- // Ignore unreadable package roots and continue scanning other roots.
128
154
  continue;
129
155
  }
130
156
 
@@ -133,75 +159,125 @@ function collectPackageSkillPaths(cwd: string): string[] {
133
159
  if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
134
160
 
135
161
  if (entry.name.startsWith("@")) {
136
- const scopeDir = path.join(dir, entry.name);
162
+ const scopeDir = path.join(dir.path, entry.name);
137
163
  let scopeEntries: fs.Dirent[];
138
164
  try {
139
165
  scopeEntries = fs.readdirSync(scopeDir, { withFileTypes: true });
140
166
  } catch {
141
- // Ignore unreadable scoped package directories and continue.
142
167
  continue;
143
168
  }
144
169
  for (const scopeEntry of scopeEntries) {
145
170
  if (scopeEntry.name.startsWith(".")) continue;
146
171
  if (!scopeEntry.isDirectory() && !scopeEntry.isSymbolicLink()) continue;
147
172
  const pkgRoot = path.join(scopeDir, scopeEntry.name);
148
- results.push(...getPackageSkillPaths(pkgRoot));
173
+ results.push(...extractSkillPathsFromPackageRoot(pkgRoot, dir.source, true));
149
174
  }
150
175
  continue;
151
176
  }
152
177
 
153
- const pkgRoot = path.join(dir, entry.name);
154
- results.push(...getPackageSkillPaths(pkgRoot));
178
+ const pkgRoot = path.join(dir.path, entry.name);
179
+ results.push(...extractSkillPathsFromPackageRoot(pkgRoot, dir.source, true));
155
180
  }
156
181
  }
157
182
 
158
183
  return results;
159
184
  }
160
185
 
161
- function collectSettingsSkillPaths(cwd: string): string[] {
162
- const results: string[] = [];
186
+ function collectSettingsSkillPaths(cwd: string): SkillSearchPath[] {
187
+ const results: SkillSearchPath[] = [];
163
188
  const settingsFiles = [
164
- { file: path.join(cwd, CONFIG_DIR, "settings.json"), base: path.join(cwd, CONFIG_DIR) },
165
- { file: path.join(AGENT_DIR, "settings.json"), base: AGENT_DIR },
189
+ { file: path.join(cwd, CONFIG_DIR, "settings.json"), base: path.join(cwd, CONFIG_DIR), source: "project-settings" as const },
190
+ { file: path.join(AGENT_DIR, "settings.json"), base: AGENT_DIR, source: "user-settings" as const },
166
191
  ];
167
192
 
168
- for (const { file, base } of settingsFiles) {
169
- try {
170
- const content = fs.readFileSync(file, "utf-8");
171
- const settings = JSON.parse(content);
172
- const skills = settings?.skills;
173
- if (!Array.isArray(skills)) continue;
174
- for (const entry of skills) {
175
- if (typeof entry !== "string") continue;
176
- let resolved = entry;
177
- if (resolved.startsWith("~/")) {
178
- resolved = path.join(os.homedir(), resolved.slice(2));
179
- } else if (!path.isAbsolute(resolved)) {
180
- resolved = path.resolve(base, resolved);
181
- }
182
- results.push(resolved);
193
+ for (const { file, base, source } of settingsFiles) {
194
+ const settings = readOptionalJsonFile(file, "skills settings file");
195
+ if (!settings || typeof settings !== "object" || Array.isArray(settings)) continue;
196
+ const skills = (settings as { skills?: unknown }).skills;
197
+ if (!Array.isArray(skills)) continue;
198
+ for (const entry of skills) {
199
+ if (typeof entry !== "string") continue;
200
+ let resolved = entry;
201
+ if (resolved.startsWith("~/")) {
202
+ resolved = path.join(os.homedir(), resolved.slice(2));
203
+ } else if (!path.isAbsolute(resolved)) {
204
+ resolved = path.resolve(base, resolved);
183
205
  }
184
- } catch {
185
- // Settings-provided skills are optional; ignore malformed or missing settings files.
206
+ results.push({ path: resolved, source });
207
+ }
208
+ }
209
+
210
+ return results;
211
+ }
212
+
213
+ function resolveSettingsPackageRoot(source: string, baseDir: string): string | undefined {
214
+ const trimmed = source.trim();
215
+ if (!trimmed) return undefined;
216
+ const normalized = trimmed.startsWith("file:") ? trimmed.slice(5) : trimmed;
217
+ if (normalized === "~") return os.homedir();
218
+ if (normalized.startsWith("~/")) return path.join(os.homedir(), normalized.slice(2));
219
+ if (path.isAbsolute(normalized)) return normalized;
220
+ if (normalized === "." || normalized === ".." || normalized.startsWith("./") || normalized.startsWith("../")) {
221
+ return path.resolve(baseDir, normalized);
222
+ }
223
+ return undefined;
224
+ }
225
+
226
+ function collectSettingsPackageSkillPaths(cwd: string): SkillSearchPath[] {
227
+ const settingsFiles = [
228
+ { file: path.join(cwd, CONFIG_DIR, "settings.json"), base: path.join(cwd, CONFIG_DIR), source: "project-package" as const },
229
+ { file: path.join(AGENT_DIR, "settings.json"), base: AGENT_DIR, source: "user-package" as const },
230
+ ];
231
+ const results: SkillSearchPath[] = [];
232
+
233
+ for (const { file, base, source } of settingsFiles) {
234
+ const settings = readOptionalJsonFile(file, "skills settings file");
235
+ if (!settings || typeof settings !== "object" || Array.isArray(settings)) continue;
236
+ const packages = (settings as { packages?: unknown }).packages;
237
+ if (!Array.isArray(packages)) continue;
238
+
239
+ for (const entry of packages) {
240
+ const packageSource = typeof entry === "string"
241
+ ? entry
242
+ : typeof entry === "object" && entry !== null && typeof (entry as { source?: unknown }).source === "string"
243
+ ? (entry as { source: string }).source
244
+ : undefined;
245
+ if (!packageSource) continue;
246
+
247
+ const packageRoot = resolveSettingsPackageRoot(packageSource, base);
248
+ if (!packageRoot) continue;
249
+ results.push(...extractSkillPathsFromPackageRoot(packageRoot, source));
186
250
  }
187
251
  }
188
252
 
189
253
  return results;
190
254
  }
191
255
 
192
- function buildSkillPaths(cwd: string): string[] {
193
- const defaultSkillPaths = [
194
- path.join(cwd, CONFIG_DIR, "skills"),
195
- path.join(cwd, ".agents", "skills"),
196
- path.join(AGENT_DIR, "skills"),
197
- path.join(os.homedir(), ".agents", "skills"),
256
+ function buildSkillPaths(cwd: string): SkillSearchPath[] {
257
+ const skillPaths: SkillSearchPath[] = [
258
+ { path: path.join(cwd, CONFIG_DIR, "skills"), source: "project" },
259
+ { path: path.join(cwd, ".agents", "skills"), source: "project" },
260
+ { path: path.join(AGENT_DIR, "skills"), source: "user" },
261
+ { path: path.join(os.homedir(), ".agents", "skills"), source: "user" },
262
+ ...collectInstalledPackageSkillPaths(cwd),
263
+ ...collectSettingsPackageSkillPaths(cwd),
264
+ ...extractSkillPathsFromPackageRoot(cwd, "project-package"),
265
+ ...collectSettingsSkillPaths(cwd),
198
266
  ];
199
- const packagePaths = collectPackageSkillPaths(cwd);
200
- const settingsPaths = collectSettingsSkillPaths(cwd);
201
- return [...new Set([...defaultSkillPaths, ...packagePaths, ...settingsPaths])];
267
+
268
+ const deduped = new Map<string, SkillSearchPath>();
269
+ for (const entry of skillPaths) {
270
+ const resolvedPath = path.resolve(entry.path);
271
+ if (!deduped.has(resolvedPath)) {
272
+ deduped.set(resolvedPath, { path: resolvedPath, source: entry.source });
273
+ }
274
+ }
275
+ return [...deduped.values()];
202
276
  }
203
277
 
204
- function inferSkillSource(filePath: string, cwd: string): SkillSource {
278
+ function inferSkillSource(filePath: string, cwd: string, sourceHint?: SkillSource): SkillSource {
279
+ if (sourceHint) return sourceHint;
280
+
205
281
  const projectConfigRoot = path.resolve(cwd, CONFIG_DIR);
206
282
  const projectSkillsRoot = path.resolve(cwd, CONFIG_DIR, "skills");
207
283
  const projectPackagesRoot = path.resolve(cwd, CONFIG_DIR, "npm", "node_modules");
@@ -252,12 +328,12 @@ function maybeReadSkillDescription(filePath: string): string | undefined {
252
328
  }
253
329
  }
254
330
 
255
- function collectFilesystemSkills(cwd: string, skillPaths: string[]): CachedSkillEntry[] {
331
+ function collectFilesystemSkills(cwd: string, skillPaths: SkillSearchPath[]): CachedSkillEntry[] {
256
332
  const entries: CachedSkillEntry[] = [];
257
333
  const seen = new Set<string>();
258
334
  let order = 0;
259
335
 
260
- const pushEntry = (name: string, filePath: string) => {
336
+ const pushEntry = (name: string, filePath: string, sourceHint?: SkillSource) => {
261
337
  const resolvedFile = path.resolve(filePath);
262
338
  if (seen.has(resolvedFile)) return;
263
339
  if (!fs.existsSync(resolvedFile)) return;
@@ -265,60 +341,58 @@ function collectFilesystemSkills(cwd: string, skillPaths: string[]): CachedSkill
265
341
  entries.push({
266
342
  name,
267
343
  filePath: resolvedFile,
268
- source: inferSkillSource(resolvedFile, cwd),
344
+ source: inferSkillSource(resolvedFile, cwd, sourceHint),
269
345
  description: maybeReadSkillDescription(resolvedFile),
270
346
  order: order++,
271
347
  });
272
348
  };
273
349
 
274
350
  for (const skillPath of skillPaths) {
275
- if (!fs.existsSync(skillPath)) continue;
351
+ if (!fs.existsSync(skillPath.path)) continue;
276
352
 
277
353
  let stat: fs.Stats;
278
354
  try {
279
- stat = fs.statSync(skillPath);
355
+ stat = fs.statSync(skillPath.path);
280
356
  } catch {
281
- // Ignore paths that disappear or become unreadable during discovery.
282
357
  continue;
283
358
  }
284
359
 
285
360
  if (stat.isFile()) {
286
- const fileName = path.basename(skillPath);
361
+ const fileName = path.basename(skillPath.path);
287
362
  if (!fileName.toLowerCase().endsWith(".md")) continue;
288
363
  const skillName = fileName.toLowerCase() === "skill.md"
289
- ? path.basename(path.dirname(skillPath))
364
+ ? path.basename(path.dirname(skillPath.path))
290
365
  : path.basename(fileName, path.extname(fileName));
291
- pushEntry(skillName, skillPath);
366
+ pushEntry(skillName, skillPath.path, skillPath.source);
292
367
  continue;
293
368
  }
294
369
 
295
370
  if (!stat.isDirectory()) continue;
296
371
 
297
- const rootSkillFile = path.join(skillPath, "SKILL.md");
372
+ const rootSkillFile = path.join(skillPath.path, "SKILL.md");
298
373
  if (fs.existsSync(rootSkillFile)) {
299
- pushEntry(path.basename(skillPath), rootSkillFile);
374
+ pushEntry(path.basename(skillPath.path), rootSkillFile, skillPath.source);
300
375
  }
301
376
 
302
377
  let childEntries: fs.Dirent[];
303
378
  try {
304
- childEntries = fs.readdirSync(skillPath, { withFileTypes: true });
379
+ childEntries = fs.readdirSync(skillPath.path, { withFileTypes: true });
305
380
  } catch {
306
- // Ignore unreadable skill directories and continue scanning.
307
381
  continue;
308
382
  }
309
383
 
310
384
  for (const child of childEntries) {
311
385
  if (child.name.startsWith(".")) continue;
312
- const childPath = path.join(skillPath, child.name);
386
+ const childPath = path.join(skillPath.path, child.name);
313
387
  if (child.isDirectory() || child.isSymbolicLink()) {
314
388
  const nestedSkillPath = path.join(childPath, "SKILL.md");
315
389
  if (fs.existsSync(nestedSkillPath)) {
316
- pushEntry(child.name, nestedSkillPath);
390
+ pushEntry(child.name, nestedSkillPath, skillPath.source);
317
391
  }
318
392
  continue;
319
393
  }
320
394
  if (child.isFile() && child.name.toLowerCase().endsWith(".md")) {
321
- pushEntry(path.basename(child.name, path.extname(child.name)), childPath);
395
+ pushEntry(path.basename(child.name, path.extname(child.name)), childPath, skillPath.source);
322
396
  }
323
397
  }
324
398
  }
@@ -418,6 +492,22 @@ export function resolveSkills(
418
492
  return { resolved, missing };
419
493
  }
420
494
 
495
+ export function resolveSkillsWithFallback(
496
+ skillNames: string[],
497
+ primaryCwd: string,
498
+ fallbackCwd?: string,
499
+ ): { resolved: ResolvedSkill[]; missing: string[] } {
500
+ const primary = resolveSkills(skillNames, primaryCwd);
501
+ if (!fallbackCwd || primary.missing.length === 0) return primary;
502
+ if (path.resolve(primaryCwd) === path.resolve(fallbackCwd)) return primary;
503
+
504
+ const fallback = resolveSkills(primary.missing, fallbackCwd);
505
+ return {
506
+ resolved: [...primary.resolved, ...fallback.resolved],
507
+ missing: fallback.missing,
508
+ };
509
+ }
510
+
421
511
  export function buildSkillInjection(skills: ResolvedSkill[]): string {
422
512
  if (skills.length === 0) return "";
423
513