@erichll/pi-sandbox 0.3.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.
- package/LICENSE +201 -0
- package/NOTICE +12 -0
- package/README.md +98 -0
- package/package.json +65 -0
- package/src/approval.ts +155 -0
- package/src/config.ts +121 -0
- package/src/index.ts +472 -0
- package/src/network-policy.d.mts +10 -0
- package/src/network-policy.mjs +72 -0
- package/src/policy.ts +102 -0
- package/src/runner.ts +239 -0
- package/src/srt-broker.mjs +127 -0
- package/src/subagent.ts +631 -0
- package/src/traps.ts +21 -0
package/src/subagent.ts
ADDED
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import type { Writable } from "node:stream";
|
|
5
|
+
import type { NetworkEndpoint } from "./approval.ts";
|
|
6
|
+
import {
|
|
7
|
+
runSandboxedCommand,
|
|
8
|
+
type SandboxCommandOptions,
|
|
9
|
+
} from "./runner.ts";
|
|
10
|
+
import type {
|
|
11
|
+
SandboxApprovalAction,
|
|
12
|
+
SandboxApprovalTrap,
|
|
13
|
+
} from "./traps.ts";
|
|
14
|
+
|
|
15
|
+
export type ProcessInvocation = {
|
|
16
|
+
command: string;
|
|
17
|
+
args: string[];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type ProcessBackedSubagentOptions = {
|
|
21
|
+
task: string;
|
|
22
|
+
cwd: string;
|
|
23
|
+
model?: string;
|
|
24
|
+
tools?: string[];
|
|
25
|
+
signal?: AbortSignal;
|
|
26
|
+
timeout?: number;
|
|
27
|
+
invocation?: ProcessInvocation;
|
|
28
|
+
env?: NodeJS.ProcessEnv;
|
|
29
|
+
review(trap: SandboxApprovalTrap): Promise<SandboxApprovalAction>;
|
|
30
|
+
reviewDomain(endpoint: NetworkEndpoint): Promise<SandboxApprovalAction>;
|
|
31
|
+
onUpdate?(text: string): void;
|
|
32
|
+
policy?: SandboxCommandOptions["policy"];
|
|
33
|
+
sandbox?: Pick<SandboxCommandOptions, "platform" | "broker">;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type ProcessBackedSubagentResult = {
|
|
37
|
+
exitCode: number | null;
|
|
38
|
+
text: string;
|
|
39
|
+
rawOutput: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type ProcessBackedSubagentSessionState =
|
|
43
|
+
| "starting"
|
|
44
|
+
| "running"
|
|
45
|
+
| "idle"
|
|
46
|
+
| "failed"
|
|
47
|
+
| "stopped";
|
|
48
|
+
|
|
49
|
+
export type ProcessBackedSubagentSessionInfo = {
|
|
50
|
+
id: string;
|
|
51
|
+
parentId?: string;
|
|
52
|
+
depth: number;
|
|
53
|
+
state: ProcessBackedSubagentSessionState;
|
|
54
|
+
task: string;
|
|
55
|
+
text: string;
|
|
56
|
+
error?: string;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
type RpcResponse = {
|
|
60
|
+
id?: string;
|
|
61
|
+
type: "response";
|
|
62
|
+
success: boolean;
|
|
63
|
+
error?: string;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
type PendingRpc = {
|
|
67
|
+
resolve(response: RpcResponse): void;
|
|
68
|
+
reject(error: Error): void;
|
|
69
|
+
timer: NodeJS.Timeout;
|
|
70
|
+
type: "get_state" | "prompt" | "follow_up" | "abort";
|
|
71
|
+
line: string;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
type SettledWaiter = {
|
|
75
|
+
target: number;
|
|
76
|
+
resolve(result: ProcessBackedSubagentResult): void;
|
|
77
|
+
reject(error: Error): void;
|
|
78
|
+
cleanup(): void;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export type ProcessBackedSubagentSessionOptions = Omit<
|
|
82
|
+
ProcessBackedSubagentOptions,
|
|
83
|
+
"task" | "signal" | "onUpdate"
|
|
84
|
+
> & {
|
|
85
|
+
id?: string;
|
|
86
|
+
task: string;
|
|
87
|
+
parentId?: string;
|
|
88
|
+
depth?: number;
|
|
89
|
+
rpcTimeoutMs?: number;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export type ProcessBackedSubagentManagerOptions = {
|
|
93
|
+
maxConcurrency?: number;
|
|
94
|
+
maxDepth?: number;
|
|
95
|
+
invocation?: ProcessInvocation;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const DEFAULT_MAX_CONCURRENCY = 4;
|
|
99
|
+
const DEFAULT_MAX_DEPTH = 3;
|
|
100
|
+
const DEFAULT_RPC_TIMEOUT_MS = 30_000;
|
|
101
|
+
const SUBAGENT_DEPTH_ENV = "PI_SANDBOX_SUBAGENT_DEPTH";
|
|
102
|
+
|
|
103
|
+
function shellQuote(value: string): string {
|
|
104
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function resolvePiInvocation(): ProcessInvocation {
|
|
108
|
+
const currentEntry = process.argv[1];
|
|
109
|
+
if (
|
|
110
|
+
currentEntry &&
|
|
111
|
+
/(?:^|[/\\])cli\.(?:js|mjs|cjs|ts)$/.test(currentEntry)
|
|
112
|
+
) {
|
|
113
|
+
return { command: process.execPath, args: [currentEntry] };
|
|
114
|
+
}
|
|
115
|
+
const packageEntry = fileURLToPath(
|
|
116
|
+
import.meta.resolve("@earendil-works/pi-coding-agent"),
|
|
117
|
+
);
|
|
118
|
+
return {
|
|
119
|
+
command: process.execPath,
|
|
120
|
+
args: [join(dirname(packageEntry), "cli.js")],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function assistantText(message: unknown): string {
|
|
125
|
+
if (!message || typeof message !== "object" || Array.isArray(message)) {
|
|
126
|
+
return "";
|
|
127
|
+
}
|
|
128
|
+
const record = message as Record<string, unknown>;
|
|
129
|
+
if (record.role !== "assistant" || !Array.isArray(record.content)) return "";
|
|
130
|
+
return record.content
|
|
131
|
+
.flatMap((part) => {
|
|
132
|
+
if (!part || typeof part !== "object" || Array.isArray(part)) return [];
|
|
133
|
+
const content = part as Record<string, unknown>;
|
|
134
|
+
return content.type === "text" && typeof content.text === "string"
|
|
135
|
+
? [content.text]
|
|
136
|
+
: [];
|
|
137
|
+
})
|
|
138
|
+
.join("\n")
|
|
139
|
+
.trim();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function finalAssistantText(output: string): string {
|
|
143
|
+
let final = "";
|
|
144
|
+
for (const line of output.split(/\r?\n/)) {
|
|
145
|
+
if (!line.trim()) continue;
|
|
146
|
+
try {
|
|
147
|
+
const event = JSON.parse(line) as Record<string, unknown>;
|
|
148
|
+
if (event.type === "message_end" && event.message) {
|
|
149
|
+
const text = assistantText(event.message);
|
|
150
|
+
if (text) final = text;
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
// stderr and non-protocol output remain available in rawOutput.
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return final;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function rpcAssistantText(event: Record<string, unknown>): string {
|
|
160
|
+
return event.type === "message_end" && event.message
|
|
161
|
+
? assistantText(event.message)
|
|
162
|
+
: "";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function sessionResult(
|
|
166
|
+
text: string,
|
|
167
|
+
rawOutput: string,
|
|
168
|
+
): ProcessBackedSubagentResult {
|
|
169
|
+
return { exitCode: 0, text, rawOutput };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export class ProcessBackedSubagentSession {
|
|
173
|
+
readonly id: string;
|
|
174
|
+
readonly parentId?: string;
|
|
175
|
+
readonly depth: number;
|
|
176
|
+
readonly task: string;
|
|
177
|
+
|
|
178
|
+
private stateValue: ProcessBackedSubagentSessionState = "starting";
|
|
179
|
+
private textValue = "";
|
|
180
|
+
private errorValue: string | undefined;
|
|
181
|
+
private rawOutput = "";
|
|
182
|
+
private stderr = "";
|
|
183
|
+
private stdoutBuffer = "";
|
|
184
|
+
private stdin: Writable | undefined;
|
|
185
|
+
private rpcSequence = 0;
|
|
186
|
+
private settledCount = 0;
|
|
187
|
+
private requestedSettlements = 0;
|
|
188
|
+
private activePrompt:
|
|
189
|
+
| {
|
|
190
|
+
message: string;
|
|
191
|
+
target: number;
|
|
192
|
+
}
|
|
193
|
+
| undefined;
|
|
194
|
+
private pending = new Map<string, PendingRpc>();
|
|
195
|
+
private waiters = new Set<SettledWaiter>();
|
|
196
|
+
private updates = new Set<(text: string) => void>();
|
|
197
|
+
private readonly controller = new AbortController();
|
|
198
|
+
private completion: Promise<void> | undefined;
|
|
199
|
+
private readonly options: ProcessBackedSubagentSessionOptions;
|
|
200
|
+
private startReady:
|
|
201
|
+
| {
|
|
202
|
+
resolve(): void;
|
|
203
|
+
reject(error: Error): void;
|
|
204
|
+
}
|
|
205
|
+
| undefined;
|
|
206
|
+
|
|
207
|
+
constructor(options: ProcessBackedSubagentSessionOptions) {
|
|
208
|
+
this.options = options;
|
|
209
|
+
this.id = options.id ?? randomUUID();
|
|
210
|
+
this.parentId = options.parentId;
|
|
211
|
+
this.depth = options.depth ?? 1;
|
|
212
|
+
this.task = options.task;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
get info(): ProcessBackedSubagentSessionInfo {
|
|
216
|
+
return {
|
|
217
|
+
id: this.id,
|
|
218
|
+
parentId: this.parentId,
|
|
219
|
+
depth: this.depth,
|
|
220
|
+
state: this.stateValue,
|
|
221
|
+
task: this.task,
|
|
222
|
+
text: this.textValue,
|
|
223
|
+
...(this.errorValue ? { error: this.errorValue } : {}),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
onUpdate(listener: (text: string) => void): () => void {
|
|
228
|
+
this.updates.add(listener);
|
|
229
|
+
return () => this.updates.delete(listener);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async start(): Promise<void> {
|
|
233
|
+
if (this.completion) throw new Error(`subagent session ${this.id} already started`);
|
|
234
|
+
const invocation = this.options.invocation ?? resolvePiInvocation();
|
|
235
|
+
const args = [
|
|
236
|
+
...invocation.args,
|
|
237
|
+
"--mode",
|
|
238
|
+
"rpc",
|
|
239
|
+
"--no-session",
|
|
240
|
+
];
|
|
241
|
+
if (this.options.model) args.push("--model", this.options.model);
|
|
242
|
+
if (this.options.tools && this.options.tools.length > 0) {
|
|
243
|
+
args.push("--tools", this.options.tools.join(","));
|
|
244
|
+
}
|
|
245
|
+
const ready = new Promise<void>((resolve, reject) => {
|
|
246
|
+
this.startReady = { resolve, reject };
|
|
247
|
+
});
|
|
248
|
+
const envDepth = String(this.depth);
|
|
249
|
+
const run = runSandboxedCommand({
|
|
250
|
+
command: `process-backed subagent rpc ${this.id}`,
|
|
251
|
+
cwd: this.options.cwd,
|
|
252
|
+
env: {
|
|
253
|
+
...(this.options.env ?? process.env),
|
|
254
|
+
[SUBAGENT_DEPTH_ENV]: envDepth,
|
|
255
|
+
},
|
|
256
|
+
signal: this.controller.signal,
|
|
257
|
+
timeout: this.options.timeout,
|
|
258
|
+
policy: this.options.policy,
|
|
259
|
+
...this.options.sandbox,
|
|
260
|
+
directInvocation: { command: invocation.command, args },
|
|
261
|
+
review: this.options.review,
|
|
262
|
+
reviewDomain: this.options.reviewDomain,
|
|
263
|
+
onData() {},
|
|
264
|
+
onStdout: (data) => this.consumeStdout(data),
|
|
265
|
+
onStderr: (data) => {
|
|
266
|
+
this.stderr += data.toString("utf8");
|
|
267
|
+
},
|
|
268
|
+
onStart: (stdin) => this.handleSandboxStart(stdin),
|
|
269
|
+
});
|
|
270
|
+
this.completion = run.then(
|
|
271
|
+
(result) => {
|
|
272
|
+
if (this.stateValue !== "stopped") {
|
|
273
|
+
const error = new Error(
|
|
274
|
+
`subagent session ${this.id} exited with ${result.exitCode}: ${this.stderr.trim()}`,
|
|
275
|
+
);
|
|
276
|
+
this.fail(error);
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
(error: unknown) => {
|
|
280
|
+
if (this.stateValue === "stopped" && this.controller.signal.aborted) return;
|
|
281
|
+
this.fail(error instanceof Error ? error : new Error(String(error)));
|
|
282
|
+
},
|
|
283
|
+
);
|
|
284
|
+
await ready;
|
|
285
|
+
await this.send("get_state");
|
|
286
|
+
this.stateValue = "idle";
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async prompt(message: string): Promise<number> {
|
|
290
|
+
this.ensureActive();
|
|
291
|
+
return this.queuePrompt("prompt", message);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async followUp(message: string): Promise<number> {
|
|
295
|
+
this.ensureActive();
|
|
296
|
+
return this.queuePrompt("follow_up", message);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async abort(): Promise<void> {
|
|
300
|
+
this.ensureActive();
|
|
301
|
+
await this.send("abort");
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
waitForSettled(
|
|
305
|
+
target = this.requestedSettlements,
|
|
306
|
+
signal?: AbortSignal,
|
|
307
|
+
): Promise<ProcessBackedSubagentResult> {
|
|
308
|
+
if (this.stateValue === "failed") {
|
|
309
|
+
return Promise.reject(new Error(this.errorValue ?? "subagent session failed"));
|
|
310
|
+
}
|
|
311
|
+
if (this.stateValue === "stopped") {
|
|
312
|
+
return Promise.reject(new Error(`subagent session ${this.id} is stopped`));
|
|
313
|
+
}
|
|
314
|
+
if (this.settledCount >= target) {
|
|
315
|
+
return Promise.resolve(sessionResult(this.textValue, this.rawOutput));
|
|
316
|
+
}
|
|
317
|
+
return new Promise((resolve, reject) => {
|
|
318
|
+
let onAbort: (() => void) | undefined;
|
|
319
|
+
const waiter: SettledWaiter = {
|
|
320
|
+
target,
|
|
321
|
+
resolve,
|
|
322
|
+
reject,
|
|
323
|
+
cleanup: () => {
|
|
324
|
+
this.waiters.delete(waiter);
|
|
325
|
+
if (onAbort) signal?.removeEventListener("abort", onAbort);
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
onAbort = () => {
|
|
329
|
+
waiter.cleanup();
|
|
330
|
+
reject(new Error("aborted"));
|
|
331
|
+
};
|
|
332
|
+
if (signal?.aborted) {
|
|
333
|
+
onAbort();
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
337
|
+
this.waiters.add(waiter);
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async stop(): Promise<void> {
|
|
342
|
+
if (this.stateValue === "stopped") return;
|
|
343
|
+
this.stateValue = "stopped";
|
|
344
|
+
this.controller.abort();
|
|
345
|
+
this.stdin?.end();
|
|
346
|
+
const error = new Error(`subagent session ${this.id} stopped`);
|
|
347
|
+
this.rejectPending(error);
|
|
348
|
+
this.rejectWaiters(error);
|
|
349
|
+
await this.completion;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private ensureActive(): void {
|
|
353
|
+
if (this.stateValue === "failed") {
|
|
354
|
+
throw new Error(this.errorValue ?? `subagent session ${this.id} failed`);
|
|
355
|
+
}
|
|
356
|
+
if (this.stateValue === "stopped") {
|
|
357
|
+
throw new Error(`subagent session ${this.id} is stopped`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private async send(
|
|
362
|
+
type: "get_state" | "prompt" | "follow_up" | "abort",
|
|
363
|
+
fields: Record<string, unknown> = {},
|
|
364
|
+
): Promise<RpcResponse> {
|
|
365
|
+
const stdin = this.stdin;
|
|
366
|
+
if (!stdin?.writable || stdin.destroyed) {
|
|
367
|
+
throw new Error(`subagent session ${this.id} RPC stdin is unavailable`);
|
|
368
|
+
}
|
|
369
|
+
const id = `${this.id}:${++this.rpcSequence}`;
|
|
370
|
+
const line = `${JSON.stringify({ id, type, ...fields })}\n`;
|
|
371
|
+
const response = new Promise<RpcResponse>((resolve, reject) => {
|
|
372
|
+
const timer = setTimeout(() => {
|
|
373
|
+
this.pending.delete(id);
|
|
374
|
+
reject(
|
|
375
|
+
new Error(
|
|
376
|
+
`timeout waiting for subagent RPC ${type}: ${this.stderr.trim()}`,
|
|
377
|
+
),
|
|
378
|
+
);
|
|
379
|
+
}, this.options.rpcTimeoutMs ?? DEFAULT_RPC_TIMEOUT_MS);
|
|
380
|
+
this.pending.set(id, { resolve, reject, timer, type, line });
|
|
381
|
+
});
|
|
382
|
+
stdin.write(line);
|
|
383
|
+
const result = await response;
|
|
384
|
+
if (!result.success) {
|
|
385
|
+
throw new Error(result.error ?? `subagent RPC ${type} failed`);
|
|
386
|
+
}
|
|
387
|
+
return result;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private async queuePrompt(
|
|
391
|
+
type: "prompt" | "follow_up",
|
|
392
|
+
message: string,
|
|
393
|
+
): Promise<number> {
|
|
394
|
+
const previousTarget = this.requestedSettlements;
|
|
395
|
+
const target = Math.max(previousTarget, this.settledCount) + 1;
|
|
396
|
+
this.requestedSettlements = target;
|
|
397
|
+
this.activePrompt = { message, target };
|
|
398
|
+
try {
|
|
399
|
+
await this.send(type, { message });
|
|
400
|
+
return target;
|
|
401
|
+
} catch (error) {
|
|
402
|
+
if (
|
|
403
|
+
this.requestedSettlements === target &&
|
|
404
|
+
this.settledCount < target
|
|
405
|
+
) {
|
|
406
|
+
this.requestedSettlements = Math.max(
|
|
407
|
+
previousTarget,
|
|
408
|
+
this.settledCount,
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
if (this.activePrompt?.target === target) {
|
|
412
|
+
this.activePrompt = undefined;
|
|
413
|
+
}
|
|
414
|
+
throw error;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private handleSandboxStart(stdin: Writable): void {
|
|
419
|
+
this.stdin = stdin;
|
|
420
|
+
this.startReady?.resolve();
|
|
421
|
+
this.startReady = undefined;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
private resolveSettledWaiters(): void {
|
|
425
|
+
const result = sessionResult(this.textValue, this.rawOutput);
|
|
426
|
+
for (const waiter of [...this.waiters]) {
|
|
427
|
+
if (waiter.target <= this.settledCount) {
|
|
428
|
+
waiter.cleanup();
|
|
429
|
+
waiter.resolve(result);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
private consumeStdout(data: Buffer): void {
|
|
435
|
+
const text = data.toString("utf8");
|
|
436
|
+
this.rawOutput += text;
|
|
437
|
+
this.stdoutBuffer += text;
|
|
438
|
+
let newline = this.stdoutBuffer.indexOf("\n");
|
|
439
|
+
while (newline >= 0) {
|
|
440
|
+
const line = this.stdoutBuffer.slice(0, newline);
|
|
441
|
+
this.stdoutBuffer = this.stdoutBuffer.slice(newline + 1);
|
|
442
|
+
this.consumeLine(line.endsWith("\r") ? line.slice(0, -1) : line);
|
|
443
|
+
newline = this.stdoutBuffer.indexOf("\n");
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
private consumeLine(line: string): void {
|
|
448
|
+
if (!line.trim()) return;
|
|
449
|
+
let event: Record<string, unknown>;
|
|
450
|
+
try {
|
|
451
|
+
event = JSON.parse(line) as Record<string, unknown>;
|
|
452
|
+
} catch {
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
if (
|
|
456
|
+
event.type === "response" &&
|
|
457
|
+
typeof event.id === "string" &&
|
|
458
|
+
this.pending.has(event.id)
|
|
459
|
+
) {
|
|
460
|
+
const pending = this.pending.get(event.id)!;
|
|
461
|
+
this.pending.delete(event.id);
|
|
462
|
+
clearTimeout(pending.timer);
|
|
463
|
+
pending.resolve(event as RpcResponse);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
const text = rpcAssistantText(event);
|
|
467
|
+
if (text) {
|
|
468
|
+
this.textValue = text;
|
|
469
|
+
for (const update of this.updates) update(text);
|
|
470
|
+
}
|
|
471
|
+
if (event.type === "agent_start") {
|
|
472
|
+
this.stateValue = "running";
|
|
473
|
+
}
|
|
474
|
+
if (event.type === "agent_settled") {
|
|
475
|
+
this.stateValue = "idle";
|
|
476
|
+
this.settledCount++;
|
|
477
|
+
if (
|
|
478
|
+
this.activePrompt &&
|
|
479
|
+
this.activePrompt.target <= this.settledCount
|
|
480
|
+
) {
|
|
481
|
+
this.activePrompt = undefined;
|
|
482
|
+
}
|
|
483
|
+
this.resolveSettledWaiters();
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
private fail(error: Error): void {
|
|
488
|
+
this.stateValue = "failed";
|
|
489
|
+
this.errorValue = error.message;
|
|
490
|
+
this.startReady?.reject(error);
|
|
491
|
+
this.startReady = undefined;
|
|
492
|
+
this.rejectPending(error);
|
|
493
|
+
this.rejectWaiters(error);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
private rejectPending(error: Error): void {
|
|
497
|
+
for (const pending of this.pending.values()) {
|
|
498
|
+
clearTimeout(pending.timer);
|
|
499
|
+
pending.reject(error);
|
|
500
|
+
}
|
|
501
|
+
this.pending.clear();
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
private rejectWaiters(error: Error): void {
|
|
505
|
+
for (const waiter of [...this.waiters]) {
|
|
506
|
+
waiter.cleanup();
|
|
507
|
+
waiter.reject(error);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
export class ProcessBackedSubagentManager {
|
|
513
|
+
private readonly sessions = new Map<string, ProcessBackedSubagentSession>();
|
|
514
|
+
private readonly maxConcurrency: number;
|
|
515
|
+
private readonly maxDepth: number;
|
|
516
|
+
private readonly options: ProcessBackedSubagentManagerOptions;
|
|
517
|
+
|
|
518
|
+
constructor(options: ProcessBackedSubagentManagerOptions = {}) {
|
|
519
|
+
this.options = options;
|
|
520
|
+
this.maxConcurrency = options.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY;
|
|
521
|
+
this.maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
522
|
+
if (!Number.isInteger(this.maxConcurrency) || this.maxConcurrency < 1) {
|
|
523
|
+
throw new Error("subagent maxConcurrency must be a positive integer");
|
|
524
|
+
}
|
|
525
|
+
if (!Number.isInteger(this.maxDepth) || this.maxDepth < 1) {
|
|
526
|
+
throw new Error("subagent maxDepth must be a positive integer");
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
list(): ProcessBackedSubagentSessionInfo[] {
|
|
531
|
+
return [...this.sessions.values()].map((session) => session.info);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
get(id: string): ProcessBackedSubagentSession {
|
|
535
|
+
const session = this.sessions.get(id);
|
|
536
|
+
if (!session) throw new Error(`unknown subagent session: ${id}`);
|
|
537
|
+
return session;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
async start(
|
|
541
|
+
options: ProcessBackedSubagentSessionOptions,
|
|
542
|
+
): Promise<ProcessBackedSubagentSession> {
|
|
543
|
+
const active = [...this.sessions.values()].filter((session) =>
|
|
544
|
+
["starting", "running", "idle"].includes(session.info.state),
|
|
545
|
+
).length;
|
|
546
|
+
if (active >= this.maxConcurrency) {
|
|
547
|
+
throw new Error(
|
|
548
|
+
`subagent concurrency limit reached (${this.maxConcurrency})`,
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
const inheritedDepth = Number.parseInt(
|
|
552
|
+
process.env[SUBAGENT_DEPTH_ENV] ?? "0",
|
|
553
|
+
10,
|
|
554
|
+
);
|
|
555
|
+
const parentDepth = options.parentId
|
|
556
|
+
? this.sessions.get(options.parentId)?.depth
|
|
557
|
+
: undefined;
|
|
558
|
+
const depth = options.depth ?? (parentDepth ?? inheritedDepth) + 1;
|
|
559
|
+
if (!Number.isSafeInteger(depth) || depth < 1 || depth > this.maxDepth) {
|
|
560
|
+
throw new Error(`subagent nesting depth limit reached (${this.maxDepth})`);
|
|
561
|
+
}
|
|
562
|
+
const session = new ProcessBackedSubagentSession({
|
|
563
|
+
...options,
|
|
564
|
+
depth,
|
|
565
|
+
invocation: options.invocation ?? this.options.invocation,
|
|
566
|
+
});
|
|
567
|
+
this.sessions.set(session.id, session);
|
|
568
|
+
try {
|
|
569
|
+
await session.start();
|
|
570
|
+
return session;
|
|
571
|
+
} catch (error) {
|
|
572
|
+
this.sessions.delete(session.id);
|
|
573
|
+
await session.stop().catch(() => undefined);
|
|
574
|
+
throw error;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async remove(id: string): Promise<void> {
|
|
579
|
+
const session = this.get(id);
|
|
580
|
+
this.sessions.delete(id);
|
|
581
|
+
await session.stop();
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async shutdown(): Promise<void> {
|
|
585
|
+
const sessions = [...this.sessions.values()];
|
|
586
|
+
this.sessions.clear();
|
|
587
|
+
await Promise.allSettled(sessions.map((session) => session.stop()));
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
export async function runProcessBackedSubagent(
|
|
592
|
+
options: ProcessBackedSubagentOptions,
|
|
593
|
+
): Promise<ProcessBackedSubagentResult> {
|
|
594
|
+
const invocation = options.invocation ?? resolvePiInvocation();
|
|
595
|
+
const args = [
|
|
596
|
+
...invocation.args,
|
|
597
|
+
"--mode",
|
|
598
|
+
"json",
|
|
599
|
+
"-p",
|
|
600
|
+
"--no-session",
|
|
601
|
+
];
|
|
602
|
+
if (options.model) args.push("--model", options.model);
|
|
603
|
+
if (options.tools && options.tools.length > 0) {
|
|
604
|
+
args.push("--tools", options.tools.join(","));
|
|
605
|
+
}
|
|
606
|
+
args.push(`Task: ${options.task}`);
|
|
607
|
+
const command = [invocation.command, ...args].map(shellQuote).join(" ");
|
|
608
|
+
let output = "";
|
|
609
|
+
const result = await runSandboxedCommand({
|
|
610
|
+
command,
|
|
611
|
+
cwd: options.cwd,
|
|
612
|
+
env: options.env,
|
|
613
|
+
signal: options.signal,
|
|
614
|
+
timeout: options.timeout,
|
|
615
|
+
policy: options.policy,
|
|
616
|
+
...options.sandbox,
|
|
617
|
+
review: options.review,
|
|
618
|
+
reviewDomain: options.reviewDomain,
|
|
619
|
+
onData(data) {
|
|
620
|
+
output += data.toString("utf8");
|
|
621
|
+
options.onUpdate?.(finalAssistantText(output) || "(subagent running)");
|
|
622
|
+
},
|
|
623
|
+
});
|
|
624
|
+
const text = finalAssistantText(output);
|
|
625
|
+
if (result.exitCode !== 0) {
|
|
626
|
+
throw new Error(
|
|
627
|
+
`subagent exited with ${result.exitCode}: ${text || output.trim()}`,
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
return { ...result, text, rawOutput: output };
|
|
631
|
+
}
|
package/src/traps.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SandboxBoundaryTrap,
|
|
3
|
+
SandboxFilesystemTrap,
|
|
4
|
+
SandboxNetworkTrap,
|
|
5
|
+
} from "@erichll/pi-auto-review/sandbox";
|
|
6
|
+
|
|
7
|
+
export type {
|
|
8
|
+
SandboxBoundaryTrap,
|
|
9
|
+
SandboxFilesystemTrap,
|
|
10
|
+
SandboxNetworkTrap,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type SandboxApprovalTrap = SandboxBoundaryTrap;
|
|
14
|
+
export type SandboxApprovalAction = "allow" | "deny";
|
|
15
|
+
|
|
16
|
+
export function formatSandboxTrap(trap: SandboxApprovalTrap): string {
|
|
17
|
+
if (trap.kind === "filesystem") {
|
|
18
|
+
return `${trap.operation} ${trap.path}`;
|
|
19
|
+
}
|
|
20
|
+
return `${trap.operation} ${trap.target}`;
|
|
21
|
+
}
|