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