brl-subagent 2.4.0 → 2.5.1

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/AGENT.md CHANGED
@@ -64,12 +64,19 @@ delegation-heavy work.
64
64
  - Graph with `dependsOn`: subtasks declare edges; the scheduler runs them in waves.
65
65
  - Background + act on wake: spawn a background run, then act when the completion message arrives. Never poll between spawn and wake.
66
66
  - Background fan-out: `tasks: [...], background: true` — one background agent per task, one id per task in task order, one wake per agent.
67
- - retryRunId: pass a failed run's id to re-run it with its recorded task and params; explicit parameters override the originals.
67
+ - retryRunId: pass a failed or **interrupted** run's id to re-run it with its recorded task and params; explicit parameters override the originals. A run interrupted before durable task capture (a pre-enrichment registry entry) has no recorded task — pass `task` explicitly for those.
68
+
69
+ ## Interrupted runs and recovery
70
+
71
+ - An interrupted run (its conductor died mid-run) stays visible with status **Interrupted** in `/brl-subagent history` and is listed by `/brl-subagent retry` alongside failed runs — including after a crash + fresh boot, where it is reconstructed from the durable per-run registry (plus the background agent record). Recover it with `retryRunId: <run-id>` (recorded params restored), or abandon it from that menu — abandon finalizes the run as stopped, drops its persisted session file, and sends **no** completion wake.
72
+ - **Task persistence (O3):** a foreground/unit run persists its task, label, model, thinking level and full `originalParams` snapshot in the durable registry at dispatch, so after a crash + fresh boot its row shows the real task and `retryRunId` restores the recorded shape — the same 17 fields a same-session retry restores (including `force`; explicit caller values still win). A successful retry retires the source entry so the superseded run is not re-offered at the next boot; a failed retry start leaves it intact and retryable.
73
+ - **Legacy rows:** an entry written before task persistence lacks those fields, so its row carries `(task unavailable)` and `retryRunId` cannot restore a task — pass `task` explicitly on the retry call. A background run keeps its task via its agent record.
74
+ - The **recovery posture** (`/brl-subagent recovery`, default `offer`) controls whether a boot surfaces interrupted runs with one turn-boundary notice; `off` silences it. Nothing is auto-resumed today.
68
75
 
69
76
  ## Deadlines and retries
70
77
 
71
78
  - `timeout` (ms) is a **deadline, not a ceiling** — an explicit value is honored as given; 30 minutes is only the default for a background run that passes none. Background expiry aborts the session (the run ends `stopped`); foreground expiry kills the process.
72
- - `retryOnTimeout` applies to **foreground runs only**. A timed-out background run is not auto-retried: re-dispatch it with `retryRunId` if that is the right call.
79
+ - `retryOnTimeout` applies to **foreground runs only**. A timed-out background run is not auto-retried: re-dispatch it with `retryRunId` if that is the right call. The same applies to an interrupted run — recovery is always an explicit `retryRunId` call, never automatic (auto mode is not yet accepted).
73
80
 
74
81
  ## Mechanics pointer
75
82
 
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.1 · **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 1500+ test suite pinning every contract against the real pi SDK.
18
18
 
19
19
  ---
20
20
 
@@ -50,6 +50,7 @@ pi remove npm:brl-subagent # uninstall
50
50
  | `/brl-subagent depth` | Set max recursion depth |
51
51
  | `/brl-subagent approval` | Set change approval mode |
52
52
  | `/brl-subagent completionnotify` | Set completion-push wake (all/failed/off) |
53
+ | `/brl-subagent recovery` | Set recovery posture for interrupted runs (offer/off) |
53
54
  | `/brl-subagent costlimit` | Set session cost limit |
54
55
  | `/brl-subagent historyentries` | Set max history entries |
55
56
  | `/brl-subagent sla` | Configure SLA tracking |
@@ -104,13 +105,15 @@ All settings persist across sessions.
104
105
  | `gitMode` | string | — | `'branch'` creates a work branch, captures the diff, and switches back; `'none'` does nothing. Falls back to the configured default. |
105
106
  | `approvalMode` | string | — | `auto` / `writes` / `always`. Default is user config (`/brl-subagent approval`). `always` is rejected for background runs. |
106
107
  | `force` | boolean | `false` | Dispatch anyway past a capability pre-flight block; the block is downgraded to a warning. |
107
- | `retryRunId` | string | — | Re-run a previously failed run with its recorded task and params. |
108
+ | `retryRunId` | string | — | Re-run a previously failed or interrupted run with its recorded task and params. |
108
109
  | `retryOnTimeout` | boolean | `false` | Retry once if a **foreground** subagent times out. The second timeout is a final failure. Background runs are not auto-retried — re-dispatch with `retryRunId`. |
109
110
 
110
111
  **Capability pre-flight:** before spawning, the extension checks that the resolved toolset can actually do the task — 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`. Such a dispatch is **rejected** by default; `force: true` downgrades the rejection to a **warning**. `force` never suppresses an `outputFile`-without-write conflict — that stays a hard error. Warnings are surfaced in the returned result in every mode.
111
112
 
112
113
  **Retries (`retryRunId`):** a retry rebuilds the parameter object from a fixed set of fields, falling back to the original run's recorded values, with explicit values on the retry call winning: `task`, `label`, `model`, `preset`, `systemPrompt`, `inheritSystemPrompt`, `thinkingLevel`, `priority`, `outputFile`, `timeout`, `cwd`, `tools`, `excludeTools`, `noBuiltinTools`, plus the four **execution-shape** fields `background`, `gitMode`, `approvalMode` and `force`.
113
114
 
115
+ **Interrupted runs:** a run whose conductor died mid-run is marked interrupted (it keeps its persisted session file) and is surfaced as **Interrupted** in `/brl-subagent history` and in `/brl-subagent retry` — including after a crash + fresh boot, when it is reconstructed from the durable per-run registry (plus the background agent record). Recover one with `retryRunId: <run-id>`, or abandon it from the retry menu — abandon finalizes it as stopped, prunes its session file, and sends no completion wake. A **foreground** run's task is not persisted, so its crashed row shows no task and a retry needs the task supplied explicitly; a background run keeps its task in its agent record. The recovery posture (`/brl-subagent recovery`, default `offer`) controls whether a boot announces interrupted runs with one turn-boundary notice; `off` silences it. Recovery is never automatic — there is no `auto` mode.
116
+
114
117
  **The execution-shape fields are restored, so a retry keeps the original's shape:** retrying a background run re-runs it in the background (with its completion wake), a `gitMode: 'branch'` retry keeps its work branch, an `approvalMode`-constrained retry keeps that gating, and a retry that was capability-blocked keeps its `force`. Passing any of them explicitly on the retry call overrides the recorded value. One qualification on `gitMode`: when neither the record nor the call carries one, the **configured** default still applies — so a user with a global `gitMode: 'branch'` gets a work branch on every retry, and one with the default `'none'` gets none. Do not assume "no work branch" without checking the effective `gitMode`.
115
118
 
116
119
  **Fan-out origins are the exception.** Parallel/chain/graph units are recorded per unit, and those records deliberately snapshot **no** `background`, so retrying a unit is a **single run in the foreground**. Pass `background: true` explicitly if you want a background retry.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brl-subagent",
3
- "version": "2.4.0",
3
+ "version": "2.5.1",
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,12 +37,12 @@
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.2.0",
41
+ "@earendil-works/pi-ai": "^1.2.0",
42
+ "@earendil-works/pi-coding-agent": "^1.2.0",
43
+ "@earendil-works/pi-tui": "^1.2.0",
44
44
  "jiti": "^2.7.0",
45
- "typebox": "^1.3.34",
45
+ "typebox": "1.3.27",
46
46
  "typescript": "7.0.2",
47
47
  "vitest": "^5.0.0"
48
48
  },
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,30 +7,42 @@
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
  // ---------------------------------------------------------------------------
14
16
  // Run pruning — R2: Disk usage policy
15
17
  // ---------------------------------------------------------------------------
16
18
 
19
+ /**
20
+ * Order run records NEWEST-FIRST by `startedAt`, without mutating the input.
21
+ *
22
+ * The ONE ordering authority for run records (issue #330): sorts by
23
+ * `startedAt` descending using `Date.parse` comparisons and returns a new
24
+ * array. `Array.prototype.sort` is stable, so records sharing a `startedAt`
25
+ * keep their relative order (spawn-before-terminal for a finalized run).
26
+ */
27
+ export function sortNewestFirst(runs: SubagentRun[]): SubagentRun[] {
28
+ return [...runs].sort(
29
+ (a, b) => Date.parse(b.startedAt) - Date.parse(a.startedAt),
30
+ );
31
+ }
32
+
17
33
  /**
18
34
  * Prune a SubagentRun array to keep only the newest entries up to maxEntries.
19
- * Uses startedAt timestamp for ordering (newest first). If maxEntries is 0,
20
- * returns all entries (no limit). Returns a new array; does not mutate input.
35
+ * The result is always ordered NEWEST-FIRST by `startedAt` — including the
36
+ * `maxEntries === 0` unlimited path, which keeps every entry but still sorts.
37
+ * Returns a new array; does not mutate the input.
21
38
  */
22
39
  export function cleanupRuns(
23
40
  runs: SubagentRun[],
24
41
  maxEntries: number = MAX_RUN_HISTORY_ENTRIES,
25
42
  ): SubagentRun[] {
26
43
  if (runs.length === 0) return [];
27
- if (maxEntries === 0) return runs;
28
-
29
- // Always sort newest-first for consistency, even when not truncating
30
- const sorted = [...runs].sort(
31
- (a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(),
32
- );
33
-
44
+ const sorted = sortNewestFirst(runs);
45
+ if (maxEntries === 0) return sorted;
34
46
  if (sorted.length <= maxEntries) return sorted;
35
47
  return sorted.slice(0, maxEntries);
36
48
  }
@@ -200,3 +212,58 @@ export function formatRunDuration(ms: number): string {
200
212
  const secs = Math.round((ms % 60_000) / 1000);
201
213
  return `${min}m ${secs}s`;
202
214
  }
215
+
216
+ // ---------------------------------------------------------------------------
217
+ // Full-output honesty lines (issue #261)
218
+ // ---------------------------------------------------------------------------
219
+
220
+ /**
221
+ * Human-readable size for an output cap, e.g. `100 * 1024` → "100 KB".
222
+ * Byte caps are whole-KB by construction; sub-KB values (mostly tests) render
223
+ * as plain bytes.
224
+ */
225
+ function formatCapSize(capBytes: number): string {
226
+ if (capBytes > 0 && capBytes % 1024 === 0) return `${capBytes / 1024} KB`;
227
+ return `${capBytes} B`;
228
+ }
229
+
230
+ /**
231
+ * Honest disclosure lines for a settled run's full-output view (issue #261).
232
+ *
233
+ * Always returns the transcript pointer. When the stored output hit the
234
+ * `capBytes` cap it also adds a disclosure that points at the transcript for
235
+ * the rest. Detection is deliberately two-signal:
236
+ *
237
+ * 1. `isOutputTruncated(run.fullOutput)` — the exact `capOutput` notice, true
238
+ * only when the stored text was genuinely truncated;
239
+ * 2. `Buffer.byteLength` at/over `capBytes` — the length fallback, so an
240
+ * UNCAPPED record (background/crash paths store raw `liveOutput`) that
241
+ * already sits at or over the cap budget is still disclosed instead of
242
+ * pretending the panel can show more than the cap's worth.
243
+ *
244
+ * Boundary precision: `capOutput` truncates only when the byte length is
245
+ * STRICTLY greater than the cap, so an output of exactly `capBytes` takes the
246
+ * length branch and is disclosed as "at or over" without carrying the notice.
247
+ * The notice branch alone is exact for "was truncated".
248
+ *
249
+ * Pure and pi-tui-free, so it is unit-testable in isolation.
250
+ */
251
+ export function buildOutputHonestyLines(
252
+ run: Pick<SubagentRun, "id" | "fullOutput">,
253
+ capBytes: number = DEFAULT_OUTPUT_CAP_BYTES,
254
+ ): string[] {
255
+ const transcriptPath = transcriptDisplayPath(run.id);
256
+ const lines = [`Transcript: ${transcriptPath}`];
257
+ const output = run.fullOutput;
258
+ const size = formatCapSize(capBytes);
259
+ if (isOutputTruncated(output)) {
260
+ lines.push(
261
+ `Output was truncated at the ${size} cap — full text in the transcript: ${transcriptPath}`,
262
+ );
263
+ } else if (output && Buffer.byteLength(output, "utf8") >= capBytes) {
264
+ lines.push(
265
+ `Stored output is at or over the ${size} cap — full transcript: ${transcriptPath}`,
266
+ );
267
+ }
268
+ return lines;
269
+ }