@narumitw/pi-subagents 3.3.2 → 3.4.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.
Files changed (42) hide show
  1. package/README.md +73 -24
  2. package/dist/child-communication-bridge.ts +3 -2
  3. package/dist/child-communication-bridge.ts.map +1 -1
  4. package/dist/child-readiness-probe.ts +114 -17
  5. package/dist/child-readiness-probe.ts.map +2 -2
  6. package/dist/chunks/{chunk-FY5HIU6Y.ts → chunk-5OXDVXXO.ts} +1 -1
  7. package/dist/chunks/{chunk-FY5HIU6Y.ts.map → chunk-5OXDVXXO.ts.map} +1 -1
  8. package/dist/chunks/chunk-B3RWNGDX.ts +192 -0
  9. package/dist/chunks/chunk-B3RWNGDX.ts.map +7 -0
  10. package/dist/chunks/{chunk-ZOAFWLDJ.ts → chunk-PRIDTAJL.ts} +4 -16
  11. package/dist/chunks/chunk-PRIDTAJL.ts.map +7 -0
  12. package/dist/chunks/{chunk-YMVSJ5GE.ts → chunk-YV2P6XJX.ts} +46 -17
  13. package/dist/chunks/chunk-YV2P6XJX.ts.map +7 -0
  14. package/dist/chunks/{menu-KAIUEWL7.ts → menu-2UH4ICLL.ts} +74 -3
  15. package/dist/chunks/menu-2UH4ICLL.ts.map +7 -0
  16. package/dist/index.ts +553 -292
  17. package/dist/index.ts.map +3 -3
  18. package/docs/attachments.md +9 -3
  19. package/docs/design-principles.md +3 -1
  20. package/docs/tools.md +15 -1
  21. package/package.json +6 -6
  22. package/src/broker-credentials.ts +44 -13
  23. package/src/child-communication-tools.ts +2 -19
  24. package/src/child-readiness-probe.ts +144 -18
  25. package/src/child-readiness.ts +118 -0
  26. package/src/menu.ts +82 -3
  27. package/src/message-broker.ts +23 -10
  28. package/src/process.ts +59 -167
  29. package/src/resource-attachments.ts +1 -3
  30. package/src/rpc-line-decoder.ts +61 -0
  31. package/src/runtime.ts +21 -9
  32. package/src/settings.ts +2 -2
  33. package/src/subagents.ts +110 -18
  34. package/src/tools.ts +83 -37
  35. package/src/types.ts +1 -0
  36. package/src/widget-renderer.ts +119 -0
  37. package/src/widget.ts +245 -121
  38. package/dist/chunks/chunk-3FCHXUKZ.ts +0 -98
  39. package/dist/chunks/chunk-3FCHXUKZ.ts.map +0 -7
  40. package/dist/chunks/chunk-YMVSJ5GE.ts.map +0 -7
  41. package/dist/chunks/chunk-ZOAFWLDJ.ts.map +0 -7
  42. package/dist/chunks/menu-KAIUEWL7.ts.map +0 -7
@@ -0,0 +1,61 @@
1
+ const MAX_EVENT_LINE_BYTES = 256 * 1024;
2
+
3
+ /** Frame RPC output by LF bytes before strict UTF-8 decoding, so one bad line cannot lose later events. */
4
+ export class JsonLineDecoder {
5
+ private fragments: Buffer[] = [];
6
+ private bytes = 0;
7
+ private dropping = false;
8
+ private readonly decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
9
+
10
+ constructor(
11
+ private readonly onValue: (value: unknown) => void,
12
+ private readonly onMalformed: () => void,
13
+ ) {}
14
+
15
+ push(chunk: Buffer | string): void {
16
+ const data = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
17
+ let offset = 0;
18
+ while (offset < data.length) {
19
+ const newline = data.indexOf(0x0a, offset);
20
+ const end = newline < 0 ? data.length : newline;
21
+ const fragment = data.subarray(offset, end);
22
+ if (!this.dropping) {
23
+ if (this.bytes + fragment.length > MAX_EVENT_LINE_BYTES) {
24
+ this.clear();
25
+ this.dropping = true;
26
+ this.onMalformed();
27
+ } else if (fragment.length > 0) {
28
+ // Copy only the bounded partial frame, not its possibly much larger source chunk.
29
+ this.fragments.push(Buffer.from(fragment));
30
+ this.bytes += fragment.length;
31
+ }
32
+ }
33
+ if (newline < 0) return;
34
+ this.complete();
35
+ offset = newline + 1;
36
+ }
37
+ }
38
+
39
+ finish(): void {
40
+ this.complete();
41
+ }
42
+
43
+ private complete(): void {
44
+ const dropping = this.dropping;
45
+ const line = Buffer.concat(this.fragments, this.bytes);
46
+ this.clear();
47
+ this.dropping = false;
48
+ if (dropping || line.length === 0) return;
49
+ try {
50
+ const text = this.decoder.decode(line);
51
+ if (text.trim()) this.onValue(JSON.parse(text));
52
+ } catch {
53
+ this.onMalformed();
54
+ }
55
+ }
56
+
57
+ private clear(): void {
58
+ this.fragments = [];
59
+ this.bytes = 0;
60
+ }
61
+ }
package/src/runtime.ts CHANGED
@@ -29,7 +29,9 @@ interface InternalJob extends JobSummary {
29
29
  resolveTerminal: () => void;
30
30
  task?: Promise<void>;
31
31
  taskText: string;
32
+ modelOverrides: SubagentModelOverrides;
32
33
  thinkingLevel: SubagentThinkingLevel;
34
+ defaultThinkingLevel: SubagentThinkingLevel;
33
35
  stopRequest?: StopRequest;
34
36
  control?: ChildControl;
35
37
  controlReady: Promise<ChildControl>;
@@ -39,7 +41,6 @@ interface InternalJob extends JobSummary {
39
41
  result?: string;
40
42
  error?: string;
41
43
  limitations: string[];
42
- deliverySent: boolean;
43
44
  generation: number;
44
45
  }
45
46
 
@@ -48,11 +49,18 @@ export interface RuntimeDependencies {
48
49
  now?: () => number;
49
50
  }
50
51
 
51
- export interface ActiveJobDisplay {
52
+ /** Resolved IDs that differ from the effective parent pair captured at spawn. */
53
+ export interface SubagentModelOverrides {
54
+ provider?: string;
55
+ model?: string;
56
+ }
57
+
58
+ export interface ActiveJobDisplay extends SubagentModelOverrides {
52
59
  jobId: string;
53
60
  task: string;
54
61
  state: Extract<SubagentJobState, "queued" | "running">;
55
62
  thinkingLevel: SubagentThinkingLevel;
63
+ defaultThinkingLevel: SubagentThinkingLevel;
56
64
  elapsedMs: number;
57
65
  timeout?: number;
58
66
  tools: string[];
@@ -64,8 +72,11 @@ export interface StartJobInput {
64
72
  skills: string[];
65
73
  extensions: ExtensionAttachment[];
66
74
  toolSources: Record<string, string[]>;
75
+ provider: string;
67
76
  model: string;
77
+ modelOverrides?: SubagentModelOverrides;
68
78
  thinkingLevel: SubagentThinkingLevel;
79
+ defaultThinkingLevel: SubagentThinkingLevel;
69
80
  cwd: string;
70
81
  timeout?: number;
71
82
  projectTrusted: boolean;
@@ -77,7 +88,6 @@ export class SubagentRuntime {
77
88
  private readonly now: () => number;
78
89
  private counter = 0;
79
90
  private generation = 0;
80
- private deliveryEnabled = false;
81
91
  private sessionActive = false;
82
92
  private omittedJobs = 0;
83
93
  private readonly jobListeners = new Set<() => void>();
@@ -96,7 +106,6 @@ export class SubagentRuntime {
96
106
  this.generation++;
97
107
  this.jobs.clear();
98
108
  this.omittedJobs = 0;
99
- this.deliveryEnabled = true;
100
109
  this.sessionActive = true;
101
110
  this.notifyJobsChanged();
102
111
  }
@@ -119,7 +128,9 @@ export class SubagentRuntime {
119
128
  jobId: job.jobId,
120
129
  task: job.taskText,
121
130
  state: job.state,
131
+ ...job.modelOverrides,
122
132
  thinkingLevel: job.thinkingLevel,
133
+ defaultThinkingLevel: job.defaultThinkingLevel,
123
134
  elapsedMs: Math.max(0, now - (job.startedAt ?? job.createdAt)),
124
135
  ...(job.timeout !== undefined ? { timeout: job.timeout } : {}),
125
136
  tools: [...job.tools],
@@ -136,6 +147,7 @@ export class SubagentRuntime {
136
147
  if (active >= MAX_ACTIVE_JOBS) {
137
148
  throw new Error(`Active subagent job limit reached (${MAX_ACTIVE_JOBS}).`);
138
149
  }
150
+ const { provider, model } = input;
139
151
  const jobId = `job_${this.now().toString(36)}_${(++this.counter).toString(36)}`;
140
152
  const communication = this.broker.issueCredentials({
141
153
  jobId,
@@ -162,7 +174,9 @@ export class SubagentRuntime {
162
174
  controller,
163
175
  tools: effectiveTools,
164
176
  taskText: input.task,
177
+ modelOverrides: { ...input.modelOverrides },
165
178
  thinkingLevel: input.thinkingLevel,
179
+ defaultThinkingLevel: input.defaultThinkingLevel,
166
180
  terminal,
167
181
  resolveTerminal,
168
182
  controlReady,
@@ -170,7 +184,6 @@ export class SubagentRuntime {
170
184
  rejectControl,
171
185
  sendQueue: Promise.resolve(),
172
186
  limitations: [],
173
- deliverySent: false,
174
187
  generation: this.generation,
175
188
  };
176
189
  this.jobs.set(jobId, job);
@@ -197,7 +210,8 @@ export class SubagentRuntime {
197
210
  toolSources: Object.fromEntries(
198
211
  Object.entries(input.toolSources).map(([tool, sources]) => [tool, [...sources]]),
199
212
  ),
200
- model: input.model,
213
+ provider,
214
+ model,
201
215
  thinkingLevel: input.thinkingLevel,
202
216
  cwd: input.cwd,
203
217
  timeout: input.timeout,
@@ -355,7 +369,6 @@ export class SubagentRuntime {
355
369
 
356
370
  async shutdown(): Promise<void> {
357
371
  if (!this.sessionActive) return;
358
- this.deliveryEnabled = false;
359
372
  this.sessionActive = false;
360
373
  const active = [...this.jobs.values()].filter((job) => !isTerminal(job.state));
361
374
  await Promise.allSettled(
@@ -414,8 +427,7 @@ export class SubagentRuntime {
414
427
  }
415
428
 
416
429
  private deliver(job: InternalJob): void {
417
- if (!this.deliveryEnabled || job.deliverySent || job.generation !== this.generation) return;
418
- job.deliverySent = true;
430
+ if (!this.sessionActive || job.generation !== this.generation) return;
419
431
  const payload = this.waitResult(job, false);
420
432
  try {
421
433
  this.pi.sendMessage(
package/src/settings.ts CHANGED
@@ -11,8 +11,8 @@ export interface SubagentSettings {
11
11
 
12
12
  export const DEFAULT_SUBAGENT_SETTINGS: Readonly<SubagentSettings> = Object.freeze({
13
13
  showWidget: true,
14
- showJobId: true,
15
- showTools: true,
14
+ showJobId: false,
15
+ showTools: false,
16
16
  showTiming: true,
17
17
  });
18
18
 
package/src/subagents.ts CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  subagentSettingsPath,
9
9
  } from "./settings.js";
10
10
  import { registerSubagentTools, type SubagentToolsDependencies } from "./tools.js";
11
- import { createSubagentWidgetController } from "./widget.js";
11
+ import { createSubagentWidgetController, type SubagentCyclePhase } from "./widget.js";
12
12
 
13
13
  export interface SubagentsDependencies extends SubagentToolsDependencies {
14
14
  settingsStore?: Pick<SubagentSettingsStore, "userPath" | "load" | "save" | "flush">;
@@ -24,9 +24,20 @@ export default function subagents(pi: ExtensionAPI, dependencies: SubagentsDepen
24
24
  let owner = new AbortController();
25
25
  let ready = false;
26
26
  let menuOpen = false;
27
+ let cyclePhase: SubagentCyclePhase = "idle";
27
28
  let settings: SubagentSettings = { ...DEFAULT_SUBAGENT_SETTINGS };
28
29
  const menuTasks = new Set<Promise<void>>();
29
- const ownsSession = (ctx: ExtensionContext) => ctx.sessionManager === activeContext?.sessionManager;
30
+ const ownsSession = (ctx: ExtensionContext) =>
31
+ activeContext !== undefined && ctx.sessionManager === activeContext.sessionManager;
32
+
33
+ const joinCleanup = async (steps: (() => void | Promise<void>)[], errors: unknown[] = []): Promise<void> => {
34
+ const results = await Promise.allSettled(steps.map(async (step) => step()));
35
+ for (const result of results) {
36
+ if (result.status === "rejected") errors.push(result.reason);
37
+ }
38
+ if (errors.length === 1) throw errors[0];
39
+ if (errors.length > 1) throw new AggregateError(errors, "Subagents session cleanup failed");
40
+ };
30
41
 
31
42
  const openSettings = async (
32
43
  ctx: ExtensionCommandContext,
@@ -79,7 +90,7 @@ export default function subagents(pi: ExtensionAPI, dependencies: SubagentsDepen
79
90
  menuOpen = true;
80
91
  const generation = sessionGeneration;
81
92
  const signal = owner.signal;
82
- const isCurrent = () => generation === sessionGeneration && ownsSession(ctx) && !signal.aborted;
93
+ const isCurrent = () => generation === sessionGeneration && !signal.aborted && ownsSession(ctx);
83
94
  const task = (async () => {
84
95
  if (route === "settings") {
85
96
  await openSettings(ctx, signal, isCurrent);
@@ -101,6 +112,7 @@ export default function subagents(pi: ExtensionAPI, dependencies: SubagentsDepen
101
112
  await tools.runtime.cancel(jobId);
102
113
  },
103
114
  showSettings: (actionSignal) => openSettings(ctx, actionSignal, isCurrent),
115
+ widget: widget.controls(ctx),
104
116
  });
105
117
  })();
106
118
  menuTasks.add(task);
@@ -118,24 +130,39 @@ export default function subagents(pi: ExtensionAPI, dependencies: SubagentsDepen
118
130
  });
119
131
 
120
132
  pi.on("session_start", async (_event, ctx) => {
133
+ const previousContext = activeContext;
134
+ const generation = ++sessionGeneration;
135
+ ready = false;
136
+ menuOpen = false;
121
137
  owner.abort();
122
- if (activeContext) widget.shutdown(activeContext);
123
138
  owner = new AbortController();
139
+ const signal = owner.signal;
124
140
  activeContext = ctx;
125
- ready = false;
126
- menuOpen = false;
127
- const generation = ++sessionGeneration;
128
- const isCurrent = () => generation === sessionGeneration && ownsSession(ctx) && !owner.signal.aborted;
129
- // Stop old children and broker delivery before waiting for slow UI storage work.
130
- await tools.startSession();
131
- if (!isCurrent()) return;
132
- await Promise.allSettled([...menuTasks]);
141
+ cyclePhase = ctx.signal === undefined ? "idle" : "running";
142
+ const isCurrent = () => generation === sessionGeneration && !signal.aborted && ownsSession(ctx);
143
+ const errors: unknown[] = [];
144
+ try {
145
+ if (previousContext) widget.shutdown(previousContext);
146
+ } catch (error) {
147
+ errors.push(error);
148
+ }
149
+ // A failed UI handoff must still stop old children, menus, and pending saves.
150
+ // Do not initialize another runtime or widget after that failure.
151
+ const failedHandoff = errors.length > 0;
152
+ await joinCleanup(
153
+ [
154
+ () => (failedHandoff ? tools.shutdown() : tools.startSession()),
155
+ ...[...menuTasks].map((task) => () => task),
156
+ ...(failedHandoff ? [() => store.flush()] : []),
157
+ ],
158
+ errors,
159
+ );
133
160
  if (!isCurrent()) return;
134
161
  const loaded = await store.load();
135
162
  if (!isCurrent()) return;
136
163
  settings = { ...loaded.settings };
164
+ widget.start(ctx, settings, cyclePhase);
137
165
  ready = true;
138
- widget.start(ctx, settings);
139
166
  if (loaded.diagnostics.length > 0) {
140
167
  const message = sanitizeSubagentDisplayText(`Subagent settings: ${loaded.diagnostics.join(" ")}`);
141
168
  if (ctx.hasUI) ctx.ui.notify(message, "warning");
@@ -143,16 +170,81 @@ export default function subagents(pi: ExtensionAPI, dependencies: SubagentsDepen
143
170
  }
144
171
  });
145
172
 
146
- pi.on("session_shutdown", async (_event, ctx) => {
173
+ pi.on("before_agent_start", (_event, ctx) => {
147
174
  if (!ownsSession(ctx)) return;
175
+ cyclePhase = "preparing";
176
+ if (ready) widget.preparing(ctx);
177
+ });
178
+ pi.on("session_before_compact", (event, ctx) => {
179
+ if (!ownsSession(ctx)) return;
180
+ if (event.reason === "manual") {
181
+ cyclePhase = "maintenance";
182
+ if (ready) widget.maintenance(ctx);
183
+ } else {
184
+ if (cyclePhase !== "running") cyclePhase = "auto-preparing";
185
+ if (ready) widget.automaticCompaction(ctx);
186
+ }
187
+ });
188
+ pi.on("session_before_tree", (_event, ctx) => {
189
+ if (!ownsSession(ctx)) return;
190
+ cyclePhase = "maintenance";
191
+ if (ready) widget.maintenance(ctx);
192
+ });
193
+ pi.on("input", (_event, ctx) => {
194
+ // Discard a canceled maintenance/preparation guard before a new idle prompt.
195
+ // Do not count input as a consumed user message.
196
+ if (!ownsSession(ctx) || !ctx.isIdle()) return;
197
+ cyclePhase = "idle";
198
+ if (ready) widget.clearCycle(ctx);
199
+ });
200
+ pi.on("agent_start", (_event, ctx) => {
201
+ if (!ownsSession(ctx)) return;
202
+ cyclePhase = "running";
203
+ if (ready) widget.agentStarted(ctx);
204
+ });
205
+ pi.on("message_start", (event, ctx) => {
206
+ // Input submission is not consumption. Extension-generated user messages count too.
207
+ if (ready && ownsSession(ctx) && event.message.role === "user") widget.userMessage(ctx);
208
+ });
209
+ pi.on("turn_end", (event, ctx) => {
210
+ if (ready && ownsSession(ctx) && event.message.role === "assistant")
211
+ widget.turnEnded(ctx, event.message.stopReason);
212
+ });
213
+ pi.on("agent_settled", (event, ctx) => {
214
+ if (!ownsSession(ctx)) return;
215
+ cyclePhase = "idle";
216
+ if (ready) widget.settled(ctx, event.aborted);
217
+ });
218
+ pi.on("session_tree", (_event, ctx) => {
219
+ if (!ownsSession(ctx)) return;
220
+ cyclePhase = "maintenance";
221
+ if (!ready) return;
148
222
  sessionGeneration++;
223
+ ready = false;
224
+ menuOpen = false;
149
225
  owner.abort();
226
+ owner = new AbortController();
227
+ widget.start(ctx, settings, cyclePhase);
228
+ ready = true;
229
+ });
230
+
231
+ pi.on("session_shutdown", async (_event, ctx) => {
232
+ if (!ownsSession(ctx)) return;
233
+ sessionGeneration++;
150
234
  ready = false;
151
235
  menuOpen = false;
152
236
  activeContext = undefined;
153
- widget.shutdown(ctx);
154
- const results = await Promise.allSettled([tools.shutdown(), ...menuTasks, store.flush()]);
155
- const failed = results.find((result) => result.status === "rejected");
156
- if (failed?.status === "rejected") throw failed.reason;
237
+ cyclePhase = "idle";
238
+ owner.abort();
239
+ const errors: unknown[] = [];
240
+ try {
241
+ widget.shutdown(ctx);
242
+ } catch (error) {
243
+ errors.push(error);
244
+ }
245
+ await joinCleanup(
246
+ [() => tools.shutdown(), ...[...menuTasks].map((task) => () => task), () => store.flush()],
247
+ errors,
248
+ );
157
249
  });
158
250
  }
package/src/tools.ts CHANGED
@@ -1,19 +1,20 @@
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
+ import { assertChildBootstrapCapacity, MAX_BOOTSTRAP_BYTES } from "./broker-credentials.js";
6
5
  import {
7
6
  type BrokerInboundMessage,
8
7
  MAX_IDENTIFIER_LENGTH,
9
8
  MAX_MESSAGE_BYTES,
10
9
  MessageBroker,
10
+ optionalIdentifier,
11
+ requiredIdentifier,
11
12
  sanitizeTerminalText,
12
13
  validateMessage,
13
14
  } from "./message-broker.js";
14
15
  import { BROKER_MESSAGE_TYPE } from "./message-renderers.js";
15
16
  import { modelVisibleJson, requireBoundedModelText } from "./model-output.js";
16
- import { assertChildCommandCapacity } from "./process.js";
17
+ import { assertChildCommandCapacity, selectedChildTools } from "./process.js";
17
18
  import {
18
19
  MAX_ATTACHED_EXTENSIONS,
19
20
  MAX_ATTACHED_SKILLS,
@@ -22,7 +23,7 @@ import {
22
23
  MAX_SELECTED_TOOLS,
23
24
  resolveResourceAttachments,
24
25
  } from "./resource-attachments.js";
25
- import { type RuntimeDependencies, SubagentRuntime } from "./runtime.js";
26
+ import { type RuntimeDependencies, type SubagentModelOverrides, SubagentRuntime } from "./runtime.js";
26
27
  import { prepareTimeoutArguments, resolveTimeoutMs } from "./timeout.js";
27
28
  import {
28
29
  CHILD_CORE_TOOL_NAMES,
@@ -42,6 +43,21 @@ const SpawnParameters = Type.Object(
42
43
  description: "Self-contained task, constraints, and expected result. Maximum 50 KiB.",
43
44
  maxLength: MAX_TASK_BYTES,
44
45
  }),
46
+ provider: Type.Optional(
47
+ Type.String({
48
+ description: "Exact child provider ID. Defaults to the main agent's effective provider at spawn time.",
49
+ minLength: 1,
50
+ maxLength: MAX_BOOTSTRAP_BYTES,
51
+ }),
52
+ ),
53
+ model: Type.Optional(
54
+ Type.String({
55
+ description:
56
+ "Exact model ID within the selected provider. Defaults independently to the main agent's effective model ID at spawn time. Unlisted models fail without fallback.",
57
+ minLength: 1,
58
+ maxLength: MAX_BOOTSTRAP_BYTES,
59
+ }),
60
+ ),
45
61
  tools: Type.Optional(
46
62
  Type.Array(
47
63
  StringEnum(CHILD_CORE_TOOL_NAMES, {
@@ -209,23 +225,25 @@ export function registerSubagentTools(
209
225
  if (ctx.cwd !== cwd || ctx.isProjectTrusted() !== projectTrusted) {
210
226
  throw new Error("Subagent spawn context changed during attachment validation; retry the request.");
211
227
  }
212
- if (tools.includes("codemode") || attachments.extensions.length > 0) {
213
- assertChildBootstrapCapacity([...new Set([...attachments.effectiveTools, ...CHILD_COMMUNICATION_TOOL_NAMES])]);
214
- }
215
- const model = resolveChildModel(ctx, attachments.extensions.length > 0);
216
- const thinkingLevel = resolveThinkingLevel(params.thinkingLevel ?? ctx.thinkingLevel ?? pi.getThinkingLevel());
228
+ const { provider, model, modelOverrides } = resolveChildModel(ctx, params, attachments.extensions.length > 0);
229
+ const defaultThinkingLevel = resolveThinkingLevel(ctx.thinkingLevel ?? pi.getThinkingLevel());
230
+ const thinkingLevel = resolveThinkingLevel(params.thinkingLevel ?? defaultThinkingLevel);
217
231
  resolveTimeoutMs(params.timeout);
218
232
  assertChildCommandCapacity(
219
233
  {
220
234
  tools,
221
235
  skills: attachments.skills,
222
236
  extensions: attachments.extensions,
223
- model,
224
237
  thinkingLevel,
225
238
  projectTrusted,
226
239
  },
227
240
  dependencies.platform,
228
241
  );
242
+ assertChildBootstrapCapacity(selectedChildTools({ tools, extensions: attachments.extensions }), {
243
+ provider,
244
+ model,
245
+ thinkingLevel,
246
+ });
229
247
  throwIfAborted(signal, "Subagent spawn was cancelled");
230
248
  return toolResult(
231
249
  runtime.start({
@@ -234,8 +252,11 @@ export function registerSubagentTools(
234
252
  skills: attachments.skills,
235
253
  extensions: attachments.extensions,
236
254
  toolSources: attachments.toolSources,
255
+ provider,
237
256
  model,
257
+ modelOverrides,
238
258
  thinkingLevel,
259
+ defaultThinkingLevel,
239
260
  cwd,
240
261
  timeout: params.timeout,
241
262
  projectTrusted,
@@ -388,19 +409,62 @@ function resolveTools(value: unknown): string[] {
388
409
  return tools;
389
410
  }
390
411
 
391
- function resolveChildModel(ctx: ExtensionContext, hasAttachedExtensions: boolean): string {
392
- const model = ctx.model;
393
- if (!model) throw new Error("Subagent model is unavailable because no main-agent model is selected.");
394
- const provider = sanitizeTerminalText(model.provider).slice(0, 128);
395
- if (!hasAttachedExtensions && ctx.modelRegistry.getRegisteredProviderIds().includes(model.provider)) {
396
- throw new Error(`Subagent model provider ${provider} is unavailable because children disable parent extensions.`);
412
+ function resolveChildModel(
413
+ ctx: ExtensionContext,
414
+ overrides: Pick<SpawnArguments, "provider" | "model">,
415
+ hasAttachedExtensions: boolean,
416
+ ): { provider: string; model: string; modelOverrides: SubagentModelOverrides } {
417
+ // Read the effective parent pair once, after asynchronous attachment preflight.
418
+ const parent = ctx.model;
419
+ if (!parent && (overrides.provider === undefined || overrides.model === undefined)) {
420
+ throw new Error("Subagent model is unavailable because no main-agent model is selected.");
421
+ }
422
+ const provider = validateModelIdentifier(
423
+ overrides.provider === undefined ? parent?.provider : overrides.provider,
424
+ "provider",
425
+ );
426
+ const model = validateModelIdentifier(overrides.model === undefined ? parent?.id : overrides.model, "model");
427
+ const displayProvider = sanitizeTerminalText(provider).slice(0, 128);
428
+ if (!hasAttachedExtensions && ctx.modelRegistry.getRegisteredProviderIds().includes(provider)) {
429
+ throw new Error(
430
+ `Subagent model provider ${displayProvider} is unavailable because children disable parent extensions.`,
431
+ );
397
432
  }
398
- if (ctx.modelRegistry.getProviderAuthStatus(model.provider).source === "runtime") {
433
+ if (ctx.modelRegistry.getProviderAuthStatus(provider).source === "runtime") {
399
434
  throw new Error(
400
- `Subagent model provider ${provider} uses a process-local runtime API key. Configure stored or environment credentials that child processes can read.`,
435
+ `Subagent model provider ${displayProvider} uses a process-local runtime API key. Configure stored or environment credentials that child processes can read.`,
401
436
  );
402
437
  }
403
- return `${model.provider}/${model.id}`;
438
+ return {
439
+ provider,
440
+ model,
441
+ modelOverrides: {
442
+ ...(provider !== parent?.provider ? { provider } : {}),
443
+ ...(model !== parent?.id ? { model } : {}),
444
+ },
445
+ };
446
+ }
447
+
448
+ function validateModelIdentifier(value: unknown, field: "provider" | "model"): string {
449
+ // Each ID must fit the shared bootstrap before character scans or sanitization.
450
+ if (
451
+ typeof value === "string" &&
452
+ (value.length > MAX_BOOTSTRAP_BYTES || Buffer.byteLength(value, "utf8") > MAX_BOOTSTRAP_BYTES)
453
+ ) {
454
+ throw new Error(`Subagent ${field} exceeds the ${MAX_BOOTSTRAP_BYTES}-byte child bootstrap size limit.`);
455
+ }
456
+ if (
457
+ typeof value !== "string" ||
458
+ !value.trim() ||
459
+ [...value].some((character) => {
460
+ const codePoint = character.codePointAt(0) ?? 0;
461
+ return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f);
462
+ }) ||
463
+ sanitizeTerminalText(value) !== value
464
+ ) {
465
+ throw new Error(`Subagent ${field} must be a non-empty exact ID without control characters.`);
466
+ }
467
+ return value;
404
468
  }
405
469
 
406
470
  function resolveThinkingLevel(value: unknown): SubagentThinkingLevel {
@@ -430,29 +494,11 @@ function resolveMainSendArguments(params: SendArguments): MainSendSelection {
430
494
  : { kind: "response", requestId: requestId ?? "", message: params.message };
431
495
  }
432
496
 
433
- function optionalIdentifier(value: unknown, field: string): string | undefined {
434
- return value === undefined ? undefined : requiredIdentifier(value, field);
435
- }
436
-
437
497
  function requiredString(value: unknown, field: string): string {
438
498
  if (typeof value !== "string" || !value.trim()) throw new Error(`Subagent ${field} is required.`);
439
499
  return value.trim();
440
500
  }
441
501
 
442
- function requiredIdentifier(value: unknown, field: string): string {
443
- const identifier = requiredString(value, field);
444
- if (
445
- identifier.length > MAX_IDENTIFIER_LENGTH ||
446
- [...identifier].some((character) => {
447
- const codePoint = character.codePointAt(0) ?? 0;
448
- return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f);
449
- })
450
- ) {
451
- throw new Error(`Subagent ${field} is invalid.`);
452
- }
453
- return identifier;
454
- }
455
-
456
502
  function assertNotNested(): void {
457
503
  if ((Number.parseInt(process.env.PI_SUBAGENT_DEPTH ?? "0", 10) || 0) > 0) {
458
504
  throw new Error("Nested subagents are not supported by pi-subagents.");
package/src/types.ts CHANGED
@@ -60,6 +60,7 @@ export interface ChildRequest {
60
60
  skills: string[];
61
61
  extensions: ExtensionAttachment[];
62
62
  toolSources: Record<string, string[]>;
63
+ provider: string;
63
64
  model: string;
64
65
  thinkingLevel: SubagentThinkingLevel;
65
66
  cwd: string;