@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/README.md +58 -49
- package/dist/child-communication-bridge.ts +7 -5
- package/dist/child-communication-bridge.ts.map +2 -2
- package/dist/child-readiness-probe.ts +68 -0
- package/dist/child-readiness-probe.ts.map +7 -0
- package/dist/chunks/chunk-3FCHXUKZ.ts +98 -0
- package/dist/chunks/chunk-3FCHXUKZ.ts.map +7 -0
- package/dist/chunks/{chunk-7JQOB375.ts → chunk-YMVSJ5GE.ts} +84 -132
- package/dist/chunks/{chunk-7JQOB375.ts.map → chunk-YMVSJ5GE.ts.map} +4 -4
- package/dist/chunks/chunk-ZOAFWLDJ.ts +130 -0
- package/dist/chunks/chunk-ZOAFWLDJ.ts.map +7 -0
- package/dist/index.ts +1195 -81
- package/dist/index.ts.map +4 -4
- package/docs/attachments.md +37 -0
- package/docs/design-principles.md +8 -2
- package/docs/messaging.md +23 -0
- package/docs/tools.md +22 -56
- package/package.json +8 -5
- package/src/attachment-utils.ts +104 -0
- package/src/broker-credentials.ts +106 -28
- package/src/child-communication-bridge.ts +3 -3
- package/src/child-communication-tools.ts +2 -24
- package/src/child-readiness-probe.ts +72 -0
- package/src/process.ts +391 -47
- package/src/resource-attachments.ts +724 -0
- package/src/runtime.ts +18 -1
- package/src/skill-attachments.ts +302 -0
- package/src/timeout.ts +24 -0
- package/src/tools.ts +96 -18
- package/src/types.ts +4 -0
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 {
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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";
|