@narumitw/pi-subagents 3.0.1 → 3.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/README.md +80 -136
- package/dist/child-communication-bridge.ts +9 -12
- package/dist/child-communication-bridge.ts.map +2 -2
- package/dist/child-readiness-probe.ts +68 -0
- package/dist/child-readiness-probe.ts.map +7 -0
- package/dist/chunks/chunk-3FCHXUKZ.ts +98 -0
- package/dist/chunks/chunk-3FCHXUKZ.ts.map +7 -0
- package/dist/chunks/{chunk-UKTWUQRM.js → chunk-YMVSJ5GE.ts} +89 -152
- package/dist/chunks/chunk-YMVSJ5GE.ts.map +7 -0
- package/dist/chunks/chunk-ZOAFWLDJ.ts +130 -0
- package/dist/chunks/chunk-ZOAFWLDJ.ts.map +7 -0
- package/dist/index.ts +1201 -120
- package/dist/index.ts.map +4 -4
- package/docs/attachments.md +37 -0
- package/docs/design-principles.md +8 -2
- package/docs/messaging.md +23 -0
- package/docs/tools.md +22 -56
- package/package.json +56 -54
- package/src/attachment-utils.ts +104 -0
- package/src/broker-credentials.ts +132 -54
- package/src/child-communication-bridge.ts +117 -125
- package/src/child-communication-tools.ts +89 -117
- package/src/child-readiness-probe.ts +72 -0
- package/src/completion-renderer.ts +46 -48
- package/src/message-broker.ts +531 -561
- package/src/model-output.ts +25 -28
- package/src/process.ts +942 -619
- package/src/resource-attachments.ts +724 -0
- package/src/runtime.ts +471 -478
- package/src/skill-attachments.ts +302 -0
- package/src/subagents.ts +20 -23
- package/src/timeout.ts +24 -0
- package/src/tools.ts +389 -325
- package/src/types.ts +45 -58
- package/src/widget.ts +85 -91
- package/dist/chunks/chunk-UKTWUQRM.js.map +0 -7
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
import { type Dirent, existsSync, type Stats, statSync } from "node:fs";
|
|
2
|
+
import {
|
|
3
|
+
opendir as opendirAsync,
|
|
4
|
+
readFile as readFileAsync,
|
|
5
|
+
realpath as realpathAsync,
|
|
6
|
+
stat as statAsync,
|
|
7
|
+
} from "node:fs/promises";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
import { DefaultPackageManager, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import ignore from "ignore";
|
|
11
|
+
import {
|
|
12
|
+
addAttachmentIgnoreRules,
|
|
13
|
+
type IgnoreMatcher,
|
|
14
|
+
isAbortError,
|
|
15
|
+
isRecord,
|
|
16
|
+
isWithin,
|
|
17
|
+
realpath,
|
|
18
|
+
throwIfAttachmentAborted,
|
|
19
|
+
toolSourceId,
|
|
20
|
+
toPosixPath,
|
|
21
|
+
} from "./attachment-utils.js";
|
|
22
|
+
import { CHILD_COMMUNICATION_TOOL_NAMES } from "./child-communication-tools.js";
|
|
23
|
+
import { assertLoadablePackageSkills, assertLoadableSkills } from "./skill-attachments.js";
|
|
24
|
+
import { CHILD_CORE_TOOL_NAMES } from "./types.js";
|
|
25
|
+
|
|
26
|
+
export {
|
|
27
|
+
MAX_SKILL_IGNORE_BYTES,
|
|
28
|
+
MAX_SKILL_SCAN_BYTES,
|
|
29
|
+
MAX_SKILL_SCAN_DEPTH,
|
|
30
|
+
MAX_SKILL_SCAN_ENTRIES,
|
|
31
|
+
} from "./skill-attachments.js";
|
|
32
|
+
|
|
33
|
+
export const MAX_ATTACHED_SKILLS = 16;
|
|
34
|
+
export const MAX_ATTACHED_EXTENSIONS = 16;
|
|
35
|
+
export const MAX_SELECTED_TOOLS = 64;
|
|
36
|
+
export const MAX_RESOURCE_PATH_BYTES = 4 * 1024;
|
|
37
|
+
export const MAX_EXTENSION_TOOL_NAME_LENGTH = 128;
|
|
38
|
+
export const MAX_EXTENSION_SCAN_DEPTH = 32;
|
|
39
|
+
export const MAX_EXTENSION_SCAN_ENTRIES = 4_096;
|
|
40
|
+
export const MAX_EXTENSION_METADATA_BYTES = 1024 * 1024;
|
|
41
|
+
|
|
42
|
+
const RESERVED_CHILD_TOOL_NAMES = new Set<string>([...CHILD_CORE_TOOL_NAMES, ...CHILD_COMMUNICATION_TOOL_NAMES]);
|
|
43
|
+
|
|
44
|
+
export interface ExtensionAttachment {
|
|
45
|
+
path: string;
|
|
46
|
+
tools: string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ResourceAttachments {
|
|
50
|
+
skills: string[];
|
|
51
|
+
extensions: ExtensionAttachment[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ResolvedResourceAttachments extends ResourceAttachments {
|
|
55
|
+
effectiveTools: string[];
|
|
56
|
+
toolSources: Record<string, string[]>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ResourceAttachmentInput {
|
|
60
|
+
skills?: unknown;
|
|
61
|
+
extensions?: unknown;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ResolveResourceAttachmentOptions {
|
|
65
|
+
cwd: string;
|
|
66
|
+
projectTrusted: boolean;
|
|
67
|
+
coreTools: readonly string[];
|
|
68
|
+
signal?: AbortSignal;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function resolveResourceAttachments(
|
|
72
|
+
input: ResourceAttachmentInput,
|
|
73
|
+
options: ResolveResourceAttachmentOptions,
|
|
74
|
+
): Promise<ResolvedResourceAttachments> {
|
|
75
|
+
const cwd = path.resolve(options.cwd);
|
|
76
|
+
const canonicalCwd = realpath(cwd, "Subagent working directory");
|
|
77
|
+
const skillInputs = optionalArray(input.skills, "skills", MAX_ATTACHED_SKILLS);
|
|
78
|
+
const extensionInputs = optionalArray(input.extensions, "extensions", MAX_ATTACHED_EXTENSIONS);
|
|
79
|
+
const skills: string[] = [];
|
|
80
|
+
const seenSkills = new Set<string>();
|
|
81
|
+
for (const candidate of skillInputs) {
|
|
82
|
+
const resolved = resolveResourcePath(candidate, "skill", cwd, canonicalCwd, options.projectTrusted);
|
|
83
|
+
if (!seenSkills.has(resolved)) {
|
|
84
|
+
seenSkills.add(resolved);
|
|
85
|
+
skills.push(resolved);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const skillScanState = await assertLoadableSkills(skills, cwd, canonicalCwd, options.projectTrusted, options.signal);
|
|
89
|
+
|
|
90
|
+
const extensions: ExtensionAttachment[] = [];
|
|
91
|
+
const extensionsByPath = new Map<string, ExtensionAttachment>();
|
|
92
|
+
const entrypointsByPath = new Map<string, string[]>();
|
|
93
|
+
const packageSkillPaths: string[] = [];
|
|
94
|
+
for (const candidate of extensionInputs) {
|
|
95
|
+
if (!isRecord(candidate) || Object.keys(candidate).some((key) => key !== "path" && key !== "tools")) {
|
|
96
|
+
throw new Error("Each subagent extension must contain only path and tools.");
|
|
97
|
+
}
|
|
98
|
+
const resolved = resolveResourcePath(candidate.path, "extension", cwd, canonicalCwd, options.projectTrusted);
|
|
99
|
+
const toolInputs = optionalArray(candidate.tools, "extension tools", MAX_SELECTED_TOOLS, false);
|
|
100
|
+
const tools = toolInputs.map(resolveExtensionToolName);
|
|
101
|
+
for (const tool of tools) {
|
|
102
|
+
if (RESERVED_CHILD_TOOL_NAMES.has(tool)) {
|
|
103
|
+
throw new Error(`Subagent extension tool name conflicts with the built-in ${tool} tool.`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
let attachment = extensionsByPath.get(resolved);
|
|
107
|
+
if (!attachment) {
|
|
108
|
+
const resources = await assertResolvableExtensionAttachment(
|
|
109
|
+
resolved,
|
|
110
|
+
cwd,
|
|
111
|
+
canonicalCwd,
|
|
112
|
+
options.projectTrusted,
|
|
113
|
+
options.signal,
|
|
114
|
+
);
|
|
115
|
+
packageSkillPaths.push(...resources.skills);
|
|
116
|
+
entrypointsByPath.set(resolved, resources.entrypoints);
|
|
117
|
+
attachment = { path: resolved, tools: [] };
|
|
118
|
+
extensionsByPath.set(resolved, attachment);
|
|
119
|
+
extensions.push(attachment);
|
|
120
|
+
}
|
|
121
|
+
for (const tool of tools) {
|
|
122
|
+
if (!attachment.tools.includes(tool)) attachment.tools.push(tool);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (packageSkillPaths.length > 0) {
|
|
127
|
+
await assertLoadablePackageSkills(packageSkillPaths, skills, cwd, skillScanState);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const effectiveTools = [...new Set([...options.coreTools, ...extensions.flatMap((extension) => extension.tools)])];
|
|
131
|
+
if (effectiveTools.length > MAX_SELECTED_TOOLS) {
|
|
132
|
+
throw new Error(`Subagent jobs may select at most ${MAX_SELECTED_TOOLS} total tools.`);
|
|
133
|
+
}
|
|
134
|
+
const toolSources = new Map<string, string[]>();
|
|
135
|
+
const firstSourceIdByCanonicalPath = new Map<string, string>();
|
|
136
|
+
for (const extension of extensions) {
|
|
137
|
+
// Pi keeps the first lexical source when multiple entrypoints resolve to the same file.
|
|
138
|
+
const sourceIds = (entrypointsByPath.get(extension.path) ?? []).map((entrypoint) => {
|
|
139
|
+
const canonicalPath = realpath(entrypoint, "Subagent extension entrypoint");
|
|
140
|
+
const sourceId = firstSourceIdByCanonicalPath.get(canonicalPath) ?? toolSourceId(entrypoint);
|
|
141
|
+
firstSourceIdByCanonicalPath.set(canonicalPath, sourceId);
|
|
142
|
+
return sourceId;
|
|
143
|
+
});
|
|
144
|
+
for (const tool of extension.tools) {
|
|
145
|
+
if (toolSources.has(tool)) {
|
|
146
|
+
throw new Error(`Subagent extension tool ${tool} is requested by multiple attachments.`);
|
|
147
|
+
}
|
|
148
|
+
toolSources.set(tool, sourceIds);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { skills, extensions, effectiveTools, toolSources: Object.fromEntries(toolSources) };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function optionalArray(value: unknown, field: string, maxItems: number, optional = true): unknown[] {
|
|
155
|
+
if (value === undefined && optional) return [];
|
|
156
|
+
if (!Array.isArray(value)) throw new Error(`Subagent ${field} must be an array.`);
|
|
157
|
+
if (value.length > maxItems) throw new Error(`Subagent ${field} may contain at most ${maxItems} entries.`);
|
|
158
|
+
return value;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function resolveResourcePath(
|
|
162
|
+
value: unknown,
|
|
163
|
+
kind: "skill" | "extension",
|
|
164
|
+
cwd: string,
|
|
165
|
+
canonicalCwd: string,
|
|
166
|
+
projectTrusted: boolean,
|
|
167
|
+
): string {
|
|
168
|
+
if (typeof value !== "string" || !value.trim()) throw new Error(`Subagent ${kind} path is required.`);
|
|
169
|
+
const resourcePath = value.trim();
|
|
170
|
+
if (hasControlCharacter(resourcePath)) throw new Error(`Subagent ${kind} path must not contain control characters.`);
|
|
171
|
+
if (Buffer.byteLength(resourcePath, "utf8") > MAX_RESOURCE_PATH_BYTES) {
|
|
172
|
+
throw new Error(`Subagent ${kind} path must be at most ${MAX_RESOURCE_PATH_BYTES} UTF-8 bytes.`);
|
|
173
|
+
}
|
|
174
|
+
if (!isLocalPath(resourcePath)) throw new Error(`Subagent ${kind} must use a local path.`);
|
|
175
|
+
const lexicalPath = path.resolve(cwd, resourcePath);
|
|
176
|
+
const canonicalPath = realpath(lexicalPath, `Subagent ${kind}`);
|
|
177
|
+
const stats = statSync(canonicalPath);
|
|
178
|
+
if (!stats.isFile() && !stats.isDirectory()) {
|
|
179
|
+
throw new Error(`Subagent ${kind} path must reference a file or directory.`);
|
|
180
|
+
}
|
|
181
|
+
if (!projectTrusted && (isWithin(cwd, lexicalPath) || isWithin(canonicalCwd, canonicalPath))) {
|
|
182
|
+
throw new Error(`Subagent ${kind} cannot load a project path because the project is not trusted.`);
|
|
183
|
+
}
|
|
184
|
+
return canonicalPath;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function assertResolvableExtensionAttachment(
|
|
188
|
+
extensionPath: string,
|
|
189
|
+
cwd: string,
|
|
190
|
+
canonicalCwd: string,
|
|
191
|
+
projectTrusted: boolean,
|
|
192
|
+
signal?: AbortSignal,
|
|
193
|
+
): Promise<{ skills: string[]; entrypoints: string[] }> {
|
|
194
|
+
await inspectExtensionAttachment(extensionPath, cwd, canonicalCwd, projectTrusted, signal);
|
|
195
|
+
throwIfAttachmentAborted(signal);
|
|
196
|
+
const resolved = await resolveAttachedExtensionResources(extensionPath, cwd);
|
|
197
|
+
throwIfAttachmentAborted(signal);
|
|
198
|
+
const entrypoints = enabledResourcePaths(resolved.extensions);
|
|
199
|
+
if (entrypoints.length === 0) {
|
|
200
|
+
throw new Error("Subagent extension directory must contain at least one loadable Pi extension entrypoint.");
|
|
201
|
+
}
|
|
202
|
+
const loadPaths = entrypoints.flatMap(resolveDirectExtensionLoadPaths);
|
|
203
|
+
for (const entrypoint of loadPaths) {
|
|
204
|
+
assertTrustedResolvedPath(entrypoint, "extension entrypoint", cwd, canonicalCwd, projectTrusted);
|
|
205
|
+
}
|
|
206
|
+
const packageSkills = enabledResourcePaths(resolved.skills);
|
|
207
|
+
const packageResources = [
|
|
208
|
+
packageSkills,
|
|
209
|
+
enabledResourcePaths(resolved.prompts),
|
|
210
|
+
enabledResourcePaths(resolved.themes),
|
|
211
|
+
].flat();
|
|
212
|
+
for (const resource of packageResources) {
|
|
213
|
+
assertTrustedResolvedPath(resource, "extension package resource", cwd, canonicalCwd, projectTrusted);
|
|
214
|
+
}
|
|
215
|
+
return { skills: packageSkills, entrypoints: loadPaths };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function resolveDirectExtensionLoadPaths(entrypoint: string): string[] {
|
|
219
|
+
if (!statSync(entrypoint).isDirectory()) return [entrypoint];
|
|
220
|
+
for (const filename of ["index.ts", "index.js"]) {
|
|
221
|
+
const indexPath = path.join(entrypoint, filename);
|
|
222
|
+
if (existsSync(indexPath) && statSync(indexPath).isFile()) return [entrypoint, indexPath];
|
|
223
|
+
}
|
|
224
|
+
throwUnresolvableExtensionEntrypoint();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function resolveAttachedExtensionResources(extensionPath: string, cwd: string) {
|
|
228
|
+
const packageManager = new DefaultPackageManager({
|
|
229
|
+
cwd,
|
|
230
|
+
agentDir: cwd,
|
|
231
|
+
settingsManager: SettingsManager.inMemory(),
|
|
232
|
+
});
|
|
233
|
+
return packageManager.resolveExtensionSources([extensionPath], { temporary: true });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function enabledResourcePaths(resources: readonly { enabled: boolean; path: string }[]): string[] {
|
|
237
|
+
return resources.filter((resource) => resource.enabled).map((resource) => resource.path);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function assertTrustedResolvedPath(
|
|
241
|
+
resourcePath: string,
|
|
242
|
+
label: string,
|
|
243
|
+
cwd: string,
|
|
244
|
+
canonicalCwd: string,
|
|
245
|
+
projectTrusted: boolean,
|
|
246
|
+
): void {
|
|
247
|
+
const canonicalPath = realpath(resourcePath, `Subagent ${label}`);
|
|
248
|
+
const stats = statSync(canonicalPath);
|
|
249
|
+
if (!stats.isFile() && !stats.isDirectory()) {
|
|
250
|
+
throw new Error(`Subagent ${label} must reference a file or directory.`);
|
|
251
|
+
}
|
|
252
|
+
assertProjectPathTrusted(resourcePath, canonicalPath, label, cwd, canonicalCwd, projectTrusted);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function assertProjectPathTrusted(
|
|
256
|
+
lexicalPath: string,
|
|
257
|
+
canonicalPath: string,
|
|
258
|
+
label: string,
|
|
259
|
+
cwd: string,
|
|
260
|
+
canonicalCwd: string,
|
|
261
|
+
projectTrusted: boolean,
|
|
262
|
+
): void {
|
|
263
|
+
if (!projectTrusted && (isWithin(cwd, path.resolve(lexicalPath)) || isWithin(canonicalCwd, canonicalPath))) {
|
|
264
|
+
throw new Error(`Subagent ${label} cannot load a project path because the project is not trusted.`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
type PackageResourceType = "skills" | "prompts" | "themes";
|
|
269
|
+
type PiManifest = Partial<Record<"extensions" | PackageResourceType, string[]>>;
|
|
270
|
+
|
|
271
|
+
interface InspectedPiManifest {
|
|
272
|
+
hasPiManifest: boolean;
|
|
273
|
+
manifest?: PiManifest;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
interface ExtensionScanState {
|
|
277
|
+
entries: number;
|
|
278
|
+
metadataBytes: number;
|
|
279
|
+
ancestors: Set<string>;
|
|
280
|
+
cwd: string;
|
|
281
|
+
canonicalCwd: string;
|
|
282
|
+
projectTrusted: boolean;
|
|
283
|
+
signal?: AbortSignal;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function inspectExtensionAttachment(
|
|
287
|
+
extensionPath: string,
|
|
288
|
+
cwd: string,
|
|
289
|
+
canonicalCwd: string,
|
|
290
|
+
projectTrusted: boolean,
|
|
291
|
+
signal?: AbortSignal,
|
|
292
|
+
): Promise<void> {
|
|
293
|
+
if (!statSync(extensionPath).isDirectory()) return;
|
|
294
|
+
const state: ExtensionScanState = {
|
|
295
|
+
entries: 0,
|
|
296
|
+
metadataBytes: 0,
|
|
297
|
+
ancestors: new Set<string>(),
|
|
298
|
+
cwd,
|
|
299
|
+
canonicalCwd,
|
|
300
|
+
projectTrusted,
|
|
301
|
+
signal,
|
|
302
|
+
};
|
|
303
|
+
const inspected = await inspectPiManifest(extensionPath, state);
|
|
304
|
+
if (inspected.hasPiManifest) {
|
|
305
|
+
await inspectDeclaredExtensionEntries(extensionPath, inspected.manifest?.extensions, true, state);
|
|
306
|
+
for (const resourceType of ["skills", "prompts", "themes"] as const) {
|
|
307
|
+
await inspectDeclaredPackageResourceEntries(
|
|
308
|
+
extensionPath,
|
|
309
|
+
inspected.manifest?.[resourceType],
|
|
310
|
+
resourceType,
|
|
311
|
+
state,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const resourceDirectories = ["extensions", "skills", "prompts", "themes"].map((name) =>
|
|
318
|
+
path.join(extensionPath, name),
|
|
319
|
+
);
|
|
320
|
+
const resourceStats = await Promise.all(resourceDirectories.map((directory) => statIfPresent(directory)));
|
|
321
|
+
if (resourceStats.some((stats) => stats?.isDirectory())) {
|
|
322
|
+
if (resourceStats[0]?.isDirectory()) await inspectAutoExtensionDirectory(resourceDirectories[0], true, state);
|
|
323
|
+
for (const [index, resourceType] of (["skills", "prompts", "themes"] as const).entries()) {
|
|
324
|
+
if (resourceStats[index + 1]?.isDirectory()) {
|
|
325
|
+
await inspectPackageResourceDirectory(resourceDirectories[index + 1], resourceType, state);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
await inspectAutoExtensionDirectory(extensionPath, true, state);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function inspectAutoExtensionDirectory(
|
|
334
|
+
directory: string,
|
|
335
|
+
discoverContents: boolean,
|
|
336
|
+
state: ExtensionScanState,
|
|
337
|
+
enforceTrust = true,
|
|
338
|
+
): Promise<boolean> {
|
|
339
|
+
throwIfAttachmentAborted(state.signal);
|
|
340
|
+
if (enforceTrust) assertExtensionPackagePathTrusted(directory, "extension entrypoint", state);
|
|
341
|
+
const inspected = await inspectPiManifest(directory, state, enforceTrust);
|
|
342
|
+
const sourceEntries = inspected.manifest?.extensions?.filter((entrypoint) => !isExtensionOverridePattern(entrypoint));
|
|
343
|
+
if (sourceEntries && sourceEntries.length > 0) {
|
|
344
|
+
await inspectDeclaredExtensionEntries(directory, inspected.manifest?.extensions, false, state);
|
|
345
|
+
return true;
|
|
346
|
+
}
|
|
347
|
+
if (await hasRegularExtensionIndex(directory, state, enforceTrust)) return true;
|
|
348
|
+
if (!discoverContents) return false;
|
|
349
|
+
|
|
350
|
+
const ignoreMatcher = ignore();
|
|
351
|
+
await addPackageIgnoreRules(ignoreMatcher, directory, directory, state, enforceTrust);
|
|
352
|
+
const entries = await readBoundedPackageEntries(directory, state, "reject");
|
|
353
|
+
let found = false;
|
|
354
|
+
for (const entry of entries) {
|
|
355
|
+
throwIfAttachmentAborted(state.signal);
|
|
356
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
357
|
+
const entryPath = path.join(directory, entry.name);
|
|
358
|
+
const entryStats = await statIfPresent(entryPath);
|
|
359
|
+
if (!entryStats) continue;
|
|
360
|
+
const relativePath = toPosixPath(path.relative(directory, entryPath));
|
|
361
|
+
if (ignoreMatcher.ignores(entryStats.isDirectory() ? `${relativePath}/` : relativePath)) continue;
|
|
362
|
+
if (entryStats.isFile()) {
|
|
363
|
+
if (!entry.name.endsWith(".ts") && !entry.name.endsWith(".js")) continue;
|
|
364
|
+
if (enforceTrust) assertExtensionPackagePathTrusted(entryPath, "extension entrypoint", state);
|
|
365
|
+
found = true;
|
|
366
|
+
} else if (entryStats.isDirectory()) {
|
|
367
|
+
if (enforceTrust) assertExtensionPackagePathTrusted(entryPath, "extension entrypoint", state);
|
|
368
|
+
if (await inspectAutoExtensionDirectory(entryPath, false, state, enforceTrust)) found = true;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return found;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function inspectDeclaredExtensionEntries(
|
|
375
|
+
directory: string,
|
|
376
|
+
entries: string[] | undefined,
|
|
377
|
+
expandDirectories: boolean,
|
|
378
|
+
state: ExtensionScanState,
|
|
379
|
+
): Promise<void> {
|
|
380
|
+
if (!entries) return;
|
|
381
|
+
// Bound declared trees here, then trust-gate only Pi's final enabled resources after manifest overrides.
|
|
382
|
+
for (const entrypoint of entries) {
|
|
383
|
+
throwIfAttachmentAborted(state.signal);
|
|
384
|
+
state.entries++;
|
|
385
|
+
if (state.entries > MAX_EXTENSION_SCAN_ENTRIES) throwExtensionScanLimit();
|
|
386
|
+
if (isExtensionOverridePattern(entrypoint)) continue;
|
|
387
|
+
if (hasExtensionGlob(entrypoint)) {
|
|
388
|
+
throw new Error("Subagent extension package must not contain glob entrypoint declarations.");
|
|
389
|
+
}
|
|
390
|
+
const resolved = path.resolve(directory, entrypoint);
|
|
391
|
+
const resolvedStats = await statIfPresent(resolved);
|
|
392
|
+
if (!resolvedStats) throwUnresolvableExtensionEntrypoint();
|
|
393
|
+
if (resolvedStats.isFile()) continue;
|
|
394
|
+
if (!resolvedStats.isDirectory()) throwUnresolvableExtensionEntrypoint();
|
|
395
|
+
if (expandDirectories) {
|
|
396
|
+
if (!(await inspectAutoExtensionDirectory(resolved, true, state, false))) throwUnresolvableExtensionEntrypoint();
|
|
397
|
+
} else if (!(await hasRegularExtensionIndex(resolved, state, false))) {
|
|
398
|
+
throwUnresolvableExtensionEntrypoint();
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function inspectDeclaredPackageResourceEntries(
|
|
404
|
+
directory: string,
|
|
405
|
+
entries: string[] | undefined,
|
|
406
|
+
resourceType: PackageResourceType,
|
|
407
|
+
state: ExtensionScanState,
|
|
408
|
+
): Promise<void> {
|
|
409
|
+
if (!entries) return;
|
|
410
|
+
// Bound declared trees here, then trust-gate only Pi's final enabled resources after manifest overrides.
|
|
411
|
+
for (const entrypoint of entries) {
|
|
412
|
+
throwIfAttachmentAborted(state.signal);
|
|
413
|
+
state.entries++;
|
|
414
|
+
if (state.entries > MAX_EXTENSION_SCAN_ENTRIES) throwExtensionScanLimit();
|
|
415
|
+
if (isExtensionOverridePattern(entrypoint)) continue;
|
|
416
|
+
if (hasExtensionGlob(entrypoint)) {
|
|
417
|
+
throw new Error("Subagent extension package must not contain glob resource declarations.");
|
|
418
|
+
}
|
|
419
|
+
const resolved = path.resolve(directory, entrypoint);
|
|
420
|
+
const resolvedStats = await statIfPresent(resolved);
|
|
421
|
+
throwIfAttachmentAborted(state.signal);
|
|
422
|
+
if (resourceType === "skills" && (!resolvedStats || (!resolvedStats.isFile() && !resolvedStats.isDirectory()))) {
|
|
423
|
+
throw new Error("Subagent extension package must not contain a missing or unreadable declared skill.");
|
|
424
|
+
}
|
|
425
|
+
if (resolvedStats?.isDirectory()) await inspectPackageResourceDirectory(resolved, resourceType, state, false);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function inspectPackageResourceDirectory(
|
|
430
|
+
directory: string,
|
|
431
|
+
resourceType: PackageResourceType,
|
|
432
|
+
state: ExtensionScanState,
|
|
433
|
+
enforceTrust = true,
|
|
434
|
+
): Promise<void> {
|
|
435
|
+
await inspectPackageResourceTree(directory, resourceType, ignore(), directory, 0, state, enforceTrust);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async function inspectPackageResourceTree(
|
|
439
|
+
directory: string,
|
|
440
|
+
resourceType: PackageResourceType,
|
|
441
|
+
ignoreMatcher: IgnoreMatcher,
|
|
442
|
+
rootDirectory: string,
|
|
443
|
+
depth: number,
|
|
444
|
+
state: ExtensionScanState,
|
|
445
|
+
enforceTrust: boolean,
|
|
446
|
+
): Promise<void> {
|
|
447
|
+
const canonicalDirectory = await enterPackageResourceDirectory(directory, resourceType, depth, state, enforceTrust);
|
|
448
|
+
if (!canonicalDirectory) return;
|
|
449
|
+
try {
|
|
450
|
+
await addPackageIgnoreRules(ignoreMatcher, directory, rootDirectory, state, enforceTrust);
|
|
451
|
+
const entries = await readBoundedPackageEntries(
|
|
452
|
+
directory,
|
|
453
|
+
state,
|
|
454
|
+
resourceType === "skills" ? "reject-skill" : "skip",
|
|
455
|
+
);
|
|
456
|
+
if (resourceType === "skills") {
|
|
457
|
+
const rootSkill = entries.find((entry) => entry.name === "SKILL.md");
|
|
458
|
+
if (rootSkill) {
|
|
459
|
+
const skillPath = path.join(directory, rootSkill.name);
|
|
460
|
+
const skillStats = await statIfPresent(skillPath);
|
|
461
|
+
const relativePath = toPosixPath(path.relative(rootDirectory, skillPath));
|
|
462
|
+
if (skillStats?.isFile() && !ignoreMatcher.ignores(relativePath)) {
|
|
463
|
+
if (enforceTrust) assertExtensionPackagePathTrusted(skillPath, "extension package resource", state);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
for (const entry of entries) {
|
|
469
|
+
throwIfAttachmentAborted(state.signal);
|
|
470
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
471
|
+
const entryPath = path.join(directory, entry.name);
|
|
472
|
+
const relativePath = toPosixPath(path.relative(rootDirectory, entryPath));
|
|
473
|
+
const entryStats = await statIfPresent(entryPath);
|
|
474
|
+
throwIfAttachmentAborted(state.signal);
|
|
475
|
+
const isSkillCandidate =
|
|
476
|
+
resourceType === "skills" &&
|
|
477
|
+
(entry.name === "SKILL.md" || (directory === rootDirectory && entry.name.endsWith(".md")));
|
|
478
|
+
if (
|
|
479
|
+
isSkillCandidate &&
|
|
480
|
+
(!entryStats || (!entryStats.isFile() && !entryStats.isDirectory())) &&
|
|
481
|
+
!ignoreMatcher.ignores(relativePath) &&
|
|
482
|
+
!ignoreMatcher.ignores(`${relativePath}/`)
|
|
483
|
+
) {
|
|
484
|
+
throw new Error("Subagent extension package must not contain an invalid or unreadable declared skill.");
|
|
485
|
+
}
|
|
486
|
+
if (!entryStats) continue;
|
|
487
|
+
if (ignoreMatcher.ignores(entryStats.isDirectory() ? `${relativePath}/` : relativePath)) continue;
|
|
488
|
+
if (entryStats.isFile()) {
|
|
489
|
+
const isResourceFile =
|
|
490
|
+
entry.name.endsWith(resourceType === "themes" ? ".json" : ".md") &&
|
|
491
|
+
(resourceType !== "skills" || directory === rootDirectory);
|
|
492
|
+
if (enforceTrust && isResourceFile) {
|
|
493
|
+
assertExtensionPackagePathTrusted(entryPath, "extension package resource", state);
|
|
494
|
+
}
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
if (!entryStats.isDirectory()) continue;
|
|
498
|
+
if (enforceTrust) assertExtensionPackagePathTrusted(entryPath, "extension package resource", state);
|
|
499
|
+
await inspectPackageResourceTree(
|
|
500
|
+
entryPath,
|
|
501
|
+
resourceType,
|
|
502
|
+
ignoreMatcher,
|
|
503
|
+
rootDirectory,
|
|
504
|
+
depth + 1,
|
|
505
|
+
state,
|
|
506
|
+
enforceTrust,
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
} finally {
|
|
510
|
+
state.ancestors.delete(canonicalDirectory);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async function enterPackageResourceDirectory(
|
|
515
|
+
directory: string,
|
|
516
|
+
resourceType: PackageResourceType,
|
|
517
|
+
depth: number,
|
|
518
|
+
state: ExtensionScanState,
|
|
519
|
+
enforceTrust: boolean,
|
|
520
|
+
): Promise<string | undefined> {
|
|
521
|
+
throwIfAttachmentAborted(state.signal);
|
|
522
|
+
if (depth > MAX_EXTENSION_SCAN_DEPTH) throwExtensionScanLimit();
|
|
523
|
+
let canonicalDirectory: string;
|
|
524
|
+
try {
|
|
525
|
+
canonicalDirectory = await realpathAsync(directory);
|
|
526
|
+
} catch {
|
|
527
|
+
throwIfAttachmentAborted(state.signal);
|
|
528
|
+
if (resourceType === "skills") throwUnreadablePackageSkillDirectory();
|
|
529
|
+
return undefined;
|
|
530
|
+
}
|
|
531
|
+
throwIfAttachmentAborted(state.signal);
|
|
532
|
+
if (enforceTrust) {
|
|
533
|
+
assertProjectPathTrusted(
|
|
534
|
+
directory,
|
|
535
|
+
canonicalDirectory,
|
|
536
|
+
"extension package resource",
|
|
537
|
+
state.cwd,
|
|
538
|
+
state.canonicalCwd,
|
|
539
|
+
state.projectTrusted,
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
if (state.ancestors.has(canonicalDirectory)) {
|
|
543
|
+
throw new Error("Subagent extension package must not contain a recursive resource directory link.");
|
|
544
|
+
}
|
|
545
|
+
state.ancestors.add(canonicalDirectory);
|
|
546
|
+
return canonicalDirectory;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async function inspectPiManifest(
|
|
550
|
+
directory: string,
|
|
551
|
+
state: ExtensionScanState,
|
|
552
|
+
enforceTrust = true,
|
|
553
|
+
): Promise<InspectedPiManifest> {
|
|
554
|
+
const manifestPath = path.join(directory, "package.json");
|
|
555
|
+
const manifestStats = await statIfPresent(manifestPath);
|
|
556
|
+
if (!manifestStats) return { hasPiManifest: false };
|
|
557
|
+
if (!manifestStats.isFile()) {
|
|
558
|
+
throw new Error("Subagent extension package manifest must be a regular file.");
|
|
559
|
+
}
|
|
560
|
+
if (enforceTrust) assertExtensionPackagePathTrusted(manifestPath, "extension package resource", state);
|
|
561
|
+
state.metadataBytes += manifestStats.size;
|
|
562
|
+
if (state.metadataBytes > MAX_EXTENSION_METADATA_BYTES) throwExtensionScanLimit();
|
|
563
|
+
let document: unknown;
|
|
564
|
+
try {
|
|
565
|
+
document = JSON.parse(
|
|
566
|
+
(await readFileAsync(manifestPath, { encoding: "utf8", signal: state.signal })).replace(/^\uFEFF/u, ""),
|
|
567
|
+
);
|
|
568
|
+
} catch (error) {
|
|
569
|
+
if (isAbortError(error)) throw error;
|
|
570
|
+
throwIfAttachmentAborted(state.signal);
|
|
571
|
+
return { hasPiManifest: false };
|
|
572
|
+
}
|
|
573
|
+
if (!isRecord(document) || !isRecord(document.pi)) return { hasPiManifest: false };
|
|
574
|
+
return {
|
|
575
|
+
hasPiManifest: true,
|
|
576
|
+
manifest: {
|
|
577
|
+
extensions: readManifestEntries(document.pi, "extensions"),
|
|
578
|
+
skills: readManifestEntries(document.pi, "skills"),
|
|
579
|
+
prompts: readManifestEntries(document.pi, "prompts"),
|
|
580
|
+
themes: readManifestEntries(document.pi, "themes"),
|
|
581
|
+
},
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function readManifestEntries(manifest: Record<string, unknown>, resourceType: string): string[] | undefined {
|
|
586
|
+
const entries = manifest[resourceType];
|
|
587
|
+
return Array.isArray(entries) && entries.every((entrypoint) => typeof entrypoint === "string") ? entries : undefined;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
async function addPackageIgnoreRules(
|
|
591
|
+
ignoreMatcher: IgnoreMatcher,
|
|
592
|
+
directory: string,
|
|
593
|
+
rootDirectory: string,
|
|
594
|
+
state: ExtensionScanState,
|
|
595
|
+
enforceTrust: boolean,
|
|
596
|
+
): Promise<void> {
|
|
597
|
+
await addAttachmentIgnoreRules(ignoreMatcher, directory, rootDirectory, state.signal, (ignorePath, bytes) => {
|
|
598
|
+
if (enforceTrust) assertExtensionPackagePathTrusted(ignorePath, "extension package resource", state);
|
|
599
|
+
state.metadataBytes += bytes;
|
|
600
|
+
if (state.metadataBytes > MAX_EXTENSION_METADATA_BYTES) throwExtensionScanLimit();
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
async function readBoundedPackageEntries(
|
|
605
|
+
directory: string,
|
|
606
|
+
state: ExtensionScanState,
|
|
607
|
+
onFailure: "skip" | "reject" | "reject-skill",
|
|
608
|
+
): Promise<Dirent[]> {
|
|
609
|
+
let directoryHandle: Awaited<ReturnType<typeof opendirAsync>>;
|
|
610
|
+
try {
|
|
611
|
+
directoryHandle = await opendirAsync(directory);
|
|
612
|
+
} catch (error) {
|
|
613
|
+
if (isAbortError(error)) throw error;
|
|
614
|
+
throwIfAttachmentAborted(state.signal);
|
|
615
|
+
return failedPackageDirectoryRead(onFailure);
|
|
616
|
+
}
|
|
617
|
+
const entries: Dirent[] = [];
|
|
618
|
+
try {
|
|
619
|
+
for await (const entry of directoryHandle) {
|
|
620
|
+
throwIfAttachmentAborted(state.signal);
|
|
621
|
+
state.entries++;
|
|
622
|
+
if (state.entries > MAX_EXTENSION_SCAN_ENTRIES) throwExtensionScanLimit();
|
|
623
|
+
entries.push(entry);
|
|
624
|
+
}
|
|
625
|
+
} catch (error) {
|
|
626
|
+
if (isAbortError(error) || isExtensionScanLimitError(error)) throw error;
|
|
627
|
+
throwIfAttachmentAborted(state.signal);
|
|
628
|
+
return failedPackageDirectoryRead(onFailure);
|
|
629
|
+
}
|
|
630
|
+
throwIfAttachmentAborted(state.signal);
|
|
631
|
+
return entries;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function failedPackageDirectoryRead(onFailure: "skip" | "reject" | "reject-skill"): Dirent[] {
|
|
635
|
+
if (onFailure === "reject") throwUnresolvableExtensionEntrypoint();
|
|
636
|
+
if (onFailure === "reject-skill") throwUnreadablePackageSkillDirectory();
|
|
637
|
+
return [];
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function throwUnreadablePackageSkillDirectory(): never {
|
|
641
|
+
throw new Error("Subagent extension package must not contain an unreadable declared skill directory.");
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
async function hasRegularExtensionIndex(
|
|
645
|
+
directory: string,
|
|
646
|
+
state: ExtensionScanState,
|
|
647
|
+
enforceTrust = true,
|
|
648
|
+
): Promise<boolean> {
|
|
649
|
+
for (const filename of ["index.ts", "index.js"]) {
|
|
650
|
+
const indexPath = path.join(directory, filename);
|
|
651
|
+
const stats = await statIfPresent(indexPath);
|
|
652
|
+
if (stats) {
|
|
653
|
+
if (enforceTrust) assertExtensionPackagePathTrusted(indexPath, "extension entrypoint", state);
|
|
654
|
+
return stats.isFile();
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return false;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function assertExtensionPackagePathTrusted(
|
|
661
|
+
resourcePath: string,
|
|
662
|
+
label: "extension entrypoint" | "extension package resource",
|
|
663
|
+
state: ExtensionScanState,
|
|
664
|
+
): void {
|
|
665
|
+
if (state.projectTrusted) return;
|
|
666
|
+
const canonicalPath = realpath(resourcePath, `Subagent ${label}`);
|
|
667
|
+
assertProjectPathTrusted(resourcePath, canonicalPath, label, state.cwd, state.canonicalCwd, state.projectTrusted);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async function statIfPresent(value: string): Promise<Stats | undefined> {
|
|
671
|
+
try {
|
|
672
|
+
return await statAsync(value);
|
|
673
|
+
} catch {
|
|
674
|
+
return undefined;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function throwUnresolvableExtensionEntrypoint(): never {
|
|
679
|
+
throw new Error("Subagent extension package must not contain a missing or unresolvable declared entrypoint.");
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function isExtensionScanLimitError(error: unknown): boolean {
|
|
683
|
+
return error instanceof Error && error.name === "ExtensionScanLimitError";
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function throwExtensionScanLimit(): never {
|
|
687
|
+
const error = new Error(
|
|
688
|
+
`Subagent extension attachment exceeds preflight limits (${MAX_EXTENSION_SCAN_ENTRIES} entries, depth ${MAX_EXTENSION_SCAN_DEPTH}, or ${MAX_EXTENSION_METADATA_BYTES} metadata bytes).`,
|
|
689
|
+
);
|
|
690
|
+
error.name = "ExtensionScanLimitError";
|
|
691
|
+
throw error;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function isExtensionOverridePattern(value: string): boolean {
|
|
695
|
+
return value.startsWith("!") || value.startsWith("+") || value.startsWith("-");
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function hasExtensionGlob(value: string): boolean {
|
|
699
|
+
return value.includes("*") || value.includes("?");
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function resolveExtensionToolName(value: unknown): string {
|
|
703
|
+
if (typeof value !== "string" || !value.trim()) throw new Error("Subagent extension tool name is required.");
|
|
704
|
+
const name = value.trim();
|
|
705
|
+
if (name.length > MAX_EXTENSION_TOOL_NAME_LENGTH || name.includes(",") || hasControlCharacter(name)) {
|
|
706
|
+
throw new Error(
|
|
707
|
+
`Subagent extension tool name must be at most ${MAX_EXTENSION_TOOL_NAME_LENGTH} characters without commas or control characters.`,
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
return name;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function isLocalPath(value: string): boolean {
|
|
714
|
+
if (value.startsWith("//") || value.startsWith("\\\\")) return false;
|
|
715
|
+
if (path.isAbsolute(value) || path.win32.isAbsolute(value)) return true;
|
|
716
|
+
return !/^[a-z][a-z0-9+.-]*:/iu.test(value);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function hasControlCharacter(value: string): boolean {
|
|
720
|
+
return [...value].some((character) => {
|
|
721
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
722
|
+
return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f);
|
|
723
|
+
});
|
|
724
|
+
}
|