pi-crew 0.11.6 → 0.11.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.11.6",
3
+ "version": "0.11.7",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -8,7 +8,7 @@
8
8
  "access": "public"
9
9
  },
10
10
  "engines": {
11
- "node": ">=22.0.0"
11
+ "node": ">=22.19.0"
12
12
  },
13
13
  "repository": {
14
14
  "type": "git",
@@ -77,6 +77,11 @@ const LIMIT_CEILINGS = {
77
77
  // (10_000 turns), which capped the effective timeout at 10s and silently disabled
78
78
  // any larger value (e.g. 300_000 = 5min) via parsePositiveInteger returning undefined.
79
79
  runtimeTaskTimeoutMs: 24 * 60 * 60 * 1000,
80
+ // Handoff budget (est tokens, chars/4) for the dynamic.dependencyContext
81
+ // prompt layer. Above the ceiling the budget is treated as the OFF-switch
82
+ // (untrimmed render), mirroring the render-site semantics in
83
+ // task-output-context.ts.
84
+ runtimeHandoffBudgetTokens: 1_000_000,
80
85
  nestingMaxSlots: 64,
81
86
  nestingMaxDepth: 10,
82
87
  } as const;
@@ -325,6 +330,21 @@ function parseModelFallbackConfig(value: unknown): CrewRuntimeConfig["modelFallb
325
330
  return Object.values(modelFallback).some((entry) => entry !== undefined) ? modelFallback : undefined;
326
331
  }
327
332
 
333
+ /**
334
+ * Handoff budget for the dynamic.dependencyContext prompt layer (est tokens,
335
+ * chars/4). Deliberately NOT parsePositiveInteger: out-of-range values are
336
+ * dropped silently (undefined → render site falls back to default 1800)
337
+ * rather than rejecting the whole runtime block. The ≤0 / >1_000_000
338
+ * OFF-switch is expressible only via env PI_CREW_HANDOFF_BUDGET_TOKENS
339
+ * (env bypasses this parse and resolves first); the config schema documents
340
+ * 1..1_000_000 as the valid range.
341
+ */
342
+ function parseHandoffBudgetTokens(value: unknown): number | undefined {
343
+ if (typeof value !== "number" || !Number.isInteger(value)) return undefined;
344
+ if (value < 1 || value > LIMIT_CEILINGS.runtimeHandoffBudgetTokens) return undefined;
345
+ return value;
346
+ }
347
+
328
348
  function parseRuntimeConfig(value: unknown): CrewRuntimeConfig | undefined {
329
349
  const obj = asRecord(value);
330
350
  if (!obj) return { inheritContext: true } as CrewRuntimeConfig;
@@ -362,10 +382,18 @@ function parseRuntimeConfig(value: unknown): CrewRuntimeConfig | undefined {
362
382
  return Object.values(parsed).some((v) => v !== undefined) ? parsed : undefined;
363
383
  })(),
364
384
  excludeContextBash: parseWithSchema(Type.Boolean(), obj.excludeContextBash),
385
+ handoffBudgetTokens: parseHandoffBudgetTokens(obj.handoffBudgetTokens),
365
386
  agentExtensions: parseStringList(obj.agentExtensions),
366
387
  isolationPolicy: parseIsolationPolicy(obj.isolationPolicy),
367
388
  surface: parseSurfacePolicy(obj.surface),
368
389
  modelFallback: parseModelFallbackConfig(obj.modelFallback),
390
+ // F19-1 discipline: every key declared in types.ts + schema MUST be
391
+ // emitted here or the config key is dead (runtime.sessionRecovery —
392
+ // W2/P1-1; runtime.workerTransport — W7/P2-3, experimental;
393
+ // runtime.hermeticWorkers — R3-19/D5 reversal, hermetic spawns).
394
+ sessionRecovery: parseWithSchema(Type.Boolean(), obj.sessionRecovery),
395
+ hermeticWorkers: parseWithSchema(Type.Boolean(), obj.hermeticWorkers),
396
+ workerTransport: parseWithSchema(Type.Union([Type.Literal("stdio"), Type.Literal("rpc")]), obj.workerTransport),
369
397
  };
370
398
  return Object.values(runtime).some((entry) => entry !== undefined) ? runtime : undefined;
371
399
  }
@@ -317,6 +317,11 @@ export const CREW_ENV_VARS: Record<string, CrewEnvVarSpec> = {
317
317
  name: "PI_CREW_DISABLE_RESULT_READ_CACHE",
318
318
  doc: "'1' bypasses the per-run result-artifact read cache (R10-1 bypass control; task-output-context.ts createResultArtifactReadCache)",
319
319
  },
320
+ PI_CREW_HANDOFF_BUDGET_TOKENS: {
321
+ name: "PI_CREW_HANDOFF_BUDGET_TOKENS",
322
+ parser: "int",
323
+ doc: "est-token (chars/4) budget for the dynamic.dependencyContext prompt layer; beats runtime.handoffBudgetTokens, default 1800; resolved ≤0 or >1_000_000 disables the trim (task-output-context.ts renderDependencyOutputContext)",
324
+ },
320
325
  PI_CREW_MOCK_LIVE_SESSION: {
321
326
  name: "PI_CREW_MOCK_LIVE_SESSION",
322
327
  doc: "'success' mocks a successful live-session (live-session-runtime.ts:558, runtime-resolver.ts:38/116)",
@@ -440,6 +445,24 @@ export const CREW_ENV_VARS: Record<string, CrewEnvVarSpec> = {
440
445
  name: "PI_CREW_AGENT_EVENTS_PATH",
441
446
  doc: "per-agent events log (<stateRoot>/agents/<taskId>/events.jsonl) written by the surface worker recorder (surface-worker.ts; derived by prepareSurfaceSpawn)",
442
447
  },
448
+ PI_CREW_SESSION_RECOVERY: {
449
+ name: "PI_CREW_SESSION_RECOVERY",
450
+ parser: "boolean",
451
+ doc: "W2 session-file recovery override for killed workers (exitCode null / killed): '0' disables, '1' forces on — beats ChildPiRunInput.sessionRecovery / runtime.sessionRecovery; default ON (session-recovery.ts:resolveSessionRecoveryEnabled)",
452
+ },
453
+ PI_CREW_HERMETIC_WORKERS: {
454
+ name: "PI_CREW_HERMETIC_WORKERS",
455
+ parser: "boolean",
456
+ doc: "R3-19/D5 hermetic worker spawn override: '1' forces --no-extensions on worker argv, '0' restores the old ambient-stack parity (discovered/configured/built-in extensions load); beats BuildPiWorkerArgsInput.hermeticWorkers / runtime.hermeticWorkers; default ON (pi-args.ts:resolveHermeticWorkers). Explicit -e prompt-runtime + agent extensions + --skill flags are unaffected (cli.md:186-191)",
457
+ },
458
+ PI_CREW_WORKER_TRANSPORT: {
459
+ name: "PI_CREW_WORKER_TRANSPORT",
460
+ doc: "W7 EXPERIMENTAL worker transport gate: 'rpc' selects the prototype RPC frame-client (src/runtime/rpc/) — the run-worker seam currently returns a structured not-implemented result instead of spawning; anything else/unset → default stdio child-pi path (rpc-worker.ts resolveWorkerTransport)",
461
+ },
462
+ PI_CREW_RPC_DIALOG_ANSWER: {
463
+ name: "PI_CREW_RPC_DIALOG_ANSWER",
464
+ doc: "W7 EXPERIMENTAL dialog auto-answer policy for RPC-mode extension_ui_request dialogs (select/confirm/input/editor): 'cancel' (default) answers {cancelled:true} — universal safe answer; 'block' leaves the server promise pending (debug). Auto-confirm deliberately NOT implemented (security); invalid → 'cancel' (rpc-worker.ts resolveDialogAnswerPolicy)",
465
+ },
443
466
  PI_CREW_AUTO_EXIT: {
444
467
  name: "PI_CREW_AUTO_EXIT",
445
468
  doc: "'1' → the worker shuts its session down after the final settled turn — spec §5.2 D7 (written by prepareSurfaceSpawn, read by surface-worker.ts)",
@@ -65,6 +65,30 @@ export interface CrewRuntimeConfig {
65
65
  */
66
66
  taskTimeoutMs?: number;
67
67
  inheritContext?: boolean;
68
+ /**
69
+ * W2 (session-file recovery): when a worker dies without a final assistant
70
+ * event (exitCode null / killed), tail-replay its session JSONL (written by
71
+ * the worker under `<artifactsRoot>/sessions/<taskId>/`) and attach the last
72
+ * COMPLETE assistant record to the task result (`recoveredFromSession`).
73
+ * Default: enabled. The env var PI_CREW_SESSION_RECOVERY overrides this
74
+ * flag in either direction (see session-recovery.ts:resolveSessionRecoveryEnabled).
75
+ * Manifest polling is NOT replaced — this only augments the settle-time result.
76
+ */
77
+ sessionRecovery?: boolean;
78
+ /**
79
+ * R3-19/D5 reversal (2026-10-04): hermetic worker spawns — pass
80
+ * `--no-extensions` on worker argv. Explicit `-e prompt-runtime` and agent
81
+ * `extensions:` still load (cli.md:186-187: "Disables discovered, configured,
82
+ * and built-in extensions. Explicit -e paths still load"), and `--skill`
83
+ * flags are independent — what gets cut is the ~1.37s/spawn ambient package
84
+ * stack (pi-crew host ext + configured packages; probe C §R3b.6: 431ms vs
85
+ * 1806ms to first stdout record) plus the ambient tool surface (team/
86
+ * crew_agent/Agent host tools + 8 direct-exposure MCP tools).
87
+ * Default: true. Env PI_CREW_HERMETIC_WORKERS overrides in either
88
+ * direction (pi-args.ts resolveHermeticWorkers). Surface TUI spawns strip
89
+ * the flag — panes keep the full interactive session.
90
+ */
91
+ hermeticWorkers?: boolean;
68
92
  promptMode?: "replace" | "append";
69
93
  groupJoin?: "off" | "group" | "smart";
70
94
  groupJoinAckTimeoutMs?: number;
@@ -85,6 +109,29 @@ export interface CrewRuntimeConfig {
85
109
  };
86
110
  /** Mark certain bash commands as excludeFromContext to reduce context tokens. Default: false */
87
111
  excludeContextBash?: boolean;
112
+ /**
113
+ * Est-token budget for the dynamic.dependencyContext prompt layer
114
+ * (handoff budget). When the rendered dependency output exceeds this many
115
+ * estimated tokens (chars/4 — the same heuristic the prompt breakdown
116
+ * uses), dependencies are trimmed IN DECLARATION ORDER: earlier deps keep
117
+ * their full output, later ones are downgraded to taskId/role/status + a
118
+ * ≤240-char summary head + an artifact pointer
119
+ * (`artifacts/<runId>/results/<taskId>.txt`) the worker can `read` back.
120
+ * Long sharedRead bodies are dropped the same way. Valid range 1..1_000_000;
121
+ * a RESOLVED value ≤0 or >1_000_000 disables the trim entirely
122
+ * (off-switch). Env override PI_CREW_HANDOFF_BUDGET_TOKENS beats this
123
+ * config. Default: 1800.
124
+ */
125
+ handoffBudgetTokens?: number;
126
+ /**
127
+ * W7 (P2-3) EXPERIMENTAL: transport used to spawn child-pi workers.
128
+ * "stdio" (default) — the production print-mode path, unchanged.
129
+ * "rpc" — prototype RPC frame-client (src/runtime/rpc/); the run-worker
130
+ * seam currently returns a structured not-implemented result instead of
131
+ * spawning. Live gate: env PI_CREW_WORKER_TRANSPORT (config→seam plumbing
132
+ * is the integration-phase follow-up).
133
+ */
134
+ workerTransport?: "stdio" | "rpc";
88
135
  /**
89
136
  * Mux-surface policy (mux-surface spec v0.7 §8.1): WHERE worker processes
90
137
  * live — a pane in tmux/herdr or headless child processes. Surface only
@@ -16,6 +16,7 @@ import { pollWorkerInbox } from "./inbox-poll.ts";
16
16
  import { createMessageTool, type MessageToolParams as MessageToolInputs, shouldRegisterMessageTool } from "./message-tool.ts";
17
17
  import { registerScratchpadLifecycle } from "./scratchpad-lifecycle.ts";
18
18
  import { createWorkerActivityTracker, registerSurfaceWorkerLifecycle, trackToolActivity } from "./surface-worker.ts";
19
+ import { createWorkerEventsChannel } from "./worker-events-channel.ts";
19
20
 
20
21
  export const PI_TEAMS_INHERIT_PROJECT_CONTEXT_ENV = "PI_TEAMS_INHERIT_PROJECT_CONTEXT";
21
22
  export const PI_TEAMS_INHERIT_SKILLS_ENV = "PI_TEAMS_INHERIT_SKILLS";
@@ -653,10 +654,11 @@ async function resolvePark(
653
654
  }
654
655
 
655
656
  /** ADR item 10: every ask-timeout outcome appends `ask.timedout` to the run's
656
- * events.jsonl. The worker cannot rely on PI_CREW_EVENTS_PATH (scratchpad-
657
- * gated), but the state store pins eventsPath === <stateRoot>/events.jsonl —
658
- * the same invariant child-pi-spawn relies on to derive PI_CREW_STATE_ROOT.
659
- * Fire-and-forget: never blocks the tool result. */
657
+ * events.jsonl. WP-9 made PI_CREW_EVENTS_PATH unconditional (this comment
658
+ * used to say it was scratchpad-gated — stale); this path predates that and
659
+ * keeps deriving the file from the state store invariant eventsPath ===
660
+ * <stateRoot>/events.jsonl — the same invariant child-pi-spawn relies on to
661
+ * derive PI_CREW_STATE_ROOT. Fire-and-forget: never blocks the tool result. */
660
662
  function emitAskTimedOutEvent(stateRoot: string, runId: string, taskId: string, questionId: string): void {
661
663
  appendEventFireAndForget(path.join(stateRoot, "events.jsonl"), {
662
664
  type: "ask.timedout",
@@ -1030,6 +1032,33 @@ export default function registerPiTeamsPromptRuntime(pi: ExtensionAPI): void {
1030
1032
  void brokerHandle.close().catch(() => undefined);
1031
1033
  });
1032
1034
 
1035
+ // ── W5 (P2-2): observe worker auto-compaction ──────────────────────────
1036
+ // Auto-compaction inside a worker is otherwise silent from the
1037
+ // orchestrator's view (perf reports only see token jumps). The SDK fires
1038
+ // `session_before_compact` right before the host pi compacts the session
1039
+ // context; this listener OBSERVES ONLY — it returns undefined so the host
1040
+ // proceeds with its own preparation untouched (host analog:
1041
+ // compaction-guard.ts `pi.on("session_before_compact", async () => { return; })`).
1042
+ // NAME DISCIPLINE: this is the SDK's SESSION compaction (host context
1043
+ // summarization) — NOT pi-crew's internal `prepareCompaction` event-log
1044
+ // rotation (event-log-rotation.ts); same word, different mechanism.
1045
+ // Channel discipline (WP-9): createWorkerEventsChannel is registered
1046
+ // unconditionally and self-no-ops without PI_CREW_EVENTS_PATH/
1047
+ // PI_CREW_BROKER_RUN_ID, so a main user session pays nothing. Payload is
1048
+ // scalar-only: branchEntries (large) and signal (AbortSignal) are never
1049
+ // serialized; preparation fields are read defensively (SDK shape evolves).
1050
+ const compactEventsChannel = createWorkerEventsChannel();
1051
+ pi.on("session_before_compact", (event) => {
1052
+ const tokensBefore = (event.preparation as { tokensBefore?: unknown } | undefined)?.tokensBefore;
1053
+ compactEventsChannel.emit("worker.session_before_compact", {
1054
+ reason: event.reason,
1055
+ willRetry: event.willRetry,
1056
+ branchEntryCount: event.branchEntries?.length ?? 0,
1057
+ hasCustomInstructions: typeof event.customInstructions === "string",
1058
+ ...(typeof tokensBefore === "number" ? { tokensBefore } : {}),
1059
+ });
1060
+ });
1061
+
1033
1062
  // ── Task 5 (§15.2): worker inbox pickup ────────────────────────────────
1034
1063
  // A broker-eligible worker ALSO picks up sibling DMs / group broadcasts
1035
1064
  // (`kind:"message"` entries addressed to this task in the durable
@@ -270,10 +270,17 @@ export function prepareSpawnContext(
270
270
  agent: input.agent,
271
271
  model: input.model,
272
272
  sessionEnabled: true,
273
+ // W2 (P1-1): deterministic session identity so crash-path tail-recovery
274
+ // can find the worker's session JSONL (see session-recovery.ts). Falls
275
+ // back to child-pi.ts deriveSessionPaths when unset (idempotent both ways).
276
+ sessionId: input.sessionId,
277
+ sessionDir: input.sessionDir,
273
278
  maxDepth: input.maxDepth,
274
279
  skillPaths: input.skillPaths,
275
280
  role: input.role,
276
281
  thinkingOverride: input.thinkingOverride,
282
+ systemPromptAppend: input.systemPromptAppend,
283
+ hermeticWorkers: input.hermeticWorkers,
277
284
  // ADR-5 §3: depthOverride is pre-encoded into depthEnv by runChildPi
278
285
  // (parent's record depth as base-env PI_CREW_DEPTH); forward it so the
279
286
  // child env gets parentDepth+1 = the true grandchild depth.
@@ -27,6 +27,16 @@ import { ChildPiLineObserver } from "./child-pi-streams.ts";
27
27
  // Phase 2.3: the six timer constructs moved to child-pi-timers.ts (pure motion).
28
28
  import { createChildPiTimers } from "./child-pi-timers.ts";
29
29
  import { runMockChildPi } from "./mock-fixtures.ts";
30
+ // W2 (session-file recovery): deterministic worker session flags + crash-path
31
+ // tail-replay of the session JSONL (see session-recovery.ts header).
32
+ import {
33
+ appendWorkerSessionArgs,
34
+ deriveSessionPaths,
35
+ recoverLastAssistantFromSession,
36
+ resolveSessionRecoveryEnabled,
37
+ type SessionRecoveryInfo,
38
+ shouldAttemptSessionRecovery,
39
+ } from "./session-recovery.ts";
30
40
 
31
41
  // ── Re-exports from child-pi-kill.ts (H-7 decomposition step 2) ──
32
42
  // killProcessTree is internal (not previously exported) — keep that invariant.
@@ -185,8 +195,18 @@ export interface ChildPiRunInput {
185
195
  inheritContext?: boolean;
186
196
  /** Pass to pi to mark certain commands as context-excluded. Default: false */
187
197
  excludeContextBash?: boolean;
188
- /** pi session ID for session naming (aligns with pi-crew run ID) */
198
+ /** pi session ID for session naming (aligns with pi-crew run ID). W2: now
199
+ * WIRED — flows to `--session-id` (deterministic worker session files for
200
+ * crash-path tail-recovery; see session-recovery.ts). */
189
201
  sessionId?: string;
202
+ /** W2 (session-file recovery): explicit session directory override. When
203
+ * absent, derived per-worker as `<artifactsRoot>/sessions/<taskId>/` so
204
+ * session JSONL is cleaned up together with run artifacts. */
205
+ sessionDir?: string;
206
+ /** W2 (session-file recovery): explicit on/off (runtime.sessionRecovery
207
+ * config). Env PI_CREW_SESSION_RECOVERY overrides in either direction;
208
+ * default ON. Only consulted on crash-ish settles (exitCode null / killed). */
209
+ sessionRecovery?: boolean;
190
210
  /** Path to steering JSONL file for real-time steer injection. */
191
211
  steeringFile?: string;
192
212
  /** Run ID for cleanup tracking */
@@ -197,6 +217,15 @@ export interface ChildPiRunInput {
197
217
  role?: string;
198
218
  /** Team-role thinking override (takes precedence over agent.thinking). */
199
219
  thinkingOverride?: string;
220
+ /** R3-1: run-static worker header for the --append-system-prompt channel
221
+ * (renderTaskPrompt().stablePrefix). Threaded by child-executor so the
222
+ * Protocol/mailbox/workspace/runtime-context block survives compaction
223
+ * instead of living in the summarizable user-message span. */
224
+ systemPromptAppend?: string;
225
+ /** R3-19/D5 (hermetic worker spawns): thread runtime.hermeticWorkers to
226
+ * buildPiWorkerArgs (--no-extensions). Env PI_CREW_HERMETIC_WORKERS
227
+ * overrides either way; default TRUE. */
228
+ hermeticWorkers?: boolean;
200
229
  /** Root directory for artifacts (used to validate transcriptPath). */
201
230
  artifactsRoot?: string;
202
231
  /** I5: run events JSONL path — threaded to the worker so its scratchpad
@@ -291,6 +320,13 @@ export interface ChildPiRunResult {
291
320
  * text) still produce a non-empty result. Consumers should prefer rawFinalText
292
321
  * first — this is a last-resort fallback. */
293
322
  intermediateFindings?: string;
323
+ /** W2 (session-file recovery): present when the worker died WITHOUT a
324
+ * final assistant event (exitCode null / killed) and a COMPLETE assistant
325
+ * record was tail-recovered from the worker's session JSONL (deterministic
326
+ * `--session-id`/`--session-dir` under the run artifacts root). Consumers
327
+ * treat it as a crash-path supplement to rawFinalText — never a replacement
328
+ * for clean-path output, and manifest polling is untouched. */
329
+ recoveredFromSession?: SessionRecoveryInfo;
294
330
  /**
295
331
  * MuxSurface A1: present ONLY when the worker booted inside a multiplexer
296
332
  * pane instead of a stdio pipe (spec §13.1). Downstream (T9 EventLogTailSource,
@@ -371,6 +407,17 @@ function safeLoadSurfaceConfig(cwd: string): PiTeamsConfig {
371
407
  }
372
408
  }
373
409
 
410
+ /**
411
+ * D1/D5: flags that apply to HEADLESS worker spawns only. The surface TUI
412
+ * branch strips them before booting a pane — a pane hosts a full interactive
413
+ * pi session where the interactive trust prompt must stay available and the
414
+ * ambient extension stack is part of the human-visible session (task mandate:
415
+ * worker spawn CHỈ, KHÔNG đụng surface TUI spawn).
416
+ */
417
+ function stripHeadlessOnlyFlags(args: string[]): string[] {
418
+ return args.filter((arg) => arg !== "--no-approve" && arg !== "--no-extensions");
419
+ }
420
+
374
421
  /**
375
422
  * MuxSurface A1 spawn branch (spec §13.1). Returns a ChildPiRunResult when the
376
423
  * worker was booted INSIDE a multiplexer pane (no stdio process is spawned and
@@ -413,7 +460,7 @@ async function trySurfaceBranch(
413
460
  livePaneCount: degradeController ? degradeController.livePaneCount() : (surfaceOpts?.livePaneCount ?? 0),
414
461
  taskId,
415
462
  cwd: input.cwd,
416
- piArgs: builtArgs,
463
+ piArgs: stripHeadlessOnlyFlags(builtArgs),
417
464
  stateRoot: input.eventsPath ? path.dirname(input.eventsPath) : "",
418
465
  baseDir: surfaceOpts?.baseDir,
419
466
  deps: preResolved ? { provider: preResolved } : {},
@@ -684,6 +731,24 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
684
731
  const spawnPrep = prepareSpawnContext(brokerSpawn ? { ...input, brokerSpawn } : input, effectiveTask, depthEnv);
685
732
  if (spawnPrep.kind === "aborted") return spawnPrep.result;
686
733
  const { spawnSpec, mergedEnv, tempDir, builtEnv, builtArgs } = spawnPrep.ctx;
734
+ // W2 (session-file recovery): resolve the worker's deterministic session
735
+ // identity (per-worker dir under the run artifacts root) and arm it on BOTH
736
+ // argv views — the headless spawnSpec.args ([script, ...builtArgs]) and the
737
+ // raw builtArgs (also consumed by the surface branch). prepareSpawnContext
738
+ // does not forward session identity to the arg builder (ownership boundary
739
+ // with child-pi-spawn.ts — see the lane report), so this is the in-zone
740
+ // activation point; appendWorkerSessionArgs is idempotent, making the move
741
+ // to builder-side forwarding later a zero-diff no-op.
742
+ const workerSession = deriveSessionPaths(input);
743
+ const sessionRecoveryEnabled = resolveSessionRecoveryEnabled(input.sessionRecovery);
744
+ if (workerSession) {
745
+ try {
746
+ fs.mkdirSync(workerSession.sessionDir, { recursive: true });
747
+ } catch {
748
+ // Unwritable target → spawn proceeds; recovery simply finds no files.
749
+ }
750
+ appendWorkerSessionArgs(spawnSpec.args, builtArgs, workerSession);
751
+ }
687
752
  // MuxSurface A1 (spec §13.1): attempt booting the worker in a mux pane BEFORE
688
753
  // spawning a stdio pipe. Returns a finished result in surface mode (pane exit
689
754
  // awaited) or null → fall through to the classic headless spawn below.
@@ -869,6 +934,22 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
869
934
  clearPostExitGuard();
870
935
  };
871
936
 
937
+ // W2 (session-file recovery): crash-path augment — when the worker died
938
+ // WITHOUT a final assistant event (exitCode null / killed), tail-replay
939
+ // its session JSONL and surface the last COMPLETE assistant record on the
940
+ // resolved result. Best-effort and deadline-bounded (session-recovery.ts)
941
+ // so settle can never hang on pathological IO. This AUGMENTS the result —
942
+ // manifest polling (heartbeat-watcher / detached-run-results) is untouched.
943
+ const recoverForSettle = async (settleResult: ChildPiRunResult): Promise<SessionRecoveryInfo | undefined> => {
944
+ if (!workerSession || !sessionRecoveryEnabled) return undefined;
945
+ if (!shouldAttemptSessionRecovery(settleResult, hardKilled)) return undefined;
946
+ try {
947
+ return (await recoverLastAssistantFromSession(workerSession.sessionDir, workerSession.sessionId)) ?? undefined;
948
+ } catch {
949
+ return undefined;
950
+ }
951
+ };
952
+
872
953
  const settle = (result: ChildPiRunResult): Promise<void> => {
873
954
  if (settled) return Promise.resolve();
874
955
  settled = true;
@@ -885,7 +966,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
885
966
  // durable transcript on disk.
886
967
  return lineObserver
887
968
  .flush()
888
- .then(() => {
969
+ .then(async () => {
889
970
  input.signal?.removeEventListener("abort", abort);
890
971
  input.signal?.removeEventListener("abort", onParentAbort);
891
972
  try {
@@ -893,12 +974,16 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
893
974
  } catch (error) {
894
975
  cleanupErrors.push(error instanceof Error ? error.message : String(error));
895
976
  }
977
+ // W2: crash-path session tail-recovery (await BEFORE resolve so the
978
+ // recovered record rides the same settled result object).
979
+ const recoveredFromSession = await recoverForSettle(result);
896
980
  // Catch all errors from settle to prevent unhandled rejection from propagating
897
981
  try {
898
982
  resolve({
899
983
  ...result,
900
984
  rawFinalText: lineObserver.getRawFinalText(),
901
985
  intermediateFindings: lineObserver.getIntermediateFindings(),
986
+ ...(recoveredFromSession ? { recoveredFromSession } : {}),
902
987
  exitStatus: result.exitStatus ?? {
903
988
  exitCode: result.exitCode,
904
989
  cancelled: abortRequested,
@@ -926,7 +1011,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
926
1011
  );
927
1012
  }
928
1013
  })
929
- .catch((flushError) => {
1014
+ .catch(async (flushError) => {
930
1015
  // Drain failed — log and still resolve so runChildPi doesn't hang.
931
1016
  logInternalError(
932
1017
  "child-pi.settle-flush-failed",
@@ -940,11 +1025,15 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
940
1025
  } catch (error) {
941
1026
  cleanupErrors.push(error instanceof Error ? error.message : String(error));
942
1027
  }
1028
+ // W2: crash-path session tail-recovery — same augment on the
1029
+ // drain-failed path (a torn flush is itself crash-adjacent).
1030
+ const recoveredFromSession = await recoverForSettle(result);
943
1031
  try {
944
1032
  resolve({
945
1033
  ...result,
946
1034
  rawFinalText: lineObserver.getRawFinalText(),
947
1035
  intermediateFindings: lineObserver.getIntermediateFindings(),
1036
+ ...(recoveredFromSession ? { recoveredFromSession } : {}),
948
1037
  exitStatus: result.exitStatus ?? {
949
1038
  exitCode: result.exitCode,
950
1039
  cancelled: abortRequested,