pi-crew 0.11.11 → 0.11.13

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 (50) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/dist/index.mjs +551 -519
  3. package/package.json +1 -1
  4. package/src/config/config-validation.ts +9 -0
  5. package/src/config/defaults.ts +7 -0
  6. package/src/config/env-vars.ts +24 -0
  7. package/src/config/types.ts +11 -0
  8. package/src/extension/registration/tool-registration.ts +7 -0
  9. package/src/extension/registration/verify-gate-tool.ts +263 -0
  10. package/src/extension/team-tool/run-deadline.ts +22 -1
  11. package/src/extension/team-tool/run.ts +38 -2
  12. package/src/extension/team-tool-types.ts +4 -0
  13. package/src/extension/team-tool.ts +31 -3
  14. package/src/prompt/inbox-poll.ts +141 -3
  15. package/src/runtime/broker/crew-broker.ts +48 -2
  16. package/src/runtime/broker/protocol/lock-busy.ts +47 -1
  17. package/src/runtime/broker/protocol/request-parsers.ts +8 -1
  18. package/src/runtime/budget-enforcement.ts +36 -20
  19. package/src/runtime/child-pi/child-pi-constants.ts +24 -0
  20. package/src/runtime/child-pi/child-pi-kill.ts +59 -2
  21. package/src/runtime/child-pi/child-pi-rpc.ts +1021 -0
  22. package/src/runtime/child-pi/child-pi-steering.ts +10 -8
  23. package/src/runtime/child-pi/child-pi-streams.ts +69 -19
  24. package/src/runtime/child-pi/child-pi.ts +29 -30
  25. package/src/runtime/child-pi/owned-process.ts +434 -0
  26. package/src/runtime/child-pi/postmortem-registry.ts +134 -0
  27. package/src/runtime/classifier/ambient-noise-gate.ts +126 -0
  28. package/src/runtime/classifier/classifier-service.ts +277 -61
  29. package/src/runtime/dispatch-batch.ts +32 -0
  30. package/src/runtime/live-session/live-agent-control.ts +2 -0
  31. package/src/runtime/live-session/live-agent-manager.ts +72 -2
  32. package/src/runtime/live-session/live-session-runtime.ts +435 -40
  33. package/src/runtime/merge-loop.ts +63 -35
  34. package/src/runtime/recovery/crash-recovery.ts +141 -3
  35. package/src/runtime/recovery/retry-executor.ts +29 -0
  36. package/src/runtime/run-tracker.ts +28 -0
  37. package/src/runtime/stale-reconciler.ts +36 -1
  38. package/src/runtime/task-runner.ts +103 -0
  39. package/src/runtime/verification/classifier-pre-gate.ts +262 -0
  40. package/src/runtime/verification/verification-gates.ts +5 -1
  41. package/src/runtime/verification/verify-gate.ts +394 -0
  42. package/src/schema/config-schema.ts +13 -0
  43. package/src/state/contracts.ts +18 -0
  44. package/src/state/coordination/mailbox.ts +203 -55
  45. package/src/state/stores/sqlite-run-state.ts +378 -0
  46. package/src/state/stores/state-store.ts +134 -6
  47. package/src/ui/agents-jobs-browser.ts +154 -8
  48. package/src/ui/events-state-source.ts +358 -0
  49. package/src/ui/run-snapshot-cache.ts +87 -3
  50. package/src/ui/widget/widget-model.ts +13 -2
@@ -0,0 +1,1021 @@
1
+ /**
2
+ * child-pi-rpc.ts — U14 (upgrade-spec 2026-10-09): child-pi protocol v2 —
3
+ * `--mode rpc` transport.
4
+ *
5
+ * Replaces the `--mode json -p` stdio contract with pi's maintained RPC
6
+ * channel (JSONL over stdio, 33 commands — PoC CONFIRMED round 8, live-verified
7
+ * round 9/10 on 1.0.4 + 1.1.0). The WIRE is owned by the SDK's `RpcClient`
8
+ * (imported from the package — never hand-rolled here); this module owns the
9
+ * pi-crew integration concerns only:
10
+ *
11
+ * - spawn: RpcClient spawns `node <absolute cliPath> --mode rpc <flags>`.
12
+ * cliPath MUST be the ABSOLUTE bundle entry (`dist/bundle/cli.js`,
13
+ * resolved via getPiSpawnCommand — the RpcClient default `dist/cli.js` is
14
+ * RELATIVE and cold-boots through jiti at ~11.7s, PoC nuance 2).
15
+ * - env: pi-crew's allowlist model (per-task key scoping + secret scrubbing
16
+ * via buildFinalChildPiSpawnOptions) is PRESERVED — RpcClient merges
17
+ * `{...process.env, ...options.env}` at spawn time, so the start helper
18
+ * splices `process.env` for the SYNCHRONOUS spawn prefix of start() and
19
+ * restores it before any other host code can run (single JS thread).
20
+ * - prompt: ALWAYS carries streamingBehavior ("followUp") — a prompt while
21
+ * streaming WITHOUT it is a live protocol error (verify round 9 P2);
22
+ * clear_queue runs BEFORE every prompt so a stale idle-steer receipt
23
+ * (steer on an idle agent QUEUES instead of erroring — PoC nuance 1)
24
+ * can never inject into the next run.
25
+ * - steer: the soft turn-limit advisory rides `client.steer()` (receipt
26
+ * `disposition: started|queued|handled`), GATED by isStreaming — same
27
+ * nuance 1 hazard as above. The hard turn-limit stays killProcessTree.
28
+ * - events: RpcClient.onEvent feeds the SAME ChildPiLineObserver pipeline
29
+ * as json stdout (observeEvent), minus `extension_ui_request` /
30
+ * `extension_ui_response` spam from host extensions (filtered by type at
31
+ * this consumer — PoC nuance 6). There is NO readiness signal: the first
32
+ * response can take ~3.4s (host extension load) and every timer here
33
+ * already tolerates that (response-timeout floor is minutes, RpcClient
34
+ * send timeout is 30s — PoC nuance 3).
35
+ * - lifecycle: the six watchdog timer constructs (child-pi-timers.ts),
36
+ * kill-tree (child-pi-kill.ts), active-child + cleanup registration, the
37
+ * settle/crash-classification contract, and session tail-recovery are the
38
+ * SAME constructs the json branch uses (bảng KHÔNG đổi §0). Completion
39
+ * maps `agent_settled` → graceful stdin-close (PoC: orderly dispose, exit
40
+ * 0 in ~73ms) with the final-drain SIGTERM ceiling as backstop; abort
41
+ * fires the graceful `client.abort()` receipt AND the existing
42
+ * kill-tree escalation unchanged.
43
+ * - pool (PHASE 1, default OFF via PI_CREW_CHILD_PI_POOL): consecutive
44
+ * runs sharing one pool KEY may REUSE a live idle rpc process (1 process
45
+ * = 1 session, MANY prompts — PoC proven). The key is agentId — the
46
+ * task-runner path passes task.id (child-executor.ts), so in practice
47
+ * that means RETRIES of one task; the delegate-spawn path keys by the
48
+ * stable subId and is the only true same-agent cross-task reuse today.
49
+ * Phase-1 limitation: per-run env control vars (broker token, steering
50
+ * file, scratchpad gates) are pinned at first spawn; argv flags apply
51
+ * only to the first task.
52
+ *
53
+ * Mode selection: PI_CREW_CHILD_PI_MODE=rpc opts a spawn into this transport
54
+ * (json remains the default until the wave-2 full-suite gate flips it — the
55
+ * flag is the "json fallback" regression lever for BOTH directions).
56
+ *
57
+ * Process-group note (→ U15): RpcClient spawns WITHOUT detached/setsid, so
58
+ * killProcessPid's negative-pgid fast path falls back to a direct pid kill
59
+ * (ESRCH on the missing group). Child-of-child tree reaping via process-group
60
+ * ownership is U15's dedicated spec, not changed here.
61
+ */
62
+
63
+ import type { ChildProcess } from "node:child_process";
64
+ import * as fs from "node:fs";
65
+ import * as path from "node:path";
66
+ import { getCrewEnv, getCrewEnvBool } from "../../config/env-vars.ts";
67
+ import { registerChildProcess, unregisterChildProcess } from "../../extension/crew-cleanup.ts";
68
+ import type { WorkerExitStatus } from "../../state/types.ts";
69
+ import { logInternalError } from "../../utils/internal-error.ts";
70
+ import { redactSecretString } from "../../utils/redaction.ts";
71
+ import { BoundedTail } from "../compaction/compact-stages/bounded-tail.ts";
72
+ import { cleanupTempDir } from "../model/pi-args.ts";
73
+ import { getPiSpawnCommand } from "../pi-spawn.ts";
74
+ import { attachPostExitStdioGuard, trySignalChild } from "../process/post-exit-stdio-guard.ts";
75
+ import { classifyProcessCrash } from "../recovery/crash-classification.ts";
76
+ import type { ChildPiRunInput, ChildPiRunResult } from "./child-pi.ts";
77
+ import { FINAL_DRAIN_MS, HARD_KILL_MS, POST_EXIT_STDIO_GUARD_MS, resolveResponseTimeoutMs } from "./child-pi-constants.ts";
78
+ import { clearHardKillTimer, killProcessTree, registerActiveChild, unregisterActiveChild } from "./child-pi-kill.ts";
79
+ import { buildFinalChildPiSpawnOptions, type SpawnContext } from "./child-pi-spawn.ts";
80
+ import { ChildPiSteeringController, STEER_WRAP_UP_MESSAGE } from "./child-pi-steering.ts";
81
+ import { ChildPiLineObserver, isFinalAssistantEvent } from "./child-pi-streams.ts";
82
+ import { createChildPiTimers } from "./child-pi-timers.ts";
83
+ import {
84
+ type deriveSessionPaths,
85
+ recoverLastAssistantFromSession,
86
+ type SessionRecoveryInfo,
87
+ shouldAttemptSessionRecovery,
88
+ } from "./session-recovery.ts";
89
+
90
+ /**
91
+ * RpcClient surface used by this module. The runtime class arrives via a LAZY
92
+ * dynamic import (SDK value-import house policy for src/runtime, see
93
+ * session-recovery.ts). `process` is a PUBLIC field on the runtime class but
94
+ * PRIVATE in the SDK .d.ts — PoC nuance 4 (stop() drops the exit code, so the
95
+ * lifecycle must read client.process directly) is therefore exercised through
96
+ * an explicit structural cast at the read site, not through this interface.
97
+ */
98
+ type RpcClientInstance = {
99
+ start(): Promise<void>;
100
+ stop(): Promise<void>;
101
+ onEvent(listener: (event: unknown) => void): () => void;
102
+ getStderr(): string;
103
+ prompt(message: string, images?: unknown[], streamingBehavior?: "steer" | "followUp"): Promise<string>;
104
+ steer(message: string, images?: unknown[]): Promise<{ success?: boolean; data?: { disposition?: string } }>;
105
+ followUp(message: string, images?: unknown[]): Promise<{ success?: boolean; data?: { disposition?: string } }>;
106
+ abort(): Promise<void>;
107
+ clearQueue(): Promise<{ steering: string[]; followUp: string[] }>;
108
+ };
109
+
110
+ /**
111
+ * U14 PoC nuance 6: host extensions emit `extension_ui_request` (×11 in the
112
+ * PoC) and the `extension_ui_response` subprotocol echo that a headless
113
+ * consumer can never answer — drop them BY TYPE here so they never reach the
114
+ * observer/transcript. The ask-gate wiring (U14 change #3, spec acceptance)
115
+ * re-opens this set deliberately when that lands.
116
+ */
117
+ export const RPC_DROPPED_EVENT_TYPES: ReadonlySet<string> = new Set(["extension_ui_request", "extension_ui_response"]);
118
+
119
+ /** PI_CREW_CHILD_PI_MODE=rpc selects this transport (json default phase 1). */
120
+ export function resolveChildPiRpcMode(): boolean {
121
+ return getCrewEnv("PI_CREW_CHILD_PI_MODE") === "rpc";
122
+ }
123
+
124
+ /** PI_CREW_CHILD_PI_POOL=1 opts into warm rpc process reuse (default OFF). */
125
+ export function resolveChildPiRpcPoolEnabled(): boolean {
126
+ return getCrewEnvBool("PI_CREW_CHILD_PI_POOL") === true;
127
+ }
128
+
129
+ /**
130
+ * Derive the RpcClient argv from the json-mode worker argv: strip the leading
131
+ * `--mode json -p` cluster (RpcClient prepends `--mode rpc` itself) and the
132
+ * trailing `@task.md` positional (the task text rides the `prompt` command —
133
+ * stdin JSONL is not world-readable like argv, so the G3 spill rationale does
134
+ * not apply). Every other flag (session identity, model, hermetic/extension
135
+ * gates, trust pin, tool cuts) applies to rpc mode identically.
136
+ */
137
+ export function buildRpcClientArgs(builtArgs: string[]): string[] {
138
+ let args = builtArgs;
139
+ if (args[0] === "--mode" && args[1] === "json") {
140
+ args = args.slice(args[2] === "-p" ? 3 : 2);
141
+ }
142
+ const last = args[args.length - 1];
143
+ if (typeof last === "string" && last.startsWith("@")) {
144
+ args = args.slice(0, -1);
145
+ }
146
+ return args;
147
+ }
148
+
149
+ /**
150
+ * Resolve the ABSOLUTE pi cli script for RpcClient's `cliPath` (PoC nuance 2:
151
+ * the RpcClient default `dist/cli.js` is RELATIVE — jiti cold boot ~11.7s).
152
+ * getPiSpawnCommand already resolves the installed package's bundle entry
153
+ * (`dist/bundle/cli.js`, honoring PI_TEAMS_PI_BIN). When resolution falls back
154
+ * to the bare `pi` PATH command there is no absolute script to hand over →
155
+ * undefined → the caller stays on the json transport (fail-safe, logged).
156
+ */
157
+ export function resolveRpcCliPath(): string | undefined {
158
+ const spec = getPiSpawnCommand([]);
159
+ if (spec.command === process.execPath && spec.args[0] && path.isAbsolute(spec.args[0])) {
160
+ return spec.args[0];
161
+ }
162
+ return undefined;
163
+ }
164
+
165
+ // ── Worker process pool (phase 1, opt-in) ────────────────────────────────
166
+
167
+ interface RpcPoolEntry {
168
+ client: RpcClientInstance;
169
+ proc: ChildProcess;
170
+ /** True while the pooled process is idle between runs (reusable). */
171
+ idle: boolean;
172
+ }
173
+
174
+ const rpcPool = new Map<string, RpcPoolEntry>();
175
+
176
+ function poolKeyFor(input: ChildPiRunInput): string {
177
+ return input.agentId ?? input.agent.name;
178
+ }
179
+
180
+ /**
181
+ * Take a pooled client for reuse when it is alive AND idle. MINOR-1
182
+ * (wave-2 review) hardening: liveness and idleness are SEPARATE conditions.
183
+ * A BUSY-but-alive entry belongs to a run that has not settled — its process
184
+ * must NOT be stopped or evicted here (phase-1 dispatch is sequential, but
185
+ * phase-2 crash-resume/concurrent same-key runs reach exactly this path;
186
+ * evicting would SIGTERM the in-flight run's process). Dead entries are
187
+ * evicted (the watchdog owns their failure); busy entries are left in place
188
+ * and the caller falls back to a fresh spawn.
189
+ */
190
+ function acquirePooledRpcClient(key: string): RpcPoolEntry | undefined {
191
+ const entry = rpcPool.get(key);
192
+ if (!entry) return undefined;
193
+ const stdin = entry.proc.stdin;
194
+ const procAlive = entry.proc.exitCode === null && entry.proc.signalCode === null && !!stdin && !stdin.destroyed;
195
+ if (!procAlive) {
196
+ rpcPool.delete(key);
197
+ void entry.client.stop().catch(() => {
198
+ /* eviction best-effort — the kill-tree/watchdog owns the rest */
199
+ });
200
+ return undefined;
201
+ }
202
+ if (!entry.idle) return undefined; // busy-but-alive: NEVER evict/stop
203
+ entry.idle = false;
204
+ return entry;
205
+ }
206
+
207
+ /** Mark a pooled entry idle again after its run settled (process stays live). */
208
+ function releasePooledRpcClient(key: string, entry: RpcPoolEntry): void {
209
+ if (rpcPool.get(key) === entry) entry.idle = true;
210
+ }
211
+
212
+ /** Drop every pooled entry and gracefully stop the processes (tests/shutdown). */
213
+ export function clearRpcPool(): void {
214
+ for (const [, entry] of rpcPool) {
215
+ void entry.client.stop().catch(() => {
216
+ /* best-effort */
217
+ });
218
+ }
219
+ rpcPool.clear();
220
+ }
221
+
222
+ /**
223
+ * MINOR-3 (wave-2 review): process-scoped exit hook for pooled processes,
224
+ * attached ONCE per process (WeakSet guard — reuse runs must not stack
225
+ * hooks): evicts the pool entry and releases the GLOBAL registrations
226
+ * (active-child / hard-kill timer / cleanup registry). Needed because the
227
+ * run-scoped handlers are detached at settle while the warm process
228
+ * deliberately OUTLIVES its runs — something process-scoped must own the
229
+ * cleanup when the shared process finally dies (idle in the pool, during a
230
+ * later run, or at clearRpcPool()). Unregister calls are idempotent, so a
231
+ * double fire alongside a still-attached run-scoped exit handler is safe.
232
+ */
233
+ const poolExitHookedProcs = new WeakSet<object>();
234
+ function attachPoolExitHook(poolKey: string, proc: ChildProcess): void {
235
+ if (poolExitHookedProcs.has(proc)) return;
236
+ poolExitHookedProcs.add(proc);
237
+ proc.once("exit", () => {
238
+ const entry = rpcPool.get(poolKey);
239
+ if (entry?.proc === proc) rpcPool.delete(poolKey);
240
+ if (proc.pid) {
241
+ unregisterActiveChild(proc.pid);
242
+ clearHardKillTimer(proc.pid);
243
+ unregisterChildProcess(proc.pid);
244
+ }
245
+ });
246
+ }
247
+
248
+ /** MINOR-4 marker var — see verifyRpcSpawnEnvSplice (never allowlisted). */
249
+ const RPC_ENV_SPLICE_CANARY = "PI_CREW_RPC_ENV_SPLICE_CANARY";
250
+
251
+ /**
252
+ * MINOR-4 (wave-2 review): the env-splice contract below assumes RpcClient
253
+ * start() spawns in its SYNCHRONOUS prefix. If a future SDK delays the spawn
254
+ * past its first await, process.env has already been RESTORED and the child
255
+ * silently inherits the FULL parent env — regressing the allowlist/per-task
256
+ * key scoping model. Post-spawn canary: the parent env temporarily carries a
257
+ * marker var that is ABSENT from the allowlisted childEnv; if /proc/<pid>/environ
258
+ * (Linux only — best-effort elsewhere) still shows it AFTER start() resolved
259
+ * (the RPC wire answered, so exec has happened and environ is stable), the
260
+ * splice did not cover the actual spawn → the caller kills the child and
261
+ * fails loudly instead of leaking. Exported for unit tests.
262
+ */
263
+ export function verifyRpcSpawnEnvSplice(proc: ChildProcess | null): boolean {
264
+ if (!proc?.pid || process.platform !== "linux") return true;
265
+ let environ: string;
266
+ try {
267
+ environ = fs.readFileSync(`/proc/${proc.pid}/environ`, "utf-8");
268
+ } catch {
269
+ return true; // unobservable (raced exit, permissions) — best-effort
270
+ }
271
+ const leaked = environ.split("\0").some((entry) => entry.startsWith(`${RPC_ENV_SPLICE_CANARY}=`));
272
+ return !leaked;
273
+ }
274
+
275
+ // ── Run orchestrator ─────────────────────────────────────────────────────
276
+
277
+ export interface ChildPiRpcRunPlan {
278
+ input: ChildPiRunInput;
279
+ /** Task text after inherit-context prepend — delivered via the prompt command. */
280
+ effectiveTask: string;
281
+ /** Spawn context from prepareSpawnContext (args/env/tempDir). */
282
+ ctx: SpawnContext;
283
+ /** W2 session identity for crash-path tail recovery (already on argv). */
284
+ workerSession: ReturnType<typeof deriveSessionPaths> | undefined;
285
+ sessionRecoveryEnabled: boolean;
286
+ }
287
+
288
+ /**
289
+ * RPC transport entry point. Returns a settled result, or null when rpc
290
+ * cannot run here (no absolute cli script) so the caller keeps the json path.
291
+ */
292
+ export async function tryChildPiRpcRun(plan: ChildPiRpcRunPlan): Promise<ChildPiRunResult | null> {
293
+ const cliPath = resolveRpcCliPath();
294
+ if (!cliPath) {
295
+ logInternalError(
296
+ "child-pi.rpc-cli-unresolved",
297
+ new Error("no absolute pi cli script for RpcClient — staying on json transport"),
298
+ `spawnCommand=${getPiSpawnCommand([]).command}`,
299
+ "warn",
300
+ );
301
+ return null;
302
+ }
303
+ // SDK value import (src/runtime house policy — session-recovery.ts header).
304
+ // The host process already has the module cached, so the dynamic import
305
+ // resolves in ~0ms; only a cold standalone test pays module load.
306
+ // LAZY: registered with scripts/check-lazy-imports.mjs (runtime dynamic import)
307
+ const { RpcClient } = await import("@earendil-works/pi-coding-agent");
308
+ const pooled = resolveChildPiRpcPoolEnabled() ? acquirePooledRpcClient(poolKeyFor(plan.input)) : undefined;
309
+ const Ctor = RpcClient as unknown as new (options: unknown) => RpcClientInstance;
310
+ return runRpcSession(Ctor, plan, cliPath, pooled);
311
+ }
312
+
313
+ /** Local SEC-1 twin of child-pi.ts redactStderrExcerpt (cycle-free import). */
314
+ function redactExcerpt(stderr: string, maxChars: number): string {
315
+ return redactSecretString(stderr.slice(-maxChars));
316
+ }
317
+
318
+ async function runRpcSession(
319
+ RpcClientCtor: new (options: unknown) => RpcClientInstance,
320
+ plan: ChildPiRpcRunPlan,
321
+ cliPath: string,
322
+ pooled: RpcPoolEntry | undefined,
323
+ ): Promise<ChildPiRunResult> {
324
+ const { input, effectiveTask, ctx } = plan;
325
+ const poolKey = poolKeyFor(input);
326
+ const spawnOptions = buildFinalChildPiSpawnOptions(input.cwd, ctx.mergedEnv, ctx.builtEnv, input.model);
327
+ // RpcClient types env as Record<string,string> — drop undefined entries.
328
+ const childEnv: Record<string, string> = {};
329
+ for (const [k, v] of Object.entries(spawnOptions.env ?? {})) {
330
+ if (v !== undefined) childEnv[k] = v;
331
+ }
332
+
333
+ let client: RpcClientInstance;
334
+ if (pooled) {
335
+ client = pooled.client;
336
+ } else {
337
+ client = new RpcClientCtor({
338
+ cliPath,
339
+ cwd: spawnOptions.cwd,
340
+ env: childEnv,
341
+ args: buildRpcClientArgs(ctx.builtArgs),
342
+ });
343
+ }
344
+
345
+ // ── start (fresh spawns only) ─────────────────────────────────────────
346
+ if (!pooled) {
347
+ // Env-splice: RpcClient spawns with `{...process.env, ...options.env}`
348
+ // (rpc-client.js start()) — WITHOUT this splice the child would inherit
349
+ // the FULL parent env, silently regressing the allowlist/per-task key
350
+ // scoping model. The spawn happens in the SYNCHRONOUS prefix of
351
+ // start(), before its first await, so restoring before awaiting keeps
352
+ // the splice invisible to every other reader of process.env (single
353
+ // JS thread — no interleaving is possible inside this sync block).
354
+ // MINOR-4: the canary below pins that assumption — if a future SDK
355
+ // moves the spawn past its first await, verifyRpcSpawnEnvSplice()
356
+ // detects the leaked full-parent-env child post-spawn (see below).
357
+ const savedEnv = process.env;
358
+ delete childEnv[RPC_ENV_SPLICE_CANARY]; // belt & braces — never allowlisted
359
+ savedEnv[RPC_ENV_SPLICE_CANARY] = "1";
360
+ let startPromise: Promise<void>;
361
+ try {
362
+ process.env = childEnv as unknown as NodeJS.ProcessEnv;
363
+ try {
364
+ startPromise = client.start();
365
+ } finally {
366
+ process.env = savedEnv;
367
+ }
368
+ } finally {
369
+ delete savedEnv[RPC_ENV_SPLICE_CANARY];
370
+ }
371
+ try {
372
+ await startPromise;
373
+ } catch (error) {
374
+ const stderr = client.getStderr?.() ?? "";
375
+ const message = `RPC child Pi failed to start: ${error instanceof Error ? error.message : String(error)}. Stderr: ${redactExcerpt(stderr, 500) || "(none)"}`;
376
+ try {
377
+ input.onLifecycleEvent?.({
378
+ type: "spawn_error",
379
+ error: message,
380
+ ts: new Date().toISOString(),
381
+ stderrExcerpt: redactExcerpt(stderr, 500) || undefined,
382
+ });
383
+ } catch {
384
+ /* listener errors never mask the start failure */
385
+ }
386
+ return {
387
+ exitCode: null,
388
+ stdout: "",
389
+ stderr: redactExcerpt(stderr, 10_000),
390
+ error: message,
391
+ exitStatus: {
392
+ exitCode: null,
393
+ cancelled: false,
394
+ timedOut: false,
395
+ killed: false,
396
+ cleanupErrors: [],
397
+ finalDrainMs: input.finalDrainMs ?? FINAL_DRAIN_MS,
398
+ crashClass: classifyProcessCrash({
399
+ exitCode: null,
400
+ cancelled: false,
401
+ timedOut: false,
402
+ spawnError: error instanceof Error ? error : new Error(String(error)),
403
+ stderrSnippet: stderr ? redactExcerpt(stderr, 1000) : undefined,
404
+ }).crashClass,
405
+ },
406
+ };
407
+ }
408
+ }
409
+ // PoC nuance 4: read client.process DIRECTLY (RpcClient.stop() drops the
410
+ // exit code — unusable for lifecycle). Private in the .d.ts, public at
411
+ // runtime (rpc-client.js field) — hence the structural cast.
412
+ const child = (client as unknown as { process: ChildProcess | null }).process;
413
+ if (!child) {
414
+ return {
415
+ exitCode: null,
416
+ stdout: "",
417
+ stderr: "",
418
+ error: "RPC child Pi start returned no process",
419
+ exitStatus: {
420
+ exitCode: null,
421
+ cancelled: false,
422
+ timedOut: false,
423
+ killed: false,
424
+ cleanupErrors: [],
425
+ finalDrainMs: input.finalDrainMs ?? FINAL_DRAIN_MS,
426
+ },
427
+ };
428
+ }
429
+ // MINOR-4: post-spawn canary (start() resolved = the RPC wire answered =
430
+ // exec happened, so /proc/<pid>/environ is stable). A canary hit means the
431
+ // sync-spawn assumption broke and the child carries the FULL parent env —
432
+ // kill it immediately and fail loudly; never register or prompt it.
433
+ if (!pooled && !verifyRpcSpawnEnvSplice(child)) {
434
+ killProcessTree(child.pid, child);
435
+ const message =
436
+ "RPC env allowlist splice failed post-spawn verification (child inherited the full parent env — SDK spawn contract drifted); process killed";
437
+ try {
438
+ input.onLifecycleEvent?.({
439
+ type: "spawn_error",
440
+ pid: child.pid,
441
+ error: message,
442
+ ts: new Date().toISOString(),
443
+ });
444
+ } catch {
445
+ /* listener errors never mask the leak failure */
446
+ }
447
+ return {
448
+ exitCode: null,
449
+ stdout: "",
450
+ stderr: "",
451
+ error: message,
452
+ exitStatus: {
453
+ exitCode: null,
454
+ cancelled: false,
455
+ timedOut: false,
456
+ killed: true,
457
+ cleanupErrors: [],
458
+ finalDrainMs: input.finalDrainMs ?? FINAL_DRAIN_MS,
459
+ crashClass: classifyProcessCrash({
460
+ exitCode: null,
461
+ cancelled: false,
462
+ timedOut: false,
463
+ spawnError: new Error(message),
464
+ }).crashClass,
465
+ },
466
+ };
467
+ }
468
+
469
+ // ── registration (same bookkeeping as the json branch) ────────────────
470
+ if (!pooled) {
471
+ if (child.pid) {
472
+ registerActiveChild(child.pid, child);
473
+ registerChildProcess(child.pid, input.runId ?? `untracked-run-${child.pid}`, input.agentId ?? `untracked-agent-${child.pid}`);
474
+ }
475
+ }
476
+ if (child.pid) input.onSpawn?.(child.pid);
477
+ input.onLifecycleEvent?.({ type: "spawned", pid: child.pid, ts: new Date().toISOString() });
478
+ // Pool-scoped exit hook (MINOR-3): eviction + global unregistration, ONCE
479
+ // per process — the entry dies with the process, whoever killed it.
480
+ if (pooled) attachPoolExitHook(poolKey, child);
481
+
482
+ // The run's pool entry: the ACQUIRED reuse target, or — on a fresh spawn
483
+ // that finishes cleanly with the pool enabled — an entry REGISTERED at
484
+ // agent_settled so the next same-agent task can reuse this process.
485
+ let poolEntry: RpcPoolEntry | undefined = pooled;
486
+ try {
487
+ return await new Promise<ChildPiRunResult>((resolve) => {
488
+ const stdoutTail = new BoundedTail();
489
+ const stderrTail = new BoundedTail();
490
+ let settled = false;
491
+ let childExited = false;
492
+ let postExitGuardCleanup: (() => void) | undefined;
493
+ let stdinEnded = false;
494
+ let isStreaming = false;
495
+ let sawFinalAssistant = false;
496
+ const finalDrainMs = input.finalDrainMs ?? FINAL_DRAIN_MS;
497
+ const hardKillMs = input.hardKillMs ?? HARD_KILL_MS;
498
+ let finalDrainArmed = false;
499
+ let lastStdoutActivityMonotonicMs = performance.now();
500
+ let finalDrainFiredMonotonicMs: number | undefined;
501
+ const spawnMonotonicMs = performance.now();
502
+ let finalAssistantEventMonotonicMs: number | undefined;
503
+ const responseTimeoutMs = resolveResponseTimeoutMs(input);
504
+ let responseTimeoutHit = false;
505
+ let forcedFinalDrain = false;
506
+ let abortRequested = input.signal?.aborted === true;
507
+ let hardKilled = false;
508
+ const cleanupErrors: string[] = [];
509
+ const steeringController = new ChildPiSteeringController(input.maxTurns, input.graceTurns);
510
+ let abortDueToParentSignal = false;
511
+ const onParentAbort = (): void => {
512
+ abortDueToParentSignal = true;
513
+ };
514
+ input.signal?.addEventListener("abort", onParentAbort, { once: true });
515
+
516
+ const {
517
+ restartNoResponseTimer,
518
+ clearNoResponseTimer,
519
+ clearFinalDrainTimers,
520
+ armFinalDrain,
521
+ hasFinalDrainTimer,
522
+ armCancelHardKill,
523
+ clearAll,
524
+ } = createChildPiTimers({
525
+ child,
526
+ input,
527
+ responseTimeoutMs,
528
+ finalDrainMs,
529
+ hardKillMs,
530
+ stdoutTail,
531
+ stderrTail,
532
+ cleanupErrors,
533
+ getSettle: () => settle,
534
+ redactStderrExcerpt: redactExcerpt,
535
+ state: {
536
+ getSettled: () => settled,
537
+ getChildExited: () => childExited,
538
+ setResponseTimeoutHit: (value) => {
539
+ responseTimeoutHit = value;
540
+ },
541
+ getHardKilled: () => hardKilled,
542
+ setHardKilled: (value) => {
543
+ hardKilled = value;
544
+ },
545
+ setForcedFinalDrain: (value) => {
546
+ forcedFinalDrain = value;
547
+ },
548
+ getLastStdoutActivityMonotonicMs: () => lastStdoutActivityMonotonicMs,
549
+ setFinalDrainFiredMonotonicMs: (value) => {
550
+ finalDrainFiredMonotonicMs = value;
551
+ },
552
+ getAbortRequested: () => abortRequested,
553
+ },
554
+ });
555
+ restartNoResponseTimer();
556
+ const lineObserver = new ChildPiLineObserver({
557
+ ...input,
558
+ onStdoutLine: (line) => {
559
+ input.onStdoutLine?.(line);
560
+ },
561
+ onJsonEvent: (event) => {
562
+ input.onJsonEvent?.(event);
563
+ },
564
+ });
565
+
566
+ const endStdinGracefully = (): void => {
567
+ if (stdinEnded) return;
568
+ stdinEnded = true;
569
+ try {
570
+ child.stdin?.end();
571
+ } catch (error) {
572
+ logInternalError("child-pi.rpc-stdin-end", error, `pid=${child.pid}`);
573
+ }
574
+ };
575
+
576
+ const clearPostExitGuard = (): void => {
577
+ if (postExitGuardCleanup) {
578
+ postExitGuardCleanup();
579
+ postExitGuardCleanup = undefined;
580
+ }
581
+ };
582
+
583
+ const recoverForSettle = async (settleResult: ChildPiRunResult): Promise<SessionRecoveryInfo | undefined> => {
584
+ if (!plan.workerSession || !plan.sessionRecoveryEnabled) return undefined;
585
+ if (!shouldAttemptSessionRecovery(settleResult, hardKilled)) return undefined;
586
+ try {
587
+ return (
588
+ (await recoverLastAssistantFromSession(plan.workerSession.sessionDir, plan.workerSession.sessionId)) ?? undefined
589
+ );
590
+ } catch {
591
+ return undefined;
592
+ }
593
+ };
594
+
595
+ const buildExitStatus = (result: { exitCode: number | null }): WorkerExitStatus => ({
596
+ exitCode: result.exitCode,
597
+ cancelled: abortRequested,
598
+ timedOut: responseTimeoutHit,
599
+ killed: hardKilled,
600
+ ...(finalDrainArmed || forcedFinalDrain
601
+ ? {
602
+ finalDrainArmed,
603
+ forcedFinalDrain,
604
+ finalDrainFiredMonotonicMs,
605
+ }
606
+ : {}),
607
+ cleanupErrors,
608
+ finalDrainMs,
609
+ });
610
+
611
+ const settle = (result: ChildPiRunResult): Promise<void> => {
612
+ if (settled) return Promise.resolve();
613
+ settled = true;
614
+ clearAll();
615
+ clearPostExitGuard();
616
+ // MINOR-3 (wave-2 review): a pooled process deliberately OUTLIVES its
617
+ // run — detach every run-scoped listener NOW so (a) the settled run
618
+ // never receives a second exit/close lifecycle event when the shared
619
+ // process dies later, (b) a stale handler cannot re-arm this run's
620
+ // no-response timer from a LATER run's traffic, and (c) listeners do
621
+ // not accumulate per reuse. Process-scoped cleanup (pool eviction +
622
+ // global unregistration) rides attachPoolExitHook instead. Removal
623
+ // during a handler's own dispatch is safe (future emits only).
624
+ unsubscribeEvents();
625
+ child.stderr?.removeListener("data", onStderrData);
626
+ child.removeListener("error", onChildError);
627
+ child.removeListener("exit", onChildExit);
628
+ child.removeListener("close", onChildClose);
629
+ input.signal?.removeEventListener("abort", abort);
630
+ input.signal?.removeEventListener("abort", onParentAbort);
631
+ if (poolEntry) releasePooledRpcClient(poolKey, poolEntry);
632
+ return lineObserver
633
+ .flush()
634
+ .then(async () => {
635
+ try {
636
+ cleanupTempDir(ctx.tempDir);
637
+ } catch (error) {
638
+ cleanupErrors.push(error instanceof Error ? error.message : String(error));
639
+ }
640
+ const recoveredFromSession = await recoverForSettle(result);
641
+ try {
642
+ resolve({
643
+ ...result,
644
+ rawFinalText: lineObserver.getRawFinalText(),
645
+ intermediateFindings: lineObserver.getIntermediateFindings(),
646
+ ...(recoveredFromSession ? { recoveredFromSession } : {}),
647
+ exitStatus: result.exitStatus ?? buildExitStatus(result),
648
+ });
649
+ } catch (resolveError) {
650
+ logInternalError(
651
+ "child-pi.rpc-settle-resolve",
652
+ resolveError,
653
+ `result=${JSON.stringify({ exitCode: result.exitCode })}`,
654
+ );
655
+ }
656
+ })
657
+ .catch(async (flushError) => {
658
+ logInternalError(
659
+ "child-pi.rpc-settle-flush-failed",
660
+ flushError,
661
+ `result=${JSON.stringify({ exitCode: result.exitCode })}`,
662
+ );
663
+ try {
664
+ cleanupTempDir(ctx.tempDir);
665
+ } catch (error) {
666
+ cleanupErrors.push(error instanceof Error ? error.message : String(error));
667
+ }
668
+ const recoveredFromSession = await recoverForSettle(result);
669
+ try {
670
+ resolve({
671
+ ...result,
672
+ rawFinalText: lineObserver.getRawFinalText(),
673
+ intermediateFindings: lineObserver.getIntermediateFindings(),
674
+ ...(recoveredFromSession ? { recoveredFromSession } : {}),
675
+ exitStatus: result.exitStatus ?? buildExitStatus(result),
676
+ });
677
+ } catch (resolveError) {
678
+ logInternalError(
679
+ "child-pi.rpc-settle-resolve",
680
+ resolveError,
681
+ `result=${JSON.stringify({ exitCode: result.exitCode })}`,
682
+ );
683
+ }
684
+ });
685
+ };
686
+
687
+ const abort = (): void => {
688
+ abortRequested = true;
689
+ clearNoResponseTimer();
690
+ // Graceful hint first: the abort receipt is settle-gated on the
691
+ // agent side (verify round 9 P1) — fire-and-forget, because the
692
+ // watchdog below (kill-tree + 200ms fast-escalate) is the real
693
+ // enforcement and its timing must NOT change (bảng KHÔNG đổi §0).
694
+ void client.abort().catch((error) => {
695
+ logInternalError(
696
+ "child-pi.rpc-abort-receipt-failed",
697
+ error instanceof Error ? error : new Error(String(error)),
698
+ `pid=${child.pid}`,
699
+ );
700
+ });
701
+ killProcessTree(child.pid, child);
702
+ if (process.platform !== "win32") {
703
+ trySignalChild(child, "SIGTERM");
704
+ }
705
+ try {
706
+ child.kill(process.platform === "win32" ? undefined : "SIGTERM");
707
+ } catch {
708
+ // Ignore kill races.
709
+ }
710
+ armCancelHardKill();
711
+ };
712
+
713
+ const onAgentSettled = (): void => {
714
+ isStreaming = false;
715
+ if (settled || childExited) return;
716
+ // Pool registration (phase 1, opt-in): a fresh process that finished
717
+ // its run cleanly and is still live becomes the agent's reuse
718
+ // candidate — and a pool-enabled run NEVER closes stdin (the warm
719
+ // process must survive for the next task). The exit listener evicts
720
+ // dead entries on the acquire side's behalf.
721
+ if (!poolEntry && resolveChildPiRpcPoolEnabled() && child.exitCode === null && child.stdin && !child.stdin.destroyed) {
722
+ poolEntry = { client, proc: child, idle: false };
723
+ rpcPool.set(poolKey, poolEntry);
724
+ attachPoolExitHook(poolKey, child);
725
+ }
726
+ if (poolEntry) {
727
+ // Warm path (acquired OR just registered): the process STAYS alive
728
+ // — settle the run here (exit 0 — the run itself completed; empty
729
+ // output keeps json parity: no final assistant event ≠ transport
730
+ // failure). The no-response timer is the hung-settle backstop.
731
+ void settle({
732
+ exitCode: 0,
733
+ stdout: "",
734
+ stderr: stderrTail.value(),
735
+ steered: steeringController.isSoftLimitReached(),
736
+ exitStatus: buildExitStatus({ exitCode: 0 }),
737
+ });
738
+ } else {
739
+ // PoC shutdown contract: stdin-close → orderly dispose → exit 0
740
+ // (~73ms). The final-drain SIGTERM ceiling stays armed (if it was)
741
+ // as the backstop for a process that refuses to exit.
742
+ endStdinGracefully();
743
+ }
744
+ };
745
+
746
+ const unsubscribeEvents = client.onEvent((event) => {
747
+ try {
748
+ if (!event || typeof event !== "object") return;
749
+ const record = event as Record<string, unknown>;
750
+ if (typeof record.type !== "string") return;
751
+ if (RPC_DROPPED_EVENT_TYPES.has(record.type)) return; // PoC nuance 6
752
+ if (!steeringController.isHardAbortInitiated()) restartNoResponseTimer();
753
+ lastStdoutActivityMonotonicMs = performance.now();
754
+ if (record.type === "turn_end") {
755
+ // steeringFile is deliberately undefined in rpc mode — the
756
+ // advisory rides client.steer() below instead of the polled
757
+ // JSONL file.
758
+ const action = steeringController.onTurnEnd(child.pid, child, undefined);
759
+ if (action.kind === "hardAbort") {
760
+ killProcessTree(action.pid, action.child);
761
+ } else if (action.kind === "steer" && isStreaming) {
762
+ // U14 PoC nuance 1: steer while IDLE queues into the
763
+ // NEXT run — only steer a live run (isStreaming gate).
764
+ void client
765
+ .steer(STEER_WRAP_UP_MESSAGE)
766
+ .then((receipt) => {
767
+ logInternalError(
768
+ "child-pi.rpc-steer-receipt",
769
+ new Error(`disposition=${receipt?.data?.disposition ?? "unknown"}`),
770
+ `pid=${child.pid}`,
771
+ "debug",
772
+ );
773
+ })
774
+ .catch((error) => {
775
+ logInternalError(
776
+ "child-pi.rpc-steer-failed",
777
+ error instanceof Error ? error : new Error(String(error)),
778
+ `pid=${child.pid}`,
779
+ );
780
+ });
781
+ }
782
+ } else if (record.type === "agent_settled") {
783
+ onAgentSettled();
784
+ }
785
+ lineObserver.observeEvent(event);
786
+ if (!isFinalAssistantEvent(event) || childExited || settled || hasFinalDrainTimer()) return;
787
+ finalAssistantEventMonotonicMs = performance.now();
788
+ sawFinalAssistant = true;
789
+ // MINOR-2 (wave-2 review): the drain ceiling must not apply when
790
+ // the pool is ENABLED — a FRESH spawn under PI_CREW_CHILD_PI_POOL=1
791
+ // registers into the pool at agent_settled and must survive a
792
+ // settle slower than finalDrainMs (the no-response timer stays
793
+ // the backstop). Gating on the acquired `pooled` param alone
794
+ // SIGTERM'd exactly those fresh spawns and silently cost warm reuse.
795
+ if (!pooled && !resolveChildPiRpcPoolEnabled()) {
796
+ finalDrainArmed = true;
797
+ armFinalDrain();
798
+ }
799
+ } catch (error) {
800
+ logInternalError("child-pi.rpc-event", error instanceof Error ? error : new Error(String(error)), `pid=${child.pid}`);
801
+ }
802
+ });
803
+
804
+ // stderr is owned by RpcClient (its buffer feeds our excerpts); a
805
+ // second listener only mirrors into the bounded tail — additive.
806
+ // MINOR-3: run-scoped (named for the settle() detach).
807
+ const onStderrData = (chunk: Buffer): void => {
808
+ if (!steeringController.isHardAbortInitiated()) restartNoResponseTimer();
809
+ stderrTail.push(chunk.toString("utf-8"));
810
+ };
811
+ child.stderr?.on("data", onStderrData);
812
+
813
+ // MINOR-3: run-scoped (named for the settle() detach).
814
+ const onChildError = (error: Error): void => {
815
+ const stderr = stderrTail.value();
816
+ const processError = new Error(
817
+ `RPC child Pi process error: ${error.message}. Stderr: ${redactExcerpt(stderr, 500) || "(none)"}`,
818
+ );
819
+ try {
820
+ input.onLifecycleEvent?.({
821
+ type: "spawn_error",
822
+ pid: child.pid,
823
+ error: processError.message,
824
+ ts: new Date().toISOString(),
825
+ stderrExcerpt: redactExcerpt(stderr, 500) || undefined,
826
+ });
827
+ } catch (err) {
828
+ logInternalError("child-pi.on-lifecycle-event", err, `event=error, pid=${child.pid}`);
829
+ }
830
+ void settle({
831
+ exitCode: null,
832
+ stdout: "",
833
+ stderr,
834
+ error: processError.message,
835
+ exitStatus: {
836
+ ...buildExitStatus({ exitCode: null }),
837
+ crashClass: classifyProcessCrash({
838
+ exitCode: null,
839
+ cancelled: abortRequested,
840
+ timedOut: responseTimeoutHit,
841
+ spawnError: error,
842
+ stderrSnippet: stderr ? redactExcerpt(stderr, 1000) : undefined,
843
+ }).crashClass,
844
+ },
845
+ });
846
+ };
847
+ child.on("error", onChildError);
848
+
849
+ const onChildExit = (code: number | null, signal: NodeJS.Signals | null): void => {
850
+ const stderr = stderrTail.value();
851
+ if (child.pid) {
852
+ unregisterActiveChild(child.pid);
853
+ clearHardKillTimer(child.pid);
854
+ unregisterChildProcess(child.pid);
855
+ }
856
+ const abnormalExit = code !== 0 && code !== null;
857
+ const isUnexpectedExit = !childExited && !settled && !responseTimeoutHit && !abortRequested && abnormalExit;
858
+ const exitError = isUnexpectedExit
859
+ ? new Error(
860
+ `RPC child Pi exited unexpectedly (code=${code ?? "null"} signal=${signal ?? "null"}). Stderr: ${redactExcerpt(stderr, 1000) || "(none)"}`,
861
+ )
862
+ : null;
863
+ try {
864
+ input.onLifecycleEvent?.({
865
+ type: "exit",
866
+ pid: child.pid,
867
+ exitCode: code,
868
+ ts: new Date().toISOString(),
869
+ error: exitError?.message,
870
+ stderrExcerpt: isUnexpectedExit ? redactExcerpt(stderr, 1000) || undefined : undefined,
871
+ ...(signal ? { signal } : {}),
872
+ ...(finalDrainArmed || forcedFinalDrain
873
+ ? {
874
+ diagnostic: {
875
+ finalDrainArmed,
876
+ forcedFinalDrain,
877
+ finalDrainFiredMonotonicMs,
878
+ finalAssistantEventMonotonicMs,
879
+ exitMonotonicMs: performance.now() - spawnMonotonicMs,
880
+ },
881
+ }
882
+ : {}),
883
+ });
884
+ } catch (err) {
885
+ logInternalError("child-pi.on-lifecycle-event", err, `event=exit, pid=${child.pid}`);
886
+ }
887
+ childExited = true;
888
+ clearNoResponseTimer();
889
+ clearFinalDrainTimers();
890
+ if (!postExitGuardCleanup) {
891
+ postExitGuardCleanup = attachPostExitStdioGuard(child, {
892
+ idleMs: POST_EXIT_STDIO_GUARD_MS,
893
+ hardMs: HARD_KILL_MS,
894
+ });
895
+ }
896
+ };
897
+ child.on("exit", onChildExit);
898
+
899
+ const onChildClose = (exitCode: number | null): void => {
900
+ const stderr = stderrTail.value();
901
+ if (child.pid) {
902
+ unregisterActiveChild(child.pid);
903
+ clearHardKillTimer(child.pid);
904
+ unregisterChildProcess(child.pid);
905
+ }
906
+ try {
907
+ input.onLifecycleEvent?.({
908
+ type: "close",
909
+ pid: child.pid,
910
+ exitCode,
911
+ ts: new Date().toISOString(),
912
+ });
913
+ } catch (err) {
914
+ logInternalError("child-pi.on-lifecycle-event", err, `event=close, pid=${child.pid}`);
915
+ }
916
+ const timeoutError =
917
+ responseTimeoutHit && !stderr.trim()
918
+ ? {
919
+ error: `RPC child Pi produced no new output for ${responseTimeoutMs}ms; process was terminated as unresponsive.`,
920
+ }
921
+ : responseTimeoutHit && stderr.trim()
922
+ ? { error: `RPC child Pi timed out after ${responseTimeoutMs}ms with stderr: ${redactExcerpt(stderr, 500)}` }
923
+ : undefined;
924
+ if (forcedFinalDrain && !timeoutError && exitCode !== 0) {
925
+ logInternalError(
926
+ "child-pi.final-drain-zero-exit",
927
+ new Error(`Child exit code overridden to 0 after forced final drain (original=${exitCode})`),
928
+ `pid=${child.pid}, finalDrainMs=${finalDrainMs}`,
929
+ );
930
+ }
931
+ const finalExitCode = forcedFinalDrain && !timeoutError ? 0 : exitCode;
932
+ const wasGraceAborted =
933
+ steeringController.isSoftLimitReached() &&
934
+ steeringController.getTurnCount() >=
935
+ (steeringController.getMaxTurns() ?? 0) + (steeringController.getGraceTurns() ?? 5);
936
+ const wasParentAborted = abortDueToParentSignal && !wasGraceAborted;
937
+ const crashClassification = classifyProcessCrash({
938
+ exitCode: finalExitCode,
939
+ signal: child.signalCode ?? undefined,
940
+ cancelled: abortRequested,
941
+ timedOut: responseTimeoutHit,
942
+ killed: hardKilled,
943
+ spawnError: undefined,
944
+ stderrSnippet: stderr ? redactExcerpt(stderr, 1000) : undefined,
945
+ });
946
+ void settle({
947
+ exitCode: finalExitCode,
948
+ stdout: stdoutTail.value(),
949
+ stderr,
950
+ ...(timeoutError ? { error: timeoutError.error } : {}),
951
+ aborted: wasGraceAborted || wasParentAborted,
952
+ steered: steeringController.isSoftLimitReached() && !wasGraceAborted,
953
+ exitStatus: {
954
+ ...buildExitStatus({ exitCode: finalExitCode }),
955
+ crashClass: crashClassification.crashClass,
956
+ },
957
+ });
958
+ };
959
+ child.on("close", onChildClose);
960
+
961
+ input.signal?.addEventListener("abort", abort, { once: true });
962
+
963
+ // ── prompt delivery ────────────────────────────────────────────
964
+ void (async () => {
965
+ try {
966
+ // PoC nuance 1: clear_queue BEFORE every prompt — a steer that
967
+ // arrived while idle was QUEUED (not errored) and would inject
968
+ // into THIS run otherwise. Round-trip is tolerated by the
969
+ // no-response timer (no readiness signal — nuance 3).
970
+ const cleared = await client.clearQueue();
971
+ if (cleared.steering.length > 0 || cleared.followUp.length > 0) {
972
+ logInternalError(
973
+ "child-pi.rpc-stale-queue-cleared",
974
+ new Error(`steering=${cleared.steering.length} followUp=${cleared.followUp.length}`),
975
+ `pid=${child.pid}`,
976
+ "warn",
977
+ );
978
+ }
979
+ // ALWAYS pass streamingBehavior (P2 LIVE): a prompt while
980
+ // streaming without it is a protocol error. "followUp" never
981
+ // interrupts a live run — the watchdog owns hard enforcement.
982
+ const disposition = await client.prompt(effectiveTask, undefined, "followUp");
983
+ if (disposition === "handled") {
984
+ // An extension consumed the prompt; no agent run started and
985
+ // no agent_settled will arrive (SDK contract) — fail the run
986
+ // instead of waiting on the watchdog.
987
+ void settle({
988
+ exitCode: 1,
989
+ stdout: "",
990
+ stderr: stderrTail.value(),
991
+ error: "RPC prompt was handled by an extension (no agent run started)",
992
+ exitStatus: buildExitStatus({ exitCode: 1 }),
993
+ });
994
+ return;
995
+ }
996
+ isStreaming = true;
997
+ } catch (error) {
998
+ // rpcAgentStarted mirrors the stdio-transport DR5 guard: if any
999
+ // session event was already observed the turn HAD begun, so the
1000
+ // failure is non-retryable from the caller's perspective.
1001
+ const started = sawFinalAssistant || finalAssistantEventMonotonicMs !== undefined;
1002
+ void settle({
1003
+ exitCode: null,
1004
+ stdout: "",
1005
+ stderr: stderrTail.value(),
1006
+ error: `RPC prompt delivery failed: ${error instanceof Error ? error.message : String(error)}`,
1007
+ ...(started ? { rpcAgentStarted: true } : {}),
1008
+ exitStatus: buildExitStatus({ exitCode: null }),
1009
+ });
1010
+ }
1011
+ })();
1012
+ });
1013
+ } finally {
1014
+ // ctx.tempDir is cleaned inside settle(); guard against settle() never
1015
+ // being reached (start() threw before the Promise body — handled above,
1016
+ // but keep the json-branch finally shape for symmetry).
1017
+ if (ctx.tempDir && fs.existsSync(ctx.tempDir)) {
1018
+ cleanupTempDir(ctx.tempDir);
1019
+ }
1020
+ }
1021
+ }