@cr1ms0n/pi-subagent 0.8.1

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.
Files changed (46) hide show
  1. package/CHANGELOG.md +352 -0
  2. package/LICENSE +21 -0
  3. package/README.md +543 -0
  4. package/docs/ARCHITECTURE.md +125 -0
  5. package/docs/COST-ACCOUNTING.md +66 -0
  6. package/docs/PLAN.md +325 -0
  7. package/docs/RELEASING.md +32 -0
  8. package/docs/ROADMAP.md +252 -0
  9. package/docs/SECURITY.md +85 -0
  10. package/docs/UI-OVERHAUL.md +186 -0
  11. package/docs/UX.md +141 -0
  12. package/extensions/subagent.ts +1 -0
  13. package/package.json +58 -0
  14. package/skills/subagent/SKILL.md +103 -0
  15. package/src/agents.ts +285 -0
  16. package/src/backend.ts +146 -0
  17. package/src/backends/claude.ts +384 -0
  18. package/src/backends/codex.ts +330 -0
  19. package/src/backends/index.ts +26 -0
  20. package/src/backends/pi.ts +94 -0
  21. package/src/btw.ts +34 -0
  22. package/src/config.ts +254 -0
  23. package/src/distill.ts +222 -0
  24. package/src/extension.ts +1527 -0
  25. package/src/format.ts +365 -0
  26. package/src/index.ts +60 -0
  27. package/src/launch.ts +120 -0
  28. package/src/maintenance.ts +6 -0
  29. package/src/model-policy.ts +157 -0
  30. package/src/notifications.ts +106 -0
  31. package/src/orchestrator.ts +247 -0
  32. package/src/output.ts +124 -0
  33. package/src/persistence.ts +334 -0
  34. package/src/policy.ts +500 -0
  35. package/src/process-lock.ts +687 -0
  36. package/src/protocol.ts +290 -0
  37. package/src/registry.ts +632 -0
  38. package/src/runner.ts +850 -0
  39. package/src/schema.ts +166 -0
  40. package/src/semaphore.ts +123 -0
  41. package/src/structured.ts +169 -0
  42. package/src/transcript.ts +360 -0
  43. package/src/types.ts +197 -0
  44. package/src/ui.ts +545 -0
  45. package/src/usage.ts +274 -0
  46. package/src/worktree.ts +753 -0
package/src/runner.ts ADDED
@@ -0,0 +1,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
+
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
+ }