@harms-haus/pi-subagents 0.1.0
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/LICENSE +21 -0
- package/README.md +362 -0
- package/docs/architecture.md +554 -0
- package/docs/changelog.md +61 -0
- package/docs/profiles.md +546 -0
- package/docs/settings.md +52 -0
- package/docs/tools-reference.md +519 -0
- package/package.json +59 -0
- package/src/cache.ts +24 -0
- package/src/commands/profile.ts +176 -0
- package/src/format-tool-call.ts +597 -0
- package/src/format-transcript.ts +151 -0
- package/src/index.ts +117 -0
- package/src/profile-editor.ts +356 -0
- package/src/profile-formatting.ts +178 -0
- package/src/profile-types.ts +73 -0
- package/src/profiles.ts +577 -0
- package/src/schemas.ts +65 -0
- package/src/settings.ts +155 -0
- package/src/skill-discovery.ts +30 -0
- package/src/spawner.ts +523 -0
- package/src/tools/delegate-render.ts +285 -0
- package/src/tools/delegate.ts +867 -0
- package/src/tools/retrieval.ts +287 -0
- package/src/types.ts +232 -0
- package/src/utils.ts +168 -0
package/src/profiles.ts
ADDED
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subagent Profile System
|
|
3
|
+
*
|
|
4
|
+
* Loads named profiles from individual markdown files with YAML frontmatter,
|
|
5
|
+
* and resolves them into CLI arguments for sub-agent processes.
|
|
6
|
+
*
|
|
7
|
+
* Profile locations:
|
|
8
|
+
* Global: ~/.pi/agent/agent-profiles/*.md
|
|
9
|
+
* Project: .pi/agent-profiles/*.md
|
|
10
|
+
*
|
|
11
|
+
* Project-local profiles override global profiles with the same name.
|
|
12
|
+
*
|
|
13
|
+
* Profile markdown format:
|
|
14
|
+
* ---
|
|
15
|
+
* name: my-profile
|
|
16
|
+
* provider: anthropic
|
|
17
|
+
* model: claude-sonnet-4-5
|
|
18
|
+
* thinkingLevel: high
|
|
19
|
+
* tools: read,bash,grep
|
|
20
|
+
* ---
|
|
21
|
+
*
|
|
22
|
+
* You are a coding agent...
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { type Dirent, existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
|
|
26
|
+
import { unlink, writeFile } from "node:fs/promises";
|
|
27
|
+
import { homedir } from "node:os";
|
|
28
|
+
import { join, resolve, sep } from "node:path";
|
|
29
|
+
import {
|
|
30
|
+
parseFrontmatter,
|
|
31
|
+
stripFrontmatter,
|
|
32
|
+
loadSkills as discoverSkills,
|
|
33
|
+
} from "@earendil-works/pi-coding-agent";
|
|
34
|
+
import { resolvePackageSkillPaths } from "./skill-discovery";
|
|
35
|
+
export { profileSummary, formatProfileDetail } from "./profile-formatting";
|
|
36
|
+
import { TtlCache } from "./cache";
|
|
37
|
+
import { serializeProfileToMarkdown } from "./profile-formatting";
|
|
38
|
+
import type {
|
|
39
|
+
SubagentProfile,
|
|
40
|
+
SubagentProfiles,
|
|
41
|
+
ThinkingLevel,
|
|
42
|
+
ProfileInvocation,
|
|
43
|
+
} from "./profile-types";
|
|
44
|
+
export type {
|
|
45
|
+
SubagentProfile,
|
|
46
|
+
SubagentProfiles,
|
|
47
|
+
ThinkingLevel,
|
|
48
|
+
ProfileInvocation,
|
|
49
|
+
} from "./profile-types";
|
|
50
|
+
|
|
51
|
+
// ── Profile Types ────────────────────────────────────────────────────
|
|
52
|
+
// (Types moved to ./profile-types.ts)
|
|
53
|
+
|
|
54
|
+
// ── Profile Cache ─────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
const profilesCache = new TtlCache<{ [name: string]: SubagentProfile }>(5000);
|
|
57
|
+
|
|
58
|
+
export function invalidateProfilesCache(): void {
|
|
59
|
+
profilesCache.invalidate();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Helpers for array/string frontmatter fields ──────────────────────
|
|
63
|
+
|
|
64
|
+
function parseStringOrArray(value: unknown): string[] | undefined {
|
|
65
|
+
if (Array.isArray(value)) {
|
|
66
|
+
return value.map(String);
|
|
67
|
+
}
|
|
68
|
+
if (typeof value === "string" && value.trim()) {
|
|
69
|
+
return value
|
|
70
|
+
.split(",")
|
|
71
|
+
.map((s) => s.trim())
|
|
72
|
+
.filter(Boolean);
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Profile Directory Paths ──────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
function getGlobalProfilesDir(): string {
|
|
80
|
+
const agentDir = process.env.PI_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
|
81
|
+
return join(agentDir, "agent-profiles");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function getProjectProfilesDir(cwd: string): string {
|
|
85
|
+
return join(cwd, ".pi", "agent-profiles");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type ProfileScope = "global" | "project";
|
|
89
|
+
|
|
90
|
+
export function getProfilesDir(scope: ProfileScope, cwd?: string): string {
|
|
91
|
+
return scope === "project" ? getProjectProfilesDir(cwd ?? process.cwd()) : getGlobalProfilesDir();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Profile Tool Validation ───────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
export function validateProfileTools(profile: SubagentProfile, profileName?: string): void {
|
|
97
|
+
if (
|
|
98
|
+
profile.tools &&
|
|
99
|
+
profile.tools.length > 0 &&
|
|
100
|
+
profile.excludeTools &&
|
|
101
|
+
profile.excludeTools.length > 0
|
|
102
|
+
) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`Profile${profileName ? ` "${profileName}"` : ""} has both "tools" (allowlist) and "excludeTools" (blacklist) set. These are mutually exclusive — choose one or the other.`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function applyExcludeTools(
|
|
110
|
+
profile: SubagentProfile,
|
|
111
|
+
allToolNames: string[],
|
|
112
|
+
): SubagentProfile {
|
|
113
|
+
if (!profile.excludeTools || profile.excludeTools.length === 0) {
|
|
114
|
+
return profile;
|
|
115
|
+
}
|
|
116
|
+
const excludeSet = new Set(profile.excludeTools);
|
|
117
|
+
const computedTools = allToolNames.filter((name) => !excludeSet.has(name));
|
|
118
|
+
return { ...profile, tools: computedTools, excludeTools: undefined };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Validate that skill-related profile fields are not mutually conflicting. Throws if suggestedSkills or loadSkills is combined with noSkills. */
|
|
122
|
+
export function validateProfileSkills(profile: SubagentProfile, profileName?: string): void {
|
|
123
|
+
if (profile.suggestedSkills && profile.suggestedSkills.length > 0 && profile.noSkills) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`Profile${profileName ? ` "${profileName}"` : ""} has both "suggestedSkills" and "noSkills" set. These are mutually exclusive — --no-skills would override --skill flags.`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
if (profile.loadSkills && profile.loadSkills.length > 0 && profile.noSkills) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`Profile${profileName ? ` "${profileName}"` : ""} has both "loadSkills" and "noSkills" set. These are mutually exclusive — --no-skills disables skill discovery.`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Resolve suggested skill names to file paths.
|
|
137
|
+
* Throws if any skill name is not found in the available skills.
|
|
138
|
+
*/
|
|
139
|
+
function resolveSuggestedSkills(
|
|
140
|
+
names: string[],
|
|
141
|
+
localSkillMap: Map<string, { filePath: string; name: string; description: string }>,
|
|
142
|
+
available: string[],
|
|
143
|
+
): string[] {
|
|
144
|
+
const paths: string[] = [];
|
|
145
|
+
const notFound: string[] = [];
|
|
146
|
+
for (const name of names) {
|
|
147
|
+
const skill = localSkillMap.get(name);
|
|
148
|
+
if (!skill) {
|
|
149
|
+
notFound.push(name);
|
|
150
|
+
} else {
|
|
151
|
+
paths.push(skill.filePath);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (notFound.length > 0) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`Unknown skills: ${notFound.map((n) => `"${n}"`).join(", ")}. Available skills: ${available.join(", ") || "(none)"}`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
return paths;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Resolve loadable skill names to content for injecting into appendSystemPrompt.
|
|
164
|
+
* Throws if any skill name is not found in the available skills.
|
|
165
|
+
*/
|
|
166
|
+
function resolveLoadSkillsContent(
|
|
167
|
+
names: string[],
|
|
168
|
+
localSkillMap: Map<string, { filePath: string; name: string; description: string }>,
|
|
169
|
+
available: string[],
|
|
170
|
+
): string {
|
|
171
|
+
const skillParts: string[] = [];
|
|
172
|
+
const loadNotFound: string[] = [];
|
|
173
|
+
for (const name of names) {
|
|
174
|
+
const skill = localSkillMap.get(name);
|
|
175
|
+
if (!skill) {
|
|
176
|
+
loadNotFound.push(name);
|
|
177
|
+
} else {
|
|
178
|
+
const raw = readFileSync(skill.filePath, "utf-8");
|
|
179
|
+
const body = stripFrontmatter(raw).trim();
|
|
180
|
+
if (body) {
|
|
181
|
+
skillParts.push(`<loaded_skill name="${skill.name}">\n${body}\n</loaded_skill>`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (loadNotFound.length > 0) {
|
|
186
|
+
throw new Error(
|
|
187
|
+
`Unknown skills: ${loadNotFound.map((n) => `"${n}"`).join(", ")}. Available skills: ${available.join(", ") || "(none)"}`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
return skillParts.length > 0 ? `\n\n${skillParts.join("\n\n")}` : "";
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Resolve skill names in a profile to file paths and content.
|
|
195
|
+
* suggestedSkills: names → file paths (for --skill CLI flags)
|
|
196
|
+
* loadSkills: names → SKILL.md body → injected into appendSystemPrompt
|
|
197
|
+
*/
|
|
198
|
+
export async function resolveProfileSkills(
|
|
199
|
+
profile: SubagentProfile,
|
|
200
|
+
cwd: string,
|
|
201
|
+
skillMap?: Map<string, { filePath: string; name: string; description: string }>,
|
|
202
|
+
): Promise<SubagentProfile> {
|
|
203
|
+
if (!profile.suggestedSkills?.length && !profile.loadSkills?.length) {
|
|
204
|
+
return profile;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
let result: { skills: { filePath: string; name: string; description: string }[] };
|
|
208
|
+
if (skillMap) {
|
|
209
|
+
result = { skills: [...skillMap.values()] };
|
|
210
|
+
} else {
|
|
211
|
+
const agentDir = process.env.PI_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
|
212
|
+
const packageSkillPaths = await resolvePackageSkillPaths(cwd, agentDir);
|
|
213
|
+
result = discoverSkills({
|
|
214
|
+
cwd,
|
|
215
|
+
agentDir,
|
|
216
|
+
skillPaths: packageSkillPaths,
|
|
217
|
+
includeDefaults: true,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
const localSkillMap = skillMap ?? new Map(result.skills.map((s) => [s.name, s]));
|
|
221
|
+
const available = result.skills.map((s) => s.name);
|
|
222
|
+
const resolved: SubagentProfile = { ...profile };
|
|
223
|
+
|
|
224
|
+
if (profile.suggestedSkills?.length) {
|
|
225
|
+
resolved.suggestedSkills = resolveSuggestedSkills(
|
|
226
|
+
profile.suggestedSkills,
|
|
227
|
+
localSkillMap,
|
|
228
|
+
available,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (profile.loadSkills?.length) {
|
|
233
|
+
const loadSkillsContent = resolveLoadSkillsContent(
|
|
234
|
+
profile.loadSkills,
|
|
235
|
+
localSkillMap,
|
|
236
|
+
available,
|
|
237
|
+
);
|
|
238
|
+
if (loadSkillsContent) {
|
|
239
|
+
resolved.appendSystemPrompt = (resolved.appendSystemPrompt ?? "") + loadSkillsContent;
|
|
240
|
+
}
|
|
241
|
+
resolved.loadSkills = undefined;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return resolved;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── Profile Loading from Markdown Files ──────────────────────────────
|
|
248
|
+
|
|
249
|
+
/** String fields to copy directly from frontmatter to profile. */
|
|
250
|
+
const STRING_FIELDS = ["provider", "model", "appendSystemPrompt"] as const;
|
|
251
|
+
|
|
252
|
+
/** Boolean flags to copy from frontmatter to profile. */
|
|
253
|
+
const BOOLEAN_FLAGS = ["noTools", "noExtensions", "noSkills", "noContextFiles"] as const;
|
|
254
|
+
|
|
255
|
+
/** Array-or-string fields to parse and copy. */
|
|
256
|
+
const ARRAY_FIELDS = [
|
|
257
|
+
"tools",
|
|
258
|
+
"excludeTools",
|
|
259
|
+
"extensions",
|
|
260
|
+
"extraArgs",
|
|
261
|
+
"suggestedSkills",
|
|
262
|
+
"loadSkills",
|
|
263
|
+
] as const;
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Apply the apiKey field with scope-aware safety checks.
|
|
267
|
+
*/
|
|
268
|
+
function applyApiKey(
|
|
269
|
+
profile: SubagentProfile,
|
|
270
|
+
frontmatter: Record<string, unknown>,
|
|
271
|
+
scope: "global" | "project",
|
|
272
|
+
name: string,
|
|
273
|
+
filePath: string,
|
|
274
|
+
): void {
|
|
275
|
+
if (typeof frontmatter.apiKey !== "string") return;
|
|
276
|
+
if (scope === "project") {
|
|
277
|
+
console.warn(
|
|
278
|
+
`Warning: Refusing to load apiKey from project-local profile "${name}" in ${filePath}. Move the profile to the global directory (~/.pi/agent/agent-profiles/) or use environment variables.`,
|
|
279
|
+
);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
profile.apiKey = frontmatter.apiKey;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Parse frontmatter fields into a SubagentProfile.
|
|
287
|
+
* Returns undefined if the frontmatter is missing a valid name.
|
|
288
|
+
*/
|
|
289
|
+
function parseProfileFromFrontmatter(
|
|
290
|
+
frontmatter: Record<string, unknown>,
|
|
291
|
+
body: string,
|
|
292
|
+
scope: "global" | "project",
|
|
293
|
+
filePath: string,
|
|
294
|
+
): SubagentProfile | undefined {
|
|
295
|
+
const name = frontmatter.name;
|
|
296
|
+
if (typeof name !== "string" || !name) {
|
|
297
|
+
return undefined;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const profile: SubagentProfile = {};
|
|
301
|
+
|
|
302
|
+
// String fields
|
|
303
|
+
for (const field of STRING_FIELDS) {
|
|
304
|
+
if (typeof frontmatter[field] === "string") {
|
|
305
|
+
profile[field] = frontmatter[field];
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
// thinkingLevel has a type cast
|
|
309
|
+
if (typeof frontmatter.thinkingLevel === "string") {
|
|
310
|
+
profile.thinkingLevel = frontmatter.thinkingLevel as ThinkingLevel;
|
|
311
|
+
}
|
|
312
|
+
applyApiKey(profile, frontmatter, scope, name, filePath);
|
|
313
|
+
|
|
314
|
+
// Body = system prompt
|
|
315
|
+
const trimmedBody = body.trim();
|
|
316
|
+
if (trimmedBody) profile.systemPrompt = trimmedBody;
|
|
317
|
+
|
|
318
|
+
// Array/string fields
|
|
319
|
+
for (const field of ARRAY_FIELDS) {
|
|
320
|
+
const parsed = parseStringOrArray(frontmatter[field]);
|
|
321
|
+
if (parsed) profile[field] = parsed;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Boolean flags
|
|
325
|
+
for (const flag of BOOLEAN_FLAGS) {
|
|
326
|
+
if (frontmatter[flag] === true) profile[flag] = true;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return profile;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function loadProfilesFromDir(
|
|
333
|
+
dir: string,
|
|
334
|
+
profiles: SubagentProfiles,
|
|
335
|
+
scope: "global" | "project" = "global",
|
|
336
|
+
): void {
|
|
337
|
+
if (!existsSync(dir)) {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
let entries: Dirent[];
|
|
342
|
+
try {
|
|
343
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
344
|
+
} catch {
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
for (const entry of entries) {
|
|
349
|
+
if (!(entry.isFile() && entry.name.endsWith(".md"))) {
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const filePath = join(dir, entry.name);
|
|
354
|
+
try {
|
|
355
|
+
const content = readFileSync(filePath, "utf-8");
|
|
356
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
357
|
+
const profile = parseProfileFromFrontmatter(frontmatter, body, scope, filePath);
|
|
358
|
+
if (profile) {
|
|
359
|
+
const name = frontmatter.name as string;
|
|
360
|
+
profiles[name] = profile;
|
|
361
|
+
}
|
|
362
|
+
} catch (error) {
|
|
363
|
+
console.warn(
|
|
364
|
+
`Failed to load profile from ${filePath}:`,
|
|
365
|
+
error instanceof Error ? error.message : error,
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Load subagent profiles from markdown files.
|
|
373
|
+
* Project-local profiles override global profiles.
|
|
374
|
+
*/
|
|
375
|
+
export function loadProfiles(cwd?: string): SubagentProfiles {
|
|
376
|
+
const cached = profilesCache.get(cwd ?? "");
|
|
377
|
+
if (cached) {
|
|
378
|
+
return cached;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const profiles: SubagentProfiles = {};
|
|
382
|
+
|
|
383
|
+
// Load global profiles
|
|
384
|
+
const globalDir = getGlobalProfilesDir();
|
|
385
|
+
loadProfilesFromDir(globalDir, profiles);
|
|
386
|
+
|
|
387
|
+
// Load project-local profiles (override globals)
|
|
388
|
+
if (cwd) {
|
|
389
|
+
const projectDir = getProjectProfilesDir(cwd);
|
|
390
|
+
loadProfilesFromDir(projectDir, profiles, "project");
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
profilesCache.set(cwd ?? "", profiles);
|
|
394
|
+
return profiles;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Resolve a profile name to a SubagentProfile, falling back to
|
|
399
|
+
* agentOverrides for backward compatibility.
|
|
400
|
+
*/
|
|
401
|
+
export function resolveProfile(
|
|
402
|
+
profiles: SubagentProfiles,
|
|
403
|
+
profileName: string,
|
|
404
|
+
): SubagentProfile | undefined {
|
|
405
|
+
// Intentional abstraction point for future backward-compatibility resolution
|
|
406
|
+
return profiles[profileName];
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ── CLI Argument Building ────────────────────────────────────────────
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Check whether a CLI argument is a tool-override flag (exact or equals-sign form).
|
|
413
|
+
* Used to prevent extraArgs from bypassing profile tool restrictions.
|
|
414
|
+
*/
|
|
415
|
+
function isDangerousFlag(arg: string): boolean {
|
|
416
|
+
return (
|
|
417
|
+
arg === "--tools" ||
|
|
418
|
+
arg.startsWith("--tools=") ||
|
|
419
|
+
arg === "-t" ||
|
|
420
|
+
arg.startsWith("-t=") ||
|
|
421
|
+
arg === "--no-tools" ||
|
|
422
|
+
arg.startsWith("--no-tools=") ||
|
|
423
|
+
arg === "-nt" ||
|
|
424
|
+
arg.startsWith("-nt=")
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Convert a SubagentProfile into invocation parameters for the pi subprocess.
|
|
430
|
+
* Returns both CLI arguments and environment variables.
|
|
431
|
+
*/
|
|
432
|
+
function isWithinDir(filePath: string, dir: string): boolean {
|
|
433
|
+
const resolved = resolve(filePath);
|
|
434
|
+
const resolvedDir = resolve(dir);
|
|
435
|
+
return resolved === resolvedDir || resolved.startsWith(resolvedDir + sep);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Push basic CLI flags (provider, model, prompts, thinking, tools, etc.) onto args.
|
|
440
|
+
*/
|
|
441
|
+
function pushBasicArgs(args: string[], profile: SubagentProfile): void {
|
|
442
|
+
if (profile.provider) args.push("--provider", profile.provider);
|
|
443
|
+
if (profile.model) args.push("--model", profile.model);
|
|
444
|
+
if (profile.systemPrompt) args.push("--system-prompt", profile.systemPrompt);
|
|
445
|
+
if (profile.appendSystemPrompt) args.push("--append-system-prompt", profile.appendSystemPrompt);
|
|
446
|
+
if (profile.thinkingLevel) args.push("--thinking", profile.thinkingLevel);
|
|
447
|
+
|
|
448
|
+
if (profile.noTools) {
|
|
449
|
+
args.push("--no-tools");
|
|
450
|
+
} else if (profile.tools && profile.tools.length > 0) {
|
|
451
|
+
args.push("--tools", profile.tools.join(","));
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (profile.noExtensions) args.push("--no-extensions");
|
|
455
|
+
if (profile.noSkills) args.push("--no-skills");
|
|
456
|
+
if (profile.noContextFiles) args.push("--no-context-files");
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Push --skill flags for suggestedSkills, validating paths are within allowed directories.
|
|
461
|
+
*/
|
|
462
|
+
function pushSkillArgs(
|
|
463
|
+
args: string[],
|
|
464
|
+
profile: SubagentProfile,
|
|
465
|
+
cwd?: string,
|
|
466
|
+
agentDir?: string,
|
|
467
|
+
): void {
|
|
468
|
+
if (!profile.suggestedSkills) return;
|
|
469
|
+
const safeDirs: string[] = [];
|
|
470
|
+
if (cwd) safeDirs.push(resolve(cwd));
|
|
471
|
+
if (agentDir) safeDirs.push(resolve(agentDir));
|
|
472
|
+
for (const skillPath of profile.suggestedSkills) {
|
|
473
|
+
if (!skillPath) continue;
|
|
474
|
+
if (safeDirs.length > 0 && !safeDirs.some((d) => isWithinDir(skillPath, d))) {
|
|
475
|
+
throw new Error(`Refusing skill path outside allowed directories: ${skillPath}`);
|
|
476
|
+
}
|
|
477
|
+
args.push("--skill", skillPath);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Validate and push extraArgs, checking for safety violations.
|
|
483
|
+
*/
|
|
484
|
+
function pushExtraArgs(args: string[], profile: SubagentProfile): void {
|
|
485
|
+
if (!profile.extraArgs) return;
|
|
486
|
+
const hasToolRestrictions =
|
|
487
|
+
profile.noTools === true ||
|
|
488
|
+
(profile.tools !== undefined && profile.tools.length > 0) ||
|
|
489
|
+
(profile.excludeTools !== undefined && profile.excludeTools.length > 0);
|
|
490
|
+
|
|
491
|
+
for (const arg of profile.extraArgs) {
|
|
492
|
+
if (hasToolRestrictions && isDangerousFlag(arg)) {
|
|
493
|
+
throw new Error(
|
|
494
|
+
`Refusing extraArg "${arg}" which would override profile tool restrictions. Use the dedicated profile fields instead.`,
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
if (arg.includes("\0")) {
|
|
498
|
+
throw new Error("Invalid extraArg: contains null byte");
|
|
499
|
+
}
|
|
500
|
+
if (/^[\s|&;$\\`!]|&&|\|\||;|>|>>|<|<</.test(arg)) {
|
|
501
|
+
throw new Error(`Refusing extraArg: potentially unsafe argument '${arg.slice(0, 40)}'`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
args.push(...profile.extraArgs);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export function profileToArgs(
|
|
508
|
+
profile: SubagentProfile,
|
|
509
|
+
cwd?: string,
|
|
510
|
+
agentDir?: string,
|
|
511
|
+
): ProfileInvocation {
|
|
512
|
+
const args: string[] = [];
|
|
513
|
+
const envVars: Record<string, string> = {};
|
|
514
|
+
|
|
515
|
+
pushBasicArgs(args, profile);
|
|
516
|
+
|
|
517
|
+
// Store API key in environment variable to avoid CLI exposure via /proc/PID/cmdline
|
|
518
|
+
if (profile.apiKey) {
|
|
519
|
+
envVars.PI_API_KEY = profile.apiKey;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (profile.extensions) {
|
|
523
|
+
for (const ext of profile.extensions) {
|
|
524
|
+
args.push("--extension", ext);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
pushSkillArgs(args, profile, cwd, agentDir);
|
|
529
|
+
pushExtraArgs(args, profile);
|
|
530
|
+
|
|
531
|
+
return { args, env: envVars };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// ── Profile Mutation ─────────────────────────────────────────────────
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Save (create or update) a profile as a markdown file in the given scope.
|
|
538
|
+
*/
|
|
539
|
+
export async function saveProfile(
|
|
540
|
+
name: string,
|
|
541
|
+
profile: SubagentProfile,
|
|
542
|
+
scope: ProfileScope,
|
|
543
|
+
cwd?: string,
|
|
544
|
+
): Promise<void> {
|
|
545
|
+
const dir =
|
|
546
|
+
scope === "project" ? getProjectProfilesDir(cwd ?? process.cwd()) : getGlobalProfilesDir();
|
|
547
|
+
if (!existsSync(dir)) {
|
|
548
|
+
mkdirSync(dir, { recursive: true });
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const filePath = join(dir, `${name}.md`);
|
|
552
|
+
const content = serializeProfileToMarkdown(name, profile);
|
|
553
|
+
await writeFile(filePath, content, "utf8");
|
|
554
|
+
invalidateProfilesCache();
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Delete a profile markdown file from the given scope.
|
|
559
|
+
* Returns true if the profile existed and was deleted.
|
|
560
|
+
*/
|
|
561
|
+
export async function deleteProfile(
|
|
562
|
+
name: string,
|
|
563
|
+
scope: ProfileScope,
|
|
564
|
+
cwd?: string,
|
|
565
|
+
): Promise<boolean> {
|
|
566
|
+
const dir =
|
|
567
|
+
scope === "project" ? getProjectProfilesDir(cwd ?? process.cwd()) : getGlobalProfilesDir();
|
|
568
|
+
const filePath = join(dir, `${name}.md`);
|
|
569
|
+
|
|
570
|
+
if (!existsSync(filePath)) {
|
|
571
|
+
return false;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
await unlink(filePath);
|
|
575
|
+
invalidateProfilesCache();
|
|
576
|
+
return true;
|
|
577
|
+
}
|
package/src/schemas.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-subagents Extension Schemas
|
|
3
|
+
*
|
|
4
|
+
* TypeBox schemas for tool parameter validation.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Type } from "typebox";
|
|
8
|
+
import { MAX_PARALLEL_TASKS } from "./types";
|
|
9
|
+
|
|
10
|
+
// ── Schema ───────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
/** Schema for a file range spec: { path, start?, end? } */
|
|
13
|
+
const FileRangeSchema = Type.Object({
|
|
14
|
+
path: Type.String({ description: "File path (absolute or relative)" }),
|
|
15
|
+
start: Type.Optional(Type.Number({ description: "1-indexed start line (inclusive)" })),
|
|
16
|
+
end: Type.Optional(Type.Number({ description: "1-indexed end line (inclusive)" })),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/** Schema for a file tail spec: { path, tail } */
|
|
20
|
+
const FileTailSchema = Type.Object({
|
|
21
|
+
path: Type.String({ description: "File path (absolute or relative)" }),
|
|
22
|
+
tail: Type.Number({ description: "Number of lines from the end of the file", minimum: 1 }),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/** Schema for a file head spec: { path, head } */
|
|
26
|
+
const FileHeadSchema = Type.Object({
|
|
27
|
+
path: Type.String({ description: "File path (absolute or relative)" }),
|
|
28
|
+
head: Type.Number({ description: "Number of lines from the start of the file", minimum: 1 }),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/** Schema for a single file spec: string path, range object, tail object, or head object */
|
|
32
|
+
const FileSpecSchema = Type.Union([Type.String(), FileRangeSchema, FileTailSchema, FileHeadSchema]);
|
|
33
|
+
|
|
34
|
+
/** Schema for a single sub-agent task */
|
|
35
|
+
export const TaskSchema = Type.Object({
|
|
36
|
+
name: Type.String({ description: "Display name for this sub-agent window" }),
|
|
37
|
+
prompt: Type.String({ description: "The task/prompt to send to the sub-agent" }),
|
|
38
|
+
cwd: Type.Optional(Type.String({ description: "Working directory for this sub-agent" })),
|
|
39
|
+
profile: Type.Optional(
|
|
40
|
+
Type.String({
|
|
41
|
+
description:
|
|
42
|
+
"Named subagent profile from settings (sets provider/model, system prompt, thinking level, etc.)",
|
|
43
|
+
}),
|
|
44
|
+
),
|
|
45
|
+
timeout: Type.Optional(
|
|
46
|
+
Type.Number({ description: "Timeout in seconds for this subagent (default 600)" }),
|
|
47
|
+
),
|
|
48
|
+
resume: Type.Optional(Type.String({ description: "Previous session ID to resume from" })),
|
|
49
|
+
files: Type.Optional(
|
|
50
|
+
Type.Array(FileSpecSchema, {
|
|
51
|
+
description:
|
|
52
|
+
"Files to read and prepend to the prompt. Each entry can be a path string or an object with path and range options (head, tail, start/end).",
|
|
53
|
+
}),
|
|
54
|
+
),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/** Schema for delegate_to_subagents tool parameters */
|
|
58
|
+
export const DelegateParams = Type.Object({
|
|
59
|
+
tasks: Type.Array(TaskSchema, { minItems: 1, maxItems: MAX_PARALLEL_TASKS }),
|
|
60
|
+
profile: Type.Optional(
|
|
61
|
+
Type.String({
|
|
62
|
+
description: "Default profile for all tasks (overridden by per-task profile)",
|
|
63
|
+
}),
|
|
64
|
+
),
|
|
65
|
+
});
|