brl-subagent 2.3.9 → 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/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ // Purpose: Entry point: tool/command registration, the delegate_task handlers, and the execution-mode runners.
1
2
  /**
2
3
  * brl-subagent — multi-agent orchestration for pi.
3
4
  * Entry point: tool registrations, command surface, and the multi-mode runners.
@@ -29,6 +30,7 @@ import type {
29
30
  GraphDetails,
30
31
  GraphWave,
31
32
  DelegateTaskDetails,
33
+ DelegateTaskParams,
32
34
  Priority,
33
35
  TaskTemplate,
34
36
  ToolResult,
@@ -56,25 +58,34 @@ import { pkgPath } from "./paths";
56
58
 
57
59
  import { sanitizeTask, validateCwd, validateOutputFile, stripAnsi, capOutput, getCurrentDepth, sanitizeErrorMessage, buildCrashResult } from "./sanitize";
58
60
  import {
59
- getCurrentBranch,
60
61
  hasUncommittedChanges,
61
62
  createWorkBranch,
62
63
  captureDiff,
63
- switchToBranch,
64
+ getHeadState,
65
+ restoreHead,
64
66
  deleteBranch,
65
67
  mergeWorkBranch,
68
+ type HeadState,
66
69
  } from "./git";
67
70
  import { preflightCheck } from "./preflight";
68
71
  import { gateSessionCost, rejectApprovalAlwaysInBackground, runPreTaskValidation, validateDelegationTargets, formatValidationWarnings } from "./prelude";
69
72
  import { loadBuiltinPresets, loadCustomPresets, getAllPresets, writePresetFile, formatPresetRestriction, formatToolRestriction } from "./presets";
70
73
  import { modelIsAvailable } from "./model-availability";
71
- import { validatePreTask, diagnoseFailure } from "./validate";
74
+ import { validatePreTask, diagnoseFailure, DEFAULT_BACKGROUND_DEADLINE_MS } from "./validate";
72
75
  import { findUnknownParams, KNOWN_DELEGATE_KEYS, resolveSubagentParams, snapshotOriginalParams } from "./params";
76
+ import { delegateTaskParamsSchema } from "./schema";
73
77
  import { createSessionState } from "./state";
74
78
  import { makeLiveOnUpdate, createUnitRun, finalizeUnitRun, finalizeUnitRunCrash, pruneHistoryIfNeeded, registerLiveRun } from "./unit-run";
75
79
  import { buildSubagentPrompt, describePromptMode } from "./prompt";
76
- 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";
77
87
  import { acquireSlot, releaseSlot, updateStatus, updateProgressStatus } from "./concurrency";
88
+ import { transcriptDisplayPath } from "./transcript-path";
78
89
  import {
79
90
  finalizeRunRecord,
80
91
  resolveRetryParams,
@@ -100,6 +111,7 @@ import {
100
111
  showRunHistory,
101
112
  showMonitor,
102
113
  showDashboard,
114
+ showComplianceMenu,
103
115
  showRetryMenu,
104
116
  renderDelegateCall,
105
117
  renderDelegateResult,
@@ -119,35 +131,6 @@ import {
119
131
  // Helpers
120
132
  // ---------------------------------------------------------------------------
121
133
 
122
- /**
123
- * Sanitize a text preview for markdown-rendered notifications: strip code
124
- * fences (``` and ~~~) and backticks, keep whole non-empty lines with
125
- * mid-line truncation for over-long lines, and never split surrogate pairs
126
- * at the boundary.
127
- */
128
- function sanitizePreview(text: string, maxLen = 500): string {
129
- // Strip markdown fences and backticks
130
- const cleaned = text.replace(/```/g, '').replace(/~~~+/g, '').replace(/`/g, '');
131
- const lines = cleaned.split('\n').filter(l => l.trim());
132
- let out = '';
133
- for (const line of lines) {
134
- if (out.length + line.length + 1 > maxLen) {
135
- // Mid-line truncation: keep as much of this line as fits
136
- const remaining = maxLen - out.length - 1;
137
- if (remaining > 10) {
138
- out += line.slice(0, remaining - 3) + '...\n';
139
- }
140
- break;
141
- }
142
- out += line + '\n';
143
- }
144
- // Avoid splitting surrogate pairs at the boundary (reachable now via slice above)
145
- const safe = out.slice(0, maxLen);
146
- const lastChar = safe[safe.length - 1];
147
- const stripped = lastChar && /[\uD800-\uDBFF]/.test(lastChar) ? safe.slice(0, -1) : safe;
148
- return stripped.trimEnd();
149
- }
150
-
151
134
  /**
152
135
  * DRY helper (issue #154 review): shape an inventory of items into a single
153
136
  * comma-delimited summary line for the LLM-facing delegation guidance. Both
@@ -192,6 +175,17 @@ export function buildTemplateGuideline(templateSummary: string): string {
192
175
  // Extension entry point
193
176
  // ---------------------------------------------------------------------------
194
177
 
178
+ /**
179
+ * Compile-time coverage (issue #229 item 4): fails to compile if
180
+ * DelegateTaskParams omits any KNOWN_DELEGATE_KEYS entry. Runtime drift is
181
+ * invisible (the schema passes the key through, the handler ignores it), so
182
+ * only the compiler can catch it. Together with per-step-model.test.ts's
183
+ * runtime ratchet this closes the chain: registered schema <-> KNOWN_DELEGATE_KEYS <-> DelegateTaskParams.
184
+ */
185
+ type _KnownDelegateKey = (typeof KNOWN_DELEGATE_KEYS extends Set<infer K> ? K : never) & string;
186
+ type _ExpectNever<T extends never> = T;
187
+ type _DelegateParamCoverage = _ExpectNever<Exclude<_KnownDelegateKey, keyof DelegateTaskParams>>;
188
+
195
189
  export default function (pi: ExtensionAPI) {
196
190
  const log = createLogger("brl-subagent");
197
191
 
@@ -413,12 +407,12 @@ export default function (pi: ExtensionAPI) {
413
407
  // -------------------------------------------------------------------
414
408
 
415
409
  async function runChainMode(
416
- params: Record<string, unknown>,
410
+ params: DelegateTaskParams,
417
411
  signal: AbortSignal | undefined,
418
412
  onUpdate: AgentToolUpdateCallback<DelegateTaskDetails> | undefined,
419
413
  ctx: ExtensionContext,
420
414
  ): Promise<ToolResult<SubagentResult | ChainDetails | undefined>> {
421
- const chainSteps = params.chain as SubTaskParams[];
415
+ const chainSteps: SubTaskParams[] = params.chain!;
422
416
 
423
417
  // Issue #133: dispatch-start timestamp — the aggregate run entry's
424
418
  // startedAt (captured once, at mode entry, not at completion).
@@ -714,7 +708,7 @@ export default function (pi: ExtensionAPI) {
714
708
  // foreground live entry (the #130 invariant gap that swept
715
709
  // graph/chain entries immediately).
716
710
  const { runId, run } = createUnitRun(merged, stepModel, chainPriority, globalParams.resolvedPreset?.name);
717
- state.persistRun(pi, run);
711
+ state.persistRun(pi, run, "unit");
718
712
 
719
713
  // R2: Prune old run entries if history exceeds limit
720
714
  pruneHistoryIfNeeded(state, ctx, log);
@@ -754,6 +748,9 @@ export default function (pi: ExtensionAPI) {
754
748
  getFinalOutput,
755
749
  log,
756
750
  currentDepth + 1,
751
+ undefined, // intercom
752
+ undefined, // subagentId
753
+ run.childMarker,
757
754
  );
758
755
  } catch (err) {
759
756
  // Issue #133 (mirrors the #119 R1/C1 discipline): finalize the
@@ -937,12 +934,12 @@ export default function (pi: ExtensionAPI) {
937
934
  // -------------------------------------------------------------------
938
935
 
939
936
  async function runParallelMode(
940
- params: Record<string, unknown>,
937
+ params: DelegateTaskParams,
941
938
  signal: AbortSignal | undefined,
942
939
  onUpdate: AgentToolUpdateCallback<DelegateTaskDetails> | undefined,
943
940
  ctx: ExtensionContext,
944
941
  ): Promise<ToolResult<SubagentResult | ParallelDetails | undefined>> {
945
- const taskList = params.tasks as SubTaskParams[];
942
+ const taskList: SubTaskParams[] = params.tasks!;
946
943
 
947
944
  // R5: Check session cost limit before spawning
948
945
  const costError = gateSessionCost({
@@ -1166,7 +1163,7 @@ export default function (pi: ExtensionAPI) {
1166
1163
  // monitor drill-in shows per-unit priority and a post-hoc audit
1167
1164
  // trail exists per subtask (mirrors single-mode run persistence).
1168
1165
  const { runId, run } = createUnitRun(merged, stepModel, parallelPriority, globalParams.resolvedPreset?.name);
1169
- state.persistRun(pi, run);
1166
+ state.persistRun(pi, run, "unit");
1170
1167
 
1171
1168
  // R2: Prune old run entries if history exceeds limit
1172
1169
  pruneHistoryIfNeeded(state, ctx, log);
@@ -1224,6 +1221,7 @@ export default function (pi: ExtensionAPI) {
1224
1221
  currentDepth + 1,
1225
1222
  intercom,
1226
1223
  subagentId,
1224
+ run.childMarker,
1227
1225
  );
1228
1226
 
1229
1227
  // Fill SubTaskResult
@@ -1407,12 +1405,12 @@ export default function (pi: ExtensionAPI) {
1407
1405
  // -------------------------------------------------------------------
1408
1406
 
1409
1407
  async function runGraphMode(
1410
- params: Record<string, unknown>,
1408
+ params: DelegateTaskParams,
1411
1409
  signal: AbortSignal | undefined,
1412
1410
  onUpdate: AgentToolUpdateCallback<DelegateTaskDetails> | undefined,
1413
1411
  ctx: ExtensionContext,
1414
1412
  ): Promise<ToolResult<SubagentResult | GraphDetails | undefined>> {
1415
- const graphTasks = params.graph as GraphTask[];
1413
+ const graphTasks: GraphTask[] = params.graph!;
1416
1414
 
1417
1415
  // Issue #133: dispatch-start timestamp — the aggregate run entry's
1418
1416
  // startedAt (captured once, at mode entry, not at completion).
@@ -1802,7 +1800,7 @@ export default function (pi: ExtensionAPI) {
1802
1800
  // foreground live entry (the #130 invariant gap that swept
1803
1801
  // graph/chain entries immediately).
1804
1802
  const { runId, run } = createUnitRun(merged, stepModel, graphPriority, globalParams.resolvedPreset?.name);
1805
- state.persistRun(pi, run);
1803
+ state.persistRun(pi, run, "unit");
1806
1804
 
1807
1805
  // R2: Prune old run entries if history exceeds limit
1808
1806
  pruneHistoryIfNeeded(state, ctx, log);
@@ -1839,6 +1837,7 @@ export default function (pi: ExtensionAPI) {
1839
1837
  currentDepth + 1,
1840
1838
  intercom,
1841
1839
  subagentId,
1840
+ run.childMarker,
1842
1841
  );
1843
1842
 
1844
1843
  const subTaskResult: SubTaskResult = {
@@ -2012,10 +2011,10 @@ export default function (pi: ExtensionAPI) {
2012
2011
  async function runBackgroundFanOut(
2013
2012
  pi: ExtensionAPI,
2014
2013
  ctx: ExtensionContext,
2015
- params: Record<string, unknown>,
2014
+ params: DelegateTaskParams,
2016
2015
  signal: AbortSignal | undefined,
2017
2016
  ): Promise<ToolResult<SubagentResult | undefined>> {
2018
- const taskList = params.tasks as SubTaskParams[];
2017
+ const taskList: SubTaskParams[] = params.tasks!;
2019
2018
 
2020
2019
  // Session cost limit gates the WHOLE batch before any spawn — same
2021
2020
  // estimate/limit logic as runParallelMode, scaled by task count.
@@ -2253,10 +2252,10 @@ export default function (pi: ExtensionAPI) {
2253
2252
  // startBackgroundAgent — the shared background-spawn tail.
2254
2253
  // Used by BOTH spawnBackgroundRun (single task) and runBackgroundFanOut
2255
2254
  // (tasks fan-out): the session-manager import, the spawn call, live-monitor
2256
- // registration, footer counters, the 2s progress poller and the hard-cap
2257
- // timer live here exactly once. Callers keep their own prelude, checks,
2258
- // validations and result formatting. THROWS on spawn failure — callers
2259
- // translate the error into their own result shape.
2255
+ // registration, footer counters, the 2s progress poller and the default
2256
+ // deadline timer live here exactly once. Callers keep their own prelude,
2257
+ // checks, validations and result formatting. THROWS on spawn failure —
2258
+ // callers translate the error into their own result shape.
2260
2259
  // -------------------------------------------------------------------
2261
2260
 
2262
2261
  async function startBackgroundAgent(
@@ -2277,6 +2276,10 @@ export default function (pi: ExtensionAPI) {
2277
2276
  originalParams?: SubagentRun["originalParams"];
2278
2277
  /** cwd used to redact paths from crash notifications. */
2279
2278
  sanitizeCwd: string;
2279
+ // Option B U1: durability identity threaded onto the background records.
2280
+ dispatchId?: string;
2281
+ resumeOf?: string;
2282
+ attempt?: number;
2280
2283
  },
2281
2284
  ): Promise<BackgroundAgent> {
2282
2285
  const { spawnBackgroundSession, setAgentFinalOutput, extractFinalOutput, updateAgentStatus } = await import('./session-manager');
@@ -2303,6 +2306,10 @@ export default function (pi: ExtensionAPI) {
2303
2306
  // Issue #98/#108: snapshotOriginalParams is the single source of truth
2304
2307
  // for the caller params a later retry restores.
2305
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,
2306
2313
  });
2307
2314
 
2308
2315
  // Register for live monitor
@@ -2412,7 +2419,7 @@ export default function (pi: ExtensionAPI) {
2412
2419
  }, { deliverAs: "followUp" });
2413
2420
  } else if (agent.status === 'stopped') {
2414
2421
  // User-initiated stop (stop_subagent) or deadline abort
2415
- // (timeout/hard cap) — not a failure.
2422
+ // (timeout/deadline) — not a failure.
2416
2423
  updateProgressStatus(state, ctx);
2417
2424
  } else {
2418
2425
  state.completedSubagents++;
@@ -2447,20 +2454,28 @@ export default function (pi: ExtensionAPI) {
2447
2454
  }
2448
2455
  }, 2000);
2449
2456
 
2450
- // Hard cap: stop polling AND abort the session after the deadline.
2451
- // W2 (issue #28): previously this only stopped the poller — the pi
2452
- // session kept running forever (orphaned). Now it pre-sets
2453
- // 'stopped' (so the .then keeps it, per W1) and aborts the
2454
- // session. The cap honors a shorter per-agent timeout.
2455
- const hardCapMs = Math.min(spawn.timeout ?? 30 * 60 * 1000, 30 * 60 * 1000);
2456
- const hardCapHandle = setTimeout(() => {
2457
+ // Default deadline: stop polling AND abort the session after the
2458
+ // deadline. W2 (issue #28): previously this only stopped the poller —
2459
+ // the pi session kept running forever (orphaned). Now it pre-sets
2460
+ // 'stopped' (so the .then keeps it, per W1) and aborts the session.
2461
+ // Issue #240: an explicit per-agent timeout is HONORED verbatim; this
2462
+ // deadline DEFAULTS to 30m only when no timeout is given. It is a
2463
+ // default, not a ceiling. Issue #244: exactly ONE deadline timer per
2464
+ // run — an explicit timeout is owned by the session-manager W3 timer,
2465
+ // so this index timer arms ONLY on the no-timeout path, mirroring
2466
+ // spawnBackgroundSession's `params.timeout && params.timeout > 0` guard
2467
+ // (with no timeout, it owns the 30m default, #28).
2468
+ const hasExplicitDeadline = spawn.timeout !== undefined && spawn.timeout > 0;
2469
+ const hardCapMs = spawn.timeout ?? DEFAULT_BACKGROUND_DEADLINE_MS;
2470
+ const hardCapHandle: ReturnType<typeof setTimeout> | undefined =
2471
+ hasExplicitDeadline ? undefined : setTimeout(() => {
2457
2472
  if (!completed && !agent.completedAt) {
2458
2473
  try {
2459
2474
  completed = true;
2460
2475
  clearInterval(pollInterval);
2461
2476
  // Pre-set stopped BEFORE aborting so the .then in
2462
2477
  // spawnBackgroundSession keeps the stopped state.
2463
- updateAgentStatus(agent.id, 'stopped', `Timed out (${hardCapMs}ms hard cap)`);
2478
+ updateAgentStatus(agent.id, 'stopped', `Timed out (${hardCapMs}ms deadline)`);
2464
2479
  agent._sessionRef?.abort().catch(() => {
2465
2480
  // Abort may reject if the session is mid-dispose;
2466
2481
  // the status flip above is already recorded.
@@ -2480,7 +2495,7 @@ export default function (pi: ExtensionAPI) {
2480
2495
  // W3/poller stopped path (no completedSubagents increment).
2481
2496
  updateProgressStatus(state, ctx);
2482
2497
  } catch (err) {
2483
- // Defensive: never let the hard-cap timer throw uncaught — that would
2498
+ // Defensive: never let the deadline timer throw uncaught — that would
2484
2499
  // skip finalizeLiveSubagent, the counter decrement, and the notification.
2485
2500
  // Do all fallible work first (output capture), then mutate counters,
2486
2501
  // then notify — a throw mid-path can't double-fire mutations.
@@ -2491,7 +2506,7 @@ export default function (pi: ExtensionAPI) {
2491
2506
  // get_subagent_result doesn't report 'running' and the W3 timer
2492
2507
  // guard (!agent.completedAt) can't re-fire later.
2493
2508
  try {
2494
- updateAgentStatus(agent.id, 'stopped', `Timed out (${hardCapMs}ms hard cap)`);
2509
+ updateAgentStatus(agent.id, 'stopped', `Timed out (${hardCapMs}ms deadline)`);
2495
2510
  } catch { /* ignore */ }
2496
2511
  try {
2497
2512
  setAgentFinalOutput(agent.id, extractAgentFinalOutput(agent));
@@ -2528,6 +2543,10 @@ export default function (pi: ExtensionAPI) {
2528
2543
  priority?: string;
2529
2544
  },
2530
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,
2531
2550
  ): Promise<ToolResult<undefined>> {
2532
2551
  // Phase 6.5: Background execution — spawn session and return ID immediately.
2533
2552
  // Resolve the preset and its model BEFORE the background branch so the
@@ -2608,6 +2627,8 @@ export default function (pi: ExtensionAPI) {
2608
2627
  bgResolvedPreset?.promptGuideline,
2609
2628
  );
2610
2629
 
2630
+ // Option B U1: durability identity (retry inherits the original dispatch).
2631
+ const bgIdentity = newDispatchIdentity(retrySourceRun);
2611
2632
  const agent = await startBackgroundAgent(pi, ctx, {
2612
2633
  task: singleTask,
2613
2634
  type: params.preset || 'general-purpose',
@@ -2622,6 +2643,9 @@ export default function (pi: ExtensionAPI) {
2622
2643
  gitMode: bgResolved.resolvedGitMode,
2623
2644
  originalParams: snapshotOriginalParams(params),
2624
2645
  sanitizeCwd: bgResolved.effectiveCwd,
2646
+ dispatchId: bgIdentity.dispatchId,
2647
+ resumeOf: bgIdentity.resumeOf,
2648
+ attempt: bgIdentity.attempt,
2625
2649
  });
2626
2650
 
2627
2651
  // B2: Surface auto-route decisions in background spawn result too —
@@ -2696,6 +2720,11 @@ export default function (pi: ExtensionAPI) {
2696
2720
  retry: () => showRetryMenu(ctx, state),
2697
2721
  sla: () => showSLAConfig(ctx, state, applyConfig),
2698
2722
  "sla-stats": () => showSLAStats(ctx, state),
2723
+ // E5: the compliance report menu (file access / secrets exposure /
2724
+ // full summary) shipped in tui.ts but had no handler key, so
2725
+ // `/brl-subagent compliance` fell through to the config menu and the
2726
+ // feature was unreachable (broken wiring, not dead code).
2727
+ compliance: () => showComplianceMenu(ctx, state),
2699
2728
  };
2700
2729
 
2701
2730
  if (trimmed && trimmed in handlers) {
@@ -2755,13 +2784,13 @@ export default function (pi: ExtensionAPI) {
2755
2784
  "You can customize per-call via inheritSystemPrompt and systemPrompt: set inheritSystemPrompt: false to save context, provide a systemPrompt for custom instructions, or use both to add instructions on top of inheritance.",
2756
2785
  "Set thinkingLevel per call to match task complexity. The level is capped at the user's configured maximum. Map tasks to levels using this heuristic: off = file listing, grep, simple read. minimal = file diff, syntax check, find-and-replace. low = refactoring, test generation, documentation. medium = default — code review, debugging, moderate analysis. high = security audit, architecture review, complex debugging. xhigh = multi-step causal reasoning, research, novel problem solving. Default to 'off' or 'minimal' for trivial tasks — do not waste the user's budget.",
2757
2786
  "Use outputFile to have the subagent write full findings to disk and return only a structured summary — saves context tokens for large investigations.",
2758
- "Set timeout (in ms) to limit how long a subagent can run. Useful for tasks that might hang or get stuck. Background runs are additionally hard-capped at 30 minutes, so a larger value is silently shortened.",
2787
+ "Set timeout (in ms) to limit how long a subagent can run. Useful for tasks that might hang or get stuck. Background runs default to a 30-minute deadline when no timeout is given; an explicit timeout is honored as given (30 minutes is a default, not a ceiling).",
2759
2788
  "Set cwd to override the subagent's working directory. Defaults to the current project directory.",
2760
2789
  "Set label to give the subagent a human-readable name (e.g., 'security-audit' or 'docs-review'). Labels appear in the status bar and tool call display.",
2761
2790
  `Use preset to apply a delegation configuration (built-in or custom via /brl-subagent preset). Preset values are defaults — explicit parameters override them. IMPORTANT: some presets restrict tools — e.g. outputFile requires the subagent's write tool, which security-auditor and code-reviewer exclude. Built-in presets: ${presetRestrictionSummary}. Custom presets are NOT listed here — inspect them via /brl-subagent preset before combining with outputFile or tool-dependent work. When combining a preset with outputFile or tool-dependent work, verify the preset allows the required tools.`,
2762
2791
  buildTemplateGuideline(templateSummary),
2763
2792
  "To retry a failed subagent, pass its run ID as retryRunId. The retry restores the recorded params - including background, gitMode, approvalMode, force - with explicit values on this call winning. Multi-step runs degrade to a single task. See the retryRunId description for the full field list.",
2764
- "Set retryOnTimeout: true to automatically retry a subagent that times out. Only retries once — the second timeout is treated as a final failure.",
2793
+ "Set retryOnTimeout: true to automatically retry a FOREGROUND subagent that times out. Only retries once — the second timeout is treated as a final failure. Background runs are not auto-retried; re-dispatch with retryRunId.",
2765
2794
  "Set background: true to run the subagent in the background without blocking. The tool returns immediately with an agent ID. With tasks, background fans out: every task starts as its own background agent, the call returns one ID per task in task order, and the conductor is woken once per agent as each finishes; chain and graph cannot be combined with background (rejected). Background runs wake the conductor with a structured completion message when they finish — do not poll: polling is only correct when completion notifications are disabled (completionNotify \"off\"); one status check as a stall check is legitimate.",
2766
2795
  "",
2767
2796
  "## Conductor Guardrails",
@@ -2771,7 +2800,7 @@ export default function (pi: ExtensionAPI) {
2771
2800
  "2. **Thinking level**: Match thinking level to task complexity: off/minimal for trivial tasks (file listing, grep), low for refactoring/docs, medium for code review/debugging, high for security audits/complex debugging, xhigh for multi-step reasoning/novel problems.",
2772
2801
  "3. **Git mode**: Use gitMode='branch' for tasks that create commits or PRs. Use gitMode='none' for read-only tasks.",
2773
2802
  "4. **Tools**: Verify the subagent has the tools it needs. If the task writes files, ensure write and edit are not excluded. If the task runs commands, ensure bash is not excluded.",
2774
- "5. **Timeout**: Set timeout based on task complexity. Simple: 30s. Medium: 60s. Complex: 120s+. xhigh thinking: at least 120s. Background runs are hard-capped at 30 minutes regardless of the value set.",
2803
+ "5. **Timeout**: Set timeout based on task complexity. Simple: 30s. Medium: 60s. Complex: 120s+. xhigh thinking: at least 120s. Background runs default to a 30-minute deadline when no timeout is set; an explicit timeout is honored.",
2775
2804
  "",
2776
2805
  "These guardrails prevent common misconfigurations. The extension also validates configuration before spawning (H1): tool warnings are surfaced in the returned result, but two mismatches are HARD errors that reject the delegation — outputFile with the write tool excluded, and a capability-critical task/toolset mismatch (a run/execute/test/compile/benchmark task without bash, or an exploration task with none of find/ls/grep/bash; pass force: true to override the capability class). Getting it right the first time is faster and more efficient.",
2777
2806
  "",
@@ -2779,328 +2808,19 @@ export default function (pi: ExtensionAPI) {
2779
2808
  "The autoRoutePreset() function can automatically select the best preset based on task keywords. Consider using it for preset selection.",
2780
2809
  "Prefer a preset when the delegation matches a standard shape — review/audit/test/docs; override per-call deliberately.",
2781
2810
  ],
2782
- parameters: Type.Object({
2783
- task: Type.Optional(Type.String({
2784
- description: "Detailed description of the task for the subagent to complete (required for single mode, optional for chain/tasks/graph)",
2785
- })),
2786
- systemPrompt: Type.Optional(
2787
- Type.String({
2788
- description:
2789
- "Custom system prompt or additional instructions for the subagent. " +
2790
- "When inheritSystemPrompt is true (default), this is appended after the inherited prompt. " +
2791
- "When inheritSystemPrompt is false, this replaces the inherited prompt entirely.",
2792
- }),
2793
- ),
2794
- inheritSystemPrompt: Type.Optional(
2795
- Type.Boolean({
2796
- description:
2797
- "Whether to inherit the main agent's system prompt. " +
2798
- "Default: true. Set to false to use only your custom systemPrompt, " +
2799
- "or to avoid passing a large inherited prompt to the subagent.",
2800
- }),
2801
- ),
2802
- thinkingLevel: Type.Optional(
2803
- Type.String({
2804
- description:
2805
- "Requested thinking level for this subagent call. " +
2806
- "One of: off, minimal, low, medium, high, xhigh. " +
2807
- "Capped at the user's configured maximum. If omitted, the user's configured level is used.",
2808
- }),
2809
- ),
2810
- outputFile: Type.Optional(
2811
- Type.String({
2812
- description:
2813
- "Project-relative path where the subagent should write its full findings. " +
2814
- "When provided, the subagent is instructed to write complete output to this file " +
2815
- "and return only a structured summary.",
2816
- }),
2817
- ),
2818
- label: Type.Optional(
2819
- Type.String({
2820
- description:
2821
- "Human-readable label for this subagent (e.g., 'security-audit', 'docs-review'). " +
2822
- "Appears in the status bar and tool call display. " +
2823
- "Omit to use the default anonymous counter.",
2824
- }),
2825
- ),
2826
- model: Type.Optional(Type.String({ description: "Model override (provider/model-id). Defaults to the global subagent model." })),
2827
- timeout: Type.Optional(
2828
- Type.Number({
2829
- description:
2830
- "Maximum time in milliseconds the subagent is allowed to run. " +
2831
- "If exceeded, the subagent is killed and an error is returned. " +
2832
- "Background runs are additionally hard-capped at 30 minutes, so a larger value is silently shortened there.",
2833
- }),
2834
- ),
2835
- cwd: Type.Optional(
2836
- Type.String({
2837
- description:
2838
- "Working directory for the subagent. Must be an existing directory. " +
2839
- "Defaults to the conductor's current working directory.",
2840
- }),
2841
- ),
2842
- tools: Type.Optional(
2843
- Type.Array(Type.String(), {
2844
- description:
2845
- "Explicit allowlist of tool names for the subagent. Maps to pi's --tools flag.",
2846
- }),
2847
- ),
2848
- excludeTools: Type.Optional(
2849
- Type.Array(Type.String(), {
2850
- description:
2851
- "Tool names to disable for the subagent. Maps to pi's --exclude-tools flag.",
2852
- }),
2853
- ),
2854
- noBuiltinTools: Type.Optional(
2855
- Type.Boolean({
2856
- description:
2857
- "Disable all built-in tools for the subagent. Maps to pi's --no-builtin-tools flag.",
2858
- }),
2859
- ),
2860
- preset: Type.Optional(
2861
- Type.String({
2862
- description:
2863
- "Name of a saved delegation preset (created via /brl-subagent preset). " +
2864
- "Preset values are used as defaults; explicit parameters on this call override them.",
2865
- }),
2866
- ),
2867
- template: Type.Optional(
2868
- Type.String({
2869
- description:
2870
- "Name of a saved task template. Use with params to fill template slots. " +
2871
- "Templates are file-backed: 9 builtin templates ship with the extension " +
2872
- "(browse via /brl-subagent templates); override or add via .md files in " +
2873
- "~/.pi/agent/brl-subagent/templates/ or .pi/brl-subagent/templates/ " +
2874
- "(project-local wins over user-global over builtin).",
2875
- }),
2876
- ),
2877
- params: Type.Optional(
2878
- Type.Record(Type.String(), Type.String(), {
2879
- description:
2880
- "Parameter values for template ${param} slots. " +
2881
- "Keys are param names, values are the substitution text.",
2882
- }),
2883
- ),
2884
- retryRunId: Type.Optional(
2885
- Type.String({
2886
- description:
2887
- "ID of a previously failed subagent run to retry. " +
2888
- "The retry rebuilds the parameter object from a fixed field set, falling back to the " +
2889
- "original run's recorded values, with explicit values on this call winning: task, label, " +
2890
- "model, preset, systemPrompt, inheritSystemPrompt, thinkingLevel, priority, outputFile, " +
2891
- "timeout, cwd, tools, excludeTools, noBuiltinTools, background, gitMode, approvalMode, force. " +
2892
- "The execution-shape fields ARE restored: a retried background run stays background (with its " +
2893
- "completion wake), and a retried branch-mode run keeps its work branch, its approvalMode " +
2894
- "gating and its force override. Qualify that by the effective gitMode - when neither the record " +
2895
- "nor this call carries one, the configured default applies, which may still be 'branch'. " +
2896
- "Not restored: chain/tasks/graph and params, so a retried multi-step run silently degrades to a " +
2897
- "single task - re-issue it fresh. retryOnTimeout is explicit-only: never restored, honoured when passed. " +
2898
- "template is asymmetric: the original's template is NOT restored, but a template passed on the " +
2899
- "retry call DOES take effect (it is resolved before the retry merge). " +
2900
- "Fan-out units (parallel/chain/graph origin) record no background, so retrying one is a single " +
2901
- "foreground run - pass background: true for a background retry. " +
2902
- "Only works with runs that ended in failure (exitCode != 0, timeout, error, or abort).",
2903
- }),
2904
- ),
2905
- gitMode: Type.Optional(
2906
- Type.String({
2907
- description:
2908
- "Git integration mode for this subagent call. " +
2909
- "'branch' creates a work branch, captures the diff, and switches back. " +
2910
- "'none' (default) does nothing. Falls back to the configured default.",
2911
- }),
2912
- ),
2913
- retryOnTimeout: Type.Optional(
2914
- Type.Boolean({
2915
- description:
2916
- "If true and the subagent times out, automatically retry with the same parameters. " +
2917
- "Only retries once — the second timeout is treated as a final failure.",
2918
- }),
2919
- ),
2920
- approvalMode: Type.Optional(
2921
- Type.String({
2922
- description:
2923
- "Change approval mode: auto (never ask), writes (ask when files changed), " +
2924
- "always (ask every time). Default is user config (/brl-subagent approval).",
2925
- }),
2926
- ),
2927
- force: Type.Optional(
2928
- Type.Boolean({
2929
- description:
2930
- "Override for capability pre-flight errors (default false). A dispatch is blocked when the task clearly needs a capability the resolved toolset lacks — " +
2931
- "a run/execute/test/compile/benchmark task with no bash, or an exploration task (search/grep/find/list/locate/glob) with none of find/ls/grep/bash. " +
2932
- "Set force: true to dispatch anyway: the mismatch is then delivered as a warning in the result instead of rejecting the call. " +
2933
- "Warnings are surfaced either way. force never suppresses outputFile-without-write conflicts.",
2934
- }),
2935
- ),
2936
- background: Type.Optional(
2937
- Type.Boolean({
2938
- description:
2939
- "Run the subagent in the background without blocking the conductor. " +
2940
- "When true, the tool returns immediately with an agent ID. " +
2941
- "The conductor is woken with a completion message; use get_subagent_result for post-wake retrieval and stall checks. " +
2942
- "Supports a single task or the tasks array (a fan-out that starts one background agent per task; with tasks the call returns one ID per task and the conductor is woken once per agent as each finishes); combining background with chain or graph is rejected. " +
2943
- "Default: false (blocking mode).",
2944
- }),
2945
- ),
2946
- priority: Type.Optional(
2947
- Type.Union([
2948
- Type.Literal("critical"),
2949
- Type.Literal("high"),
2950
- Type.Literal("normal"),
2951
- Type.Literal("low"),
2952
- ], {
2953
- description: "Concurrency priority for this delegation: critical, high, normal, or low. Higher-priority delegations queue ahead. Defaults to normal.",
2954
- })
2955
- ),
2956
- // Issue #114: NO per-step `priority` on chain[] — chain steps never
2957
- // compete for concurrency slots (the chain holds ONE slot for its whole
2958
- // duration); array order IS the priority.
2959
- chain: Type.Optional(Type.Array(Type.Object({
2960
- task: Type.String({ description: "Task description. Use {previous} to reference the previous step output." }),
2961
- label: Type.Optional(Type.String({})),
2962
- model: Type.Optional(Type.String({ description: "Model override for this step (provider/model-id). Defaults to the global subagent model." })),
2963
- thinkingLevel: Type.Optional(Type.String({})),
2964
- cwd: Type.Optional(Type.String({})),
2965
- timeout: Type.Optional(Type.Number({})),
2966
- outputFile: Type.Optional(Type.String({})),
2967
- tools: Type.Optional(Type.Array(Type.String({}))),
2968
- excludeTools: Type.Optional(Type.Array(Type.String({}))),
2969
- noBuiltinTools: Type.Optional(Type.Boolean({})),
2970
- systemPrompt: Type.Optional(Type.String({})),
2971
- inheritSystemPrompt: Type.Optional(Type.Boolean({})),
2972
- }), {
2973
- description: "Sequential chain of tasks. Each step receives the previous step output via {previous} placeholder in the task string. Chain stops at the first failure. Max " + MAX_CHAIN_STEPS + " steps."
2974
- })),
2975
- tasks: Type.Optional(Type.Array(Type.Object({
2976
- task: Type.String({ description: "Task description for this parallel subtask" }),
2977
- label: Type.Optional(Type.String({})),
2978
- model: Type.Optional(Type.String({ description: "Model override for this step (provider/model-id). Defaults to the global subagent model." })),
2979
- thinkingLevel: Type.Optional(Type.String({})),
2980
- priority: Type.Optional(
2981
- Type.Union([
2982
- Type.Literal("critical"),
2983
- Type.Literal("high"),
2984
- Type.Literal("normal"),
2985
- Type.Literal("low"),
2986
- ], { description: "Concurrency priority for this subtask (overrides the call-level default)." })
2987
- ),
2988
- cwd: Type.Optional(Type.String({})),
2989
- timeout: Type.Optional(Type.Number({})),
2990
- outputFile: Type.Optional(Type.String({})),
2991
- tools: Type.Optional(Type.Array(Type.String({}))),
2992
- excludeTools: Type.Optional(Type.Array(Type.String({}))),
2993
- noBuiltinTools: Type.Optional(Type.Boolean({})),
2994
- systemPrompt: Type.Optional(Type.String({})),
2995
- inheritSystemPrompt: Type.Optional(Type.Boolean({})),
2996
- }), {
2997
- description: "Parallel tasks to execute concurrently. All tasks run regardless of individual failures. Max " + MAX_PARALLEL_TASKS + " tasks."
2998
- })),
2999
- graph: Type.Optional(Type.Array(Type.Object({
3000
- id: Type.String({ description: "Unique identifier for this task node" }),
3001
- task: Type.String({ description: "Task description. Use {<nodeId>} to reference output from another task (the referenced node's own id, which must be a word-character id - letters, digits, underscore; ids like 'step-1' or 'node.a' are not matched and the braces are left as literal text)." }),
3002
- label: Type.Optional(Type.String({})),
3003
- model: Type.Optional(Type.String({ description: "Model override for this step (provider/model-id). Defaults to the global subagent model." })),
3004
- dependsOn: Type.Optional(Type.Array(Type.String({}), { description: "IDs of tasks that must complete before this one starts" })),
3005
- thinkingLevel: Type.Optional(Type.String({})),
3006
- priority: Type.Optional(
3007
- Type.Union([
3008
- Type.Literal("critical"),
3009
- Type.Literal("high"),
3010
- Type.Literal("normal"),
3011
- Type.Literal("low"),
3012
- ], { description: "Concurrency priority for this subtask (overrides the call-level default)." })
3013
- ),
3014
- cwd: Type.Optional(Type.String({})),
3015
- timeout: Type.Optional(Type.Number({})),
3016
- outputFile: Type.Optional(Type.String({})),
3017
- tools: Type.Optional(Type.Array(Type.String({}))),
3018
- excludeTools: Type.Optional(Type.Array(Type.String({}))),
3019
- noBuiltinTools: Type.Optional(Type.Boolean({})),
3020
- systemPrompt: Type.Optional(Type.String({})),
3021
- inheritSystemPrompt: Type.Optional(Type.Boolean({})),
3022
- }), {
3023
- description: "Declare tasks with dependencies. The scheduler parallelizes independent tasks and sequences dependent ones. Max " + MAX_GRAPH_TASKS + " tasks."
3024
- })),
3025
- }),
2811
+ parameters: delegateTaskParamsSchema,
3026
2812
 
3027
2813
  async execute(
3028
2814
  _toolCallId: string,
3029
- params: {
3030
- // Schema registers task as Type.Optional — required for single mode only,
3031
- // omitted by chain/tasks/graph calls. Aligned with Static<TParams>.
3032
- task?: string;
3033
- label?: string;
3034
- model?: string;
3035
- preset?: string;
3036
- systemPrompt?: string;
3037
- inheritSystemPrompt?: boolean;
3038
- thinkingLevel?: string;
3039
- outputFile?: string;
3040
- timeout?: number;
3041
- cwd?: string;
3042
- tools?: string[];
3043
- excludeTools?: string[];
3044
- noBuiltinTools?: boolean;
3045
- template?: string;
3046
- params?: Record<string, string>;
3047
- retryRunId?: string;
3048
- retryOnTimeout?: boolean;
3049
- background?: boolean;
3050
- force?: boolean;
3051
- gitMode?: string;
3052
- priority?: string;
3053
- chain?: Array<{
3054
- task: string;
3055
- label?: string;
3056
- model?: string;
3057
- thinkingLevel?: string;
3058
- cwd?: string;
3059
- timeout?: number;
3060
- outputFile?: string;
3061
- tools?: string[];
3062
- excludeTools?: string[];
3063
- noBuiltinTools?: boolean;
3064
- systemPrompt?: string;
3065
- inheritSystemPrompt?: boolean;
3066
- }>;
3067
- tasks?: Array<{
3068
- task: string;
3069
- label?: string;
3070
- model?: string;
3071
- thinkingLevel?: string;
3072
- priority?: string;
3073
- cwd?: string;
3074
- timeout?: number;
3075
- outputFile?: string;
3076
- tools?: string[];
3077
- excludeTools?: string[];
3078
- noBuiltinTools?: boolean;
3079
- systemPrompt?: string;
3080
- inheritSystemPrompt?: boolean;
3081
- }>;
3082
- graph?: Array<{
3083
- id: string;
3084
- task: string;
3085
- label?: string;
3086
- model?: string;
3087
- dependsOn?: string[];
3088
- thinkingLevel?: string;
3089
- priority?: string;
3090
- cwd?: string;
3091
- timeout?: number;
3092
- outputFile?: string;
3093
- tools?: string[];
3094
- excludeTools?: string[];
3095
- noBuiltinTools?: boolean;
3096
- systemPrompt?: string;
3097
- inheritSystemPrompt?: boolean;
3098
- }>;
3099
- },
2815
+ params: DelegateTaskParams,
3100
2816
  signal: AbortSignal | undefined,
3101
2817
  onUpdate: AgentToolUpdateCallback<DelegateTaskDetails> | undefined,
3102
2818
  ctx: ExtensionContext,
3103
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;
3104
2824
  // Issue #99: warn on unknown params — typebox allows additional properties
3105
2825
  // by default (pi's validateToolArguments passes unknown keys through), so a
3106
2826
  // typo like `thinkinglevel` or a schema-drifted param is otherwise silently
@@ -3204,7 +2924,8 @@ export default function (pi: ExtensionAPI) {
3204
2924
  if (params.retryRunId) {
3205
2925
  const runEntry = state.findSpawnRunById(ctx, params.retryRunId);
3206
2926
  if (runEntry) {
3207
- params = resolveRetryParams(params, runEntry);
2927
+ retrySourceRun = runEntry;
2928
+ params = resolveRetryParams(params, runEntry) as DelegateTaskParams;
3208
2929
  } else {
3209
2930
  // Issue #98: a silent no-op here made retries of background runs
3210
2931
  // (which previously never wrote run entries) start as FRESH runs
@@ -3323,7 +3044,7 @@ export default function (pi: ExtensionAPI) {
3323
3044
  const singleTask = params.task!; // non-empty (sanitizer guarantees; isSingle confirms)
3324
3045
 
3325
3046
  if (params.background) {
3326
- return spawnBackgroundRun(pi, ctx, params, singleTask);
3047
+ return spawnBackgroundRun(pi, ctx, params, singleTask, retrySourceRun);
3327
3048
  }
3328
3049
 
3329
3050
  // R5: Check session cost limit before spawning
@@ -3470,6 +3191,10 @@ export default function (pi: ExtensionAPI) {
3470
3191
 
3471
3192
  // Create run record
3472
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);
3473
3198
  const run: SubagentRun = {
3474
3199
  id: runId,
3475
3200
  task,
@@ -3482,8 +3207,13 @@ export default function (pi: ExtensionAPI) {
3482
3207
  priority: params.priority,
3483
3208
  startedAt: new Date().toISOString(),
3484
3209
  originalParams: snapshotOriginalParams(params),
3210
+ dispatchId: identity.dispatchId,
3211
+ resumeOf: identity.resumeOf,
3212
+ attempt: identity.attempt,
3213
+ owner: currentProcessOwner(),
3214
+ childMarker: crypto.randomUUID(),
3485
3215
  };
3486
- state.persistRun(pi, run);
3216
+ state.persistRun(pi, run, "foreground");
3487
3217
 
3488
3218
  // R2: Prune old run entries if history exceeds limit
3489
3219
  pruneHistoryIfNeeded(state, ctx, log);
@@ -3492,11 +3222,11 @@ export default function (pi: ExtensionAPI) {
3492
3222
  registerLiveRun(state, run, ctx);
3493
3223
 
3494
3224
  // P3: Git integration — set up work branch if gitMode is "branch"
3495
- let originalBranch: string | undefined;
3225
+ let headState: HeadState | undefined;
3496
3226
  let workBranchName: string | undefined;
3497
3227
  if (resolvedGitMode === "branch") {
3498
3228
  try {
3499
- originalBranch = getCurrentBranch(resolvedCwd);
3229
+ headState = getHeadState(resolvedCwd);
3500
3230
 
3501
3231
  if (hasUncommittedChanges(resolvedCwd)) {
3502
3232
  log.warn("Uncommitted changes detected; proceeding with branch-based workflow anyway", {
@@ -3504,40 +3234,56 @@ export default function (pi: ExtensionAPI) {
3504
3234
  });
3505
3235
  }
3506
3236
 
3507
- const branchResult = createWorkBranch(resolvedCwd, originalBranch);
3237
+ const branchResult = createWorkBranch(resolvedCwd, headState.sha);
3508
3238
  if (branchResult.ok) {
3509
3239
  workBranchName = branchResult.branch;
3510
3240
  log.info("Created work branch for subagent", {
3511
3241
  branch: workBranchName,
3512
- base: originalBranch,
3242
+ base: headState.branch,
3513
3243
  });
3514
3244
  } else {
3515
3245
  log.error("Failed to create work branch, falling back to 'none'", {
3516
3246
  error: branchResult.error,
3517
3247
  });
3518
- originalBranch = undefined;
3248
+ headState = undefined;
3519
3249
  }
3520
3250
  } catch (err) {
3521
3251
  log.warn("Not a git repository or git error; falling back to gitMode 'none'", {
3522
3252
  error: (err as Error).message,
3523
3253
  });
3524
- originalBranch = undefined;
3254
+ headState = undefined;
3525
3255
  workBranchName = undefined;
3526
3256
  }
3527
3257
  }
3528
3258
 
3529
- // 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.
3530
3263
  const cleanupGitBranch = () => {
3531
- if (workBranchName && originalBranch) {
3532
- try {
3533
- switchToBranch(resolvedCwd, originalBranch);
3534
- log.info("Switched back to original branch", { branch: originalBranch });
3535
- // Attempt to delete the work branch (non-critical)
3536
- deleteBranch(resolvedCwd, workBranchName);
3537
- } catch {
3538
- // 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
+ });
3539
3277
  }
3540
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
+ }
3541
3287
  };
3542
3288
 
3543
3289
  // R1: Circuit breaker check — reject if circuit is open
@@ -3635,6 +3381,7 @@ export default function (pi: ExtensionAPI) {
3635
3381
  childDepth,
3636
3382
  undefined, // intercom
3637
3383
  undefined, // subagentId
3384
+ run.childMarker,
3638
3385
  );
3639
3386
 
3640
3387
  // Auto-retry on timeout
@@ -3682,6 +3429,7 @@ export default function (pi: ExtensionAPI) {
3682
3429
  childDepth,
3683
3430
  undefined, // intercom
3684
3431
  undefined, // subagentId
3432
+ run.childMarker,
3685
3433
  );
3686
3434
  }
3687
3435
 
@@ -3694,17 +3442,23 @@ export default function (pi: ExtensionAPI) {
3694
3442
  result.label = label;
3695
3443
 
3696
3444
  // P3: Capture git diff if we created a work branch
3697
- if (workBranchName && originalBranch) {
3698
- 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);
3699
3450
  if (diffResult.ok) {
3700
3451
  result.gitBranch = workBranchName;
3701
3452
  result.gitDiff = diffResult.diff;
3702
3453
  }
3703
3454
 
3704
- // Switch back to the original branch
3705
- const switchResult = switchToBranch(resolvedCwd, originalBranch);
3706
- if (switchResult.ok) {
3707
- 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
+ });
3708
3462
 
3709
3463
  // P4: Change approval workflow — let user review the diff
3710
3464
  const diffContent = (result.gitDiff ?? "").trim();
@@ -3748,13 +3502,42 @@ export default function (pi: ExtensionAPI) {
3748
3502
  error: mergeResult.error,
3749
3503
  });
3750
3504
  }
3751
- 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
+ }
3752
3521
  workBranchName = undefined;
3753
3522
  } else {
3754
3523
  // No changes — just delete the empty branch
3755
3524
  deleteBranch(resolvedCwd, workBranchName);
3756
3525
  workBranchName = undefined;
3757
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;
3758
3541
  }
3759
3542
  }
3760
3543
 
@@ -4018,8 +3801,7 @@ export default function (pi: ExtensionAPI) {
4018
3801
  }
4019
3802
 
4020
3803
  // Include transcript path
4021
- const { getTranscriptPath } = await import('./session-manager');
4022
- resultText += `\nTranscript: ${getTranscriptPath(params.agent_id)}`;
3804
+ resultText += `\nTranscript: ${transcriptDisplayPath(params.agent_id)}`;
4023
3805
 
4024
3806
  return {
4025
3807
  content: [{ type: "text" as const, text: resultText }],
@@ -4038,7 +3820,7 @@ export default function (pi: ExtensionAPI) {
4038
3820
  label: "Steer Subagent",
4039
3821
  description: [
4040
3822
  "Send a steering message to a running background agent.",
4041
- "The message interrupts after the current tool execution.",
3823
+ "The message is delivered after the agent's current tool call finishes, before its next model call.",
4042
3824
  "Use this to redirect an agent's work without restarting it.",
4043
3825
  ].join(" "),
4044
3826
  parameters: Type.Object({
@@ -4053,7 +3835,7 @@ export default function (pi: ExtensionAPI) {
4053
3835
  const { steerAgent } = await import('./session-manager');
4054
3836
 
4055
3837
  try {
4056
- const agent = steerAgent(params.agent_id, params.message);
3838
+ const agent = await steerAgent(params.agent_id, params.message);
4057
3839
 
4058
3840
  if (!agent) {
4059
3841
  return {
@@ -4138,6 +3920,50 @@ export default function (pi: ExtensionAPI) {
4138
3920
  // Session lifecycle — F7: Session-bound state initialization
4139
3921
  // -------------------------------------------------------------------
4140
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
+
4141
3967
  pi.on("session_start", async (_event, ctx) => {
4142
3968
  // Issue #147: capture the session context for the completion-push subscriber.
4143
3969
  sessionCtx = ctx;
@@ -4188,6 +4014,10 @@ export default function (pi: ExtensionAPI) {
4188
4014
  // F5/F9: Safe state restoration with type guards
4189
4015
  state.restoreFromSession(ctx);
4190
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
+
4191
4021
  updateStatus(state, ctx);
4192
4022
  });
4193
4023
 
@@ -4197,6 +4027,14 @@ export default function (pi: ExtensionAPI) {
4197
4027
  activeSubagents: state.activeSubagents,
4198
4028
  });
4199
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
+
4200
4038
  // Clear all live subagent sessions
4201
4039
  state.subagentSessions.clear();
4202
4040
  // Drop pending finalize claims too (PR #71 review: the claim set must