brl-subagent 2.3.4

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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +257 -0
  3. package/package.json +49 -0
  4. package/presets/code-reviewer.md +37 -0
  5. package/presets/data-analyst.md +33 -0
  6. package/presets/debugger.md +36 -0
  7. package/presets/dev-agent.md +30 -0
  8. package/presets/rapid-prototyper.md +25 -0
  9. package/presets/refactorer.md +34 -0
  10. package/presets/security-auditor.md +39 -0
  11. package/presets/tech-writer.md +33 -0
  12. package/presets/test-engineer.md +34 -0
  13. package/src/concurrency.ts +186 -0
  14. package/src/diff.ts +102 -0
  15. package/src/event-bus.ts +86 -0
  16. package/src/git.ts +187 -0
  17. package/src/history.ts +177 -0
  18. package/src/index.ts +3824 -0
  19. package/src/logging.ts +135 -0
  20. package/src/messaging.ts +164 -0
  21. package/src/metrics.ts +221 -0
  22. package/src/model-availability.ts +73 -0
  23. package/src/notify-completion.ts +207 -0
  24. package/src/params.ts +225 -0
  25. package/src/preflight.ts +150 -0
  26. package/src/presets.ts +425 -0
  27. package/src/prompt.ts +169 -0
  28. package/src/reports.ts +325 -0
  29. package/src/router.ts +133 -0
  30. package/src/runner.ts +877 -0
  31. package/src/sanitize.ts +365 -0
  32. package/src/scheduler.ts +187 -0
  33. package/src/session-manager.ts +1065 -0
  34. package/src/state.ts +590 -0
  35. package/src/templates.ts +469 -0
  36. package/src/transcript-tail.ts +327 -0
  37. package/src/transcript.ts +119 -0
  38. package/src/tui-format.ts +51 -0
  39. package/src/tui.ts +3103 -0
  40. package/src/types.ts +803 -0
  41. package/src/unit-run.ts +269 -0
  42. package/src/update.ts +109 -0
  43. package/src/validate.ts +302 -0
  44. package/templates/analyze-data.md +9 -0
  45. package/templates/code-review.md +10 -0
  46. package/templates/debug-issue.md +9 -0
  47. package/templates/implement-feature.md +9 -0
  48. package/templates/prototype.md +9 -0
  49. package/templates/refactor.md +9 -0
  50. package/templates/security-audit.md +10 -0
  51. package/templates/write-docs.md +9 -0
  52. package/templates/write-tests.md +10 -0
@@ -0,0 +1,207 @@
1
+ /**
2
+ * brl-subagent — Completion-push wake (issue #147)
3
+ *
4
+ * Builds and sends the `subagent-completion` custom message that wakes the
5
+ * conductor when a background run reaches a terminal state. This module is
6
+ * deliberately split into:
7
+ * - a PURE `buildCompletionMessage(agent, run)` builder (unit-tested, no pi
8
+ * SDK, no side effects — derives status/category/label/cost from the run
9
+ * record);
10
+ * - a PURE `resolveDelivery(status, knob)` that maps the D1+D2 matrix to a
11
+ * `{ deliverAs, triggerTurn }` pair;
12
+ * - a THIN `sendCompletionNotification(pi, ...)` sender — the ONLY call site
13
+ * of `pi.sendMessage`, one call, no logic.
14
+ *
15
+ * Delivery is always-on (minimum `nextTurn`); the `completionNotify` knob only
16
+ * controls the WAKE (D2). D1: failed/stopped → "steer", completed → "followUp".
17
+ */
18
+
19
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
20
+ import type {
21
+ BackgroundAgent,
22
+ SubagentRun,
23
+ CompletionNotifyMode,
24
+ AgentStatus,
25
+ } from "./types";
26
+ import { truncateTail } from "./transcript-tail";
27
+ import { formatRunDuration } from "./history";
28
+
29
+ /** Terminal statuses the completion message can carry. */
30
+ export type CompletionStatus = "completed" | "failed" | "stopped";
31
+
32
+ /** Structured `details` the conductor can act on without drill-in (D3). */
33
+ export interface CompletionMessageDetails {
34
+ id: string;
35
+ status: CompletionStatus;
36
+ errorCategory: string;
37
+ errorMessage?: string;
38
+ costUsd?: number;
39
+ tokensIn?: number;
40
+ tokensOut?: number;
41
+ durationMs?: number;
42
+ label?: string;
43
+ }
44
+
45
+ export interface CompletionMessagePayload {
46
+ customType: "subagent-completion";
47
+ content: string;
48
+ display: true;
49
+ details: CompletionMessageDetails;
50
+ }
51
+
52
+ export interface DeliveryResolution {
53
+ deliverAs: "steer" | "followUp" | "nextTurn";
54
+ triggerTurn: boolean;
55
+ }
56
+
57
+ /** Default dedupe cap: the set is simply cleared when it exceeds this. */
58
+ const DEDUPE_CAP = 200;
59
+
60
+ /**
61
+ * Soft directive appended to the content (D3): the conductor should process
62
+ * the completion silently unless action is needed; on failure apply the retry
63
+ * taxonomy (re-dispatch ≠ retry; Rule 18 governs terminations — ask the user
64
+ * before terminating anything).
65
+ */
66
+ const COMPLETION_DIRECTIVE =
67
+ "Process this completion silently unless action is needed. If the run failed, apply the retry taxonomy: a re-dispatch is not a retry, and Rule 18 governs terminations — ask the user before terminating anything.";
68
+
69
+ /**
70
+ * Normalize a BackgroundAgent status to a terminal status word. The subscriber
71
+ * only fires on terminal events, so the status is always terminal in practice;
72
+ * this maps any residual non-terminal value to "completed" rather than throwing.
73
+ */
74
+ export function normalizeCompletionStatus(status: AgentStatus): CompletionStatus {
75
+ if (status === "failed" || status === "stopped") return status;
76
+ return "completed";
77
+ }
78
+
79
+ /**
80
+ * Resolve the finalized entry for a run id among the raw getRunEntries list.
81
+ *
82
+ * Each background run writes TWO custom entries sharing the same id and
83
+ * startedAt: a spawn entry (status "running") and a final entry (status
84
+ * "done"/"failed", stamped by finalizeRunRecord). The raw list preserves
85
+ * append order — the spawn entry comes FIRST (verified empirically) — so a
86
+ * plain `.find(r => r.id === id)` matches the spawn entry and never sees the
87
+ * stamped errorCategory/cost/duration/tokens. Prefer the terminal entry
88
+ * (status !== "running"); when none is terminal yet, fall back to the
89
+ * matching spawn entry (the pre-finalize stopped-run shape).
90
+ */
91
+ export function resolveRunEntry(
92
+ entries: SubagentRun[],
93
+ id: string,
94
+ ): SubagentRun | undefined {
95
+ const matching = entries.filter((r) => r.id === id);
96
+ if (matching.length === 0) return undefined;
97
+ return matching.find((r) => r.status !== "running") ?? matching[0];
98
+ }
99
+
100
+ /**
101
+ * Build the one-line summary: `Background agent "<label>" (<id>) — <status> in
102
+ * <duration> · $<cost> · category: <category>` (D3).
103
+ */
104
+ function buildSummaryLine(
105
+ label: string,
106
+ id: string,
107
+ status: CompletionStatus,
108
+ durationMs: number | undefined,
109
+ costUsd: number | undefined,
110
+ errorCategory: string,
111
+ ): string {
112
+ let line = `Background agent "${label}" (${id}) — ${status}`;
113
+ if (durationMs !== undefined) line += ` in ${formatRunDuration(durationMs)}`;
114
+ if (costUsd !== undefined) line += ` · $${costUsd}`;
115
+ line += ` · category: ${errorCategory}`;
116
+ return line;
117
+ }
118
+
119
+ /**
120
+ * PURE builder — no pi SDK, no side effects, never throws. Returns the
121
+ * sendMessage payload shape per D3.
122
+ *
123
+ * Cost/tokens/duration are read from the run entry when present; if the run
124
+ * entry is absent (or the field is unset) the field is omitted from `details`
125
+ * and the summary tail.
126
+ */
127
+ export function buildCompletionMessage(
128
+ agent: BackgroundAgent,
129
+ run: SubagentRun | undefined,
130
+ ): CompletionMessagePayload {
131
+ const status = normalizeCompletionStatus(agent.status);
132
+ // BackgroundAgent has no `label` field — the caller's label is `description`
133
+ // (set from params.description at spawn). Fall back to the id.
134
+ const label = agent.description || agent.id;
135
+ // completed → "success"; failed/stopped → classified category from the run
136
+ // record (finalizeRunEntry stamps it), falling back to "unknown".
137
+ const errorCategory =
138
+ status === "completed" ? "success" : (run?.originalParams?.errorCategory ?? "unknown");
139
+ const durationMs = run?.durationMs;
140
+ const costUsd = run?.cost;
141
+ const tokensIn = run?.tokensIn;
142
+ const tokensOut = run?.tokensOut;
143
+ const errorMessage = status === "completed" ? undefined : agent.error ?? run?.errorMessage;
144
+
145
+ const summary = buildSummaryLine(label, agent.id, status, durationMs, costUsd, errorCategory);
146
+ const tail = truncateTail(agent.finalOutput ?? run?.fullOutput ?? "", 15);
147
+ const parts = [summary];
148
+ if (tail) parts.push(tail);
149
+ parts.push(COMPLETION_DIRECTIVE);
150
+ const content = parts.join("\n\n");
151
+
152
+ const details: CompletionMessageDetails = {
153
+ id: agent.id,
154
+ status,
155
+ errorCategory,
156
+ ...(errorMessage ? { errorMessage } : {}),
157
+ ...(costUsd !== undefined ? { costUsd } : {}),
158
+ ...(tokensIn !== undefined ? { tokensIn } : {}),
159
+ ...(tokensOut !== undefined ? { tokensOut } : {}),
160
+ ...(durationMs !== undefined ? { durationMs } : {}),
161
+ ...(label ? { label } : {}),
162
+ };
163
+
164
+ return { customType: "subagent-completion", content, display: true, details };
165
+ }
166
+
167
+ /**
168
+ * D1 + D2 matrix — the only knob-dependent logic. Delivery is always-on
169
+ * (minimum `nextTurn`); the knob controls the WAKE (triggerTurn).
170
+ */
171
+ export function resolveDelivery(
172
+ status: CompletionStatus,
173
+ knob: CompletionNotifyMode,
174
+ ): DeliveryResolution {
175
+ if (status === "completed") {
176
+ if (knob === "all") return { deliverAs: "followUp", triggerTurn: true };
177
+ return { deliverAs: "nextTurn", triggerTurn: false }; // knob "failed" | "off"
178
+ }
179
+ // status === "failed" | "stopped"
180
+ if (knob === "off") return { deliverAs: "nextTurn", triggerTurn: false };
181
+ return { deliverAs: "steer", triggerTurn: true }; // knob "all" | "failed"
182
+ }
183
+
184
+ /**
185
+ * Dedupe guard — first terminal event per id wins. Returns true when the event
186
+ * should be delivered, false when it is a duplicate. Capped simply (W1): the
187
+ * set is cleared when it exceeds the cap.
188
+ */
189
+ export function markTerminalSeen(seen: Set<string>, id: string, cap = DEDUPE_CAP): boolean {
190
+ if (seen.has(id)) return false;
191
+ seen.add(id);
192
+ if (seen.size > cap) seen.clear();
193
+ return true;
194
+ }
195
+
196
+ /**
197
+ * THIN sender — the only `pi.sendMessage` call site. One call, no logic.
198
+ * The `delivery` is the resolved output of `resolveDelivery` (D1+D2); it
199
+ * carries both `deliverAs` and `triggerTurn` so the matrix is honored.
200
+ */
201
+ export function sendCompletionNotification(
202
+ pi: ExtensionAPI,
203
+ message: CompletionMessagePayload,
204
+ delivery: DeliveryResolution,
205
+ ): void {
206
+ pi.sendMessage(message, { deliverAs: delivery.deliverAs, triggerTurn: delivery.triggerTurn });
207
+ }
package/src/params.ts ADDED
@@ -0,0 +1,225 @@
1
+ /**
2
+ * brl-subagent — Parameter Resolution (issue #59)
3
+ *
4
+ * resolveSubagentParams merges per-call params with the resolved preset,
5
+ * then applies thinking-level caps, timeout normalization, the edit→write
6
+ * tool fix, git mode / approval mode resolution, and E2 auto-routing.
7
+ *
8
+ * The function used to live as a closure inside src/index.ts's default
9
+ * export, which forced the integration tests to replicate it — a copy that
10
+ * silently drifted from the real behavior. Extracted here so the tests can
11
+ * drive the REAL function (no more silent test drift).
12
+ */
13
+
14
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
15
+
16
+ import type {
17
+ ThinkingLevel,
18
+ SubagentPreset,
19
+ GitMode,
20
+ ApprovalMode,
21
+ SubagentToolOptions,
22
+ ResolvedParams,
23
+ } from "./types";
24
+ import { resolveThinkingLevel } from "./types";
25
+ import { getAllPresets, getPreset } from "./presets";
26
+ import { autoRoutePreset } from "./router";
27
+ import { normalizeTimeout } from "./validate";
28
+ import type { SessionState } from "./state";
29
+ import type { Logger } from "./logging";
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // delegate_task known keys (issue #99)
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /**
36
+ * Keys the delegate_task schema declares. TypeBox's Type.Object allows
37
+ * additional properties by default, so this set is the source of truth for
38
+ * the unknown-param warn: any received key outside it is silently ignored
39
+ * by execute and worth a warn.
40
+ *
41
+ * Single source of truth (review R1): index.ts imports this instead of
42
+ * carrying a duplicate, and per-step-model.test.ts ratchets it against the
43
+ * REAL registered schema (the #108-class drift that #99's fix targeted can
44
+ * never silently return). Keep in sync with the schema's Type.Object({ ... })
45
+ * block in index.ts.
46
+ */
47
+ export const KNOWN_DELEGATE_KEYS = new Set([
48
+ "task", "systemPrompt", "inheritSystemPrompt", "thinkingLevel",
49
+ "outputFile", "label", "model", "timeout", "cwd", "tools",
50
+ "excludeTools", "noBuiltinTools", "preset", "template", "params",
51
+ "retryRunId", "gitMode", "retryOnTimeout", "approvalMode", "background",
52
+ "priority", "chain", "tasks", "graph",
53
+ ] as const);
54
+
55
+ /**
56
+ * Issue #99: warn-worthy unknown keys in a delegate_task params object.
57
+ * TypeBox's Type.Object allows additional properties by default and pi's
58
+ * validateToolArguments returns validated args UNCHANGED — so unknown keys
59
+ * survive to execute and are silently ignored. Comparing received keys
60
+ * against the schema's known keys converts that silent class into a
61
+ * visible one (warn-not-reject: never break a delegation).
62
+ */
63
+ export function findUnknownParams(
64
+ received: Record<string, unknown>,
65
+ knownKeys: ReadonlySet<string>,
66
+ ): string[] {
67
+ return Object.keys(received).filter((k) => !knownKeys.has(k));
68
+ }
69
+
70
+ /**
71
+ * Issue #108: snapshot the caller's raw delegate_task params onto a run
72
+ * record's originalParams — single source of truth for the retry merge
73
+ * (resolveRetryParams). The background and foreground run-record creation
74
+ * sites previously duplicated this 12-field literal (priority added, issue
75
+ * #114); a future param added to one and not the other would silently
76
+ * degrade retries of that path.
77
+ * NOTE: the schema's `params` (template slots) key is intentionally NOT
78
+ * snapshotted — it is a template-resolution input, not a retry override.
79
+ */
80
+ export function snapshotOriginalParams(params: {
81
+ systemPrompt?: string;
82
+ inheritSystemPrompt?: boolean;
83
+ model?: string;
84
+ thinkingLevel?: string;
85
+ priority?: string;
86
+ outputFile?: string;
87
+ timeout?: number;
88
+ cwd?: string;
89
+ tools?: string[];
90
+ excludeTools?: string[];
91
+ noBuiltinTools?: boolean;
92
+ preset?: string;
93
+ }): Record<string, unknown> {
94
+ return {
95
+ systemPrompt: params.systemPrompt,
96
+ inheritSystemPrompt: params.inheritSystemPrompt,
97
+ model: params.model,
98
+ thinkingLevel: params.thinkingLevel,
99
+ priority: params.priority,
100
+ outputFile: params.outputFile,
101
+ timeout: params.timeout,
102
+ cwd: params.cwd,
103
+ tools: params.tools,
104
+ excludeTools: params.excludeTools,
105
+ noBuiltinTools: params.noBuiltinTools,
106
+ preset: params.preset,
107
+ };
108
+ }
109
+
110
+ export function resolveSubagentParams(
111
+ params: {
112
+ task: string;
113
+ label?: string;
114
+ preset?: string;
115
+ systemPrompt?: string;
116
+ inheritSystemPrompt?: boolean;
117
+ thinkingLevel?: string;
118
+ outputFile?: string;
119
+ timeout?: number;
120
+ cwd?: string;
121
+ tools?: string[];
122
+ excludeTools?: string[];
123
+ noBuiltinTools?: boolean;
124
+ gitMode?: string;
125
+ approvalMode?: string;
126
+ template?: string;
127
+ },
128
+ state: SessionState,
129
+ ctx: ExtensionContext,
130
+ log: Logger,
131
+ ): ResolvedParams & {
132
+ resolvedGitMode: GitMode;
133
+ resolvedApprovalMode: ApprovalMode;
134
+ resolvedPreset?: SubagentPreset;
135
+ autoRoutedPreset?: SubagentPreset; // set only when autoRoutePreset chose it
136
+ } {
137
+ // E2: Auto-route to best preset only when the conductor expressed NO
138
+ // explicit preference — an explicit preset, template, or tool
139
+ // parameters (tools/excludeTools/noBuiltinTools) all count as intent
140
+ // that must win over keyword-based routing (issue #57).
141
+ let resolvedPreset = params.preset;
142
+ let wasAutoRouted = false;
143
+ const hasExplicitToolPreference =
144
+ params.tools !== undefined ||
145
+ params.excludeTools !== undefined ||
146
+ params.noBuiltinTools !== undefined;
147
+ if (!resolvedPreset && !params.template && !hasExplicitToolPreference) {
148
+ const allPresets = getAllPresets(state.builtinPresets, state.customPresets);
149
+ const suggested = autoRoutePreset(params.task, allPresets);
150
+ if (suggested) {
151
+ resolvedPreset = suggested;
152
+ wasAutoRouted = true;
153
+ log.info("Auto-routed task to preset", { preset: suggested });
154
+ }
155
+ }
156
+
157
+ const preset = resolvedPreset
158
+ ? getPreset(resolvedPreset, state.builtinPresets, state.customPresets)
159
+ : undefined;
160
+ const autoRoutedPreset = wasAutoRouted ? preset : undefined;
161
+
162
+ const mergedThinkingLevel =
163
+ (params.thinkingLevel as ThinkingLevel | undefined) ??
164
+ (preset?.thinkingLevel as ThinkingLevel | undefined);
165
+ const mergedSystemPrompt = params.systemPrompt ?? preset?.systemPrompt;
166
+ const mergedInheritSP = params.inheritSystemPrompt ?? preset?.inheritSystemPrompt;
167
+ const mergedOutputFile = params.outputFile ?? preset?.outputFile;
168
+ const mergedTimeout = normalizeTimeout(params.timeout ?? preset?.timeout);
169
+ const mergedTools = params.tools ?? preset?.tools;
170
+ // Fix: edit depends on write in pi's tool system.
171
+ // If edit is in the allowlist but write is not, all tools fail to resolve.
172
+ const resolvedTools = mergedTools && mergedTools.includes("edit") && !mergedTools.includes("write")
173
+ ? [...mergedTools, "write"]
174
+ : mergedTools;
175
+ const mergedExcludeTools = params.excludeTools ?? preset?.excludeTools;
176
+ const mergedNoBuiltinTools = params.noBuiltinTools ?? preset?.noBuiltinTools;
177
+
178
+ // QUIRK (pre-existing, preserved from the original closure): gitMode merges
179
+ // from preset?.NAME, not a preset gitMode field (SubagentPreset has none).
180
+ // A preset literally named "branch"/"none" would silently set gitMode.
181
+ // Deliberately NOT changed here — altering it is a behavior change beyond
182
+ // issue #59's scope.
183
+ const mergedGitMode = (params.gitMode as GitMode | undefined) ?? preset?.name;
184
+ const resolvedGitMode: GitMode =
185
+ mergedGitMode === "branch" || mergedGitMode === "none"
186
+ ? mergedGitMode
187
+ : state.config.gitMode;
188
+
189
+ // P4: Resolve approval mode: per-call param > state config > default "writes"
190
+ const mergedApprovalMode = params.approvalMode as ApprovalMode | undefined;
191
+ const resolvedApprovalMode: ApprovalMode =
192
+ mergedApprovalMode === "auto" || mergedApprovalMode === "writes" || mergedApprovalMode === "always"
193
+ ? mergedApprovalMode
194
+ : state.config.approvalMode;
195
+
196
+ const thinkingLevel = resolveThinkingLevel(
197
+ mergedThinkingLevel,
198
+ state.config.maxThinkingLevel,
199
+ );
200
+
201
+ const toolOptions: SubagentToolOptions | undefined =
202
+ resolvedTools || mergedExcludeTools || mergedNoBuiltinTools
203
+ ? {
204
+ tools: resolvedTools,
205
+ excludeTools: mergedExcludeTools,
206
+ noBuiltinTools: mergedNoBuiltinTools,
207
+ }
208
+ : undefined;
209
+
210
+ return {
211
+ task: params.task,
212
+ label: params.label?.trim() || undefined,
213
+ inheritSP: mergedInheritSP !== false,
214
+ customSP: mergedSystemPrompt,
215
+ outputFile: mergedOutputFile,
216
+ timeout: mergedTimeout,
217
+ effectiveCwd: params.cwd || ctx.cwd,
218
+ thinkingLevel,
219
+ toolOptions,
220
+ resolvedGitMode,
221
+ resolvedApprovalMode,
222
+ resolvedPreset: preset,
223
+ autoRoutedPreset,
224
+ };
225
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * brl-subagent — Pre-flight Checks (R3)
3
+ *
4
+ * Validates the execution environment before spawning a subagent.
5
+ * Checks: pi binary availability, temp directory writability,
6
+ * and project cwd readability.
7
+ */
8
+
9
+ import * as fs from "node:fs";
10
+ import * as os from "node:os";
11
+ import * as path from "node:path";
12
+ import { getPiInvocation } from "./runner";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Types
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export type PreflightResult = { ok: true } | { ok: false; error: string };
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Checks
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /**
25
+ * Verify the pi binary is accessible via the same resolution logic used
26
+ * at spawn time. When `getPiInvocation` falls back to "pi" on PATH, we
27
+ * walk PATH to confirm the binary exists. For absolute paths (e.g.
28
+ * process.execPath), we verify the file is executable.
29
+ */
30
+ function checkPiBinary(): PreflightResult {
31
+ const { command } = getPiInvocation([]);
32
+
33
+ if (command !== "pi") {
34
+ // Absolute or relative path — verify it exists
35
+ try {
36
+ fs.accessSync(command, fs.constants.X_OK);
37
+ return { ok: true };
38
+ } catch {
39
+ return {
40
+ ok: false,
41
+ error: `Subagent command '${command}' is not accessible or not executable.`,
42
+ };
43
+ }
44
+ }
45
+
46
+ // "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
+ }
61
+ }
62
+
63
+ return {
64
+ ok: false,
65
+ error:
66
+ "pi binary not found in PATH. " +
67
+ "Ensure pi is installed and accessible from the command line.",
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Verify the project working directory exists and is readable.
73
+ */
74
+ function checkCwdReadable(cwd: string): PreflightResult {
75
+ try {
76
+ fs.accessSync(cwd, fs.constants.R_OK);
77
+ } catch {
78
+ return {
79
+ ok: false,
80
+ error: `Project working directory is not readable: ${cwd}. ` +
81
+ "Check that the directory exists and has read permissions.",
82
+ };
83
+ }
84
+
85
+ // Double-check it's actually a directory
86
+ try {
87
+ const stat = fs.statSync(cwd);
88
+ if (!stat.isDirectory()) {
89
+ return {
90
+ ok: false,
91
+ error: `Project working directory is not a directory: ${cwd}.`,
92
+ };
93
+ }
94
+ } catch {
95
+ return {
96
+ ok: false,
97
+ error: `Cannot stat project working directory: ${cwd}.`,
98
+ };
99
+ }
100
+
101
+ return { ok: true };
102
+ }
103
+
104
+ /**
105
+ * Verify the system temp directory is writable by creating and
106
+ * removing a small temporary file.
107
+ */
108
+ function checkTempWritable(): PreflightResult {
109
+ const tmpDir = os.tmpdir();
110
+ const testFile = path.join(tmpDir, `.brl-subagent-pcheck-${process.pid}`);
111
+
112
+ try {
113
+ fs.writeFileSync(testFile, "ok", { encoding: "utf-8", mode: 0o600 });
114
+ fs.unlinkSync(testFile);
115
+ return { ok: true };
116
+ } catch (err) {
117
+ const message = err instanceof Error ? err.message : String(err);
118
+ return {
119
+ ok: false,
120
+ error: `System temp directory (${tmpDir}) is not writable: ${message}. ` +
121
+ "Set TMPDIR or TEMP to a writable directory.",
122
+ };
123
+ }
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Public API
128
+ // ---------------------------------------------------------------------------
129
+
130
+ /**
131
+ * Run all pre-flight checks for subagent execution.
132
+ * Returns `{ ok: true }` if all checks pass,
133
+ * or `{ ok: false, error: "..." }` with a human-readable description
134
+ * of the first failure.
135
+ *
136
+ * Call this BEFORE acquiring a concurrency slot to avoid wasting
137
+ * resources on a doomed spawn.
138
+ */
139
+ export function preflightCheck(cwd: string): PreflightResult {
140
+ const piResult = checkPiBinary();
141
+ if (!piResult.ok) return piResult;
142
+
143
+ const cwdResult = checkCwdReadable(cwd);
144
+ if (!cwdResult.ok) return cwdResult;
145
+
146
+ const tempResult = checkTempWritable();
147
+ if (!tempResult.ok) return tempResult;
148
+
149
+ return { ok: true };
150
+ }