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/CHANGELOG.md +23 -4
- package/README.md +35 -14
- package/agent-management.ts +15 -6
- package/agent-manager-detail.ts +13 -3
- package/agent-manager-edit.ts +75 -23
- package/agent-manager-list.ts +12 -5
- package/agent-manager.ts +199 -11
- package/agents.ts +315 -20
- package/artifacts.ts +11 -5
- package/async-execution.ts +92 -73
- package/chain-clarify.ts +49 -160
- package/chain-execution.ts +38 -76
- package/execution.ts +53 -48
- package/index.ts +1 -1
- package/install.mjs +3 -3
- package/model-fallback.ts +8 -2
- package/package.json +1 -1
- package/parallel-utils.ts +5 -5
- package/prompt-template-bridge.ts +19 -8
- package/render.ts +23 -50
- package/schemas.ts +1 -1
- package/settings.ts +6 -4
- package/single-output.ts +2 -2
- package/skills.ts +165 -75
- package/subagent-executor.ts +52 -18
- package/subagent-runner.ts +171 -54
- package/types.ts +65 -14
- package/utils.ts +52 -21
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;
|
|
37
|
+
const targetWidth = maxWidth - 1;
|
|
39
38
|
let result = "";
|
|
40
39
|
let currentWidth = 0;
|
|
41
|
-
let activeStyles: string[] = [];
|
|
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 = [];
|
|
50
|
+
activeStyles = [];
|
|
53
51
|
} else {
|
|
54
|
-
activeStyles.push(code);
|
|
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;
|
|
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", "
|
|
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",
|
|
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", "
|
|
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:
|
|
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", `
|
|
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>/
|
|
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\
|
|
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\
|
|
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
|
|
78
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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(...
|
|
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(...
|
|
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):
|
|
162
|
-
const results:
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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):
|
|
193
|
-
const
|
|
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
|
-
|
|
200
|
-
const
|
|
201
|
-
|
|
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:
|
|
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
|
|