@cr1ms0n/pi-subagent 0.8.8 → 0.9.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/runner.ts CHANGED
@@ -1,850 +1,1299 @@
1
- import { spawn, type ChildProcess } from "node:child_process";
2
- import * as fs from "node:fs/promises";
3
- import * as os from "node:os";
4
- import * as path from "node:path";
5
- import type {
6
- ChildProcessIdentity,
7
- TaskResult,
8
- TaskSpec,
9
- TimeoutPhase,
10
- UsageStats,
11
- } from "./types.js";
12
- import { emptyUsage } from "./types.js";
13
- import { ProtocolParser, type ProtocolUpdate } from "./protocol.js";
14
- import { Semaphore } from "./semaphore.js";
15
- import { defaultConfig } from "./config.js";
16
- import { DEPTH_ENV_VAR, SPAWNS_ENV_VAR, parseDepth } from "./policy.js";
17
- import {
18
- processStartTime,
19
- type ProcessLockManager,
20
- type SlotToken,
21
- } from "./process-lock.js";
22
- import { createGetPiCommand } from "./launch.js";
23
- import {
24
- checkAgainstSchema,
25
- extractStructuredResult,
26
- repairMessage,
27
- } from "./structured.js";
28
- import type { BackendAdapter, BackendParser } from "./backend.js";
29
- import { resolveBackend } from "./backends/index.js";
30
-
31
- export type GetPiCommand = (args: string[]) => {
32
- command: string;
33
- args: string[];
34
- };
35
-
36
- export interface RunnerOptions {
37
- semaphore?: Semaphore;
38
- getPiCommand?: GetPiCommand;
39
- sessionDir?: string;
40
- onCheckpoint?: (result: Partial<TaskResult>) => void;
41
- killGraceMs?: number;
42
- /**
43
- * Optional durable coordinator for global slots + run process records.
44
- *
45
- * **Library consumers:** without `locks` + `runId`, no durable run record is
46
- * written and the child is invisible to orphan reclaim on parent restart.
47
- * There is intentionally **no** implicit default lock manager (opt in when
48
- * you need durability; magic global state is worse).
49
- */
50
- locks?: ProcessLockManager;
51
- /** Run id for durable identity (orphan reconcile). Required with `locks` for reclaim. */
52
- runId?: string;
53
- /** Parent session key for durable identity. */
54
- parentSessionKey?: string;
55
- /** Max task stdin bytes (Guard against runaway prompt buffering). */
56
- maxTaskBytes?: number;
57
- /** Wrap-up grace turns after a budget breach (spec.graceTurns overrides). */
58
- graceTurns?: number;
59
- /** Protocol-silence window before flagging a running child as stalled. 0 disables. */
60
- stallAfterMs?: number;
61
- /** Additional silence after the stall flag before the child is killed. 0 disables kill. */
62
- stallKillAfterMs?: number;
63
- /** Backend adapter override (defaults to the spec's backend, then pi). */
64
- backend?: BackendAdapter;
65
- }
66
-
67
- type StopReason =
68
- "cancelled" | "timeout" | "max_turns" | "max_cost" | "fatal" | "stalled";
69
-
70
- /** Budget stops preserve completed work: they end as "partial", not "failed". */
71
- const BUDGET_STOPS = new Set<StopReason>(["max_turns", "max_cost"]);
72
-
73
- const DEFAULT_MAX_TASK_BYTES = 512 * 1024;
74
-
75
- const WRAP_UP_MESSAGE =
76
- "You have reached your budget for this task. Stop all tool use and provide your final answer NOW, " +
77
- "summarizing what you completed, what remains, and any key findings. This is your last chance to respond.";
78
-
79
- /** Owns exactly one child Pi process and its process tree. */
80
- export class ChildRunner {
81
- /** Live stdin command channel; set while the child process is running. */
82
- private sendCommand?: (command: unknown) => boolean;
83
- private readonly graceTurns: number;
84
- private readonly stallAfterMs: number;
85
- private readonly stallKillAfterMs: number;
86
- private readonly backendOverride?: BackendAdapter;
87
- /** Backend for the in-flight run; set at spawn so steer() uses the right dialect. */
88
- private backend: BackendAdapter = resolveBackend("pi");
89
-
90
- constructor(
91
- private readonly semaphore = new Semaphore(
92
- defaultConfig.maxActiveProcesses,
93
- defaultConfig.maxQueuedTasks,
94
- ),
95
- private readonly getPiCommand: GetPiCommand = createGetPiCommand(),
96
- private readonly sessionDir = defaultConfig.sessionDir,
97
- private readonly onCheckpoint?: (result: Partial<TaskResult>) => void,
98
- private readonly killGraceMs = defaultConfig.killGraceMs,
99
- private readonly locks?: ProcessLockManager,
100
- private readonly runId?: string,
101
- private readonly parentSessionKey?: string,
102
- private readonly maxTaskBytes = DEFAULT_MAX_TASK_BYTES,
103
- options: Pick<
104
- RunnerOptions,
105
- "graceTurns" | "stallAfterMs" | "stallKillAfterMs" | "backend"
106
- > = {},
107
- ) {
108
- this.backendOverride = options.backend;
109
- this.graceTurns = options.graceTurns ?? defaultConfig.graceTurns;
110
- this.stallAfterMs = options.stallAfterMs ?? defaultConfig.stallAfterMs;
111
- this.stallKillAfterMs =
112
- options.stallKillAfterMs ?? defaultConfig.stallKillAfterMs;
113
- }
114
-
115
- /**
116
- * Queue a steering message into the running child (delivered after the
117
- * current assistant turn, before the next LLM call). Returns false when the
118
- * child is not running or its stdin is closed.
119
- */
120
- steer(message: string): boolean {
121
- const command = this.backend.steerCommand?.(message);
122
- if (command === undefined) return false;
123
- return this.sendCommand?.(command) === true;
124
- }
125
-
126
- async run(spec: TaskSpec, abortSignal?: AbortSignal): Promise<TaskResult> {
127
- const startedAt = Date.now();
128
- const result: TaskResult = {
129
- label: spec.label ?? "subagent",
130
- task: spec.task,
131
- model: spec.model,
132
- state: "queued",
133
- exitCode: null,
134
- messages: [],
135
- stderr: "",
136
- usage: emptyUsage(),
137
- outputFile: spec.output,
138
- outputMode: spec.outputMode,
139
- thinking: spec.thinking,
140
- profile: spec.profile,
141
- backend: spec.backend ?? "pi",
142
- canWrite: spec.canWrite,
143
- startedAt,
144
- protocol: {
145
- headerSeen: false,
146
- assistantEndSeen: false,
147
- agentEndSeen: false,
148
- agentSettledSeen: false,
149
- validEvents: 0,
150
- parseErrors: 0,
151
- },
152
- };
153
-
154
- let processHandle: ChildProcess | undefined;
155
- let slotHeld = false;
156
- let globalSlot: SlotToken | undefined;
157
- let forceKillTimer: NodeJS.Timeout | undefined;
158
- const tempDirs: string[] = [];
159
- let requestedStop: StopReason | undefined;
160
- let fatalError: string | undefined;
161
- let timeoutPhase: TimeoutPhase | undefined;
162
- let abortHandler: (() => void) | undefined;
163
- let stderr = "";
164
- // Backend resolution happens before anything else so parser dialect,
165
- // capability checks and stdin command shapes all agree.
166
- const backend =
167
- this.backendOverride ?? resolveBackend(spec.backend ?? "pi");
168
- this.backend = backend;
169
- const parser: BackendParser = backend.createParser();
170
- let spawned = false;
171
- let acquiredAt: number | undefined;
172
- let childStartTime = 0;
173
- // Graceful budget stop state: after a breach the child is steered to wrap
174
- // up and allowed `graceTurns` more turns before SIGTERM.
175
- let pendingBudgetStop:
176
- { reason: "max_turns" | "max_cost"; deadlineTurns: number } | undefined;
177
- let wrappedUp = false;
178
- // Structured-output repair state: one steer-based retry after failed validation.
179
- let schemaRepairAttempted = false;
180
- // Stall watchdog state.
181
- let lastEventAt = Date.now();
182
- let stallTimer: NodeJS.Timeout | undefined;
183
- let stalledAt: number | undefined;
184
-
185
- // Internal signal combines the caller's abort with the run timeout so both
186
- // interrupt semaphore queue waits. Queue time counts against timeoutMs.
187
- const internal = new AbortController();
188
- const onExternalAbort = () => internal.abort();
189
- if (abortSignal?.aborted) internal.abort();
190
- else
191
- abortSignal?.addEventListener("abort", onExternalAbort, { once: true });
192
- const timeout = setTimeout(() => {
193
- // Record which phase timed out before nightfall.
194
- timeoutPhase = !slotHeld ? "queued" : !spawned ? "starting" : "running";
195
- requestStop("timeout");
196
- internal.abort();
197
- }, spec.timeoutMs);
198
- timeout.unref?.();
199
-
200
- const release = () => {
201
- if (slotHeld) {
202
- slotHeld = false;
203
- this.semaphore.release();
204
- }
205
- if (globalSlot) {
206
- this.locks?.releaseGlobalSlot(globalSlot);
207
- globalSlot = undefined;
208
- }
209
- };
210
-
211
- /**
212
- * Group-kill only when the PID still belongs to our child (start-time
213
- * identity check guards against PID reuse racing a delayed kill). When
214
- * identity is unverifiable, fall back to the direct child handle, which
215
- * Node ties to the real process regardless of PID recycling.
216
- */
217
- const pidStillOurs = (pid: number): boolean => {
218
- if (childStartTime <= 0) return false;
219
- const live = processStartTime(pid);
220
- return live > 0 && live === childStartTime;
221
- };
222
-
223
- const forceKillTree = () => {
224
- const pid = processHandle?.pid;
225
- if (!pid) return;
226
- try {
227
- if (process.platform === "win32") {
228
- const killer = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], {
229
- shell: false,
230
- stdio: "ignore",
231
- });
232
- killer.unref();
233
- } else if (
234
- processHandle &&
235
- processHandle.exitCode === null &&
236
- processHandle.signalCode === null
237
- ) {
238
- // Child object still live: group id is safe to use.
239
- process.kill(-pid, "SIGKILL");
240
- } else if (pidStillOurs(pid)) {
241
- process.kill(-pid, "SIGKILL");
242
- }
243
- // Child exited and identity is unverifiable: skip the group kill (a
244
- // recycled PID must never be killed); descendants are covered by the
245
- // exit-path reap that runs while the handle is still authoritative.
246
- } catch (error: any) {
247
- if (error?.code !== "ESRCH") {
248
- try {
249
- processHandle?.kill("SIGKILL");
250
- } catch {
251
- /* best effort */
252
- }
253
- }
254
- }
255
- };
256
-
257
- const requestStop = (reason: StopReason) => {
258
- if (!requestedStop) requestedStop = reason;
259
- // Cancellation may arrive before spawn. In that case remember the reason,
260
- // then a second call immediately after spawn performs the actual signal.
261
- if (forceKillTimer) return;
262
- const pid = processHandle?.pid;
263
- if (!pid) return;
264
- try {
265
- if (process.platform === "win32") {
266
- const killer = spawn("taskkill", ["/pid", String(pid), "/T"], {
267
- shell: false,
268
- stdio: "ignore",
269
- });
270
- killer.unref();
271
- } else {
272
- process.kill(-pid, "SIGTERM");
273
- }
274
- } catch (error: any) {
275
- if (error?.code !== "ESRCH") {
276
- try {
277
- processHandle?.kill("SIGTERM");
278
- } catch {
279
- /* best effort */
280
- }
281
- }
282
- }
283
- forceKillTimer = setTimeout(forceKillTree, this.killGraceMs);
284
- forceKillTimer.unref?.();
285
- };
286
-
287
- const stopStallWatchdog = () => {
288
- if (stallTimer) clearInterval(stallTimer);
289
- stallTimer = undefined;
290
- };
291
-
292
- /**
293
- * Activity-based stall detection: protocol silence for `stallAfterMs`
294
- * flags the task as stalled (visible in checkpoints/status); continued
295
- * silence for `stallKillAfterMs` more kills the child so retry can take
296
- * over. Any protocol event clears the flag.
297
- */
298
- const startStallWatchdog = () => {
299
- if (this.stallAfterMs <= 0 || stallTimer) return;
300
- const tick = Math.max(
301
- 1_000,
302
- Math.min(10_000, Math.floor(this.stallAfterMs / 3)),
303
- );
304
- stallTimer = setInterval(() => {
305
- if (requestedStop) return stopStallWatchdog();
306
- const silence = Date.now() - lastEventAt;
307
- if (silence < this.stallAfterMs) {
308
- if (stalledAt !== undefined) {
309
- stalledAt = undefined;
310
- result.stalledSince = undefined;
311
- progress({ stalledSince: undefined });
312
- }
313
- return;
314
- }
315
- if (stalledAt === undefined) {
316
- stalledAt = lastEventAt + this.stallAfterMs;
317
- result.stalledSince = stalledAt;
318
- progress({ stalledSince: stalledAt });
319
- // Cheap liveness probe: a healthy-but-quiet child answers get_state,
320
- // which itself counts as protocol activity and clears the flag.
321
- this.sendCommand?.({ type: "get_state" });
322
- return;
323
- }
324
- if (
325
- this.stallKillAfterMs > 0 &&
326
- silence >= this.stallAfterMs + this.stallKillAfterMs
327
- ) {
328
- stopStallWatchdog();
329
- requestStop("stalled");
330
- }
331
- }, tick);
332
- stallTimer.unref?.();
333
- };
334
-
335
- const cleanup = async () => {
336
- this.sendCommand = undefined;
337
- clearTimeout(timeout);
338
- stopStallWatchdog();
339
- if (forceKillTimer) clearTimeout(forceKillTimer);
340
- abortSignal?.removeEventListener("abort", onExternalAbort);
341
- if (abortSignal && abortHandler)
342
- abortSignal.removeEventListener("abort", abortHandler);
343
- processHandle?.stdout?.removeAllListeners();
344
- processHandle?.stderr?.removeAllListeners();
345
- processHandle?.removeAllListeners();
346
- for (const dir of tempDirs)
347
- await fs.rm(dir, { recursive: true, force: true }).catch(() => {});
348
- release();
349
- };
350
-
351
- // Transcript joins are O(transcript) — only attach them on structural
352
- // updates (message boundaries), not per-chunk live-text ticks.
353
- const progress = (partial: Partial<TaskResult>, withTranscript = false) => {
354
- Object.assign(result, partial);
355
- const checkpoint: Partial<TaskResult> = {
356
- ...result,
357
- liveText: parser.getLiveText(),
358
- };
359
- if (withTranscript) checkpoint.transcript = parser.getTranscript();
360
- else delete checkpoint.transcript;
361
- this.onCheckpoint?.(checkpoint);
362
- };
363
-
364
- /**
365
- * Budget breach → graceful wrap-up: steer the child to answer NOW and
366
- * allow `graceTurns` more turns. SIGTERM fires only when grace is
367
- * exhausted (or configured to 0, or steering is impossible).
368
- */
369
- const handleBudgetBreach = (
370
- reason: "max_turns" | "max_cost",
371
- turns: number,
372
- ) => {
373
- if (requestedStop || pendingBudgetStop) {
374
- if (pendingBudgetStop && turns >= pendingBudgetStop.deadlineTurns)
375
- requestStop(pendingBudgetStop.reason);
376
- return;
377
- }
378
- const grace = spec.graceTurns ?? this.graceTurns;
379
- if (
380
- grace <= 0 ||
381
- !this.sendCommand?.({ type: "steer", message: WRAP_UP_MESSAGE })
382
- ) {
383
- requestStop(reason);
384
- return;
385
- }
386
- pendingBudgetStop = { reason, deadlineTurns: turns + grace };
387
- };
388
-
389
- const handleUpdates = (updates: ProtocolUpdate[]) => {
390
- if (updates.length) {
391
- lastEventAt = Date.now();
392
- if (stalledAt !== undefined) {
393
- stalledAt = undefined;
394
- result.stalledSince = undefined;
395
- }
396
- }
397
- for (const update of updates) {
398
- if (update.type === "session")
399
- progress({ sessionId: update.sessionId });
400
- if (update.type === "live-text")
401
- progress({ liveText: update.liveText });
402
- if (update.type === "message") {
403
- result.messages = parser.getMessages();
404
- result.usage = update.usage;
405
- progress(
406
- {
407
- messages: result.messages,
408
- usage: result.usage,
409
- liveText: parser.getLiveText(),
410
- },
411
- true,
412
- );
413
- if (pendingBudgetStop) {
414
- if (update.usage.turns >= pendingBudgetStop.deadlineTurns)
415
- requestStop(pendingBudgetStop.reason);
416
- } else {
417
- const budget = this.checkBudgets(spec, result.usage);
418
- if (budget) handleBudgetBreach(budget, update.usage.turns);
419
- }
420
- }
421
- // Headless children cannot answer extension UI dialogs; cancel so the child never hangs.
422
- if (update.type === "ui-request")
423
- this.sendCommand?.({
424
- type: "extension_ui_response",
425
- id: update.id,
426
- cancelled: true,
427
- });
428
- if (update.type === "fatal") {
429
- fatalError = update.error;
430
- requestStop("fatal");
431
- }
432
- // RPC children stay alive until stdin closes; end it once the run settles.
433
- if (update.type === "agent-settled") {
434
- // A settle during the wrap-up window means the child finished its
435
- // final answer in time.
436
- if (pendingBudgetStop && !requestedStop) wrappedUp = true;
437
- // Structured-output gate: validate before letting the child exit.
438
- // Invalid one steer-based repair round (a fresh prompt keeps the
439
- // RPC child alive and produces a new settle when it finishes).
440
- if (spec.outputSchema && !requestedStop && !pendingBudgetStop) {
441
- const extracted = extractStructuredResult(parser.getLiveText());
442
- const check =
443
- extracted.value !== undefined
444
- ? checkAgainstSchema(extracted.value, spec.outputSchema)
445
- : {
446
- ok: false,
447
- errors: [
448
- extracted.raw
449
- ? "json:result block did not parse as JSON"
450
- : "no json:result block found in the final message",
451
- ],
452
- };
453
- if (!check.ok && !schemaRepairAttempted) {
454
- schemaRepairAttempted = true;
455
- if (
456
- this.sendCommand?.({
457
- type: "prompt",
458
- message: repairMessage(check.errors),
459
- })
460
- ) {
461
- lastEventAt = Date.now();
462
- continue; // repair round in flight: do not close stdin yet
463
- }
464
- }
465
- }
466
- try {
467
- processHandle?.stdin?.end();
468
- } catch {
469
- /* already closed */
470
- }
471
- }
472
- }
473
- };
474
-
475
- const applyTimeoutSemantics = (base: TaskResult): TaskResult => {
476
- if (requestedStop !== "timeout") return base;
477
- // Queue timeouts never start work: model them as a clean timeout with phase,
478
- // not a mysterious execution "failed".
479
- const phase = timeoutPhase ?? "running";
480
- return {
481
- ...base,
482
- state: "timeout",
483
- stopReason: "timeout",
484
- timeoutPhase: phase,
485
- errorMessage:
486
- phase === "queued"
487
- ? "Timed out waiting for a process slot (never started)"
488
- : phase === "starting"
489
- ? "Timed out while starting the child process"
490
- : base.errorMessage || "Timed out while the child was running",
491
- };
492
- };
493
-
494
- try {
495
- if (internal.signal.aborted) {
496
- result.state = requestedStop === "timeout" ? "timeout" : "cancelled";
497
- result.stopReason = requestedStop ?? "cancelled";
498
- result.timeoutPhase =
499
- requestedStop === "timeout" ? (timeoutPhase ?? "queued") : undefined;
500
- result.exitCode = 1;
501
- result.endedAt = Date.now();
502
- if (result.state === "timeout" && !result.errorMessage) {
503
- result.errorMessage =
504
- "Timed out waiting for a process slot (never started)";
505
- }
506
- return result;
507
- }
508
-
509
- // Global cap (if configured) is checked before the per-session semaphore so
510
- // a saturated machine rejects early with a clear message. Depth is the
511
- // parent process nest level (PI_SUBAGENT_DEPTH via parseDepth): shallow tiers
512
- // reserve capacity so nested spawns cannot deadlock on a full pool.
513
- if (this.locks) {
514
- try {
515
- globalSlot = this.locks.tryAcquireGlobalSlot(
516
- this.runId ?? "anonymous",
517
- parseDepth(),
518
- );
519
- } catch (error: any) {
520
- result.state = "failed";
521
- result.stopReason = "global_limit";
522
- result.errorMessage = error?.message ?? String(error);
523
- result.exitCode = 1;
524
- result.endedAt = Date.now();
525
- return result;
526
- }
527
- }
528
-
529
- await this.semaphore.acquire(internal.signal);
530
- slotHeld = true;
531
- acquiredAt = Date.now();
532
- result.acquiredAt = acquiredAt;
533
- if (internal.signal.aborted)
534
- throw new Error("Subagent cancelled before spawn");
535
- if (abortSignal) {
536
- abortHandler = () => requestStop("cancelled");
537
- abortSignal.addEventListener("abort", abortHandler, { once: true });
538
- }
539
- result.state = "running";
540
- progress({ state: "running", acquiredAt });
541
-
542
- await fs.mkdir(this.sessionDir, { recursive: true });
543
- if (internal.signal.aborted)
544
- throw new Error("Subagent cancelled before spawn");
545
-
546
- const taskBytes = Buffer.byteLength(spec.task, "utf8");
547
- if (taskBytes > this.maxTaskBytes) {
548
- throw new Error(
549
- `Task exceeds maxTaskBytes (${taskBytes} > ${this.maxTaskBytes}). Pass a shorter objective or raise the limit.`,
550
- );
551
- }
552
-
553
- const invocation = await backend.buildInvocation(spec, {
554
- sessionDir: this.sessionDir,
555
- getPiCommand: this.getPiCommand,
556
- });
557
- if (invocation.cleanupDirs?.length)
558
- tempDirs.push(...invocation.cleanupDirs);
559
-
560
- const depth = Number.parseInt(process.env[DEPTH_ENV_VAR] ?? "0", 10) || 0;
561
- // Pin the launch identity + depth in env. Children re-register only when
562
- // depth leaves remaining headroom (enforced in extension + policy too).
563
- // Encode the child's own spawn allowlist so grandchildren validate against it.
564
- const spawnEnv =
565
- spec.spawns === false
566
- ? ""
567
- : Array.isArray(spec.spawns)
568
- ? spec.spawns.join(",")
569
- : spec.spawns === "*"
570
- ? "*"
571
- : undefined;
572
- const childEnv: NodeJS.ProcessEnv = {
573
- ...process.env,
574
- [DEPTH_ENV_VAR]: String(depth + 1),
575
- ...(spawnEnv !== undefined ? { [SPAWNS_ENV_VAR]: spawnEnv } : {}),
576
- };
577
-
578
- processHandle = spawn(invocation.command, invocation.args, {
579
- cwd: spec.cwd || process.cwd(),
580
- shell: false,
581
- detached: process.platform !== "win32",
582
- stdio: ["pipe", "pipe", "pipe"],
583
- env: childEnv,
584
- });
585
- spawned = true;
586
-
587
- const pid = processHandle.pid;
588
- if (pid) {
589
- childStartTime = processStartTime(pid);
590
- const identity: ChildProcessIdentity = {
591
- pid,
592
- startTime: childStartTime,
593
- // On POSIX the child is a new process group leader (detached).
594
- pgid: process.platform === "win32" ? undefined : pid,
595
- hostname: os.hostname(),
596
- };
597
- result.process = identity;
598
- progress({ process: identity });
599
- if (this.locks && this.runId) {
600
- this.locks.writeRunRecord({
601
- runId: this.runId,
602
- parentSessionKey: this.parentSessionKey ?? "",
603
- childSessionId: result.sessionId,
604
- // Worktree-isolated runs record their checkout so concurrent Pi
605
- // processes' machine-wide GC sweeps can shield it while we live.
606
- worktreeCwd: spec.isolation === "worktree" ? spec.cwd : undefined,
607
- process: {
608
- pid: identity.pid,
609
- startTime: identity.startTime,
610
- pgid: identity.pgid,
611
- hostname: identity.hostname ?? os.hostname(),
612
- },
613
- startedAt: Date.now(),
614
- state: "running",
615
- updatedAt: Date.now(),
616
- });
617
- }
618
- }
619
-
620
- if (requestedStop) requestStop(requestedStop);
621
- else if (internal.signal.aborted) requestStop("cancelled");
622
-
623
- // Attach readers BEFORE writing stdin so a chatty child cannot fill the
624
- // OS pipe buffer and deadlock waiting for us to drain.
625
- processHandle.stdout?.on("data", (chunk: Buffer) =>
626
- handleUpdates(parser.feed(chunk)),
627
- );
628
- processHandle.stderr?.on("data", (chunk: Buffer) => {
629
- stderr = (stderr + chunk.toString()).slice(-50 * 1024);
630
- result.stderr = stderr;
631
- });
632
- processHandle.stdin?.on("error", (error: NodeJS.ErrnoException) => {
633
- // EPIPE is expected when a child fails before consuming stdin.
634
- if (error.code !== "EPIPE")
635
- result.errorMessage = `stdin error: ${error.message}`;
636
- });
637
- const send = (command: unknown): boolean => {
638
- const stdin = processHandle?.stdin;
639
- if (!stdin || !stdin.writable || stdin.destroyed) return false;
640
- try {
641
- stdin.write(JSON.stringify(command) + "\n"); // JSONL: LF-delimited, JSON escapes embedded newlines
642
- return true;
643
- } catch {
644
- return false;
645
- }
646
- };
647
- this.sendCommand = send;
648
- send({ type: "prompt", message: spec.task });
649
- // RPC mode has no session header line; get_state supplies the session id.
650
- send({ type: "get_state" });
651
- lastEventAt = Date.now();
652
- startStallWatchdog();
653
-
654
- const closed = await new Promise<{
655
- code: number | null;
656
- signal: NodeJS.Signals | null;
657
- error?: Error;
658
- }>((resolve) => {
659
- let settled = false;
660
- const finish = (value: {
661
- code: number | null;
662
- signal: NodeJS.Signals | null;
663
- error?: Error;
664
- }) => {
665
- if (settled) return;
666
- settled = true;
667
- resolve(value);
668
- };
669
- processHandle!.once("close", (code, signal) =>
670
- finish({ code, signal }),
671
- );
672
- processHandle!.once("error", (error) =>
673
- finish({ code: 1, signal: null, error }),
674
- );
675
- });
676
-
677
- handleUpdates(parser.flush());
678
- // The child owns a dedicated process group. Reap descendants even when the
679
- // direct Pi process exits normally after a tool backgrounds work — unless
680
- // the task explicitly opted into keeping backgrounded processes alive.
681
- if (!spec.keepBackground || requestedStop) forceKillTree();
682
- const finalized = parser.finalize(
683
- closed.code,
684
- closed.signal ?? undefined,
685
- stderr,
686
- );
687
- Object.assign(result, finalized, {
688
- label: result.label,
689
- task: spec.task,
690
- outputFile: spec.output,
691
- outputMode: spec.outputMode,
692
- thinking: spec.thinking,
693
- profile: spec.profile,
694
- backend: spec.backend ?? "pi",
695
- model: finalized.model ?? result.model ?? spec.model,
696
- canWrite: spec.canWrite,
697
- process: result.process,
698
- startedAt,
699
- acquiredAt,
700
- endedAt: Date.now(),
701
- });
702
-
703
- if (closed.error) {
704
- result.state = "failed";
705
- result.stopReason = "spawn_error";
706
- result.errorMessage = closed.error.message;
707
- } else if (requestedStop) {
708
- if (requestedStop === "timeout") {
709
- Object.assign(result, applyTimeoutSemantics(result));
710
- } else if (requestedStop === "cancelled") {
711
- result.state = "cancelled";
712
- result.stopReason = "cancelled";
713
- result.exitCode = closed.code;
714
- } else if (requestedStop === "stalled") {
715
- // Stall kill is a transient infrastructure failure (retryable), but
716
- // completed turns still carry useful output.
717
- result.state = result.usage.turns > 0 ? "partial" : "failed";
718
- result.stopReason = "stalled";
719
- result.exitCode = closed.code ?? 1;
720
- result.stalledSince = stalledAt;
721
- result.errorMessage = `Child produced no protocol activity for ${Math.round((this.stallAfterMs + this.stallKillAfterMs) / 1000)}s and was stopped`;
722
- } else if (BUDGET_STOPS.has(requestedStop) && result.usage.turns > 0) {
723
- result.state = "partial";
724
- result.stopReason = requestedStop;
725
- result.exitCode = closed.code;
726
- result.errorMessage = `Stopped by ${requestedStop.replace("_", " ")} budget after the wrap-up grace period; partial output preserved`;
727
- } else {
728
- result.state = "failed";
729
- result.stopReason =
730
- requestedStop === "fatal" ? "error" : requestedStop;
731
- result.exitCode = closed.code ?? 1;
732
- if (fatalError) result.errorMessage = fatalError;
733
- }
734
- } else if (
735
- pendingBudgetStop &&
736
- (result.state as TaskResult["state"]) === "completed"
737
- ) {
738
- // Budget breached, but the child wrapped up its final answer within the
739
- // grace turns: a concluded (if budget-limited) result, not a truncation.
740
- result.state = "partial";
741
- result.stopReason = pendingBudgetStop.reason;
742
- result.wrappedUp = true;
743
- result.errorMessage = `Reached ${pendingBudgetStop.reason.replace("_", " ")} budget and wrapped up gracefully`;
744
- }
745
-
746
- // Structured-output verdict: validate the final text once, after any
747
- // repair round. Failure downgrades completed → partial (paid work is
748
- // still delivered; the parent sees why it is not machine-readable).
749
- if (spec.outputSchema) {
750
- const extracted = extractStructuredResult(result.liveText);
751
- const check =
752
- extracted.value !== undefined
753
- ? checkAgainstSchema(extracted.value, spec.outputSchema)
754
- : {
755
- ok: false,
756
- errors: [
757
- extracted.raw
758
- ? "json:result block did not parse as JSON"
759
- : "no json:result block found in the final message",
760
- ],
761
- };
762
- if (check.ok) {
763
- result.structuredOutput = extracted.value;
764
- } else {
765
- result.structuredError = check.errors.slice(0, 10).join("; ");
766
- if ((result.state as TaskResult["state"]) === "completed") {
767
- result.state = "partial";
768
- result.stopReason = "schema_mismatch";
769
- result.errorMessage = `Structured output failed validation${schemaRepairAttempted ? " (after one repair round)" : ""}: ${result.structuredError}`;
770
- }
771
- }
772
- }
773
-
774
- if (this.locks && this.runId) {
775
- this.locks.markRunTerminal(this.runId, result.state);
776
- }
777
- return result;
778
- } catch (error: any) {
779
- const cancelled =
780
- (abortSignal?.aborted && requestedStop !== "timeout") ||
781
- /cancel/i.test(String(error?.message));
782
- if (requestedStop === "timeout") {
783
- result.state = "timeout";
784
- result.stopReason = "timeout";
785
- result.timeoutPhase =
786
- timeoutPhase ?? (!slotHeld ? "queued" : "running");
787
- result.errorMessage =
788
- result.timeoutPhase === "queued"
789
- ? "Timed out waiting for a process slot (never started)"
790
- : (error?.message ?? "Timed out");
791
- } else {
792
- result.state = cancelled ? "cancelled" : "failed";
793
- result.stopReason =
794
- requestedStop ??
795
- (result.state === "cancelled" ? "cancelled" : "error");
796
- result.errorMessage = error?.message ?? String(error);
797
- }
798
- result.exitCode ??= 1;
799
- result.endedAt = Date.now();
800
- if (this.locks && this.runId)
801
- this.locks.markRunTerminal(this.runId, result.state);
802
- return result;
803
- } finally {
804
- await cleanup();
805
- }
806
- }
807
-
808
- private checkBudgets(
809
- spec: TaskSpec,
810
- usage: UsageStats,
811
- ): "max_turns" | "max_cost" | undefined {
812
- // Stop only after a completed turn has pushed usage beyond the configured ceiling.
813
- if (spec.maxTurns !== undefined && usage.turns > spec.maxTurns)
814
- return "max_turns";
815
- if (spec.maxCost !== undefined && usage.cost > spec.maxCost)
816
- return "max_cost";
817
- return undefined;
818
- }
819
- }
820
-
821
- /**
822
- * Run a single subagent child process.
823
- *
824
- * **Orphan reclaim:** without `options.locks` **and** `options.runId`, no durable
825
- * run record is written under the lock root, so children are invisible to
826
- * startup orphan reclaim. Pass both when embedding the runner as a library if you
827
- * need crash recovery. No implicit default lock manager is created (opt-in only).
828
- */
829
- export function runSubagent(
830
- spec: TaskSpec,
831
- options: RunnerOptions & { signal?: AbortSignal } = {},
832
- ): Promise<TaskResult> {
833
- return new ChildRunner(
834
- options.semaphore,
835
- options.getPiCommand,
836
- options.sessionDir,
837
- options.onCheckpoint,
838
- options.killGraceMs,
839
- options.locks,
840
- options.runId,
841
- options.parentSessionKey,
842
- options.maxTaskBytes,
843
- {
844
- graceTurns: options.graceTurns,
845
- stallAfterMs: options.stallAfterMs,
846
- stallKillAfterMs: options.stallKillAfterMs,
847
- backend: options.backend,
848
- },
849
- ).run(spec, options.signal);
850
- }
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
+ import * as fs from "node:fs/promises";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import type {
6
+ ChildProcessIdentity,
7
+ TaskResult,
8
+ TaskSpec,
9
+ TimeoutPhase,
10
+ UsageStats,
11
+ } from "./types.js";
12
+ import { emptyUsage } from "./types.js";
13
+ import { ProtocolParser, type ProtocolUpdate } from "./protocol.js";
14
+ import { Semaphore } from "./semaphore.js";
15
+ import { defaultConfig } from "./config.js";
16
+ import { DEPTH_ENV_VAR, SPAWNS_ENV_VAR, parseDepth } from "./policy.js";
17
+ import {
18
+ processStartTime,
19
+ type ProcessLockManager,
20
+ type SlotToken,
21
+ } from "./process-lock.js";
22
+ import { createGetPiCommand } from "./launch.js";
23
+ import {
24
+ checkAgainstSchema,
25
+ extractStructuredResult,
26
+ repairMessage,
27
+ } from "./structured.js";
28
+ import type { BackendAdapter, BackendParser } from "./backend.js";
29
+ import { resolveBackend } from "./backends/index.js";
30
+ import {
31
+ PREFLIGHT_FAILURE_STOP_REASON,
32
+ PREFLIGHT_MANIFEST_ENV,
33
+ STARTUP_FAILURE_RESULT_PREFIX,
34
+ ownExtensionEntryCandidates,
35
+ ownPreflightExtensionPath,
36
+ parsePreflightAckContent,
37
+ parsePreflightManifest,
38
+ preflightCommandBase,
39
+ readStartupFailure,
40
+ resolvePreflightCommand,
41
+ startupFailure,
42
+ startupTimeoutDetail,
43
+ summarizeCommandResolution,
44
+ summarizePreflightProblems,
45
+ verifyPreflightAck,
46
+ type PreflightExpectation,
47
+ } from "./startup-check.js";
48
+
49
+ export type GetPiCommand = (args: string[]) => {
50
+ command: string;
51
+ args: string[];
52
+ };
53
+
54
+ export interface RunnerOptions {
55
+ semaphore?: Semaphore;
56
+ getPiCommand?: GetPiCommand;
57
+ sessionDir?: string;
58
+ onCheckpoint?: (result: Partial<TaskResult>) => void;
59
+ killGraceMs?: number;
60
+ /**
61
+ * Optional durable coordinator for global slots + run process records.
62
+ *
63
+ * **Library consumers:** without `locks` + `runId`, no durable run record is
64
+ * written and the child is invisible to orphan reclaim on parent restart.
65
+ * There is intentionally **no** implicit default lock manager (opt in when
66
+ * you need durability; magic global state is worse).
67
+ */
68
+ locks?: ProcessLockManager;
69
+ /** Run id for durable identity (orphan reconcile). Required with `locks` for reclaim. */
70
+ runId?: string;
71
+ /** Parent session key for durable identity. */
72
+ parentSessionKey?: string;
73
+ /** Max task stdin bytes (Guard against runaway prompt buffering). */
74
+ maxTaskBytes?: number;
75
+ /** Wrap-up grace turns after a budget breach (spec.graceTurns overrides). */
76
+ graceTurns?: number;
77
+ /** Protocol-silence window before flagging a running child as stalled. 0 disables. */
78
+ stallAfterMs?: number;
79
+ /** Additional silence after the stall flag before the child is killed. 0 disables kill. */
80
+ stallKillAfterMs?: number;
81
+ /**
82
+ * Bounded startup-verification budget for routed tasks (model/tool handshake before
83
+ * the real prompt). Defaults to 30s and is always clamped by the remaining task time.
84
+ */
85
+ startupTimeoutMs?: number;
86
+ /** Backend adapter override (defaults to the spec's backend, then pi). */
87
+ backend?: BackendAdapter;
88
+ }
89
+
90
+ type StopReason =
91
+ "cancelled" | "timeout" | "max_turns" | "max_cost" | "fatal" | "stalled";
92
+
93
+ /** Budget stops preserve completed work: they end as "partial", not "failed". */
94
+ const BUDGET_STOPS = new Set<StopReason>(["max_turns", "max_cost"]);
95
+
96
+ const DEFAULT_MAX_TASK_BYTES = 512 * 1024;
97
+
98
+ /** Startup handshake budget; a routed child that cannot confirm model+tools fails fast. */
99
+ const DEFAULT_STARTUP_TIMEOUT_MS = 30_000;
100
+
101
+ /** Poll interval while waiting for the private preflight command to load. */
102
+ const STARTUP_COMMAND_POLL_MS = 200;
103
+
104
+ /** Bound on get_commands polls so an unhealthy child cannot flood its stdin. */
105
+ const MAX_STARTUP_COMMAND_POLLS = 250;
106
+
107
+ const WRAP_UP_MESSAGE =
108
+ "You have reached your budget for this task. Stop all tool use and provide your final answer NOW, " +
109
+ "summarizing what you completed, what remains, and any key findings. This is your last chance to respond.";
110
+
111
+ /** Bounded timer that never keeps the parent process alive. */
112
+ function sleep(ms: number): Promise<void> {
113
+ return new Promise((resolve) => {
114
+ const timer = setTimeout(resolve, Math.max(0, ms));
115
+ timer.unref?.();
116
+ });
117
+ }
118
+
119
+ /** Order-insensitive equality for the finalized/expected tool name sets. */
120
+ function sameNameSet(left: readonly string[], right: readonly string[]): boolean {
121
+ if (left.length !== right.length) return false;
122
+ const observed = new Set(left);
123
+ if (observed.size !== left.length) return false;
124
+ return right.every((name) => observed.has(name));
125
+ }
126
+
127
+ /**
128
+ * Convert a run result into a non-transient startup-capability failure.
129
+ *
130
+ * Uses `PREFLIGHT_FAILURE_STOP_REASON` (never in the transient-retry classification) so a
131
+ * routed child that could not be verified is refused rather than retried into an
132
+ * unverified launch.
133
+ */
134
+ function markStartupFailure(result: TaskResult, code: string, detail: string): TaskResult {
135
+ result.state = "failed";
136
+ result.stopReason = PREFLIGHT_FAILURE_STOP_REASON;
137
+ result.errorMessage = `${STARTUP_FAILURE_RESULT_PREFIX} (${code}): ${detail}`;
138
+ result.exitCode ??= 1;
139
+ result.endedAt = Date.now();
140
+ return result;
141
+ }
142
+
143
+ /** Owns exactly one child Pi process and its process tree. */
144
+ export class ChildRunner {
145
+ /** Live stdin command channel; set while the child process is running. */
146
+ private sendCommand?: (command: unknown) => boolean;
147
+ private readonly graceTurns: number;
148
+ private readonly stallAfterMs: number;
149
+ private readonly stallKillAfterMs: number;
150
+ private readonly startupTimeoutMs: number;
151
+ private readonly backendOverride?: BackendAdapter;
152
+ /** Backend for the in-flight run; set at spawn so steer() uses the right dialect. */
153
+ private backend: BackendAdapter = resolveBackend("pi");
154
+
155
+ constructor(
156
+ private readonly semaphore = new Semaphore(
157
+ defaultConfig.maxActiveProcesses,
158
+ defaultConfig.maxQueuedTasks,
159
+ ),
160
+ private readonly getPiCommand: GetPiCommand = createGetPiCommand(),
161
+ private readonly sessionDir = defaultConfig.sessionDir,
162
+ private readonly onCheckpoint?: (result: Partial<TaskResult>) => void,
163
+ private readonly killGraceMs = defaultConfig.killGraceMs,
164
+ private readonly locks?: ProcessLockManager,
165
+ private readonly runId?: string,
166
+ private readonly parentSessionKey?: string,
167
+ private readonly maxTaskBytes = DEFAULT_MAX_TASK_BYTES,
168
+ options: Pick<
169
+ RunnerOptions,
170
+ "graceTurns" | "stallAfterMs" | "stallKillAfterMs" | "startupTimeoutMs" | "backend"
171
+ > = {},
172
+ ) {
173
+ this.backendOverride = options.backend;
174
+ this.graceTurns = options.graceTurns ?? defaultConfig.graceTurns;
175
+ this.stallAfterMs = options.stallAfterMs ?? defaultConfig.stallAfterMs;
176
+ this.stallKillAfterMs =
177
+ options.stallKillAfterMs ?? defaultConfig.stallKillAfterMs;
178
+ this.startupTimeoutMs = Math.max(
179
+ 0,
180
+ options.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS,
181
+ );
182
+ }
183
+
184
+ /**
185
+ * Queue a steering message into the running child (delivered after the
186
+ * current assistant turn, before the next LLM call). Returns false when the
187
+ * child is not running or its stdin is closed.
188
+ */
189
+ steer(message: string): boolean {
190
+ const command = this.backend.steerCommand?.(message);
191
+ if (command === undefined) return false;
192
+ return this.sendCommand?.(command) === true;
193
+ }
194
+
195
+ async run(spec: TaskSpec, abortSignal?: AbortSignal): Promise<TaskResult> {
196
+ const startedAt = Date.now();
197
+ const result: TaskResult = {
198
+ label: spec.label ?? "subagent",
199
+ task: spec.task,
200
+ model: spec.model,
201
+ routing: spec.routing,
202
+ state: "queued",
203
+ exitCode: null,
204
+ messages: [],
205
+ stderr: "",
206
+ usage: emptyUsage(),
207
+ outputFile: spec.output,
208
+ outputMode: spec.outputMode,
209
+ thinking: spec.thinking,
210
+ profile: spec.profile,
211
+ backend: spec.backend ?? "pi",
212
+ canWrite: spec.canWrite,
213
+ startedAt,
214
+ protocol: {
215
+ headerSeen: false,
216
+ assistantEndSeen: false,
217
+ agentEndSeen: false,
218
+ agentSettledSeen: false,
219
+ validEvents: 0,
220
+ parseErrors: 0,
221
+ },
222
+ };
223
+
224
+ let processHandle: ChildProcess | undefined;
225
+ let slotHeld = false;
226
+ let globalSlot: SlotToken | undefined;
227
+ let forceKillTimer: NodeJS.Timeout | undefined;
228
+ const tempDirs: string[] = [];
229
+ let requestedStop: StopReason | undefined;
230
+ let fatalError: string | undefined;
231
+ let timeoutPhase: TimeoutPhase | undefined;
232
+ let abortHandler: (() => void) | undefined;
233
+ let stderr = "";
234
+ // Backend resolution happens before anything else so parser dialect,
235
+ // capability checks and stdin command shapes all agree.
236
+ const backend =
237
+ this.backendOverride ?? resolveBackend(spec.backend ?? "pi");
238
+ this.backend = backend;
239
+ const parser: BackendParser = backend.createParser();
240
+ let spawned = false;
241
+ let acquiredAt: number | undefined;
242
+ let childStartTime = 0;
243
+ // Graceful budget stop state: after a breach the child is steered to wrap
244
+ // up and allowed `graceTurns` more turns before SIGTERM.
245
+ let pendingBudgetStop:
246
+ { reason: "max_turns" | "max_cost"; deadlineTurns: number } | undefined;
247
+ let wrappedUp = false;
248
+ // Structured-output repair state: one steer-based retry after failed validation.
249
+ let schemaRepairAttempted = false;
250
+ // Stall watchdog state.
251
+ let lastEventAt = Date.now();
252
+ let stallTimer: NodeJS.Timeout | undefined;
253
+ let stalledAt: number | undefined;
254
+
255
+ // ---- Routed-task startup verification state --------------------------------
256
+ // `spec.routing` is added by the extension only for Jev-routed dispatches; the
257
+ // trusted low-level SDK never sets it, so unrouted runs keep the old lifecycle.
258
+ const routed = spec.routing !== undefined;
259
+ let taskPromptSent = false;
260
+ const absoluteDeadline =
261
+ typeof spec.deadline === "number" && Number.isFinite(spec.deadline) ? spec.deadline : undefined;
262
+ const deadlineRemainingMs =
263
+ absoluteDeadline === undefined
264
+ ? undefined
265
+ : Math.max(0, absoluteDeadline - Date.now());
266
+ // The absolute task deadline is honored from the first line of the run and is never
267
+ // reset by a retry, restart or a later phase.
268
+ const effectiveTimeoutMs =
269
+ deadlineRemainingMs === undefined
270
+ ? spec.timeoutMs
271
+ : Math.min(spec.timeoutMs, deadlineRemainingMs);
272
+ type StartupWaiter = {
273
+ test: (update: ProtocolUpdate) => boolean;
274
+ resolve: (update: ProtocolUpdate | null) => void;
275
+ };
276
+ const startupWaiters = new Set<StartupWaiter>();
277
+ const settleStartupWaiters = () => {
278
+ for (const waiter of [...startupWaiters]) {
279
+ startupWaiters.delete(waiter);
280
+ waiter.resolve(null);
281
+ }
282
+ };
283
+ const waitForUpdate = (
284
+ test: StartupWaiter["test"],
285
+ ): Promise<ProtocolUpdate | null> =>
286
+ new Promise((resolve) => {
287
+ startupWaiters.add({ test, resolve });
288
+ });
289
+ let startupTimer: NodeJS.Timeout | undefined;
290
+ let startupTimedOut = false;
291
+ let startupBudgetMs = 0;
292
+ let childExited:
293
+ { code: number | null; signal: NodeJS.Signals | null; error?: Error } | undefined;
294
+
295
+ // Internal signal combines the caller's abort with the run timeout so both
296
+ // interrupt semaphore queue waits. Queue time counts against timeoutMs.
297
+ const internal = new AbortController();
298
+ const onExternalAbort = () => internal.abort();
299
+ const onInternalAbort = () => settleStartupWaiters();
300
+ internal.signal.addEventListener("abort", onInternalAbort, { once: true });
301
+ if (abortSignal?.aborted) internal.abort();
302
+ else
303
+ abortSignal?.addEventListener("abort", onExternalAbort, { once: true });
304
+ const timeout = setTimeout(() => {
305
+ // Record which phase timed out before nightfall.
306
+ timeoutPhase = !slotHeld ? "queued" : !spawned || (routed && !taskPromptSent) ? "starting" : "running";
307
+ requestStop("timeout");
308
+ internal.abort();
309
+ }, effectiveTimeoutMs);
310
+ timeout.unref?.();
311
+
312
+ const release = () => {
313
+ if (slotHeld) {
314
+ slotHeld = false;
315
+ this.semaphore.release();
316
+ }
317
+ if (globalSlot) {
318
+ this.locks?.releaseGlobalSlot(globalSlot);
319
+ globalSlot = undefined;
320
+ }
321
+ };
322
+
323
+ /**
324
+ * Group-kill only when the PID still belongs to our child (start-time
325
+ * identity check guards against PID reuse racing a delayed kill). When
326
+ * identity is unverifiable, fall back to the direct child handle, which
327
+ * Node ties to the real process regardless of PID recycling.
328
+ */
329
+ const pidStillOurs = (pid: number): boolean => {
330
+ if (childStartTime <= 0) return false;
331
+ const live = processStartTime(pid);
332
+ return live > 0 && live === childStartTime;
333
+ };
334
+
335
+ const forceKillTree = () => {
336
+ const pid = processHandle?.pid;
337
+ if (!pid) return;
338
+ try {
339
+ if (process.platform === "win32") {
340
+ const killer = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], {
341
+ shell: false,
342
+ stdio: "ignore",
343
+ });
344
+ killer.unref();
345
+ } else if (
346
+ processHandle &&
347
+ processHandle.exitCode === null &&
348
+ processHandle.signalCode === null
349
+ ) {
350
+ // Child object still live: group id is safe to use.
351
+ process.kill(-pid, "SIGKILL");
352
+ } else if (pidStillOurs(pid)) {
353
+ process.kill(-pid, "SIGKILL");
354
+ }
355
+ // Child exited and identity is unverifiable: skip the group kill (a
356
+ // recycled PID must never be killed); descendants are covered by the
357
+ // exit-path reap that runs while the handle is still authoritative.
358
+ } catch (error: any) {
359
+ if (error?.code !== "ESRCH") {
360
+ try {
361
+ processHandle?.kill("SIGKILL");
362
+ } catch {
363
+ /* best effort */
364
+ }
365
+ }
366
+ }
367
+ };
368
+
369
+ const requestStop = (reason: StopReason) => {
370
+ if (!requestedStop) requestedStop = reason;
371
+ // Cancellation may arrive before spawn. In that case remember the reason,
372
+ // then a second call immediately after spawn performs the actual signal.
373
+ if (forceKillTimer) return;
374
+ const pid = processHandle?.pid;
375
+ if (!pid) return;
376
+ try {
377
+ if (process.platform === "win32") {
378
+ const killer = spawn("taskkill", ["/pid", String(pid), "/T"], {
379
+ shell: false,
380
+ stdio: "ignore",
381
+ });
382
+ killer.unref();
383
+ } else {
384
+ process.kill(-pid, "SIGTERM");
385
+ }
386
+ } catch (error: any) {
387
+ if (error?.code !== "ESRCH") {
388
+ try {
389
+ processHandle?.kill("SIGTERM");
390
+ } catch {
391
+ /* best effort */
392
+ }
393
+ }
394
+ }
395
+ forceKillTimer = setTimeout(forceKillTree, this.killGraceMs);
396
+ forceKillTimer.unref?.();
397
+ };
398
+
399
+ const stopStallWatchdog = () => {
400
+ if (stallTimer) clearInterval(stallTimer);
401
+ stallTimer = undefined;
402
+ };
403
+
404
+ /**
405
+ * Activity-based stall detection: protocol silence for `stallAfterMs`
406
+ * flags the task as stalled (visible in checkpoints/status); continued
407
+ * silence for `stallKillAfterMs` more kills the child so retry can take
408
+ * over. Any protocol event clears the flag.
409
+ */
410
+ const startStallWatchdog = () => {
411
+ if (this.stallAfterMs <= 0 || stallTimer) return;
412
+ const tick = Math.max(
413
+ 1_000,
414
+ Math.min(10_000, Math.floor(this.stallAfterMs / 3)),
415
+ );
416
+ stallTimer = setInterval(() => {
417
+ if (requestedStop) return stopStallWatchdog();
418
+ const silence = Date.now() - lastEventAt;
419
+ if (silence < this.stallAfterMs) {
420
+ if (stalledAt !== undefined) {
421
+ stalledAt = undefined;
422
+ result.stalledSince = undefined;
423
+ progress({ stalledSince: undefined });
424
+ }
425
+ return;
426
+ }
427
+ if (stalledAt === undefined) {
428
+ stalledAt = lastEventAt + this.stallAfterMs;
429
+ result.stalledSince = stalledAt;
430
+ progress({ stalledSince: stalledAt });
431
+ // Cheap liveness probe: a healthy-but-quiet child answers get_state,
432
+ // which itself counts as protocol activity and clears the flag.
433
+ this.sendCommand?.({ type: "get_state" });
434
+ return;
435
+ }
436
+ if (
437
+ this.stallKillAfterMs > 0 &&
438
+ silence >= this.stallAfterMs + this.stallKillAfterMs
439
+ ) {
440
+ stopStallWatchdog();
441
+ requestStop("stalled");
442
+ }
443
+ }, tick);
444
+ stallTimer.unref?.();
445
+ };
446
+
447
+ const cleanup = async () => {
448
+ this.sendCommand = undefined;
449
+ clearTimeout(timeout);
450
+ stopStallWatchdog();
451
+ if (forceKillTimer) clearTimeout(forceKillTimer);
452
+ if (startupTimer) clearTimeout(startupTimer);
453
+ startupTimer = undefined;
454
+ // Pending startup waiters must never keep the run (or a stale session) alive.
455
+ settleStartupWaiters();
456
+ internal.signal.removeEventListener("abort", onInternalAbort);
457
+ abortSignal?.removeEventListener("abort", onExternalAbort);
458
+ if (abortSignal && abortHandler)
459
+ abortSignal.removeEventListener("abort", abortHandler);
460
+ processHandle?.stdout?.removeAllListeners();
461
+ processHandle?.stderr?.removeAllListeners();
462
+ processHandle?.removeAllListeners();
463
+ for (const dir of tempDirs)
464
+ await fs.rm(dir, { recursive: true, force: true }).catch(() => {});
465
+ release();
466
+ };
467
+
468
+ // Transcript joins are O(transcript) — only attach them on structural
469
+ // updates (message boundaries), not per-chunk live-text ticks.
470
+ const progress = (partial: Partial<TaskResult>, withTranscript = false) => {
471
+ Object.assign(result, partial);
472
+ const checkpoint: Partial<TaskResult> = {
473
+ ...result,
474
+ liveText: parser.getLiveText(),
475
+ };
476
+ if (withTranscript) checkpoint.transcript = parser.getTranscript();
477
+ else delete checkpoint.transcript;
478
+ this.onCheckpoint?.(checkpoint);
479
+ };
480
+
481
+ /**
482
+ * Budget breach → graceful wrap-up: steer the child to answer NOW and
483
+ * allow `graceTurns` more turns. SIGTERM fires only when grace is
484
+ * exhausted (or configured to 0, or steering is impossible).
485
+ */
486
+ const handleBudgetBreach = (
487
+ reason: "max_turns" | "max_cost",
488
+ turns: number,
489
+ ) => {
490
+ if (requestedStop || pendingBudgetStop) {
491
+ if (pendingBudgetStop && turns >= pendingBudgetStop.deadlineTurns)
492
+ requestStop(pendingBudgetStop.reason);
493
+ return;
494
+ }
495
+ const grace = spec.graceTurns ?? this.graceTurns;
496
+ if (
497
+ grace <= 0 ||
498
+ !this.sendCommand?.({ type: "steer", message: WRAP_UP_MESSAGE })
499
+ ) {
500
+ requestStop(reason);
501
+ return;
502
+ }
503
+ pendingBudgetStop = { reason, deadlineTurns: turns + grace };
504
+ };
505
+
506
+ const handleUpdates = (updates: ProtocolUpdate[]) => {
507
+ if (updates.length) {
508
+ lastEventAt = Date.now();
509
+ if (stalledAt !== undefined) {
510
+ stalledAt = undefined;
511
+ result.stalledSince = undefined;
512
+ }
513
+ }
514
+ for (const update of updates) {
515
+ // Startup verification waiters are registered before the command is written,
516
+ // so a fast acknowledgement cannot race past its waiter.
517
+ if (startupWaiters.size) {
518
+ for (const waiter of [...startupWaiters]) {
519
+ if (!waiter.test(update)) continue;
520
+ startupWaiters.delete(waiter);
521
+ waiter.resolve(update);
522
+ }
523
+ }
524
+ if (update.type === "session")
525
+ progress({ sessionId: update.sessionId });
526
+ if (update.type === "live-text")
527
+ progress({ liveText: update.liveText });
528
+ if (update.type === "message") {
529
+ result.messages = parser.getMessages();
530
+ result.usage = update.usage;
531
+ progress(
532
+ {
533
+ messages: result.messages,
534
+ usage: result.usage,
535
+ liveText: parser.getLiveText(),
536
+ },
537
+ true,
538
+ );
539
+ if (pendingBudgetStop) {
540
+ if (update.usage.turns >= pendingBudgetStop.deadlineTurns)
541
+ requestStop(pendingBudgetStop.reason);
542
+ } else {
543
+ const budget = this.checkBudgets(spec, result.usage);
544
+ if (budget) handleBudgetBreach(budget, update.usage.turns);
545
+ }
546
+ }
547
+ // Headless children cannot answer extension UI dialogs; cancel so the child never hangs.
548
+ if (update.type === "ui-request")
549
+ this.sendCommand?.({
550
+ type: "extension_ui_response",
551
+ id: update.id,
552
+ cancelled: true,
553
+ });
554
+ if (update.type === "fatal") {
555
+ fatalError = update.error;
556
+ requestStop("fatal");
557
+ }
558
+ // RPC children stay alive until stdin closes; end it once the run settles.
559
+ if (update.type === "agent-settled") {
560
+ // A settle during the wrap-up window means the child finished its
561
+ // final answer in time.
562
+ if (pendingBudgetStop && !requestedStop) wrappedUp = true;
563
+ // Structured-output gate: validate before letting the child exit.
564
+ // Invalid → one steer-based repair round (a fresh prompt keeps the
565
+ // RPC child alive and produces a new settle when it finishes).
566
+ if (spec.outputSchema && !requestedStop && !pendingBudgetStop) {
567
+ const extracted = extractStructuredResult(parser.getLiveText());
568
+ const check =
569
+ extracted.value !== undefined
570
+ ? checkAgainstSchema(extracted.value, spec.outputSchema)
571
+ : {
572
+ ok: false,
573
+ errors: [
574
+ extracted.raw
575
+ ? "json:result block did not parse as JSON"
576
+ : "no json:result block found in the final message",
577
+ ],
578
+ };
579
+ if (!check.ok && !schemaRepairAttempted) {
580
+ schemaRepairAttempted = true;
581
+ if (
582
+ this.sendCommand?.({
583
+ type: "prompt",
584
+ message: repairMessage(check.errors),
585
+ })
586
+ ) {
587
+ lastEventAt = Date.now();
588
+ continue; // repair round in flight: do not close stdin yet
589
+ }
590
+ }
591
+ }
592
+ try {
593
+ processHandle?.stdin?.end();
594
+ } catch {
595
+ /* already closed */
596
+ }
597
+ }
598
+ }
599
+ };
600
+
601
+ const applyTimeoutSemantics = (base: TaskResult): TaskResult => {
602
+ if (requestedStop !== "timeout") return base;
603
+ // Queue timeouts never start work: model them as a clean timeout with phase,
604
+ // not a mysterious execution "failed".
605
+ const phase = timeoutPhase ?? "running";
606
+ return {
607
+ ...base,
608
+ state: "timeout",
609
+ stopReason: "timeout",
610
+ timeoutPhase: phase,
611
+ errorMessage:
612
+ phase === "queued"
613
+ ? "Timed out waiting for a process slot (never started)"
614
+ : phase === "starting"
615
+ ? "Timed out while starting the child process"
616
+ : base.errorMessage || "Timed out while the child was running",
617
+ };
618
+ };
619
+
620
+ try {
621
+ if (internal.signal.aborted) {
622
+ result.state = requestedStop === "timeout" ? "timeout" : "cancelled";
623
+ result.stopReason = requestedStop ?? "cancelled";
624
+ result.timeoutPhase =
625
+ requestedStop === "timeout" ? (timeoutPhase ?? "queued") : undefined;
626
+ result.exitCode = 1;
627
+ result.endedAt = Date.now();
628
+ if (result.state === "timeout" && !result.errorMessage) {
629
+ result.errorMessage =
630
+ "Timed out waiting for a process slot (never started)";
631
+ }
632
+ return result;
633
+ }
634
+
635
+ // A routed spec must carry the finalized model + explicit tool ceiling before
636
+ // anything is spawned; otherwise the child's active set could not be verified.
637
+ if (routed) {
638
+ if (!spec.model?.trim()) {
639
+ return markStartupFailure(
640
+ result,
641
+ "model_missing",
642
+ "A routed subagent task must carry the Jev-selected execution model.",
643
+ );
644
+ }
645
+ if (!Array.isArray(spec.tools)) {
646
+ return markStartupFailure(
647
+ result,
648
+ "tools_missing",
649
+ "A routed subagent task must carry the finalized tool allowlist so the child's active set can be verified.",
650
+ );
651
+ }
652
+ // The absolute task deadline is shared across attempts and is honored here,
653
+ // before a slot is taken or a child is spawned. It is never reset later.
654
+ if (deadlineRemainingMs !== undefined && deadlineRemainingMs <= 0) {
655
+ timeoutPhase = "queued";
656
+ requestStop("timeout");
657
+ internal.abort();
658
+ throw new Error("The task deadline expired before the child could start.");
659
+ }
660
+ }
661
+
662
+ // Global cap (if configured) is checked before the per-session semaphore so
663
+ // a saturated machine rejects early with a clear message. Depth is the
664
+ // parent process nest level (PI_SUBAGENT_DEPTH via parseDepth): shallow tiers
665
+ // reserve capacity so nested spawns cannot deadlock on a full pool.
666
+ if (this.locks) {
667
+ try {
668
+ globalSlot = this.locks.tryAcquireGlobalSlot(
669
+ this.runId ?? "anonymous",
670
+ parseDepth(),
671
+ );
672
+ } catch (error: any) {
673
+ result.state = "failed";
674
+ result.stopReason = "global_limit";
675
+ result.errorMessage = error?.message ?? String(error);
676
+ result.exitCode = 1;
677
+ result.endedAt = Date.now();
678
+ return result;
679
+ }
680
+ }
681
+
682
+ await this.semaphore.acquire(internal.signal);
683
+ slotHeld = true;
684
+ acquiredAt = Date.now();
685
+ result.acquiredAt = acquiredAt;
686
+ if (internal.signal.aborted)
687
+ throw new Error("Subagent cancelled before spawn");
688
+ if (abortSignal) {
689
+ abortHandler = () => requestStop("cancelled");
690
+ abortSignal.addEventListener("abort", abortHandler, { once: true });
691
+ }
692
+ result.state = "running";
693
+ progress({ state: "running", acquiredAt });
694
+
695
+ await fs.mkdir(this.sessionDir, { recursive: true });
696
+ if (internal.signal.aborted)
697
+ throw new Error("Subagent cancelled before spawn");
698
+
699
+ const taskBytes = Buffer.byteLength(spec.task, "utf8");
700
+ if (taskBytes > this.maxTaskBytes) {
701
+ throw new Error(
702
+ `Task exceeds maxTaskBytes (${taskBytes} > ${this.maxTaskBytes}). Pass a shorter objective or raise the limit.`,
703
+ );
704
+ }
705
+
706
+ const invocation = await backend.buildInvocation(spec, {
707
+ sessionDir: this.sessionDir,
708
+ getPiCommand: this.getPiCommand,
709
+ });
710
+ if (invocation.cleanupDirs?.length)
711
+ tempDirs.push(...invocation.cleanupDirs);
712
+ // Invocation construction performs filesystem awaits. Cancellation/deadline must be
713
+ // rechecked before spawning, otherwise an earlier stop had no process to terminate.
714
+ if (internal.signal.aborted) throw new Error("Subagent cancelled before spawn");
715
+ if (absoluteDeadline !== undefined && Date.now() >= absoluteDeadline) {
716
+ timeoutPhase = "starting";
717
+ requestStop("timeout");
718
+ internal.abort();
719
+ throw new Error("Subagent deadline expired before spawn");
720
+ }
721
+
722
+ const depth = Number.parseInt(process.env[DEPTH_ENV_VAR] ?? "0", 10) || 0;
723
+ // Pin the launch identity + depth in env. Children re-register only when
724
+ // depth leaves remaining headroom (enforced in extension + policy too).
725
+ // Encode the child's own spawn allowlist so grandchildren validate against it.
726
+ const spawnEnv =
727
+ spec.spawns === false
728
+ ? ""
729
+ : Array.isArray(spec.spawns)
730
+ ? spec.spawns.join(",")
731
+ : spec.spawns === "*"
732
+ ? "*"
733
+ : undefined;
734
+ // Trusted backend env (e.g. the temporary preflight manifest path) is merged
735
+ // over the inherited environment, but the depth/spawn controls stay authoritative:
736
+ // they are stripped from the backend env and re-applied last.
737
+ const trustedEnv: Record<string, string> = { ...(invocation.env ?? {}) };
738
+ delete trustedEnv[DEPTH_ENV_VAR];
739
+ delete trustedEnv[SPAWNS_ENV_VAR];
740
+ const childEnv: NodeJS.ProcessEnv = {
741
+ ...process.env,
742
+ ...trustedEnv,
743
+ [DEPTH_ENV_VAR]: String(depth + 1),
744
+ ...(spawnEnv !== undefined ? { [SPAWNS_ENV_VAR]: spawnEnv } : {}),
745
+ };
746
+
747
+ processHandle = spawn(invocation.command, invocation.args, {
748
+ cwd: spec.cwd || process.cwd(),
749
+ shell: false,
750
+ detached: process.platform !== "win32",
751
+ stdio: ["pipe", "pipe", "pipe"],
752
+ env: childEnv,
753
+ });
754
+ spawned = true;
755
+
756
+ const pid = processHandle.pid;
757
+ if (pid) {
758
+ childStartTime = processStartTime(pid);
759
+ const identity: ChildProcessIdentity = {
760
+ pid,
761
+ startTime: childStartTime,
762
+ // On POSIX the child is a new process group leader (detached).
763
+ pgid: process.platform === "win32" ? undefined : pid,
764
+ hostname: os.hostname(),
765
+ };
766
+ result.process = identity;
767
+ progress({ process: identity });
768
+ if (this.locks && this.runId) {
769
+ this.locks.writeRunRecord({
770
+ runId: this.runId,
771
+ parentSessionKey: this.parentSessionKey ?? "",
772
+ childSessionId: result.sessionId,
773
+ // Worktree-isolated runs record their checkout so concurrent Pi
774
+ // processes' machine-wide GC sweeps can shield it while we live.
775
+ worktreeCwd: spec.isolation === "worktree" ? spec.cwd : undefined,
776
+ process: {
777
+ pid: identity.pid,
778
+ startTime: identity.startTime,
779
+ pgid: identity.pgid,
780
+ hostname: identity.hostname ?? os.hostname(),
781
+ },
782
+ startedAt: Date.now(),
783
+ state: "running",
784
+ updatedAt: Date.now(),
785
+ });
786
+ }
787
+ }
788
+
789
+ if (requestedStop) requestStop(requestedStop);
790
+ else if (internal.signal.aborted) requestStop("cancelled");
791
+
792
+ // Attach readers BEFORE writing stdin so a chatty child cannot fill the
793
+ // OS pipe buffer and deadlock waiting for us to drain.
794
+ processHandle.stdout?.on("data", (chunk: Buffer) =>
795
+ handleUpdates(parser.feed(chunk)),
796
+ );
797
+ processHandle.stderr?.on("data", (chunk: Buffer) => {
798
+ stderr = (stderr + chunk.toString()).slice(-50 * 1024);
799
+ result.stderr = stderr;
800
+ });
801
+ processHandle.stdin?.on("error", (error: NodeJS.ErrnoException) => {
802
+ // EPIPE is expected when a child fails before consuming stdin.
803
+ if (error.code !== "EPIPE")
804
+ result.errorMessage = `stdin error: ${error.message}`;
805
+ });
806
+ const send = (command: unknown): boolean => {
807
+ const stdin = processHandle?.stdin;
808
+ if (!stdin || !stdin.writable || stdin.destroyed) return false;
809
+ try {
810
+ stdin.write(JSON.stringify(command) + "\n"); // JSONL: LF-delimited, JSON escapes embedded newlines
811
+ return true;
812
+ } catch {
813
+ return false;
814
+ }
815
+ };
816
+ if (!routed) this.sendCommand = send;
817
+
818
+ // The close promise is created before any startup traffic so the handshake, the
819
+ // real task and the final await all observe exactly one exit event.
820
+ const closedPromise = new Promise<{
821
+ code: number | null;
822
+ signal: NodeJS.Signals | null;
823
+ error?: Error;
824
+ }>((resolve) => {
825
+ let settled = false;
826
+ const finish = (value: {
827
+ code: number | null;
828
+ signal: NodeJS.Signals | null;
829
+ error?: Error;
830
+ }) => {
831
+ if (settled) return;
832
+ settled = true;
833
+ resolve(value);
834
+ };
835
+ processHandle!.once("close", (code, signal) =>
836
+ finish({ code, signal }),
837
+ );
838
+ processHandle!.once("error", (error) =>
839
+ finish({ code: 1, signal: null, error }),
840
+ );
841
+ });
842
+ closedPromise.then((value) => {
843
+ childExited = value;
844
+ // A dead child will never answer; unblock startup waiters immediately.
845
+ settleStartupWaiters();
846
+ });
847
+
848
+ type StartupOutcome =
849
+ | { kind: "ok" }
850
+ | { kind: "cancelled" }
851
+ | { kind: "failed"; code: string; detail: string };
852
+
853
+ const describeChildExit = (): string => {
854
+ if (!childExited) return "exit status not observed";
855
+ if (childExited.signal) return `signal ${childExited.signal}`;
856
+ return `exit code ${childExited.code ?? "unknown"}`;
857
+ };
858
+
859
+ /** Stop a child that failed startup verification, bounded by the kill grace. */
860
+ const stopChildForStartupFailure = async () => {
861
+ if (!processHandle) return;
862
+ requestStop("fatal");
863
+ await Promise.race([
864
+ closedPromise,
865
+ new Promise<void>((resolve) => {
866
+ const timer = setTimeout(resolve, Math.max(0, this.killGraceMs));
867
+ timer.unref?.();
868
+ }),
869
+ ]);
870
+ forceKillTree();
871
+ };
872
+
873
+ /**
874
+ * Provider-free startup handshake. Returns `ok` only after the child's active
875
+ * model and tool set were both proven to match the finalized route.
876
+ * Never submits an unverified slash command: an unknown command would be treated
877
+ * as an ordinary model prompt.
878
+ */
879
+ const runStartupPreflight = async (
880
+ manifestPath: string | undefined,
881
+ ): Promise<StartupOutcome> => {
882
+ const interruption = (): StartupOutcome | undefined => {
883
+ if (abortSignal?.aborted || requestedStop === "cancelled") return { kind: "cancelled" };
884
+ if (startupTimedOut || requestedStop === "timeout")
885
+ return { kind: "failed", code: "startup_timeout", detail: startupTimeoutDetail(startupBudgetMs) };
886
+ if (childExited)
887
+ return {
888
+ kind: "failed",
889
+ code: "child_exit",
890
+ detail: `The child process exited during startup verification (${describeChildExit()}).`,
891
+ };
892
+ return undefined;
893
+ };
894
+
895
+ // 1. Read the expectation the backend handed to the child and cross-check it
896
+ // against the spec we are about to enforce (defence in depth).
897
+ let expectation: PreflightExpectation;
898
+ try {
899
+ if (!manifestPath) {
900
+ throw startupFailure(
901
+ "preflight_manifest_missing",
902
+ "The Pi backend did not provide a startup expectation manifest for this routed task.",
903
+ );
904
+ }
905
+ const raw = await fs.readFile(manifestPath, "utf8");
906
+ const parsed = parsePreflightManifest(raw);
907
+ if (!parsed.ok) throw startupFailure(parsed.code, parsed.message);
908
+ if (parsed.manifest.model !== spec.model) {
909
+ throw startupFailure(
910
+ "preflight_manifest_mismatch",
911
+ "The child's startup manifest model did not match the finalized route model.",
912
+ );
913
+ }
914
+ if (!sameNameSet(parsed.manifest.tools, spec.tools ?? [])) {
915
+ throw startupFailure(
916
+ "preflight_manifest_mismatch",
917
+ "The child's startup manifest tool allowlist did not match the finalized route tools.",
918
+ );
919
+ }
920
+ expectation = {
921
+ nonce: parsed.manifest.nonce,
922
+ model: parsed.manifest.model,
923
+ tools: parsed.manifest.tools,
924
+ nestedTools: parsed.manifest.nestedTools,
925
+ ownEntryPaths: ownExtensionEntryCandidates(),
926
+ preflightCommandPath: ownPreflightExtensionPath(),
927
+ };
928
+ } catch (error) {
929
+ const failure = readStartupFailure(error);
930
+ if (failure) return { kind: "failed", ...failure };
931
+ if (error instanceof Error && /cancel|abort/i.test(error.message)) return { kind: "cancelled" };
932
+ return {
933
+ kind: "failed",
934
+ code: "preflight_manifest_unreadable",
935
+ detail: "The startup expectation manifest could not be read.",
936
+ };
937
+ }
938
+
939
+ // 2. Correlated get_commands polling: extensions may still be loading, so an
940
+ // early empty answer is not yet a failure.
941
+ const baseName = preflightCommandBase(expectation.nonce);
942
+ const pollDeadline = Date.now() + Math.max(0, startupBudgetMs);
943
+ let verified: { invocableName: string } | undefined;
944
+ let lastResolution: string | undefined;
945
+ for (let attempt = 1; attempt <= MAX_STARTUP_COMMAND_POLLS; attempt += 1) {
946
+ // Cancellation and child death end the loop immediately; an expired budget
947
+ // falls through to the post-loop diagnosis so the remedy stays specific.
948
+ const stop = interruption();
949
+ if (stop && stop.kind === "cancelled") return stop;
950
+ if (stop && stop.code === "child_exit") return stop;
951
+ if (stop) break;
952
+ const requestId = `pi-subagent-preflight-cmd-${attempt}`;
953
+ const responseWait = waitForUpdate(
954
+ (update) => update.type === "rpc-response" && update.id === requestId,
955
+ );
956
+ if (!send({ type: "get_commands", id: requestId })) {
957
+ return {
958
+ kind: "failed",
959
+ code: "child_stdin_closed",
960
+ detail: "The child's command channel closed before startup verification could run.",
961
+ };
962
+ }
963
+ const update = await responseWait;
964
+ const stopAfterResponse = interruption();
965
+ if (stopAfterResponse && stopAfterResponse.kind === "cancelled") return stopAfterResponse;
966
+ if (stopAfterResponse && stopAfterResponse.code === "child_exit") return stopAfterResponse;
967
+ if (stopAfterResponse) break;
968
+ if (update && update.type === "rpc-response") {
969
+ if (!update.success) {
970
+ return {
971
+ kind: "failed",
972
+ code: "get_commands_rejected",
973
+ detail: "The child host rejected the capability probe required for startup verification.",
974
+ };
975
+ }
976
+ const commands = (update.data as { commands?: unknown } | undefined)?.commands;
977
+ const expectedCommandPaths = expectation.preflightCommandPath
978
+ ? [expectation.preflightCommandPath]
979
+ : [];
980
+ const resolution = resolvePreflightCommand(commands, baseName, expectedCommandPaths);
981
+ if (resolution.ok) {
982
+ verified = { invocableName: resolution.invocableName };
983
+ break;
984
+ }
985
+ lastResolution = summarizeCommandResolution(resolution);
986
+ }
987
+ if (Date.now() >= pollDeadline) break;
988
+ await sleep(STARTUP_COMMAND_POLL_MS);
989
+ }
990
+ if (!verified) {
991
+ const stop = interruption();
992
+ // A definite observation (we saw get_commands answers) gives a better remedy
993
+ // than the generic budget message, but never mask a cancellation or a death.
994
+ if (stop && stop.kind === "cancelled") return stop;
995
+ if (stop && stop.code === "child_exit") return stop;
996
+ if (lastResolution !== undefined) {
997
+ return {
998
+ kind: "failed",
999
+ code: "preflight_command_unavailable",
1000
+ detail: `The private startup command was not available from the expected package source (${lastResolution}).`,
1001
+ };
1002
+ }
1003
+ if (stop) return stop;
1004
+ return {
1005
+ kind: "failed",
1006
+ code: "preflight_command_unavailable",
1007
+ detail: "The private startup command was not available from the expected package source.",
1008
+ };
1009
+ }
1010
+
1011
+ // 3. Invoke only the verified command and require BOTH a successful correlated
1012
+ // response and a nonce-matching typed acknowledgement.
1013
+ const promptId = "pi-subagent-preflight-prompt";
1014
+ // Any typed acknowledgement is accepted here and validated below, so a wrong-nonce
1015
+ // or malformed answer fails fast with a precise reason instead of a generic budget
1016
+ // timeout. There is exactly one child, so no stale acknowledgement can arrive.
1017
+ const ackWait = waitForUpdate((update) => update.type === "preflight-ack");
1018
+ const promptResponseWait = waitForUpdate(
1019
+ (update) => update.type === "rpc-response" && update.id === promptId,
1020
+ );
1021
+ if (!send({ type: "prompt", message: `/${verified.invocableName}`, id: promptId })) {
1022
+ return {
1023
+ kind: "failed",
1024
+ code: "child_stdin_closed",
1025
+ detail: "The child's command channel closed before the startup command could be invoked.",
1026
+ };
1027
+ }
1028
+ const promptResponse = await promptResponseWait;
1029
+ const stopAfterPrompt = interruption();
1030
+ if (stopAfterPrompt) return stopAfterPrompt;
1031
+ if (!promptResponse || promptResponse.type !== "rpc-response" || !promptResponse.success) {
1032
+ return {
1033
+ kind: "failed",
1034
+ code: "preflight_prompt_rejected",
1035
+ detail: "The child rejected its verified startup command.",
1036
+ };
1037
+ }
1038
+ const ackUpdate = await ackWait;
1039
+ const stopAfterAck = interruption();
1040
+ if (!ackUpdate || ackUpdate.type !== "preflight-ack") {
1041
+ if (stopAfterAck && stopAfterAck.kind === "cancelled") return stopAfterAck;
1042
+ if (stopAfterAck && stopAfterAck.code === "child_exit") return stopAfterAck;
1043
+ return {
1044
+ kind: "failed",
1045
+ code: "preflight_ack_missing",
1046
+ detail: startupTimeoutDetail(startupBudgetMs),
1047
+ };
1048
+ }
1049
+ if (stopAfterAck) return stopAfterAck;
1050
+ const ack = parsePreflightAckContent(ackUpdate.content);
1051
+ if (ack === null) {
1052
+ return {
1053
+ kind: "failed",
1054
+ code: "preflight_ack_malformed",
1055
+ detail: "The child's startup acknowledgement was not bounded, valid JSON.",
1056
+ };
1057
+ }
1058
+ if (ack.nonce !== expectation.nonce) {
1059
+ return {
1060
+ kind: "failed",
1061
+ code: "preflight_ack_nonce_mismatch",
1062
+ detail: "The child's startup acknowledgement did not carry this invocation's correlation nonce.",
1063
+ };
1064
+ }
1065
+ const problems = verifyPreflightAck(ack, expectation);
1066
+ if (problems.length > 0) {
1067
+ return { kind: "failed", code: "preflight_ack_rejected", detail: summarizePreflightProblems(problems) };
1068
+ }
1069
+ return { kind: "ok" };
1070
+ };
1071
+
1072
+ let startupOutcome: StartupOutcome = { kind: "ok" };
1073
+ if (routed) {
1074
+ // Bounded by both the local startup budget and the remaining absolute task time.
1075
+ startupBudgetMs = Math.min(
1076
+ this.startupTimeoutMs,
1077
+ Math.max(0, effectiveTimeoutMs - (Date.now() - startedAt)),
1078
+ );
1079
+ startupTimer = setTimeout(() => {
1080
+ startupTimedOut = true;
1081
+ settleStartupWaiters();
1082
+ }, startupBudgetMs);
1083
+ startupTimer.unref?.();
1084
+ startupOutcome = await runStartupPreflight(invocation.env?.[PREFLIGHT_MANIFEST_ENV]);
1085
+ if (startupTimer) {
1086
+ clearTimeout(startupTimer);
1087
+ startupTimer = undefined;
1088
+ }
1089
+ }
1090
+
1091
+ // Keep public steering unavailable until verification, and recheck cancellation
1092
+ // even when the final acknowledgement was delivered in the same microtask turn.
1093
+ if (startupOutcome.kind === "ok" && (internal.signal.aborted || abortSignal?.aborted)) {
1094
+ startupOutcome = { kind: "cancelled" };
1095
+ }
1096
+ if (startupOutcome.kind === "failed") {
1097
+ // Capability mismatch is not transient: never compensate by broadening tools,
1098
+ // choosing another model or retrying into an unverified launch.
1099
+ await stopChildForStartupFailure();
1100
+ throw startupFailure(startupOutcome.code, startupOutcome.detail);
1101
+ }
1102
+ if (startupOutcome.kind === "cancelled") {
1103
+ // Cancelled/timed out during startup: never send the real task prompt.
1104
+ if (!requestedStop) requestStop("cancelled");
1105
+ } else {
1106
+ this.sendCommand = send;
1107
+ taskPromptSent = send({ type: "prompt", message: spec.task });
1108
+ // RPC mode has no session header line; get_state supplies the session id.
1109
+ send({ type: "get_state" });
1110
+ }
1111
+ lastEventAt = Date.now();
1112
+ startStallWatchdog();
1113
+
1114
+ const closed = await closedPromise;
1115
+
1116
+ handleUpdates(parser.flush());
1117
+ // The child owns a dedicated process group. Reap descendants even when the
1118
+ // direct Pi process exits normally after a tool backgrounds work — unless
1119
+ // the task explicitly opted into keeping backgrounded processes alive.
1120
+ if (!spec.keepBackground || requestedStop) forceKillTree();
1121
+ const finalized = parser.finalize(
1122
+ closed.code,
1123
+ closed.signal ?? undefined,
1124
+ stderr,
1125
+ );
1126
+ Object.assign(result, finalized, {
1127
+ label: result.label,
1128
+ task: spec.task,
1129
+ outputFile: spec.output,
1130
+ outputMode: spec.outputMode,
1131
+ thinking: spec.thinking,
1132
+ profile: spec.profile,
1133
+ backend: spec.backend ?? "pi",
1134
+ model: finalized.model ?? result.model ?? spec.model,
1135
+ canWrite: spec.canWrite,
1136
+ process: result.process,
1137
+ startedAt,
1138
+ acquiredAt,
1139
+ endedAt: Date.now(),
1140
+ });
1141
+
1142
+ if (closed.error) {
1143
+ result.state = "failed";
1144
+ result.stopReason = "spawn_error";
1145
+ result.errorMessage = closed.error.message;
1146
+ } else if (requestedStop) {
1147
+ if (requestedStop === "timeout") {
1148
+ Object.assign(result, applyTimeoutSemantics(result));
1149
+ } else if (requestedStop === "cancelled") {
1150
+ result.state = "cancelled";
1151
+ result.stopReason = "cancelled";
1152
+ result.exitCode = closed.code;
1153
+ } else if (requestedStop === "stalled") {
1154
+ // Stall kill is a transient infrastructure failure (retryable), but
1155
+ // completed turns still carry useful output.
1156
+ result.state = result.usage.turns > 0 ? "partial" : "failed";
1157
+ result.stopReason = "stalled";
1158
+ result.exitCode = closed.code ?? 1;
1159
+ result.stalledSince = stalledAt;
1160
+ result.errorMessage = `Child produced no protocol activity for ${Math.round((this.stallAfterMs + this.stallKillAfterMs) / 1000)}s and was stopped`;
1161
+ } else if (BUDGET_STOPS.has(requestedStop) && result.usage.turns > 0) {
1162
+ result.state = "partial";
1163
+ result.stopReason = requestedStop;
1164
+ result.exitCode = closed.code;
1165
+ result.errorMessage = `Stopped by ${requestedStop.replace("_", " ")} budget after the wrap-up grace period; partial output preserved`;
1166
+ } else {
1167
+ result.state = "failed";
1168
+ result.stopReason =
1169
+ requestedStop === "fatal" ? "error" : requestedStop;
1170
+ result.exitCode = closed.code ?? 1;
1171
+ if (fatalError) result.errorMessage = fatalError;
1172
+ }
1173
+ } else if (
1174
+ pendingBudgetStop &&
1175
+ (result.state as TaskResult["state"]) === "completed"
1176
+ ) {
1177
+ // Budget breached, but the child wrapped up its final answer within the
1178
+ // grace turns: a concluded (if budget-limited) result, not a truncation.
1179
+ result.state = "partial";
1180
+ result.stopReason = pendingBudgetStop.reason;
1181
+ result.wrappedUp = true;
1182
+ result.errorMessage = `Reached ${pendingBudgetStop.reason.replace("_", " ")} budget and wrapped up gracefully`;
1183
+ }
1184
+
1185
+ // Structured-output verdict: validate the final text once, after any
1186
+ // repair round. Failure downgrades completed → partial (paid work is
1187
+ // still delivered; the parent sees why it is not machine-readable).
1188
+ if (spec.outputSchema) {
1189
+ const extracted = extractStructuredResult(result.liveText);
1190
+ const check =
1191
+ extracted.value !== undefined
1192
+ ? checkAgainstSchema(extracted.value, spec.outputSchema)
1193
+ : {
1194
+ ok: false,
1195
+ errors: [
1196
+ extracted.raw
1197
+ ? "json:result block did not parse as JSON"
1198
+ : "no json:result block found in the final message",
1199
+ ],
1200
+ };
1201
+ if (check.ok) {
1202
+ result.structuredOutput = extracted.value;
1203
+ } else {
1204
+ result.structuredError = check.errors.slice(0, 10).join("; ");
1205
+ if ((result.state as TaskResult["state"]) === "completed") {
1206
+ result.state = "partial";
1207
+ result.stopReason = "schema_mismatch";
1208
+ result.errorMessage = `Structured output failed validation${schemaRepairAttempted ? " (after one repair round)" : ""}: ${result.structuredError}`;
1209
+ }
1210
+ }
1211
+ }
1212
+
1213
+ if (this.locks && this.runId) {
1214
+ this.locks.markRunTerminal(this.runId, result.state);
1215
+ }
1216
+ return result;
1217
+ } catch (error: any) {
1218
+ // A routed child that could not be verified is a non-transient capability refusal:
1219
+ // plain Error + owned code, never a custom subclass or a transient retry.
1220
+ const startupFailureInfo = readStartupFailure(error);
1221
+ if (startupFailureInfo && requestedStop !== "timeout" && !abortSignal?.aborted) {
1222
+ markStartupFailure(result, startupFailureInfo.code, startupFailureInfo.detail);
1223
+ if (this.locks && this.runId)
1224
+ this.locks.markRunTerminal(this.runId, result.state);
1225
+ return result;
1226
+ }
1227
+ const cancelled =
1228
+ (abortSignal?.aborted && requestedStop !== "timeout") ||
1229
+ /cancel/i.test(String(error?.message));
1230
+ if (requestedStop === "timeout") {
1231
+ result.state = "timeout";
1232
+ result.stopReason = "timeout";
1233
+ result.timeoutPhase =
1234
+ timeoutPhase ?? (!slotHeld ? "queued" : "running");
1235
+ result.errorMessage =
1236
+ result.timeoutPhase === "queued"
1237
+ ? "Timed out waiting for a process slot (never started)"
1238
+ : (error?.message ?? "Timed out");
1239
+ } else {
1240
+ result.state = cancelled ? "cancelled" : "failed";
1241
+ result.stopReason =
1242
+ requestedStop ??
1243
+ (result.state === "cancelled" ? "cancelled" : "error");
1244
+ result.errorMessage = error?.message ?? String(error);
1245
+ }
1246
+ result.exitCode ??= 1;
1247
+ result.endedAt = Date.now();
1248
+ if (this.locks && this.runId)
1249
+ this.locks.markRunTerminal(this.runId, result.state);
1250
+ return result;
1251
+ } finally {
1252
+ await cleanup();
1253
+ }
1254
+ }
1255
+
1256
+ private checkBudgets(
1257
+ spec: TaskSpec,
1258
+ usage: UsageStats,
1259
+ ): "max_turns" | "max_cost" | undefined {
1260
+ // Stop only after a completed turn has pushed usage beyond the configured ceiling.
1261
+ if (spec.maxTurns !== undefined && usage.turns > spec.maxTurns)
1262
+ return "max_turns";
1263
+ if (spec.maxCost !== undefined && usage.cost > spec.maxCost)
1264
+ return "max_cost";
1265
+ return undefined;
1266
+ }
1267
+ }
1268
+
1269
+ /**
1270
+ * Run a single subagent child process.
1271
+ *
1272
+ * **Orphan reclaim:** without `options.locks` **and** `options.runId`, no durable
1273
+ * run record is written under the lock root, so children are invisible to
1274
+ * startup orphan reclaim. Pass both when embedding the runner as a library if you
1275
+ * need crash recovery. No implicit default lock manager is created (opt-in only).
1276
+ */
1277
+ export function runSubagent(
1278
+ spec: TaskSpec,
1279
+ options: RunnerOptions & { signal?: AbortSignal } = {},
1280
+ ): Promise<TaskResult> {
1281
+ return new ChildRunner(
1282
+ options.semaphore,
1283
+ options.getPiCommand,
1284
+ options.sessionDir,
1285
+ options.onCheckpoint,
1286
+ options.killGraceMs,
1287
+ options.locks,
1288
+ options.runId,
1289
+ options.parentSessionKey,
1290
+ options.maxTaskBytes,
1291
+ {
1292
+ graceTurns: options.graceTurns,
1293
+ stallAfterMs: options.stallAfterMs,
1294
+ stallKillAfterMs: options.stallKillAfterMs,
1295
+ startupTimeoutMs: options.startupTimeoutMs,
1296
+ backend: options.backend,
1297
+ },
1298
+ ).run(spec, options.signal);
1299
+ }