@oldflag2333333/pi-subagent 0.0.0-stage → 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 +171 -2
- package/README.zh-CN.md +172 -0
- package/docs/releasing.md +61 -0
- package/herdr-plugin/README.md +34 -0
- package/herdr-plugin/action.ts +19 -0
- package/herdr-plugin/herdr-plugin.toml +27 -0
- package/herdr-plugin/index.mjs +108 -0
- package/package.json +65 -4
- package/src/adapters/herdr.ts +155 -0
- package/src/adapters/index.ts +31 -0
- package/src/channel-monitor.ts +127 -0
- package/src/channel.ts +286 -0
- package/src/commands/clean-sub.ts +82 -0
- package/src/commands/sub.ts +39 -0
- package/src/inbox-state.ts +108 -0
- package/src/index.ts +45 -0
- package/src/main-context.ts +91 -0
- package/src/manual-context.ts +9 -0
- package/src/profiles/context.ts +27 -0
- package/src/profiles/launch-args.ts +21 -0
- package/src/profiles/loader.ts +325 -0
- package/src/profiles/mcp.ts +16 -0
- package/src/profiles/runtime.ts +102 -0
- package/src/profiles/system-prompt.ts +33 -0
- package/src/profiles/tool-selection.ts +21 -0
- package/src/profiles/tool-sources.ts +49 -0
- package/src/profiles/types.ts +37 -0
- package/src/run-manager.ts +442 -0
- package/src/sessions.ts +50 -0
- package/src/talk-delivery.ts +73 -0
- package/src/talk-message.ts +32 -0
- package/src/talk-render.ts +18 -0
- package/src/tools/main.ts +299 -0
- package/src/tools/sub.ts +215 -0
- package/src/types.ts +111 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type {
|
|
6
|
+
LoadedProfile,
|
|
7
|
+
ProfileCatalog,
|
|
8
|
+
ProfileDefinition,
|
|
9
|
+
ProfileDiagnostic,
|
|
10
|
+
ProfileSource,
|
|
11
|
+
ResolvedProfile,
|
|
12
|
+
} from "./types.js";
|
|
13
|
+
|
|
14
|
+
const PROFILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
15
|
+
const MAX_PROFILE_BYTES = 256 * 1024;
|
|
16
|
+
const PROFILE_CONFIG_FILE = "config.json";
|
|
17
|
+
const PROFILE_INSTRUCTIONS_FILE = "instructions.md";
|
|
18
|
+
const MAX_INSTRUCTIONS_CHARACTERS = 64 * 1024;
|
|
19
|
+
const ALLOWED_KEYS = new Set([
|
|
20
|
+
"version",
|
|
21
|
+
"name",
|
|
22
|
+
"description",
|
|
23
|
+
"model",
|
|
24
|
+
"thinkingLevel",
|
|
25
|
+
"sessionPersistence",
|
|
26
|
+
"invocation",
|
|
27
|
+
"tools",
|
|
28
|
+
"skills",
|
|
29
|
+
"instructions",
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
33
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function stringArray(value: unknown, maxItems: number): value is string[] {
|
|
37
|
+
return Array.isArray(value) && value.length <= maxItems
|
|
38
|
+
&& value.every((item) => typeof item === "string" && item.trim().length > 0 && item.length <= 4096);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseProfile(value: unknown, expectedName: string): ProfileDefinition {
|
|
42
|
+
if (!isRecord(value)) throw new Error("profile must be a JSON object");
|
|
43
|
+
const unknownKeys = Object.keys(value).filter((key) => !ALLOWED_KEYS.has(key));
|
|
44
|
+
if (unknownKeys.length > 0) throw new Error(`unknown fields: ${unknownKeys.join(", ")}`);
|
|
45
|
+
if (value.version !== 1) throw new Error("version must be 1");
|
|
46
|
+
if (typeof value.name !== "string" || !PROFILE_NAME.test(value.name)) {
|
|
47
|
+
throw new Error("name must be 1-64 characters using letters, numbers, dot, underscore, or hyphen");
|
|
48
|
+
}
|
|
49
|
+
if (!stringArray(value.tools, 128) || value.tools.length === 0) throw new Error("tools must be a non-empty string array with at most 128 entries");
|
|
50
|
+
if (value.skills !== undefined && !stringArray(value.skills, 128)) throw new Error("skills must be a string array with at most 128 entries");
|
|
51
|
+
if (value.sessionPersistence !== undefined && value.sessionPersistence !== "ephemeral" && value.sessionPersistence !== "persistent") {
|
|
52
|
+
throw new Error("sessionPersistence must be 'ephemeral' or 'persistent'");
|
|
53
|
+
}
|
|
54
|
+
if (value.invocation !== undefined && value.invocation !== "both" && value.invocation !== "manual") {
|
|
55
|
+
throw new Error("invocation must be 'both' or 'manual'");
|
|
56
|
+
}
|
|
57
|
+
for (const key of ["description", "model", "thinkingLevel", "instructions"] as const) {
|
|
58
|
+
if (value[key] !== undefined && (typeof value[key] !== "string" || value[key].trim().length === 0)) {
|
|
59
|
+
throw new Error(`${key} must be a non-empty string`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (typeof value.description === "string" && value.description.length > 1024) throw new Error("description exceeds 1024 characters");
|
|
63
|
+
if (typeof value.model === "string" && value.model.length > 512) throw new Error("model exceeds 512 characters");
|
|
64
|
+
if (typeof value.thinkingLevel === "string" && value.thinkingLevel.length > 128) throw new Error("thinkingLevel exceeds 128 characters");
|
|
65
|
+
if (typeof value.instructions === "string" && value.instructions.length > MAX_INSTRUCTIONS_CHARACTERS) throw new Error(`instructions exceeds ${MAX_INSTRUCTIONS_CHARACTERS} characters`);
|
|
66
|
+
if (expectedName !== value.name) throw new Error(`name '${value.name}' must match profile entry '${expectedName}'`);
|
|
67
|
+
return {
|
|
68
|
+
version: 1,
|
|
69
|
+
name: value.name,
|
|
70
|
+
tools: [...new Set(value.tools)],
|
|
71
|
+
...(value.invocation === "both" || value.invocation === "manual" ? { invocation: value.invocation } : {}),
|
|
72
|
+
...(typeof value.description === "string" ? { description: value.description } : {}),
|
|
73
|
+
...(typeof value.model === "string" ? { model: value.model } : {}),
|
|
74
|
+
...(typeof value.thinkingLevel === "string" ? { thinkingLevel: value.thinkingLevel } : {}),
|
|
75
|
+
...(value.sessionPersistence === "ephemeral" || value.sessionPersistence === "persistent"
|
|
76
|
+
? { sessionPersistence: value.sessionPersistence }
|
|
77
|
+
: {}),
|
|
78
|
+
...(Array.isArray(value.skills) ? { skills: [...new Set(value.skills as string[])] } : {}),
|
|
79
|
+
...(typeof value.instructions === "string" ? { instructions: value.instructions } : {}),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function readInstructions(file: string): string | undefined {
|
|
84
|
+
let stat: fs.Stats;
|
|
85
|
+
try {
|
|
86
|
+
stat = fs.statSync(file);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
if (!stat.isFile()) throw new Error("instructions.md must be a regular file");
|
|
92
|
+
// Bound the read while allowing multi-byte UTF-8 instructions.
|
|
93
|
+
if (stat.size > MAX_INSTRUCTIONS_CHARACTERS * 4) throw new Error("instructions.md exceeds maximum byte size");
|
|
94
|
+
const instructions = fs.readFileSync(file, "utf8");
|
|
95
|
+
if (!instructions.trim()) throw new Error("instructions.md must be a non-empty string");
|
|
96
|
+
if (instructions.length > MAX_INSTRUCTIONS_CHARACTERS) throw new Error(`instructions.md exceeds ${MAX_INSTRUCTIONS_CHARACTERS} characters`);
|
|
97
|
+
return instructions;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface ProfileCandidate {
|
|
101
|
+
name: string;
|
|
102
|
+
configPath: string;
|
|
103
|
+
instructionsPath?: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface LoadedProfileDirectory {
|
|
107
|
+
profiles: Map<string, LoadedProfile>;
|
|
108
|
+
invalidNames: Set<string>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function loadDirectory(directory: string, source: ProfileSource, diagnostics: ProfileDiagnostic[]): LoadedProfileDirectory {
|
|
112
|
+
const profiles = new Map<string, LoadedProfile>();
|
|
113
|
+
const invalidNames = new Set<string>();
|
|
114
|
+
let entryNames: string[];
|
|
115
|
+
try {
|
|
116
|
+
entryNames = fs.readdirSync(directory).sort();
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { profiles, invalidNames };
|
|
119
|
+
diagnostics.push({ path: directory, message: String(error) });
|
|
120
|
+
return { profiles, invalidNames };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const candidates = new Map<string, ProfileCandidate[]>();
|
|
124
|
+
for (const entryName of entryNames) {
|
|
125
|
+
const entryPath = path.join(directory, entryName);
|
|
126
|
+
let stat: fs.Stats;
|
|
127
|
+
try {
|
|
128
|
+
stat = fs.statSync(entryPath);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
diagnostics.push({ path: entryPath, message: error instanceof Error ? error.message : String(error) });
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
let candidate: ProfileCandidate | undefined;
|
|
134
|
+
if (stat.isFile() && entryName.endsWith(".json")) {
|
|
135
|
+
candidate = { name: path.basename(entryName, ".json"), configPath: entryPath };
|
|
136
|
+
} else if (stat.isDirectory()) {
|
|
137
|
+
candidate = {
|
|
138
|
+
name: entryName,
|
|
139
|
+
configPath: path.join(entryPath, PROFILE_CONFIG_FILE),
|
|
140
|
+
instructionsPath: path.join(entryPath, PROFILE_INSTRUCTIONS_FILE),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
if (!candidate) continue;
|
|
144
|
+
const entries = candidates.get(candidate.name) ?? [];
|
|
145
|
+
entries.push(candidate);
|
|
146
|
+
candidates.set(candidate.name, entries);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
for (const [name, matches] of candidates) {
|
|
150
|
+
if (!PROFILE_NAME.test(name)) {
|
|
151
|
+
invalidNames.add(name);
|
|
152
|
+
diagnostics.push({ path: matches[0]!.configPath, message: "profile entry name must be 1-64 characters using letters, numbers, dot, underscore, or hyphen" });
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (matches.length > 1) {
|
|
156
|
+
invalidNames.add(name);
|
|
157
|
+
diagnostics.push({ path: directory, message: `duplicate profile '${name}' is defined as both a JSON file and a directory` });
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const candidate = matches[0]!;
|
|
162
|
+
let parsed: ProfileDefinition;
|
|
163
|
+
let diagnosticPath = candidate.configPath;
|
|
164
|
+
try {
|
|
165
|
+
const stat = fs.statSync(candidate.configPath);
|
|
166
|
+
if (!stat.isFile()) throw new Error(`${path.basename(candidate.configPath)} must be a regular file`);
|
|
167
|
+
if (stat.size > MAX_PROFILE_BYTES) throw new Error(`profile exceeds ${MAX_PROFILE_BYTES} bytes`);
|
|
168
|
+
parsed = parseProfile(JSON.parse(fs.readFileSync(candidate.configPath, "utf8")) as unknown, name);
|
|
169
|
+
if (candidate.instructionsPath) {
|
|
170
|
+
diagnosticPath = candidate.instructionsPath;
|
|
171
|
+
const instructions = readInstructions(candidate.instructionsPath);
|
|
172
|
+
if (instructions !== undefined) parsed.instructions = instructions;
|
|
173
|
+
}
|
|
174
|
+
} catch (error) {
|
|
175
|
+
invalidNames.add(name);
|
|
176
|
+
diagnostics.push({ path: diagnosticPath, message: error instanceof Error ? error.message : String(error) });
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
profiles.set(parsed.name, {
|
|
181
|
+
...parsed,
|
|
182
|
+
source,
|
|
183
|
+
sourcePath: candidate.configPath,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
return { profiles, invalidNames };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function globalProfilesDir(): string {
|
|
190
|
+
return path.join(getAgentDir(), "subagent", "profiles");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function projectProfilesDir(cwd: string): string {
|
|
194
|
+
return path.join(cwd, CONFIG_DIR_NAME, "subagent", "profiles");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function ancestorDirectories(cwd: string): string[] {
|
|
198
|
+
const directories: string[] = [];
|
|
199
|
+
let current = path.resolve(cwd);
|
|
200
|
+
while (true) {
|
|
201
|
+
directories.push(current);
|
|
202
|
+
const parent = path.dirname(current);
|
|
203
|
+
if (parent === current) return directories;
|
|
204
|
+
current = parent;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function loadProfiles(cwd: string, includeProject: boolean): ProfileCatalog {
|
|
209
|
+
const diagnostics: ProfileDiagnostic[] = [];
|
|
210
|
+
const global = loadDirectory(globalProfilesDir(), "global", diagnostics);
|
|
211
|
+
const profiles = global.profiles;
|
|
212
|
+
if (includeProject) {
|
|
213
|
+
for (const directory of ancestorDirectories(cwd).reverse()) {
|
|
214
|
+
const project = loadDirectory(projectProfilesDir(directory), "project", diagnostics);
|
|
215
|
+
for (const name of project.invalidNames) profiles.delete(name);
|
|
216
|
+
for (const [name, profile] of project.profiles) profiles.set(name, profile);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return { profiles, diagnostics };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function expandHome(value: string): string {
|
|
223
|
+
return value === "~" ? os.homedir() : value.startsWith("~/") ? path.join(os.homedir(), value.slice(2)) : value;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function skillCandidate(candidate: string): string | undefined {
|
|
227
|
+
try {
|
|
228
|
+
const stat = fs.statSync(candidate);
|
|
229
|
+
if (stat.isFile()) return candidate;
|
|
230
|
+
if (stat.isDirectory()) {
|
|
231
|
+
const entry = path.join(candidate, "SKILL.md");
|
|
232
|
+
if (fs.statSync(entry).isFile()) return entry;
|
|
233
|
+
}
|
|
234
|
+
} catch {}
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function resolveSkill(reference: string, profile: LoadedProfile, cwd: string, includeProject: boolean): string {
|
|
239
|
+
const expanded = expandHome(reference);
|
|
240
|
+
const looksLikePath = path.isAbsolute(expanded) || expanded.startsWith(".") || expanded.includes("/") || expanded.includes("\\");
|
|
241
|
+
const candidates = looksLikePath
|
|
242
|
+
? [path.resolve(path.dirname(profile.sourcePath), expanded)]
|
|
243
|
+
: [
|
|
244
|
+
path.join(getAgentDir(), "skills", expanded),
|
|
245
|
+
path.join(os.homedir(), ".agents", "skills", expanded),
|
|
246
|
+
...(includeProject ? ancestorDirectories(cwd).flatMap((directory) => [
|
|
247
|
+
path.join(directory, CONFIG_DIR_NAME, "skills", expanded),
|
|
248
|
+
path.join(directory, ".agents", "skills", expanded),
|
|
249
|
+
]) : []),
|
|
250
|
+
];
|
|
251
|
+
for (const candidate of candidates) {
|
|
252
|
+
const resolved = skillCandidate(candidate) ?? skillCandidate(`${candidate}.md`);
|
|
253
|
+
if (resolved) return path.resolve(resolved);
|
|
254
|
+
}
|
|
255
|
+
throw new Error(`Profile '${profile.name}' references unknown skill '${reference}'.`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Discover only the selected directory profile's private skills, never sibling profiles. */
|
|
259
|
+
function discoverProfileSkills(profile: LoadedProfile): string[] {
|
|
260
|
+
// Legacy profiles share a parent directory and must not scan its skills folder.
|
|
261
|
+
if (path.basename(profile.sourcePath) !== PROFILE_CONFIG_FILE
|
|
262
|
+
|| path.basename(path.dirname(profile.sourcePath)) !== profile.name) return [];
|
|
263
|
+
const root = path.join(path.dirname(profile.sourcePath), "skills");
|
|
264
|
+
const skills: string[] = [];
|
|
265
|
+
const visited = new Set<string>();
|
|
266
|
+
function visit(directory: string, isRoot: boolean): void {
|
|
267
|
+
let realPath: string;
|
|
268
|
+
try {
|
|
269
|
+
realPath = fs.realpathSync(directory);
|
|
270
|
+
} catch (error) {
|
|
271
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
274
|
+
if (visited.has(realPath)) return;
|
|
275
|
+
visited.add(realPath);
|
|
276
|
+
const entry = skillCandidate(path.join(directory, "SKILL.md"));
|
|
277
|
+
if (entry) {
|
|
278
|
+
skills.push(entry);
|
|
279
|
+
return; // Supporting Markdown and nested assets are not separate skills.
|
|
280
|
+
}
|
|
281
|
+
for (const name of fs.readdirSync(directory).sort()) {
|
|
282
|
+
if (name.startsWith(".") || name === "node_modules") continue;
|
|
283
|
+
const file = path.join(directory, name);
|
|
284
|
+
let stat: fs.Stats;
|
|
285
|
+
try { stat = fs.statSync(file); } catch (error) {
|
|
286
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
if (stat.isDirectory()) visit(file, false);
|
|
290
|
+
else if (isRoot && stat.isFile() && name.endsWith(".md")) skills.push(file);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
visit(root, true);
|
|
294
|
+
return skills;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function resolveProfile(name: string, cwd: string, includeProject: boolean, options: { agentOnly?: boolean } = {}): ResolvedProfile {
|
|
298
|
+
const catalog = loadProfiles(cwd, includeProject);
|
|
299
|
+
const invalid = catalog.diagnostics.map((item) => `${item.path}: ${item.message}`);
|
|
300
|
+
const profile = catalog.profiles.get(name);
|
|
301
|
+
if (!profile) {
|
|
302
|
+
const available = [...catalog.profiles.values()].filter((item) => !options.agentOnly || item.invocation !== "manual").map((item) => item.name).sort().join(", ") || "(none)";
|
|
303
|
+
const suffix = !options.agentOnly && invalid.length > 0 ? ` Invalid profiles: ${invalid.join("; ")}` : "";
|
|
304
|
+
throw new Error(`Unknown Pi Subagent profile '${name}'. Available: ${available}.${suffix}`);
|
|
305
|
+
}
|
|
306
|
+
if (options.agentOnly && profile.invocation === "manual") {
|
|
307
|
+
throw new Error(`Profile '${name}' is user-invoked only. The user can start it with /sub:${name}; delegate cannot start it.`);
|
|
308
|
+
}
|
|
309
|
+
const skillPaths = [
|
|
310
|
+
...discoverProfileSkills(profile),
|
|
311
|
+
...(profile.skills ?? []).map((skill) => resolveSkill(skill, profile, cwd, includeProject)),
|
|
312
|
+
];
|
|
313
|
+
const seen = new Set<string>();
|
|
314
|
+
const resolvedSkills = skillPaths.filter((skill) => {
|
|
315
|
+
const realPath = fs.realpathSync(skill);
|
|
316
|
+
if (seen.has(realPath)) return false;
|
|
317
|
+
seen.add(realPath);
|
|
318
|
+
return true;
|
|
319
|
+
});
|
|
320
|
+
return {
|
|
321
|
+
...profile,
|
|
322
|
+
resolvedSkills,
|
|
323
|
+
resolvedExtensions: [],
|
|
324
|
+
};
|
|
325
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// MCP is ambient Pi context, not selected or validated by a Pi Subagent profile.
|
|
2
|
+
export const MCP_EXTENSIONS = ["builtin:codemode", "builtin:tool-search", "builtin:mcp"] as const;
|
|
3
|
+
export const MCP_DISCOVERY_TOOLS = ["codemode", "tool_search"] as const;
|
|
4
|
+
const RESOURCE_TOOLS = ["list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource"];
|
|
5
|
+
|
|
6
|
+
// Keep all MCP tools eligible for native registration/exposure, including tools
|
|
7
|
+
// discovered after startup. Tool-name patterns require Pi 1.0.4 or newer.
|
|
8
|
+
export const MCP_TOOL_SELECTION = [...MCP_DISCOVERY_TOOLS, "mcp__*", ...RESOURCE_TOOLS];
|
|
9
|
+
|
|
10
|
+
export function isMcpTool(name: string): boolean {
|
|
11
|
+
return name.startsWith("mcp__") || RESOURCE_TOOLS.includes(name);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function isMcpContextTool(name: string): boolean {
|
|
15
|
+
return isMcpTool(name) || (MCP_DISCOVERY_TOOLS as readonly string[]).includes(name);
|
|
16
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { buildProfilesContext } from "./context.js";
|
|
4
|
+
import { loadProfiles, resolveProfile } from "./loader.js";
|
|
5
|
+
import { applyStartupPromptSections, bindPromptSections } from "./system-prompt.js";
|
|
6
|
+
import { applyProfileTools } from "./tool-selection.js";
|
|
7
|
+
import type { ResolvedProfile } from "./types.js";
|
|
8
|
+
import { isMcpTool } from "./mcp.js";
|
|
9
|
+
|
|
10
|
+
export class StartupProfileRuntime {
|
|
11
|
+
private active?: ResolvedProfile;
|
|
12
|
+
private startupError?: string;
|
|
13
|
+
private profilesContext = "";
|
|
14
|
+
|
|
15
|
+
constructor(private readonly pi: ExtensionAPI) {}
|
|
16
|
+
|
|
17
|
+
register(): void {
|
|
18
|
+
this.pi.registerFlag("profile", {
|
|
19
|
+
description: "Pi Subagent profile name to apply at process startup",
|
|
20
|
+
type: "string",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
this.pi.on("session_start", async (_event, ctx) => {
|
|
24
|
+
this.profilesContext = buildProfilesContext(loadProfiles(ctx.cwd, ctx.isProjectTrusted()));
|
|
25
|
+
const selected = this.pi.getFlag("profile");
|
|
26
|
+
if (typeof selected !== "string" || !selected.trim()) {
|
|
27
|
+
this.active = undefined;
|
|
28
|
+
this.startupError = undefined;
|
|
29
|
+
ctx.ui.setStatus("subagent-profile", undefined);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
this.active = resolveProfile(selected.trim(), ctx.cwd, ctx.isProjectTrusted());
|
|
34
|
+
await this.apply(this.active, ctx);
|
|
35
|
+
this.startupError = undefined;
|
|
36
|
+
ctx.ui.setStatus("subagent-profile", `profile:${this.active.name}`);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
this.active = undefined;
|
|
39
|
+
this.startupError = error instanceof Error ? error.message : String(error);
|
|
40
|
+
ctx.ui.notify(this.startupError, "error");
|
|
41
|
+
if (!ctx.hasUI) console.error(`Pi Subagent profile error: ${this.startupError}`);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
this.pi.on("input", (_event, ctx) => {
|
|
46
|
+
if (!this.startupError) return;
|
|
47
|
+
ctx.ui.notify(`Cannot start agent: ${this.startupError}`, "error");
|
|
48
|
+
return { action: "handled" as const };
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
this.pi.on("resources_discover", () => {
|
|
52
|
+
if (!this.active?.resolvedSkills.length) return;
|
|
53
|
+
return { skillPaths: this.active.resolvedSkills };
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
bindPromptSections(this.pi, ["subagent_profiles", "subagent_profile"], () => {
|
|
57
|
+
const sections: Record<string, string> = {};
|
|
58
|
+
applyStartupPromptSections(sections, this.active, this.profilesContext);
|
|
59
|
+
return sections;
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
this.pi.registerCommand("profiles", {
|
|
63
|
+
description: "List startup profiles and their source",
|
|
64
|
+
handler: async (_args, ctx) => {
|
|
65
|
+
const catalog = loadProfiles(ctx.cwd, ctx.isProjectTrusted());
|
|
66
|
+
const lines = [...catalog.profiles.values()]
|
|
67
|
+
.sort((left, right) => left.name.localeCompare(right.name))
|
|
68
|
+
.map((profile) => `${profile.name} (${profile.source}, ${profile.invocation ?? "both"})${profile.description ? ` — ${profile.description}` : ""}`);
|
|
69
|
+
const diagnostics = catalog.diagnostics.map((item) => `${item.path}: ${item.message}`);
|
|
70
|
+
ctx.ui.notify([
|
|
71
|
+
lines.length > 0 ? lines.join("\n") : "No Pi Subagent profiles configured.",
|
|
72
|
+
...(diagnostics.length > 0 ? [`Invalid profiles:\n${diagnostics.join("\n")}`] : []),
|
|
73
|
+
].join("\n\n"), diagnostics.length > 0 ? "warning" : "info");
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private async apply(profile: ResolvedProfile, ctx: ExtensionContext): Promise<void> {
|
|
79
|
+
const allTools = new Set(this.pi.getAllTools().map((tool) => tool.name));
|
|
80
|
+
const unknownTools = profile.tools.filter((tool) => !isMcpTool(tool) && !allTools.has(tool));
|
|
81
|
+
if (unknownTools.length > 0) throw new Error(`Profile '${profile.name}' references unknown tools: ${unknownTools.join(", ")}.`);
|
|
82
|
+
|
|
83
|
+
if (profile.model) {
|
|
84
|
+
const separator = profile.model.indexOf("/");
|
|
85
|
+
if (separator <= 0 || separator === profile.model.length - 1) {
|
|
86
|
+
throw new Error(`Profile '${profile.name}' model must use provider/model format.`);
|
|
87
|
+
}
|
|
88
|
+
const model = ctx.modelRegistry.find(profile.model.slice(0, separator), profile.model.slice(separator + 1));
|
|
89
|
+
if (!model) throw new Error(`Profile '${profile.name}' model '${profile.model}' is unavailable.`);
|
|
90
|
+
if (!(await this.pi.setModel(model))) throw new Error(`Profile '${profile.name}' model '${profile.model}' has no usable credentials.`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
applyProfileTools(this.pi, profile.name, profile.tools);
|
|
94
|
+
if (profile.thinkingLevel) {
|
|
95
|
+
this.pi.setThinkingLevel(profile.thinkingLevel as ThinkingLevel);
|
|
96
|
+
const effective = this.pi.getThinkingLevel();
|
|
97
|
+
if (effective !== profile.thinkingLevel) {
|
|
98
|
+
ctx.ui.notify(`Profile '${profile.name}' requested thinking '${profile.thinkingLevel}'; Pi selected '${effective}'.`, "warning");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ResolvedProfile } from "./types.js";
|
|
3
|
+
|
|
4
|
+
/** Talk uses Pi's user-input path, so idle deliveries share normal prompt assembly. */
|
|
5
|
+
export function bindPromptSections(pi: ExtensionAPI, keys: readonly string[], getSections: () => Record<string, string>): void {
|
|
6
|
+
pi.on("before_agent_start", (event) => {
|
|
7
|
+
const sections = getSections();
|
|
8
|
+
for (const key of keys) {
|
|
9
|
+
if (sections[key]) event.systemPromptOptions.sections[key] = sections[key]!;
|
|
10
|
+
else delete event.systemPromptOptions.sections[key];
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const SUB_PROTOCOL = `## Pi Subagent Sub protocol
|
|
16
|
+
You are an isolated Sub Pi working with a Main Pi. You have not received the Main's conversation and must not read Pi session files. Use talk whenever you need information from the Main or need to deliver work. Each talk sends one message to the Main and ends the current turn. Remain available after delivery. The Main alone decides whether to respond, request more work, or close this Sub session. Never close the session yourself.`;
|
|
17
|
+
|
|
18
|
+
export function applySubPromptSections(sections: Record<string, string>, profile: ResolvedProfile): void {
|
|
19
|
+
sections.subagent_sub_protocol = SUB_PROTOCOL;
|
|
20
|
+
if (profile.instructions) sections.subagent_profile = `## Pi Subagent profile: ${profile.name}\n${profile.instructions}`;
|
|
21
|
+
else delete sections.subagent_profile;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function applyStartupPromptSections(
|
|
25
|
+
sections: Record<string, string>,
|
|
26
|
+
profile: ResolvedProfile | undefined,
|
|
27
|
+
profilesContext: string,
|
|
28
|
+
): void {
|
|
29
|
+
if (profilesContext) sections.subagent_profiles = profilesContext;
|
|
30
|
+
else delete sections.subagent_profiles;
|
|
31
|
+
if (profile?.instructions) sections.subagent_profile = `## Active Pi Subagent profile: ${profile.name}\n${profile.instructions}`;
|
|
32
|
+
else delete sections.subagent_profile;
|
|
33
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { isMcpTool, isMcpContextTool } from "./mcp.js";
|
|
3
|
+
|
|
4
|
+
/** Select the model-facing tool set; profiles do not install execution guards. */
|
|
5
|
+
export function applyProfileTools(pi: ExtensionAPI, profileName: string, tools: readonly string[]): void {
|
|
6
|
+
// Leave MCP visibility and discovery helpers as Pi configured them. Explicit
|
|
7
|
+
// MCP names in a profile neither restrict servers nor delay Sub readiness.
|
|
8
|
+
const selected = tools.filter((name) => !isMcpTool(name));
|
|
9
|
+
const ambient = pi.getActiveTools().filter(isMcpContextTool);
|
|
10
|
+
const available = new Map(pi.getAllTools().map((tool) => [tool.name, tool]));
|
|
11
|
+
const unavailable = selected.filter((name) => !available.has(name) || available.get(name)?.exposure === "hidden");
|
|
12
|
+
if (unavailable.length > 0) {
|
|
13
|
+
throw new Error(`Profile '${profileName}' references unavailable tools: ${unavailable.join(", ")}.`);
|
|
14
|
+
}
|
|
15
|
+
pi.setActiveTools([...new Set([...selected, ...ambient])]);
|
|
16
|
+
const active = new Set(pi.getActiveTools());
|
|
17
|
+
const missing = selected.filter((name) => !active.has(name));
|
|
18
|
+
if (missing.length > 0) {
|
|
19
|
+
throw new Error(`Profile '${profileName}' could not activate tools: ${missing.join(", ")}.`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { ResolvedProfile } from "./types.js";
|
|
4
|
+
import { isMcpTool, MCP_EXTENSIONS } from "./mcp.js";
|
|
5
|
+
|
|
6
|
+
const NATIVE_TOOLS = new Set(["read", "bash", "powershell", "edit", "write", "grep", "find", "ls"]);
|
|
7
|
+
const SUPPORTED_BUILTIN_EXTENSIONS = new Set<string>(MCP_EXTENSIONS);
|
|
8
|
+
|
|
9
|
+
interface ToolSource {
|
|
10
|
+
name: string;
|
|
11
|
+
sourceInfo: {
|
|
12
|
+
path: string;
|
|
13
|
+
source: string;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function resolveToolExtensions(profile: ResolvedProfile, tools: ToolSource[]): ResolvedProfile {
|
|
18
|
+
const available = new Map(tools.map((tool) => [tool.name, tool]));
|
|
19
|
+
const extensionPaths = new Set<string>();
|
|
20
|
+
for (const name of profile.tools) {
|
|
21
|
+
// Native MCP connects asynchronously; do not require its tools in Main yet.
|
|
22
|
+
if (isMcpTool(name)) continue;
|
|
23
|
+
const tool = available.get(name);
|
|
24
|
+
if (!tool) throw new Error(`Profile '${profile.name}' references unavailable tool '${name}'.`);
|
|
25
|
+
const sourcePath = tool.sourceInfo.path;
|
|
26
|
+
if (tool.sourceInfo.source === "builtin") {
|
|
27
|
+
if (NATIVE_TOOLS.has(name) && (sourcePath === `builtin:${name}` || sourcePath === `<builtin:${name}>`)) continue;
|
|
28
|
+
if (!SUPPORTED_BUILTIN_EXTENSIONS.has(sourcePath)) {
|
|
29
|
+
throw new Error(`Profile '${profile.name}' tool '${name}' requires unsupported isolated built-in extension '${sourcePath}'.`);
|
|
30
|
+
}
|
|
31
|
+
extensionPaths.add(sourcePath);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (tool.sourceInfo.source === "sdk" || !sourcePath || sourcePath.startsWith("<")) {
|
|
35
|
+
throw new Error(`Profile '${profile.name}' tool '${name}' cannot be loaded in an isolated Sub process.`);
|
|
36
|
+
}
|
|
37
|
+
let stat: fs.Stats;
|
|
38
|
+
try {
|
|
39
|
+
stat = fs.statSync(sourcePath);
|
|
40
|
+
} catch {
|
|
41
|
+
throw new Error(`Profile '${profile.name}' tool '${name}' extension is missing: ${sourcePath}.`);
|
|
42
|
+
}
|
|
43
|
+
if (!stat.isFile() && !stat.isDirectory()) {
|
|
44
|
+
throw new Error(`Profile '${profile.name}' tool '${name}' has an unsupported extension path: ${sourcePath}.`);
|
|
45
|
+
}
|
|
46
|
+
extensionPaths.add(path.resolve(sourcePath));
|
|
47
|
+
}
|
|
48
|
+
return { ...profile, resolvedExtensions: [...extensionPaths] };
|
|
49
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export type ProfileSource = "global" | "project";
|
|
2
|
+
export type SessionPersistence = "ephemeral" | "persistent";
|
|
3
|
+
export type ProfileInvocation = "both" | "manual";
|
|
4
|
+
|
|
5
|
+
export interface ProfileDefinition {
|
|
6
|
+
version: 1;
|
|
7
|
+
name: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
model?: string;
|
|
10
|
+
thinkingLevel?: string;
|
|
11
|
+
sessionPersistence?: SessionPersistence;
|
|
12
|
+
/** Manual profiles are started by user commands, not model delegation. */
|
|
13
|
+
invocation?: ProfileInvocation;
|
|
14
|
+
tools: string[];
|
|
15
|
+
skills?: string[];
|
|
16
|
+
instructions?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface LoadedProfile extends ProfileDefinition {
|
|
20
|
+
source: ProfileSource;
|
|
21
|
+
sourcePath: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ResolvedProfile extends LoadedProfile {
|
|
25
|
+
resolvedSkills: string[];
|
|
26
|
+
resolvedExtensions: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ProfileDiagnostic {
|
|
30
|
+
path: string;
|
|
31
|
+
message: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ProfileCatalog {
|
|
35
|
+
profiles: Map<string, LoadedProfile>;
|
|
36
|
+
diagnostics: ProfileDiagnostic[];
|
|
37
|
+
}
|