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.
- package/LICENSE +21 -0
- package/README.md +257 -0
- package/package.json +49 -0
- package/presets/code-reviewer.md +37 -0
- package/presets/data-analyst.md +33 -0
- package/presets/debugger.md +36 -0
- package/presets/dev-agent.md +30 -0
- package/presets/rapid-prototyper.md +25 -0
- package/presets/refactorer.md +34 -0
- package/presets/security-auditor.md +39 -0
- package/presets/tech-writer.md +33 -0
- package/presets/test-engineer.md +34 -0
- package/src/concurrency.ts +186 -0
- package/src/diff.ts +102 -0
- package/src/event-bus.ts +86 -0
- package/src/git.ts +187 -0
- package/src/history.ts +177 -0
- package/src/index.ts +3824 -0
- package/src/logging.ts +135 -0
- package/src/messaging.ts +164 -0
- package/src/metrics.ts +221 -0
- package/src/model-availability.ts +73 -0
- package/src/notify-completion.ts +207 -0
- package/src/params.ts +225 -0
- package/src/preflight.ts +150 -0
- package/src/presets.ts +425 -0
- package/src/prompt.ts +169 -0
- package/src/reports.ts +325 -0
- package/src/router.ts +133 -0
- package/src/runner.ts +877 -0
- package/src/sanitize.ts +365 -0
- package/src/scheduler.ts +187 -0
- package/src/session-manager.ts +1065 -0
- package/src/state.ts +590 -0
- package/src/templates.ts +469 -0
- package/src/transcript-tail.ts +327 -0
- package/src/transcript.ts +119 -0
- package/src/tui-format.ts +51 -0
- package/src/tui.ts +3103 -0
- package/src/types.ts +803 -0
- package/src/unit-run.ts +269 -0
- package/src/update.ts +109 -0
- package/src/validate.ts +302 -0
- package/templates/analyze-data.md +9 -0
- package/templates/code-review.md +10 -0
- package/templates/debug-issue.md +9 -0
- package/templates/implement-feature.md +9 -0
- package/templates/prototype.md +9 -0
- package/templates/refactor.md +9 -0
- package/templates/security-audit.md +10 -0
- package/templates/write-docs.md +9 -0
- 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
|
+
}
|
package/src/preflight.ts
ADDED
|
@@ -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
|
+
}
|