@narumitw/pi-subagents 3.3.3 → 3.4.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 +74 -25
- package/dist/child-communication-bridge.ts +3 -2
- package/dist/child-communication-bridge.ts.map +1 -1
- package/dist/child-readiness-probe.ts +114 -17
- package/dist/child-readiness-probe.ts.map +2 -2
- package/dist/chunks/{chunk-FY5HIU6Y.ts → chunk-5OXDVXXO.ts} +1 -1
- package/dist/chunks/{chunk-FY5HIU6Y.ts.map → chunk-5OXDVXXO.ts.map} +1 -1
- package/dist/chunks/chunk-B3RWNGDX.ts +192 -0
- package/dist/chunks/chunk-B3RWNGDX.ts.map +7 -0
- package/dist/chunks/{chunk-ZOAFWLDJ.ts → chunk-PRIDTAJL.ts} +4 -16
- package/dist/chunks/chunk-PRIDTAJL.ts.map +7 -0
- package/dist/chunks/{chunk-YMVSJ5GE.ts → chunk-YV2P6XJX.ts} +46 -17
- package/dist/chunks/chunk-YV2P6XJX.ts.map +7 -0
- package/dist/chunks/{menu-KAIUEWL7.ts → menu-2UH4ICLL.ts} +74 -3
- package/dist/chunks/menu-2UH4ICLL.ts.map +7 -0
- package/dist/index.ts +554 -293
- package/dist/index.ts.map +3 -3
- package/docs/attachments.md +9 -3
- package/docs/design-principles.md +3 -1
- package/docs/messaging.md +3 -1
- package/docs/tools.md +15 -1
- package/package.json +3 -3
- package/src/broker-credentials.ts +44 -13
- package/src/child-communication-tools.ts +2 -19
- package/src/child-readiness-probe.ts +144 -18
- package/src/child-readiness.ts +118 -0
- package/src/menu.ts +82 -3
- package/src/message-broker.ts +23 -10
- package/src/process.ts +59 -167
- package/src/resource-attachments.ts +1 -3
- package/src/rpc-line-decoder.ts +61 -0
- package/src/runtime.ts +22 -10
- package/src/settings.ts +2 -2
- package/src/subagents.ts +110 -18
- package/src/tools.ts +83 -37
- package/src/types.ts +1 -0
- package/src/widget-renderer.ts +119 -0
- package/src/widget.ts +245 -121
- package/dist/chunks/chunk-3FCHXUKZ.ts +0 -98
- package/dist/chunks/chunk-3FCHXUKZ.ts.map +0 -7
- package/dist/chunks/chunk-YMVSJ5GE.ts.map +0 -7
- package/dist/chunks/chunk-ZOAFWLDJ.ts.map +0 -7
- 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:
|
|
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 }) => {
|
package/src/message-broker.ts
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
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
|
-
|
|
108
|
+
args.push("-e", childReadinessProbePath());
|
|
106
109
|
for (const skill of request.skills) args.push("--skill", skill);
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
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
|
|
178
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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 (
|
|
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(
|
|
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:
|
|
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 (
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|