@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
package/src/menu.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
2
  import { defineMenu, runConfirmation, runMenu, runTask, sanitizeTerminalText } from "@narumitw/pi-tui-kit";
3
+ import type { EditorStatusItemHandle } from "@narumitw/pi-tui-kit/editor-status-collection";
3
4
  import type { SubagentSettings } from "./settings.js";
4
5
  import { JOB_STATES, type JobSummary } from "./types.js";
6
+ import type { SubagentWidgetControls } from "./widget.js";
5
7
 
6
8
  interface SubagentsMenuState {
7
9
  settings: SubagentSettings;
@@ -16,16 +18,19 @@ interface SubagentsMenuOptions {
16
18
  getState(): SubagentsMenuState;
17
19
  cancelJob(jobId: string, signal: AbortSignal): Promise<void>;
18
20
  showSettings(signal: AbortSignal): Promise<"back" | "close" | "stale">;
21
+ widget?: SubagentWidgetControls;
19
22
  }
20
23
 
21
24
  export async function showSubagentsMenu(ctx: ExtensionCommandContext, options: SubagentsMenuOptions): Promise<void> {
22
25
  if (options.signal.aborted || !options.isCurrent()) return;
23
26
  let selectedJobId: string | undefined;
27
+ let selectedItem: EditorStatusItemHandle | undefined;
28
+ const widget = ctx.mode === "tui" ? options.widget : undefined;
24
29
  const current = (signal: AbortSignal) => !signal.aborted && options.isCurrent();
25
30
  const menu = defineMenu<
26
31
  SubagentsMenuState,
27
- "main" | "jobs" | "job" | "status" | "help",
28
- "selectJob" | "cancelJob" | "settings"
32
+ "main" | "jobs" | "job" | "status" | "help" | "view" | "trigger",
33
+ "selectJob" | "cancelJob" | "settings" | "view" | "trigger" | "itemView"
29
34
  >({
30
35
  start: "main",
31
36
  screens: {
@@ -36,6 +41,7 @@ export async function showSubagentsMenu(ctx: ExtensionCommandContext, options: S
36
41
  items: [
37
42
  { id: "jobs", label: "Jobs", to: "jobs" },
38
43
  { id: "settings", label: "Settings", action: "settings" },
44
+ ...(widget ? [{ id: "view", label: "Widget view", to: "view" as const }] : []),
39
45
  { id: "status", label: "Status", to: "status" },
40
46
  { id: "help", label: "Help", to: "help" },
41
47
  { id: "close", label: "Close", close: true },
@@ -60,10 +66,62 @@ export async function showSubagentsMenu(ctx: ExtensionCommandContext, options: S
60
66
  kind: "actions",
61
67
  title: "Subagent Job",
62
68
  lines: job ? jobLines(job) : ["This job is no longer available."],
63
- items: job && isActive(job) ? [{ id: "cancel", label: "Cancel job…", action: "cancelJob" }] : [],
69
+ items:
70
+ job && isActive(job)
71
+ ? [
72
+ { id: "cancel", label: "Cancel job…", action: "cancelJob" },
73
+ ...(widget && selectedItem
74
+ ? [
75
+ { id: "expand", label: "Widget: job details", action: "itemView" as const },
76
+ { id: "compact", label: "Widget: job summary", action: "itemView" as const },
77
+ { id: "remove", label: "Remove from widget (keep job)", action: "itemView" as const },
78
+ ]
79
+ : []),
80
+ ]
81
+ : [],
64
82
  hint: "back",
65
83
  };
66
84
  },
85
+ view: ({ state }) => ({
86
+ kind: "actions",
87
+ title: "Subagents Widget View",
88
+ lines: [
89
+ `View: ${widget?.state().mode ?? "default"} · pinned: ${widget?.state().pinned ?? false}`,
90
+ `Automatic compaction: ${widget?.state().trigger ?? "manual"}`,
91
+ ...(state.settings.showWidget ? [] : ["Disabled by Settings. View controls do not enable the widget."]),
92
+ "Display changes never cancel jobs. No completed-job history is shown.",
93
+ ],
94
+ items: [
95
+ { id: "expanded", label: "Show details", action: "view" },
96
+ { id: "compact", label: "Show summary", action: "view" },
97
+ {
98
+ id: "visibility",
99
+ label: widget?.state().mode === "hidden" ? "Show widget" : "Hide widget",
100
+ action: "view",
101
+ },
102
+ { id: "pin", label: widget?.state().pinned ? "Unpin view" : "Pin view", action: "view" },
103
+ { id: "trigger", label: "Automatic compaction", to: "trigger" },
104
+ { id: "reset-items", label: "Reset item views", action: "view" },
105
+ ],
106
+ hint: "back",
107
+ }),
108
+ trigger: () => ({
109
+ kind: "actions",
110
+ title: "Subagents Automatic Compaction",
111
+ lines: [
112
+ "Opt-in. A pending rule rearms on meaningful active-job changes, not timing ticks.",
113
+ "User messages count only when consumed, including extension-generated user messages.",
114
+ "Settlement requires a non-aborted cycle with an observed non-error, non-aborted final turn.",
115
+ "A later cycle needs its own post-arm consumed user message; queued batches can share a cycle.",
116
+ "The cycle already active when armed is excluded. Pins and disabled/hidden views prevent compaction.",
117
+ ],
118
+ items: [
119
+ { id: "manual", label: "Manual only", action: "trigger" },
120
+ { id: "user-message", label: "Next consumed user message", action: "trigger" },
121
+ { id: "settled-cycle", label: "Next eligible settled cycle", action: "trigger" },
122
+ ],
123
+ hint: "back",
124
+ }),
67
125
  status: ({ state }) => ({
68
126
  kind: "detail",
69
127
  title: "Subagents Status",
@@ -99,10 +157,31 @@ export async function showSubagentsMenu(ctx: ExtensionCommandContext, options: S
99
157
  }),
100
158
  },
101
159
  actions: {
160
+ view: ({ itemId, signal }) => {
161
+ if (!current(signal)) return { kind: "close" };
162
+ if (!widget) return { kind: "stay" };
163
+ if (itemId === "expanded" || itemId === "compact") widget.setMode(itemId);
164
+ else if (itemId === "visibility") widget.setMode(widget.state().mode === "hidden" ? "expanded" : "hidden");
165
+ else if (itemId === "pin") widget.setPinned(!widget.state().pinned);
166
+ else if (itemId === "reset-items") widget.resetItems();
167
+ return { kind: "stay" };
168
+ },
169
+ trigger: ({ itemId, signal }) => {
170
+ if (!current(signal)) return { kind: "close" };
171
+ if (itemId === "manual" || itemId === "user-message" || itemId === "settled-cycle") widget?.setTrigger(itemId);
172
+ return { kind: "stay" };
173
+ },
174
+ itemView: ({ itemId, signal }) => {
175
+ if (!current(signal)) return { kind: "close" };
176
+ if (itemId === "compact" || itemId === "expand" || itemId === "remove") selectedItem?.[itemId]();
177
+ selectedItem = selectedJobId === undefined ? undefined : widget?.item(selectedJobId);
178
+ return { kind: "stay" };
179
+ },
102
180
  selectJob: ({ itemId, signal }) => {
103
181
  if (!current(signal)) return { kind: "close" };
104
182
  if (!options.getState().jobs.some((job) => job.jobId === itemId)) return { kind: "stay" };
105
183
  selectedJobId = itemId;
184
+ selectedItem = widget?.item(itemId);
106
185
  return { kind: "to", screen: "job" };
107
186
  },
108
187
  settings: async ({ signal }) => {
@@ -42,7 +42,6 @@ export interface MessageBrokerOptions {
42
42
  interface JobRecord {
43
43
  jobId: string;
44
44
  generation: number;
45
- token: string;
46
45
  }
47
46
 
48
47
  interface RequestWaiter {
@@ -55,9 +54,6 @@ interface RequestRecord {
55
54
  jobId: string;
56
55
  generation: number;
57
56
  origin: BrokerRequestOrigin;
58
- expectedResponder: BrokerRequestOrigin;
59
- message: string;
60
- createdAt: number;
61
57
  response?: string;
62
58
  consumedAt?: number;
63
59
  deliveryQueued?: boolean;
@@ -159,7 +155,7 @@ export class MessageBroker {
159
155
  throw new Error(`Subagent messaging credentials already exist for ${input.jobId}.`);
160
156
  }
161
157
  const token = randomBytes(32).toString("hex");
162
- const record: JobRecord = { ...input, token };
158
+ const record: JobRecord = { ...input };
163
159
  this.jobsByToken.set(token, record);
164
160
  this.tokenByJob.set(input.jobId, token);
165
161
  return { host: BROKER_HOST, port: this.address?.port ?? 0, token };
@@ -285,9 +281,6 @@ export class MessageBroker {
285
281
  jobId: job.jobId,
286
282
  generation: job.generation,
287
283
  origin,
288
- expectedResponder: origin === "main" ? "child" : "main",
289
- message,
290
- createdAt: this.now(),
291
284
  };
292
285
  this.requests.set(request.requestId, request);
293
286
  if (origin === "child") {
@@ -296,7 +289,7 @@ export class MessageBroker {
296
289
  kind: "request",
297
290
  requestId: request.requestId,
298
291
  jobId: request.jobId,
299
- message: request.message,
292
+ message,
300
293
  });
301
294
  } catch (error) {
302
295
  this.requests.delete(request.requestId);
@@ -317,7 +310,7 @@ export class MessageBroker {
317
310
  const request = this.requests.get(requestId);
318
311
  if (
319
312
  !request ||
320
- request.expectedResponder !== responder ||
313
+ request.origin === responder ||
321
314
  (job && (request.jobId !== job.jobId || request.generation !== job.generation))
322
315
  ) {
323
316
  throw new Error("Unknown, expired, or unauthorized subagent request.");
@@ -537,6 +530,26 @@ export class MessageBroker {
537
530
  }
538
531
  }
539
532
 
533
+ export function optionalIdentifier(value: unknown, field: string): string | undefined {
534
+ return value === undefined ? undefined : requiredIdentifier(value, field);
535
+ }
536
+
537
+ /** Tool arguments accept trimmed IDs; broker request IDs have a separate UUID check. */
538
+ export function requiredIdentifier(value: unknown, field: string): string {
539
+ if (typeof value !== "string" || !value.trim()) throw new Error(`Subagent ${field} is required.`);
540
+ const identifier = value.trim();
541
+ if (
542
+ identifier.length > MAX_IDENTIFIER_LENGTH ||
543
+ [...identifier].some((character) => {
544
+ const codePoint = character.codePointAt(0) ?? 0;
545
+ return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f);
546
+ })
547
+ ) {
548
+ throw new Error(`Subagent ${field} is invalid.`);
549
+ }
550
+ return identifier;
551
+ }
552
+
540
553
  export function validateMessage(message: string, label: string): void {
541
554
  if (!message.trim()) throw new Error(`${label} is required.`);
542
555
  if (message.includes("\0")) throw new Error(`${label} must not contain NUL bytes.`);
package/src/process.ts CHANGED
@@ -4,18 +4,24 @@ import * as path from "node:path";
4
4
  import { StringDecoder } from "node:string_decoder";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { getPackageDir } from "@earendil-works/pi-coding-agent";
7
- import { isWithin } from "./attachment-utils.js";
8
7
  import {
9
8
  assertChildBootstrapCapacity,
10
9
  BROKER_CREDENTIAL_FD,
11
10
  CHILD_READINESS_FD,
12
- CHILD_READINESS_FD_ENV,
13
11
  childBootstrapEnvironment,
14
12
  MAX_READINESS_FRAME_BYTES,
15
13
  serializeChildBootstrap,
16
14
  } from "./broker-credentials.js";
17
15
  import { CHILD_COMMUNICATION_TOOL_NAMES } from "./child-communication-tools.js";
16
+ import {
17
+ assertChildModelPair,
18
+ assertChildModelState,
19
+ assertTrustedChildResources,
20
+ type ChildReadinessFrame,
21
+ parseReadinessFrame,
22
+ } from "./child-readiness.js";
18
23
  import { sanitizeTerminalText } from "./message-broker.js";
24
+ import { JsonLineDecoder } from "./rpc-line-decoder.js";
19
25
  import { resolveTimeoutMs } from "./timeout.js";
20
26
  import type { ChildControl, ChildRequest, ChildResult } from "./types.js";
21
27
 
@@ -24,7 +30,6 @@ export { resolveTimeoutMs };
24
30
  const CORE_PACKAGE_NAME = "@earendil-works/pi-coding-agent";
25
31
  const MAX_OUTPUT_BYTES = 32 * 1024;
26
32
  const MAX_ERROR_BYTES = 8 * 1024;
27
- const MAX_EVENT_LINE_BYTES = 256 * 1024;
28
33
  const RPC_RESPONSE_TIMEOUT_MS = 30_000;
29
34
  const KILL_GRACE_MS = 1_000;
30
35
  const MAX_WINDOWS_COMMAND_LINE_CODE_UNITS = 32_767;
@@ -39,6 +44,7 @@ interface ProcessSettlement {
39
44
 
40
45
  interface AssistantEvent {
41
46
  type?: string;
47
+ command?: string;
42
48
  id?: string;
43
49
  success?: boolean;
44
50
  error?: string;
@@ -84,10 +90,7 @@ export async function runChild(request: ChildRequest): Promise<ChildResult> {
84
90
  }
85
91
  }
86
92
 
87
- type ChildCommandRequest = Pick<
88
- ChildRequest,
89
- "tools" | "skills" | "extensions" | "model" | "thinkingLevel" | "projectTrusted"
90
- >;
93
+ type ChildCommandRequest = Pick<ChildRequest, "tools" | "skills" | "extensions" | "thinkingLevel" | "projectTrusted">;
91
94
 
92
95
  export function buildPiArgs(request: ChildCommandRequest): string[] {
93
96
  const args = [
@@ -102,15 +105,10 @@ export function buildPiArgs(request: ChildCommandRequest): string[] {
102
105
  ];
103
106
  if (request.tools.includes("codemode")) args.push("-e", "builtin:codemode");
104
107
  for (const extension of request.extensions) args.push("-e", extension.path);
105
- if (requiresReadinessAttestation(request)) args.push("-e", childReadinessProbePath());
108
+ args.push("-e", childReadinessProbePath());
106
109
  for (const skill of request.skills) args.push("--skill", skill);
107
- args.push(
108
- "--model",
109
- request.model,
110
- "--thinking",
111
- request.thinkingLevel,
112
- request.projectTrusted ? "--approve" : "--no-approve",
113
- );
110
+ // CLI model flags use pattern matching. The readiness probe binds the private exact selection.
111
+ args.push("--thinking", request.thinkingLevel, request.projectTrusted ? "--approve" : "--no-approve");
114
112
  args.push("--tools", selectedChildTools(request).join(","));
115
113
  return args;
116
114
  }
@@ -154,11 +152,7 @@ export function childReadinessProbePath(): string {
154
152
  return fileURLToPath(new URL("./child-readiness-probe.ts", import.meta.url));
155
153
  }
156
154
 
157
- function requiresReadinessAttestation(request: Pick<ChildRequest, "tools" | "extensions">): boolean {
158
- return request.tools.includes("codemode") || request.extensions.length > 0;
159
- }
160
-
161
- function selectedChildTools(request: Pick<ChildRequest, "tools" | "extensions">): string[] {
155
+ export function selectedChildTools(request: Pick<ChildRequest, "tools" | "extensions">): string[] {
162
156
  return [
163
157
  ...new Set([
164
158
  ...request.tools,
@@ -174,8 +168,8 @@ async function executeProcess(
174
168
  ): Promise<ChildResult> {
175
169
  const timeoutMs = resolveTimeoutMs(request.timeout);
176
170
  const expectedTools = selectedChildTools(request);
177
- const expectReadiness = requiresReadinessAttestation(request);
178
- if (expectReadiness) assertChildBootstrapCapacity(expectedTools);
171
+ const selection = { provider: request.provider, model: request.model, thinkingLevel: request.thinkingLevel };
172
+ assertChildBootstrapCapacity(expectedTools, selection);
179
173
  let latestOutput = "";
180
174
  let terminalOutput: string | undefined;
181
175
  let terminalStopReason: "stop" | "length" | undefined;
@@ -212,7 +206,7 @@ async function executeProcess(
212
206
  };
213
207
  let malformedEvents = 0;
214
208
  let rpcCounter = 0;
215
- let attachmentStartupPending = expectReadiness;
209
+ let attachmentReady = false;
216
210
  let attachmentStartupError: string | undefined;
217
211
  const pendingCommands = new Map<string, PendingRpcCommand>();
218
212
  let rpcInputError: Error | undefined;
@@ -258,9 +252,10 @@ async function executeProcess(
258
252
  (value) => {
259
253
  const event = value as AssistantEvent;
260
254
  if (
261
- expectReadiness &&
262
255
  event.type === "extension_error" &&
263
- (event.event === "session_start" || event.event === "resources_discover") &&
256
+ ["session_start", "resources_discover", "model_select", "thinking_level_select"].includes(
257
+ String(event.event),
258
+ ) &&
264
259
  !attachmentStartupError
265
260
  ) {
266
261
  const detail =
@@ -273,7 +268,9 @@ async function executeProcess(
273
268
  if (event.type === "response" && typeof event.id === "string") {
274
269
  const pending = pendingCommands.get(event.id);
275
270
  if (!pending) return;
276
- if (event.success === true) {
271
+ if (event.command !== pending.command) {
272
+ rejectPendingCommand(event.id, new Error("Subagent RPC response returned an invalid command."));
273
+ } else if (event.success === true) {
277
274
  resolvePendingCommand(event.id, event);
278
275
  } else {
279
276
  rejectPendingCommand(
@@ -320,8 +317,9 @@ async function executeProcess(
320
317
  },
321
318
  () => {
322
319
  malformedEvents++;
323
- if (attachmentStartupPending && !attachmentStartupError) {
320
+ if (!attachmentReady && !attachmentStartupError) {
324
321
  attachmentStartupError = "Subagent attachment startup emitted malformed or oversized RPC output.";
322
+ failRpcInput(new Error(attachmentStartupError));
325
323
  }
326
324
  },
327
325
  );
@@ -336,10 +334,10 @@ async function executeProcess(
336
334
  let timedOut = false;
337
335
  let completed = false;
338
336
  let ready = false;
339
- let attachmentReady = !expectReadiness;
340
337
  let promptStarted = false;
341
338
  let readinessPipe: import("node:stream").Readable | undefined;
342
339
  let deadline: NodeJS.Timeout | undefined;
340
+ let startupDeadline: NodeJS.Timeout | undefined;
343
341
  let forceClose: NodeJS.Timeout | undefined;
344
342
  let escalation: NodeJS.Timeout | undefined;
345
343
  let termination: Promise<void> | undefined;
@@ -351,6 +349,7 @@ async function executeProcess(
351
349
  if (settled) return;
352
350
  settled = true;
353
351
  if (deadline) clearTimeout(deadline);
352
+ if (startupDeadline) clearTimeout(startupDeadline);
354
353
  if (forceClose) clearTimeout(forceClose);
355
354
  if (escalation) clearTimeout(escalation);
356
355
  request.signal.removeEventListener("abort", onAbort);
@@ -365,6 +364,8 @@ async function executeProcess(
365
364
  const terminate = (code: number) => {
366
365
  if (settled || terminating) return;
367
366
  terminating = true;
367
+ rejectPendingCommands(new Error("Subagent RPC process is terminating."));
368
+ if (startupDeadline) clearTimeout(startupDeadline);
368
369
  if (deadline) {
369
370
  clearTimeout(deadline);
370
371
  deadline = undefined;
@@ -401,15 +402,14 @@ async function executeProcess(
401
402
  try {
402
403
  const environment: NodeJS.ProcessEnv = {
403
404
  ...globalThis.process.env,
404
- ...childBootstrapEnvironment(expectReadiness),
405
+ ...childBootstrapEnvironment(true),
405
406
  PI_SUBAGENT_DEPTH: String((Number.parseInt(globalThis.process.env.PI_SUBAGENT_DEPTH ?? "0", 10) || 0) + 1),
406
407
  };
407
- if (!expectReadiness) delete environment[CHILD_READINESS_FD_ENV];
408
408
  process = spawn(invocation.command, invocation.args, {
409
409
  cwd: request.cwd,
410
410
  detached: globalThis.process.platform !== "win32",
411
411
  shell: false,
412
- stdio: expectReadiness ? ["pipe", "pipe", "pipe", "pipe", "pipe"] : ["pipe", "pipe", "pipe", "pipe"],
412
+ stdio: ["pipe", "pipe", "pipe", "pipe", "pipe"],
413
413
  env: environment,
414
414
  });
415
415
  } catch (error) {
@@ -467,8 +467,18 @@ async function executeProcess(
467
467
  };
468
468
 
469
469
  const startPrompt = () => {
470
- if (!spawned || !attachmentReady || promptStarted || settled || cancelled || terminating) return;
470
+ if (
471
+ !spawned ||
472
+ !attachmentReady ||
473
+ promptStarted ||
474
+ settled ||
475
+ cancelled ||
476
+ terminating ||
477
+ request.signal.aborted
478
+ )
479
+ return;
471
480
  promptStarted = true;
481
+ if (startupDeadline) clearTimeout(startupDeadline);
472
482
  void sendCommand(
473
483
  { type: "prompt", message: `Task: ${request.task}` },
474
484
  () => {
@@ -505,12 +515,19 @@ async function executeProcess(
505
515
  };
506
516
  const failReadiness = (message: string) => {
507
517
  if (settled || terminating || attachmentReady) return;
508
- errorMessage = truncateText(redactAttachmentPaths(message, request), MAX_ERROR_BYTES).text;
518
+ const limited = truncateText(redactAttachmentPaths(sanitizeTerminalText(message), request), MAX_ERROR_BYTES);
519
+ errorMessage = limited.text;
520
+ truncated ||= limited.truncated;
509
521
  terminate(1);
510
522
  };
511
523
  const confirmAttachmentStartup = () => {
512
- void sendCommand({ type: "get_state" }, undefined, request.signal)
524
+ void sendCommand(
525
+ { type: "get_state" },
526
+ (response) => assertChildModelState(response.data, request),
527
+ request.signal,
528
+ )
513
529
  .then(async () => {
530
+ if (settled || terminating || request.signal.aborted) return;
514
531
  if (attachmentStartupError) throw new Error(attachmentStartupError);
515
532
  if (!request.projectTrusted) {
516
533
  // Pi's RPC get_commands includes effective skills and prompts after resources_discover.
@@ -521,8 +538,8 @@ async function executeProcess(
521
538
  request.signal,
522
539
  );
523
540
  }
541
+ if (settled || terminating || request.signal.aborted) return;
524
542
  if (attachmentStartupError) throw new Error(attachmentStartupError);
525
- attachmentStartupPending = false;
526
543
  attachmentReady = true;
527
544
  startPrompt();
528
545
  })
@@ -537,7 +554,9 @@ async function executeProcess(
537
554
  spawned = true;
538
555
  startPrompt();
539
556
  });
540
- if (expectReadiness) {
557
+ {
558
+ startupDeadline = setTimeout(() => failReadiness("Subagent child readiness timed out."), RPC_RESPONSE_TIMEOUT_MS);
559
+ startupDeadline.unref();
541
560
  const candidate = process.stdio[CHILD_READINESS_FD];
542
561
  if (!candidate || !("on" in candidate)) {
543
562
  errorMessage = "Subagent readiness pipe is unavailable.";
@@ -557,9 +576,10 @@ async function executeProcess(
557
576
  readinessPipe.once("end", () => {
558
577
  if (readinessSettled || terminating) return;
559
578
  readinessSettled = true;
560
- let frame: { ok: true; sources: string[] } | { ok: false; error: string };
579
+ let frame: ChildReadinessFrame;
561
580
  try {
562
581
  frame = parseReadinessFrame(buffer, expectedTools.length);
582
+ if (frame.ok) assertChildModelPair(frame.model, request);
563
583
  } catch (error) {
564
584
  failReadiness(error instanceof Error ? error.message : String(error));
565
585
  return;
@@ -629,7 +649,8 @@ async function executeProcess(
629
649
  credentialPipe.end(
630
650
  serializeChildBootstrap({
631
651
  communication: request.communication,
632
- expectedTools: expectReadiness ? expectedTools : [],
652
+ expectedTools,
653
+ selection,
633
654
  }),
634
655
  );
635
656
  } catch {
@@ -802,79 +823,6 @@ function killImmediateChild(process: ChildProcess): void {
802
823
  }
803
824
  }
804
825
 
805
- function assertTrustedChildResources(value: unknown, cwd: string): void {
806
- const invalid = () => new Error("Subagent child resource attestation returned invalid commands.");
807
- if (!value || typeof value !== "object" || Array.isArray(value)) throw invalid();
808
- const commands = (value as { commands?: unknown }).commands;
809
- if (!Array.isArray(commands)) throw invalid();
810
- let canonicalCwd: string;
811
- try {
812
- canonicalCwd = fs.realpathSync(cwd);
813
- } catch {
814
- throw invalid();
815
- }
816
- for (const command of commands) {
817
- if (!command || typeof command !== "object" || Array.isArray(command)) throw invalid();
818
- const entry = command as { source?: unknown; sourceInfo?: { path?: unknown } };
819
- if (entry.source !== "skill" && entry.source !== "prompt") continue;
820
- const resourcePath = entry.sourceInfo?.path;
821
- if (typeof resourcePath !== "string" || !resourcePath) throw invalid();
822
- const lexicalPath = path.resolve(cwd, resourcePath);
823
- let canonicalPath: string;
824
- try {
825
- canonicalPath = fs.realpathSync(lexicalPath);
826
- } catch {
827
- throw invalid();
828
- }
829
- if (isWithin(cwd, lexicalPath) || isWithin(canonicalCwd, canonicalPath)) {
830
- throw new Error("Subagent child cannot load project resources because the project is not trusted.");
831
- }
832
- }
833
- }
834
-
835
- function parseReadinessFrame(
836
- buffer: Buffer,
837
- expectedToolCount: number,
838
- ): { ok: true; sources: string[] } | { ok: false; error: string } {
839
- if (buffer.byteLength === 0) {
840
- throw new Error("Subagent readiness pipe closed without a result.");
841
- }
842
- const text = buffer.toString("utf8");
843
- const newline = text.indexOf("\n");
844
- if (newline < 0 || newline !== text.length - 1) {
845
- throw new Error("Subagent readiness pipe returned an invalid frame.");
846
- }
847
- let value: unknown;
848
- try {
849
- value = JSON.parse(text.slice(0, newline));
850
- } catch {
851
- throw new Error("Subagent readiness pipe returned malformed JSON.");
852
- }
853
- if (!value || typeof value !== "object" || Array.isArray(value)) {
854
- throw new Error("Subagent readiness pipe returned an invalid result.");
855
- }
856
- const record = value as Record<string, unknown>;
857
- if (
858
- record.ok === true &&
859
- Object.keys(record).length === 2 &&
860
- Array.isArray(record.sources) &&
861
- record.sources.length === expectedToolCount &&
862
- record.sources.every((source) => typeof source === "string" && /^[a-f0-9]{64}$/u.test(source))
863
- ) {
864
- return { ok: true, sources: record.sources };
865
- }
866
- if (
867
- record.ok === false &&
868
- Object.keys(record).length === 2 &&
869
- typeof record.error === "string" &&
870
- record.error.length > 0 &&
871
- Buffer.byteLength(record.error, "utf8") <= MAX_ERROR_BYTES
872
- ) {
873
- return { ok: false, error: sanitizeTerminalText(record.error) };
874
- }
875
- throw new Error("Subagent readiness pipe returned an invalid result.");
876
- }
877
-
878
826
  function redactAttachmentPaths(value: string, request: Pick<ChildRequest, "skills" | "extensions">): string {
879
827
  return redactAttachmentPathVariants(value, attachmentPathVariants(request));
880
828
  }
@@ -954,59 +902,3 @@ function truncateTail(text: string, maxBytes: number): { text: string; truncated
954
902
  truncated: true,
955
903
  };
956
904
  }
957
-
958
- class JsonLineDecoder {
959
- private buffer = "";
960
- private dropping = false;
961
- private readonly decoder = new StringDecoder("utf8");
962
-
963
- constructor(
964
- private readonly onValue: (value: unknown) => void,
965
- private readonly onMalformed: () => void,
966
- ) {}
967
-
968
- push(chunk: Buffer | string): void {
969
- this.buffer += typeof chunk === "string" ? chunk : this.decoder.write(chunk);
970
- this.drain(false);
971
- }
972
-
973
- finish(): void {
974
- this.buffer += this.decoder.end();
975
- this.drain(true);
976
- this.buffer = "";
977
- this.dropping = false;
978
- }
979
-
980
- private drain(flush: boolean): void {
981
- while (true) {
982
- const newline = this.buffer.indexOf("\n");
983
- if (newline < 0) break;
984
- const line = this.buffer.slice(0, newline).replace(/\r$/u, "");
985
- this.buffer = this.buffer.slice(newline + 1);
986
- if (this.dropping) {
987
- this.dropping = false;
988
- continue;
989
- }
990
- this.parse(line);
991
- }
992
- if (!flush && Buffer.byteLength(this.buffer, "utf8") > MAX_EVENT_LINE_BYTES) {
993
- this.onMalformed();
994
- this.buffer = "";
995
- this.dropping = true;
996
- }
997
- if (flush && this.buffer && !this.dropping) this.parse(this.buffer.replace(/\r$/u, ""));
998
- }
999
-
1000
- private parse(line: string): void {
1001
- if (!line.trim()) return;
1002
- if (Buffer.byteLength(line, "utf8") > MAX_EVENT_LINE_BYTES) {
1003
- this.onMalformed();
1004
- return;
1005
- }
1006
- try {
1007
- this.onValue(JSON.parse(line));
1008
- } catch {
1009
- this.onMalformed();
1010
- }
1011
- }
1012
- }
@@ -178,9 +178,7 @@ function resolveResourcePath(
178
178
  if (!stats.isFile() && !stats.isDirectory()) {
179
179
  throw new Error(`Subagent ${kind} path must reference a file or directory.`);
180
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
- }
181
+ assertProjectPathTrusted(lexicalPath, canonicalPath, kind, cwd, canonicalCwd, projectTrusted);
184
182
  return canonicalPath;
185
183
  }
186
184