@narumitw/pi-subagents 3.0.2 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/runtime.ts CHANGED
@@ -3,6 +3,7 @@ import { COMPLETION_MESSAGE_TYPE } from "./completion-renderer.js";
3
3
  import { type BrokerSendAcknowledgement, type MessageBroker, sanitizeTerminalText } from "./message-broker.js";
4
4
  import { modelVisibleJson, requireBoundedModelText } from "./model-output.js";
5
5
  import { runChild as defaultRunChild } from "./process.js";
6
+ import type { ExtensionAttachment } from "./resource-attachments.js";
6
7
  import {
7
8
  type ChildControl,
8
9
  type ChildRequest,
@@ -56,6 +57,9 @@ export interface ActiveJobDisplay {
56
57
  export interface StartJobInput {
57
58
  task: string;
58
59
  tools: string[];
60
+ skills: string[];
61
+ extensions: ExtensionAttachment[];
62
+ toolSources: Record<string, string[]>;
59
63
  model: string;
60
64
  thinkingLevel: SubagentThinkingLevel;
61
65
  cwd: string;
@@ -93,6 +97,10 @@ export class SubagentRuntime {
93
97
  this.notifyJobsChanged();
94
98
  }
95
99
 
100
+ getSessionGeneration(): number {
101
+ return this.generation;
102
+ }
103
+
96
104
  subscribeJobs(listener: () => void): () => void {
97
105
  this.jobListeners.add(listener);
98
106
  return () => this.jobListeners.delete(listener);
@@ -139,13 +147,14 @@ export class SubagentRuntime {
139
147
  });
140
148
  void controlReady.catch(() => undefined);
141
149
  const controller = new AbortController();
150
+ const effectiveTools = [...new Set([...input.tools, ...input.extensions.flatMap((extension) => extension.tools)])];
142
151
  const job: InternalJob = {
143
152
  jobId,
144
153
  state: "queued",
145
154
  createdAt: this.now(),
146
155
  ...(input.timeout !== undefined ? { timeout: input.timeout } : {}),
147
156
  controller,
148
- tools: [...input.tools],
157
+ tools: effectiveTools,
149
158
  terminal,
150
159
  resolveTerminal,
151
160
  controlReady,
@@ -172,6 +181,14 @@ export class SubagentRuntime {
172
181
  child = await this.runChild({
173
182
  task: input.task,
174
183
  tools: [...input.tools],
184
+ skills: [...input.skills],
185
+ extensions: input.extensions.map((extension) => ({
186
+ path: extension.path,
187
+ tools: [...extension.tools],
188
+ })),
189
+ toolSources: Object.fromEntries(
190
+ Object.entries(input.toolSources).map(([tool, sources]) => [tool, [...sources]]),
191
+ ),
175
192
  model: input.model,
176
193
  thinkingLevel: input.thinkingLevel,
177
194
  cwd: input.cwd,
@@ -0,0 +1,302 @@
1
+ import { type Dirent, statSync } from "node:fs";
2
+ import { opendir as opendirAsync, realpath as realpathAsync, stat as statAsync } from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import { loadSkills } from "@earendil-works/pi-coding-agent";
5
+ import ignore from "ignore";
6
+ import {
7
+ addAttachmentIgnoreRules,
8
+ type IgnoreMatcher,
9
+ isAbortError,
10
+ isWithin,
11
+ realpath,
12
+ throwIfAttachmentAborted,
13
+ toPosixPath,
14
+ } from "./attachment-utils.js";
15
+
16
+ export const MAX_SKILL_SCAN_DEPTH = 32;
17
+ export const MAX_SKILL_SCAN_ENTRIES = 4_096;
18
+ export const MAX_SKILL_SCAN_BYTES = 4 * 1024 * 1024;
19
+ export const MAX_SKILL_IGNORE_BYTES = 1024 * 1024;
20
+
21
+ export async function assertLoadableSkills(
22
+ skillPaths: string[],
23
+ cwd: string,
24
+ canonicalCwd: string,
25
+ projectTrusted: boolean,
26
+ signal?: AbortSignal,
27
+ ): Promise<SkillScanState> {
28
+ const scanState: SkillScanState = {
29
+ entries: 0,
30
+ skillBytes: 0,
31
+ ignoreBytes: 0,
32
+ ancestors: new Set<string>(),
33
+ signal,
34
+ };
35
+ for (const skillPath of skillPaths) {
36
+ throwIfAttachmentAborted(signal);
37
+ const candidates = await collectBoundedSkillCandidates(skillPath, scanState);
38
+ throwIfAttachmentAborted(signal);
39
+ const result = loadExplicitSkills([skillPath], cwd);
40
+ if (result.skills.length === 0) {
41
+ throw new Error("Subagent skill path must contain at least one loadable Pi skill.");
42
+ }
43
+ assertNoUntrustedProjectSkills(result, cwd, canonicalCwd, projectTrusted);
44
+ assertNoOmittedSkillDiagnostics(result);
45
+ assertNoSkillNameCollisions(result.diagnostics);
46
+ await assertNoSilentlyOmittedSkills(skillPath, cwd, result, canonicalCwd, projectTrusted, candidates, signal);
47
+ }
48
+ if (skillPaths.length > 1) {
49
+ throwIfAttachmentAborted(signal);
50
+ const result = loadExplicitSkills(skillPaths, cwd);
51
+ assertNoUntrustedProjectSkills(result, cwd, canonicalCwd, projectTrusted);
52
+ assertNoSkillNameCollisions(result.diagnostics);
53
+ }
54
+ return scanState;
55
+ }
56
+
57
+ function loadExplicitSkills(skillPaths: string[], cwd: string): ReturnType<typeof loadSkills> {
58
+ return loadSkills({ cwd, agentDir: cwd, skillPaths, includeDefaults: false });
59
+ }
60
+
61
+ function assertNoOmittedSkillDiagnostics(result: ReturnType<typeof loadSkills>): void {
62
+ const loadedPaths = new Set(result.skills.map((skill) => skill.filePath));
63
+ const omitted = result.diagnostics.some(
64
+ (diagnostic) =>
65
+ diagnostic.type === "error" ||
66
+ (diagnostic.type === "warning" && (!diagnostic.path || !loadedPaths.has(diagnostic.path))),
67
+ );
68
+ if (omitted) {
69
+ throw new Error("Subagent skill attachment must not contain an invalid or unreadable declared skill.");
70
+ }
71
+ }
72
+
73
+ function assertNoSkillNameCollisions(diagnostics: ReturnType<typeof loadSkills>["diagnostics"]): void {
74
+ if (
75
+ diagnostics.some((diagnostic) => diagnostic.type === "collision" && diagnostic.collision?.resourceType === "skill")
76
+ ) {
77
+ throw new Error("Subagent skill attachments must not contain duplicate skill names.");
78
+ }
79
+ }
80
+
81
+ function assertNoUntrustedProjectSkills(
82
+ result: ReturnType<typeof loadSkills>,
83
+ cwd: string,
84
+ canonicalCwd: string,
85
+ projectTrusted: boolean,
86
+ ): void {
87
+ if (projectTrusted) return;
88
+ for (const skill of result.skills) {
89
+ const lexicalPath = path.resolve(skill.filePath);
90
+ const canonicalPath = realpath(skill.filePath, "Loaded subagent skill");
91
+ if (isWithin(cwd, lexicalPath) || isWithin(canonicalCwd, canonicalPath)) {
92
+ throw new Error("Subagent skill cannot load a project path because the project is not trusted.");
93
+ }
94
+ }
95
+ }
96
+
97
+ async function assertNoSilentlyOmittedSkills(
98
+ skillPath: string,
99
+ cwd: string,
100
+ result: ReturnType<typeof loadSkills>,
101
+ canonicalCwd: string,
102
+ projectTrusted: boolean,
103
+ candidates: string[],
104
+ signal?: AbortSignal,
105
+ ): Promise<void> {
106
+ if (!statSync(skillPath).isDirectory()) return;
107
+ const loadedPaths = new Set(result.skills.map((skill) => realpath(skill.filePath, "Loaded subagent skill")));
108
+ for (const [index, candidate] of candidates.entries()) {
109
+ if (index > 0 && index % 64 === 0) await new Promise<void>((resolve) => setImmediate(resolve));
110
+ throwIfAttachmentAborted(signal);
111
+ const candidateResult = loadExplicitSkills([candidate], cwd);
112
+ assertNoUntrustedProjectSkills(candidateResult, cwd, canonicalCwd, projectTrusted);
113
+ if (candidateResult.skills.length === 0) {
114
+ if (path.basename(candidate) === "SKILL.md") throwInvalidDeclaredSkill();
115
+ continue;
116
+ }
117
+ const loaded = candidateResult.skills.some((skill) =>
118
+ loadedPaths.has(realpath(skill.filePath, "Loaded subagent skill")),
119
+ );
120
+ if (!loaded) throwInvalidDeclaredSkill();
121
+ }
122
+ }
123
+
124
+ interface SkillScanState {
125
+ entries: number;
126
+ skillBytes: number;
127
+ ignoreBytes: number;
128
+ ancestors: Set<string>;
129
+ signal?: AbortSignal;
130
+ }
131
+
132
+ async function collectBoundedSkillCandidates(skillPath: string, state: SkillScanState): Promise<string[]> {
133
+ throwIfAttachmentAborted(state.signal);
134
+ const stats = await statAsync(skillPath);
135
+ throwIfAttachmentAborted(state.signal);
136
+ if (!stats.isDirectory()) {
137
+ if (stats.isFile() && skillPath.endsWith(".md")) addSkillBytes(state, stats.size);
138
+ return [];
139
+ }
140
+ return collectSkillCandidates(skillPath, true, ignore(), skillPath, 0, state);
141
+ }
142
+
143
+ async function collectSkillCandidates(
144
+ directory: string,
145
+ includeRootMarkdown: boolean,
146
+ ignoreMatcher: IgnoreMatcher,
147
+ rootDirectory: string,
148
+ depth: number,
149
+ state: SkillScanState,
150
+ ): Promise<string[]> {
151
+ throwIfAttachmentAborted(state.signal);
152
+ if (depth > MAX_SKILL_SCAN_DEPTH) throwSkillScanLimit();
153
+ let canonicalDirectory: string;
154
+ try {
155
+ canonicalDirectory = await realpathAsync(directory);
156
+ } catch {
157
+ throwInvalidDeclaredSkill();
158
+ }
159
+ throwIfAttachmentAborted(state.signal);
160
+ if (state.ancestors.has(canonicalDirectory)) {
161
+ throw new Error("Subagent skill attachment must not contain a recursive directory link.");
162
+ }
163
+ state.ancestors.add(canonicalDirectory);
164
+ try {
165
+ await addAttachmentIgnoreRules(ignoreMatcher, directory, rootDirectory, state.signal, (_ignorePath, bytes) => {
166
+ state.ignoreBytes += bytes;
167
+ if (state.ignoreBytes > MAX_SKILL_IGNORE_BYTES) throwSkillScanLimit();
168
+ });
169
+ let directoryHandle: Awaited<ReturnType<typeof opendirAsync>>;
170
+ try {
171
+ directoryHandle = await opendirAsync(directory);
172
+ } catch {
173
+ throwInvalidDeclaredSkill();
174
+ }
175
+ const entries: Dirent[] = [];
176
+ try {
177
+ for await (const entry of directoryHandle) {
178
+ throwIfAttachmentAborted(state.signal);
179
+ state.entries++;
180
+ if (state.entries > MAX_SKILL_SCAN_ENTRIES) throwSkillScanLimit();
181
+ entries.push(entry);
182
+ }
183
+ } catch (error) {
184
+ if (isAbortError(error) || isSkillScanLimitError(error)) throw error;
185
+ throwInvalidDeclaredSkill();
186
+ }
187
+
188
+ const rootSkill = entries.find((entry) => entry.name === "SKILL.md");
189
+ if (rootSkill) {
190
+ const rootPath = path.join(directory, rootSkill.name);
191
+ const relativePath = toPosixPath(path.relative(rootDirectory, rootPath));
192
+ if (!ignoreMatcher.ignores(relativePath)) {
193
+ let rootStats: Awaited<ReturnType<typeof statAsync>>;
194
+ try {
195
+ rootStats = await statAsync(rootPath);
196
+ } catch (error) {
197
+ if (isAbortError(error)) throw error;
198
+ throwInvalidDeclaredSkill();
199
+ }
200
+ throwIfAttachmentAborted(state.signal);
201
+ if (rootStats.isFile()) {
202
+ addSkillBytes(state, rootStats.size);
203
+ return [rootPath];
204
+ }
205
+ }
206
+ }
207
+
208
+ const candidates: string[] = [];
209
+ for (const entry of entries) {
210
+ throwIfAttachmentAborted(state.signal);
211
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
212
+ const candidate = path.join(directory, entry.name);
213
+ const relativePath = toPosixPath(path.relative(rootDirectory, candidate));
214
+ let candidateStats: Awaited<ReturnType<typeof statAsync>>;
215
+ try {
216
+ candidateStats = await statAsync(candidate);
217
+ } catch (error) {
218
+ if (isAbortError(error)) throw error;
219
+ throwIfAttachmentAborted(state.signal);
220
+ if (
221
+ !ignoreMatcher.ignores(relativePath) &&
222
+ (entry.name === "SKILL.md" || (includeRootMarkdown && entry.name.endsWith(".md")))
223
+ ) {
224
+ throwInvalidDeclaredSkill();
225
+ }
226
+ continue;
227
+ }
228
+ throwIfAttachmentAborted(state.signal);
229
+ if (ignoreMatcher.ignores(candidateStats.isDirectory() ? `${relativePath}/` : relativePath)) continue;
230
+ if (candidateStats.isDirectory()) {
231
+ candidates.push(
232
+ ...(await collectSkillCandidates(candidate, false, ignoreMatcher, rootDirectory, depth + 1, state)),
233
+ );
234
+ } else if (
235
+ candidateStats.isFile() &&
236
+ (entry.name === "SKILL.md" || (includeRootMarkdown && entry.name.endsWith(".md")))
237
+ ) {
238
+ addSkillBytes(state, candidateStats.size);
239
+ candidates.push(candidate);
240
+ }
241
+ }
242
+ return candidates;
243
+ } finally {
244
+ state.ancestors.delete(canonicalDirectory);
245
+ }
246
+ }
247
+
248
+ function addSkillBytes(state: SkillScanState, bytes: number): void {
249
+ state.skillBytes += bytes;
250
+ if (state.skillBytes > MAX_SKILL_SCAN_BYTES) throwSkillScanLimit();
251
+ }
252
+
253
+ function isSkillScanLimitError(error: unknown): boolean {
254
+ return error instanceof Error && error.name === "SkillScanLimitError";
255
+ }
256
+
257
+ function throwSkillScanLimit(): never {
258
+ const error = new Error(
259
+ `Subagent skill attachment exceeds traversal limits (${MAX_SKILL_SCAN_ENTRIES} entries, depth ${MAX_SKILL_SCAN_DEPTH}, ${MAX_SKILL_SCAN_BYTES} skill bytes, or ${MAX_SKILL_IGNORE_BYTES} ignore bytes).`,
260
+ );
261
+ error.name = "SkillScanLimitError";
262
+ throw error;
263
+ }
264
+
265
+ function throwInvalidDeclaredSkill(): never {
266
+ throw new Error("Subagent skill attachment must not contain an invalid or unreadable declared skill.");
267
+ }
268
+
269
+ export async function assertLoadablePackageSkills(
270
+ packageSkillPaths: string[],
271
+ explicitSkillPaths: string[],
272
+ cwd: string,
273
+ scanState: SkillScanState,
274
+ ): Promise<void> {
275
+ const uniquePackageSkillPaths = deduplicatePackageSkillPaths(packageSkillPaths, explicitSkillPaths);
276
+ for (const [index, skillPath] of uniquePackageSkillPaths.entries()) {
277
+ if (index > 0 && index % 64 === 0) await new Promise<void>((resolve) => setImmediate(resolve));
278
+ throwIfAttachmentAborted(scanState.signal);
279
+ await collectBoundedSkillCandidates(skillPath, scanState);
280
+ }
281
+ for (const [index, skillPath] of uniquePackageSkillPaths.entries()) {
282
+ if (index > 0 && index % 64 === 0) await new Promise<void>((resolve) => setImmediate(resolve));
283
+ throwIfAttachmentAborted(scanState.signal);
284
+ const result = loadExplicitSkills([skillPath], cwd);
285
+ if (result.skills.length === 0) throwInvalidDeclaredSkill();
286
+ assertNoOmittedSkillDiagnostics(result);
287
+ }
288
+ throwIfAttachmentAborted(scanState.signal);
289
+ assertNoSkillNameCollisions(loadExplicitSkills([...uniquePackageSkillPaths, ...explicitSkillPaths], cwd).diagnostics);
290
+ }
291
+
292
+ function deduplicatePackageSkillPaths(packageSkillPaths: string[], explicitSkillPaths: string[]): string[] {
293
+ const seenPaths = new Set(explicitSkillPaths.map((skillPath) => realpath(skillPath, "Loaded subagent skill")));
294
+ const uniquePaths: string[] = [];
295
+ for (const skillPath of packageSkillPaths) {
296
+ const canonicalPath = realpath(skillPath, "Loaded subagent package skill");
297
+ if (seenPaths.has(canonicalPath)) continue;
298
+ seenPaths.add(canonicalPath);
299
+ uniquePaths.push(skillPath);
300
+ }
301
+ return uniquePaths;
302
+ }
package/src/timeout.ts ADDED
@@ -0,0 +1,24 @@
1
+ const MAX_TIMEOUT_MS = 2_147_483_647;
2
+ const MAX_TIMEOUT_SECONDS = MAX_TIMEOUT_MS / 1000;
3
+
4
+ export function resolveTimeoutMs(timeout: number | undefined): number | undefined {
5
+ if (timeout === undefined) return undefined;
6
+ if (!Number.isFinite(timeout) || timeout <= 0) {
7
+ throw new Error("Invalid timeout: must be a finite number of seconds");
8
+ }
9
+ const timeoutMs = timeout * 1000;
10
+ if (timeoutMs > MAX_TIMEOUT_MS) {
11
+ throw new Error(`Invalid timeout: maximum is ${MAX_TIMEOUT_SECONDS} seconds`);
12
+ }
13
+ return timeoutMs;
14
+ }
15
+
16
+ export function prepareTimeoutArguments(args: unknown): Record<string, unknown> {
17
+ if (!args || typeof args !== "object") return args as Record<string, unknown>;
18
+ if (!Object.hasOwn(args, "timeoutMs")) return args as Record<string, unknown>;
19
+ const record = args as Record<string, unknown>;
20
+ if (typeof record.timeoutMs !== "number") return record;
21
+ const { timeoutMs, ...prepared } = record;
22
+ if (prepared.timeout === undefined) return { ...prepared, timeout: timeoutMs / 1000 };
23
+ return prepared;
24
+ }
package/src/tools.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { StringEnum } from "@earendil-works/pi-ai";
2
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import { type Static, Type } from "typebox";
4
+ import { assertChildBootstrapCapacity } from "./broker-credentials.js";
5
+ import { CHILD_COMMUNICATION_TOOL_NAMES } from "./child-communication-tools.js";
4
6
  import {
5
7
  type BrokerInboundMessage,
6
8
  MAX_IDENTIFIER_LENGTH,
@@ -10,8 +12,17 @@ import {
10
12
  validateMessage,
11
13
  } from "./message-broker.js";
12
14
  import { modelVisibleJson, requireBoundedModelText } from "./model-output.js";
13
- import { resolveTimeoutMs } from "./process.js";
15
+ import { assertChildCommandCapacity } from "./process.js";
16
+ import {
17
+ MAX_ATTACHED_EXTENSIONS,
18
+ MAX_ATTACHED_SKILLS,
19
+ MAX_EXTENSION_TOOL_NAME_LENGTH,
20
+ MAX_RESOURCE_PATH_BYTES,
21
+ MAX_SELECTED_TOOLS,
22
+ resolveResourceAttachments,
23
+ } from "./resource-attachments.js";
14
24
  import { type RuntimeDependencies, SubagentRuntime } from "./runtime.js";
25
+ import { prepareTimeoutArguments, resolveTimeoutMs } from "./timeout.js";
15
26
  import {
16
27
  CHILD_CORE_TOOL_NAMES,
17
28
  DEFAULT_SUBAGENT_TOOLS,
@@ -20,7 +31,7 @@ import {
20
31
  } from "./types.js";
21
32
 
22
33
  const MAX_TASK_BYTES = 50 * 1024;
23
- const MAX_TOOLS = 64;
34
+ const MAX_TOOLS = MAX_SELECTED_TOOLS;
24
35
  const MESSAGE_TYPE = "pi-subagents-message";
25
36
  const CHILD_CORE_TOOL_SET = new Set<string>(CHILD_CORE_TOOL_NAMES);
26
37
  const THINKING_LEVEL_SET = new Set<string>(SUBAGENT_THINKING_LEVELS);
@@ -42,6 +53,50 @@ const SpawnParameters = Type.Object(
42
53
  },
43
54
  ),
44
55
  ),
56
+ skills: Type.Optional(
57
+ Type.Array(
58
+ Type.String({
59
+ description:
60
+ "Local Markdown skill file or directory whose declared Pi skills all load and have names unique across attachments, resolved from the child working directory.",
61
+ minLength: 1,
62
+ maxLength: MAX_RESOURCE_PATH_BYTES,
63
+ }),
64
+ {
65
+ description: "Explicit local skills available to the child. Automatic skill discovery stays disabled.",
66
+ maxItems: MAX_ATTACHED_SKILLS,
67
+ },
68
+ ),
69
+ ),
70
+ extensions: Type.Optional(
71
+ Type.Array(
72
+ Type.Object(
73
+ {
74
+ path: Type.String({
75
+ description: "Local extension file or directory path, resolved from the child working directory.",
76
+ minLength: 1,
77
+ maxLength: MAX_RESOURCE_PATH_BYTES,
78
+ }),
79
+ tools: Type.Array(
80
+ Type.String({
81
+ description: "Exact extension-specific tool name to activate initially; built-in names are rejected.",
82
+ minLength: 1,
83
+ maxLength: MAX_EXTENSION_TOOL_NAME_LENGTH,
84
+ }),
85
+ {
86
+ description:
87
+ "Extension tools to activate. Use an empty list for lifecycle or provider behavior only. All child tool names share a 16 KiB startup bootstrap.",
88
+ maxItems: MAX_SELECTED_TOOLS,
89
+ },
90
+ ),
91
+ },
92
+ { additionalProperties: false },
93
+ ),
94
+ {
95
+ description: "Explicit trusted local extensions loaded in the isolated child process.",
96
+ maxItems: MAX_ATTACHED_EXTENSIONS,
97
+ },
98
+ ),
99
+ ),
45
100
  thinkingLevel: Type.Optional(
46
101
  StringEnum(SUBAGENT_THINKING_LEVELS, {
47
102
  description: "Child thinking level. Defaults to the main agent's effective level.",
@@ -109,6 +164,7 @@ type MainSendSelection =
109
164
 
110
165
  export interface SubagentToolsDependencies extends RuntimeDependencies {
111
166
  createBroker?: (onMessage: (message: BrokerInboundMessage) => void) => MessageBroker;
167
+ platform?: NodeJS.Platform;
112
168
  }
113
169
 
114
170
  export interface RegisteredSubagentTools {
@@ -130,7 +186,7 @@ export function registerSubagentTools(
130
186
  name: "subagent_spawn",
131
187
  label: "Subagent · Spawn",
132
188
  description:
133
- "Use subagent_spawn to start one Pi subagent job and return its jobId immediately. The task defines the child's specialization, and the selected tools define its capabilities. The job may ask the main agent questions and publishes one asynchronous completion when terminal.",
189
+ "Use subagent_spawn to start one Pi subagent job and return its jobId immediately. The task defines the child's specialization; selected core tools and explicit trusted local skills or extensions define its initial capabilities. The job may ask the main agent questions and publishes one asynchronous completion when terminal.",
134
190
  promptSnippet: "Use subagent_spawn to start one Pi subagent job",
135
191
  parameters: SpawnParameters,
136
192
  prepareArguments: prepareSpawnArguments,
@@ -139,18 +195,50 @@ export function registerSubagentTools(
139
195
  assertNotNested();
140
196
  const task = validateTask(params.task, "subagent_spawn");
141
197
  const tools = resolveTools(params.tools);
142
- const model = resolveChildModel(ctx);
198
+ const cwd = ctx.cwd;
199
+ const projectTrusted = ctx.isProjectTrusted();
200
+ const sessionGeneration = runtime.getSessionGeneration();
201
+ const attachments = await resolveResourceAttachments(
202
+ { skills: params.skills, extensions: params.extensions },
203
+ { cwd, projectTrusted, coreTools: tools, signal },
204
+ );
205
+ throwIfAborted(signal, "Subagent spawn was cancelled");
206
+ if (runtime.getSessionGeneration() !== sessionGeneration) {
207
+ throw new Error("Subagent spawn session changed during attachment validation; retry the request.");
208
+ }
209
+ if (ctx.cwd !== cwd || ctx.isProjectTrusted() !== projectTrusted) {
210
+ throw new Error("Subagent spawn context changed during attachment validation; retry the request.");
211
+ }
212
+ if (attachments.extensions.length > 0) {
213
+ assertChildBootstrapCapacity([...new Set([...attachments.effectiveTools, ...CHILD_COMMUNICATION_TOOL_NAMES])]);
214
+ }
215
+ const model = resolveChildModel(ctx, attachments.extensions.length > 0);
143
216
  const thinkingLevel = resolveThinkingLevel(params.thinkingLevel ?? ctx.thinkingLevel ?? pi.getThinkingLevel());
144
217
  resolveTimeoutMs(params.timeout);
218
+ assertChildCommandCapacity(
219
+ {
220
+ tools,
221
+ skills: attachments.skills,
222
+ extensions: attachments.extensions,
223
+ model,
224
+ thinkingLevel,
225
+ projectTrusted,
226
+ },
227
+ dependencies.platform,
228
+ );
229
+ throwIfAborted(signal, "Subagent spawn was cancelled");
145
230
  return toolResult(
146
231
  runtime.start({
147
232
  task,
148
233
  tools,
234
+ skills: attachments.skills,
235
+ extensions: attachments.extensions,
236
+ toolSources: attachments.toolSources,
149
237
  model,
150
238
  thinkingLevel,
151
- cwd: ctx.cwd,
239
+ cwd,
152
240
  timeout: params.timeout,
153
- projectTrusted: ctx.isProjectTrusted(),
241
+ projectTrusted,
154
242
  }),
155
243
  );
156
244
  },
@@ -300,11 +388,11 @@ function resolveTools(value: unknown): string[] {
300
388
  return tools;
301
389
  }
302
390
 
303
- function resolveChildModel(ctx: ExtensionContext): string {
391
+ function resolveChildModel(ctx: ExtensionContext, hasAttachedExtensions: boolean): string {
304
392
  const model = ctx.model;
305
393
  if (!model) throw new Error("Subagent model is unavailable because no main-agent model is selected.");
306
394
  const provider = sanitizeTerminalText(model.provider).slice(0, 128);
307
- if (ctx.modelRegistry.getRegisteredProviderIds().includes(model.provider)) {
395
+ if (!hasAttachedExtensions && ctx.modelRegistry.getRegisteredProviderIds().includes(model.provider)) {
308
396
  throw new Error(`Subagent model provider ${provider} is unavailable because children disable parent extensions.`);
309
397
  }
310
398
  if (ctx.modelRegistry.getProviderAuthStatus(model.provider).source === "runtime") {
@@ -330,16 +418,6 @@ function prepareWaitArguments(args: unknown): WaitArguments {
330
418
  return prepareTimeoutArguments(args) as WaitArguments;
331
419
  }
332
420
 
333
- function prepareTimeoutArguments(args: unknown): Record<string, unknown> {
334
- if (!args || typeof args !== "object") return args as Record<string, unknown>;
335
- if (!Object.hasOwn(args, "timeoutMs")) return args as Record<string, unknown>;
336
- const record = args as Record<string, unknown>;
337
- if (typeof record.timeoutMs !== "number") return record;
338
- const { timeoutMs, ...prepared } = record;
339
- if (prepared.timeout === undefined) return { ...prepared, timeout: timeoutMs / 1000 };
340
- return prepared;
341
- }
342
-
343
421
  function resolveMainSendArguments(params: SendArguments): MainSendSelection {
344
422
  validateMessage(params.message, "Subagent message");
345
423
  const recipient = optionalIdentifier(params.recipient, "recipient");
package/src/types.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
2
+ import type { ExtensionAttachment } from "./resource-attachments.js";
2
3
 
3
4
  export const JOB_STATES = ["queued", "running", "completed", "partial", "failed", "timed_out", "cancelled"] as const;
4
5
 
@@ -46,6 +47,9 @@ export interface ChildControl {
46
47
  export interface ChildRequest {
47
48
  task: string;
48
49
  tools: string[];
50
+ skills: string[];
51
+ extensions: ExtensionAttachment[];
52
+ toolSources: Record<string, string[]>;
49
53
  model: string;
50
54
  thinkingLevel: SubagentThinkingLevel;
51
55
  cwd: string;