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