brl-subagent 2.4.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > Multi-agent orchestration for [pi](https://github.com/earendil-works/pi) — chain, parallel, and dependency-graph delegation to isolated subagents with per-step model routing, preset-driven tool scoping, thinking-level control, and background execution with live monitoring, real abort, and per-agent timeouts.
4
4
 
5
- **Version:** 2.4.0 · **Author:** Azmeen Afandi / [Beeroo Labs](https://beeroolabs.com) · **License:** MIT
5
+ **Version:** 2.5.0 · **Author:** Azmeen Afandi / [Beeroo Labs](https://beeroolabs.com) · **License:** MIT
6
6
 
7
7
  ---
8
8
 
@@ -14,7 +14,7 @@
14
14
  - **True background execution** — live monitor, real abort (`stop_subagent`), per-agent timeouts, and a 30-minute default deadline for runs that don't set one. Nothing orphans; nothing leaks.
15
15
  - **Preset-driven tool scoping** — every subagent runs with exactly the tools its job needs, restricted by preset or per-call `tools`/`excludeTools`, with auto-route that picks the right preset when you don't.
16
16
  - **Templates with slots** — saved, file-backed task templates with `${param}` placeholders for workflows you run again and again.
17
- - **Safety by default** — task-fence injection protection, sanitized error paths, owner-only persistence, and a 1200+ test suite pinning every contract against the real pi SDK.
17
+ - **Safety by default** — task-fence injection protection, sanitized error paths, owner-only persistence, and a 1300+ test suite pinning every contract against the real pi SDK.
18
18
 
19
19
  ---
20
20
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brl-subagent",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "description": "Subagent delegation for pi — blocking or background runs, chain/parallel/graph modes, presets and task templates, live monitor, and dispatch capability guards.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -37,10 +37,10 @@
37
37
  "typebox": "*"
38
38
  },
39
39
  "devDependencies": {
40
- "@earendil-works/pi-agent-core": "^1.0.0",
41
- "@earendil-works/pi-ai": "^1.0.0",
42
- "@earendil-works/pi-coding-agent": "^1.0.0",
43
- "@earendil-works/pi-tui": "^1.0.0",
40
+ "@earendil-works/pi-agent-core": "^1.1.0",
41
+ "@earendil-works/pi-ai": "^1.1.0",
42
+ "@earendil-works/pi-coding-agent": "^1.1.0",
43
+ "@earendil-works/pi-tui": "^1.1.0",
44
44
  "jiti": "^2.7.0",
45
45
  "typebox": "^1.3.34",
46
46
  "typescript": "7.0.2",
package/src/event-bus.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  // Purpose: In-memory lifecycle pub/sub (`subagent:*` events) for extensions and the completion wake.
2
2
  import type { SubagentEvent, SubagentEventType, SubagentEventListener } from './types';
3
+ import { createLogger } from './logging';
4
+
5
+ // Issue #265: listener failures route through the logger (file-only by default)
6
+ // so raw console.error writes cannot corrupt the pi TUI.
7
+ const log = createLogger('event-bus');
3
8
 
4
9
  // Event listeners by type
5
10
  const listeners = new Map<SubagentEventType, Set<SubagentEventListener>>();
@@ -49,7 +54,7 @@ export function emit(event: SubagentEvent): void {
49
54
  listener(event);
50
55
  } catch (err) {
51
56
  // Don't let listener errors break the event loop
52
- console.error(`[event-bus] Listener error for ${event.type}:`, err);
57
+ log.error(`Listener error for ${event.type}`, { error: err instanceof Error ? err.message : String(err) });
53
58
  }
54
59
  }
55
60
  }
@@ -59,7 +64,7 @@ export function emit(event: SubagentEvent): void {
59
64
  try {
60
65
  listener(event);
61
66
  } catch (err) {
62
- console.error(`[event-bus] Global listener error:`, err);
67
+ log.error('Global listener error', { error: err instanceof Error ? err.message : String(err) });
63
68
  }
64
69
  }
65
70
  }
package/src/git.ts CHANGED
@@ -17,6 +17,11 @@ function gitOpts(cwd: string) {
17
17
  cwd,
18
18
  encoding: "utf-8" as const,
19
19
  timeout: 10_000,
20
+ // Capture child stderr instead of inheriting it: expected git failures
21
+ // (e.g. deleting a checked-out branch) are caught and returned as
22
+ // {ok:false, error}; execFileSync still puts the captured stderr in the
23
+ // thrown error's message, so real failures stay diagnosable.
24
+ stdio: ["ignore", "pipe", "pipe"] as ["ignore", "pipe", "pipe"],
20
25
  };
21
26
  }
22
27
 
@@ -33,6 +38,46 @@ export function getCurrentBranch(cwd: string): string {
33
38
  return output.trim();
34
39
  }
35
40
 
41
+ /**
42
+ * The starting state of HEAD, captured at spawn so teardown can restore it.
43
+ *
44
+ * `getCurrentBranch` alone is NOT enough to restore a detached HEAD: it
45
+ * returns the "HEAD" pseudo-ref, so a `git checkout HEAD` teardown is a no-op
46
+ * that stays on the work branch (stranding it and leaking the branch in CI PR
47
+ * builds, which check out a detached HEAD). The sha + attached-ness must be
48
+ * captured together.
49
+ *
50
+ * `detached` also governs DATA SAFETY at teardown (#308): a detached start has
51
+ * no branch of its own to retain a merged work branch, so the auto-approve path
52
+ * MUST preserve the work branch as the durable ref rather than delete it.
53
+ */
54
+ export interface HeadState {
55
+ /** Commit HEAD pointed at when captured. */
56
+ sha: string;
57
+ /** True when HEAD was detached (no symbolic ref) at capture time. */
58
+ detached: boolean;
59
+ /**
60
+ * Branch name when attached; the literal "HEAD" pseudo-ref when detached.
61
+ * Attached callers can pass it straight to createWorkBranch/captureDiff.
62
+ */
63
+ branch: string;
64
+ }
65
+
66
+ /**
67
+ * Capture the current HEAD state (sha + attached-ness) for later restoration.
68
+ * Throws if the cwd is not a git repository.
69
+ */
70
+ export function getHeadState(cwd: string): HeadState {
71
+ const sha = execFileSync("git", ["rev-parse", "HEAD"], gitOpts(cwd)).trim();
72
+ try {
73
+ // Exits non-zero when HEAD is detached — that is the discriminator.
74
+ const branch = execFileSync("git", ["symbolic-ref", "--short", "-q", "HEAD"], gitOpts(cwd)).trim();
75
+ return { sha, detached: false, branch };
76
+ } catch {
77
+ return { sha, detached: true, branch: "HEAD" };
78
+ }
79
+ }
80
+
36
81
  /**
37
82
  * Resolve the repository root that contains the given directory.
38
83
  * Returns undefined when the directory is not inside a repository, or when
@@ -105,6 +150,28 @@ export function switchToBranch(
105
150
  }
106
151
  }
107
152
 
153
+ /**
154
+ * Restore HEAD to the state captured by getHeadState.
155
+ * Detached starts are re-detached at the captured sha (`git checkout
156
+ * --detach <sha>`) instead of switching to the useless "HEAD" pseudo-ref;
157
+ * attached starts switch back to the original branch.
158
+ * Returns { ok: true } on success, { ok: false, error } on failure.
159
+ */
160
+ export function restoreHead(
161
+ cwd: string,
162
+ state: HeadState,
163
+ ): { ok: true } | { ok: false; error: string } {
164
+ try {
165
+ const args = state.detached
166
+ ? ["checkout", "--detach", state.sha]
167
+ : ["checkout", state.branch];
168
+ execFileSync("git", args, gitOpts(cwd));
169
+ return { ok: true };
170
+ } catch (err) {
171
+ return { ok: false, error: (err as Error).message };
172
+ }
173
+ }
174
+
108
175
  /**
109
176
  * Merge a work branch into the current branch.
110
177
  * Uses --no-edit to prevent opening an editor.
package/src/history.ts CHANGED
@@ -7,7 +7,9 @@
7
7
  */
8
8
 
9
9
  import type { SubagentRun, SubagentResult } from "./types";
10
- import { isSubagentError, getFinalOutput, isSubagentRunShape, classifyError, coherentFailureReason, CUSTOM_ENTRY_TYPES, MAX_RUN_HISTORY_ENTRIES } from "./types";
10
+ import { isSubagentError, getFinalOutput, isSubagentRunShape, classifyError, coherentFailureReason, CUSTOM_ENTRY_TYPES, MAX_RUN_HISTORY_ENTRIES, DEFAULT_OUTPUT_CAP_BYTES } from "./types";
11
+ import { isOutputTruncated } from "./sanitize";
12
+ import { transcriptDisplayPath } from "./transcript-path";
11
13
  import type { ExtensionContext, SessionManager } from "@earendil-works/pi-coding-agent";
12
14
 
13
15
  // ---------------------------------------------------------------------------
@@ -200,3 +202,58 @@ export function formatRunDuration(ms: number): string {
200
202
  const secs = Math.round((ms % 60_000) / 1000);
201
203
  return `${min}m ${secs}s`;
202
204
  }
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // Full-output honesty lines (issue #261)
208
+ // ---------------------------------------------------------------------------
209
+
210
+ /**
211
+ * Human-readable size for an output cap, e.g. `100 * 1024` → "100 KB".
212
+ * Byte caps are whole-KB by construction; sub-KB values (mostly tests) render
213
+ * as plain bytes.
214
+ */
215
+ function formatCapSize(capBytes: number): string {
216
+ if (capBytes > 0 && capBytes % 1024 === 0) return `${capBytes / 1024} KB`;
217
+ return `${capBytes} B`;
218
+ }
219
+
220
+ /**
221
+ * Honest disclosure lines for a settled run's full-output view (issue #261).
222
+ *
223
+ * Always returns the transcript pointer. When the stored output hit the
224
+ * `capBytes` cap it also adds a disclosure that points at the transcript for
225
+ * the rest. Detection is deliberately two-signal:
226
+ *
227
+ * 1. `isOutputTruncated(run.fullOutput)` — the exact `capOutput` notice, true
228
+ * only when the stored text was genuinely truncated;
229
+ * 2. `Buffer.byteLength` at/over `capBytes` — the length fallback, so an
230
+ * UNCAPPED record (background/crash paths store raw `liveOutput`) that
231
+ * already sits at or over the cap budget is still disclosed instead of
232
+ * pretending the panel can show more than the cap's worth.
233
+ *
234
+ * Boundary precision: `capOutput` truncates only when the byte length is
235
+ * STRICTLY greater than the cap, so an output of exactly `capBytes` takes the
236
+ * length branch and is disclosed as "at or over" without carrying the notice.
237
+ * The notice branch alone is exact for "was truncated".
238
+ *
239
+ * Pure and pi-tui-free, so it is unit-testable in isolation.
240
+ */
241
+ export function buildOutputHonestyLines(
242
+ run: Pick<SubagentRun, "id" | "fullOutput">,
243
+ capBytes: number = DEFAULT_OUTPUT_CAP_BYTES,
244
+ ): string[] {
245
+ const transcriptPath = transcriptDisplayPath(run.id);
246
+ const lines = [`Transcript: ${transcriptPath}`];
247
+ const output = run.fullOutput;
248
+ const size = formatCapSize(capBytes);
249
+ if (isOutputTruncated(output)) {
250
+ lines.push(
251
+ `Output was truncated at the ${size} cap — full text in the transcript: ${transcriptPath}`,
252
+ );
253
+ } else if (output && Buffer.byteLength(output, "utf8") >= capBytes) {
254
+ lines.push(
255
+ `Stored output is at or over the ${size} cap — full transcript: ${transcriptPath}`,
256
+ );
257
+ }
258
+ return lines;
259
+ }
package/src/index.ts CHANGED
@@ -58,13 +58,14 @@ import { pkgPath } from "./paths";
58
58
 
59
59
  import { sanitizeTask, validateCwd, validateOutputFile, stripAnsi, capOutput, getCurrentDepth, sanitizeErrorMessage, buildCrashResult } from "./sanitize";
60
60
  import {
61
- getCurrentBranch,
62
61
  hasUncommittedChanges,
63
62
  createWorkBranch,
64
63
  captureDiff,
65
- switchToBranch,
64
+ getHeadState,
65
+ restoreHead,
66
66
  deleteBranch,
67
67
  mergeWorkBranch,
68
+ type HeadState,
68
69
  } from "./git";
69
70
  import { preflightCheck } from "./preflight";
70
71
  import { gateSessionCost, rejectApprovalAlwaysInBackground, runPreTaskValidation, validateDelegationTargets, formatValidationWarnings } from "./prelude";
@@ -76,8 +77,15 @@ import { delegateTaskParamsSchema } from "./schema";
76
77
  import { createSessionState } from "./state";
77
78
  import { makeLiveOnUpdate, createUnitRun, finalizeUnitRun, finalizeUnitRunCrash, pruneHistoryIfNeeded, registerLiveRun } from "./unit-run";
78
79
  import { buildSubagentPrompt, describePromptMode } from "./prompt";
79
- import { runSubagent, cleanupTempDirs } from "./runner";
80
+ import { runSubagent, cleanupTempDirs, reapActiveChildren } from "./runner";
81
+ import {
82
+ recoverProduction,
83
+ currentProcessOwner,
84
+ newDispatchIdentity,
85
+ } from "./recovery";
86
+ import { markInterrupted } from "./run-registry";
80
87
  import { acquireSlot, releaseSlot, updateStatus, updateProgressStatus } from "./concurrency";
88
+ import { transcriptDisplayPath } from "./transcript-path";
81
89
  import {
82
90
  finalizeRunRecord,
83
91
  resolveRetryParams,
@@ -700,7 +708,7 @@ export default function (pi: ExtensionAPI) {
700
708
  // foreground live entry (the #130 invariant gap that swept
701
709
  // graph/chain entries immediately).
702
710
  const { runId, run } = createUnitRun(merged, stepModel, chainPriority, globalParams.resolvedPreset?.name);
703
- state.persistRun(pi, run);
711
+ state.persistRun(pi, run, "unit");
704
712
 
705
713
  // R2: Prune old run entries if history exceeds limit
706
714
  pruneHistoryIfNeeded(state, ctx, log);
@@ -740,6 +748,9 @@ export default function (pi: ExtensionAPI) {
740
748
  getFinalOutput,
741
749
  log,
742
750
  currentDepth + 1,
751
+ undefined, // intercom
752
+ undefined, // subagentId
753
+ run.childMarker,
743
754
  );
744
755
  } catch (err) {
745
756
  // Issue #133 (mirrors the #119 R1/C1 discipline): finalize the
@@ -1152,7 +1163,7 @@ export default function (pi: ExtensionAPI) {
1152
1163
  // monitor drill-in shows per-unit priority and a post-hoc audit
1153
1164
  // trail exists per subtask (mirrors single-mode run persistence).
1154
1165
  const { runId, run } = createUnitRun(merged, stepModel, parallelPriority, globalParams.resolvedPreset?.name);
1155
- state.persistRun(pi, run);
1166
+ state.persistRun(pi, run, "unit");
1156
1167
 
1157
1168
  // R2: Prune old run entries if history exceeds limit
1158
1169
  pruneHistoryIfNeeded(state, ctx, log);
@@ -1210,6 +1221,7 @@ export default function (pi: ExtensionAPI) {
1210
1221
  currentDepth + 1,
1211
1222
  intercom,
1212
1223
  subagentId,
1224
+ run.childMarker,
1213
1225
  );
1214
1226
 
1215
1227
  // Fill SubTaskResult
@@ -1788,7 +1800,7 @@ export default function (pi: ExtensionAPI) {
1788
1800
  // foreground live entry (the #130 invariant gap that swept
1789
1801
  // graph/chain entries immediately).
1790
1802
  const { runId, run } = createUnitRun(merged, stepModel, graphPriority, globalParams.resolvedPreset?.name);
1791
- state.persistRun(pi, run);
1803
+ state.persistRun(pi, run, "unit");
1792
1804
 
1793
1805
  // R2: Prune old run entries if history exceeds limit
1794
1806
  pruneHistoryIfNeeded(state, ctx, log);
@@ -1825,6 +1837,7 @@ export default function (pi: ExtensionAPI) {
1825
1837
  currentDepth + 1,
1826
1838
  intercom,
1827
1839
  subagentId,
1840
+ run.childMarker,
1828
1841
  );
1829
1842
 
1830
1843
  const subTaskResult: SubTaskResult = {
@@ -2263,6 +2276,10 @@ export default function (pi: ExtensionAPI) {
2263
2276
  originalParams?: SubagentRun["originalParams"];
2264
2277
  /** cwd used to redact paths from crash notifications. */
2265
2278
  sanitizeCwd: string;
2279
+ // Option B U1: durability identity threaded onto the background records.
2280
+ dispatchId?: string;
2281
+ resumeOf?: string;
2282
+ attempt?: number;
2266
2283
  },
2267
2284
  ): Promise<BackgroundAgent> {
2268
2285
  const { spawnBackgroundSession, setAgentFinalOutput, extractFinalOutput, updateAgentStatus } = await import('./session-manager');
@@ -2289,6 +2306,10 @@ export default function (pi: ExtensionAPI) {
2289
2306
  // Issue #98/#108: snapshotOriginalParams is the single source of truth
2290
2307
  // for the caller params a later retry restores.
2291
2308
  originalParams: spawn.originalParams,
2309
+ // Option B U1: durability identity for the agent record + run entry.
2310
+ dispatchId: spawn.dispatchId,
2311
+ resumeOf: spawn.resumeOf,
2312
+ attempt: spawn.attempt,
2292
2313
  });
2293
2314
 
2294
2315
  // Register for live monitor
@@ -2522,6 +2543,10 @@ export default function (pi: ExtensionAPI) {
2522
2543
  priority?: string;
2523
2544
  },
2524
2545
  singleTask: string,
2546
+ // Option B U1: the spawn entry a retryRunId resolved to, so a retried
2547
+ // background run inherits dispatchId/resumeOf/attempt (record identity,
2548
+ // not a delegate param — D4).
2549
+ retrySourceRun?: SubagentRun,
2525
2550
  ): Promise<ToolResult<undefined>> {
2526
2551
  // Phase 6.5: Background execution — spawn session and return ID immediately.
2527
2552
  // Resolve the preset and its model BEFORE the background branch so the
@@ -2602,6 +2627,8 @@ export default function (pi: ExtensionAPI) {
2602
2627
  bgResolvedPreset?.promptGuideline,
2603
2628
  );
2604
2629
 
2630
+ // Option B U1: durability identity (retry inherits the original dispatch).
2631
+ const bgIdentity = newDispatchIdentity(retrySourceRun);
2605
2632
  const agent = await startBackgroundAgent(pi, ctx, {
2606
2633
  task: singleTask,
2607
2634
  type: params.preset || 'general-purpose',
@@ -2616,6 +2643,9 @@ export default function (pi: ExtensionAPI) {
2616
2643
  gitMode: bgResolved.resolvedGitMode,
2617
2644
  originalParams: snapshotOriginalParams(params),
2618
2645
  sanitizeCwd: bgResolved.effectiveCwd,
2646
+ dispatchId: bgIdentity.dispatchId,
2647
+ resumeOf: bgIdentity.resumeOf,
2648
+ attempt: bgIdentity.attempt,
2619
2649
  });
2620
2650
 
2621
2651
  // B2: Surface auto-route decisions in background spawn result too —
@@ -2787,6 +2817,10 @@ export default function (pi: ExtensionAPI) {
2787
2817
  onUpdate: AgentToolUpdateCallback<DelegateTaskDetails> | undefined,
2788
2818
  ctx: ExtensionContext,
2789
2819
  ) {
2820
+ // Option B U1: the run a retryRunId resolves to (spawn entry). Internal
2821
+ // record identity — dispatchId/resumeOf/attempt are threaded onto the new
2822
+ // record, NOT through the delegate-param surface (D4).
2823
+ let retrySourceRun: SubagentRun | undefined;
2790
2824
  // Issue #99: warn on unknown params — typebox allows additional properties
2791
2825
  // by default (pi's validateToolArguments passes unknown keys through), so a
2792
2826
  // typo like `thinkinglevel` or a schema-drifted param is otherwise silently
@@ -2890,6 +2924,7 @@ export default function (pi: ExtensionAPI) {
2890
2924
  if (params.retryRunId) {
2891
2925
  const runEntry = state.findSpawnRunById(ctx, params.retryRunId);
2892
2926
  if (runEntry) {
2927
+ retrySourceRun = runEntry;
2893
2928
  params = resolveRetryParams(params, runEntry) as DelegateTaskParams;
2894
2929
  } else {
2895
2930
  // Issue #98: a silent no-op here made retries of background runs
@@ -3009,7 +3044,7 @@ export default function (pi: ExtensionAPI) {
3009
3044
  const singleTask = params.task!; // non-empty (sanitizer guarantees; isSingle confirms)
3010
3045
 
3011
3046
  if (params.background) {
3012
- return spawnBackgroundRun(pi, ctx, params, singleTask);
3047
+ return spawnBackgroundRun(pi, ctx, params, singleTask, retrySourceRun);
3013
3048
  }
3014
3049
 
3015
3050
  // R5: Check session cost limit before spawning
@@ -3156,6 +3191,10 @@ export default function (pi: ExtensionAPI) {
3156
3191
 
3157
3192
  // Create run record
3158
3193
  const runId = crypto.randomUUID();
3194
+ // Option B U1: durability identity — a retry inherits the original
3195
+ // dispatchId and records resumeOf/attempt; a fresh marker keys the
3196
+ // child process for boot-time orphan discovery.
3197
+ const identity = newDispatchIdentity(retrySourceRun);
3159
3198
  const run: SubagentRun = {
3160
3199
  id: runId,
3161
3200
  task,
@@ -3168,8 +3207,13 @@ export default function (pi: ExtensionAPI) {
3168
3207
  priority: params.priority,
3169
3208
  startedAt: new Date().toISOString(),
3170
3209
  originalParams: snapshotOriginalParams(params),
3210
+ dispatchId: identity.dispatchId,
3211
+ resumeOf: identity.resumeOf,
3212
+ attempt: identity.attempt,
3213
+ owner: currentProcessOwner(),
3214
+ childMarker: crypto.randomUUID(),
3171
3215
  };
3172
- state.persistRun(pi, run);
3216
+ state.persistRun(pi, run, "foreground");
3173
3217
 
3174
3218
  // R2: Prune old run entries if history exceeds limit
3175
3219
  pruneHistoryIfNeeded(state, ctx, log);
@@ -3178,11 +3222,11 @@ export default function (pi: ExtensionAPI) {
3178
3222
  registerLiveRun(state, run, ctx);
3179
3223
 
3180
3224
  // P3: Git integration — set up work branch if gitMode is "branch"
3181
- let originalBranch: string | undefined;
3225
+ let headState: HeadState | undefined;
3182
3226
  let workBranchName: string | undefined;
3183
3227
  if (resolvedGitMode === "branch") {
3184
3228
  try {
3185
- originalBranch = getCurrentBranch(resolvedCwd);
3229
+ headState = getHeadState(resolvedCwd);
3186
3230
 
3187
3231
  if (hasUncommittedChanges(resolvedCwd)) {
3188
3232
  log.warn("Uncommitted changes detected; proceeding with branch-based workflow anyway", {
@@ -3190,40 +3234,56 @@ export default function (pi: ExtensionAPI) {
3190
3234
  });
3191
3235
  }
3192
3236
 
3193
- const branchResult = createWorkBranch(resolvedCwd, originalBranch);
3237
+ const branchResult = createWorkBranch(resolvedCwd, headState.sha);
3194
3238
  if (branchResult.ok) {
3195
3239
  workBranchName = branchResult.branch;
3196
3240
  log.info("Created work branch for subagent", {
3197
3241
  branch: workBranchName,
3198
- base: originalBranch,
3242
+ base: headState.branch,
3199
3243
  });
3200
3244
  } else {
3201
3245
  log.error("Failed to create work branch, falling back to 'none'", {
3202
3246
  error: branchResult.error,
3203
3247
  });
3204
- originalBranch = undefined;
3248
+ headState = undefined;
3205
3249
  }
3206
3250
  } catch (err) {
3207
3251
  log.warn("Not a git repository or git error; falling back to gitMode 'none'", {
3208
3252
  error: (err as Error).message,
3209
3253
  });
3210
- originalBranch = undefined;
3254
+ headState = undefined;
3211
3255
  workBranchName = undefined;
3212
3256
  }
3213
3257
  }
3214
3258
 
3215
- // Helper to switch back to original branch and optionally delete work branch
3259
+ // Helper to restore the starting HEAD and delete the work branch. The
3260
+ // delete is NOT gated on the restore succeeding: the branch must never
3261
+ // survive teardown. A still-checked-out branch simply makes deleteBranch
3262
+ // fail (logged) rather than silently leaking the ref.
3216
3263
  const cleanupGitBranch = () => {
3217
- if (workBranchName && originalBranch) {
3218
- try {
3219
- switchToBranch(resolvedCwd, originalBranch);
3220
- log.info("Switched back to original branch", { branch: originalBranch });
3221
- // Attempt to delete the work branch (non-critical)
3222
- deleteBranch(resolvedCwd, workBranchName);
3223
- } catch {
3224
- // Non-fatal: best-effort cleanup
3264
+ if (!workBranchName) return;
3265
+ if (headState) {
3266
+ const restoreResult = restoreHead(resolvedCwd, headState);
3267
+ if (restoreResult.ok) {
3268
+ log.info("Restored original HEAD", {
3269
+ branch: headState.branch,
3270
+ detached: headState.detached,
3271
+ });
3272
+ } else {
3273
+ log.warn("Failed to restore original HEAD during cleanup", {
3274
+ branch: headState.branch,
3275
+ error: restoreResult.error,
3276
+ });
3225
3277
  }
3226
3278
  }
3279
+ // Attempt to delete the work branch (non-critical)
3280
+ const deleteResult = deleteBranch(resolvedCwd, workBranchName);
3281
+ if (!deleteResult.ok) {
3282
+ log.warn("Failed to delete work branch during cleanup", {
3283
+ branch: workBranchName,
3284
+ error: deleteResult.error,
3285
+ });
3286
+ }
3227
3287
  };
3228
3288
 
3229
3289
  // R1: Circuit breaker check — reject if circuit is open
@@ -3321,6 +3381,7 @@ export default function (pi: ExtensionAPI) {
3321
3381
  childDepth,
3322
3382
  undefined, // intercom
3323
3383
  undefined, // subagentId
3384
+ run.childMarker,
3324
3385
  );
3325
3386
 
3326
3387
  // Auto-retry on timeout
@@ -3368,6 +3429,7 @@ export default function (pi: ExtensionAPI) {
3368
3429
  childDepth,
3369
3430
  undefined, // intercom
3370
3431
  undefined, // subagentId
3432
+ run.childMarker,
3371
3433
  );
3372
3434
  }
3373
3435
 
@@ -3380,17 +3442,23 @@ export default function (pi: ExtensionAPI) {
3380
3442
  result.label = label;
3381
3443
 
3382
3444
  // P3: Capture git diff if we created a work branch
3383
- if (workBranchName && originalBranch) {
3384
- const diffResult = captureDiff(resolvedCwd, originalBranch);
3445
+ if (workBranchName && headState) {
3446
+ // Diff against the captured starting sha rather than the branch
3447
+ // name: a detached start's branch label is the "HEAD" pseudo-ref,
3448
+ // for which `git diff HEAD...HEAD` is always empty.
3449
+ const diffResult = captureDiff(resolvedCwd, headState.sha);
3385
3450
  if (diffResult.ok) {
3386
3451
  result.gitBranch = workBranchName;
3387
3452
  result.gitDiff = diffResult.diff;
3388
3453
  }
3389
3454
 
3390
- // Switch back to the original branch
3391
- const switchResult = switchToBranch(resolvedCwd, originalBranch);
3392
- if (switchResult.ok) {
3393
- log.info("Switched back to original branch", { branch: originalBranch });
3455
+ // Restore the starting HEAD (branch, or detached sha)
3456
+ const restoreResult = restoreHead(resolvedCwd, headState);
3457
+ if (restoreResult.ok) {
3458
+ log.info("Restored original HEAD", {
3459
+ branch: headState.branch,
3460
+ detached: headState.detached,
3461
+ });
3394
3462
 
3395
3463
  // P4: Change approval workflow — let user review the diff
3396
3464
  const diffContent = (result.gitDiff ?? "").trim();
@@ -3434,13 +3502,42 @@ export default function (pi: ExtensionAPI) {
3434
3502
  error: mergeResult.error,
3435
3503
  });
3436
3504
  }
3437
- deleteBranch(resolvedCwd, workBranchName);
3505
+ // #308: a DETACHED start has no branch of its own to hold the
3506
+ // merge result — teardown re-detached at the captured sha and
3507
+ // the merge fast-forwarded THAT detached HEAD, so deleting the
3508
+ // work branch would leave the committed work reachable only
3509
+ // from the detached HEAD (silently lost on the next checkout).
3510
+ // Keep the branch as the durable ref and log where the work
3511
+ // lives. An ATTACHED start merged onto its own branch, which
3512
+ // already retains the commit, so the temporary work branch is
3513
+ // safe to drop.
3514
+ if (headState.detached) {
3515
+ log.info("detached checkout: work preserved on branch", {
3516
+ branch: workBranchName,
3517
+ });
3518
+ } else {
3519
+ deleteBranch(resolvedCwd, workBranchName);
3520
+ }
3438
3521
  workBranchName = undefined;
3439
3522
  } else {
3440
3523
  // No changes — just delete the empty branch
3441
3524
  deleteBranch(resolvedCwd, workBranchName);
3442
3525
  workBranchName = undefined;
3443
3526
  }
3527
+ } else {
3528
+ log.warn("Failed to restore original HEAD after work-branch run", {
3529
+ branch: headState.branch,
3530
+ error: restoreResult.error,
3531
+ });
3532
+ }
3533
+
3534
+ // Nothing may leave teardown with the work branch still present: the
3535
+ // approval flow above clears workBranchName on every terminal choice
3536
+ // (including "apply", where keeping the branch is intentional), so a
3537
+ // still-set name means the restore failed and the flow was skipped.
3538
+ if (workBranchName) {
3539
+ deleteBranch(resolvedCwd, workBranchName);
3540
+ workBranchName = undefined;
3444
3541
  }
3445
3542
  }
3446
3543
 
@@ -3704,8 +3801,7 @@ export default function (pi: ExtensionAPI) {
3704
3801
  }
3705
3802
 
3706
3803
  // Include transcript path
3707
- const { getTranscriptPath } = await import('./session-manager');
3708
- resultText += `\nTranscript: ${getTranscriptPath(params.agent_id)}`;
3804
+ resultText += `\nTranscript: ${transcriptDisplayPath(params.agent_id)}`;
3709
3805
 
3710
3806
  return {
3711
3807
  content: [{ type: "text" as const, text: resultText }],
@@ -3824,6 +3920,50 @@ export default function (pi: ExtensionAPI) {
3824
3920
  // Session lifecycle — F7: Session-bound state initialization
3825
3921
  // -------------------------------------------------------------------
3826
3922
 
3923
+ // -------------------------------------------------------------------
3924
+ // Option B U1: boot recovery — identity/liveness detection + child reap
3925
+ // -------------------------------------------------------------------
3926
+ // Runs AFTER restoreFromSession so config is ready. Candidate runs come from
3927
+ // the DURABLE registry (`.pi/run-registry/`), NOT from this session's run
3928
+ // entries: a crashed session's foreground/unit records are invisible to a
3929
+ // default fresh startup, and the full `.pi/subagents/` store is too costly to
3930
+ // parse (only the one agent record a background entry names is read). For
3931
+ // each in-flight run whose owner is dead, its orphaned marker-verified child
3932
+ // is reaped and the record is marked: the registry entry always, plus the
3933
+ // agent record for background runs. SILENT in the UI — the notice/offer
3934
+ // surface is U3. Platforms without /proc skip the whole scan (one log line):
3935
+ // pid-only liveness cannot rule out pid reuse, so nothing is killed or
3936
+ // marked unverified.
3937
+ async function performBootRecovery(): Promise<void> {
3938
+ try {
3939
+ const { getAgent, markAgentInterrupted } = await import("./session-manager");
3940
+ // `recoverProduction` owns the /proc platform guard (nothing is marked or
3941
+ // killed without start-token verification) — see src/recovery.ts.
3942
+ const summary = await recoverProduction({
3943
+ log,
3944
+ // Read THAT ONE agent record by id — never the whole store.
3945
+ readAgentRecord: (id) => getAgent(id),
3946
+ mark(record, interruptedAt) {
3947
+ // Registry entry = the durable mark for every kind. Issue #304: its
3948
+ // boolean is the durable-write result — do NOT drop it, or the boot
3949
+ // summary would claim a mark that never landed. The scan counts a
3950
+ // `false` as `markFailures` and warns with the id.
3951
+ const durable = markInterrupted(record.id, interruptedAt);
3952
+ if (record.kind === "agent") markAgentInterrupted(record.id, interruptedAt);
3953
+ return durable;
3954
+ },
3955
+ });
3956
+ // Report the scan whenever it did anything durable OR attempted a mark
3957
+ // that failed — a failure-only boot must not be silent (issue #304).
3958
+ if (summary.marked > 0 || summary.markFailures > 0) {
3959
+ log.warn("Boot recovery marked interrupted runs", { ...summary });
3960
+ }
3961
+ } catch (err) {
3962
+ // Recovery is best-effort hygiene — it must never break session start.
3963
+ log.warn("Boot recovery scan failed", { error: (err as Error).message });
3964
+ }
3965
+ }
3966
+
3827
3967
  pi.on("session_start", async (_event, ctx) => {
3828
3968
  // Issue #147: capture the session context for the completion-push subscriber.
3829
3969
  sessionCtx = ctx;
@@ -3874,6 +4014,10 @@ export default function (pi: ExtensionAPI) {
3874
4014
  // F5/F9: Safe state restoration with type guards
3875
4015
  state.restoreFromSession(ctx);
3876
4016
 
4017
+ // Option B U1: reap a dead conductor's orphaned children and mark the
4018
+ // records interrupted BEFORE anything else touches their worktrees.
4019
+ await performBootRecovery();
4020
+
3877
4021
  updateStatus(state, ctx);
3878
4022
  });
3879
4023
 
@@ -3883,6 +4027,14 @@ export default function (pi: ExtensionAPI) {
3883
4027
  activeSubagents: state.activeSubagents,
3884
4028
  });
3885
4029
 
4030
+ // Option B U1: reap in-flight foreground children first, so a clean exit
4031
+ // never orphans a subprocess (the crash case is covered by boot recovery).
4032
+ try {
4033
+ await reapActiveChildren(log);
4034
+ } catch (err) {
4035
+ log.warn("Shutdown reap failed", { error: (err as Error).message });
4036
+ }
4037
+
3886
4038
  // Clear all live subagent sessions
3887
4039
  state.subagentSessions.clear();
3888
4040
  // Drop pending finalize claims too (PR #71 review: the claim set must