pi-subagents 0.8.2 → 0.8.3
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 -1
- package/README.md +43 -18
- package/agent-management.ts +7 -0
- package/agent-manager-detail.ts +4 -0
- package/agent-manager-edit.ts +4 -2
- package/agent-scope.ts +6 -0
- package/agent-selection.ts +20 -0
- package/agent-serializer.ts +6 -0
- package/agents.ts +13 -12
- package/artifacts.ts +2 -1
- package/async-execution.ts +8 -18
- package/chain-execution.ts +15 -7
- package/completion-dedupe.ts +63 -0
- package/execution.ts +36 -13
- package/file-coalescer.ts +40 -0
- package/index.ts +40 -22
- package/jsonl-writer.ts +72 -0
- package/notify.ts +4 -13
- package/package.json +2 -2
- package/pi-spawn.ts +77 -0
- package/render.ts +44 -18
- package/schemas.ts +5 -4
- package/settings.ts +18 -2
- package/single-output.ts +55 -0
- package/skills.ts +221 -80
- package/subagent-runner.ts +32 -6
- package/types.ts +4 -2
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:
|
|
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: '
|
|
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 = "
|
|
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
|
-
|
|
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
|
}
|
package/single-output.ts
ADDED
|
@@ -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:
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
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:
|
|
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:
|
|
331
|
+
source: SkillSource;
|
|
144
332
|
description?: string;
|
|
145
333
|
}> {
|
|
146
|
-
const skills
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
}
|
package/subagent-runner.ts
CHANGED
|
@@ -5,6 +5,8 @@ import * as os from "node:os";
|
|
|
5
5
|
import * as path from "node:path";
|
|
6
6
|
import { pathToFileURL } from "node:url";
|
|
7
7
|
import { appendJsonl, getArtifactPaths } from "./artifacts.js";
|
|
8
|
+
import { getPiSpawnCommand } from "./pi-spawn.js";
|
|
9
|
+
import { persistSingleOutput } from "./single-output.js";
|
|
8
10
|
import {
|
|
9
11
|
type ArtifactConfig,
|
|
10
12
|
type ArtifactPaths,
|
|
@@ -20,9 +22,11 @@ interface SubagentStep {
|
|
|
20
22
|
cwd?: string;
|
|
21
23
|
model?: string;
|
|
22
24
|
tools?: string[];
|
|
25
|
+
extensions?: string[];
|
|
23
26
|
mcpDirectTools?: string[];
|
|
24
27
|
systemPrompt?: string | null;
|
|
25
28
|
skills?: string[];
|
|
29
|
+
outputPath?: string;
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
interface SubagentRunConfig {
|
|
@@ -104,7 +108,8 @@ function runPiStreaming(
|
|
|
104
108
|
return new Promise((resolve) => {
|
|
105
109
|
const outputStream = fs.createWriteStream(outputFile, { flags: "w" });
|
|
106
110
|
const spawnEnv = { ...process.env, ...(env ?? {}), ...getSubagentDepthEnv() };
|
|
107
|
-
const
|
|
111
|
+
const spawnSpec = getPiSpawnCommand(args);
|
|
112
|
+
const child = spawn(spawnSpec.command, spawnSpec.args, { cwd, stdio: ["ignore", "pipe", "pipe"], env: spawnEnv });
|
|
108
113
|
let stdout = "";
|
|
109
114
|
|
|
110
115
|
child.stdout.on("data", (chunk: Buffer) => {
|
|
@@ -337,19 +342,27 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
|
|
|
337
342
|
} catch {}
|
|
338
343
|
args.push("--session-dir", config.sessionDir);
|
|
339
344
|
}
|
|
340
|
-
|
|
345
|
+
// Use --models (not --model) because pi CLI silently ignores --model
|
|
346
|
+
// without a companion --provider flag. --models resolves the provider
|
|
347
|
+
// automatically via resolveModelScope. See: #8
|
|
348
|
+
if (step.model) args.push("--models", step.model);
|
|
349
|
+
const toolExtensionPaths: string[] = [];
|
|
341
350
|
if (step.tools?.length) {
|
|
342
351
|
const builtinTools: string[] = [];
|
|
343
|
-
const extensionPaths: string[] = [];
|
|
344
352
|
for (const tool of step.tools) {
|
|
345
353
|
if (tool.includes("/") || tool.endsWith(".ts") || tool.endsWith(".js")) {
|
|
346
|
-
|
|
354
|
+
toolExtensionPaths.push(tool);
|
|
347
355
|
} else {
|
|
348
356
|
builtinTools.push(tool);
|
|
349
357
|
}
|
|
350
358
|
}
|
|
351
359
|
if (builtinTools.length > 0) args.push("--tools", builtinTools.join(","));
|
|
352
|
-
|
|
360
|
+
}
|
|
361
|
+
if (step.extensions !== undefined) {
|
|
362
|
+
args.push("--no-extensions");
|
|
363
|
+
for (const extPath of step.extensions) args.push("--extension", extPath);
|
|
364
|
+
} else {
|
|
365
|
+
for (const extPath of toolExtensionPaths) args.push("--extension", extPath);
|
|
353
366
|
}
|
|
354
367
|
|
|
355
368
|
let tmpDir: string | null = null;
|
|
@@ -392,6 +405,19 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
|
|
|
392
405
|
|
|
393
406
|
const output = (result.stdout || "").trim();
|
|
394
407
|
previousOutput = output;
|
|
408
|
+
let outputForSummary = output;
|
|
409
|
+
if (step.outputPath && result.exitCode === 0) {
|
|
410
|
+
const persisted = persistSingleOutput(step.outputPath, output);
|
|
411
|
+
if (persisted.savedPath) {
|
|
412
|
+
outputForSummary = output
|
|
413
|
+
? `${output}\n\n📄 Output saved to: ${persisted.savedPath}`
|
|
414
|
+
: `📄 Output saved to: ${persisted.savedPath}`;
|
|
415
|
+
} else if (persisted.error) {
|
|
416
|
+
outputForSummary = output
|
|
417
|
+
? `${output}\n\n⚠️ Failed to save output to: ${step.outputPath}\n${persisted.error}`
|
|
418
|
+
: `⚠️ Failed to save output to: ${step.outputPath}\n${persisted.error}`;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
395
421
|
|
|
396
422
|
const cumulativeTokens = config.sessionDir ? parseSessionTokens(config.sessionDir) : null;
|
|
397
423
|
const stepTokens: TokenUsage | null = cumulativeTokens
|
|
@@ -407,7 +433,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
|
|
|
407
433
|
|
|
408
434
|
const stepResult: StepResult = {
|
|
409
435
|
agent: step.agent,
|
|
410
|
-
output,
|
|
436
|
+
output: outputForSummary,
|
|
411
437
|
success: result.exitCode === 0,
|
|
412
438
|
artifactPaths,
|
|
413
439
|
};
|
package/types.ts
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* Type definitions for the subagent extension
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import * as os from "node:os";
|
|
6
|
+
import * as path from "node:path";
|
|
5
7
|
import type { Message } from "@mariozechner/pi-ai";
|
|
6
8
|
|
|
7
9
|
// ============================================================================
|
|
@@ -237,8 +239,8 @@ export const DEFAULT_ARTIFACT_CONFIG: ArtifactConfig = {
|
|
|
237
239
|
|
|
238
240
|
export const MAX_PARALLEL = 8;
|
|
239
241
|
export const MAX_CONCURRENCY = 4;
|
|
240
|
-
export const RESULTS_DIR = "
|
|
241
|
-
export const ASYNC_DIR = "
|
|
242
|
+
export const RESULTS_DIR = path.join(os.tmpdir(), "pi-async-subagent-results");
|
|
243
|
+
export const ASYNC_DIR = path.join(os.tmpdir(), "pi-async-subagent-runs");
|
|
242
244
|
export const WIDGET_KEY = "subagent-async";
|
|
243
245
|
export const POLL_INTERVAL_MS = 250;
|
|
244
246
|
export const MAX_WIDGET_JOBS = 4;
|