@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/process.ts CHANGED
@@ -4,18 +4,30 @@ 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 { BROKER_CREDENTIAL_FD, brokerCredentialEnvironment, serializeBrokerCredentials } from "./broker-credentials.js";
7
+ import { isWithin } from "./attachment-utils.js";
8
+ import {
9
+ assertChildBootstrapCapacity,
10
+ BROKER_CREDENTIAL_FD,
11
+ CHILD_READINESS_FD,
12
+ CHILD_READINESS_FD_ENV,
13
+ childBootstrapEnvironment,
14
+ MAX_READINESS_FRAME_BYTES,
15
+ serializeChildBootstrap,
16
+ } from "./broker-credentials.js";
8
17
  import { CHILD_COMMUNICATION_TOOL_NAMES } from "./child-communication-tools.js";
18
+ import { sanitizeTerminalText } from "./message-broker.js";
19
+ import { resolveTimeoutMs } from "./timeout.js";
9
20
  import type { ChildControl, ChildRequest, ChildResult } from "./types.js";
10
21
 
22
+ export { resolveTimeoutMs };
23
+
11
24
  const CORE_PACKAGE_NAME = "@earendil-works/pi-coding-agent";
12
- const MAX_TIMEOUT_MS = 2_147_483_647;
13
- const MAX_TIMEOUT_SECONDS = MAX_TIMEOUT_MS / 1000;
14
25
  const MAX_OUTPUT_BYTES = 32 * 1024;
15
26
  const MAX_ERROR_BYTES = 8 * 1024;
16
27
  const MAX_EVENT_LINE_BYTES = 256 * 1024;
17
28
  const RPC_RESPONSE_TIMEOUT_MS = 30_000;
18
29
  const KILL_GRACE_MS = 1_000;
30
+ const MAX_WINDOWS_COMMAND_LINE_CODE_UNITS = 32_767;
19
31
 
20
32
  interface ProcessSettlement {
21
33
  code: number;
@@ -30,6 +42,8 @@ interface AssistantEvent {
30
42
  id?: string;
31
43
  success?: boolean;
32
44
  error?: string;
45
+ event?: string;
46
+ data?: unknown;
33
47
  message?: {
34
48
  role?: string;
35
49
  content?: Array<{ type?: string; text?: string }>;
@@ -43,40 +57,39 @@ interface PendingRpcCommand {
43
57
  resolve: () => void;
44
58
  reject: (error: Error) => void;
45
59
  timer: NodeJS.Timeout;
46
- onAccepted?: () => void;
60
+ onAccepted?: (response: AssistantEvent) => void;
47
61
  signal?: AbortSignal;
48
62
  onAbort?: () => void;
49
63
  }
50
64
 
51
- export function resolveTimeoutMs(timeout: number | undefined): number | undefined {
52
- if (timeout === undefined) return undefined;
53
- if (!Number.isFinite(timeout) || timeout <= 0) {
54
- throw new Error("Invalid timeout: must be a finite number of seconds");
55
- }
56
- const timeoutMs = timeout * 1000;
57
- if (timeoutMs > MAX_TIMEOUT_MS) {
58
- throw new Error(`Invalid timeout: maximum is ${MAX_TIMEOUT_SECONDS} seconds`);
59
- }
60
- return timeoutMs;
61
- }
65
+ type ChildRpcCommand = { type: "get_state" | "get_commands" } | { type: "prompt" | "steer"; message: string };
62
66
 
63
67
  export async function runChild(request: ChildRequest): Promise<ChildResult> {
64
68
  if (request.signal.aborted) return cancelledResult();
65
69
  try {
70
+ assertChildCommandCapacity(request);
66
71
  const invocation = resolvePiInvocation(buildPiArgs(request));
67
72
  return await executeProcess(invocation, request);
68
73
  } catch (error) {
69
74
  if (request.signal.aborted) return cancelledResult();
70
75
  return {
71
76
  state: "failed",
72
- error: truncateText(error instanceof Error ? error.message : String(error), MAX_ERROR_BYTES).text,
77
+ error: truncateText(
78
+ redactAttachmentPaths(error instanceof Error ? error.message : String(error), request),
79
+ MAX_ERROR_BYTES,
80
+ ).text,
73
81
  limitations: [],
74
82
  truncated: false,
75
83
  };
76
84
  }
77
85
  }
78
86
 
79
- export function buildPiArgs(request: ChildRequest): string[] {
87
+ type ChildCommandRequest = Pick<
88
+ ChildRequest,
89
+ "tools" | "skills" | "extensions" | "model" | "thinkingLevel" | "projectTrusted"
90
+ >;
91
+
92
+ export function buildPiArgs(request: ChildCommandRequest): string[] {
80
93
  const args = [
81
94
  "--mode",
82
95
  "rpc",
@@ -86,40 +99,125 @@ export function buildPiArgs(request: ChildRequest): string[] {
86
99
  "--no-prompt-templates",
87
100
  "-e",
88
101
  childCommunicationBridgePath(),
102
+ ];
103
+ for (const extension of request.extensions) args.push("-e", extension.path);
104
+ if (requiresReadinessAttestation(request)) args.push("-e", childReadinessProbePath());
105
+ for (const skill of request.skills) args.push("--skill", skill);
106
+ args.push(
89
107
  "--model",
90
108
  request.model,
91
109
  "--thinking",
92
110
  request.thinkingLevel,
93
111
  request.projectTrusted ? "--approve" : "--no-approve",
94
- ];
95
- const tools = [...new Set([...request.tools, ...CHILD_COMMUNICATION_TOOL_NAMES])];
96
- args.push("--tools", tools.join(","));
112
+ );
113
+ args.push("--tools", selectedChildTools(request).join(","));
97
114
  return args;
98
115
  }
99
116
 
117
+ export function assertChildCommandCapacity(
118
+ request: ChildCommandRequest,
119
+ platform: NodeJS.Platform = process.platform,
120
+ ): void {
121
+ if (platform !== "win32") return;
122
+ const invocation = resolvePiInvocation(buildPiArgs(request));
123
+ const commandLineLength = [invocation.command, ...invocation.args].map(quoteWindowsArgument).join(" ").length;
124
+ if (commandLineLength + 1 > MAX_WINDOWS_COMMAND_LINE_CODE_UNITS) {
125
+ throw new Error("Subagent child command line exceeds the Windows process limit.");
126
+ }
127
+ }
128
+
129
+ function quoteWindowsArgument(value: string): string {
130
+ if (value.length > 0 && !/[\s"]/u.test(value)) return value;
131
+ let quoted = '"';
132
+ let backslashes = 0;
133
+ for (const character of value) {
134
+ if (character === "\\") {
135
+ backslashes++;
136
+ continue;
137
+ }
138
+ if (character === '"') {
139
+ quoted += `${"\\".repeat(backslashes * 2 + 1)}"`;
140
+ } else {
141
+ quoted += `${"\\".repeat(backslashes)}${character}`;
142
+ }
143
+ backslashes = 0;
144
+ }
145
+ return `${quoted}${"\\".repeat(backslashes * 2)}"`;
146
+ }
147
+
100
148
  export function childCommunicationBridgePath(): string {
101
149
  return fileURLToPath(new URL("./child-communication-bridge.ts", import.meta.url));
102
150
  }
103
151
 
152
+ export function childReadinessProbePath(): string {
153
+ return fileURLToPath(new URL("./child-readiness-probe.ts", import.meta.url));
154
+ }
155
+
156
+ function requiresReadinessAttestation(request: Pick<ChildRequest, "extensions">): boolean {
157
+ return request.extensions.length > 0;
158
+ }
159
+
160
+ function selectedChildTools(request: Pick<ChildRequest, "tools" | "extensions">): string[] {
161
+ return [
162
+ ...new Set([
163
+ ...request.tools,
164
+ ...CHILD_COMMUNICATION_TOOL_NAMES,
165
+ ...request.extensions.flatMap((extension) => extension.tools),
166
+ ]),
167
+ ];
168
+ }
169
+
104
170
  async function executeProcess(
105
171
  invocation: { command: string; args: string[] },
106
172
  request: ChildRequest,
107
173
  ): Promise<ChildResult> {
108
174
  const timeoutMs = resolveTimeoutMs(request.timeout);
175
+ const expectedTools = selectedChildTools(request);
176
+ const expectReadiness = requiresReadinessAttestation(request);
177
+ if (expectReadiness) assertChildBootstrapCapacity(expectedTools);
109
178
  let latestOutput = "";
110
179
  let terminalOutput: string | undefined;
111
180
  let terminalStopReason: "stop" | "length" | undefined;
112
181
  let errorMessage = "";
113
182
  let assistantFailed = false;
114
183
  let stderr = "";
184
+ let stderrRedactionCarry = "";
185
+ const stderrDecoder = new StringDecoder("utf8");
186
+ const diagnosticAttachmentPaths = attachmentPathVariants(request);
115
187
  let truncated = false;
188
+ const appendStderr = (value: string) => {
189
+ if (!value) return;
190
+ const limited = truncateTail(`${stderr}${value}`, MAX_ERROR_BYTES);
191
+ stderr = limited.text;
192
+ truncated ||= limited.truncated;
193
+ };
194
+ const pushStderr = (chunk: Buffer | string) => {
195
+ const combined = `${stderrRedactionCarry}${typeof chunk === "string" ? chunk : stderrDecoder.write(chunk)}`;
196
+ const carryLength = attachmentPathCarryLength(combined, diagnosticAttachmentPaths);
197
+ const boundary = combined.length - carryLength;
198
+ appendStderr(redactAttachmentPathVariants(combined.slice(0, boundary), diagnosticAttachmentPaths));
199
+ stderrRedactionCarry = combined.slice(boundary);
200
+ };
201
+ const flushStderr = () => {
202
+ const combined = `${stderrRedactionCarry}${stderrDecoder.end()}`;
203
+ const carryLength = attachmentPathCarryLength(combined, diagnosticAttachmentPaths);
204
+ const boundary = combined.length - carryLength;
205
+ appendStderr(
206
+ `${redactAttachmentPathVariants(combined.slice(0, boundary), diagnosticAttachmentPaths)}${
207
+ carryLength > 0 ? "[attachment path]" : ""
208
+ }`,
209
+ );
210
+ stderrRedactionCarry = "";
211
+ };
116
212
  let malformedEvents = 0;
117
213
  let rpcCounter = 0;
214
+ let attachmentStartupPending = expectReadiness;
215
+ let attachmentStartupError: string | undefined;
118
216
  const pendingCommands = new Map<string, PendingRpcCommand>();
119
217
  let rpcInputError: Error | undefined;
120
218
  let sendCommand: (
121
- command: { type: "prompt" | "steer"; message: string },
122
- onAccepted?: () => void,
219
+ command: ChildRpcCommand,
220
+ onAccepted?: (response: AssistantEvent) => void,
123
221
  signal?: AbortSignal,
124
222
  ) => Promise<void> = () => Promise.reject(new Error("Subagent RPC process is unavailable."));
125
223
  let onAgentSettled: () => void = () => undefined;
@@ -140,11 +238,11 @@ async function executeProcess(
140
238
  const rejectPendingCommands = (error: Error) => {
141
239
  for (const id of [...pendingCommands.keys()]) rejectPendingCommand(id, error);
142
240
  };
143
- const resolvePendingCommand = (id: string) => {
241
+ const resolvePendingCommand = (id: string, response: AssistantEvent) => {
144
242
  const pending = takePendingCommand(id);
145
243
  if (!pending) return;
146
244
  try {
147
- pending.onAccepted?.();
245
+ pending.onAccepted?.(response);
148
246
  pending.resolve();
149
247
  } catch (error) {
150
248
  pending.reject(error instanceof Error ? error : new Error(String(error)));
@@ -158,16 +256,33 @@ async function executeProcess(
158
256
  const decoder = new JsonLineDecoder(
159
257
  (value) => {
160
258
  const event = value as AssistantEvent;
259
+ if (
260
+ expectReadiness &&
261
+ event.type === "extension_error" &&
262
+ (event.event === "session_start" || event.event === "resources_discover") &&
263
+ !attachmentStartupError
264
+ ) {
265
+ const detail =
266
+ typeof event.error === "string" && event.error
267
+ ? redactAttachmentPaths(sanitizeTerminalText(event.error), request)
268
+ : "Unknown error.";
269
+ attachmentStartupError = `Subagent attachment startup failed during ${event.event}: ${detail}`;
270
+ return;
271
+ }
161
272
  if (event.type === "response" && typeof event.id === "string") {
162
273
  const pending = pendingCommands.get(event.id);
163
274
  if (!pending) return;
164
275
  if (event.success === true) {
165
- resolvePendingCommand(event.id);
276
+ resolvePendingCommand(event.id, event);
166
277
  } else {
167
278
  rejectPendingCommand(
168
279
  event.id,
169
280
  new Error(
170
- typeof event.error === "string" ? event.error : `Subagent RPC ${pending.command} command failed.`,
281
+ pending.command === "get_commands"
282
+ ? "Subagent child resource attestation failed."
283
+ : typeof event.error === "string"
284
+ ? event.error
285
+ : `Subagent RPC ${pending.command} command failed.`,
171
286
  ),
172
287
  );
173
288
  }
@@ -196,7 +311,7 @@ async function executeProcess(
196
311
  assistantFailed = true;
197
312
  }
198
313
  if (event.message.errorMessage) {
199
- const limited = truncateText(event.message.errorMessage, MAX_ERROR_BYTES);
314
+ const limited = truncateText(redactAttachmentPaths(event.message.errorMessage, request), MAX_ERROR_BYTES);
200
315
  errorMessage = limited.text;
201
316
  truncated ||= limited.truncated;
202
317
  }
@@ -204,6 +319,9 @@ async function executeProcess(
204
319
  },
205
320
  () => {
206
321
  malformedEvents++;
322
+ if (attachmentStartupPending && !attachmentStartupError) {
323
+ attachmentStartupError = "Subagent attachment startup emitted malformed or oversized RPC output.";
324
+ }
207
325
  },
208
326
  );
209
327
 
@@ -217,6 +335,9 @@ async function executeProcess(
217
335
  let timedOut = false;
218
336
  let completed = false;
219
337
  let ready = false;
338
+ let attachmentReady = !expectReadiness;
339
+ let promptStarted = false;
340
+ let readinessPipe: import("node:stream").Readable | undefined;
220
341
  let deadline: NodeJS.Timeout | undefined;
221
342
  let forceClose: NodeJS.Timeout | undefined;
222
343
  let escalation: NodeJS.Timeout | undefined;
@@ -232,6 +353,8 @@ async function executeProcess(
232
353
  if (forceClose) clearTimeout(forceClose);
233
354
  if (escalation) clearTimeout(escalation);
234
355
  request.signal.removeEventListener("abort", onAbort);
356
+ readinessPipe?.removeAllListeners();
357
+ readinessPipe?.destroy();
235
358
  rejectPendingCommands(new Error("Subagent RPC process closed."));
236
359
  resolve({ code, cancelled, timedOut, completed, launchError });
237
360
  };
@@ -257,6 +380,7 @@ async function executeProcess(
257
380
  process.stdin?.destroy();
258
381
  process.stdout?.destroy();
259
382
  process.stderr?.destroy();
383
+ readinessPipe?.destroy();
260
384
  finish(code);
261
385
  }, KILL_GRACE_MS * 2);
262
386
  forceClose.unref();
@@ -274,16 +398,18 @@ async function executeProcess(
274
398
  onAgentSettled = completeNormally;
275
399
 
276
400
  try {
401
+ const environment: NodeJS.ProcessEnv = {
402
+ ...globalThis.process.env,
403
+ ...childBootstrapEnvironment(expectReadiness),
404
+ PI_SUBAGENT_DEPTH: String((Number.parseInt(globalThis.process.env.PI_SUBAGENT_DEPTH ?? "0", 10) || 0) + 1),
405
+ };
406
+ if (!expectReadiness) delete environment[CHILD_READINESS_FD_ENV];
277
407
  process = spawn(invocation.command, invocation.args, {
278
408
  cwd: request.cwd,
279
409
  detached: globalThis.process.platform !== "win32",
280
410
  shell: false,
281
- stdio: ["pipe", "pipe", "pipe", "pipe"],
282
- env: {
283
- ...globalThis.process.env,
284
- ...brokerCredentialEnvironment(),
285
- PI_SUBAGENT_DEPTH: String((Number.parseInt(globalThis.process.env.PI_SUBAGENT_DEPTH ?? "0", 10) || 0) + 1),
286
- },
411
+ stdio: expectReadiness ? ["pipe", "pipe", "pipe", "pipe", "pipe"] : ["pipe", "pipe", "pipe", "pipe"],
412
+ env: environment,
287
413
  });
288
414
  } catch (error) {
289
415
  finish(1, error instanceof Error ? error.message : String(error));
@@ -339,11 +465,9 @@ async function executeProcess(
339
465
  });
340
466
  };
341
467
 
342
- request.signal.addEventListener("abort", onAbort, { once: true });
343
- if (request.signal.aborted) onAbort();
344
- process.once("spawn", () => {
345
- spawned = true;
346
- if (settled || cancelled) return;
468
+ const startPrompt = () => {
469
+ if (!spawned || !attachmentReady || promptStarted || settled || cancelled || terminating) return;
470
+ promptStarted = true;
347
471
  void sendCommand(
348
472
  { type: "prompt", message: `Task: ${request.task}` },
349
473
  () => {
@@ -371,22 +495,114 @@ async function executeProcess(
371
495
  request.signal,
372
496
  ).catch((error) => {
373
497
  if (settled || terminating) return;
374
- errorMessage = truncateText(error instanceof Error ? error.message : String(error), MAX_ERROR_BYTES).text;
498
+ errorMessage = truncateText(
499
+ redactAttachmentPaths(error instanceof Error ? error.message : String(error), request),
500
+ MAX_ERROR_BYTES,
501
+ ).text;
375
502
  terminate(1);
376
503
  });
504
+ };
505
+ const failReadiness = (message: string) => {
506
+ if (settled || terminating || attachmentReady) return;
507
+ errorMessage = truncateText(redactAttachmentPaths(message, request), MAX_ERROR_BYTES).text;
508
+ terminate(1);
509
+ };
510
+ const confirmAttachmentStartup = () => {
511
+ void sendCommand({ type: "get_state" }, undefined, request.signal)
512
+ .then(async () => {
513
+ if (attachmentStartupError) throw new Error(attachmentStartupError);
514
+ if (!request.projectTrusted) {
515
+ // Pi's RPC get_commands includes effective skills and prompts after resources_discover.
516
+ // RPC themes have no UI or model-visible representation.
517
+ await sendCommand(
518
+ { type: "get_commands" },
519
+ (response) => assertTrustedChildResources(response.data, request.cwd),
520
+ request.signal,
521
+ );
522
+ }
523
+ if (attachmentStartupError) throw new Error(attachmentStartupError);
524
+ attachmentStartupPending = false;
525
+ attachmentReady = true;
526
+ startPrompt();
527
+ })
528
+ .catch((error) => {
529
+ failReadiness(error instanceof Error ? error.message : String(error));
530
+ });
531
+ };
532
+
533
+ request.signal.addEventListener("abort", onAbort, { once: true });
534
+ if (request.signal.aborted) onAbort();
535
+ process.once("spawn", () => {
536
+ spawned = true;
537
+ startPrompt();
377
538
  });
539
+ if (expectReadiness) {
540
+ const candidate = process.stdio[CHILD_READINESS_FD];
541
+ if (!candidate || !("on" in candidate)) {
542
+ errorMessage = "Subagent readiness pipe is unavailable.";
543
+ terminate(1);
544
+ } else {
545
+ readinessPipe = candidate as import("node:stream").Readable;
546
+ let buffer = Buffer.alloc(0);
547
+ let readinessSettled = false;
548
+ readinessPipe.on("data", (chunk: Buffer | string) => {
549
+ if (readinessSettled || terminating) return;
550
+ buffer = Buffer.concat([buffer, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);
551
+ if (buffer.byteLength > MAX_READINESS_FRAME_BYTES) {
552
+ readinessSettled = true;
553
+ failReadiness("Subagent readiness frame exceeded its size limit.");
554
+ }
555
+ });
556
+ readinessPipe.once("end", () => {
557
+ if (readinessSettled || terminating) return;
558
+ readinessSettled = true;
559
+ let frame: { ok: true; sources: string[] } | { ok: false; error: string };
560
+ try {
561
+ frame = parseReadinessFrame(buffer, expectedTools.length);
562
+ } catch (error) {
563
+ failReadiness(error instanceof Error ? error.message : String(error));
564
+ return;
565
+ }
566
+ if (!frame.ok) {
567
+ failReadiness(frame.error);
568
+ return;
569
+ }
570
+ for (const extension of request.extensions) {
571
+ for (const tool of extension.tools) {
572
+ const source = frame.sources[expectedTools.indexOf(tool)];
573
+ if (
574
+ !source ||
575
+ !Object.hasOwn(request.toolSources, tool) ||
576
+ !request.toolSources[tool]?.includes(source)
577
+ ) {
578
+ failReadiness(`Subagent extension tool ${tool} was not provided by its requested attachment.`);
579
+ return;
580
+ }
581
+ }
582
+ }
583
+ confirmAttachmentStartup();
584
+ });
585
+ readinessPipe.once("error", () => {
586
+ if (readinessSettled || terminating) return;
587
+ readinessSettled = true;
588
+ failReadiness("Subagent readiness transfer failed.");
589
+ });
590
+ readinessPipe.once("close", () => {
591
+ if (readinessSettled || terminating) return;
592
+ readinessSettled = true;
593
+ failReadiness("Subagent readiness pipe closed without a result.");
594
+ });
595
+ }
596
+ }
378
597
  process.stdout?.on("data", (chunk) => decoder.push(chunk));
379
- process.stderr?.on("data", (chunk) => {
380
- const limited = truncateTail(`${stderr}${chunk.toString()}`, MAX_ERROR_BYTES);
381
- stderr = limited.text;
382
- truncated ||= limited.truncated;
383
- });
598
+ process.stderr?.on("data", (chunk: Buffer | string) => pushStderr(chunk));
384
599
  process.once("close", (code) => {
600
+ flushStderr();
385
601
  decoder.finish();
386
602
  finish(cancelled ? 130 : timedOut ? 124 : completed ? 0 : (code ?? 1));
387
603
  });
388
604
  process.once("error", (error) => {
389
- const limited = truncateText(error.message, MAX_ERROR_BYTES);
605
+ const limited = truncateText(redactAttachmentPaths(error.message, request), MAX_ERROR_BYTES);
390
606
  errorMessage = limited.text;
391
607
  truncated ||= limited.truncated;
392
608
  if (spawned) terminate(1);
@@ -409,7 +625,12 @@ async function executeProcess(
409
625
  credentialPipe.on("error", onCredentialError);
410
626
  credentialPipe.once("close", removeCredentialListeners);
411
627
  try {
412
- credentialPipe.end(serializeBrokerCredentials(request.communication));
628
+ credentialPipe.end(
629
+ serializeChildBootstrap({
630
+ communication: request.communication,
631
+ expectedTools: expectReadiness ? expectedTools : [],
632
+ }),
633
+ );
413
634
  } catch {
414
635
  onCredentialError();
415
636
  }
@@ -432,7 +653,17 @@ async function executeProcess(
432
653
  truncated,
433
654
  };
434
655
  }
435
- const error = settlement.launchError || errorMessage || stderr.trim();
656
+ const redactedStderr = truncateTail(redactAttachmentPaths(stderr.trim(), request), MAX_ERROR_BYTES);
657
+ const combinedError = combineErrors(
658
+ redactAttachmentPaths(settlement.launchError ?? "", request),
659
+ redactAttachmentPaths(errorMessage, request),
660
+ redactedStderr.text,
661
+ );
662
+ const error = combinedError.text;
663
+ if ((combinedError.truncated || redactedStderr.truncated) && !truncated) {
664
+ limitations.push("Child output was truncated to runtime limits.");
665
+ }
666
+ truncated ||= combinedError.truncated || redactedStderr.truncated;
436
667
  if (settlement.completed && terminalStopReason === "stop" && !assistantFailed && !errorMessage) {
437
668
  return {
438
669
  state: "completed",
@@ -570,6 +801,119 @@ function killImmediateChild(process: ChildProcess): void {
570
801
  }
571
802
  }
572
803
 
804
+ function assertTrustedChildResources(value: unknown, cwd: string): void {
805
+ const invalid = () => new Error("Subagent child resource attestation returned invalid commands.");
806
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw invalid();
807
+ const commands = (value as { commands?: unknown }).commands;
808
+ if (!Array.isArray(commands)) throw invalid();
809
+ let canonicalCwd: string;
810
+ try {
811
+ canonicalCwd = fs.realpathSync(cwd);
812
+ } catch {
813
+ throw invalid();
814
+ }
815
+ for (const command of commands) {
816
+ if (!command || typeof command !== "object" || Array.isArray(command)) throw invalid();
817
+ const entry = command as { source?: unknown; sourceInfo?: { path?: unknown } };
818
+ if (entry.source !== "skill" && entry.source !== "prompt") continue;
819
+ const resourcePath = entry.sourceInfo?.path;
820
+ if (typeof resourcePath !== "string" || !resourcePath) throw invalid();
821
+ const lexicalPath = path.resolve(cwd, resourcePath);
822
+ let canonicalPath: string;
823
+ try {
824
+ canonicalPath = fs.realpathSync(lexicalPath);
825
+ } catch {
826
+ throw invalid();
827
+ }
828
+ if (isWithin(cwd, lexicalPath) || isWithin(canonicalCwd, canonicalPath)) {
829
+ throw new Error("Subagent child cannot load project resources because the project is not trusted.");
830
+ }
831
+ }
832
+ }
833
+
834
+ function parseReadinessFrame(
835
+ buffer: Buffer,
836
+ expectedToolCount: number,
837
+ ): { ok: true; sources: string[] } | { ok: false; error: string } {
838
+ if (buffer.byteLength === 0) {
839
+ throw new Error("Subagent readiness pipe closed without a result.");
840
+ }
841
+ const text = buffer.toString("utf8");
842
+ const newline = text.indexOf("\n");
843
+ if (newline < 0 || newline !== text.length - 1) {
844
+ throw new Error("Subagent readiness pipe returned an invalid frame.");
845
+ }
846
+ let value: unknown;
847
+ try {
848
+ value = JSON.parse(text.slice(0, newline));
849
+ } catch {
850
+ throw new Error("Subagent readiness pipe returned malformed JSON.");
851
+ }
852
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
853
+ throw new Error("Subagent readiness pipe returned an invalid result.");
854
+ }
855
+ const record = value as Record<string, unknown>;
856
+ if (
857
+ record.ok === true &&
858
+ Object.keys(record).length === 2 &&
859
+ Array.isArray(record.sources) &&
860
+ record.sources.length === expectedToolCount &&
861
+ record.sources.every((source) => typeof source === "string" && /^[a-f0-9]{64}$/u.test(source))
862
+ ) {
863
+ return { ok: true, sources: record.sources };
864
+ }
865
+ if (
866
+ record.ok === false &&
867
+ Object.keys(record).length === 2 &&
868
+ typeof record.error === "string" &&
869
+ record.error.length > 0 &&
870
+ Buffer.byteLength(record.error, "utf8") <= MAX_ERROR_BYTES
871
+ ) {
872
+ return { ok: false, error: sanitizeTerminalText(record.error) };
873
+ }
874
+ throw new Error("Subagent readiness pipe returned an invalid result.");
875
+ }
876
+
877
+ function redactAttachmentPaths(value: string, request: Pick<ChildRequest, "skills" | "extensions">): string {
878
+ return redactAttachmentPathVariants(value, attachmentPathVariants(request));
879
+ }
880
+
881
+ function redactAttachmentPathVariants(value: string, candidates: readonly string[]): string {
882
+ let redacted = value;
883
+ for (const candidate of candidates) redacted = redacted.replaceAll(candidate, "[attachment path]");
884
+ return redacted;
885
+ }
886
+
887
+ function attachmentPathCarryLength(value: string, candidates: readonly string[]): number {
888
+ const maximum = Math.min(
889
+ value.length,
890
+ candidates.reduce((length, candidate) => Math.max(length, candidate.length - 1), 0),
891
+ );
892
+ for (let length = maximum; length > 0; length--) {
893
+ const suffix = value.slice(-length);
894
+ if (candidates.some((candidate) => candidate.length > length && candidate.startsWith(suffix))) return length;
895
+ }
896
+ return 0;
897
+ }
898
+
899
+ function attachmentPathVariants(request: Pick<ChildRequest, "skills" | "extensions">): string[] {
900
+ const variants = new Set<string>();
901
+ const paths = [...request.skills, ...request.extensions.map((extension) => extension.path)];
902
+ for (const resourcePath of paths) {
903
+ for (const spelling of [resourcePath, resourcePath.replaceAll("\\", "/"), resourcePath.replaceAll("/", "\\")]) {
904
+ if (!spelling) continue;
905
+ variants.add(spelling);
906
+ variants.add(JSON.stringify(spelling).slice(1, -1));
907
+ }
908
+ }
909
+ return [...variants].sort((left, right) => right.length - left.length);
910
+ }
911
+
912
+ function combineErrors(...messages: Array<string | undefined>): { text: string; truncated: boolean } {
913
+ const unique = [...new Set(messages.filter((message): message is string => Boolean(message)))];
914
+ return truncateText(unique.join("\n"), MAX_ERROR_BYTES);
915
+ }
916
+
573
917
  function abortError(message: string): Error {
574
918
  const error = new Error(message);
575
919
  error.name = "AbortError";