brl-subagent 2.5.0 → 2.5.2

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/tui.ts CHANGED
@@ -32,11 +32,13 @@ import type {
32
32
  UsageStats,
33
33
  FileDiff,
34
34
  CompletionNotifyMode,
35
+ RecoveryMode,
35
36
  } from "./types";
36
37
  import {
37
38
  THINKING_LEVELS,
38
39
  NAV_FOOTER,
39
40
  COMPLETION_NOTIFY_MODES,
41
+ RECOVERY_MODES,
40
42
  TASK_PREVIEW_MAX_LENGTH,
41
43
  COLLAPSED_OUTPUT_LINES,
42
44
  COLLAPSED_DIFF_FILES_PREVIEW,
@@ -51,6 +53,7 @@ import {
51
53
  isMultiSubagentDetails,
52
54
  isGraphDetails,
53
55
  isUnsettledPartial,
56
+ isInterruptedRun,
54
57
  } from "./types";
55
58
  import { buildFileAccessReport, buildSecretsExposureReport, generateComplianceSummary } from "./reports";
56
59
  import { extractParamNames } from "./templates";
@@ -59,10 +62,11 @@ import { formatPresetSummary, getPreset, loadCustomPresets } from "./presets";
59
62
  import { buildOutputHonestyLines, formatRunDuration } from "./history";
60
63
  import * as fs from "node:fs";
61
64
  import * as path from "node:path";
62
- import { collapseRunsForHistory, sweepStaleLiveSubagents, type SessionState } from "./state";
65
+ import { collapseRunsForHistory, retryableRuns, sweepStaleLiveSubagents, type SessionState } from "./state";
63
66
  import { getAgent } from "./session-manager";
67
+ import { visibleRunEntriesForSession } from "./recovery-surface";
64
68
  import { computeSLAMetrics, computeCostTrend, formatSparkline } from "./metrics";
65
- import { formatElapsed, liveRowName, liveSpinner, formatLiveRowDim } from "./tui-format";
69
+ import { formatElapsed, liveRowName, liveSpinner, formatLiveRowDim, runHistoryStatus, runHistoryStatusLabel, buildRetryOfferMessage, runDisplayName } from "./tui-format";
66
70
  import {
67
71
  renderTranscriptMessages,
68
72
  type TranscriptLineStyle,
@@ -336,6 +340,32 @@ export async function showCompletionNotifySelector(
336
340
  onConfigChanged(ctx, `Completion notify mode set to ${result}`);
337
341
  }
338
342
 
343
+ /**
344
+ * Option B U3 (D4): recovery posture selector. Modeled on the completionNotify
345
+ * selector; `RECOVERY_MODES` excludes "auto" by construction.
346
+ */
347
+ export async function showRecoverySelector(
348
+ ctx: ExtensionContext,
349
+ state: SessionState,
350
+ onConfigChanged: (ctx: ExtensionContext, msg: string) => void,
351
+ ): Promise<void> {
352
+ const modeDescriptions: Record<RecoveryMode, string> = {
353
+ offer: "Surface interrupted runs (Run History row + one boot notice) - default",
354
+ off: "Do not surface interrupted runs",
355
+ };
356
+ const items: SelectItem[] = RECOVERY_MODES.map((mode) => ({
357
+ value: mode,
358
+ label: mode,
359
+ description: modeDescriptions[mode],
360
+ }));
361
+
362
+ const result = await showSelectList(ctx, "Recovery Posture", items, 5);
363
+ if (!result) return;
364
+
365
+ state.config.recovery = result as RecoveryMode;
366
+ onConfigChanged(ctx, `Recovery posture set to ${result}`);
367
+ }
368
+
339
369
  // ---------------------------------------------------------------------------
340
370
  // E4: SLA tracking configuration
341
371
  // ---------------------------------------------------------------------------
@@ -938,6 +968,13 @@ export function getConfigMenuItems(state: SessionState): SelectItem[] {
938
968
  ? "Wake only on failed/stopped"
939
969
  : "Never wake (passive delivery)",
940
970
  },
971
+ {
972
+ value: "recovery",
973
+ label: "Set Recovery Posture",
974
+ description: state.config.recovery === "offer"
975
+ ? "Surface interrupted runs (default)"
976
+ : "Do not surface interrupted runs",
977
+ },
941
978
 
942
979
  {
943
980
  value: "historyentries",
@@ -1085,6 +1122,17 @@ export async function showConfigMenu(
1085
1122
  // Run history viewer
1086
1123
  // ---------------------------------------------------------------------------
1087
1124
 
1125
+ /**
1126
+ * U3 Fix A: the recovery visibility bridge wired for a TUI surface. Session
1127
+ * run entries UNIONED with synthetic durable interrupted records the session
1128
+ * has never seen (a genuine fresh boot), deduped by id. Both the history list
1129
+ * and the retry menu read THIS, so they can never disagree about an interrupted
1130
+ * run that exists only in the durable registry.
1131
+ */
1132
+ function recoverableRunEntries(ctx: ExtensionContext, state: SessionState): SubagentRun[] {
1133
+ return visibleRunEntriesForSession(state.getRunEntries(ctx), getAgent);
1134
+ }
1135
+
1088
1136
  export async function showRunHistory(
1089
1137
  ctx: ExtensionContext,
1090
1138
  state: SessionState,
@@ -1094,6 +1142,7 @@ export async function showRunHistory(
1094
1142
  running: ctx.ui.theme.fg("accent", "◉"),
1095
1143
  done: ctx.ui.theme.fg("success", "✓"),
1096
1144
  failed: ctx.ui.theme.fg("error", "✗"),
1145
+ interrupted: ctx.ui.theme.fg("warning", "◌"),
1097
1146
  };
1098
1147
 
1099
1148
  // Browse loop (issue #260): list → detail → list … until the LIST is exited
@@ -1102,7 +1151,7 @@ export async function showRunHistory(
1102
1151
  // One row per SETTLED run (issue #259): collapse each run's spawn+terminal
1103
1152
  // entry pair to its terminal-preferred record; in-flight runs are omitted
1104
1153
  // (the live monitor owns running state).
1105
- const runs = collapseRunsForHistory(state.getRunEntries(ctx));
1154
+ const runs = collapseRunsForHistory(recoverableRunEntries(ctx, state));
1106
1155
 
1107
1156
  if (runs.length === 0) {
1108
1157
  ctx.ui.notify("No subagent runs recorded yet. Delegate a task to see history.", "info");
@@ -1110,10 +1159,15 @@ export async function showRunHistory(
1110
1159
  }
1111
1160
 
1112
1161
  const items: SelectItem[] = runs.map((r) => {
1113
- const icon = statusIcon[r.status] || "·";
1114
- const name = r.label || r.task.slice(0, 60);
1162
+ const status = runHistoryStatus(r);
1163
+ const icon = statusIcon[status] || "·";
1164
+ const name = runDisplayName(r);
1115
1165
  const model = r.model.split("/").pop() || r.model;
1116
- const when = r.finishedAt ? formatRunDuration(r.durationMs || 0) : "running...";
1166
+ const when = r.finishedAt
1167
+ ? formatRunDuration(r.durationMs || 0)
1168
+ : status === "interrupted"
1169
+ ? "interrupted"
1170
+ : "running...";
1117
1171
  const cost = r.cost ? ` $${r.cost.toFixed(4)}` : "";
1118
1172
  const desc = `${r.thinkingLevel} · ${model} · ${when}${cost}`;
1119
1173
  return { value: r.id, label: `${icon} ${name}`, description: desc };
@@ -1218,8 +1272,7 @@ async function showRunDetail(ctx: ExtensionContext, run: SubagentRun): Promise<v
1218
1272
  const container = new Container();
1219
1273
  container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1220
1274
 
1221
- const statusLabel =
1222
- run.status === "done" ? "Completed" : run.status === "failed" ? "Failed" : "Running";
1275
+ const statusLabel = runHistoryStatusLabel(run);
1223
1276
  container.addChild(
1224
1277
  new Text(
1225
1278
  theme.fg(
@@ -1993,32 +2046,59 @@ export async function showDashboard(
1993
2046
  // Retry menu
1994
2047
  // ---------------------------------------------------------------------------
1995
2048
 
1996
- export async function showRetryMenu(ctx: ExtensionContext, state: SessionState): Promise<void> {
1997
- const failedRuns = state.getRunEntries(ctx).filter((r) => r.status === "failed").reverse();
2049
+ export async function showRetryMenu(
2050
+ ctx: ExtensionContext,
2051
+ state: SessionState,
2052
+ // U3 (D5): the abandon action is injected by the extension entry point (it
2053
+ // owns the session-manager/registry wiring). Omitted in read-only contexts.
2054
+ onAbandon?: (id: string) => void | Promise<void>,
2055
+ ): Promise<void> {
2056
+ // U3 (D3): failed AND unresolved interrupted runs share this surface, one row
2057
+ // per run, built on the same terminal-preferred collapse the history uses.
2058
+ const candidates = retryableRuns(recoverableRunEntries(ctx, state));
1998
2059
 
1999
- if (failedRuns.length === 0) {
2000
- ctx.ui.notify("No failed subagent runs to retry.", "info");
2060
+ if (candidates.length === 0) {
2061
+ ctx.ui.notify("No failed or interrupted subagent runs to retry.", "info");
2001
2062
  return;
2002
2063
  }
2003
2064
 
2004
- const items: SelectItem[] = failedRuns.map((r) => {
2005
- const name = r.label || r.task.slice(0, 60);
2065
+ const items: SelectItem[] = candidates.map((r) => {
2066
+ const interrupted = isInterruptedRun(r);
2067
+ const name = runDisplayName(r);
2006
2068
  const model = r.model.split("/").pop() || r.model;
2007
- const err = r.errorMessage ? r.errorMessage.slice(0, 40) : "error";
2069
+ const err = r.errorMessage ? r.errorMessage.slice(0, 40) : interrupted ? "interrupted" : "error";
2008
2070
  return {
2009
2071
  value: r.id,
2010
- label: `✗ ${name}`,
2011
- description: `${model} · ${err}`,
2072
+ label: `${interrupted ? "◌" : "✗"} ${name}`,
2073
+ description: `${interrupted ? "interrupted · " : ""}${model} · ${err}`,
2012
2074
  };
2013
2075
  });
2014
2076
 
2015
- const selectedId = await showSelectList(ctx, "Retry Failed Run", items, 10);
2077
+ const selectedId = await showSelectList(ctx, "Retry Failed or Interrupted Run", items, 10);
2016
2078
  if (!selectedId) return;
2017
2079
 
2018
- ctx.ui.notify(
2019
- `Ask the LLM to retry: "Use delegate_task to retry failed run ${selectedId.slice(0, 8)}..."`,
2020
- "info",
2021
- );
2080
+ const run = candidates.find((r) => r.id === selectedId);
2081
+ if (!run) return;
2082
+
2083
+ if (isInterruptedRun(run) && onAbandon) {
2084
+ const action = await showSelectList(
2085
+ ctx,
2086
+ "Interrupted Run",
2087
+ [
2088
+ { value: "retry", label: "Retry", description: "Re-dispatch with its recorded params (retryRunId)" },
2089
+ { value: "abandon", label: "Abandon", description: "Finalize as stopped and prune its session file" },
2090
+ ],
2091
+ 5,
2092
+ );
2093
+ if (!action) return;
2094
+ if (action === "abandon") {
2095
+ await onAbandon(run.id);
2096
+ ctx.ui.notify(`Abandoned interrupted run ${run.id.slice(0, 8)}.`, "info");
2097
+ return;
2098
+ }
2099
+ }
2100
+
2101
+ ctx.ui.notify(buildRetryOfferMessage(run), "info");
2022
2102
  }
2023
2103
 
2024
2104
  // ---------------------------------------------------------------------------
package/src/types.ts CHANGED
@@ -22,6 +22,15 @@ export type CompletionNotifyMode = (typeof COMPLETION_NOTIFY_MODES)[number];
22
22
 
23
23
  export const DEFAULT_COMPLETION_NOTIFY: CompletionNotifyMode = "all";
24
24
 
25
+ // Option B U3 (D4): boot recovery posture. "offer" surfaces interrupted runs
26
+ // (Run History row + one boot notice); "off" silences the notice and marks
27
+ // newly dispatched runs so they are never offered. "auto" is deliberately NOT
28
+ // a member (U4 territory), enforced by RECOVERY_MODES being the shape guard.
29
+ export const RECOVERY_MODES = ["offer", "off"] as const;
30
+ export type RecoveryMode = (typeof RECOVERY_MODES)[number];
31
+
32
+ export const DEFAULT_RECOVERY: RecoveryMode = "offer";
33
+
25
34
  export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
26
35
 
27
36
  export const THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
@@ -238,6 +247,9 @@ export interface SubagentState {
238
247
  // state, "failed" wakes only on failed/stopped, "off" never wakes (still
239
248
  // delivers passively at the next user prompt).
240
249
  completionNotify: CompletionNotifyMode;
250
+ // Option B U3 (D4): recovery posture for the session. Applies to newly
251
+ // dispatched runs (snapshotted per run) and to the boot notice.
252
+ recovery: RecoveryMode;
241
253
  maxHistoryEntries: number; // 0 = unlimited
242
254
  sessionCostLimit: number; // 0 = unlimited
243
255
  perTaskCostEstimate: number; // 0 = no estimate, use default
@@ -327,6 +339,9 @@ export interface SubagentRun {
327
339
  interruptedAt?: string;
328
340
  owner?: ProcessOwner;
329
341
  childMarker?: string;
342
+ // Option B U3 (D4): the recovery posture in force when this run was
343
+ // dispatched. Additive: a pre-U3 record lacks it and reads as "offer".
344
+ recoveryPosture?: RecoveryMode;
330
345
  startedAt: string;
331
346
  finishedAt?: string;
332
347
  durationMs?: number;
@@ -707,7 +722,7 @@ export const RESERVED_NAME_PATTERN = /^__.*__$/;
707
722
  export const RESERVED_COMMAND_NAMES = new Set([
708
723
  "model", "thinking", "concurrency", "depth", "history", "monitor",
709
724
  "preset", "retry", "reset", "priority", "templates", "dashboard", "approval",
710
- "costlimit", "historyentries", "sla", "completionnotify",
725
+ "costlimit", "historyentries", "sla", "completionnotify", "recovery",
711
726
  "graph", "sla-stats", "compliance",
712
727
  ]);
713
728
 
@@ -735,6 +750,13 @@ export interface BackgroundAgent {
735
750
  finalOutput?: string;
736
751
  /** @internal — session reference for live monitor polling */
737
752
  _sessionRef?: import('@earendil-works/pi-coding-agent').AgentSession;
753
+ /**
754
+ * @internal — Option B U2: the on-disk session file for this run, captured
755
+ * from `SessionManager.getSessionFile()` at spawn. Used by the settle path to
756
+ * prune the EXACT per-run directory. Live-only: stripped by `persistAgent`,
757
+ * never a persisted record field (D4).
758
+ */
759
+ _sessionFile?: string;
738
760
  // Option B U1 (D3/D4): additive durability identity, mirroring SubagentRun.
739
761
  // Background records carry dispatchId/owner/attempt. `childMarker` is
740
762
  // declared for round-trip tolerance with the run-record/agent-record JSON
@@ -746,6 +768,9 @@ export interface BackgroundAgent {
746
768
  interruptedAt?: string;
747
769
  owner?: ProcessOwner;
748
770
  childMarker?: string;
771
+ // Option B U3 (D4): the recovery posture at dispatch, mirrored from the
772
+ // run record. Additive (absent reads as "offer").
773
+ recoveryPosture?: RecoveryMode;
749
774
  }
750
775
 
751
776
  // ---------------------------------------------------------------------------
@@ -1010,6 +1035,11 @@ export function isSubagentStateShape(value: unknown): value is SubagentState {
1010
1035
  if (!COMPLETION_NOTIFY_MODES.includes(v.completionNotify as CompletionNotifyMode)) return false;
1011
1036
  }
1012
1037
 
1038
+ // recovery must be one of RECOVERY_MODES if present ("auto" is rejected)
1039
+ if (v.recovery !== undefined) {
1040
+ if (!RECOVERY_MODES.includes(v.recovery as RecoveryMode)) return false;
1041
+ }
1042
+
1013
1043
  // maxHistoryEntries must be a non-negative number if present
1014
1044
  if (v.maxHistoryEntries !== undefined) {
1015
1045
  if (typeof v.maxHistoryEntries !== "number" || v.maxHistoryEntries < 0) return false;
@@ -1045,9 +1075,25 @@ export function isSubagentRunShape(value: unknown): value is SubagentRun {
1045
1075
  if (typeof v.id !== "string") return false;
1046
1076
  if (typeof v.task !== "string") return false;
1047
1077
  if (!["running", "done", "failed"].includes(v.status as string)) return false;
1078
+ // U3 (D4): a malformed snapshot is rejected like any other bad field; an
1079
+ // absent one is fine (pre-U3 records) and reads as "offer" via the helper.
1080
+ if (v.recoveryPosture !== undefined) {
1081
+ if (!RECOVERY_MODES.includes(v.recoveryPosture as RecoveryMode)) return false;
1082
+ }
1048
1083
  return true;
1049
1084
  }
1050
1085
 
1086
+ /**
1087
+ * Option B U3 (D4): the recovery posture recorded for a run. Absent (pre-U3
1088
+ * records) or an unrecognized value reads as the default "offer" — the
1089
+ * additive-field contract (no migration).
1090
+ */
1091
+ export function runRecoveryPosture(
1092
+ run: Pick<SubagentRun, "recoveryPosture">,
1093
+ ): RecoveryMode {
1094
+ return run.recoveryPosture === "off" ? "off" : DEFAULT_RECOVERY;
1095
+ }
1096
+
1051
1097
  /**
1052
1098
  * Option B U1 (D3): the ONE interrupted-state predicate. Status deliberately
1053
1099
  * stays "running" (a new status value would be dropped by old readers and
@@ -1059,6 +1105,25 @@ export function isInterruptedRun(run: SubagentRun): boolean {
1059
1105
  return run.status === "running" && !!run.interruptedAt;
1060
1106
  }
1061
1107
 
1108
+ /**
1109
+ * Fix B (item 2): TRUE when a terminal run record represents an ABORT rather
1110
+ * than a genuine failure. Abandon writes the store's ONLY stopped-outcome
1111
+ * shape — `failed` + `stopReason`/`errorCategory` "aborted" (D5 mapping) — and
1112
+ * a user `stop_subagent` writes the same. Failure tallies (SLA) must exclude
1113
+ * these. The run record carries no dedicated "abandoned" marker and D5 forbids
1114
+ * adding one, so a user-ABANDONED run and a user-STOPPED run are
1115
+ * indistinguishable here; both are treated as non-failures.
1116
+ */
1117
+ export function isAbortedRun(
1118
+ run: Pick<SubagentRun, "stopReason" | "errorCategory" | "originalParams">,
1119
+ ): boolean {
1120
+ return (
1121
+ run.stopReason === "aborted" ||
1122
+ run.errorCategory === "aborted" ||
1123
+ run.originalParams?.errorCategory === "aborted"
1124
+ );
1125
+ }
1126
+
1062
1127
  /**
1063
1128
  * Type guard: checks if a value is a valid MultiSubagentDetails shape.
1064
1129
  */
package/src/unit-run.ts CHANGED
@@ -27,8 +27,9 @@ import type {
27
27
  SubagentRun,
28
28
  SubagentToolOptions,
29
29
  Priority,
30
+ RecoveryMode,
30
31
  } from "./types";
31
- import { getFinalOutput, classifyError } from "./types";
32
+ import { getFinalOutput, classifyError, DEFAULT_RECOVERY } from "./types";
32
33
  import { snapshotOriginalParams } from "./params";
33
34
  import { currentProcessOwner } from "./recovery";
34
35
  import { capOutput, stripAnsi, buildCrashResult } from "./sanitize";
@@ -106,6 +107,9 @@ export function createUnitRun(
106
107
  stepModel: { provider: string; id: string },
107
108
  priorityFloor: Priority,
108
109
  presetName?: string,
110
+ // U3 (D4): the session recovery posture at dispatch. Optional so existing
111
+ // callers/tests stay valid; production passes `state.config.recovery`.
112
+ recoveryPosture: RecoveryMode = DEFAULT_RECOVERY,
109
113
  ): { runId: string; run: SubagentRun } {
110
114
  const runId = crypto.randomUUID();
111
115
  const run: SubagentRun = {
@@ -131,6 +135,9 @@ export function createUnitRun(
131
135
  attempt: 1,
132
136
  owner: currentProcessOwner(),
133
137
  childMarker: crypto.randomUUID(),
138
+ // U3 (D4): snapshot the posture so the boot notice/offer reads what was
139
+ // in force at dispatch.
140
+ recoveryPosture,
134
141
  // The execution-shape fields (background/gitMode/approvalMode/force) are
135
142
  // deliberately omitted: a fan-out unit retries as a SINGLE run, so it must
136
143
  // not inherit the fan-out's background-ness. A retry that wants background