@ferris1225/pi-subagents 4.2.1 → 4.2.2
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 +1 -1
- package/agents/explorer.md +1 -1
- package/package.json +55 -55
- package/src/prompt.ts +3 -2
- package/src/rpc-run.ts +993 -993
package/src/rpc-run.ts
CHANGED
|
@@ -1,993 +1,993 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Persistent pi RPC child transport for one logical sub-agent generation.
|
|
3
|
-
*
|
|
4
|
-
* A child stays alive across prompt/abort operations and speaks
|
|
5
|
-
* strict LF-delimited JSONL. The process is terminated only after the logical
|
|
6
|
-
* run settles, is parked/stopped, or fails. Session files remain owned by the
|
|
7
|
-
* parent runtime so a later generation can resume the same thread.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { spawn, type ChildProcess } from "node:child_process";
|
|
11
|
-
import { existsSync, readdirSync, unlinkSync, rmdirSync } from "node:fs";
|
|
12
|
-
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
13
|
-
import { basename, join } from "node:path";
|
|
14
|
-
import { StringDecoder } from "node:string_decoder";
|
|
15
|
-
import type { Message } from "@earendil-works/pi-ai";
|
|
16
|
-
import { SUBAGENT_TOOL_NAMES, type AgentConfig } from "./agents.ts";
|
|
17
|
-
import type { ThinkingLevel } from "./config.ts";
|
|
18
|
-
import { writeTempOwnerMarker } from "./temp-hygiene.ts";
|
|
19
|
-
import type { IsolationMode, WorktreeFinalizationStatus } from "./worktree.ts";
|
|
20
|
-
|
|
21
|
-
export const DEPTH_ENV_VAR = "PI_SUBAGENT_DEPTH";
|
|
22
|
-
export const SUBAGENT_KILL_GRACE_MS = 5_000;
|
|
23
|
-
/** ACK budget after the child is known to be reading RPC. */
|
|
24
|
-
export const RPC_COMMAND_TIMEOUT_MS = 30_000;
|
|
25
|
-
|
|
26
|
-
/** clear_queue is stop-path hygiene ahead of the abort: give it its own short
|
|
27
|
-
* budget so a hung response cannot eat into the abort-settle window. */
|
|
28
|
-
const RPC_CLEAR_QUEUE_TIMEOUT_MS = 2_000;
|
|
29
|
-
/** Time allowed for the child to boot and answer get_state. */
|
|
30
|
-
export const RPC_READY_TIMEOUT_MS = 60_000;
|
|
31
|
-
export const RPC_ABORT_SETTLE_TIMEOUT_MS = 5_000;
|
|
32
|
-
|
|
33
|
-
export function isRpcCommandTimeoutError(message?: string): boolean {
|
|
34
|
-
return typeof message === "string" && message.includes("Timed out waiting for RPC response");
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Prevent RPC prompt expansion when a control objective itself starts with
|
|
38
|
-
* slash (for example `/subagents-setup`). The original text stays verbatim
|
|
39
|
-
* below a non-command prefix and therefore always starts a model turn. */
|
|
40
|
-
export function asPlainTextRpcPrompt(message: string): string {
|
|
41
|
-
if (!message.trimStart().startsWith("/")) return message;
|
|
42
|
-
return `Treat the following as plain-text sub-agent instructions, not a Pi command:\n\n${message}`;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export interface UsageStats {
|
|
46
|
-
input: number;
|
|
47
|
-
output: number;
|
|
48
|
-
cacheRead: number;
|
|
49
|
-
cacheWrite: number;
|
|
50
|
-
cost: number;
|
|
51
|
-
contextTokens: number;
|
|
52
|
-
turns: number;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export interface RpcSingleResult {
|
|
56
|
-
agent: string;
|
|
57
|
-
task: string;
|
|
58
|
-
exitCode: number;
|
|
59
|
-
messages: Message[];
|
|
60
|
-
stderr: string;
|
|
61
|
-
usage: UsageStats;
|
|
62
|
-
model?: string;
|
|
63
|
-
thinking?: string;
|
|
64
|
-
stopReason?: string;
|
|
65
|
-
errorMessage?: string;
|
|
66
|
-
/** Selected model when this result handed off to the current main model. */
|
|
67
|
-
modelFallbackFrom?: string;
|
|
68
|
-
dispatchFailed?: boolean;
|
|
69
|
-
/** An accepted generation failed because an RPC prompt was rejected before
|
|
70
|
-
* model execution. This remains main-model handoff eligible even when an
|
|
71
|
-
* earlier, aborted objective left assistant text in the session. */
|
|
72
|
-
rpcPromptRejected?: boolean;
|
|
73
|
-
/** Startup handshake failed before the initial prompt was dispatched. This
|
|
74
|
-
* transport miss is safe to retry and is not a model/provider failure. */
|
|
75
|
-
rpcStartupFailed?: boolean;
|
|
76
|
-
/** The parent dispatched the initial prompt command. Until its response is
|
|
77
|
-
* observed, Pi may already be running it, so startup retries must not replay it. */
|
|
78
|
-
rpcPromptDispatched?: boolean;
|
|
79
|
-
/** The child confirmed prompt acceptance (or emitted agent activity). */
|
|
80
|
-
rpcPromptAccepted?: boolean;
|
|
81
|
-
/** Pi emitted agent/turn/model/tool activity for this attempt. */
|
|
82
|
-
rpcActivity?: boolean;
|
|
83
|
-
startupRetries?: number;
|
|
84
|
-
failedTools?: Array<{ toolName: string; error: string }>;
|
|
85
|
-
sessionId?: string;
|
|
86
|
-
sessionDir?: string;
|
|
87
|
-
/** Original task/project cwd used for result-artifact retention buckets. */
|
|
88
|
-
projectCwd?: string;
|
|
89
|
-
/** Stable logical run id assigned by dispatch (also present on queued results). */
|
|
90
|
-
runId?: number;
|
|
91
|
-
/** Filesystem isolation selected for this logical thread. */
|
|
92
|
-
isolation?: IsolationMode;
|
|
93
|
-
/** Final integration state for a worktree-isolated settlement. */
|
|
94
|
-
integrationStatus?: "pending" | WorktreeFinalizationStatus;
|
|
95
|
-
integrationApplied?: boolean;
|
|
96
|
-
integrationError?: string;
|
|
97
|
-
/** Retained only when integration/cleanup failed; never contains patch data. */
|
|
98
|
-
integrationWorktreePath?: string;
|
|
99
|
-
integrationPatchPath?: string;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
export type SubagentLiveEvent =
|
|
103
|
-
| { kind: "status"; status: "queued" | "running" | "interrupting" | "done" | "failed" }
|
|
104
|
-
| { kind: "model"; model?: string; thinking?: ThinkingLevel; fallbackFrom?: string }
|
|
105
|
-
| { kind: "usage"; usage: UsageStats; model?: string }
|
|
106
|
-
| { kind: "session"; sessionId: string; sessionDir: string }
|
|
107
|
-
| { kind: "tool_start"; toolCallId?: string; toolName: string; args: unknown }
|
|
108
|
-
| { kind: "tool_end"; toolCallId?: string; toolName: string; isError: boolean }
|
|
109
|
-
| { kind: "thinking" }
|
|
110
|
-
| { kind: "text" };
|
|
111
|
-
|
|
112
|
-
export type RpcControlPhase =
|
|
113
|
-
| "queued"
|
|
114
|
-
| "starting"
|
|
115
|
-
| "running"
|
|
116
|
-
| "interrupting"
|
|
117
|
-
| "retrying"
|
|
118
|
-
| "settled"
|
|
119
|
-
| "stopped";
|
|
120
|
-
|
|
121
|
-
interface AttemptControl {
|
|
122
|
-
stop(reason?: string): Promise<void>;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Stable control surface for a logical run generation. Startup/main-handoff attempts
|
|
127
|
-
* attach and detach beneath it, so callers never retain a stale child handle.
|
|
128
|
-
* Control calls are serialized to prevent overlapping abort/settle/prompt flows.
|
|
129
|
-
*/
|
|
130
|
-
export class RpcRunControl {
|
|
131
|
-
private objective: string;
|
|
132
|
-
private phase: RpcControlPhase = "queued";
|
|
133
|
-
private attempt?: { token: number; control: AttemptControl };
|
|
134
|
-
private nextToken = 1;
|
|
135
|
-
private serial: Promise<void> = Promise.resolve();
|
|
136
|
-
private stopRequested = false;
|
|
137
|
-
private stopMessage = "Subagent was aborted";
|
|
138
|
-
private childPids = new Set<number>();
|
|
139
|
-
|
|
140
|
-
constructor(
|
|
141
|
-
objective: string,
|
|
142
|
-
readonly generation: number,
|
|
143
|
-
private readonly onPhase?: (phase: RpcControlPhase) => void,
|
|
144
|
-
) {
|
|
145
|
-
this.objective = objective;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
getObjective(): string {
|
|
149
|
-
return this.objective;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
getPhase(): RpcControlPhase {
|
|
153
|
-
return this.phase;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
isStopRequested(): boolean {
|
|
157
|
-
return this.stopRequested;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
getStopMessage(): string {
|
|
161
|
-
return this.stopMessage;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/** Pids of every child process this generation spawned. Persisted with the
|
|
165
|
-
* thread record so a later load can kill orphans that still hold the
|
|
166
|
-
* retained session. */
|
|
167
|
-
noteChildPid(pid: number): void {
|
|
168
|
-
if (Number.isInteger(pid) && pid > 0) this.childPids.add(pid);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
getChildPids(): number[] {
|
|
172
|
-
return [...this.childPids];
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
markStarting(): void {
|
|
176
|
-
this.setPhase("starting");
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
markRetrying(): void {
|
|
180
|
-
if (!this.stopRequested) this.setPhase("retrying");
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
markSettled(): void {
|
|
184
|
-
this.attempt = undefined;
|
|
185
|
-
if (!this.stopRequested) this.setPhase("settled");
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/** Allocate an attempt token used to reject state updates from old children. */
|
|
189
|
-
beginAttempt(): number {
|
|
190
|
-
return this.nextToken++;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
attach(token: number, control: AttemptControl): void {
|
|
194
|
-
this.attempt = { token, control };
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
detach(token: number): void {
|
|
198
|
-
if (this.attempt?.token === token) this.attempt = undefined;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
updateAttemptPhase(token: number, phase: RpcControlPhase): void {
|
|
202
|
-
if (this.attempt?.token !== token) return;
|
|
203
|
-
this.setPhase(phase);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
async stop(reason = "Subagent was aborted"): Promise<void> {
|
|
207
|
-
return this.serialize(async () => {
|
|
208
|
-
this.stopRequested = true;
|
|
209
|
-
this.stopMessage = reason;
|
|
210
|
-
const attempt = this.attempt?.control;
|
|
211
|
-
if (attempt) await attempt.stop(reason);
|
|
212
|
-
this.setPhase("stopped");
|
|
213
|
-
});
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
private setPhase(phase: RpcControlPhase): void {
|
|
217
|
-
if (this.phase === phase) return;
|
|
218
|
-
this.phase = phase;
|
|
219
|
-
try {
|
|
220
|
-
this.onPhase?.(phase);
|
|
221
|
-
} catch {
|
|
222
|
-
/* monitor callbacks must never break control flow */
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
private serialize<T>(operation: () => Promise<T>): Promise<T> {
|
|
227
|
-
const next = this.serial.then(operation, operation);
|
|
228
|
-
this.serial = next.then(
|
|
229
|
-
() => undefined,
|
|
230
|
-
() => undefined,
|
|
231
|
-
);
|
|
232
|
-
return next;
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
export function emptyUsage(): UsageStats {
|
|
237
|
-
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
export function currentSubagentDepth(env: NodeJS.ProcessEnv = process.env): number {
|
|
241
|
-
const raw = env[DEPTH_ENV_VAR];
|
|
242
|
-
const parsed = raw === undefined ? 0 : Number.parseInt(raw, 10);
|
|
243
|
-
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
/** Match pi's `<timestamp>Z_<id>.jsonl` session-file convention. */
|
|
247
|
-
export function sessionExists(sessionDir: string, sessionId: string): boolean {
|
|
248
|
-
try {
|
|
249
|
-
return readdirSync(sessionDir).some((file) => file.endsWith(`_${sessionId}.jsonl`));
|
|
250
|
-
} catch {
|
|
251
|
-
return false;
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/** Resolve how to invoke the same pi build as the current process. */
|
|
256
|
-
export function getPiInvocation(args: string[]): { command: string; args: string[] } {
|
|
257
|
-
const currentScript = process.argv[1];
|
|
258
|
-
const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
|
|
259
|
-
if (currentScript && !isBunVirtualScript && existsSync(currentScript)) {
|
|
260
|
-
return { command: process.execPath, args: [currentScript, ...args] };
|
|
261
|
-
}
|
|
262
|
-
const execName = basename(process.execPath).toLowerCase();
|
|
263
|
-
const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
|
|
264
|
-
if (!isGenericRuntime) return { command: process.execPath, args };
|
|
265
|
-
return { command: "pi", args };
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/** Terminate the child and every tool process in its process tree. */
|
|
269
|
-
export function terminateProcessTree(proc: ChildProcess, force: boolean, processGroup = false): void {
|
|
270
|
-
if (process.platform === "win32" && proc.pid !== undefined) {
|
|
271
|
-
const killer = spawn("taskkill", ["/pid", String(proc.pid), "/t", "/f"], {
|
|
272
|
-
stdio: "ignore",
|
|
273
|
-
windowsHide: true,
|
|
274
|
-
});
|
|
275
|
-
const fallback = (): void => {
|
|
276
|
-
try {
|
|
277
|
-
proc.kill(force ? "SIGKILL" : "SIGTERM");
|
|
278
|
-
} catch {
|
|
279
|
-
/* process may already be gone */
|
|
280
|
-
}
|
|
281
|
-
};
|
|
282
|
-
killer.on("error", fallback);
|
|
283
|
-
killer.on("close", (code) => {
|
|
284
|
-
if (code !== 0) fallback();
|
|
285
|
-
});
|
|
286
|
-
return;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
try {
|
|
290
|
-
if (processGroup && proc.pid !== undefined) {
|
|
291
|
-
// RPC children are spawned as POSIX process-group leaders. Signalling the
|
|
292
|
-
// negative pid reaches Pi and non-detached tool descendants; Pi's SIGTERM
|
|
293
|
-
// handler cleans its own tracked detached children before the hard fallback.
|
|
294
|
-
process.kill(-proc.pid, force ? "SIGKILL" : "SIGTERM");
|
|
295
|
-
} else {
|
|
296
|
-
proc.kill(force ? "SIGKILL" : "SIGTERM");
|
|
297
|
-
}
|
|
298
|
-
} catch {
|
|
299
|
-
/* process may already be gone */
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
export function extractToolErrorText(content: unknown): string {
|
|
304
|
-
const parts = Array.isArray(content) ? content : [];
|
|
305
|
-
const text = parts
|
|
306
|
-
.filter(
|
|
307
|
-
(part): part is { type: "text"; text: string } =>
|
|
308
|
-
typeof part === "object" &&
|
|
309
|
-
part !== null &&
|
|
310
|
-
(part as { type?: unknown }).type === "text" &&
|
|
311
|
-
typeof (part as { text?: unknown }).text === "string",
|
|
312
|
-
)
|
|
313
|
-
.map((part) => part.text)
|
|
314
|
-
.join("\n");
|
|
315
|
-
return text
|
|
316
|
-
.split("\n")
|
|
317
|
-
.map((line) => line.trim())
|
|
318
|
-
.filter(Boolean)
|
|
319
|
-
.slice(-3)
|
|
320
|
-
.map((line) => (line.length > 200 ? `${line.slice(0, 200)}…` : line))
|
|
321
|
-
.join("\n");
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
interface ChildRetryPolicyExtension {
|
|
325
|
-
dir: string;
|
|
326
|
-
filePath: string;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
/** Build a child-only Pi extension that replaces the selected provider's
|
|
330
|
-
* stream adapter with its registered API implementation while forcing
|
|
331
|
-
* maxRetries=0. It uses Pi's public extension and pi-ai compatibility APIs, so
|
|
332
|
-
* it works in Node and standalone/Bun builds without touching user settings. */
|
|
333
|
-
export async function writeChildRetryPolicyExtension(
|
|
334
|
-
scratchRoot: string,
|
|
335
|
-
modelRef?: string,
|
|
336
|
-
): Promise<ChildRetryPolicyExtension> {
|
|
337
|
-
await mkdir(scratchRoot, { recursive: true });
|
|
338
|
-
const dir = await mkdtemp(join(scratchRoot, "pi-subagents-policy-"));
|
|
339
|
-
writeTempOwnerMarker(dir);
|
|
340
|
-
const filePath = join(dir, "no-provider-retries.mjs");
|
|
341
|
-
const slash = modelRef?.indexOf("/") ?? -1;
|
|
342
|
-
const selectedProvider = slash > 0 ? modelRef!.slice(0, slash) : undefined;
|
|
343
|
-
const source = `import { getApiProvider } from "@earendil-works/pi-ai/compat";\n`
|
|
344
|
-
+ `const selectedProvider = ${JSON.stringify(selectedProvider)};\n`
|
|
345
|
-
+ `export default function noProviderRetries(pi) {\n`
|
|
346
|
-
+ ` pi.on("before_provider_request", (_event, ctx) => {\n`
|
|
347
|
-
+ ` const providerId = ctx.model?.provider ?? selectedProvider;\n`
|
|
348
|
-
+ ` if (!providerId) return;\n`
|
|
349
|
-
+ ` pi.registerProvider(providerId, {\n`
|
|
350
|
-
+ ` streamSimple(model, context, options) {\n`
|
|
351
|
-
+ ` const api = getApiProvider(model.api);\n`
|
|
352
|
-
+ ` if (!api) throw new Error(\`No API stream implementation is registered for \${model.api}.\`);\n`
|
|
353
|
-
+ ` return api.streamSimple(model, context, { ...options, maxRetries: 0 });\n`
|
|
354
|
-
+ ` },\n`
|
|
355
|
-
+ ` });\n`
|
|
356
|
-
+ ` });\n`
|
|
357
|
-
+ `}\n`;
|
|
358
|
-
try {
|
|
359
|
-
await writeFile(filePath, source, "utf8");
|
|
360
|
-
return { dir, filePath };
|
|
361
|
-
} catch (error) {
|
|
362
|
-
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
|
363
|
-
throw error;
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
async function writePromptToTempFile(
|
|
368
|
-
scratchRoot: string,
|
|
369
|
-
agentName: string,
|
|
370
|
-
prompt: string,
|
|
371
|
-
): Promise<{ dir: string; filePath: string }> {
|
|
372
|
-
await mkdir(scratchRoot, { recursive: true });
|
|
373
|
-
const dir = await mkdtemp(join(scratchRoot, "pi-subagents-"));
|
|
374
|
-
writeTempOwnerMarker(dir);
|
|
375
|
-
const safeName = agentName.replace(/[^\w.-]+/g, "_");
|
|
376
|
-
const filePath = join(dir, `prompt-${safeName}.md`);
|
|
377
|
-
try {
|
|
378
|
-
await writeFile(filePath, prompt, "utf8");
|
|
379
|
-
return { dir, filePath };
|
|
380
|
-
} catch (error) {
|
|
381
|
-
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
|
382
|
-
throw error;
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
interface RpcResponse {
|
|
387
|
-
id?: string;
|
|
388
|
-
type: "response";
|
|
389
|
-
command: string;
|
|
390
|
-
success: boolean;
|
|
391
|
-
error?: string;
|
|
392
|
-
data?: unknown;
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
class RpcCommandRejectedError extends Error {
|
|
396
|
-
constructor(message: string) {
|
|
397
|
-
super(message);
|
|
398
|
-
this.name = "RpcCommandRejectedError";
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
interface PendingRequest {
|
|
403
|
-
resolve: (response: RpcResponse) => void;
|
|
404
|
-
reject: (error: Error) => void;
|
|
405
|
-
timer?: ReturnType<typeof setTimeout>;
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
interface Deferred<T> {
|
|
409
|
-
promise: Promise<T>;
|
|
410
|
-
resolve(value: T): void;
|
|
411
|
-
reject(error: Error): void;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
function deferred<T>(): Deferred<T> {
|
|
415
|
-
let resolve!: (value: T) => void;
|
|
416
|
-
let reject!: (error: Error) => void;
|
|
417
|
-
const promise = new Promise<T>((res, rej) => {
|
|
418
|
-
resolve = res;
|
|
419
|
-
reject = rej;
|
|
420
|
-
});
|
|
421
|
-
return { promise, resolve, reject };
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
export interface RunRpcAttemptOptions {
|
|
425
|
-
defaultCwd: string;
|
|
426
|
-
agent: AgentConfig;
|
|
427
|
-
agentName: string;
|
|
428
|
-
task: string;
|
|
429
|
-
cwd?: string;
|
|
430
|
-
thinkingLevel: ThinkingLevel;
|
|
431
|
-
idleTimeoutMs: number;
|
|
432
|
-
sessionDir?: string;
|
|
433
|
-
sessionId?: string;
|
|
434
|
-
/** Transient scratch root (child prompt files, retry-policy extension):
|
|
435
|
-
* the project-scoped tmp directory, never the OS temp directory. */
|
|
436
|
-
scratchRoot: string;
|
|
437
|
-
prompt: string;
|
|
438
|
-
signal?: AbortSignal;
|
|
439
|
-
onLive?: (event: SubagentLiveEvent) => void;
|
|
440
|
-
env?: NodeJS.ProcessEnv;
|
|
441
|
-
control?: RpcRunControl;
|
|
442
|
-
rpcReadyTimeoutMs?: number;
|
|
443
|
-
rpcCommandTimeoutMs?: number;
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
/** Run one persistent RPC child until a stable `agent_settled` or control action. */
|
|
447
|
-
export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise<RpcSingleResult> {
|
|
448
|
-
const { agent, agentName, task, thinkingLevel, idleTimeoutMs, signal, onLive, control } = options;
|
|
449
|
-
const args: string[] = ["--mode", "rpc", "--exclude-tools", SUBAGENT_TOOL_NAMES.join(",")];
|
|
450
|
-
if (options.sessionDir && options.sessionId) {
|
|
451
|
-
args.push("--session-dir", options.sessionDir);
|
|
452
|
-
args.push(sessionExists(options.sessionDir, options.sessionId) ? "--session" : "--session-id", options.sessionId);
|
|
453
|
-
} else {
|
|
454
|
-
args.push("--no-session");
|
|
455
|
-
}
|
|
456
|
-
if (agent.model) args.push("--model", agent.model);
|
|
457
|
-
args.push("--thinking", thinkingLevel);
|
|
458
|
-
if (agent.tools) {
|
|
459
|
-
if (agent.tools.length > 0) args.push("--tools", agent.tools.join(","));
|
|
460
|
-
else args.push("--no-tools");
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
let tmpPromptDir: string | null = null;
|
|
464
|
-
let tmpPromptPath: string | null = null;
|
|
465
|
-
if (agent.systemPrompt.trim()) {
|
|
466
|
-
const tmp = await writePromptToTempFile(options.scratchRoot, agent.name, agent.systemPrompt);
|
|
467
|
-
tmpPromptDir = tmp.dir;
|
|
468
|
-
tmpPromptPath = tmp.filePath;
|
|
469
|
-
args.push("--append-system-prompt", tmpPromptPath);
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
let retryPolicy: ChildRetryPolicyExtension;
|
|
473
|
-
try {
|
|
474
|
-
retryPolicy = await writeChildRetryPolicyExtension(options.scratchRoot, agent.model);
|
|
475
|
-
args.push("--extension", retryPolicy.filePath);
|
|
476
|
-
} catch (error) {
|
|
477
|
-
if (tmpPromptDir) await rm(tmpPromptDir, { recursive: true, force: true }).catch(() => undefined);
|
|
478
|
-
throw error;
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
const result: RpcSingleResult = {
|
|
482
|
-
agent: agentName,
|
|
483
|
-
task,
|
|
484
|
-
exitCode: 0,
|
|
485
|
-
messages: [],
|
|
486
|
-
stderr: "",
|
|
487
|
-
usage: emptyUsage(),
|
|
488
|
-
model: agent.model,
|
|
489
|
-
thinking: thinkingLevel,
|
|
490
|
-
sessionId: options.sessionId,
|
|
491
|
-
sessionDir: options.sessionDir,
|
|
492
|
-
};
|
|
493
|
-
|
|
494
|
-
const childDepth = currentSubagentDepth(options.env) + 1;
|
|
495
|
-
const childEnv: NodeJS.ProcessEnv = {
|
|
496
|
-
...(options.env ?? process.env),
|
|
497
|
-
[DEPTH_ENV_VAR]: String(childDepth),
|
|
498
|
-
};
|
|
499
|
-
const invocation = getPiInvocation(args);
|
|
500
|
-
const usePosixProcessGroup = process.platform !== "win32";
|
|
501
|
-
const proc = spawn(invocation.command, invocation.args, {
|
|
502
|
-
cwd: options.cwd ?? options.defaultCwd,
|
|
503
|
-
shell: false,
|
|
504
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
505
|
-
env: childEnv,
|
|
506
|
-
detached: usePosixProcessGroup,
|
|
507
|
-
});
|
|
508
|
-
|
|
509
|
-
const attemptToken = control?.beginAttempt();
|
|
510
|
-
let closed = false;
|
|
511
|
-
let finished = false;
|
|
512
|
-
let requestId = 0;
|
|
513
|
-
let stdoutBuffer = "";
|
|
514
|
-
let lastActivityAt = Date.now();
|
|
515
|
-
let idleTimer: ReturnType<typeof setInterval> | undefined;
|
|
516
|
-
let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
|
|
517
|
-
let abortHandler: (() => void) | undefined;
|
|
518
|
-
let abortSettlement: Deferred<void> | undefined;
|
|
519
|
-
let droppedQueuedCount = 0;
|
|
520
|
-
let initialPromptResolved = false;
|
|
521
|
-
const initialPrompt = deferred<{ accepted: boolean; error?: Error }>();
|
|
522
|
-
const pendingRequests = new Map<string, PendingRequest>();
|
|
523
|
-
const outcome = deferred<void>();
|
|
524
|
-
const processClosed = deferred<void>();
|
|
525
|
-
const stdoutDecoder = new StringDecoder("utf8");
|
|
526
|
-
const stderrDecoder = new StringDecoder("utf8");
|
|
527
|
-
|
|
528
|
-
const emit = (event: SubagentLiveEvent): void => {
|
|
529
|
-
try {
|
|
530
|
-
onLive?.(event);
|
|
531
|
-
} catch {
|
|
532
|
-
/* live observers must never break protocol handling */
|
|
533
|
-
}
|
|
534
|
-
};
|
|
535
|
-
|
|
536
|
-
const setAttemptPhase = (phase: RpcControlPhase): void => {
|
|
537
|
-
if (attemptToken !== undefined) control?.updateAttemptPhase(attemptToken, phase);
|
|
538
|
-
if (phase === "running" || phase === "interrupting") {
|
|
539
|
-
emit({ kind: "status", status: phase });
|
|
540
|
-
}
|
|
541
|
-
};
|
|
542
|
-
|
|
543
|
-
const rejectPending = (error: Error): void => {
|
|
544
|
-
for (const request of pendingRequests.values()) {
|
|
545
|
-
if (request.timer) clearTimeout(request.timer);
|
|
546
|
-
request.reject(error);
|
|
547
|
-
}
|
|
548
|
-
pendingRequests.clear();
|
|
549
|
-
};
|
|
550
|
-
|
|
551
|
-
const finish = (): void => {
|
|
552
|
-
if (finished) return;
|
|
553
|
-
finished = true;
|
|
554
|
-
if (idleTimer) clearInterval(idleTimer);
|
|
555
|
-
if (signal && abortHandler) signal.removeEventListener("abort", abortHandler);
|
|
556
|
-
outcome.resolve();
|
|
557
|
-
};
|
|
558
|
-
|
|
559
|
-
const resolveInitialPrompt = (accepted: boolean, error?: Error): void => {
|
|
560
|
-
if (initialPromptResolved) return;
|
|
561
|
-
initialPromptResolved = true;
|
|
562
|
-
if (accepted) result.rpcPromptAccepted = true;
|
|
563
|
-
initialPrompt.resolve({ accepted, error });
|
|
564
|
-
};
|
|
565
|
-
|
|
566
|
-
const settleRun = (): void => {
|
|
567
|
-
const failed = result.stopReason === "error" || result.stopReason === "aborted";
|
|
568
|
-
result.exitCode = failed ? 1 : 0;
|
|
569
|
-
// RPC settlement only means model transport is quiescent. Dispatch may still
|
|
570
|
-
// be applying an isolated worktree, so it alone publishes the terminal live
|
|
571
|
-
// status after filesystem finalization completes.
|
|
572
|
-
finish();
|
|
573
|
-
};
|
|
574
|
-
|
|
575
|
-
const terminate = (force = false): void => {
|
|
576
|
-
if (closed) return;
|
|
577
|
-
terminateProcessTree(proc, force, usePosixProcessGroup);
|
|
578
|
-
if (!force && !forceKillTimer) {
|
|
579
|
-
forceKillTimer = setTimeout(() => {
|
|
580
|
-
if (!closed) terminateProcessTree(proc, true, usePosixProcessGroup);
|
|
581
|
-
}, SUBAGENT_KILL_GRACE_MS);
|
|
582
|
-
}
|
|
583
|
-
};
|
|
584
|
-
|
|
585
|
-
const readyTimeoutMs = options.rpcReadyTimeoutMs ?? RPC_READY_TIMEOUT_MS;
|
|
586
|
-
const commandTimeoutMs = options.rpcCommandTimeoutMs ?? RPC_COMMAND_TIMEOUT_MS;
|
|
587
|
-
|
|
588
|
-
const writeLine = (value: object): Promise<void> =>
|
|
589
|
-
new Promise((resolve, reject) => {
|
|
590
|
-
if (!proc.stdin || proc.stdin.destroyed || !proc.stdin.writable) {
|
|
591
|
-
reject(new Error("Subagent RPC stdin is not writable."));
|
|
592
|
-
return;
|
|
593
|
-
}
|
|
594
|
-
// JSON strings may contain U+2028/U+2029. Only the final ASCII LF frames a
|
|
595
|
-
// record; never use a generic line reader on the receiving side.
|
|
596
|
-
proc.stdin.write(`${JSON.stringify(value)}\n`, "utf8", (error) => {
|
|
597
|
-
if (error) reject(error);
|
|
598
|
-
else resolve();
|
|
599
|
-
});
|
|
600
|
-
});
|
|
601
|
-
|
|
602
|
-
const send = async (command: Record<string, unknown>, timeoutMs = commandTimeoutMs): Promise<RpcResponse> => {
|
|
603
|
-
if (finished || closed) throw new Error("Subagent RPC process is no longer active.");
|
|
604
|
-
const id = `req_${++requestId}`;
|
|
605
|
-
const payload = { ...command, id };
|
|
606
|
-
return new Promise<RpcResponse>((resolve, reject) => {
|
|
607
|
-
const pending: PendingRequest = { resolve, reject };
|
|
608
|
-
pendingRequests.set(id, pending);
|
|
609
|
-
void writeLine(payload).then(
|
|
610
|
-
() => {
|
|
611
|
-
if (!pendingRequests.has(id)) return;
|
|
612
|
-
pending.timer = setTimeout(() => {
|
|
613
|
-
pendingRequests.delete(id);
|
|
614
|
-
reject(new Error(`Timed out waiting for RPC response to ${String(command.type)}.`));
|
|
615
|
-
}, timeoutMs);
|
|
616
|
-
if (typeof pending.timer.unref === "function") pending.timer.unref();
|
|
617
|
-
},
|
|
618
|
-
(error) => {
|
|
619
|
-
if (!pendingRequests.has(id)) return;
|
|
620
|
-
pendingRequests.delete(id);
|
|
621
|
-
if (pending.timer) clearTimeout(pending.timer);
|
|
622
|
-
reject(error instanceof Error ? error : new Error(String(error)));
|
|
623
|
-
},
|
|
624
|
-
);
|
|
625
|
-
}).then((response) => {
|
|
626
|
-
if (!response.success) {
|
|
627
|
-
throw new RpcCommandRejectedError(response.error || `RPC ${response.command} failed.`);
|
|
628
|
-
}
|
|
629
|
-
return response;
|
|
630
|
-
});
|
|
631
|
-
};
|
|
632
|
-
|
|
633
|
-
const waitForAbortSettlement = (): Deferred<void> => {
|
|
634
|
-
if (abortSettlement) throw new Error("Another RPC abort transition is already in progress.");
|
|
635
|
-
abortSettlement = deferred<void>();
|
|
636
|
-
return abortSettlement;
|
|
637
|
-
};
|
|
638
|
-
|
|
639
|
-
const abortAcceptedPrompt = async (): Promise<boolean> => {
|
|
640
|
-
const acceptance = await initialPrompt.promise;
|
|
641
|
-
if (!acceptance.accepted) return false;
|
|
642
|
-
// Pi continues queued steering/follow-up messages after an abort. Drop
|
|
643
|
-
// them first so a stopped run — or a later resume of its retained
|
|
644
|
-
// thread — cannot be revived by stale queue entries. Best-effort: an
|
|
645
|
-
// older child rejects the command and a hung child falls through to
|
|
646
|
-
// the bounded abort below.
|
|
647
|
-
try {
|
|
648
|
-
const cleared = await send({ type: "clear_queue" }, RPC_CLEAR_QUEUE_TIMEOUT_MS);
|
|
649
|
-
const data = cleared.data as { steering?: unknown; followUp?: unknown } | undefined;
|
|
650
|
-
droppedQueuedCount =
|
|
651
|
-
(Array.isArray(data?.steering) ? data.steering.length : 0) +
|
|
652
|
-
(Array.isArray(data?.followUp) ? data.followUp.length : 0);
|
|
653
|
-
} catch {
|
|
654
|
-
/* abort below is the authoritative stop */
|
|
655
|
-
}
|
|
656
|
-
const stable = waitForAbortSettlement();
|
|
657
|
-
try {
|
|
658
|
-
await Promise.all([send({ type: "abort" }), stable.promise]);
|
|
659
|
-
return true;
|
|
660
|
-
} catch (error) {
|
|
661
|
-
if (abortSettlement === stable) {
|
|
662
|
-
abortSettlement = undefined;
|
|
663
|
-
stable.resolve();
|
|
664
|
-
}
|
|
665
|
-
throw error;
|
|
666
|
-
}
|
|
667
|
-
};
|
|
668
|
-
|
|
669
|
-
const attemptControl: AttemptControl = {
|
|
670
|
-
async stop(reason = "Subagent was aborted"): Promise<void> {
|
|
671
|
-
if (finished) {
|
|
672
|
-
if (!closed) await processClosed.promise;
|
|
673
|
-
return;
|
|
674
|
-
}
|
|
675
|
-
setAttemptPhase("interrupting");
|
|
676
|
-
if (!initialPromptResolved) {
|
|
677
|
-
const stopped = new Error(reason);
|
|
678
|
-
resolveInitialPrompt(false, stopped);
|
|
679
|
-
rejectPending(stopped);
|
|
680
|
-
}
|
|
681
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
682
|
-
const timeout = new Promise<boolean>((resolve) => {
|
|
683
|
-
timer = setTimeout(() => resolve(false), RPC_ABORT_SETTLE_TIMEOUT_MS);
|
|
684
|
-
if (typeof timer.unref === "function") timer.unref();
|
|
685
|
-
});
|
|
686
|
-
try {
|
|
687
|
-
await Promise.race([abortAcceptedPrompt(), timeout]);
|
|
688
|
-
} catch {
|
|
689
|
-
/* process termination below is the bounded fallback */
|
|
690
|
-
} finally {
|
|
691
|
-
if (timer) clearTimeout(timer);
|
|
692
|
-
}
|
|
693
|
-
if (abortSettlement) {
|
|
694
|
-
const stable = abortSettlement;
|
|
695
|
-
abortSettlement = undefined;
|
|
696
|
-
stable.resolve();
|
|
697
|
-
}
|
|
698
|
-
result.exitCode = 1;
|
|
699
|
-
result.stopReason = "aborted";
|
|
700
|
-
result.errorMessage =
|
|
701
|
-
droppedQueuedCount > 0
|
|
702
|
-
? `${reason} — dropped ${droppedQueuedCount} queued steering/follow-up message${droppedQueuedCount === 1 ? "" : "s"}`
|
|
703
|
-
: reason;
|
|
704
|
-
finish();
|
|
705
|
-
// Even when RPC abort/settle times out, give Pi SIGTERM first so its
|
|
706
|
-
// shutdown handler can reap detached tool process groups. terminate()
|
|
707
|
-
// retains the hard-kill timer as the bounded fallback.
|
|
708
|
-
terminate(false);
|
|
709
|
-
if (!closed) await processClosed.promise;
|
|
710
|
-
},
|
|
711
|
-
};
|
|
712
|
-
|
|
713
|
-
if (attemptToken !== undefined) control?.attach(attemptToken, attemptControl);
|
|
714
|
-
if (proc.pid !== undefined) control?.noteChildPid(proc.pid);
|
|
715
|
-
control?.markStarting();
|
|
716
|
-
|
|
717
|
-
const processLine = (rawLine: string): void => {
|
|
718
|
-
let line = rawLine;
|
|
719
|
-
if (line.endsWith("\r")) line = line.slice(0, -1);
|
|
720
|
-
if (!line.trim()) return;
|
|
721
|
-
let event: any;
|
|
722
|
-
try {
|
|
723
|
-
event = JSON.parse(line);
|
|
724
|
-
} catch {
|
|
725
|
-
return;
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
if (event.type === "response" && typeof event.id === "string") {
|
|
729
|
-
const pending = pendingRequests.get(event.id);
|
|
730
|
-
if (pending) {
|
|
731
|
-
pendingRequests.delete(event.id);
|
|
732
|
-
clearTimeout(pending.timer);
|
|
733
|
-
pending.resolve(event as RpcResponse);
|
|
734
|
-
return;
|
|
735
|
-
}
|
|
736
|
-
}
|
|
737
|
-
if (finished) return;
|
|
738
|
-
|
|
739
|
-
if (
|
|
740
|
-
[
|
|
741
|
-
"agent_start",
|
|
742
|
-
"agent_end",
|
|
743
|
-
"turn_start",
|
|
744
|
-
"turn_end",
|
|
745
|
-
"message_start",
|
|
746
|
-
"message_update",
|
|
747
|
-
"message_end",
|
|
748
|
-
"tool_execution_start",
|
|
749
|
-
"tool_execution_update",
|
|
750
|
-
"tool_execution_end",
|
|
751
|
-
"auto_retry_start",
|
|
752
|
-
"auto_retry_end",
|
|
753
|
-
"agent_settled",
|
|
754
|
-
].includes(event.type)
|
|
755
|
-
) {
|
|
756
|
-
result.rpcActivity = true;
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
// Let Pi's outer turn retry run. Grok/xAI long streams commonly drop with
|
|
760
|
-
// a retryable `terminated` mid-turn; aborting that retry was misread as
|
|
761
|
-
// "model unavailable" and handed a still-working model back to the parent.
|
|
762
|
-
// After retries exhaust, dispatch still classifies a settled model-level
|
|
763
|
-
// failure and hands off.
|
|
764
|
-
|
|
765
|
-
// Child RPC mode exposes extension dialogs. Sub-agents are non-interactive:
|
|
766
|
-
// cancel blocking dialogs so an unrelated child extension cannot deadlock.
|
|
767
|
-
if (
|
|
768
|
-
event.type === "extension_ui_request" &&
|
|
769
|
-
typeof event.id === "string" &&
|
|
770
|
-
["select", "confirm", "input", "editor"].includes(event.method)
|
|
771
|
-
) {
|
|
772
|
-
void writeLine({ type: "extension_ui_response", id: event.id, cancelled: true }).catch(() => undefined);
|
|
773
|
-
return;
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
if (event.type === "agent_start") {
|
|
777
|
-
resolveInitialPrompt(true);
|
|
778
|
-
setAttemptPhase("running");
|
|
779
|
-
emit({ kind: "status", status: "running" });
|
|
780
|
-
}
|
|
781
|
-
if (event.type === "turn_start") {
|
|
782
|
-
setAttemptPhase("running");
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
if (event.type === "message_update") {
|
|
786
|
-
const type = event.assistantMessageEvent?.type;
|
|
787
|
-
if (type === "thinking_delta" || type === "text_delta") {
|
|
788
|
-
emit({ kind: type === "thinking_delta" ? "thinking" : "text" });
|
|
789
|
-
}
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
if (event.type === "tool_execution_start") {
|
|
793
|
-
emit({
|
|
794
|
-
kind: "tool_start",
|
|
795
|
-
...(typeof event.toolCallId === "string" ? { toolCallId: event.toolCallId } : {}),
|
|
796
|
-
toolName: event.toolName ?? "unknown",
|
|
797
|
-
args: event.args,
|
|
798
|
-
});
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
if (event.type === "tool_execution_end") {
|
|
802
|
-
emit({
|
|
803
|
-
kind: "tool_end",
|
|
804
|
-
...(typeof event.toolCallId === "string" ? { toolCallId: event.toolCallId } : {}),
|
|
805
|
-
toolName: event.toolName ?? "unknown",
|
|
806
|
-
isError: Boolean(event.isError),
|
|
807
|
-
});
|
|
808
|
-
if (event.isError) {
|
|
809
|
-
(result.failedTools ??= []).push({
|
|
810
|
-
toolName: event.toolName ?? "unknown",
|
|
811
|
-
error: extractToolErrorText(event.result?.content),
|
|
812
|
-
});
|
|
813
|
-
}
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
if (event.type === "message_end" && event.message) {
|
|
817
|
-
const message = event.message as Message;
|
|
818
|
-
result.messages.push(message);
|
|
819
|
-
if (message.role === "assistant") {
|
|
820
|
-
result.usage.turns++;
|
|
821
|
-
const usage = (message as any).usage;
|
|
822
|
-
if (usage) {
|
|
823
|
-
result.usage.input += usage.input || 0;
|
|
824
|
-
result.usage.output += usage.output || 0;
|
|
825
|
-
result.usage.cacheRead += usage.cacheRead || 0;
|
|
826
|
-
result.usage.cacheWrite += usage.cacheWrite || 0;
|
|
827
|
-
result.usage.cost += usage.cost?.total || 0;
|
|
828
|
-
result.usage.contextTokens = usage.totalTokens || 0;
|
|
829
|
-
}
|
|
830
|
-
if (!result.model && (message as any).model) result.model = (message as any).model;
|
|
831
|
-
if ((message as any).stopReason) result.stopReason = (message as any).stopReason;
|
|
832
|
-
if ((message as any).errorMessage) result.errorMessage = (message as any).errorMessage;
|
|
833
|
-
}
|
|
834
|
-
emit({ kind: "usage", usage: { ...result.usage }, model: result.model });
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
if (event.type === "agent_settled") {
|
|
838
|
-
if (abortSettlement) {
|
|
839
|
-
const stable = abortSettlement;
|
|
840
|
-
abortSettlement = undefined;
|
|
841
|
-
stable.resolve();
|
|
842
|
-
return;
|
|
843
|
-
}
|
|
844
|
-
settleRun();
|
|
845
|
-
}
|
|
846
|
-
};
|
|
847
|
-
|
|
848
|
-
proc.stdout?.on("data", (chunk: Buffer | string) => {
|
|
849
|
-
lastActivityAt = Date.now();
|
|
850
|
-
stdoutBuffer += typeof chunk === "string" ? chunk : stdoutDecoder.write(chunk);
|
|
851
|
-
while (true) {
|
|
852
|
-
const lf = stdoutBuffer.indexOf("\n");
|
|
853
|
-
if (lf === -1) break;
|
|
854
|
-
const line = stdoutBuffer.slice(0, lf);
|
|
855
|
-
stdoutBuffer = stdoutBuffer.slice(lf + 1);
|
|
856
|
-
processLine(line);
|
|
857
|
-
}
|
|
858
|
-
});
|
|
859
|
-
|
|
860
|
-
proc.stderr?.on("data", (chunk: Buffer | string) => {
|
|
861
|
-
result.stderr += typeof chunk === "string" ? chunk : stderrDecoder.write(chunk);
|
|
862
|
-
});
|
|
863
|
-
|
|
864
|
-
proc.stdin?.on("error", (error) => {
|
|
865
|
-
if (finished) return;
|
|
866
|
-
resolveInitialPrompt(false, error);
|
|
867
|
-
result.exitCode = 1;
|
|
868
|
-
result.stopReason = "error";
|
|
869
|
-
result.errorMessage ??= `Subagent RPC stdin failed: ${error.message}`;
|
|
870
|
-
result.dispatchFailed = true;
|
|
871
|
-
finish();
|
|
872
|
-
terminate();
|
|
873
|
-
});
|
|
874
|
-
|
|
875
|
-
proc.once("error", (error) => {
|
|
876
|
-
if (finished) return;
|
|
877
|
-
resolveInitialPrompt(false, error);
|
|
878
|
-
result.exitCode = 1;
|
|
879
|
-
result.stopReason = "error";
|
|
880
|
-
result.errorMessage ??= `Failed to start the sub-agent process: ${error.message}`;
|
|
881
|
-
result.dispatchFailed = true;
|
|
882
|
-
finish();
|
|
883
|
-
});
|
|
884
|
-
|
|
885
|
-
proc.once("close", (code) => {
|
|
886
|
-
closed = true;
|
|
887
|
-
resolveInitialPrompt(false, new Error(`Subagent RPC process exited before the initial prompt was accepted (code=${code ?? "signal"}).`));
|
|
888
|
-
if (forceKillTimer) clearTimeout(forceKillTimer);
|
|
889
|
-
stdoutBuffer += stdoutDecoder.end();
|
|
890
|
-
result.stderr += stderrDecoder.end();
|
|
891
|
-
if (stdoutBuffer.length > 0) processLine(stdoutBuffer);
|
|
892
|
-
const exitError = new Error(
|
|
893
|
-
`Subagent RPC process exited before settling (code=${code ?? "signal"}).${result.stderr ? ` ${result.stderr.trim()}` : ""}`,
|
|
894
|
-
);
|
|
895
|
-
rejectPending(exitError);
|
|
896
|
-
if (abortSettlement) {
|
|
897
|
-
abortSettlement.reject(exitError);
|
|
898
|
-
abortSettlement = undefined;
|
|
899
|
-
}
|
|
900
|
-
if (!finished) {
|
|
901
|
-
result.exitCode = code === 0 ? 1 : (code ?? 1);
|
|
902
|
-
result.stopReason ??= signal?.aborted ? "aborted" : "error";
|
|
903
|
-
if (signal?.aborted) result.errorMessage ??= "Subagent was aborted";
|
|
904
|
-
finish();
|
|
905
|
-
}
|
|
906
|
-
processClosed.resolve();
|
|
907
|
-
});
|
|
908
|
-
|
|
909
|
-
if (idleTimeoutMs > 0) {
|
|
910
|
-
const checkInterval = Math.max(1, Math.min(10_000, Math.floor(idleTimeoutMs / 3)));
|
|
911
|
-
idleTimer = setInterval(() => {
|
|
912
|
-
if (finished || closed) return;
|
|
913
|
-
if (Date.now() - lastActivityAt >= idleTimeoutMs) {
|
|
914
|
-
result.exitCode = 1;
|
|
915
|
-
result.stopReason = "error";
|
|
916
|
-
result.errorMessage = `Subagent idle timeout: no activity for ${Math.ceil(idleTimeoutMs / 1000)} seconds.`;
|
|
917
|
-
finish();
|
|
918
|
-
terminate();
|
|
919
|
-
}
|
|
920
|
-
}, checkInterval);
|
|
921
|
-
}
|
|
922
|
-
|
|
923
|
-
if (signal) {
|
|
924
|
-
abortHandler = () => {
|
|
925
|
-
void attemptControl.stop("Subagent was aborted").catch(() => undefined);
|
|
926
|
-
};
|
|
927
|
-
if (signal.aborted) abortHandler();
|
|
928
|
-
else signal.addEventListener("abort", abortHandler, { once: true });
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
try {
|
|
932
|
-
if (control?.isStopRequested()) {
|
|
933
|
-
resolveInitialPrompt(false, new Error("Run was stopped before its initial prompt."));
|
|
934
|
-
await attemptControl.stop();
|
|
935
|
-
} else {
|
|
936
|
-
const failBeforePrompt = (error: Error, startup: boolean): void => {
|
|
937
|
-
resolveInitialPrompt(false, error);
|
|
938
|
-
if (finished) return;
|
|
939
|
-
result.exitCode = 1;
|
|
940
|
-
result.stopReason = "error";
|
|
941
|
-
result.errorMessage = error.message;
|
|
942
|
-
if (startup) result.rpcStartupFailed = true;
|
|
943
|
-
else if (error instanceof RpcCommandRejectedError) result.rpcPromptRejected = true;
|
|
944
|
-
finish();
|
|
945
|
-
terminate();
|
|
946
|
-
};
|
|
947
|
-
try {
|
|
948
|
-
await send({ type: "get_state" }, readyTimeoutMs);
|
|
949
|
-
} catch (error) {
|
|
950
|
-
const handshakeError = error instanceof Error ? error : new Error(String(error));
|
|
951
|
-
if (!control?.isStopRequested()) {
|
|
952
|
-
failBeforePrompt(handshakeError, true);
|
|
953
|
-
} else {
|
|
954
|
-
resolveInitialPrompt(false, handshakeError);
|
|
955
|
-
}
|
|
956
|
-
}
|
|
957
|
-
if (!finished && !initialPromptResolved && !control?.isStopRequested()) {
|
|
958
|
-
// Pi starts the agent immediately after prompt preflight, before its
|
|
959
|
-
// success response necessarily reaches stdout. From this point on, a
|
|
960
|
-
// missing ACK is ambiguous and must never be recovered by replay.
|
|
961
|
-
result.rpcPromptDispatched = true;
|
|
962
|
-
void send({ type: "prompt", message: asPlainTextRpcPrompt(options.prompt) }).then(
|
|
963
|
-
() => resolveInitialPrompt(true),
|
|
964
|
-
(error) => {
|
|
965
|
-
const promptError = error instanceof Error ? error : new Error(String(error));
|
|
966
|
-
failBeforePrompt(promptError, false);
|
|
967
|
-
},
|
|
968
|
-
);
|
|
969
|
-
}
|
|
970
|
-
}
|
|
971
|
-
await outcome.promise;
|
|
972
|
-
terminate();
|
|
973
|
-
if (!closed) await processClosed.promise;
|
|
974
|
-
return result;
|
|
975
|
-
} finally {
|
|
976
|
-
if (attemptToken !== undefined) control?.detach(attemptToken);
|
|
977
|
-
if (tmpPromptPath) {
|
|
978
|
-
try {
|
|
979
|
-
unlinkSync(tmpPromptPath);
|
|
980
|
-
} catch {
|
|
981
|
-
/* ignore */
|
|
982
|
-
}
|
|
983
|
-
}
|
|
984
|
-
if (tmpPromptDir) {
|
|
985
|
-
try {
|
|
986
|
-
rmdirSync(tmpPromptDir);
|
|
987
|
-
} catch {
|
|
988
|
-
/* ignore */
|
|
989
|
-
}
|
|
990
|
-
}
|
|
991
|
-
await rm(retryPolicy.dir, { recursive: true, force: true }).catch(() => undefined);
|
|
992
|
-
}
|
|
993
|
-
}
|
|
1
|
+
/*
|
|
2
|
+
* Persistent pi RPC child transport for one logical sub-agent generation.
|
|
3
|
+
*
|
|
4
|
+
* A child stays alive across prompt/abort operations and speaks
|
|
5
|
+
* strict LF-delimited JSONL. The process is terminated only after the logical
|
|
6
|
+
* run settles, is parked/stopped, or fails. Session files remain owned by the
|
|
7
|
+
* parent runtime so a later generation can resume the same thread.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
11
|
+
import { existsSync, readdirSync, unlinkSync, rmdirSync } from "node:fs";
|
|
12
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
13
|
+
import { basename, join } from "node:path";
|
|
14
|
+
import { StringDecoder } from "node:string_decoder";
|
|
15
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
16
|
+
import { SUBAGENT_TOOL_NAMES, type AgentConfig } from "./agents.ts";
|
|
17
|
+
import type { ThinkingLevel } from "./config.ts";
|
|
18
|
+
import { writeTempOwnerMarker } from "./temp-hygiene.ts";
|
|
19
|
+
import type { IsolationMode, WorktreeFinalizationStatus } from "./worktree.ts";
|
|
20
|
+
|
|
21
|
+
export const DEPTH_ENV_VAR = "PI_SUBAGENT_DEPTH";
|
|
22
|
+
export const SUBAGENT_KILL_GRACE_MS = 5_000;
|
|
23
|
+
/** ACK budget after the child is known to be reading RPC. */
|
|
24
|
+
export const RPC_COMMAND_TIMEOUT_MS = 30_000;
|
|
25
|
+
|
|
26
|
+
/** clear_queue is stop-path hygiene ahead of the abort: give it its own short
|
|
27
|
+
* budget so a hung response cannot eat into the abort-settle window. */
|
|
28
|
+
const RPC_CLEAR_QUEUE_TIMEOUT_MS = 2_000;
|
|
29
|
+
/** Time allowed for the child to boot and answer get_state. */
|
|
30
|
+
export const RPC_READY_TIMEOUT_MS = 60_000;
|
|
31
|
+
export const RPC_ABORT_SETTLE_TIMEOUT_MS = 5_000;
|
|
32
|
+
|
|
33
|
+
export function isRpcCommandTimeoutError(message?: string): boolean {
|
|
34
|
+
return typeof message === "string" && message.includes("Timed out waiting for RPC response");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Prevent RPC prompt expansion when a control objective itself starts with
|
|
38
|
+
* slash (for example `/subagents-setup`). The original text stays verbatim
|
|
39
|
+
* below a non-command prefix and therefore always starts a model turn. */
|
|
40
|
+
export function asPlainTextRpcPrompt(message: string): string {
|
|
41
|
+
if (!message.trimStart().startsWith("/")) return message;
|
|
42
|
+
return `Treat the following as plain-text sub-agent instructions, not a Pi command:\n\n${message}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface UsageStats {
|
|
46
|
+
input: number;
|
|
47
|
+
output: number;
|
|
48
|
+
cacheRead: number;
|
|
49
|
+
cacheWrite: number;
|
|
50
|
+
cost: number;
|
|
51
|
+
contextTokens: number;
|
|
52
|
+
turns: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface RpcSingleResult {
|
|
56
|
+
agent: string;
|
|
57
|
+
task: string;
|
|
58
|
+
exitCode: number;
|
|
59
|
+
messages: Message[];
|
|
60
|
+
stderr: string;
|
|
61
|
+
usage: UsageStats;
|
|
62
|
+
model?: string;
|
|
63
|
+
thinking?: string;
|
|
64
|
+
stopReason?: string;
|
|
65
|
+
errorMessage?: string;
|
|
66
|
+
/** Selected model when this result handed off to the current main model. */
|
|
67
|
+
modelFallbackFrom?: string;
|
|
68
|
+
dispatchFailed?: boolean;
|
|
69
|
+
/** An accepted generation failed because an RPC prompt was rejected before
|
|
70
|
+
* model execution. This remains main-model handoff eligible even when an
|
|
71
|
+
* earlier, aborted objective left assistant text in the session. */
|
|
72
|
+
rpcPromptRejected?: boolean;
|
|
73
|
+
/** Startup handshake failed before the initial prompt was dispatched. This
|
|
74
|
+
* transport miss is safe to retry and is not a model/provider failure. */
|
|
75
|
+
rpcStartupFailed?: boolean;
|
|
76
|
+
/** The parent dispatched the initial prompt command. Until its response is
|
|
77
|
+
* observed, Pi may already be running it, so startup retries must not replay it. */
|
|
78
|
+
rpcPromptDispatched?: boolean;
|
|
79
|
+
/** The child confirmed prompt acceptance (or emitted agent activity). */
|
|
80
|
+
rpcPromptAccepted?: boolean;
|
|
81
|
+
/** Pi emitted agent/turn/model/tool activity for this attempt. */
|
|
82
|
+
rpcActivity?: boolean;
|
|
83
|
+
startupRetries?: number;
|
|
84
|
+
failedTools?: Array<{ toolName: string; error: string }>;
|
|
85
|
+
sessionId?: string;
|
|
86
|
+
sessionDir?: string;
|
|
87
|
+
/** Original task/project cwd used for result-artifact retention buckets. */
|
|
88
|
+
projectCwd?: string;
|
|
89
|
+
/** Stable logical run id assigned by dispatch (also present on queued results). */
|
|
90
|
+
runId?: number;
|
|
91
|
+
/** Filesystem isolation selected for this logical thread. */
|
|
92
|
+
isolation?: IsolationMode;
|
|
93
|
+
/** Final integration state for a worktree-isolated settlement. */
|
|
94
|
+
integrationStatus?: "pending" | WorktreeFinalizationStatus;
|
|
95
|
+
integrationApplied?: boolean;
|
|
96
|
+
integrationError?: string;
|
|
97
|
+
/** Retained only when integration/cleanup failed; never contains patch data. */
|
|
98
|
+
integrationWorktreePath?: string;
|
|
99
|
+
integrationPatchPath?: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export type SubagentLiveEvent =
|
|
103
|
+
| { kind: "status"; status: "queued" | "running" | "interrupting" | "done" | "failed" }
|
|
104
|
+
| { kind: "model"; model?: string; thinking?: ThinkingLevel; fallbackFrom?: string }
|
|
105
|
+
| { kind: "usage"; usage: UsageStats; model?: string }
|
|
106
|
+
| { kind: "session"; sessionId: string; sessionDir: string }
|
|
107
|
+
| { kind: "tool_start"; toolCallId?: string; toolName: string; args: unknown }
|
|
108
|
+
| { kind: "tool_end"; toolCallId?: string; toolName: string; isError: boolean }
|
|
109
|
+
| { kind: "thinking" }
|
|
110
|
+
| { kind: "text" };
|
|
111
|
+
|
|
112
|
+
export type RpcControlPhase =
|
|
113
|
+
| "queued"
|
|
114
|
+
| "starting"
|
|
115
|
+
| "running"
|
|
116
|
+
| "interrupting"
|
|
117
|
+
| "retrying"
|
|
118
|
+
| "settled"
|
|
119
|
+
| "stopped";
|
|
120
|
+
|
|
121
|
+
interface AttemptControl {
|
|
122
|
+
stop(reason?: string): Promise<void>;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Stable control surface for a logical run generation. Startup/main-handoff attempts
|
|
127
|
+
* attach and detach beneath it, so callers never retain a stale child handle.
|
|
128
|
+
* Control calls are serialized to prevent overlapping abort/settle/prompt flows.
|
|
129
|
+
*/
|
|
130
|
+
export class RpcRunControl {
|
|
131
|
+
private objective: string;
|
|
132
|
+
private phase: RpcControlPhase = "queued";
|
|
133
|
+
private attempt?: { token: number; control: AttemptControl };
|
|
134
|
+
private nextToken = 1;
|
|
135
|
+
private serial: Promise<void> = Promise.resolve();
|
|
136
|
+
private stopRequested = false;
|
|
137
|
+
private stopMessage = "Subagent was aborted";
|
|
138
|
+
private childPids = new Set<number>();
|
|
139
|
+
|
|
140
|
+
constructor(
|
|
141
|
+
objective: string,
|
|
142
|
+
readonly generation: number,
|
|
143
|
+
private readonly onPhase?: (phase: RpcControlPhase) => void,
|
|
144
|
+
) {
|
|
145
|
+
this.objective = objective;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
getObjective(): string {
|
|
149
|
+
return this.objective;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
getPhase(): RpcControlPhase {
|
|
153
|
+
return this.phase;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
isStopRequested(): boolean {
|
|
157
|
+
return this.stopRequested;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
getStopMessage(): string {
|
|
161
|
+
return this.stopMessage;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Pids of every child process this generation spawned. Persisted with the
|
|
165
|
+
* thread record so a later load can kill orphans that still hold the
|
|
166
|
+
* retained session. */
|
|
167
|
+
noteChildPid(pid: number): void {
|
|
168
|
+
if (Number.isInteger(pid) && pid > 0) this.childPids.add(pid);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
getChildPids(): number[] {
|
|
172
|
+
return [...this.childPids];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
markStarting(): void {
|
|
176
|
+
this.setPhase("starting");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
markRetrying(): void {
|
|
180
|
+
if (!this.stopRequested) this.setPhase("retrying");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
markSettled(): void {
|
|
184
|
+
this.attempt = undefined;
|
|
185
|
+
if (!this.stopRequested) this.setPhase("settled");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Allocate an attempt token used to reject state updates from old children. */
|
|
189
|
+
beginAttempt(): number {
|
|
190
|
+
return this.nextToken++;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
attach(token: number, control: AttemptControl): void {
|
|
194
|
+
this.attempt = { token, control };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
detach(token: number): void {
|
|
198
|
+
if (this.attempt?.token === token) this.attempt = undefined;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
updateAttemptPhase(token: number, phase: RpcControlPhase): void {
|
|
202
|
+
if (this.attempt?.token !== token) return;
|
|
203
|
+
this.setPhase(phase);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async stop(reason = "Subagent was aborted"): Promise<void> {
|
|
207
|
+
return this.serialize(async () => {
|
|
208
|
+
this.stopRequested = true;
|
|
209
|
+
this.stopMessage = reason;
|
|
210
|
+
const attempt = this.attempt?.control;
|
|
211
|
+
if (attempt) await attempt.stop(reason);
|
|
212
|
+
this.setPhase("stopped");
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private setPhase(phase: RpcControlPhase): void {
|
|
217
|
+
if (this.phase === phase) return;
|
|
218
|
+
this.phase = phase;
|
|
219
|
+
try {
|
|
220
|
+
this.onPhase?.(phase);
|
|
221
|
+
} catch {
|
|
222
|
+
/* monitor callbacks must never break control flow */
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private serialize<T>(operation: () => Promise<T>): Promise<T> {
|
|
227
|
+
const next = this.serial.then(operation, operation);
|
|
228
|
+
this.serial = next.then(
|
|
229
|
+
() => undefined,
|
|
230
|
+
() => undefined,
|
|
231
|
+
);
|
|
232
|
+
return next;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function emptyUsage(): UsageStats {
|
|
237
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function currentSubagentDepth(env: NodeJS.ProcessEnv = process.env): number {
|
|
241
|
+
const raw = env[DEPTH_ENV_VAR];
|
|
242
|
+
const parsed = raw === undefined ? 0 : Number.parseInt(raw, 10);
|
|
243
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Match pi's `<timestamp>Z_<id>.jsonl` session-file convention. */
|
|
247
|
+
export function sessionExists(sessionDir: string, sessionId: string): boolean {
|
|
248
|
+
try {
|
|
249
|
+
return readdirSync(sessionDir).some((file) => file.endsWith(`_${sessionId}.jsonl`));
|
|
250
|
+
} catch {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Resolve how to invoke the same pi build as the current process. */
|
|
256
|
+
export function getPiInvocation(args: string[]): { command: string; args: string[] } {
|
|
257
|
+
const currentScript = process.argv[1];
|
|
258
|
+
const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
|
|
259
|
+
if (currentScript && !isBunVirtualScript && existsSync(currentScript)) {
|
|
260
|
+
return { command: process.execPath, args: [currentScript, ...args] };
|
|
261
|
+
}
|
|
262
|
+
const execName = basename(process.execPath).toLowerCase();
|
|
263
|
+
const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
|
|
264
|
+
if (!isGenericRuntime) return { command: process.execPath, args };
|
|
265
|
+
return { command: "pi", args };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Terminate the child and every tool process in its process tree. */
|
|
269
|
+
export function terminateProcessTree(proc: ChildProcess, force: boolean, processGroup = false): void {
|
|
270
|
+
if (process.platform === "win32" && proc.pid !== undefined) {
|
|
271
|
+
const killer = spawn("taskkill", ["/pid", String(proc.pid), "/t", "/f"], {
|
|
272
|
+
stdio: "ignore",
|
|
273
|
+
windowsHide: true,
|
|
274
|
+
});
|
|
275
|
+
const fallback = (): void => {
|
|
276
|
+
try {
|
|
277
|
+
proc.kill(force ? "SIGKILL" : "SIGTERM");
|
|
278
|
+
} catch {
|
|
279
|
+
/* process may already be gone */
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
killer.on("error", fallback);
|
|
283
|
+
killer.on("close", (code) => {
|
|
284
|
+
if (code !== 0) fallback();
|
|
285
|
+
});
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
try {
|
|
290
|
+
if (processGroup && proc.pid !== undefined) {
|
|
291
|
+
// RPC children are spawned as POSIX process-group leaders. Signalling the
|
|
292
|
+
// negative pid reaches Pi and non-detached tool descendants; Pi's SIGTERM
|
|
293
|
+
// handler cleans its own tracked detached children before the hard fallback.
|
|
294
|
+
process.kill(-proc.pid, force ? "SIGKILL" : "SIGTERM");
|
|
295
|
+
} else {
|
|
296
|
+
proc.kill(force ? "SIGKILL" : "SIGTERM");
|
|
297
|
+
}
|
|
298
|
+
} catch {
|
|
299
|
+
/* process may already be gone */
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function extractToolErrorText(content: unknown): string {
|
|
304
|
+
const parts = Array.isArray(content) ? content : [];
|
|
305
|
+
const text = parts
|
|
306
|
+
.filter(
|
|
307
|
+
(part): part is { type: "text"; text: string } =>
|
|
308
|
+
typeof part === "object" &&
|
|
309
|
+
part !== null &&
|
|
310
|
+
(part as { type?: unknown }).type === "text" &&
|
|
311
|
+
typeof (part as { text?: unknown }).text === "string",
|
|
312
|
+
)
|
|
313
|
+
.map((part) => part.text)
|
|
314
|
+
.join("\n");
|
|
315
|
+
return text
|
|
316
|
+
.split("\n")
|
|
317
|
+
.map((line) => line.trim())
|
|
318
|
+
.filter(Boolean)
|
|
319
|
+
.slice(-3)
|
|
320
|
+
.map((line) => (line.length > 200 ? `${line.slice(0, 200)}…` : line))
|
|
321
|
+
.join("\n");
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
interface ChildRetryPolicyExtension {
|
|
325
|
+
dir: string;
|
|
326
|
+
filePath: string;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Build a child-only Pi extension that replaces the selected provider's
|
|
330
|
+
* stream adapter with its registered API implementation while forcing
|
|
331
|
+
* maxRetries=0. It uses Pi's public extension and pi-ai compatibility APIs, so
|
|
332
|
+
* it works in Node and standalone/Bun builds without touching user settings. */
|
|
333
|
+
export async function writeChildRetryPolicyExtension(
|
|
334
|
+
scratchRoot: string,
|
|
335
|
+
modelRef?: string,
|
|
336
|
+
): Promise<ChildRetryPolicyExtension> {
|
|
337
|
+
await mkdir(scratchRoot, { recursive: true });
|
|
338
|
+
const dir = await mkdtemp(join(scratchRoot, "pi-subagents-policy-"));
|
|
339
|
+
writeTempOwnerMarker(dir);
|
|
340
|
+
const filePath = join(dir, "no-provider-retries.mjs");
|
|
341
|
+
const slash = modelRef?.indexOf("/") ?? -1;
|
|
342
|
+
const selectedProvider = slash > 0 ? modelRef!.slice(0, slash) : undefined;
|
|
343
|
+
const source = `import { getApiProvider } from "@earendil-works/pi-ai/compat";\n`
|
|
344
|
+
+ `const selectedProvider = ${JSON.stringify(selectedProvider)};\n`
|
|
345
|
+
+ `export default function noProviderRetries(pi) {\n`
|
|
346
|
+
+ ` pi.on("before_provider_request", (_event, ctx) => {\n`
|
|
347
|
+
+ ` const providerId = ctx.model?.provider ?? selectedProvider;\n`
|
|
348
|
+
+ ` if (!providerId) return;\n`
|
|
349
|
+
+ ` pi.registerProvider(providerId, {\n`
|
|
350
|
+
+ ` streamSimple(model, context, options) {\n`
|
|
351
|
+
+ ` const api = getApiProvider(model.api);\n`
|
|
352
|
+
+ ` if (!api) throw new Error(\`No API stream implementation is registered for \${model.api}.\`);\n`
|
|
353
|
+
+ ` return api.streamSimple(model, context, { ...options, maxRetries: 0 });\n`
|
|
354
|
+
+ ` },\n`
|
|
355
|
+
+ ` });\n`
|
|
356
|
+
+ ` });\n`
|
|
357
|
+
+ `}\n`;
|
|
358
|
+
try {
|
|
359
|
+
await writeFile(filePath, source, "utf8");
|
|
360
|
+
return { dir, filePath };
|
|
361
|
+
} catch (error) {
|
|
362
|
+
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
|
363
|
+
throw error;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function writePromptToTempFile(
|
|
368
|
+
scratchRoot: string,
|
|
369
|
+
agentName: string,
|
|
370
|
+
prompt: string,
|
|
371
|
+
): Promise<{ dir: string; filePath: string }> {
|
|
372
|
+
await mkdir(scratchRoot, { recursive: true });
|
|
373
|
+
const dir = await mkdtemp(join(scratchRoot, "pi-subagents-"));
|
|
374
|
+
writeTempOwnerMarker(dir);
|
|
375
|
+
const safeName = agentName.replace(/[^\w.-]+/g, "_");
|
|
376
|
+
const filePath = join(dir, `prompt-${safeName}.md`);
|
|
377
|
+
try {
|
|
378
|
+
await writeFile(filePath, prompt, "utf8");
|
|
379
|
+
return { dir, filePath };
|
|
380
|
+
} catch (error) {
|
|
381
|
+
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
|
382
|
+
throw error;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
interface RpcResponse {
|
|
387
|
+
id?: string;
|
|
388
|
+
type: "response";
|
|
389
|
+
command: string;
|
|
390
|
+
success: boolean;
|
|
391
|
+
error?: string;
|
|
392
|
+
data?: unknown;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
class RpcCommandRejectedError extends Error {
|
|
396
|
+
constructor(message: string) {
|
|
397
|
+
super(message);
|
|
398
|
+
this.name = "RpcCommandRejectedError";
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
interface PendingRequest {
|
|
403
|
+
resolve: (response: RpcResponse) => void;
|
|
404
|
+
reject: (error: Error) => void;
|
|
405
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
interface Deferred<T> {
|
|
409
|
+
promise: Promise<T>;
|
|
410
|
+
resolve(value: T): void;
|
|
411
|
+
reject(error: Error): void;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function deferred<T>(): Deferred<T> {
|
|
415
|
+
let resolve!: (value: T) => void;
|
|
416
|
+
let reject!: (error: Error) => void;
|
|
417
|
+
const promise = new Promise<T>((res, rej) => {
|
|
418
|
+
resolve = res;
|
|
419
|
+
reject = rej;
|
|
420
|
+
});
|
|
421
|
+
return { promise, resolve, reject };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export interface RunRpcAttemptOptions {
|
|
425
|
+
defaultCwd: string;
|
|
426
|
+
agent: AgentConfig;
|
|
427
|
+
agentName: string;
|
|
428
|
+
task: string;
|
|
429
|
+
cwd?: string;
|
|
430
|
+
thinkingLevel: ThinkingLevel;
|
|
431
|
+
idleTimeoutMs: number;
|
|
432
|
+
sessionDir?: string;
|
|
433
|
+
sessionId?: string;
|
|
434
|
+
/** Transient scratch root (child prompt files, retry-policy extension):
|
|
435
|
+
* the project-scoped tmp directory, never the OS temp directory. */
|
|
436
|
+
scratchRoot: string;
|
|
437
|
+
prompt: string;
|
|
438
|
+
signal?: AbortSignal;
|
|
439
|
+
onLive?: (event: SubagentLiveEvent) => void;
|
|
440
|
+
env?: NodeJS.ProcessEnv;
|
|
441
|
+
control?: RpcRunControl;
|
|
442
|
+
rpcReadyTimeoutMs?: number;
|
|
443
|
+
rpcCommandTimeoutMs?: number;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Run one persistent RPC child until a stable `agent_settled` or control action. */
|
|
447
|
+
export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise<RpcSingleResult> {
|
|
448
|
+
const { agent, agentName, task, thinkingLevel, idleTimeoutMs, signal, onLive, control } = options;
|
|
449
|
+
const args: string[] = ["--mode", "rpc", "--exclude-tools", SUBAGENT_TOOL_NAMES.join(",")];
|
|
450
|
+
if (options.sessionDir && options.sessionId) {
|
|
451
|
+
args.push("--session-dir", options.sessionDir);
|
|
452
|
+
args.push(sessionExists(options.sessionDir, options.sessionId) ? "--session" : "--session-id", options.sessionId);
|
|
453
|
+
} else {
|
|
454
|
+
args.push("--no-session");
|
|
455
|
+
}
|
|
456
|
+
if (agent.model) args.push("--model", agent.model);
|
|
457
|
+
args.push("--thinking", thinkingLevel);
|
|
458
|
+
if (agent.tools) {
|
|
459
|
+
if (agent.tools.length > 0) args.push("--tools", agent.tools.join(","));
|
|
460
|
+
else args.push("--no-tools");
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
let tmpPromptDir: string | null = null;
|
|
464
|
+
let tmpPromptPath: string | null = null;
|
|
465
|
+
if (agent.systemPrompt.trim()) {
|
|
466
|
+
const tmp = await writePromptToTempFile(options.scratchRoot, agent.name, agent.systemPrompt);
|
|
467
|
+
tmpPromptDir = tmp.dir;
|
|
468
|
+
tmpPromptPath = tmp.filePath;
|
|
469
|
+
args.push("--append-system-prompt", tmpPromptPath);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
let retryPolicy: ChildRetryPolicyExtension;
|
|
473
|
+
try {
|
|
474
|
+
retryPolicy = await writeChildRetryPolicyExtension(options.scratchRoot, agent.model);
|
|
475
|
+
args.push("--extension", retryPolicy.filePath);
|
|
476
|
+
} catch (error) {
|
|
477
|
+
if (tmpPromptDir) await rm(tmpPromptDir, { recursive: true, force: true }).catch(() => undefined);
|
|
478
|
+
throw error;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const result: RpcSingleResult = {
|
|
482
|
+
agent: agentName,
|
|
483
|
+
task,
|
|
484
|
+
exitCode: 0,
|
|
485
|
+
messages: [],
|
|
486
|
+
stderr: "",
|
|
487
|
+
usage: emptyUsage(),
|
|
488
|
+
model: agent.model,
|
|
489
|
+
thinking: thinkingLevel,
|
|
490
|
+
sessionId: options.sessionId,
|
|
491
|
+
sessionDir: options.sessionDir,
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
const childDepth = currentSubagentDepth(options.env) + 1;
|
|
495
|
+
const childEnv: NodeJS.ProcessEnv = {
|
|
496
|
+
...(options.env ?? process.env),
|
|
497
|
+
[DEPTH_ENV_VAR]: String(childDepth),
|
|
498
|
+
};
|
|
499
|
+
const invocation = getPiInvocation(args);
|
|
500
|
+
const usePosixProcessGroup = process.platform !== "win32";
|
|
501
|
+
const proc = spawn(invocation.command, invocation.args, {
|
|
502
|
+
cwd: options.cwd ?? options.defaultCwd,
|
|
503
|
+
shell: false,
|
|
504
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
505
|
+
env: childEnv,
|
|
506
|
+
detached: usePosixProcessGroup,
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
const attemptToken = control?.beginAttempt();
|
|
510
|
+
let closed = false;
|
|
511
|
+
let finished = false;
|
|
512
|
+
let requestId = 0;
|
|
513
|
+
let stdoutBuffer = "";
|
|
514
|
+
let lastActivityAt = Date.now();
|
|
515
|
+
let idleTimer: ReturnType<typeof setInterval> | undefined;
|
|
516
|
+
let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
|
|
517
|
+
let abortHandler: (() => void) | undefined;
|
|
518
|
+
let abortSettlement: Deferred<void> | undefined;
|
|
519
|
+
let droppedQueuedCount = 0;
|
|
520
|
+
let initialPromptResolved = false;
|
|
521
|
+
const initialPrompt = deferred<{ accepted: boolean; error?: Error }>();
|
|
522
|
+
const pendingRequests = new Map<string, PendingRequest>();
|
|
523
|
+
const outcome = deferred<void>();
|
|
524
|
+
const processClosed = deferred<void>();
|
|
525
|
+
const stdoutDecoder = new StringDecoder("utf8");
|
|
526
|
+
const stderrDecoder = new StringDecoder("utf8");
|
|
527
|
+
|
|
528
|
+
const emit = (event: SubagentLiveEvent): void => {
|
|
529
|
+
try {
|
|
530
|
+
onLive?.(event);
|
|
531
|
+
} catch {
|
|
532
|
+
/* live observers must never break protocol handling */
|
|
533
|
+
}
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
const setAttemptPhase = (phase: RpcControlPhase): void => {
|
|
537
|
+
if (attemptToken !== undefined) control?.updateAttemptPhase(attemptToken, phase);
|
|
538
|
+
if (phase === "running" || phase === "interrupting") {
|
|
539
|
+
emit({ kind: "status", status: phase });
|
|
540
|
+
}
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
const rejectPending = (error: Error): void => {
|
|
544
|
+
for (const request of pendingRequests.values()) {
|
|
545
|
+
if (request.timer) clearTimeout(request.timer);
|
|
546
|
+
request.reject(error);
|
|
547
|
+
}
|
|
548
|
+
pendingRequests.clear();
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
const finish = (): void => {
|
|
552
|
+
if (finished) return;
|
|
553
|
+
finished = true;
|
|
554
|
+
if (idleTimer) clearInterval(idleTimer);
|
|
555
|
+
if (signal && abortHandler) signal.removeEventListener("abort", abortHandler);
|
|
556
|
+
outcome.resolve();
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
const resolveInitialPrompt = (accepted: boolean, error?: Error): void => {
|
|
560
|
+
if (initialPromptResolved) return;
|
|
561
|
+
initialPromptResolved = true;
|
|
562
|
+
if (accepted) result.rpcPromptAccepted = true;
|
|
563
|
+
initialPrompt.resolve({ accepted, error });
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
const settleRun = (): void => {
|
|
567
|
+
const failed = result.stopReason === "error" || result.stopReason === "aborted";
|
|
568
|
+
result.exitCode = failed ? 1 : 0;
|
|
569
|
+
// RPC settlement only means model transport is quiescent. Dispatch may still
|
|
570
|
+
// be applying an isolated worktree, so it alone publishes the terminal live
|
|
571
|
+
// status after filesystem finalization completes.
|
|
572
|
+
finish();
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
const terminate = (force = false): void => {
|
|
576
|
+
if (closed) return;
|
|
577
|
+
terminateProcessTree(proc, force, usePosixProcessGroup);
|
|
578
|
+
if (!force && !forceKillTimer) {
|
|
579
|
+
forceKillTimer = setTimeout(() => {
|
|
580
|
+
if (!closed) terminateProcessTree(proc, true, usePosixProcessGroup);
|
|
581
|
+
}, SUBAGENT_KILL_GRACE_MS);
|
|
582
|
+
}
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
const readyTimeoutMs = options.rpcReadyTimeoutMs ?? RPC_READY_TIMEOUT_MS;
|
|
586
|
+
const commandTimeoutMs = options.rpcCommandTimeoutMs ?? RPC_COMMAND_TIMEOUT_MS;
|
|
587
|
+
|
|
588
|
+
const writeLine = (value: object): Promise<void> =>
|
|
589
|
+
new Promise((resolve, reject) => {
|
|
590
|
+
if (!proc.stdin || proc.stdin.destroyed || !proc.stdin.writable) {
|
|
591
|
+
reject(new Error("Subagent RPC stdin is not writable."));
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
// JSON strings may contain U+2028/U+2029. Only the final ASCII LF frames a
|
|
595
|
+
// record; never use a generic line reader on the receiving side.
|
|
596
|
+
proc.stdin.write(`${JSON.stringify(value)}\n`, "utf8", (error) => {
|
|
597
|
+
if (error) reject(error);
|
|
598
|
+
else resolve();
|
|
599
|
+
});
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
const send = async (command: Record<string, unknown>, timeoutMs = commandTimeoutMs): Promise<RpcResponse> => {
|
|
603
|
+
if (finished || closed) throw new Error("Subagent RPC process is no longer active.");
|
|
604
|
+
const id = `req_${++requestId}`;
|
|
605
|
+
const payload = { ...command, id };
|
|
606
|
+
return new Promise<RpcResponse>((resolve, reject) => {
|
|
607
|
+
const pending: PendingRequest = { resolve, reject };
|
|
608
|
+
pendingRequests.set(id, pending);
|
|
609
|
+
void writeLine(payload).then(
|
|
610
|
+
() => {
|
|
611
|
+
if (!pendingRequests.has(id)) return;
|
|
612
|
+
pending.timer = setTimeout(() => {
|
|
613
|
+
pendingRequests.delete(id);
|
|
614
|
+
reject(new Error(`Timed out waiting for RPC response to ${String(command.type)}.`));
|
|
615
|
+
}, timeoutMs);
|
|
616
|
+
if (typeof pending.timer.unref === "function") pending.timer.unref();
|
|
617
|
+
},
|
|
618
|
+
(error) => {
|
|
619
|
+
if (!pendingRequests.has(id)) return;
|
|
620
|
+
pendingRequests.delete(id);
|
|
621
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
622
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
623
|
+
},
|
|
624
|
+
);
|
|
625
|
+
}).then((response) => {
|
|
626
|
+
if (!response.success) {
|
|
627
|
+
throw new RpcCommandRejectedError(response.error || `RPC ${response.command} failed.`);
|
|
628
|
+
}
|
|
629
|
+
return response;
|
|
630
|
+
});
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
const waitForAbortSettlement = (): Deferred<void> => {
|
|
634
|
+
if (abortSettlement) throw new Error("Another RPC abort transition is already in progress.");
|
|
635
|
+
abortSettlement = deferred<void>();
|
|
636
|
+
return abortSettlement;
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
const abortAcceptedPrompt = async (): Promise<boolean> => {
|
|
640
|
+
const acceptance = await initialPrompt.promise;
|
|
641
|
+
if (!acceptance.accepted) return false;
|
|
642
|
+
// Pi continues queued steering/follow-up messages after an abort. Drop
|
|
643
|
+
// them first so a stopped run — or a later resume of its retained
|
|
644
|
+
// thread — cannot be revived by stale queue entries. Best-effort: an
|
|
645
|
+
// older child rejects the command and a hung child falls through to
|
|
646
|
+
// the bounded abort below.
|
|
647
|
+
try {
|
|
648
|
+
const cleared = await send({ type: "clear_queue" }, RPC_CLEAR_QUEUE_TIMEOUT_MS);
|
|
649
|
+
const data = cleared.data as { steering?: unknown; followUp?: unknown } | undefined;
|
|
650
|
+
droppedQueuedCount =
|
|
651
|
+
(Array.isArray(data?.steering) ? data.steering.length : 0) +
|
|
652
|
+
(Array.isArray(data?.followUp) ? data.followUp.length : 0);
|
|
653
|
+
} catch {
|
|
654
|
+
/* abort below is the authoritative stop */
|
|
655
|
+
}
|
|
656
|
+
const stable = waitForAbortSettlement();
|
|
657
|
+
try {
|
|
658
|
+
await Promise.all([send({ type: "abort" }), stable.promise]);
|
|
659
|
+
return true;
|
|
660
|
+
} catch (error) {
|
|
661
|
+
if (abortSettlement === stable) {
|
|
662
|
+
abortSettlement = undefined;
|
|
663
|
+
stable.resolve();
|
|
664
|
+
}
|
|
665
|
+
throw error;
|
|
666
|
+
}
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
const attemptControl: AttemptControl = {
|
|
670
|
+
async stop(reason = "Subagent was aborted"): Promise<void> {
|
|
671
|
+
if (finished) {
|
|
672
|
+
if (!closed) await processClosed.promise;
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
setAttemptPhase("interrupting");
|
|
676
|
+
if (!initialPromptResolved) {
|
|
677
|
+
const stopped = new Error(reason);
|
|
678
|
+
resolveInitialPrompt(false, stopped);
|
|
679
|
+
rejectPending(stopped);
|
|
680
|
+
}
|
|
681
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
682
|
+
const timeout = new Promise<boolean>((resolve) => {
|
|
683
|
+
timer = setTimeout(() => resolve(false), RPC_ABORT_SETTLE_TIMEOUT_MS);
|
|
684
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
685
|
+
});
|
|
686
|
+
try {
|
|
687
|
+
await Promise.race([abortAcceptedPrompt(), timeout]);
|
|
688
|
+
} catch {
|
|
689
|
+
/* process termination below is the bounded fallback */
|
|
690
|
+
} finally {
|
|
691
|
+
if (timer) clearTimeout(timer);
|
|
692
|
+
}
|
|
693
|
+
if (abortSettlement) {
|
|
694
|
+
const stable = abortSettlement;
|
|
695
|
+
abortSettlement = undefined;
|
|
696
|
+
stable.resolve();
|
|
697
|
+
}
|
|
698
|
+
result.exitCode = 1;
|
|
699
|
+
result.stopReason = "aborted";
|
|
700
|
+
result.errorMessage =
|
|
701
|
+
droppedQueuedCount > 0
|
|
702
|
+
? `${reason} — dropped ${droppedQueuedCount} queued steering/follow-up message${droppedQueuedCount === 1 ? "" : "s"}`
|
|
703
|
+
: reason;
|
|
704
|
+
finish();
|
|
705
|
+
// Even when RPC abort/settle times out, give Pi SIGTERM first so its
|
|
706
|
+
// shutdown handler can reap detached tool process groups. terminate()
|
|
707
|
+
// retains the hard-kill timer as the bounded fallback.
|
|
708
|
+
terminate(false);
|
|
709
|
+
if (!closed) await processClosed.promise;
|
|
710
|
+
},
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
if (attemptToken !== undefined) control?.attach(attemptToken, attemptControl);
|
|
714
|
+
if (proc.pid !== undefined) control?.noteChildPid(proc.pid);
|
|
715
|
+
control?.markStarting();
|
|
716
|
+
|
|
717
|
+
const processLine = (rawLine: string): void => {
|
|
718
|
+
let line = rawLine;
|
|
719
|
+
if (line.endsWith("\r")) line = line.slice(0, -1);
|
|
720
|
+
if (!line.trim()) return;
|
|
721
|
+
let event: any;
|
|
722
|
+
try {
|
|
723
|
+
event = JSON.parse(line);
|
|
724
|
+
} catch {
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
if (event.type === "response" && typeof event.id === "string") {
|
|
729
|
+
const pending = pendingRequests.get(event.id);
|
|
730
|
+
if (pending) {
|
|
731
|
+
pendingRequests.delete(event.id);
|
|
732
|
+
clearTimeout(pending.timer);
|
|
733
|
+
pending.resolve(event as RpcResponse);
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
if (finished) return;
|
|
738
|
+
|
|
739
|
+
if (
|
|
740
|
+
[
|
|
741
|
+
"agent_start",
|
|
742
|
+
"agent_end",
|
|
743
|
+
"turn_start",
|
|
744
|
+
"turn_end",
|
|
745
|
+
"message_start",
|
|
746
|
+
"message_update",
|
|
747
|
+
"message_end",
|
|
748
|
+
"tool_execution_start",
|
|
749
|
+
"tool_execution_update",
|
|
750
|
+
"tool_execution_end",
|
|
751
|
+
"auto_retry_start",
|
|
752
|
+
"auto_retry_end",
|
|
753
|
+
"agent_settled",
|
|
754
|
+
].includes(event.type)
|
|
755
|
+
) {
|
|
756
|
+
result.rpcActivity = true;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Let Pi's outer turn retry run. Grok/xAI long streams commonly drop with
|
|
760
|
+
// a retryable `terminated` mid-turn; aborting that retry was misread as
|
|
761
|
+
// "model unavailable" and handed a still-working model back to the parent.
|
|
762
|
+
// After retries exhaust, dispatch still classifies a settled model-level
|
|
763
|
+
// failure and hands off.
|
|
764
|
+
|
|
765
|
+
// Child RPC mode exposes extension dialogs. Sub-agents are non-interactive:
|
|
766
|
+
// cancel blocking dialogs so an unrelated child extension cannot deadlock.
|
|
767
|
+
if (
|
|
768
|
+
event.type === "extension_ui_request" &&
|
|
769
|
+
typeof event.id === "string" &&
|
|
770
|
+
["select", "confirm", "input", "editor"].includes(event.method)
|
|
771
|
+
) {
|
|
772
|
+
void writeLine({ type: "extension_ui_response", id: event.id, cancelled: true }).catch(() => undefined);
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
if (event.type === "agent_start") {
|
|
777
|
+
resolveInitialPrompt(true);
|
|
778
|
+
setAttemptPhase("running");
|
|
779
|
+
emit({ kind: "status", status: "running" });
|
|
780
|
+
}
|
|
781
|
+
if (event.type === "turn_start") {
|
|
782
|
+
setAttemptPhase("running");
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
if (event.type === "message_update") {
|
|
786
|
+
const type = event.assistantMessageEvent?.type;
|
|
787
|
+
if (type === "thinking_delta" || type === "text_delta") {
|
|
788
|
+
emit({ kind: type === "thinking_delta" ? "thinking" : "text" });
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
if (event.type === "tool_execution_start") {
|
|
793
|
+
emit({
|
|
794
|
+
kind: "tool_start",
|
|
795
|
+
...(typeof event.toolCallId === "string" ? { toolCallId: event.toolCallId } : {}),
|
|
796
|
+
toolName: event.toolName ?? "unknown",
|
|
797
|
+
args: event.args,
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
if (event.type === "tool_execution_end") {
|
|
802
|
+
emit({
|
|
803
|
+
kind: "tool_end",
|
|
804
|
+
...(typeof event.toolCallId === "string" ? { toolCallId: event.toolCallId } : {}),
|
|
805
|
+
toolName: event.toolName ?? "unknown",
|
|
806
|
+
isError: Boolean(event.isError),
|
|
807
|
+
});
|
|
808
|
+
if (event.isError) {
|
|
809
|
+
(result.failedTools ??= []).push({
|
|
810
|
+
toolName: event.toolName ?? "unknown",
|
|
811
|
+
error: extractToolErrorText(event.result?.content),
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
if (event.type === "message_end" && event.message) {
|
|
817
|
+
const message = event.message as Message;
|
|
818
|
+
result.messages.push(message);
|
|
819
|
+
if (message.role === "assistant") {
|
|
820
|
+
result.usage.turns++;
|
|
821
|
+
const usage = (message as any).usage;
|
|
822
|
+
if (usage) {
|
|
823
|
+
result.usage.input += usage.input || 0;
|
|
824
|
+
result.usage.output += usage.output || 0;
|
|
825
|
+
result.usage.cacheRead += usage.cacheRead || 0;
|
|
826
|
+
result.usage.cacheWrite += usage.cacheWrite || 0;
|
|
827
|
+
result.usage.cost += usage.cost?.total || 0;
|
|
828
|
+
result.usage.contextTokens = usage.totalTokens || 0;
|
|
829
|
+
}
|
|
830
|
+
if (!result.model && (message as any).model) result.model = (message as any).model;
|
|
831
|
+
if ((message as any).stopReason) result.stopReason = (message as any).stopReason;
|
|
832
|
+
if ((message as any).errorMessage) result.errorMessage = (message as any).errorMessage;
|
|
833
|
+
}
|
|
834
|
+
emit({ kind: "usage", usage: { ...result.usage }, model: result.model });
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
if (event.type === "agent_settled") {
|
|
838
|
+
if (abortSettlement) {
|
|
839
|
+
const stable = abortSettlement;
|
|
840
|
+
abortSettlement = undefined;
|
|
841
|
+
stable.resolve();
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
settleRun();
|
|
845
|
+
}
|
|
846
|
+
};
|
|
847
|
+
|
|
848
|
+
proc.stdout?.on("data", (chunk: Buffer | string) => {
|
|
849
|
+
lastActivityAt = Date.now();
|
|
850
|
+
stdoutBuffer += typeof chunk === "string" ? chunk : stdoutDecoder.write(chunk);
|
|
851
|
+
while (true) {
|
|
852
|
+
const lf = stdoutBuffer.indexOf("\n");
|
|
853
|
+
if (lf === -1) break;
|
|
854
|
+
const line = stdoutBuffer.slice(0, lf);
|
|
855
|
+
stdoutBuffer = stdoutBuffer.slice(lf + 1);
|
|
856
|
+
processLine(line);
|
|
857
|
+
}
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
proc.stderr?.on("data", (chunk: Buffer | string) => {
|
|
861
|
+
result.stderr += typeof chunk === "string" ? chunk : stderrDecoder.write(chunk);
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
proc.stdin?.on("error", (error) => {
|
|
865
|
+
if (finished) return;
|
|
866
|
+
resolveInitialPrompt(false, error);
|
|
867
|
+
result.exitCode = 1;
|
|
868
|
+
result.stopReason = "error";
|
|
869
|
+
result.errorMessage ??= `Subagent RPC stdin failed: ${error.message}`;
|
|
870
|
+
result.dispatchFailed = true;
|
|
871
|
+
finish();
|
|
872
|
+
terminate();
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
proc.once("error", (error) => {
|
|
876
|
+
if (finished) return;
|
|
877
|
+
resolveInitialPrompt(false, error);
|
|
878
|
+
result.exitCode = 1;
|
|
879
|
+
result.stopReason = "error";
|
|
880
|
+
result.errorMessage ??= `Failed to start the sub-agent process: ${error.message}`;
|
|
881
|
+
result.dispatchFailed = true;
|
|
882
|
+
finish();
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
proc.once("close", (code) => {
|
|
886
|
+
closed = true;
|
|
887
|
+
resolveInitialPrompt(false, new Error(`Subagent RPC process exited before the initial prompt was accepted (code=${code ?? "signal"}).`));
|
|
888
|
+
if (forceKillTimer) clearTimeout(forceKillTimer);
|
|
889
|
+
stdoutBuffer += stdoutDecoder.end();
|
|
890
|
+
result.stderr += stderrDecoder.end();
|
|
891
|
+
if (stdoutBuffer.length > 0) processLine(stdoutBuffer);
|
|
892
|
+
const exitError = new Error(
|
|
893
|
+
`Subagent RPC process exited before settling (code=${code ?? "signal"}).${result.stderr ? ` ${result.stderr.trim()}` : ""}`,
|
|
894
|
+
);
|
|
895
|
+
rejectPending(exitError);
|
|
896
|
+
if (abortSettlement) {
|
|
897
|
+
abortSettlement.reject(exitError);
|
|
898
|
+
abortSettlement = undefined;
|
|
899
|
+
}
|
|
900
|
+
if (!finished) {
|
|
901
|
+
result.exitCode = code === 0 ? 1 : (code ?? 1);
|
|
902
|
+
result.stopReason ??= signal?.aborted ? "aborted" : "error";
|
|
903
|
+
if (signal?.aborted) result.errorMessage ??= "Subagent was aborted";
|
|
904
|
+
finish();
|
|
905
|
+
}
|
|
906
|
+
processClosed.resolve();
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
if (idleTimeoutMs > 0) {
|
|
910
|
+
const checkInterval = Math.max(1, Math.min(10_000, Math.floor(idleTimeoutMs / 3)));
|
|
911
|
+
idleTimer = setInterval(() => {
|
|
912
|
+
if (finished || closed) return;
|
|
913
|
+
if (Date.now() - lastActivityAt >= idleTimeoutMs) {
|
|
914
|
+
result.exitCode = 1;
|
|
915
|
+
result.stopReason = "error";
|
|
916
|
+
result.errorMessage = `Subagent idle timeout: no activity for ${Math.ceil(idleTimeoutMs / 1000)} seconds.`;
|
|
917
|
+
finish();
|
|
918
|
+
terminate();
|
|
919
|
+
}
|
|
920
|
+
}, checkInterval);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
if (signal) {
|
|
924
|
+
abortHandler = () => {
|
|
925
|
+
void attemptControl.stop("Subagent was aborted").catch(() => undefined);
|
|
926
|
+
};
|
|
927
|
+
if (signal.aborted) abortHandler();
|
|
928
|
+
else signal.addEventListener("abort", abortHandler, { once: true });
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
try {
|
|
932
|
+
if (control?.isStopRequested()) {
|
|
933
|
+
resolveInitialPrompt(false, new Error("Run was stopped before its initial prompt."));
|
|
934
|
+
await attemptControl.stop();
|
|
935
|
+
} else {
|
|
936
|
+
const failBeforePrompt = (error: Error, startup: boolean): void => {
|
|
937
|
+
resolveInitialPrompt(false, error);
|
|
938
|
+
if (finished) return;
|
|
939
|
+
result.exitCode = 1;
|
|
940
|
+
result.stopReason = "error";
|
|
941
|
+
result.errorMessage = error.message;
|
|
942
|
+
if (startup) result.rpcStartupFailed = true;
|
|
943
|
+
else if (error instanceof RpcCommandRejectedError) result.rpcPromptRejected = true;
|
|
944
|
+
finish();
|
|
945
|
+
terminate();
|
|
946
|
+
};
|
|
947
|
+
try {
|
|
948
|
+
await send({ type: "get_state" }, readyTimeoutMs);
|
|
949
|
+
} catch (error) {
|
|
950
|
+
const handshakeError = error instanceof Error ? error : new Error(String(error));
|
|
951
|
+
if (!control?.isStopRequested()) {
|
|
952
|
+
failBeforePrompt(handshakeError, true);
|
|
953
|
+
} else {
|
|
954
|
+
resolveInitialPrompt(false, handshakeError);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
if (!finished && !initialPromptResolved && !control?.isStopRequested()) {
|
|
958
|
+
// Pi starts the agent immediately after prompt preflight, before its
|
|
959
|
+
// success response necessarily reaches stdout. From this point on, a
|
|
960
|
+
// missing ACK is ambiguous and must never be recovered by replay.
|
|
961
|
+
result.rpcPromptDispatched = true;
|
|
962
|
+
void send({ type: "prompt", message: asPlainTextRpcPrompt(options.prompt) }).then(
|
|
963
|
+
() => resolveInitialPrompt(true),
|
|
964
|
+
(error) => {
|
|
965
|
+
const promptError = error instanceof Error ? error : new Error(String(error));
|
|
966
|
+
failBeforePrompt(promptError, false);
|
|
967
|
+
},
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
await outcome.promise;
|
|
972
|
+
terminate();
|
|
973
|
+
if (!closed) await processClosed.promise;
|
|
974
|
+
return result;
|
|
975
|
+
} finally {
|
|
976
|
+
if (attemptToken !== undefined) control?.detach(attemptToken);
|
|
977
|
+
if (tmpPromptPath) {
|
|
978
|
+
try {
|
|
979
|
+
unlinkSync(tmpPromptPath);
|
|
980
|
+
} catch {
|
|
981
|
+
/* ignore */
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
if (tmpPromptDir) {
|
|
985
|
+
try {
|
|
986
|
+
rmdirSync(tmpPromptDir);
|
|
987
|
+
} catch {
|
|
988
|
+
/* ignore */
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
await rm(retryPolicy.dir, { recursive: true, force: true }).catch(() => undefined);
|
|
992
|
+
}
|
|
993
|
+
}
|