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.
@@ -0,0 +1,94 @@
1
+ // Purpose: The one SIGTERM → grace → SIGKILL escalation shared by foreground reap, abort/timeout, and boot recovery.
2
+ /**
3
+ * Process kill escalation (Option B U1, review items B4 + B7).
4
+ *
5
+ * The SIGTERM → grace → SIGKILL sequence was duplicated four times with
6
+ * inconsistent wait semantics. This module owns the ONE sequence; each site
7
+ * supplies its own targets and death check:
8
+ *
9
+ * - `runner.ts` `reapActiveChildren` — every tracked foreground child at once
10
+ * - `runner.ts` abort handler — one child
11
+ * - `runner.ts` timeout handler — one child
12
+ * - `recovery.ts` `reapPids` — every marker-verified orphan at once
13
+ *
14
+ * Order is terminate → wait → force-kill. With `pollMs` set the wait ends as
15
+ * soon as every target verifies dead (B4: the shutdown reap no longer blocks
16
+ * the full 5 s when children exit promptly), bounded by `graceMs`; without it
17
+ * the helper waits the full grace once (the boot scan's wait-once shape).
18
+ *
19
+ * `verifyDeath` is supplied per site but must always answer "is the process
20
+ * REALLY dead" — for a child, `exitCode`/`signalCode`; for a pid, `kill -0`
21
+ * liveness. It must never be a "a signal was delivered" check: `child.killed`
22
+ * flips when SIGTERM is SENT, so gating SIGKILL on it skips escalation for a
23
+ * SIGTERM-ignoring child (issue #303). What is shared is the
24
+ * terminate-then-escalate order and the no-throw-on-vanished discipline.
25
+ */
26
+
27
+ /** A process handle the escalation helper can signal and liveness-check. */
28
+ export interface EscalationTarget {
29
+ /** Send SIGTERM; may throw when the target is already gone. */
30
+ terminate(): void;
31
+ /** Send SIGKILL; may throw when the target is already gone. */
32
+ forceKill(): void;
33
+ /** True when the target is confirmed dead (so no SIGKILL is needed). */
34
+ verifyDeath(): boolean;
35
+ }
36
+
37
+ export interface EscalationOptions {
38
+ /** Grace window between SIGTERM and SIGKILL (ms). */
39
+ graceMs: number;
40
+ /** Sleep between SIGTERM and the liveness re-check (injectable). */
41
+ sleep(ms: number): Promise<void>;
42
+ /**
43
+ * Poll interval for the early-exit wait (B4). When > 0 the helper returns as
44
+ * soon as every target verifies dead, bounded by `graceMs`; when omitted the
45
+ * helper waits the full `graceMs` once.
46
+ */
47
+ pollMs?: number;
48
+ /** Injectable clock for the deadline (defaults to Date.now). */
49
+ now?(): number;
50
+ }
51
+
52
+ /**
53
+ * Escalate every target together: SIGTERM all, wait (at most) one grace window,
54
+ * then SIGKILL every target that still verifies alive. Returns the survivors.
55
+ * Never throws on a target that vanished mid-sequence.
56
+ */
57
+ export async function escalateKill(
58
+ targets: EscalationTarget[],
59
+ options: EscalationOptions,
60
+ ): Promise<EscalationTarget[]> {
61
+ for (const target of targets) {
62
+ try {
63
+ target.terminate();
64
+ } catch {
65
+ // Already gone — the desired end state.
66
+ }
67
+ }
68
+
69
+ if (targets.length > 0) {
70
+ const pollMs = options.pollMs ?? 0;
71
+ if (pollMs > 0) {
72
+ const now = options.now ?? Date.now;
73
+ const deadline = now() + options.graceMs;
74
+ while (now() < deadline && !targets.every((target) => target.verifyDeath())) {
75
+ const remaining = Math.max(deadline - now(), 1);
76
+ await options.sleep(Math.min(pollMs, remaining));
77
+ }
78
+ } else {
79
+ await options.sleep(options.graceMs);
80
+ }
81
+ }
82
+
83
+ const survivors: EscalationTarget[] = [];
84
+ for (const target of targets) {
85
+ if (target.verifyDeath()) continue;
86
+ try {
87
+ target.forceKill();
88
+ } catch {
89
+ // Exited between the liveness check and the signal.
90
+ }
91
+ if (!target.verifyDeath()) survivors.push(target);
92
+ }
93
+ return survivors;
94
+ }
package/src/logging.ts CHANGED
@@ -1,3 +1,4 @@
1
+ // Purpose: Leveled structured logging with file output and rotation under `.pi/subagent-logs/`.
1
2
  /**
2
3
  * brl-subagent — Structured Logging (F10)
3
4
  *
@@ -41,8 +42,49 @@ const LOG_LEVELS: Record<LogLevel, number> = {
41
42
  error: 3,
42
43
  };
43
44
 
45
+ /** The level names BRL_LOG_LEVEL accepts (the keys of LOG_LEVELS). */
46
+ const LOG_LEVEL_NAMES: readonly LogLevel[] = ["debug", "info", "warn", "error"];
47
+
44
48
  /** Current minimum log level — can be changed at runtime */
45
- let minLevel: LogLevel = "info";
49
+ let minLevel: LogLevel = resolveInitialLogLevel();
50
+
51
+ /**
52
+ * Whether the console mirror is enabled — opt-in via BRL_LOG_CONSOLE (issue #265).
53
+ * File-only is the default so extension logging never corrupts the pi TUI.
54
+ */
55
+ let consoleOutput: boolean = resolveInitialConsoleOutput();
56
+
57
+ /**
58
+ * Issue #235: the log verbosity knob was never wired, so the three `log.debug()`
59
+ * call sites were permanently suppressed with no way to turn them on.
60
+ * `BRL_LOG_LEVEL` is read ONCE at module load (logger call sites are spread
61
+ * across the codebase, and re-reading env per call would be wasteful and
62
+ * non-deterministic mid-run). `setLogLevel()` remains the programmatic override.
63
+ * Anything outside debug|info|warn|error falls back to "info".
64
+ */
65
+ function resolveInitialLogLevel(): LogLevel {
66
+ const raw = process.env.BRL_LOG_LEVEL?.trim().toLowerCase();
67
+ if (raw && (LOG_LEVEL_NAMES as readonly string[]).includes(raw)) {
68
+ return raw as LogLevel;
69
+ }
70
+ return "info";
71
+ }
72
+
73
+ /**
74
+ * Issue #265: extension code runs inside pi's TUI process, so a raw console
75
+ * write lands wherever the renderer's cursor sits and corrupts the display.
76
+ * File-only is the default; `BRL_LOG_CONSOLE` opts the terminal mirror back in.
77
+ * Read ONCE at module load for the same reason as BRL_LOG_LEVEL — logger call
78
+ * sites are spread across the codebase. Accepts `1|true|yes`; anything else is
79
+ * off. `setConsoleOutput()` remains the programmatic override.
80
+ *
81
+ * Explicitly NOT gated on process.stdout.isTTY: in pi print mode stdout IS the
82
+ * result channel, so that test is exactly backwards.
83
+ */
84
+ function resolveInitialConsoleOutput(): boolean {
85
+ const raw = process.env.BRL_LOG_CONSOLE?.trim().toLowerCase();
86
+ return raw === "1" || raw === "true" || raw === "yes";
87
+ }
46
88
 
47
89
  // ---------------------------------------------------------------------------
48
90
  // Logger
@@ -59,6 +101,15 @@ export function setLogLevel(level: LogLevel): void {
59
101
  minLevel = level;
60
102
  }
61
103
 
104
+ /**
105
+ * Programmatic override for the console mirror (issue #265). Mirrors
106
+ * setLogLevel: module-level state, so tests can toggle it without re-reading
107
+ * the environment.
108
+ */
109
+ export function setConsoleOutput(enabled: boolean): void {
110
+ consoleOutput = enabled;
111
+ }
112
+
62
113
  // Issue #179 (D6): createLogger() runs at MODULE LOAD (index.ts,
63
114
  // session-manager.ts) where the session cwd does not exist yet. The cwd is set
64
115
  // later from the session_start hook (ctx.cwd). All logger instances share this
@@ -101,9 +152,12 @@ export function createLogger(prefix: string, cwd?: string): Logger {
101
152
 
102
153
  const line = JSON.stringify(entry);
103
154
 
104
- // Console output
105
- const consoleMethod = level === "error" ? console.error : level === "warn" ? console.warn : console.log;
106
- consoleMethod(`[${prefix}] ${level.toUpperCase()}: ${message}`);
155
+ // Console mirror — opt-in (issue #265). Off by default so a logger call
156
+ // can never corrupt the pi TUI renderer; BRL_LOG_CONSOLE opts back in.
157
+ if (consoleOutput) {
158
+ const consoleMethod = level === "error" ? console.error : level === "warn" ? console.warn : console.log;
159
+ consoleMethod(`[${prefix}] ${level.toUpperCase()}: ${message}`);
160
+ }
107
161
 
108
162
  // File output — resolved on each call because the cwd may be set after
109
163
  // createLogger ran (module load) but before the first entry.
package/src/messaging.ts CHANGED
@@ -1,3 +1,4 @@
1
+ // Purpose: Inter-subagent messaging: the Intercom channel and `[TO:id]` output parsing.
1
2
  /**
2
3
  * brl-subagent — Subagent-to-Subagent Messaging (E10)
3
4
  *
package/src/metrics.ts CHANGED
@@ -1,3 +1,4 @@
1
+ // Purpose: SLA metrics over run history: p50/p95/p99 latency, success and cost rates, degradation detection.
1
2
  /**
2
3
  * brl-subagent — SLA Metrics (E4)
3
4
  *
@@ -1,3 +1,4 @@
1
+ // Purpose: Auth-aware model availability: distinguishes catalog presence from configured provider auth.
1
2
  /**
2
3
  * Auth-aware model availability check (issue #4).
3
4
  *
@@ -1,3 +1,4 @@
1
+ // Purpose: Builds and sends the completion-push wake message for terminal background runs.
1
2
  /**
2
3
  * brl-subagent — Completion-push wake (issue #147)
3
4
  *
package/src/params.ts CHANGED
@@ -1,3 +1,4 @@
1
+ // Purpose: Param resolution: per-call + preset merge, thinking caps, timeout normalization, tool and git/approval resolution, auto-routing.
1
2
  /**
2
3
  * brl-subagent — Parameter Resolution (issue #59)
3
4
  *
package/src/paths.ts CHANGED
@@ -1,3 +1,4 @@
1
+ // Purpose: Package-root path resolution (`pkgPath()`) so shipped assets resolve from the extension directory.
1
2
  /**
2
3
  * brl-subagent — Package-root path resolution
3
4
  *
package/src/preflight.ts CHANGED
@@ -1,3 +1,4 @@
1
+ // Purpose: Pre-spawn environment checks: pi binary, temp-dir writability, cwd readability.
1
2
  /**
2
3
  * brl-subagent — Pre-flight Checks (R3)
3
4
  *
@@ -21,6 +22,33 @@ export type PreflightResult = { ok: true } | { ok: false; error: string };
21
22
  // Checks
22
23
  // ---------------------------------------------------------------------------
23
24
 
25
+ /**
26
+ * Walk PATH for an executable `pi`, returning the first match.
27
+ *
28
+ * Mirrors the spawn-time fallback in `getPiInvocation` (the bare `"pi"`
29
+ * case). Shared with the Tier-2 e2e harness (issue #271) so the preflight
30
+ * check and the harness resolve the real binary identically.
31
+ */
32
+ export function resolvePiOnPath(): string | undefined {
33
+ const pathDirs = (process.env.PATH || "").split(path.delimiter);
34
+ const isWindows = process.platform === "win32";
35
+ const piNames = isWindows ? ["pi.cmd", "pi.exe", "pi"] : ["pi"];
36
+
37
+ for (const dir of pathDirs) {
38
+ for (const name of piNames) {
39
+ const fullPath = path.join(dir, name);
40
+ try {
41
+ fs.accessSync(fullPath, fs.constants.X_OK);
42
+ return fullPath;
43
+ } catch {
44
+ // Not found in this directory — continue searching
45
+ }
46
+ }
47
+ }
48
+
49
+ return undefined;
50
+ }
51
+
24
52
  /**
25
53
  * Verify the pi binary is accessible via the same resolution logic used
26
54
  * at spawn time. When `getPiInvocation` falls back to "pi" on PATH, we
@@ -44,20 +72,8 @@ function checkPiBinary(): PreflightResult {
44
72
  }
45
73
 
46
74
  // "pi" fallback — search PATH
47
- const pathDirs = (process.env.PATH || "").split(path.delimiter);
48
- const isWindows = process.platform === "win32";
49
- const piNames = isWindows ? ["pi.cmd", "pi.exe", "pi"] : ["pi"];
50
-
51
- for (const dir of pathDirs) {
52
- for (const name of piNames) {
53
- const fullPath = path.join(dir, name);
54
- try {
55
- fs.accessSync(fullPath, fs.constants.X_OK);
56
- return { ok: true };
57
- } catch {
58
- // Not found in this directory — continue searching
59
- }
60
- }
75
+ if (resolvePiOnPath()) {
76
+ return { ok: true };
61
77
  }
62
78
 
63
79
  return {
package/src/prelude.ts CHANGED
@@ -1,3 +1,4 @@
1
+ // Purpose: Shared guard/validation prelude opening every delegation mode (cost gate, approval, H1 validation, dispatch guards).
1
2
  /**
2
3
  * Shared guard/validation prelude for delegation modes (issue #201).
3
4
  *
package/src/presets.ts CHANGED
@@ -1,3 +1,4 @@
1
+ // Purpose: Preset loading, parsing, validation, and the file-backed custom-preset tier.
1
2
  /**
2
3
  * brl-subagent — Presets
3
4
  *
package/src/proc.ts ADDED
@@ -0,0 +1,140 @@
1
+ // Purpose: Production process primitives — Linux/POSIX /proc reads, feature detection, and the default recovery deps.
2
+ /**
3
+ * Option B U1 — production process wiring for the recovery engine.
4
+ *
5
+ * The recovery decision engine (`src/recovery-engine.ts`) is pure over the
6
+ * injected `RecoveryDeps`. This module supplies the REAL deps: the Linux/POSIX
7
+ * `/proc` start-time token (`/proc/<pid>/stat` field 22), the child-marker scan
8
+ * (`/proc/<pid>/environ`), cross-platform pid liveness, and the default
9
+ * `RecoveryDeps` assembly.
10
+ *
11
+ * PLATFORM BOUNDARY (required): the start token and marker scan are Linux/POSIX
12
+ * reads. `isProcAvailable()` feature-detects them (cached; the answer cannot
13
+ * change mid-process). Where they are unavailable, `pidAlive` still works but
14
+ * on its own cannot rule out pid reuse — so the boot scan skips entirely and
15
+ * nothing is killed or marked unverified (`recoverProduction`, src/recovery.ts).
16
+ *
17
+ * `__setProcAvailableForTest` is the injectable seam that lets a test exercise
18
+ * that platform guard without mocking `node:fs` module-wide.
19
+ */
20
+
21
+ import * as fs from "node:fs";
22
+ import * as path from "node:path";
23
+ import type { ProcessOwner } from "./types";
24
+ import { SIGKILL_GRACE_MS } from "./types";
25
+ import { CHILD_MARKER_ENV_KEY } from "./sanitize";
26
+ import type { RecoveryDeps } from "./recovery-engine";
27
+
28
+ const PROC_ROOT = "/proc";
29
+
30
+ let procAvailable: boolean | undefined;
31
+ let procAvailableOverride: boolean | undefined;
32
+
33
+ /**
34
+ * TEST-ONLY: force the `/proc` feature-detection answer (undefined restores the
35
+ * real detection and clears the cache). The production scan consults this
36
+ * through `isProcAvailable`, so the platform guard is exercisable in-repo.
37
+ */
38
+ export function __setProcAvailableForTest(value: boolean | undefined): void {
39
+ procAvailableOverride = value;
40
+ procAvailable = undefined;
41
+ }
42
+
43
+ /**
44
+ * Feature-detect the Linux/POSIX `/proc` surface this module needs. Cached:
45
+ * the answer cannot change mid-process. Exported so the entry point can skip
46
+ * the whole scan with one log line on platforms without /proc.
47
+ */
48
+ export function isProcAvailable(): boolean {
49
+ if (procAvailableOverride !== undefined) return procAvailableOverride;
50
+ if (procAvailable === undefined) {
51
+ try {
52
+ procAvailable = fs.existsSync(path.join(PROC_ROOT, "self", "stat"));
53
+ } catch {
54
+ procAvailable = false;
55
+ }
56
+ }
57
+ return procAvailable;
58
+ }
59
+
60
+ /**
61
+ * Read a process's start-time token — `/proc/<pid>/stat` field 22 (1-indexed),
62
+ * i.e. index 19 of the whitespace-split fields AFTER the `(comm)` field. The
63
+ * comm field is skipped by slicing at the LAST `)` so a name containing spaces
64
+ * or parens cannot shift the parse. Returns undefined when /proc is
65
+ * unavailable, the pid is invalid, or the process vanished.
66
+ */
67
+ export function readStartToken(pid: number): string | undefined {
68
+ if (!isProcAvailable() || !Number.isInteger(pid) || pid <= 0) return undefined;
69
+ try {
70
+ const stat = fs.readFileSync(path.join(PROC_ROOT, String(pid), "stat"), "utf-8");
71
+ const close = stat.lastIndexOf(")");
72
+ if (close < 0) return undefined;
73
+ const rest = stat.slice(close + 1).trim();
74
+ if (!rest) return undefined;
75
+ const fields = rest.split(/\s+/);
76
+ const token = fields[19];
77
+ return token && /^\d+$/.test(token) ? token : undefined;
78
+ } catch {
79
+ return undefined;
80
+ }
81
+ }
82
+
83
+ /** The CURRENT process's owner identity (pid + start token; empty string when unavailable). */
84
+ export function currentProcessOwner(): ProcessOwner {
85
+ return { pid: process.pid, start: readStartToken(process.pid) ?? "" };
86
+ }
87
+
88
+ /** Cross-platform pid liveness (`kill -0`). EPERM means the process exists. */
89
+ export function pidAlive(pid: number): boolean {
90
+ if (!Number.isInteger(pid) || pid <= 0) return false;
91
+ try {
92
+ process.kill(pid, 0);
93
+ return true;
94
+ } catch (err) {
95
+ return (err as NodeJS.ErrnoException).code === "EPERM";
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Pids whose environment carries the marker. Scans `/proc/<pid>/environ` (NUL
101
+ * separated). Skips this process; unreadable/vanished processes are ignored.
102
+ * Returns [] — never a kill — when /proc is unavailable.
103
+ */
104
+ export function findByMarker(marker: string): number[] {
105
+ if (!marker || !isProcAvailable()) return [];
106
+ const needle = `${CHILD_MARKER_ENV_KEY}=${marker}`;
107
+ const found: number[] = [];
108
+ let entries: string[];
109
+ try {
110
+ entries = fs.readdirSync(PROC_ROOT);
111
+ } catch {
112
+ return [];
113
+ }
114
+ for (const entry of entries) {
115
+ if (!/^\d+$/.test(entry)) continue;
116
+ const pid = Number(entry);
117
+ if (pid === process.pid) continue;
118
+ try {
119
+ const environ = fs.readFileSync(path.join(PROC_ROOT, entry, "environ"), "utf-8");
120
+ if (environ.split("\0").includes(needle)) found.push(pid);
121
+ } catch {
122
+ // Vanished or not readable by this user — not a verified match.
123
+ }
124
+ }
125
+ return found;
126
+ }
127
+
128
+ /** The real production dependencies (Linux/POSIX /proc reads + process signals). */
129
+ export function defaultRecoveryDeps(): RecoveryDeps {
130
+ return {
131
+ pidAlive,
132
+ startTokenOf: readStartToken,
133
+ findByMarker,
134
+ kill: (pid, signal) => {
135
+ process.kill(pid, signal);
136
+ },
137
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
138
+ graceMs: SIGKILL_GRACE_MS,
139
+ };
140
+ }
package/src/prompt.ts CHANGED
@@ -1,3 +1,4 @@
1
+ // Purpose: Builds the subagent system prompt, including the task fence and inherited-instruction handling.
1
2
  /**
2
3
  * brl-subagent — Prompt Builder
3
4
  *