pi-bro 0.21.0 → 0.22.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.
- package/CHANGELOG.md +53 -0
- package/README.md +80 -37
- package/backend.ts +55 -42
- package/bro.ts +166 -1359
- package/config-ui.ts +325 -0
- package/docs/README.md +2 -0
- package/docs/guided-review.md +131 -0
- package/docs/pig-compatibility.md +1 -0
- package/package.json +13 -4
- package/prompt.ts +19 -4
- package/review-modal.ts +1272 -0
- package/review-source.ts +296 -0
- package/review-ui.ts +519 -0
- package/review.ts +586 -0
- package/settings.ts +505 -0
- package/sources.ts +367 -0
- package/util.ts +26 -0
package/bro.ts
CHANGED
|
@@ -1,69 +1,75 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { hasBroCustomUi, broModalRows, canBroInsertIntoEditor, insertBroDesktopText } from "./ui-capabilities.ts";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
|
-
import {
|
|
5
|
-
import { mkdir, mkdtemp, readdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
|
|
6
|
-
import { request as httpRequest, type IncomingMessage } from "node:http";
|
|
7
|
-
import { request as httpsRequest } from "node:https";
|
|
8
|
-
import { BlockList, isIP } from "node:net";
|
|
4
|
+
import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
9
5
|
import { tmpdir } from "node:os";
|
|
10
|
-
import {
|
|
6
|
+
import { join } from "node:path";
|
|
11
7
|
import { stripVTControlCharacters } from "node:util";
|
|
12
8
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
13
|
-
import { Container, Editor, Input, Markdown,
|
|
14
|
-
import { convertToLlm, copyToClipboard, getAgentDir, getMarkdownTheme, getSelectListTheme
|
|
15
|
-
import { Defuddle } from "defuddle/node";
|
|
16
|
-
import { parseHTML } from "linkedom";
|
|
17
|
-
import mammoth from "mammoth";
|
|
9
|
+
import { Container, Editor, Input, Markdown, Text, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component, type EditorTheme, type Focusable, type TUI } from "@earendil-works/pi-tui";
|
|
10
|
+
import { convertToLlm, copyToClipboard, getAgentDir, getMarkdownTheme, getSelectListTheme } from "@earendil-works/pi-coding-agent";
|
|
18
11
|
import { Type } from "typebox";
|
|
19
|
-
import {
|
|
20
|
-
import { BRO_MODES, DEFAULT_BRO_MODE, MAX_PREFERENCES_CHARS, STARTER_PREFERENCES, buildAdvisorPrompt, buildBtwPrompt, buildDefaultPrompt, buildShowPrompt, nextBroMode, parseBroMode, type BroMode } from "./prompt.ts";
|
|
12
|
+
import { BRO_MODES, MAX_PREFERENCES_CHARS, MAX_ADVISOR_STEERING_CHARS, STARTER_PREFERENCES, buildAdvisorPrompt, buildBtwPrompt, buildDefaultPrompt, buildShowPrompt, nextBroMode, parseBroMode, type BroMode } from "./prompt.ts";
|
|
21
13
|
import {
|
|
22
14
|
agyFailureMessage,
|
|
23
|
-
|
|
24
|
-
advisorFlagErrorHint,
|
|
25
|
-
CLAUDE_EFFORTS,
|
|
26
|
-
GROK_EFFORTS,
|
|
27
|
-
CODEX_EFFORTS,
|
|
28
|
-
MUSE_EFFORTS,
|
|
15
|
+
reviewBackendError,
|
|
29
16
|
execute as executeBackend,
|
|
30
|
-
parseBtwAgyLine,
|
|
31
|
-
type AgySelection,
|
|
32
17
|
type BackendProgress,
|
|
33
18
|
type BackendSelection,
|
|
34
19
|
} from "./backend.ts";
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
20
|
+
import { isRecord, errorMessage, withDoctor, unquote } from "./util.ts";
|
|
21
|
+
import {
|
|
22
|
+
type BackendName,
|
|
23
|
+
type BroSettings,
|
|
24
|
+
type AgyModelFamily,
|
|
25
|
+
type AgyEffort,
|
|
26
|
+
EFFORTS,
|
|
27
|
+
CAPABILITIES,
|
|
28
|
+
CAPABILITY_LABELS,
|
|
29
|
+
capabilityBackend,
|
|
30
|
+
capabilityOverride,
|
|
31
|
+
capabilityPair,
|
|
32
|
+
ensureSettingsFile,
|
|
33
|
+
isClaudeEffort,
|
|
34
|
+
isGrokEffort,
|
|
35
|
+
isCodexEffort,
|
|
36
|
+
isMuseEffort,
|
|
37
|
+
readSettings,
|
|
38
|
+
resolveCapabilitySettings,
|
|
39
|
+
resolveModelEffort,
|
|
40
|
+
selectionForCapability,
|
|
41
|
+
selectionLabel,
|
|
42
|
+
settingsFile,
|
|
43
|
+
writeSettings,
|
|
44
|
+
} from "./settings.ts";
|
|
45
|
+
import {
|
|
46
|
+
type BroSource,
|
|
47
|
+
extractDocumentText,
|
|
48
|
+
extractWebPage,
|
|
49
|
+
isWorkspaceFile,
|
|
50
|
+
looksLikeWebUrl,
|
|
51
|
+
MAX_TEXT_LENGTH,
|
|
52
|
+
} from "./sources.ts";
|
|
53
|
+
import { createConfigModal, type Theme, type TuiLike } from "./config-ui.ts";
|
|
54
|
+
|
|
55
|
+
import { openGuidedReview, stopGuidedReviews } from "./review-ui.ts";
|
|
56
|
+
|
|
57
|
+
export * from "./util.ts";
|
|
58
|
+
export * from "./settings.ts";
|
|
59
|
+
export * from "./sources.ts";
|
|
60
|
+
export * from "./config-ui.ts";
|
|
61
|
+
|
|
62
|
+
const PREFERENCES_FILE = join(getAgentDir(), "bro-preferences.md");
|
|
63
|
+
const ADVISOR_STEERING_FILE = join(getAgentDir(), "bro-advisor.md");
|
|
42
64
|
// Larger files are rejected before reading, even by the editor; the prompt limit is MAX_PREFERENCES_CHARS.
|
|
43
65
|
const MAX_PREFERENCES_FILE_BYTES = 64 * 1024;
|
|
44
|
-
const
|
|
66
|
+
const MAX_ADVISOR_STEERING_FILE_BYTES = 64 * 1024;
|
|
45
67
|
const LOADING_TEXT = "Simplifying for my bro…";
|
|
46
|
-
const MAX_FILE_BYTES = 10 * 1024 * 1024;
|
|
47
|
-
const MAX_WEB_BYTES = 5 * 1024 * 1024;
|
|
48
|
-
const MAX_WEB_ELEMENTS = 100_000;
|
|
49
|
-
const MAX_WEB_REDIRECTS = 5;
|
|
50
|
-
const WEB_TIMEOUT_MS = 25_000;
|
|
51
|
-
const MAX_TEXT_LENGTH = 100_000;
|
|
52
68
|
const BTW_CONTEXT_TURNS = 8;
|
|
53
69
|
const BTW_CONTEXT_MAX = 40_000;
|
|
54
|
-
const DEFAULT_SHOW_TURNS = 1;
|
|
55
70
|
const SHOW_HTML_FILE_PATTERN = /^bro-show-[0-9a-f]{8}\.html$/;
|
|
56
|
-
|
|
57
|
-
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
58
|
-
|
|
59
|
-
type Theme = ExtensionCommandContext["ui"]["theme"];
|
|
60
|
-
type TuiLike = {
|
|
61
|
-
readonly mode: "regular" | "fullscreen";
|
|
62
|
-
readonly terminal?: { rows?: number; write?: (data: string) => void };
|
|
63
|
-
requestRender(): void;
|
|
64
|
-
};
|
|
71
|
+
|
|
65
72
|
type ModalKind = "loading" | "streaming" | "result" | "help" | "empty" | "error";
|
|
66
|
-
type BroSource = { text: string; label?: string };
|
|
67
73
|
// `mode` is the built-in explain mode that produced the text; absent for Show and Doctor.
|
|
68
74
|
// `preferences` records whether bro-preferences.md shaped this result; like `mode`, it belongs to the result.
|
|
69
75
|
type BroResult = { source: BroSource; text: string; model?: string; mode?: BroMode; preferences?: boolean };
|
|
@@ -73,55 +79,6 @@ type BtwTurn = { question: string; answer: string };
|
|
|
73
79
|
// thread; `sessionFull` and `sessionPreferences` are the access mode and preferences the native
|
|
74
80
|
// session last ran with. `preferences` records whether the latest turn used them (header tag).
|
|
75
81
|
type BtwThread = { turns: BtwTurn[]; conversationId?: string; full: boolean; backend?: BackendName; model?: string; preferences?: boolean; context?: string; sessionFull?: boolean; sessionPreferences?: string };
|
|
76
|
-
const EFFORTS = ["default", "low", "medium", "high"] as const;
|
|
77
|
-
const BACKENDS = ["agy", "claude", "grok", "codex", "muse"] as const;
|
|
78
|
-
type BackendName = (typeof BACKENDS)[number];
|
|
79
|
-
type BroEffort = "default" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
80
|
-
type AgyEffort = Exclude<BroEffort, "default" | "minimal" | "xhigh" | "max">;
|
|
81
|
-
type ClaudeEffort = (typeof CLAUDE_EFFORTS)[number];
|
|
82
|
-
export { GROK_EFFORTS, CODEX_EFFORTS, MUSE_EFFORTS };
|
|
83
|
-
type GrokEffort = (typeof GROK_EFFORTS)[number];
|
|
84
|
-
type CodexEffort = (typeof CODEX_EFFORTS)[number];
|
|
85
|
-
type MuseEffort = (typeof MUSE_EFFORTS)[number];
|
|
86
|
-
// "advisor" is a real capability (command, Agy invocation, Doctor check) like the other three;
|
|
87
|
-
// see docs/plans/2026-09-19-bro-advisor-design.md. All four share one model/effort resolution,
|
|
88
|
-
// override, and Doctor-check path via this single list -- there is no configuration-only tier.
|
|
89
|
-
const CAPABILITIES = ["explain", "show", "btw", "advisor"] as const;
|
|
90
|
-
type Capability = (typeof CAPABILITIES)[number];
|
|
91
|
-
const CAPABILITY_LABELS: Record<Capability, string> = { explain: "Explain", show: "Show", btw: "Btw", advisor: "Advisor" };
|
|
92
|
-
type ModelEffortPair = { backend?: BackendName; model: string; effort: BroEffort };
|
|
93
|
-
type BroSettings = {
|
|
94
|
-
backend?: BackendName;
|
|
95
|
-
model: string;
|
|
96
|
-
effort: BroEffort;
|
|
97
|
-
mode: BroMode;
|
|
98
|
-
showTurns: number;
|
|
99
|
-
overrides: Partial<Record<Capability, ModelEffortPair>>;
|
|
100
|
-
};
|
|
101
|
-
// BackendSelection (shared execution routing selection) lives in backend.ts; the backend
|
|
102
|
-
// field stays optional there so every legacy backend-less pair keeps meaning Agy.
|
|
103
|
-
export const CLAUDE_MODELS = [
|
|
104
|
-
{ id: "sonnet", label: "Claude Sonnet" },
|
|
105
|
-
{ id: "opus", label: "Claude Opus" },
|
|
106
|
-
] as const;
|
|
107
|
-
export const GROK_MODELS = [
|
|
108
|
-
{ id: "grok-4.7", label: "Grok 4.7" },
|
|
109
|
-
{ id: "grok-4.7-build-fast", label: "Grok 4.7 Build Fast" },
|
|
110
|
-
] as const;
|
|
111
|
-
export const CODEX_MODELS = [
|
|
112
|
-
{ id: "gpt-5.5", label: "GPT-5.5" },
|
|
113
|
-
{ id: "gpt-5.4", label: "GPT-5.4" },
|
|
114
|
-
] as const;
|
|
115
|
-
export const MUSE_MODELS = [
|
|
116
|
-
{ id: "muse-spark-1.3-contributor", label: "Muse Spark 1.3 Contributor" },
|
|
117
|
-
{ id: "muse-spark-1.3", label: "Muse Spark 1.3" },
|
|
118
|
-
] as const;
|
|
119
|
-
type AgyModelFamily = {
|
|
120
|
-
id: string;
|
|
121
|
-
label: string;
|
|
122
|
-
efforts: AgyEffort[];
|
|
123
|
-
variants: Array<{ id: string; effort?: AgyEffort }>;
|
|
124
|
-
};
|
|
125
82
|
export function wheelDelta(data: string): number {
|
|
126
83
|
const match = /^\x1b\[<(\d+);\d+;\d+[Mm]$/.exec(data);
|
|
127
84
|
if (!match) return 0;
|
|
@@ -144,6 +101,7 @@ const COMMANDS = [
|
|
|
144
101
|
{ value: "show", label: "show", description: "Draw what happened in recent session turns as shapes" },
|
|
145
102
|
{ value: "mode", label: "mode", description: "View or choose explanation mode (brief, balanced, faithful)" },
|
|
146
103
|
{ value: "preferences", label: "preferences", description: "View or edit what Bro knows about you and how you like answers" },
|
|
104
|
+
{ value: "guided-review", label: "guided-review", description: "Understand and assess a PR with captured code and optional private questions" },
|
|
147
105
|
{ value: "btw", label: "btw", description: "Open a side conversation (starts conversation-only; /mode toggles full permission)" },
|
|
148
106
|
{ value: "config", label: "config", description: "Configure shared defaults and per-capability model/effort overrides" },
|
|
149
107
|
{ value: "advisor", label: "advisor", description: "Check whether the executor's advisor tool is available right now" },
|
|
@@ -152,652 +110,6 @@ const COMMANDS = [
|
|
|
152
110
|
];
|
|
153
111
|
const KNOWN_ACTIONS = new Set(COMMANDS.map((command) => command.value));
|
|
154
112
|
|
|
155
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
156
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function errorMessage(error: unknown): string {
|
|
160
|
-
return error instanceof Error ? error.message : String(error);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function withDoctor(error: unknown): string {
|
|
164
|
-
const message = errorMessage(error);
|
|
165
|
-
return message.includes("/bro doctor") ? message : `${message}\n\nRun \`/bro doctor\` for setup help.`;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function fileError(path: string, error: unknown): Error {
|
|
169
|
-
const code = (error as NodeJS.ErrnoException).code;
|
|
170
|
-
if (code === "ENOENT") return new Error(`File not found: ${path}`);
|
|
171
|
-
if (code === "EACCES" || code === "EPERM") return new Error(`File is not readable: ${path}`);
|
|
172
|
-
return new Error(`Could not read ${path}: ${errorMessage(error)}`);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
function unquote(value: string): string {
|
|
176
|
-
if (value.length >= 2 && ((value[0] === '"' && value.at(-1) === '"') || (value[0] === "'" && value.at(-1) === "'"))) {
|
|
177
|
-
return value.slice(1, -1);
|
|
178
|
-
}
|
|
179
|
-
return value;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
export async function extractDocumentText(input: string, cwd: string, signal?: AbortSignal): Promise<string> {
|
|
183
|
-
const requested = unquote(input.trim());
|
|
184
|
-
if (!requested) throw new Error("Use /bro file <path>.");
|
|
185
|
-
|
|
186
|
-
let root: string;
|
|
187
|
-
let path: string;
|
|
188
|
-
try {
|
|
189
|
-
root = await realpath(cwd);
|
|
190
|
-
path = await realpath(resolve(cwd, requested));
|
|
191
|
-
} catch (error) {
|
|
192
|
-
throw fileError(requested, error);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
const fromRoot = relative(root, path);
|
|
196
|
-
if (fromRoot === ".." || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) {
|
|
197
|
-
throw new Error("Bro can read only files inside the current workspace.");
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
let info;
|
|
201
|
-
try {
|
|
202
|
-
info = await stat(path);
|
|
203
|
-
} catch (error) {
|
|
204
|
-
throw fileError(requested, error);
|
|
205
|
-
}
|
|
206
|
-
if (!info.isFile()) throw new Error(`Not a regular file: ${requested}`);
|
|
207
|
-
if (info.size > MAX_FILE_BYTES) throw new Error("File is larger than Bro's 10 MiB limit.");
|
|
208
|
-
|
|
209
|
-
let buffer: Buffer;
|
|
210
|
-
try {
|
|
211
|
-
buffer = await readFile(path, { signal });
|
|
212
|
-
} catch (error) {
|
|
213
|
-
if (signal?.aborted) throw new Error("Canceled.");
|
|
214
|
-
throw fileError(requested, error);
|
|
215
|
-
}
|
|
216
|
-
if (buffer.byteLength > MAX_FILE_BYTES) throw new Error("File is larger than Bro's 10 MiB limit.");
|
|
217
|
-
if (signal?.aborted) throw new Error("Canceled.");
|
|
218
|
-
|
|
219
|
-
const extension = extname(path).toLowerCase();
|
|
220
|
-
let text: string;
|
|
221
|
-
try {
|
|
222
|
-
if (TEXT_EXTENSIONS.has(extension)) {
|
|
223
|
-
text = new TextDecoder("utf-8", { fatal: true }).decode(buffer);
|
|
224
|
-
} else if (extension === ".pdf") {
|
|
225
|
-
text = (await extractText(new Uint8Array(buffer), { mergePages: true })).text;
|
|
226
|
-
} else if (extension === ".docx") {
|
|
227
|
-
text = (await mammoth.extractRawText({ buffer })).value;
|
|
228
|
-
} else {
|
|
229
|
-
throw new Error("Unsupported file type. Use .md, .markdown, .txt, .pdf, or .docx.");
|
|
230
|
-
}
|
|
231
|
-
} catch (error) {
|
|
232
|
-
if (error instanceof Error && error.message.startsWith("Unsupported file type.")) throw error;
|
|
233
|
-
throw new Error(`Could not extract text from ${requested}: ${errorMessage(error)}`);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
text = text.trim();
|
|
237
|
-
if (!text) throw new Error("No readable text found. Scanned PDFs need OCR, which Bro does not support.");
|
|
238
|
-
if (text.length > MAX_TEXT_LENGTH) throw new Error("Extracted text is longer than Bro's 100,000-character limit.");
|
|
239
|
-
return text;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
const SNIFFABLE_FILE_EXTENSIONS = new Set([...TEXT_EXTENSIONS, ".pdf", ".docx"]);
|
|
243
|
-
|
|
244
|
-
async function isWorkspaceFile(input: string, cwd: string): Promise<boolean> {
|
|
245
|
-
// ponytail: duplicates extractDocumentText's workspace guard rather than sharing its error semantics.
|
|
246
|
-
try {
|
|
247
|
-
const path = await realpath(resolve(cwd, input));
|
|
248
|
-
const fromRoot = relative(await realpath(cwd), path);
|
|
249
|
-
if (fromRoot === ".." || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) {
|
|
250
|
-
return false;
|
|
251
|
-
}
|
|
252
|
-
const info = await stat(path);
|
|
253
|
-
return info.isFile() && SNIFFABLE_FILE_EXTENSIONS.has(extname(path).toLowerCase());
|
|
254
|
-
} catch {
|
|
255
|
-
return false;
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
const NON_PUBLIC_ADDRESSES = new BlockList();
|
|
260
|
-
for (const [network, prefix] of [
|
|
261
|
-
["0.0.0.0", 8],
|
|
262
|
-
["10.0.0.0", 8],
|
|
263
|
-
["100.64.0.0", 10],
|
|
264
|
-
["127.0.0.0", 8],
|
|
265
|
-
["169.254.0.0", 16],
|
|
266
|
-
["172.16.0.0", 12],
|
|
267
|
-
["192.0.0.0", 24],
|
|
268
|
-
["192.0.2.0", 24],
|
|
269
|
-
["192.31.196.0", 24],
|
|
270
|
-
["192.52.193.0", 24],
|
|
271
|
-
["192.88.99.0", 24],
|
|
272
|
-
["192.168.0.0", 16],
|
|
273
|
-
["192.175.48.0", 24],
|
|
274
|
-
["198.18.0.0", 15],
|
|
275
|
-
["198.51.100.0", 24],
|
|
276
|
-
["203.0.113.0", 24],
|
|
277
|
-
["224.0.0.0", 4],
|
|
278
|
-
["240.0.0.0", 4],
|
|
279
|
-
] as const) {
|
|
280
|
-
NON_PUBLIC_ADDRESSES.addSubnet(network, prefix, "ipv4");
|
|
281
|
-
}
|
|
282
|
-
for (const [network, prefix] of [
|
|
283
|
-
["::", 128],
|
|
284
|
-
["::1", 128],
|
|
285
|
-
["64:ff9b::", 96],
|
|
286
|
-
["64:ff9b:1::", 48],
|
|
287
|
-
["100::", 64],
|
|
288
|
-
["2001::", 23],
|
|
289
|
-
["2001:db8::", 32],
|
|
290
|
-
["2002::", 16],
|
|
291
|
-
["3fff::", 20],
|
|
292
|
-
["5f00::", 16],
|
|
293
|
-
["fc00::", 7],
|
|
294
|
-
["fe80::", 10],
|
|
295
|
-
["ff00::", 8],
|
|
296
|
-
] as const) {
|
|
297
|
-
NON_PUBLIC_ADDRESSES.addSubnet(network, prefix, "ipv6");
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
export function isPublicWebAddress(address: string): boolean {
|
|
301
|
-
const family = isIP(address);
|
|
302
|
-
return family === 4
|
|
303
|
-
? !NON_PUBLIC_ADDRESSES.check(address, "ipv4")
|
|
304
|
-
: family === 6
|
|
305
|
-
? !NON_PUBLIC_ADDRESSES.check(address, "ipv6")
|
|
306
|
-
: false;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
export function parseWebUrl(input: string): URL {
|
|
310
|
-
const requested = unquote(input.trim());
|
|
311
|
-
if (!requested) throw new Error("Use /bro url <url>.");
|
|
312
|
-
|
|
313
|
-
let url: URL;
|
|
314
|
-
try {
|
|
315
|
-
url = new URL(requested);
|
|
316
|
-
} catch {
|
|
317
|
-
throw new Error("That is not a valid URL. Use /bro url https://example.com/article.");
|
|
318
|
-
}
|
|
319
|
-
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
320
|
-
throw new Error("Bro can read only public HTTP or HTTPS webpages.");
|
|
321
|
-
}
|
|
322
|
-
if (url.username || url.password) {
|
|
323
|
-
throw new Error("Bro does not accept URLs containing usernames or passwords.");
|
|
324
|
-
}
|
|
325
|
-
url.hash = "";
|
|
326
|
-
return url;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
export function looksLikeWebUrl(input: string): boolean {
|
|
330
|
-
// Structurally http(s) only: credential or syntax problems must surface as url errors, not text leaks.
|
|
331
|
-
let url: URL;
|
|
332
|
-
try {
|
|
333
|
-
url = new URL(input);
|
|
334
|
-
} catch {
|
|
335
|
-
return false;
|
|
336
|
-
}
|
|
337
|
-
return url.protocol === "http:" || url.protocol === "https:";
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
export function parseWebRedirect(current: URL, location: string): URL {
|
|
341
|
-
const next = parseWebUrl(new URL(location, current).href);
|
|
342
|
-
if (current.protocol === "https:" && next.protocol !== "https:") {
|
|
343
|
-
throw new Error("Bro refused an insecure HTTPS-to-HTTP redirect.");
|
|
344
|
-
}
|
|
345
|
-
return next;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
function headerValue(value: string | string[] | undefined): string {
|
|
349
|
-
return Array.isArray(value) ? value[0] ?? "" : value ?? "";
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
async function resolvePublicAddress(hostname: string): Promise<{ address: string; family: 4 | 6 }> {
|
|
353
|
-
const host = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
|
354
|
-
let addresses: Array<{ address: string; family: number }>;
|
|
355
|
-
try {
|
|
356
|
-
addresses = await lookup(host, { all: true, verbatim: true });
|
|
357
|
-
} catch (error) {
|
|
358
|
-
throw new Error(`Could not resolve webpage host: ${errorMessage(error)}`);
|
|
359
|
-
}
|
|
360
|
-
if (!addresses.length) throw new Error("The webpage host has no network address.");
|
|
361
|
-
if (addresses.some((item) => !isPublicWebAddress(item.address))) {
|
|
362
|
-
throw new Error("Bro cannot connect to local, private, or reserved network addresses.");
|
|
363
|
-
}
|
|
364
|
-
return { address: addresses[0].address, family: addresses[0].family === 6 ? 6 : 4 };
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
function requestWebPage(url: URL, address: { address: string; family: 4 | 6 }, signal: AbortSignal): Promise<IncomingMessage> {
|
|
368
|
-
return new Promise((resolveResponse, rejectResponse) => {
|
|
369
|
-
const request = (url.protocol === "https:" ? httpsRequest : httpRequest)(
|
|
370
|
-
url,
|
|
371
|
-
{
|
|
372
|
-
method: "GET",
|
|
373
|
-
signal,
|
|
374
|
-
headers: {
|
|
375
|
-
Accept: "text/html,application/xhtml+xml",
|
|
376
|
-
"Accept-Encoding": "identity",
|
|
377
|
-
"User-Agent": "pi-bro URL reader (+https://github.com/tranhoangnguyen03/pi-bro)",
|
|
378
|
-
},
|
|
379
|
-
lookup: (_hostname, options, callback) => {
|
|
380
|
-
if (options.all) callback(null, [address]);
|
|
381
|
-
else callback(null, address.address, address.family);
|
|
382
|
-
},
|
|
383
|
-
},
|
|
384
|
-
resolveResponse,
|
|
385
|
-
);
|
|
386
|
-
request.once("error", rejectResponse);
|
|
387
|
-
request.end();
|
|
388
|
-
});
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
async function readWebBody(response: IncomingMessage): Promise<Buffer> {
|
|
392
|
-
const contentEncoding = headerValue(response.headers["content-encoding"]).trim().toLowerCase();
|
|
393
|
-
if (contentEncoding && contentEncoding !== "identity") {
|
|
394
|
-
response.destroy();
|
|
395
|
-
throw new Error(`Bro cannot read this page's ${contentEncoding} response encoding.`);
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
const contentLength = Number.parseInt(headerValue(response.headers["content-length"]), 10);
|
|
399
|
-
if (Number.isFinite(contentLength) && contentLength > MAX_WEB_BYTES) {
|
|
400
|
-
response.destroy();
|
|
401
|
-
throw new Error("Webpage is larger than Bro's 5 MiB download limit.");
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
const chunks: Buffer[] = [];
|
|
405
|
-
let size = 0;
|
|
406
|
-
try {
|
|
407
|
-
for await (const chunk of response) {
|
|
408
|
-
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
409
|
-
size += buffer.byteLength;
|
|
410
|
-
if (size > MAX_WEB_BYTES) throw new Error("Webpage is larger than Bro's 5 MiB download limit.");
|
|
411
|
-
chunks.push(buffer);
|
|
412
|
-
}
|
|
413
|
-
} catch (error) {
|
|
414
|
-
response.destroy();
|
|
415
|
-
throw error;
|
|
416
|
-
}
|
|
417
|
-
return Buffer.concat(chunks, size);
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
function decodeWebHtml(buffer: Buffer, contentType: string): string {
|
|
421
|
-
const headerCharset = /charset\s*=\s*["']?([^\s;"']+)/i.exec(contentType)?.[1];
|
|
422
|
-
const head = new TextDecoder("latin1").decode(buffer.subarray(0, 2048));
|
|
423
|
-
const metaCharset = /<meta[^>]+charset\s*=\s*["']?([^\s;"'>]+)/i.exec(head)?.[1]
|
|
424
|
-
?? /<meta[^>]+content\s*=\s*["'][^"']*charset=([^\s;"']+)/i.exec(head)?.[1];
|
|
425
|
-
const charset = headerCharset ?? metaCharset ?? "utf-8";
|
|
426
|
-
try {
|
|
427
|
-
return new TextDecoder(charset).decode(buffer);
|
|
428
|
-
} catch {
|
|
429
|
-
throw new Error(`Bro does not support this page's ${charset} character encoding.`);
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
function assertWebElementLimit(html: string): void {
|
|
434
|
-
let count = 0;
|
|
435
|
-
for (let index = 0; index < html.length - 1; index++) {
|
|
436
|
-
if (html.charCodeAt(index) !== 60) continue;
|
|
437
|
-
const next = html.charCodeAt(index + 1) | 32;
|
|
438
|
-
if (next >= 97 && next <= 122 && ++count > MAX_WEB_ELEMENTS) {
|
|
439
|
-
throw new Error("Webpage is too complex for Bro to read safely.");
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
async function fetchPublicHtml(startUrl: URL, signal: AbortSignal): Promise<{ html: string; url: URL }> {
|
|
445
|
-
let url = startUrl;
|
|
446
|
-
const visited = new Set<string>();
|
|
447
|
-
|
|
448
|
-
for (let redirects = 0; ; redirects++) {
|
|
449
|
-
if (visited.has(url.href)) throw new Error("Webpage redirect loop detected.");
|
|
450
|
-
visited.add(url.href);
|
|
451
|
-
const address = await resolvePublicAddress(url.hostname);
|
|
452
|
-
let response: IncomingMessage;
|
|
453
|
-
try {
|
|
454
|
-
response = await requestWebPage(url, address, signal);
|
|
455
|
-
} catch (error) {
|
|
456
|
-
throw new Error(`Could not fetch webpage: ${errorMessage(error)}`);
|
|
457
|
-
}
|
|
458
|
-
const status = response.statusCode ?? 0;
|
|
459
|
-
|
|
460
|
-
if (REDIRECT_STATUSES.has(status)) {
|
|
461
|
-
response.destroy();
|
|
462
|
-
if (redirects >= MAX_WEB_REDIRECTS) throw new Error("Webpage redirected too many times.");
|
|
463
|
-
const location = headerValue(response.headers.location);
|
|
464
|
-
if (!location) throw new Error(`Webpage returned HTTP ${status} without a redirect location.`);
|
|
465
|
-
url = parseWebRedirect(url, location);
|
|
466
|
-
continue;
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
if (status < 200 || status >= 300) {
|
|
470
|
-
response.destroy();
|
|
471
|
-
if (status === 401 || status === 403) {
|
|
472
|
-
throw new Error(`Webpage returned HTTP ${status}. It may require a login or block automated readers.`);
|
|
473
|
-
}
|
|
474
|
-
if (status === 429) throw new Error("Webpage returned HTTP 429 and is limiting automated requests.");
|
|
475
|
-
throw new Error(`Webpage returned HTTP ${status}.`);
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
const contentType = headerValue(response.headers["content-type"]);
|
|
479
|
-
const mime = contentType.split(";", 1)[0].trim().toLowerCase();
|
|
480
|
-
if (mime !== "text/html" && mime !== "application/xhtml+xml") {
|
|
481
|
-
response.destroy();
|
|
482
|
-
throw new Error(`Unsupported webpage content type: ${mime || "missing"}.`);
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
const html = decodeWebHtml(await readWebBody(response), contentType);
|
|
486
|
-
assertWebElementLimit(html);
|
|
487
|
-
return { html, url };
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
export async function extractWebHtml(html: string, url: string): Promise<BroSource> {
|
|
492
|
-
assertWebElementLimit(html);
|
|
493
|
-
const parsedUrl = parseWebUrl(url);
|
|
494
|
-
const { document } = parseHTML(html);
|
|
495
|
-
const result = await Defuddle(document, parsedUrl.href, {
|
|
496
|
-
markdown: true,
|
|
497
|
-
removeImages: true,
|
|
498
|
-
includeReplies: false,
|
|
499
|
-
useAsync: false,
|
|
500
|
-
});
|
|
501
|
-
const text = (result.contentMarkdown || result.content || "").trim();
|
|
502
|
-
if (!text) {
|
|
503
|
-
throw new Error("Bro found no readable page content. The page may require JavaScript, a login, or block automated readers.");
|
|
504
|
-
}
|
|
505
|
-
if (text.length > MAX_TEXT_LENGTH) {
|
|
506
|
-
throw new Error("Extracted webpage text is longer than Bro's 100,000-character limit.");
|
|
507
|
-
}
|
|
508
|
-
const title = result.title
|
|
509
|
-
? stripVTControlCharacters(result.title).replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").replace(/\s+/g, " ").trim().slice(0, 200)
|
|
510
|
-
: undefined;
|
|
511
|
-
return { text, label: [parsedUrl.hostname, title].filter(Boolean).join(" · ") };
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
export async function extractWebPage(input: string, signal?: AbortSignal): Promise<BroSource> {
|
|
515
|
-
const timeout = AbortSignal.timeout(WEB_TIMEOUT_MS);
|
|
516
|
-
const combinedSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
517
|
-
try {
|
|
518
|
-
const fetched = await fetchPublicHtml(parseWebUrl(input), combinedSignal);
|
|
519
|
-
return await extractWebHtml(fetched.html, fetched.url.href);
|
|
520
|
-
} catch (error) {
|
|
521
|
-
if (signal?.aborted) throw new Error("Canceled.");
|
|
522
|
-
if (timeout.aborted) throw new Error("Webpage took longer than 25 seconds to respond.");
|
|
523
|
-
throw error;
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
function parseBackend(value: unknown, context: string): BackendName {
|
|
528
|
-
if (typeof value !== "string" || !BACKENDS.some((backend) => backend === value)) {
|
|
529
|
-
throw new Error(`${context} backend must be "agy", "claude", "grok", "codex", or "muse".`);
|
|
530
|
-
}
|
|
531
|
-
return value as BackendName;
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
function isAgyEffort(effort: unknown): effort is AgyEffort | "default" {
|
|
535
|
-
return EFFORTS.some((item) => item === effort);
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
function isClaudeEffort(effort: unknown): effort is ClaudeEffort | "default" {
|
|
539
|
-
return effort === "default" || CLAUDE_EFFORTS.some((item) => item === effort);
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
function isGrokEffort(effort: unknown): effort is GrokEffort | "default" {
|
|
543
|
-
return effort === "default" || GROK_EFFORTS.some((item) => item === effort);
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
function isCodexEffort(effort: unknown): effort is CodexEffort | "default" {
|
|
547
|
-
return effort === "default" || CODEX_EFFORTS.some((item) => item === effort);
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
function isMuseEffort(effort: unknown): effort is MuseEffort | "default" {
|
|
551
|
-
return effort === "default" || MUSE_EFFORTS.some((item) => item === effort);
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
function parseModelEffortPair(value: unknown, context: string): ModelEffortPair {
|
|
555
|
-
if (!isRecord(value) || typeof value.model !== "string" || !value.model.trim()) {
|
|
556
|
-
throw new Error(`${context} must contain a model and effort set to "default", "low", "medium", or "high".`);
|
|
557
|
-
}
|
|
558
|
-
const backend = value.backend === undefined ? undefined : parseBackend(value.backend, context);
|
|
559
|
-
if (backend === "claude") {
|
|
560
|
-
if (!isClaudeEffort(value.effort)) {
|
|
561
|
-
throw new Error(`${context} must contain a model and effort set to "default", "low", "medium", "high", "xhigh", or "max".`);
|
|
562
|
-
}
|
|
563
|
-
return { backend, model: value.model.trim(), effort: value.effort };
|
|
564
|
-
}
|
|
565
|
-
if (backend === "grok") {
|
|
566
|
-
if (!isGrokEffort(value.effort)) {
|
|
567
|
-
throw new Error(`${context} must contain a model and effort set to "default", "low", "medium", "high", or "xhigh".`);
|
|
568
|
-
}
|
|
569
|
-
return { backend, model: value.model.trim(), effort: value.effort };
|
|
570
|
-
}
|
|
571
|
-
if (backend === "codex") {
|
|
572
|
-
if (!isCodexEffort(value.effort)) {
|
|
573
|
-
throw new Error(`${context} must contain a model and effort set to "default", "low", "medium", "high", or "xhigh".`);
|
|
574
|
-
}
|
|
575
|
-
return { backend, model: value.model.trim(), effort: value.effort };
|
|
576
|
-
}
|
|
577
|
-
if (backend === "muse") {
|
|
578
|
-
if (!isMuseEffort(value.effort)) {
|
|
579
|
-
throw new Error(`${context} must contain a model and effort set to "default", "minimal", "low", "medium", "high", "xhigh", or "max".`);
|
|
580
|
-
}
|
|
581
|
-
return { backend, model: value.model.trim(), effort: value.effort };
|
|
582
|
-
}
|
|
583
|
-
if (!isAgyEffort(value.effort)) {
|
|
584
|
-
throw new Error(`${context} must contain a model and effort set to "default", "low", "medium", or "high".`);
|
|
585
|
-
}
|
|
586
|
-
const pair: ModelEffortPair = { model: value.model.trim(), effort: value.effort };
|
|
587
|
-
if (backend !== undefined) pair.backend = backend;
|
|
588
|
-
return pair;
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
function parseOverrides(value: unknown): Partial<Record<Capability, ModelEffortPair>> {
|
|
592
|
-
if (value === undefined) return {};
|
|
593
|
-
if (!isRecord(value)) throw new Error("Settings overrides must be an object.");
|
|
594
|
-
const overrides: Partial<Record<Capability, ModelEffortPair>> = {};
|
|
595
|
-
for (const capability of CAPABILITIES) {
|
|
596
|
-
if (value[capability] === undefined) continue;
|
|
597
|
-
overrides[capability] = parseModelEffortPair(value[capability], `Settings overrides.${capability}`);
|
|
598
|
-
}
|
|
599
|
-
return overrides;
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
export function parseBroSettings(value: unknown): BroSettings {
|
|
603
|
-
if (!isRecord(value)) {
|
|
604
|
-
throw new Error('Settings must contain a model and effort set to "default", "low", "medium", or "high".');
|
|
605
|
-
}
|
|
606
|
-
// v2 disk shape: { version: 2, default: { backend, model, effort }, mode, showTurns, overrides }.
|
|
607
|
-
// Reads never rewrite: a legacy flat file keeps parsing into backend-less (Agy) settings.
|
|
608
|
-
if (value.version !== undefined && value.version !== 2) throw new Error(`Unsupported settings version ${JSON.stringify(value.version)}.`);
|
|
609
|
-
if (value.version === 2 && value.default === undefined) throw new Error("Settings version 2 requires default.");
|
|
610
|
-
if (value.default !== undefined) {
|
|
611
|
-
if (!isRecord(value.default)) throw new Error("Settings default must contain a model and effort.");
|
|
612
|
-
if (value.version !== undefined && value.version !== 2) {
|
|
613
|
-
throw new Error(`Unsupported settings version ${JSON.stringify(value.version)}.`);
|
|
614
|
-
}
|
|
615
|
-
const pair = parseModelEffortPair(value.default, "Settings default");
|
|
616
|
-
const mode = value.mode === undefined ? DEFAULT_BRO_MODE : parseBroMode(value.mode);
|
|
617
|
-
if (!mode) throw new Error('Settings mode must be "brief", "balanced", or "faithful".');
|
|
618
|
-
const showTurns = value.showTurns === undefined ? DEFAULT_SHOW_TURNS : value.showTurns;
|
|
619
|
-
if (typeof showTurns !== "number" || !Number.isInteger(showTurns) || showTurns < 1) {
|
|
620
|
-
throw new Error("Settings showTurns must be a positive whole number of turns.");
|
|
621
|
-
}
|
|
622
|
-
const overrides = parseOverrides(value.overrides);
|
|
623
|
-
const settings: BroSettings = { model: pair.model, effort: pair.effort, mode, showTurns, overrides };
|
|
624
|
-
if (pair.backend !== undefined) settings.backend = pair.backend;
|
|
625
|
-
return settings;
|
|
626
|
-
}
|
|
627
|
-
if (typeof value.model !== "string" || !value.model.trim()) {
|
|
628
|
-
throw new Error('Settings must contain a model and effort set to "default", "low", "medium", or "high".');
|
|
629
|
-
}
|
|
630
|
-
const backend = value.backend === undefined ? undefined : parseBackend(value.backend, "Settings");
|
|
631
|
-
const effortOk =
|
|
632
|
-
backend === "claude"
|
|
633
|
-
? isClaudeEffort(value.effort)
|
|
634
|
-
: backend === "grok"
|
|
635
|
-
? isGrokEffort(value.effort)
|
|
636
|
-
: backend === "codex"
|
|
637
|
-
? isCodexEffort(value.effort)
|
|
638
|
-
: backend === "muse"
|
|
639
|
-
? isMuseEffort(value.effort)
|
|
640
|
-
: isAgyEffort(value.effort);
|
|
641
|
-
if (!effortOk) {
|
|
642
|
-
throw new Error(
|
|
643
|
-
backend === "claude"
|
|
644
|
-
? 'Settings must contain a model and effort set to "default", "low", "medium", "high", "xhigh", or "max".'
|
|
645
|
-
: backend === "grok" || backend === "codex"
|
|
646
|
-
? 'Settings must contain a model and effort set to "default", "low", "medium", "high", or "xhigh".'
|
|
647
|
-
: backend === "muse"
|
|
648
|
-
? 'Settings must contain a model and effort set to "default", "minimal", "low", "medium", "high", "xhigh", or "max".'
|
|
649
|
-
: 'Settings must contain a model and effort set to "default", "low", "medium", or "high".',
|
|
650
|
-
);
|
|
651
|
-
}
|
|
652
|
-
const mode = value.mode === undefined ? DEFAULT_BRO_MODE : parseBroMode(value.mode);
|
|
653
|
-
if (!mode) throw new Error('Settings mode must be "brief", "balanced", or "faithful".');
|
|
654
|
-
const showTurns = value.showTurns === undefined ? DEFAULT_SHOW_TURNS : value.showTurns;
|
|
655
|
-
if (typeof showTurns !== "number" || !Number.isInteger(showTurns) || showTurns < 1) {
|
|
656
|
-
throw new Error("Settings showTurns must be a positive whole number of turns.");
|
|
657
|
-
}
|
|
658
|
-
const overrides = parseOverrides(value.overrides);
|
|
659
|
-
const settings: BroSettings = {
|
|
660
|
-
model: value.model.trim(),
|
|
661
|
-
effort: value.effort as BroSettings["effort"],
|
|
662
|
-
mode,
|
|
663
|
-
showTurns,
|
|
664
|
-
overrides,
|
|
665
|
-
};
|
|
666
|
-
if (backend !== undefined) settings.backend = backend;
|
|
667
|
-
return settings;
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
function capabilityOverride(settings: BroSettings, capability: Capability): ModelEffortPair | undefined {
|
|
671
|
-
return settings.overrides[capability];
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
function capabilityPair(settings: BroSettings, capability: Capability): ModelEffortPair {
|
|
675
|
-
return capabilityOverride(settings, capability) ?? { ...(settings.backend ? { backend: settings.backend } : {}), model: settings.model, effort: settings.effort };
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
// An override always pins both model and effort together (never just one), so a capability's
|
|
679
|
-
// setting is either fully inherited or fully its own — no partial-inheritance edge cases.
|
|
680
|
-
//
|
|
681
|
-
// An override is cleared ONLY by an explicit "Default" selection (pair === undefined), never
|
|
682
|
-
// automatically because it happens to match the shared default: a user who deliberately pins a
|
|
683
|
-
// capability to the model that currently IS the shared default must keep that pin — unchanged —
|
|
684
|
-
// if the shared default is later changed to something else. Silently dropping an override that
|
|
685
|
-
// merely coincides with the default would make that pin impossible to express.
|
|
686
|
-
export function withCapabilityOverride(
|
|
687
|
-
settings: BroSettings,
|
|
688
|
-
capability: Capability,
|
|
689
|
-
pair: ModelEffortPair | undefined,
|
|
690
|
-
): BroSettings {
|
|
691
|
-
const overrides = { ...settings.overrides };
|
|
692
|
-
if (!pair) delete overrides[capability];
|
|
693
|
-
else overrides[capability] = pair;
|
|
694
|
-
return { ...settings, overrides };
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
export function resolveModelEffort(
|
|
698
|
-
pair: ModelEffortPair,
|
|
699
|
-
families: AgyModelFamily[],
|
|
700
|
-
): { pair: ModelEffortPair; family?: AgyModelFamily } {
|
|
701
|
-
// Claude/Grok/Codex/Muse selections never resolve through the Agy catalog; they pass through untouched.
|
|
702
|
-
if (pair.backend === "claude" || pair.backend === "grok" || pair.backend === "codex" || pair.backend === "muse") return { pair };
|
|
703
|
-
const family = families.find((item) => item.id === pair.model || item.variants.some((variant) => variant.id === pair.model));
|
|
704
|
-
if (!family) return { pair };
|
|
705
|
-
const variant = family.variants.find((item) => item.id === pair.model);
|
|
706
|
-
const resolved: ModelEffortPair = {
|
|
707
|
-
model: family.id,
|
|
708
|
-
effort: pair.effort === "default" && variant?.effort ? variant.effort : pair.effort,
|
|
709
|
-
};
|
|
710
|
-
if (pair.backend !== undefined) resolved.backend = pair.backend;
|
|
711
|
-
return { family, pair: resolved };
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
// A capability's effective backend: an explicit override is a complete selection, so a
|
|
715
|
-
// backend-less override still means Agy (all legacy stays Agy) and never inherits the
|
|
716
|
-
// shared default's backend. Only a capability without an override inherits the default.
|
|
717
|
-
export function capabilityBackend(settings: BroSettings, capability: Capability): BackendName {
|
|
718
|
-
const override = settings.overrides[capability];
|
|
719
|
-
if (override) return override.backend ?? "agy";
|
|
720
|
-
return settings.backend ?? "agy";
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
// Sonnet/opus aliases resolve case-insensitively; any other non-empty string passes
|
|
724
|
-
// through untouched as an explicit user-entered model ID.
|
|
725
|
-
export function resolveClaudeModel(input: string): string {
|
|
726
|
-
const trimmed = input.trim();
|
|
727
|
-
if (!trimmed) throw new Error("Claude model must be a non-empty model ID (sonnet, opus, or an explicit model ID).");
|
|
728
|
-
const alias = CLAUDE_MODELS.find((model) => model.id === trimmed.toLowerCase());
|
|
729
|
-
return alias ? alias.id : trimmed;
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
// Grok seeds resolve to themselves; any other non-empty string passes through
|
|
733
|
-
// untouched as an explicit user-entered model ID (installed `grok models` confirms).
|
|
734
|
-
export function resolveGrokModel(input: string): string {
|
|
735
|
-
const trimmed = input.trim();
|
|
736
|
-
if (!trimmed) throw new Error("Grok model must be a non-empty model ID (grok-4.7, grok-4.7-build-fast, or an explicit model ID).");
|
|
737
|
-
return trimmed;
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
// Codex seeds resolve to themselves; any other non-empty string passes through
|
|
741
|
-
// untouched as an explicit user-entered model ID.
|
|
742
|
-
export function resolveCodexModel(input: string): string {
|
|
743
|
-
const trimmed = input.trim();
|
|
744
|
-
if (!trimmed) throw new Error("Codex model must be a non-empty model ID (gpt-5.5, gpt-5.4, or an explicit model ID).");
|
|
745
|
-
return trimmed;
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
// Muse seeds resolve to themselves; any other non-empty string passes through
|
|
749
|
-
// untouched as an explicit user-entered model ID.
|
|
750
|
-
export function resolveMuseModel(input: string): string {
|
|
751
|
-
const trimmed = input.trim();
|
|
752
|
-
if (!trimmed) throw new Error("Muse model must be a non-empty model ID (muse-spark-1.3-contributor, muse-spark-1.3, or an explicit model ID).");
|
|
753
|
-
return trimmed;
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
// Routes one capability's whole pair to its backend execution selection. Agy keeps the
|
|
757
|
-
// existing model/effort split; Claude/Grok/Codex/Muse carry the model plus an optional effort
|
|
758
|
-
// ("default" means the CLI's own default and is omitted). Throws for an effort the
|
|
759
|
-
// resolved backend does not support instead of silently sending a mismatched pair.
|
|
760
|
-
export function selectionForCapability(settings: BroSettings, capability: Capability): BackendSelection {
|
|
761
|
-
const pair = capabilityPair(settings, capability);
|
|
762
|
-
const backend = capabilityBackend(settings, capability);
|
|
763
|
-
if (backend === "claude" || backend === "grok" || backend === "codex" || backend === "muse") {
|
|
764
|
-
const valid =
|
|
765
|
-
backend === "claude"
|
|
766
|
-
? isClaudeEffort(pair.effort)
|
|
767
|
-
: backend === "grok"
|
|
768
|
-
? isGrokEffort(pair.effort)
|
|
769
|
-
: backend === "codex"
|
|
770
|
-
? isCodexEffort(pair.effort)
|
|
771
|
-
: isMuseEffort(pair.effort);
|
|
772
|
-
if (!valid) {
|
|
773
|
-
const label = backend === "claude" ? "Claude" : backend === "grok" ? "Grok" : backend === "codex" ? "Codex" : "Muse";
|
|
774
|
-
throw new Error(`\`${pair.effort}\` is not supported on the ${label} backend. Run \`/bro config\` to fix this.`);
|
|
775
|
-
}
|
|
776
|
-
return (
|
|
777
|
-
pair.effort === "default"
|
|
778
|
-
? { backend, model: pair.model }
|
|
779
|
-
: { backend, model: pair.model, effort: pair.effort }
|
|
780
|
-
) as BackendSelection;
|
|
781
|
-
}
|
|
782
|
-
if (!isAgyEffort(pair.effort)) {
|
|
783
|
-
throw new Error(`\`${pair.effort}\` is not supported on the Agy backend. Run \`/bro config\` to fix this.`);
|
|
784
|
-
}
|
|
785
|
-
return agySelection({ model: pair.model, effort: pair.effort });
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
// The modal label for the exact selection a request runs with: model plus effort, where an
|
|
789
|
-
// omitted effort (the model's own default) reads simply "default".
|
|
790
|
-
export function selectionLabel(selection: BackendSelection): string {
|
|
791
|
-
return `${selection.model} · ${selection.effort ?? "default"}`;
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
function resolveCapabilitySettings(
|
|
795
|
-
settings: BroSettings,
|
|
796
|
-
capability: Capability,
|
|
797
|
-
families: AgyModelFamily[],
|
|
798
|
-
): { pair: ModelEffortPair; family?: AgyModelFamily } {
|
|
799
|
-
return resolveModelEffort(capabilityPair(settings, capability), families);
|
|
800
|
-
}
|
|
801
113
|
|
|
802
114
|
// The steering brief is stored as a session `custom` entry — extension state that never
|
|
803
115
|
// participates in LLM context (see docs/plans/2026-09-19-bro-advisor-design.md). Writes go
|
|
@@ -830,52 +142,6 @@ export function resolveAdvisorState(branch: readonly SessionEntry[]): AdvisorSta
|
|
|
830
142
|
return { steering };
|
|
831
143
|
}
|
|
832
144
|
|
|
833
|
-
async function ensureSettingsFile(): Promise<void> {
|
|
834
|
-
await mkdir(AGENT_DIR, { recursive: true });
|
|
835
|
-
try {
|
|
836
|
-
await writeFile(
|
|
837
|
-
SETTINGS_FILE,
|
|
838
|
-
`${JSON.stringify({ model: DEFAULT_MODEL, effort: ENV_MODEL ? "default" : "low", mode: DEFAULT_BRO_MODE, showTurns: DEFAULT_SHOW_TURNS }, null, 2)}\n`,
|
|
839
|
-
{ encoding: "utf8", flag: "wx", mode: 0o600 },
|
|
840
|
-
);
|
|
841
|
-
} catch (error) {
|
|
842
|
-
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
843
|
-
}
|
|
844
|
-
}
|
|
845
|
-
|
|
846
|
-
async function readSettings(): Promise<BroSettings> {
|
|
847
|
-
await ensureSettingsFile();
|
|
848
|
-
try {
|
|
849
|
-
return parseBroSettings(JSON.parse(await readFile(SETTINGS_FILE, "utf8")));
|
|
850
|
-
} catch (error) {
|
|
851
|
-
if (error instanceof SyntaxError) throw new Error(`${SETTINGS_FILE} is not valid JSON.`);
|
|
852
|
-
if (error instanceof Error) throw new Error(`${SETTINGS_FILE}: ${error.message}`);
|
|
853
|
-
throw error;
|
|
854
|
-
}
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
// Exported so persistence stays testable without touching the filesystem. This only ever omits
|
|
858
|
-
// the `overrides` key itself when there are no overrides at all — it does NOT deduplicate or drop
|
|
859
|
-
// any individual override that happens to match the shared default; see withCapabilityOverride
|
|
860
|
-
// for why an explicit override is always kept until the user clears it back to "Default".
|
|
861
|
-
export function settingsPayload(settings: BroSettings): Record<string, unknown> {
|
|
862
|
-
const overrides: Record<string, unknown> = {};
|
|
863
|
-
for (const [capability, pair] of Object.entries(settings.overrides)) {
|
|
864
|
-
if (pair) overrides[capability] = { backend: pair.backend ?? "agy", model: pair.model, effort: pair.effort };
|
|
865
|
-
}
|
|
866
|
-
return {
|
|
867
|
-
version: 2,
|
|
868
|
-
default: { backend: settings.backend ?? "agy", model: settings.model, effort: settings.effort },
|
|
869
|
-
mode: settings.mode,
|
|
870
|
-
showTurns: settings.showTurns,
|
|
871
|
-
...(Object.keys(overrides).length ? { overrides } : {}),
|
|
872
|
-
};
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
async function writeSettings(settings: BroSettings): Promise<void> {
|
|
876
|
-
// ponytail: last writer wins across concurrent Pi processes; add locking only if that becomes a common workflow.
|
|
877
|
-
await writeFile(SETTINGS_FILE, `${JSON.stringify(settingsPayload(settings), null, 2)}\n`, "utf8");
|
|
878
|
-
}
|
|
879
145
|
|
|
880
146
|
export function formatAgyUsage(value: unknown): string {
|
|
881
147
|
if (!isRecord(value) || value.status !== "SUCCESS" || typeof value.response !== "string") {
|
|
@@ -977,25 +243,21 @@ function resolveCatalogSettings(
|
|
|
977
243
|
return { family: resolved.family, settings: { ...settings, ...resolved.pair } };
|
|
978
244
|
}
|
|
979
245
|
|
|
980
|
-
function
|
|
981
|
-
return family.efforts.includes("low") ? "low" : (family.efforts[0] ?? "default");
|
|
982
|
-
}
|
|
983
|
-
|
|
984
|
-
async function checkClaudeVersion(pi: ExtensionAPI, signal: AbortSignal): Promise<string> {
|
|
246
|
+
async function checkCliVersion(pi: ExtensionAPI, binary: string, displayName: string, signal: AbortSignal): Promise<string> {
|
|
985
247
|
const runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
|
|
986
248
|
try {
|
|
987
|
-
const result = await pi.exec(
|
|
249
|
+
const result = await pi.exec(binary, ["--version"], { cwd: runDirectory, signal, timeout: 10_000 });
|
|
988
250
|
if (signal.aborted) throw new Error("Canceled.");
|
|
989
251
|
if (result.killed || result.code !== 0) {
|
|
990
252
|
const detail = result.stderr.trim() || result.stdout.trim();
|
|
991
253
|
throw new Error(
|
|
992
254
|
detail
|
|
993
|
-
?
|
|
994
|
-
:
|
|
255
|
+
? `${displayName} could not start: ${detail}\n\nRun \`/bro doctor\` for setup help.`
|
|
256
|
+
: `${displayName} could not start. Make sure ${displayName} is installed and on PATH, then run \`/bro doctor\`.`,
|
|
995
257
|
);
|
|
996
258
|
}
|
|
997
259
|
const version = result.stdout.trim() || result.stderr.trim();
|
|
998
|
-
if (!version) throw new Error(
|
|
260
|
+
if (!version) throw new Error(`${displayName} returned no version information. Update ${displayName}, then run \`/bro doctor\` again.`);
|
|
999
261
|
return version;
|
|
1000
262
|
} finally {
|
|
1001
263
|
await rm(runDirectory, { recursive: true, force: true });
|
|
@@ -1022,48 +284,6 @@ async function checkClaudeAuth(pi: ExtensionAPI, signal: AbortSignal): Promise<s
|
|
|
1022
284
|
}
|
|
1023
285
|
}
|
|
1024
286
|
|
|
1025
|
-
async function checkGrokVersion(pi: ExtensionAPI, signal: AbortSignal): Promise<string> {
|
|
1026
|
-
const runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
|
|
1027
|
-
try {
|
|
1028
|
-
const result = await pi.exec("grok", ["--version"], { cwd: runDirectory, signal, timeout: 10_000 });
|
|
1029
|
-
if (signal.aborted) throw new Error("Canceled.");
|
|
1030
|
-
if (result.killed || result.code !== 0) {
|
|
1031
|
-
const detail = result.stderr.trim() || result.stdout.trim();
|
|
1032
|
-
throw new Error(
|
|
1033
|
-
detail
|
|
1034
|
-
? `Grok could not start: ${detail}\n\nRun \`/bro doctor\` for setup help.`
|
|
1035
|
-
: "Grok could not start. Make sure Grok is installed and on PATH, then run `/bro doctor`.",
|
|
1036
|
-
);
|
|
1037
|
-
}
|
|
1038
|
-
const version = result.stdout.trim() || result.stderr.trim();
|
|
1039
|
-
if (!version) throw new Error("Grok returned no version information. Update Grok, then run `/bro doctor` again.");
|
|
1040
|
-
return version;
|
|
1041
|
-
} finally {
|
|
1042
|
-
await rm(runDirectory, { recursive: true, force: true });
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1046
|
-
async function checkCodexVersion(pi: ExtensionAPI, signal: AbortSignal): Promise<string> {
|
|
1047
|
-
const runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
|
|
1048
|
-
try {
|
|
1049
|
-
const result = await pi.exec("codex", ["--version"], { cwd: runDirectory, signal, timeout: 10_000 });
|
|
1050
|
-
if (signal.aborted) throw new Error("Canceled.");
|
|
1051
|
-
if (result.killed || result.code !== 0) {
|
|
1052
|
-
const detail = result.stderr.trim() || result.stdout.trim();
|
|
1053
|
-
throw new Error(
|
|
1054
|
-
detail
|
|
1055
|
-
? `Codex could not start: ${detail}\n\nRun \`/bro doctor\` for setup help.`
|
|
1056
|
-
: "Codex could not start. Make sure Codex is installed and on PATH, then run `/bro doctor`.",
|
|
1057
|
-
);
|
|
1058
|
-
}
|
|
1059
|
-
const version = result.stdout.trim() || result.stderr.trim();
|
|
1060
|
-
if (!version) throw new Error("Codex returned no version information. Update Codex, then run `/bro doctor` again.");
|
|
1061
|
-
return version;
|
|
1062
|
-
} finally {
|
|
1063
|
-
await rm(runDirectory, { recursive: true, force: true });
|
|
1064
|
-
}
|
|
1065
|
-
}
|
|
1066
|
-
|
|
1067
287
|
// Auth status only: a version/auth answer without a model request. A passing answer here
|
|
1068
288
|
// says the CLI starts and reports signed-in state -- it does not imply connectivity.
|
|
1069
289
|
async function checkCodexAuth(pi: ExtensionAPI, signal: AbortSignal): Promise<string> {
|
|
@@ -1083,27 +303,6 @@ async function checkCodexAuth(pi: ExtensionAPI, signal: AbortSignal): Promise<st
|
|
|
1083
303
|
}
|
|
1084
304
|
}
|
|
1085
305
|
|
|
1086
|
-
async function checkMuseVersion(pi: ExtensionAPI, signal: AbortSignal): Promise<string> {
|
|
1087
|
-
const runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
|
|
1088
|
-
try {
|
|
1089
|
-
const result = await pi.exec("muse", ["--version"], { cwd: runDirectory, signal, timeout: 10_000 });
|
|
1090
|
-
if (signal.aborted) throw new Error("Canceled.");
|
|
1091
|
-
if (result.killed || result.code !== 0) {
|
|
1092
|
-
const detail = result.stderr.trim() || result.stdout.trim();
|
|
1093
|
-
throw new Error(
|
|
1094
|
-
detail
|
|
1095
|
-
? `Muse could not start: ${detail}\n\nRun \`/bro doctor\` for setup help.`
|
|
1096
|
-
: "Muse could not start. Make sure Muse is installed and on PATH, then run `/bro doctor`.",
|
|
1097
|
-
);
|
|
1098
|
-
}
|
|
1099
|
-
const version = result.stdout.trim() || result.stderr.trim();
|
|
1100
|
-
if (!version) throw new Error("Muse returned no version information. Update Muse, then run `/bro doctor` again.");
|
|
1101
|
-
return version;
|
|
1102
|
-
} finally {
|
|
1103
|
-
await rm(runDirectory, { recursive: true, force: true });
|
|
1104
|
-
}
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
306
|
async function checkAgyUsage(pi: ExtensionAPI, signal: AbortSignal): Promise<string> {
|
|
1108
307
|
const runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
|
|
1109
308
|
try {
|
|
@@ -1124,7 +323,7 @@ async function checkAgyUsage(pi: ExtensionAPI, signal: AbortSignal): Promise<str
|
|
|
1124
323
|
}
|
|
1125
324
|
}
|
|
1126
325
|
|
|
1127
|
-
async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, signal: AbortSignal): Promise<string> {
|
|
326
|
+
export async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, signal: AbortSignal): Promise<string> {
|
|
1128
327
|
const lines: string[] = [];
|
|
1129
328
|
let failed = false;
|
|
1130
329
|
let settings: BroSettings | undefined;
|
|
@@ -1152,11 +351,14 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
|
|
|
1152
351
|
|
|
1153
352
|
// Probe only the backends some feature actually selects: a Claude/Grok/Codex/Muse-only setup never
|
|
1154
353
|
// requires Agy to be installed, and vice versa.
|
|
1155
|
-
const
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
const
|
|
1159
|
-
const
|
|
354
|
+
const backendInUse = (backend: BackendName) => settings
|
|
355
|
+
? CAPABILITIES.some(capability => capabilityBackend(settings, capability) === backend)
|
|
356
|
+
: backend === "agy";
|
|
357
|
+
const agyInUse = backendInUse("agy");
|
|
358
|
+
const claudeInUse = backendInUse("claude");
|
|
359
|
+
const grokInUse = backendInUse("grok");
|
|
360
|
+
const codexInUse = backendInUse("codex");
|
|
361
|
+
const museInUse = backendInUse("muse");
|
|
1160
362
|
|
|
1161
363
|
let agyStarted = false;
|
|
1162
364
|
if (agyInUse) {
|
|
@@ -1194,7 +396,7 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
|
|
|
1194
396
|
// starts and reports signed-in state -- it does not imply connectivity.
|
|
1195
397
|
if (claudeInUse) {
|
|
1196
398
|
try {
|
|
1197
|
-
pass("Claude", await
|
|
399
|
+
pass("Claude", await checkCliVersion(pi, "claude", "Claude", signal));
|
|
1198
400
|
} catch (error) {
|
|
1199
401
|
if (signal.aborted) throw error;
|
|
1200
402
|
fail("Claude", error);
|
|
@@ -1213,7 +415,7 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
|
|
|
1213
415
|
// version says the CLI starts — it says nothing about auth or connectivity.
|
|
1214
416
|
if (grokInUse) {
|
|
1215
417
|
try {
|
|
1216
|
-
pass("Grok", await
|
|
418
|
+
pass("Grok", await checkCliVersion(pi, "grok", "Grok", signal));
|
|
1217
419
|
} catch (error) {
|
|
1218
420
|
if (signal.aborted) throw error;
|
|
1219
421
|
fail("Grok", error);
|
|
@@ -1226,7 +428,7 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
|
|
|
1226
428
|
// Version and auth status only, never a model request.
|
|
1227
429
|
if (codexInUse) {
|
|
1228
430
|
try {
|
|
1229
|
-
pass("Codex", await
|
|
431
|
+
pass("Codex", await checkCliVersion(pi, "codex", "Codex", signal));
|
|
1230
432
|
} catch (error) {
|
|
1231
433
|
if (signal.aborted) throw error;
|
|
1232
434
|
fail("Codex", error);
|
|
@@ -1245,7 +447,7 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
|
|
|
1245
447
|
// version says the CLI starts — it says nothing about auth or connectivity.
|
|
1246
448
|
if (museInUse) {
|
|
1247
449
|
try {
|
|
1248
|
-
pass("Muse", await
|
|
450
|
+
pass("Muse", await checkCliVersion(pi, "muse", "Muse", signal));
|
|
1249
451
|
} catch (error) {
|
|
1250
452
|
if (signal.aborted) throw error;
|
|
1251
453
|
fail("Muse", error);
|
|
@@ -1315,6 +517,8 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
|
|
|
1315
517
|
const override = capabilityOverride(settings, capability);
|
|
1316
518
|
const backend = capabilityBackend(settings, capability);
|
|
1317
519
|
const pair = capabilityPair(settings, capability);
|
|
520
|
+
const reviewError = capability === "review" ? reviewBackendError(backend) : undefined;
|
|
521
|
+
if (reviewError) { lines.push(`- ℹ **${label}:** needs a Claude or Muse override in \`/bro config\` for model calls; saved reviews remain readable.`); continue; }
|
|
1318
522
|
if (backend === "claude") {
|
|
1319
523
|
if (!isClaudeEffort(pair.effort)) {
|
|
1320
524
|
fail(label, `\`${pair.effort}\` is unsupported for claude \`${pair.model}\`. Run \`/bro config\` to fix this.`);
|
|
@@ -1396,7 +600,14 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
|
|
|
1396
600
|
const advisorActive = pi.getActiveTools().includes(ADVISOR_TOOL_NAME);
|
|
1397
601
|
if (advisorExposed && !advisorActive) fail("Advisor tool", "bro_advisor is exposed but not active in this session. Run /reload.");
|
|
1398
602
|
else pass("Advisor tool", advisorExposed ? "bro_advisor is exposed and active" : "bro_advisor is not exposed by this host (tool restriction, or the extension has not finished loading)");
|
|
1399
|
-
|
|
603
|
+
const sessionSteering = resolveAdvisorState(ctx.sessionManager.getBranch()).steering.trim();
|
|
604
|
+
pass("Advisor steering (session)", sessionSteering ? `${sessionSteering.length.toLocaleString("en-US")} characters · /bro advisor-steer` : "none — /bro advisor-steer to set for this session");
|
|
605
|
+
try {
|
|
606
|
+
const durable = await readAdvisorSteering();
|
|
607
|
+
pass("Advisor steering (durable)", durable ? `${durable.length.toLocaleString("en-US")} characters · ${ADVISOR_STEERING_FILE}` : `none — ${ADVISOR_STEERING_FILE}`);
|
|
608
|
+
} catch (error) {
|
|
609
|
+
fail("Advisor steering (durable)", error);
|
|
610
|
+
}
|
|
1400
611
|
if (settings && capabilityBackend(settings, "advisor") === "claude") {
|
|
1401
612
|
pass("Advisor compatibility", "Claude backend — no Agy version floor applies");
|
|
1402
613
|
} else if (settings && capabilityBackend(settings, "advisor") === "grok") {
|
|
@@ -1629,6 +840,43 @@ export async function readPreferences(): Promise<string> {
|
|
|
1629
840
|
return text;
|
|
1630
841
|
}
|
|
1631
842
|
|
|
843
|
+
// Raw durable advisor steering text, undefined when there is no file. Rejects non-regular files and
|
|
844
|
+
// files > 64 KB before read.
|
|
845
|
+
export async function readAdvisorSteeringRaw(): Promise<string | undefined> {
|
|
846
|
+
let stats: Awaited<ReturnType<typeof stat>>;
|
|
847
|
+
try {
|
|
848
|
+
stats = await stat(ADVISOR_STEERING_FILE);
|
|
849
|
+
} catch (error) {
|
|
850
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
851
|
+
throw new Error(`Cannot read ${ADVISOR_STEERING_FILE}: ${errorMessage(error)}`);
|
|
852
|
+
}
|
|
853
|
+
if (!stats.isFile()) {
|
|
854
|
+
throw new Error(`${ADVISOR_STEERING_FILE} is not a regular file. Edit or delete it directly.`);
|
|
855
|
+
}
|
|
856
|
+
if (stats.size > MAX_ADVISOR_STEERING_FILE_BYTES) {
|
|
857
|
+
throw new Error(`${ADVISOR_STEERING_FILE} is larger than 64 KB. Edit or delete it directly.`);
|
|
858
|
+
}
|
|
859
|
+
try {
|
|
860
|
+
const bytes = await readFile(ADVISOR_STEERING_FILE);
|
|
861
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes).replace(/^\uFEFF/, "");
|
|
862
|
+
} catch (error) {
|
|
863
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
864
|
+
throw new Error(`Cannot read ${ADVISOR_STEERING_FILE}: ${errorMessage(error)}`);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// Durable steering for the advisor tool: "" when absent or blank. Re-read on every consultation.
|
|
869
|
+
// An oversize or unreadable file throws an actionable message for the executor agent before spawn.
|
|
870
|
+
export async function readAdvisorSteering(): Promise<string> {
|
|
871
|
+
const text = ((await readAdvisorSteeringRaw()) ?? "").trim();
|
|
872
|
+
if (text.length > MAX_ADVISOR_STEERING_CHARS) {
|
|
873
|
+
throw new Error(
|
|
874
|
+
`Bro advisor steering file ${ADVISOR_STEERING_FILE} is ${text.length.toLocaleString("en-US")} characters (keep under ${MAX_ADVISOR_STEERING_CHARS.toLocaleString("en-US")}). Tell the human to edit or delete it; retrying won't help.`
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
return text;
|
|
878
|
+
}
|
|
879
|
+
|
|
1632
880
|
async function simplify(
|
|
1633
881
|
response: string,
|
|
1634
882
|
signal: AbortSignal,
|
|
@@ -1638,7 +886,7 @@ async function simplify(
|
|
|
1638
886
|
): Promise<{ text: string; model: string; mode: BroMode; preferences: boolean }> {
|
|
1639
887
|
const selection = selectionForCapability(settings, "explain");
|
|
1640
888
|
const preferences = await readPreferences();
|
|
1641
|
-
const text = await
|
|
889
|
+
const text = await runBackendText(buildDefaultPrompt(response, mode, preferences), selection, signal, onProgress);
|
|
1642
890
|
return { text, model: selectionLabel(selection), mode, preferences: Boolean(preferences) };
|
|
1643
891
|
}
|
|
1644
892
|
|
|
@@ -1651,14 +899,14 @@ async function runShowExplanation(
|
|
|
1651
899
|
): Promise<{ text: string; model: string; preferences: boolean }> {
|
|
1652
900
|
const selection = selectionForCapability(settings, "show");
|
|
1653
901
|
const preferences = await readPreferences();
|
|
1654
|
-
const text = await
|
|
902
|
+
const text = await runBackendText(buildShowPrompt(transcript, steering, preferences), selection, signal, onProgress, "show");
|
|
1655
903
|
return { text, model: selectionLabel(selection), preferences: Boolean(preferences) };
|
|
1656
904
|
}
|
|
1657
905
|
|
|
1658
906
|
// Thin presentation-boundary wrapper around the shared backend: coalesces raw text progress to the
|
|
1659
|
-
// existing 75ms cadence
|
|
1660
|
-
//
|
|
1661
|
-
async function
|
|
907
|
+
// existing 75ms cadence and translates the backend's tagged outcome back into this function's
|
|
908
|
+
// existing throw-on-failure contract.
|
|
909
|
+
async function runBackendText(
|
|
1662
910
|
prompt: string,
|
|
1663
911
|
selection: BackendSelection,
|
|
1664
912
|
signal: AbortSignal,
|
|
@@ -1909,10 +1157,11 @@ export type AdvisorToolDetails = {
|
|
|
1909
1157
|
durationMs?: number;
|
|
1910
1158
|
cwd?: string;
|
|
1911
1159
|
steeringIncluded?: boolean;
|
|
1160
|
+
steeringSources?: { durable: boolean; session: boolean };
|
|
1912
1161
|
snapshotChars?: number;
|
|
1913
1162
|
broTruncated?: false;
|
|
1914
1163
|
omissions?: string;
|
|
1915
|
-
// Real
|
|
1164
|
+
// Real backend-reported activity for the current attempt only -- reset to empty whenever a new
|
|
1916
1165
|
// attempt (including a retry) starts, never carried over from a prior failed attempt.
|
|
1917
1166
|
activity?: string[];
|
|
1918
1167
|
lastActivityAt?: number;
|
|
@@ -1930,9 +1179,9 @@ export type AdvisorRunResult = { advice: string; attempts: number; durationMs: n
|
|
|
1930
1179
|
// keeps ticking independently.
|
|
1931
1180
|
const ADVISOR_ACTIVITY_THROTTLE_MS = 250;
|
|
1932
1181
|
|
|
1933
|
-
// `consult`/`delayFn` are injectable so tests can swap in a fake
|
|
1182
|
+
// `consult`/`delayFn` are injectable so tests can swap in a fake process spawn and a fake clock
|
|
1934
1183
|
// instead of spawning real processes and waiting 15 real seconds. Every attempt sends the
|
|
1935
|
-
// identical prompt/selection/cwd to a fresh, standalone
|
|
1184
|
+
// identical prompt/selection/cwd to a fresh, standalone backend process — never resumed via
|
|
1936
1185
|
// --conversation, even across retries.
|
|
1937
1186
|
export async function runAdvisorWithRetries(
|
|
1938
1187
|
prompt: string,
|
|
@@ -2053,475 +1302,6 @@ export function advisorAttemptLabel(details: AdvisorToolDetails): string {
|
|
|
2053
1302
|
return `Bro advisor · running · ${Math.floor(details.elapsedMs / 1_000)}s · attempt ${details.attempt}/${details.of} · ${activityLabel}`;
|
|
2054
1303
|
}
|
|
2055
1304
|
|
|
2056
|
-
const SHOW_TURNS_PRESETS = [1, 2, 3, 5, 8];
|
|
2057
|
-
|
|
2058
|
-
function showTurnsValues(current: number): string[] {
|
|
2059
|
-
return [...new Set([...SHOW_TURNS_PRESETS, current])].sort((a, b) => a - b).map(String);
|
|
2060
|
-
}
|
|
2061
|
-
|
|
2062
|
-
// A resolved pair's display string: distinguishes a model that is genuinely fixed-effort from
|
|
2063
|
-
// one that simply isn't in the current catalog (both used to render as "fixed", which read as
|
|
2064
|
-
// falsely healthy for an unavailable model), and flags a stored effort that isn't one of the
|
|
2065
|
-
// resolved family's supported efforts instead of silently showing it as if it were valid.
|
|
2066
|
-
function effortDisplay(resolved: { pair: ModelEffortPair; family?: AgyModelFamily }, backend: BackendName): string {
|
|
2067
|
-
// Claude/Grok/Codex/Muse selections never touch the Agy catalog: the stored effort is valid exactly
|
|
2068
|
-
// when it is one of that backend's levels (or "default" for the CLI's own default).
|
|
2069
|
-
if (backend === "claude") return isClaudeEffort(resolved.pair.effort) ? resolved.pair.effort : `${resolved.pair.effort} (unsupported)`;
|
|
2070
|
-
if (backend === "grok") return isGrokEffort(resolved.pair.effort) ? resolved.pair.effort : `${resolved.pair.effort} (unsupported)`;
|
|
2071
|
-
if (backend === "codex") return isCodexEffort(resolved.pair.effort) ? resolved.pair.effort : `${resolved.pair.effort} (unsupported)`;
|
|
2072
|
-
if (backend === "muse") return isMuseEffort(resolved.pair.effort) ? resolved.pair.effort : `${resolved.pair.effort} (unsupported)`;
|
|
2073
|
-
if (!resolved.family) return "unavailable";
|
|
2074
|
-
const fixed = !resolved.family.efforts.length;
|
|
2075
|
-
const valid = fixed ? resolved.pair.effort === "default" : resolved.family.efforts.includes(resolved.pair.effort as AgyEffort);
|
|
2076
|
-
if (!valid) return `${resolved.pair.effort} (unsupported)`;
|
|
2077
|
-
return fixed ? "fixed" : resolved.pair.effort;
|
|
2078
|
-
}
|
|
2079
|
-
|
|
2080
|
-
// Model-picker values that switch backend carry a "claude:"/"grok:"/"codex:"/"muse:" prefix so one
|
|
2081
|
-
// atomic picker commit changes backend+model together -- cancelling the picker (Esc)
|
|
2082
|
-
// leaves both unchanged via the existing submenu-cancel path.
|
|
2083
|
-
const CLAUDE_OPTION_PREFIX = "claude:";
|
|
2084
|
-
function parseClaudeOption(value: string): string | undefined {
|
|
2085
|
-
return value.startsWith(CLAUDE_OPTION_PREFIX) && value.length > CLAUDE_OPTION_PREFIX.length
|
|
2086
|
-
? value.slice(CLAUDE_OPTION_PREFIX.length)
|
|
2087
|
-
: undefined;
|
|
2088
|
-
}
|
|
2089
|
-
const GROK_OPTION_PREFIX = "grok:";
|
|
2090
|
-
function parseGrokOption(value: string): string | undefined {
|
|
2091
|
-
return value.startsWith(GROK_OPTION_PREFIX) && value.length > GROK_OPTION_PREFIX.length
|
|
2092
|
-
? value.slice(GROK_OPTION_PREFIX.length)
|
|
2093
|
-
: undefined;
|
|
2094
|
-
}
|
|
2095
|
-
const CODEX_OPTION_PREFIX = "codex:";
|
|
2096
|
-
function parseCodexOption(value: string): string | undefined {
|
|
2097
|
-
return value.startsWith(CODEX_OPTION_PREFIX) && value.length > CODEX_OPTION_PREFIX.length
|
|
2098
|
-
? value.slice(CODEX_OPTION_PREFIX.length)
|
|
2099
|
-
: undefined;
|
|
2100
|
-
}
|
|
2101
|
-
const MUSE_OPTION_PREFIX = "muse:";
|
|
2102
|
-
function parseMuseOption(value: string): string | undefined {
|
|
2103
|
-
return value.startsWith(MUSE_OPTION_PREFIX) && value.length > MUSE_OPTION_PREFIX.length
|
|
2104
|
-
? value.slice(MUSE_OPTION_PREFIX.length)
|
|
2105
|
-
: undefined;
|
|
2106
|
-
}
|
|
2107
|
-
function parseBackendOption(value: string): { backend: "claude" | "grok" | "codex" | "muse"; id: string } | undefined {
|
|
2108
|
-
const claudeId = parseClaudeOption(value);
|
|
2109
|
-
if (claudeId !== undefined) return { backend: "claude", id: claudeId };
|
|
2110
|
-
const grokId = parseGrokOption(value);
|
|
2111
|
-
if (grokId !== undefined) return { backend: "grok", id: grokId };
|
|
2112
|
-
const codexId = parseCodexOption(value);
|
|
2113
|
-
if (codexId !== undefined) return { backend: "codex", id: codexId };
|
|
2114
|
-
const museId = parseMuseOption(value);
|
|
2115
|
-
if (museId !== undefined) return { backend: "muse", id: museId };
|
|
2116
|
-
return undefined;
|
|
2117
|
-
}
|
|
2118
|
-
|
|
2119
|
-
// Testable core: takes settings/catalog/persist as plain arguments so smoke tests can drive
|
|
2120
|
-
// the exact interaction (submenus, cancel, cycling, save failure) without a real Agy process
|
|
2121
|
-
// or settings file. showBroConfigModal below wires this to the real ctx/pi/filesystem.
|
|
2122
|
-
export function createConfigModal(
|
|
2123
|
-
initialSettings: BroSettings,
|
|
2124
|
-
families: AgyModelFamily[],
|
|
2125
|
-
persistSettings: (settings: BroSettings) => Promise<void>,
|
|
2126
|
-
): (tui: TuiLike, theme: Theme, keybindings: unknown, done: (value?: void) => void) => Component & { dispose?(): void } {
|
|
2127
|
-
return (tui, theme, _keybindings, done) => {
|
|
2128
|
-
let settings = initialSettings;
|
|
2129
|
-
// The last settings actually confirmed on disk. A failed save reverts `settings` (and the
|
|
2130
|
-
// whole displayed row set) back to this, so the screen never shows state that doesn't exist.
|
|
2131
|
-
let savedSettings = initialSettings;
|
|
2132
|
-
// Only one persistSettings call is ever in flight. A change that arrives while one is
|
|
2133
|
-
// already running is coalesced into `queued` (overwriting any earlier queued change) rather
|
|
2134
|
-
// than firing a second concurrent write — this is what keeps writes serialized and makes
|
|
2135
|
-
// sure the on-disk file always converges on the latest intent instead of a stale one that
|
|
2136
|
-
// happened to finish last.
|
|
2137
|
-
let saving = false;
|
|
2138
|
-
let queued: BroSettings | undefined;
|
|
2139
|
-
// Esc while a save is in flight must not close past an unshown result: it requests a close
|
|
2140
|
-
// that only actually happens once the in-flight (and any coalesced) save has settled, and
|
|
2141
|
-
// only if it succeeded — a failure cancels the pending close so its notice stays visible.
|
|
2142
|
-
let closeRequested = false;
|
|
2143
|
-
|
|
2144
|
-
const findFamily = (modelId: string) =>
|
|
2145
|
-
families.find((item) => item.id === modelId || item.variants.some((variant) => variant.id === modelId));
|
|
2146
|
-
|
|
2147
|
-
const modelPicker = (current: string, pickerDone: (value?: string) => void, capability?: Capability) => {
|
|
2148
|
-
const defaultResolved = resolveModelEffort({ model: settings.model, effort: settings.effort }, families);
|
|
2149
|
-
const options: SelectItem[] = [
|
|
2150
|
-
...(capability ? [{ value: "__default__", label: `Default (${defaultResolved.family?.label ?? settings.model})` }] : []),
|
|
2151
|
-
...families.map((family) => ({
|
|
2152
|
-
value: family.id,
|
|
2153
|
-
label: `${family.label}${family.efforts.length ? "" : " · fixed effort"}`,
|
|
2154
|
-
})),
|
|
2155
|
-
// Alternative CLI entries stay last so existing Agy keyboard navigation is unaffected.
|
|
2156
|
-
...CLAUDE_MODELS.map((model) => ({
|
|
2157
|
-
value: `${CLAUDE_OPTION_PREFIX}${model.id}`,
|
|
2158
|
-
label: `${model.label} · claude`,
|
|
2159
|
-
})),
|
|
2160
|
-
...GROK_MODELS.map((model) => ({
|
|
2161
|
-
value: `${GROK_OPTION_PREFIX}${model.id}`,
|
|
2162
|
-
label: `${model.label} · grok`,
|
|
2163
|
-
})),
|
|
2164
|
-
...CODEX_MODELS.map((model) => ({
|
|
2165
|
-
value: `${CODEX_OPTION_PREFIX}${model.id}`,
|
|
2166
|
-
label: `${model.label} · codex`,
|
|
2167
|
-
})),
|
|
2168
|
-
...MUSE_MODELS.map((model) => ({
|
|
2169
|
-
value: `${MUSE_OPTION_PREFIX}${model.id}`,
|
|
2170
|
-
label: `${model.label} · muse`,
|
|
2171
|
-
})),
|
|
2172
|
-
];
|
|
2173
|
-
options.push({ value: "__claude_custom__", label: "Claude · custom model ID…" });
|
|
2174
|
-
options.push({ value: "__grok_custom__", label: "Grok · custom model ID…" });
|
|
2175
|
-
options.push({ value: "__codex_custom__", label: "Codex · custom model ID…" });
|
|
2176
|
-
options.push({ value: "__muse_custom__", label: "Muse · custom model ID…" });
|
|
2177
|
-
const input = new Input();
|
|
2178
|
-
let enteringBackend: "claude" | "grok" | "codex" | "muse" | undefined;
|
|
2179
|
-
input.onSubmit = (value) => {
|
|
2180
|
-
if (value.trim() && enteringBackend) {
|
|
2181
|
-
const prefix =
|
|
2182
|
-
enteringBackend === "claude"
|
|
2183
|
-
? CLAUDE_OPTION_PREFIX
|
|
2184
|
-
: enteringBackend === "grok"
|
|
2185
|
-
? GROK_OPTION_PREFIX
|
|
2186
|
-
: enteringBackend === "codex"
|
|
2187
|
-
? CODEX_OPTION_PREFIX
|
|
2188
|
-
: MUSE_OPTION_PREFIX;
|
|
2189
|
-
pickerDone(`${prefix}${value.trim()}`);
|
|
2190
|
-
}
|
|
2191
|
-
};
|
|
2192
|
-
const picker = new SelectList(options, Math.min(options.length, 8), getSelectListTheme());
|
|
2193
|
-
const selectedIndex = capability && current === "Default" ? 0 : options.findIndex((option) => option.value === current);
|
|
2194
|
-
picker.setSelectedIndex(Math.max(0, selectedIndex));
|
|
2195
|
-
picker.onSelect = (item) => {
|
|
2196
|
-
if (item.value === "__claude_custom__") { enteringBackend = "claude"; tui.requestRender(); }
|
|
2197
|
-
else if (item.value === "__grok_custom__") { enteringBackend = "grok"; tui.requestRender(); }
|
|
2198
|
-
else if (item.value === "__codex_custom__") { enteringBackend = "codex"; tui.requestRender(); }
|
|
2199
|
-
else if (item.value === "__muse_custom__") { enteringBackend = "muse"; tui.requestRender(); }
|
|
2200
|
-
else pickerDone(item.value);
|
|
2201
|
-
};
|
|
2202
|
-
picker.onCancel = () => pickerDone();
|
|
2203
|
-
const customLabel = () => enteringBackend === "claude" ? "Claude" : enteringBackend === "grok" ? "Grok" : enteringBackend === "codex" ? "Codex" : "Muse";
|
|
2204
|
-
return {
|
|
2205
|
-
render: (width: number) => enteringBackend ? [`${customLabel()} model ID (Enter saves, Esc cancels)`, ...input.render(width)] : picker.render(width),
|
|
2206
|
-
invalidate: () => { picker.invalidate(); input.invalidate(); },
|
|
2207
|
-
handleInput: (data: string) => {
|
|
2208
|
-
if (enteringBackend && matchesKey(data, "escape")) pickerDone();
|
|
2209
|
-
else if (enteringBackend) input.handleInput(data);
|
|
2210
|
-
else picker.handleInput(data);
|
|
2211
|
-
},
|
|
2212
|
-
};
|
|
2213
|
-
};
|
|
2214
|
-
|
|
2215
|
-
const modelItem: SettingItem = { id: "model", label: "Default model", currentValue: settings.model, submenu: modelPicker };
|
|
2216
|
-
const effortItem: SettingItem = { id: "effort", label: "Default effort", currentValue: "" };
|
|
2217
|
-
const modeItem: SettingItem = { id: "mode", label: "Explain mode", currentValue: settings.mode, values: [...BRO_MODES] };
|
|
2218
|
-
const showTurnsItem: SettingItem = {
|
|
2219
|
-
id: "showTurns",
|
|
2220
|
-
label: "Show turns",
|
|
2221
|
-
currentValue: String(settings.showTurns),
|
|
2222
|
-
values: showTurnsValues(settings.showTurns),
|
|
2223
|
-
};
|
|
2224
|
-
const capabilityItems = Object.fromEntries(
|
|
2225
|
-
CAPABILITIES.map((capability) => [
|
|
2226
|
-
capability,
|
|
2227
|
-
{
|
|
2228
|
-
model: {
|
|
2229
|
-
id: `${capability}Model`,
|
|
2230
|
-
label: `${CAPABILITY_LABELS[capability]} model`,
|
|
2231
|
-
currentValue: "Default",
|
|
2232
|
-
submenu: (current: string, pickerDone: (value?: string) => void) => modelPicker(current, pickerDone, capability),
|
|
2233
|
-
} as SettingItem,
|
|
2234
|
-
effort: {
|
|
2235
|
-
id: `${capability}Effort`,
|
|
2236
|
-
label: `${CAPABILITY_LABELS[capability]} effort`,
|
|
2237
|
-
currentValue: "",
|
|
2238
|
-
} as SettingItem,
|
|
2239
|
-
},
|
|
2240
|
-
]),
|
|
2241
|
-
) as Record<Capability, { model: SettingItem; effort: SettingItem }>;
|
|
2242
|
-
|
|
2243
|
-
function refresh(): void {
|
|
2244
|
-
const defaultBackend = settings.backend ?? "agy";
|
|
2245
|
-
const def = resolveModelEffort({ backend: settings.backend, model: settings.model, effort: settings.effort }, families);
|
|
2246
|
-
modelItem.currentValue =
|
|
2247
|
-
defaultBackend === "claude"
|
|
2248
|
-
? `${CLAUDE_OPTION_PREFIX}${settings.model}`
|
|
2249
|
-
: defaultBackend === "grok"
|
|
2250
|
-
? `${GROK_OPTION_PREFIX}${settings.model}`
|
|
2251
|
-
: defaultBackend === "codex"
|
|
2252
|
-
? `${CODEX_OPTION_PREFIX}${settings.model}`
|
|
2253
|
-
: defaultBackend === "muse"
|
|
2254
|
-
? `${MUSE_OPTION_PREFIX}${settings.model}`
|
|
2255
|
-
: (def.family?.id ?? settings.model);
|
|
2256
|
-
effortItem.currentValue = effortDisplay(def, defaultBackend);
|
|
2257
|
-
effortItem.values =
|
|
2258
|
-
defaultBackend === "claude"
|
|
2259
|
-
? ["default", ...CLAUDE_EFFORTS]
|
|
2260
|
-
: defaultBackend === "grok"
|
|
2261
|
-
? ["default", ...GROK_EFFORTS]
|
|
2262
|
-
: defaultBackend === "codex"
|
|
2263
|
-
? ["default", ...CODEX_EFFORTS]
|
|
2264
|
-
: defaultBackend === "muse"
|
|
2265
|
-
? ["default", ...MUSE_EFFORTS]
|
|
2266
|
-
: def.family?.efforts.length
|
|
2267
|
-
? [...def.family.efforts]
|
|
2268
|
-
: undefined;
|
|
2269
|
-
modeItem.currentValue = settings.mode;
|
|
2270
|
-
showTurnsItem.currentValue = String(settings.showTurns);
|
|
2271
|
-
showTurnsItem.values = showTurnsValues(settings.showTurns);
|
|
2272
|
-
|
|
2273
|
-
for (const capability of CAPABILITIES) {
|
|
2274
|
-
const override = capabilityOverride(settings, capability);
|
|
2275
|
-
const backend = capabilityBackend(settings, capability);
|
|
2276
|
-
const resolved = resolveModelEffort(capabilityPair(settings, capability), families);
|
|
2277
|
-
const rows = capabilityItems[capability];
|
|
2278
|
-
rows.model.currentValue = !override
|
|
2279
|
-
? "Default"
|
|
2280
|
-
: backend === "claude"
|
|
2281
|
-
? `${CLAUDE_OPTION_PREFIX}${override.model}`
|
|
2282
|
-
: backend === "grok"
|
|
2283
|
-
? `${GROK_OPTION_PREFIX}${override.model}`
|
|
2284
|
-
: backend === "codex"
|
|
2285
|
-
? `${CODEX_OPTION_PREFIX}${override.model}`
|
|
2286
|
-
: backend === "muse"
|
|
2287
|
-
? `${MUSE_OPTION_PREFIX}${override.model}`
|
|
2288
|
-
: (resolved.family?.id ?? override.model);
|
|
2289
|
-
rows.effort.currentValue = effortDisplay(resolved, backend);
|
|
2290
|
-
rows.effort.values =
|
|
2291
|
-
backend === "claude"
|
|
2292
|
-
? ["default", ...CLAUDE_EFFORTS]
|
|
2293
|
-
: backend === "grok"
|
|
2294
|
-
? ["default", ...GROK_EFFORTS]
|
|
2295
|
-
: backend === "codex"
|
|
2296
|
-
? ["default", ...CODEX_EFFORTS]
|
|
2297
|
-
: backend === "muse"
|
|
2298
|
-
? ["default", ...MUSE_EFFORTS]
|
|
2299
|
-
: resolved.family?.efforts.length
|
|
2300
|
-
? [...resolved.family.efforts]
|
|
2301
|
-
: undefined;
|
|
2302
|
-
}
|
|
2303
|
-
}
|
|
2304
|
-
refresh();
|
|
2305
|
-
|
|
2306
|
-
const items: SettingItem[] = [
|
|
2307
|
-
modelItem,
|
|
2308
|
-
effortItem,
|
|
2309
|
-
modeItem,
|
|
2310
|
-
showTurnsItem,
|
|
2311
|
-
...CAPABILITIES.flatMap((capability) => [capabilityItems[capability].model, capabilityItems[capability].effort]),
|
|
2312
|
-
];
|
|
2313
|
-
|
|
2314
|
-
const noticeText = new Text("");
|
|
2315
|
-
|
|
2316
|
-
function runSave(toSave: BroSettings): void {
|
|
2317
|
-
saving = true;
|
|
2318
|
-
void persistSettings(toSave)
|
|
2319
|
-
.then(() => {
|
|
2320
|
-
savedSettings = toSave;
|
|
2321
|
-
noticeText.setText("");
|
|
2322
|
-
})
|
|
2323
|
-
.catch((error: unknown) => {
|
|
2324
|
-
// Restore the last state that is actually on disk: showing the failed, unsaved
|
|
2325
|
-
// value would let the screen claim a setting that doesn't really exist.
|
|
2326
|
-
settings = savedSettings;
|
|
2327
|
-
queued = undefined;
|
|
2328
|
-
closeRequested = false;
|
|
2329
|
-
refresh();
|
|
2330
|
-
noticeText.setText(theme.fg("warning", `Could not save settings: ${errorMessage(error)}. Reverted to the last saved settings.`));
|
|
2331
|
-
})
|
|
2332
|
-
.finally(() => {
|
|
2333
|
-
saving = false;
|
|
2334
|
-
tui.requestRender();
|
|
2335
|
-
if (queued !== undefined) {
|
|
2336
|
-
const next = queued;
|
|
2337
|
-
queued = undefined;
|
|
2338
|
-
runSave(next);
|
|
2339
|
-
} else if (closeRequested) {
|
|
2340
|
-
closeRequested = false;
|
|
2341
|
-
done(undefined);
|
|
2342
|
-
}
|
|
2343
|
-
});
|
|
2344
|
-
}
|
|
2345
|
-
|
|
2346
|
-
function persist(toSave: BroSettings): void {
|
|
2347
|
-
if (saving) {
|
|
2348
|
-
queued = toSave;
|
|
2349
|
-
return;
|
|
2350
|
-
}
|
|
2351
|
-
runSave(toSave);
|
|
2352
|
-
}
|
|
2353
|
-
|
|
2354
|
-
const onChange = (id: string, newValue: string) => {
|
|
2355
|
-
if (id === "model") {
|
|
2356
|
-
const switched = parseBackendOption(newValue);
|
|
2357
|
-
if (switched !== undefined) {
|
|
2358
|
-
if (switched.backend === "claude") {
|
|
2359
|
-
const effort = settings.backend === "claude" && isClaudeEffort(settings.effort) ? settings.effort : "default";
|
|
2360
|
-
settings = { ...settings, backend: "claude", model: resolveClaudeModel(switched.id), effort };
|
|
2361
|
-
} else if (switched.backend === "grok") {
|
|
2362
|
-
const effort = settings.backend === "grok" && isGrokEffort(settings.effort) ? settings.effort : "default";
|
|
2363
|
-
settings = { ...settings, backend: "grok", model: resolveGrokModel(switched.id), effort };
|
|
2364
|
-
} else if (switched.backend === "codex") {
|
|
2365
|
-
const effort = settings.backend === "codex" && isCodexEffort(settings.effort) ? settings.effort : "default";
|
|
2366
|
-
settings = { ...settings, backend: "codex", model: resolveCodexModel(switched.id), effort };
|
|
2367
|
-
} else {
|
|
2368
|
-
const effort = settings.backend === "muse" && isMuseEffort(settings.effort) ? settings.effort : "default";
|
|
2369
|
-
settings = { ...settings, backend: "muse", model: resolveMuseModel(switched.id), effort };
|
|
2370
|
-
}
|
|
2371
|
-
} else {
|
|
2372
|
-
const family = findFamily(newValue);
|
|
2373
|
-
if (!family) return;
|
|
2374
|
-
const keepCurrent = (settings.backend ?? "agy") === "agy" && (settings.effort === "default" ? !family.efforts.length : family.efforts.includes(settings.effort as AgyEffort));
|
|
2375
|
-
// Backend-less internal pairs mean Agy; disk saves still tag them explicitly.
|
|
2376
|
-
const { backend: _dropped, ...rest } = settings;
|
|
2377
|
-
settings = { ...rest, model: family.id, effort: keepCurrent ? settings.effort : preferredEffort(family) };
|
|
2378
|
-
}
|
|
2379
|
-
} else if (id === "effort") {
|
|
2380
|
-
if ((settings.backend ?? "agy") === "claude") {
|
|
2381
|
-
if (!isClaudeEffort(newValue)) return;
|
|
2382
|
-
settings = { ...settings, effort: newValue };
|
|
2383
|
-
} else if ((settings.backend ?? "agy") === "grok") {
|
|
2384
|
-
if (!isGrokEffort(newValue)) return;
|
|
2385
|
-
settings = { ...settings, effort: newValue };
|
|
2386
|
-
} else if ((settings.backend ?? "agy") === "codex") {
|
|
2387
|
-
if (!isCodexEffort(newValue)) return;
|
|
2388
|
-
settings = { ...settings, effort: newValue };
|
|
2389
|
-
} else if ((settings.backend ?? "agy") === "muse") {
|
|
2390
|
-
if (!isMuseEffort(newValue)) return;
|
|
2391
|
-
settings = { ...settings, effort: newValue };
|
|
2392
|
-
} else {
|
|
2393
|
-
// Effort-only edit: pin the resolved family's canonical id, same reasoning as the
|
|
2394
|
-
// capability-override effort-only edit below -- otherwise a shared default created
|
|
2395
|
-
// from a suffixed variant id (e.g. "gemini-x-low") would end up paired with an
|
|
2396
|
-
// unrelated effort instead of its actual family id.
|
|
2397
|
-
const resolved = resolveModelEffort({ model: settings.model, effort: settings.effort }, families);
|
|
2398
|
-
settings = { ...settings, model: resolved.family?.id ?? settings.model, effort: newValue as BroEffort };
|
|
2399
|
-
}
|
|
2400
|
-
} else if (id === "mode") {
|
|
2401
|
-
const mode = parseBroMode(newValue);
|
|
2402
|
-
if (!mode) return;
|
|
2403
|
-
settings = { ...settings, mode };
|
|
2404
|
-
} else if (id === "showTurns") {
|
|
2405
|
-
const turns = Number(newValue);
|
|
2406
|
-
if (!Number.isInteger(turns) || turns < 1) return;
|
|
2407
|
-
settings = { ...settings, showTurns: turns };
|
|
2408
|
-
} else {
|
|
2409
|
-
const capability = CAPABILITIES.find((item) => id === `${item}Model` || id === `${item}Effort`);
|
|
2410
|
-
if (!capability) return;
|
|
2411
|
-
if (id === `${capability}Model`) {
|
|
2412
|
-
if (newValue === "__default__") {
|
|
2413
|
-
settings = withCapabilityOverride(settings, capability, undefined);
|
|
2414
|
-
} else {
|
|
2415
|
-
const switched = parseBackendOption(newValue);
|
|
2416
|
-
if (switched !== undefined) {
|
|
2417
|
-
const currentEffort = capabilityPair(settings, capability).effort;
|
|
2418
|
-
if (switched.backend === "claude") {
|
|
2419
|
-
settings = withCapabilityOverride(settings, capability, {
|
|
2420
|
-
backend: "claude",
|
|
2421
|
-
model: resolveClaudeModel(switched.id),
|
|
2422
|
-
effort: capabilityBackend(settings, capability) === "claude" && isClaudeEffort(currentEffort) ? currentEffort : "default",
|
|
2423
|
-
});
|
|
2424
|
-
} else if (switched.backend === "grok") {
|
|
2425
|
-
settings = withCapabilityOverride(settings, capability, {
|
|
2426
|
-
backend: "grok",
|
|
2427
|
-
model: resolveGrokModel(switched.id),
|
|
2428
|
-
effort: capabilityBackend(settings, capability) === "grok" && isGrokEffort(currentEffort) ? currentEffort : "default",
|
|
2429
|
-
});
|
|
2430
|
-
} else if (switched.backend === "codex") {
|
|
2431
|
-
settings = withCapabilityOverride(settings, capability, {
|
|
2432
|
-
backend: "codex",
|
|
2433
|
-
model: resolveCodexModel(switched.id),
|
|
2434
|
-
effort: capabilityBackend(settings, capability) === "codex" && isCodexEffort(currentEffort) ? currentEffort : "default",
|
|
2435
|
-
});
|
|
2436
|
-
} else {
|
|
2437
|
-
settings = withCapabilityOverride(settings, capability, {
|
|
2438
|
-
backend: "muse",
|
|
2439
|
-
model: resolveMuseModel(switched.id),
|
|
2440
|
-
effort: capabilityBackend(settings, capability) === "muse" && isMuseEffort(currentEffort) ? currentEffort : "default",
|
|
2441
|
-
});
|
|
2442
|
-
}
|
|
2443
|
-
} else {
|
|
2444
|
-
const family = findFamily(newValue);
|
|
2445
|
-
if (!family) return;
|
|
2446
|
-
const currentEffort = capabilityPair(settings, capability).effort;
|
|
2447
|
-
const keepCurrent = capabilityBackend(settings, capability) === "agy" && (currentEffort === "default" ? !family.efforts.length : family.efforts.includes(currentEffort as AgyEffort));
|
|
2448
|
-
settings = withCapabilityOverride(settings, capability, {
|
|
2449
|
-
model: family.id,
|
|
2450
|
-
effort: keepCurrent ? currentEffort : preferredEffort(family),
|
|
2451
|
-
});
|
|
2452
|
-
}
|
|
2453
|
-
}
|
|
2454
|
-
} else if (capabilityBackend(settings, capability) === "claude") {
|
|
2455
|
-
if (!isClaudeEffort(newValue)) return;
|
|
2456
|
-
const existing = capabilityOverride(settings, capability);
|
|
2457
|
-
const model = existing?.model ?? settings.model;
|
|
2458
|
-
settings = withCapabilityOverride(settings, capability, { backend: "claude", model, effort: newValue });
|
|
2459
|
-
} else if (capabilityBackend(settings, capability) === "grok") {
|
|
2460
|
-
if (!isGrokEffort(newValue)) return;
|
|
2461
|
-
const existing = capabilityOverride(settings, capability);
|
|
2462
|
-
const model = existing?.model ?? settings.model;
|
|
2463
|
-
settings = withCapabilityOverride(settings, capability, { backend: "grok", model, effort: newValue });
|
|
2464
|
-
} else if (capabilityBackend(settings, capability) === "codex") {
|
|
2465
|
-
if (!isCodexEffort(newValue)) return;
|
|
2466
|
-
const existing = capabilityOverride(settings, capability);
|
|
2467
|
-
const model = existing?.model ?? settings.model;
|
|
2468
|
-
settings = withCapabilityOverride(settings, capability, { backend: "codex", model, effort: newValue });
|
|
2469
|
-
} else if (capabilityBackend(settings, capability) === "muse") {
|
|
2470
|
-
if (!isMuseEffort(newValue)) return;
|
|
2471
|
-
const existing = capabilityOverride(settings, capability);
|
|
2472
|
-
const model = existing?.model ?? settings.model;
|
|
2473
|
-
settings = withCapabilityOverride(settings, capability, { backend: "muse", model, effort: newValue });
|
|
2474
|
-
} else {
|
|
2475
|
-
// Effort-only edit: pin the resolved family's canonical id, never whatever raw
|
|
2476
|
-
// string happens to sit in settings.model/override.model (which — for a shared
|
|
2477
|
-
// default created from a suffixed variant id such as "gemini-x-low" with
|
|
2478
|
-
// effort "default" — is not the family id). Storing the raw string here would
|
|
2479
|
-
// pair a mismatched model/effort (e.g. a "-low"-suffixed id with effort "high").
|
|
2480
|
-
const resolved = resolveModelEffort(capabilityPair(settings, capability), families);
|
|
2481
|
-
const existing = capabilityOverride(settings, capability);
|
|
2482
|
-
const model = resolved.family?.id ?? existing?.model ?? settings.model;
|
|
2483
|
-
settings = withCapabilityOverride(settings, capability, { model, effort: newValue as BroEffort });
|
|
2484
|
-
}
|
|
2485
|
-
}
|
|
2486
|
-
refresh();
|
|
2487
|
-
tui.requestRender();
|
|
2488
|
-
persist(settings);
|
|
2489
|
-
};
|
|
2490
|
-
|
|
2491
|
-
const requestClose = () => {
|
|
2492
|
-
if (saving) {
|
|
2493
|
-
closeRequested = true;
|
|
2494
|
-
return;
|
|
2495
|
-
}
|
|
2496
|
-
done(undefined);
|
|
2497
|
-
};
|
|
2498
|
-
|
|
2499
|
-
const settingsList = new SettingsList(items, Math.min(items.length + 2, 18), getSettingsListTheme(), onChange, requestClose);
|
|
2500
|
-
const container = new Container();
|
|
2501
|
-
container.addChild(new Text(theme.fg("accent", theme.bold("Bro · config"))));
|
|
2502
|
-
container.addChild(new Text(theme.fg("dim", "Shared defaults, with optional overrides per capability")));
|
|
2503
|
-
container.addChild(settingsList);
|
|
2504
|
-
container.addChild(noticeText);
|
|
2505
|
-
container.addChild(new Text(theme.fg("dim", "↑/↓ navigate · Enter select/change · Esc back/close")));
|
|
2506
|
-
|
|
2507
|
-
return {
|
|
2508
|
-
render: (w: number) => {
|
|
2509
|
-
const inner = Math.max(1, w - 4);
|
|
2510
|
-
const border = (left: string, right: string) => theme.fg("border", left + "─".repeat(inner + 2) + right);
|
|
2511
|
-
return [border("┌", "┐"), ...container.render(inner).map(line => {
|
|
2512
|
-
const text = truncateToWidth(line, inner, "");
|
|
2513
|
-
return theme.fg("border", "│") + " " + text + " ".repeat(Math.max(0, inner - visibleWidth(text))) + " " + theme.fg("border", "│");
|
|
2514
|
-
}), border("└", "┘")];
|
|
2515
|
-
},
|
|
2516
|
-
invalidate: () => container.invalidate(),
|
|
2517
|
-
handleInput: (data: string) => {
|
|
2518
|
-
settingsList.handleInput?.(data);
|
|
2519
|
-
tui.requestRender();
|
|
2520
|
-
},
|
|
2521
|
-
};
|
|
2522
|
-
};
|
|
2523
|
-
}
|
|
2524
|
-
|
|
2525
1305
|
export async function showBroConfigModal(ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> {
|
|
2526
1306
|
if (!hasBroCustomUi(ctx)) {
|
|
2527
1307
|
ctx.ui.notify("Use /bro config in Pi's interactive UI.", "warning");
|
|
@@ -2683,7 +1463,7 @@ export function createAdvisorSteerModal(
|
|
|
2683
1463
|
): ReturnType<typeof createTextEditorModal> {
|
|
2684
1464
|
return createTextEditorModal({
|
|
2685
1465
|
title: "Bro · advisor steer",
|
|
2686
|
-
subtitle:
|
|
1466
|
+
subtitle: `Session-specific steering brief for the advisor (takes precedence over ${ADVISOR_STEERING_FILE}) — never sent to the main model.`,
|
|
2687
1467
|
initialText,
|
|
2688
1468
|
onSave,
|
|
2689
1469
|
onClear,
|
|
@@ -2708,7 +1488,7 @@ export function createPreferencesModal(
|
|
|
2708
1488
|
const starter = loaded.text === undefined && !loaded.error;
|
|
2709
1489
|
return createTextEditorModal({
|
|
2710
1490
|
title: "Bro · preferences",
|
|
2711
|
-
subtitle: "About you and how you like answers. Sent to the selected backend with every explain, show, and
|
|
1491
|
+
subtitle: "About you and how you like answers. Sent to the selected backend with every explain, show, btw, and Guided Review request — never to the advisor or Pi's main model.",
|
|
2712
1492
|
initialText: starter ? STARTER_PREFERENCES : (loaded.text ?? ""),
|
|
2713
1493
|
initialNotice: loaded.error ?? (starter ? "Starter text — not saved. Ctrl+S saves it; Esc leaves no file." : undefined),
|
|
2714
1494
|
validate: preferencesTooLong,
|
|
@@ -2799,6 +1579,13 @@ Quick reference. The README is the full user guide: https://github.com/tranhoang
|
|
|
2799
1579
|
- \`/bro open\` — reopen the latest explanation without a new request
|
|
2800
1580
|
- \`/bro show [n-turns] [query]\` — draw recent turns' conversation text as shapes; an optional query steers the focus
|
|
2801
1581
|
|
|
1582
|
+
## Guided Review
|
|
1583
|
+
|
|
1584
|
+
- \`/bro guided-review <PR number or URL>\` — automatically explain and assess a PR with captured code and optional private questions (local Pi interactive only; Git and authenticated gh required).
|
|
1585
|
+
- Your work is saved automatically. Open the same PR to return without a model call; \`/bro guided-review\` lists saved reviews (\`resume\` is an alias). Reopening keeps the captured revision, not newer commits.
|
|
1586
|
+
- Enter opens an item or focuses actions; Enter on a highlighted action activates it. Tab changes focus. Esc goes back, then closes from Contents. Close saves automatically; Stop and close stops active work first.
|
|
1587
|
+
- Copy finding copies to the system clipboard, not GitHub. Regenerate guide is optional and makes a confirmed call about the same captured code. Review generation/questions require Claude or Muse file-only tools: shell, writes and web tools are disabled. Set a review override in /bro config; other backends can still read saved reviews. Repository text can still mislead the model; citations are not proof.
|
|
1588
|
+
|
|
2802
1589
|
## Side conversation
|
|
2803
1590
|
|
|
2804
1591
|
- \`/bro btw [question]\` — open a side conversation seeded with recent main-session context. It starts conversation-only; reopening keeps the thread and its mode.
|
|
@@ -2809,11 +1596,12 @@ Quick reference. The README is the full user guide: https://github.com/tranhoang
|
|
|
2809
1596
|
- \`bro_advisor\` — a tool the executor agent may call for a second opinion from a fresh backend process with real, auto-approved workspace access; it is told to advise, not edit, but that is not enforced
|
|
2810
1597
|
- \`/bro advisor\` — whether \`bro_advisor\` is available right now
|
|
2811
1598
|
- \`/bro advisor-steer\` — edit the session's persistent steering brief (**Ctrl+S** save, **Ctrl+K** clear, **Ctrl+C** copy, **Esc** close)
|
|
1599
|
+
- Standing advisor steering: \`${ADVISOR_STEERING_FILE}\` sets standing priorities across sessions (session steering takes precedence)
|
|
2812
1600
|
|
|
2813
1601
|
## Configure and check
|
|
2814
1602
|
|
|
2815
|
-
- \`/bro preferences\` — tell Bro about yourself and how you like answers; added to explain, show, and
|
|
2816
|
-
- \`/bro config\` — shared default and per-capability (explain/show/btw/advisor) backend, model, and effort; explain mode; show turns. Changes save immediately.
|
|
1603
|
+
- \`/bro preferences\` — tell Bro about yourself and how you like answers; added to explain, show, btw, and Guided Review prompts, never the advisor (**Ctrl+S** save, **Ctrl+K** delete, **Ctrl+C** copy, **Esc** close)
|
|
1604
|
+
- \`/bro config\` — shared default and per-capability (explain/show/btw/advisor/review) backend, model, and effort; explain mode; show turns. Changes save immediately.
|
|
2817
1605
|
- \`/bro mode [brief|balanced|faithful]\` — view or choose the explanation mode
|
|
2818
1606
|
- \`/bro doctor\` — check settings, preferences, and every selected backend without running a model turn
|
|
2819
1607
|
|
|
@@ -2822,7 +1610,7 @@ Quick reference. The README is the full user guide: https://github.com/tranhoang
|
|
|
2822
1610
|
${settingsSummary}
|
|
2823
1611
|
- **Preferences:** ${preferences} — \`/bro preferences\`
|
|
2824
1612
|
|
|
2825
|
-
Saved in \`${
|
|
1613
|
+
Saved in \`${settingsFile()}\`, \`${PREFERENCES_FILE}\`, and \`${ADVISOR_STEERING_FILE}\`.
|
|
2826
1614
|
|
|
2827
1615
|
## Explanation modes
|
|
2828
1616
|
|
|
@@ -2845,7 +1633,7 @@ The mode decides how much of the source to keep; your preferences decide who it'
|
|
|
2845
1633
|
|
|
2846
1634
|
## Privacy
|
|
2847
1635
|
|
|
2848
|
-
Bro sends the captured source (or, for the advisor, the executor's instructions, tools, and conversation) to the selected backend and its model provider, which may retain it under their own policies. Your preferences go with every explain, show, and
|
|
1636
|
+
Bro sends the captured source (or, for the advisor, the executor's instructions, tools, and conversation) to the selected backend and its model provider, which may retain it under their own policies. Your preferences go with every explain, show, btw, and Guided Review request. Guided Review sends PR data/diff and relevant private questions; records/source remain locally under getAgentDir()/bro-reviews. Copy finding goes to the system clipboard, not GitHub. Nothing is added to Pi's conversation unless you insert it. Access controls differ by backend; see the README.`;
|
|
2849
1637
|
}
|
|
2850
1638
|
|
|
2851
1639
|
// The overlay framing pattern is adapted from pi-btw (MIT); see THIRD_PARTY_NOTICES.md.
|
|
@@ -3720,6 +2508,8 @@ export default async function bro(pi: ExtensionAPI) {
|
|
|
3720
2508
|
}
|
|
3721
2509
|
};
|
|
3722
2510
|
|
|
2511
|
+
pi.on("session_shutdown", async () => { stopGuidedReviews(); });
|
|
2512
|
+
|
|
3723
2513
|
pi.on("session_start", async (_event, _ctx) => {
|
|
3724
2514
|
lastResult = undefined;
|
|
3725
2515
|
btwThread = undefined;
|
|
@@ -3744,11 +2534,14 @@ export default async function bro(pi: ExtensionAPI) {
|
|
|
3744
2534
|
}),
|
|
3745
2535
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
3746
2536
|
const state = resolveAdvisorState(ctx.sessionManager.getBranch());
|
|
2537
|
+
const durableSteering = await readAdvisorSteering();
|
|
3747
2538
|
const settings = await readSettings();
|
|
3748
2539
|
const selection = selectionForCapability(settings, "advisor");
|
|
3749
2540
|
const backend = selection.backend ?? "agy";
|
|
3750
2541
|
const snapshot = buildAdvisorSnapshot(ctx, pi);
|
|
3751
|
-
const prompt = buildAdvisorPrompt(state.steering, snapshot.text, params
|
|
2542
|
+
const prompt = buildAdvisorPrompt({ durable: durableSteering, session: state.steering }, snapshot.text, params?.question);
|
|
2543
|
+
const hasDurable = Boolean(durableSteering.trim());
|
|
2544
|
+
const hasSession = Boolean(state.steering.trim());
|
|
3752
2545
|
let lastAttempt: AdvisorToolDetails = { status: "investigating", attempt: 1, of: 3, elapsedMs: 0, backend };
|
|
3753
2546
|
try {
|
|
3754
2547
|
const run = await runAdvisorWithRetries(
|
|
@@ -3785,7 +2578,8 @@ export default async function bro(pi: ExtensionAPI) {
|
|
|
3785
2578
|
effort,
|
|
3786
2579
|
durationMs: run.durationMs,
|
|
3787
2580
|
cwd: ctx.cwd,
|
|
3788
|
-
steeringIncluded:
|
|
2581
|
+
steeringIncluded: hasDurable || hasSession,
|
|
2582
|
+
steeringSources: { durable: hasDurable, session: hasSession },
|
|
3789
2583
|
snapshotChars: snapshot.text.length,
|
|
3790
2584
|
broTruncated: false,
|
|
3791
2585
|
omissions,
|
|
@@ -3795,7 +2589,14 @@ export default async function bro(pi: ExtensionAPI) {
|
|
|
3795
2589
|
};
|
|
3796
2590
|
// The backend qualifier appears only off Agy, keeping the Agy header byte-identical.
|
|
3797
2591
|
const header = `Bro advisor · ${backend === "agy" ? "" : `backend: ${backend} · `}model: ${selection.model} · effort: ${effort} · ${run.attempts} attempt${run.attempts === 1 ? "" : "s"} · ${Math.ceil(run.durationMs / 1_000)}s`;
|
|
3798
|
-
const
|
|
2592
|
+
const steeringSummary = hasDurable && hasSession
|
|
2593
|
+
? "included (durable+session)"
|
|
2594
|
+
: hasDurable
|
|
2595
|
+
? "included (durable)"
|
|
2596
|
+
: hasSession
|
|
2597
|
+
? "included"
|
|
2598
|
+
: "none";
|
|
2599
|
+
const context = `Context · cwd: ${JSON.stringify(ctx.cwd)} · steering: ${steeringSummary} · snapshot: ${snapshot.text.length} chars · Bro truncation: none · omissions: ${omissions}`;
|
|
3799
2600
|
return { content: [{ type: "text", text: `${header}\n${context}\n\n${run.advice}` }], details };
|
|
3800
2601
|
} finally {
|
|
3801
2602
|
ctx.ui.setStatus(ADVISOR_STATUS_BAR_KEY, undefined);
|
|
@@ -3988,6 +2789,12 @@ export default async function bro(pi: ExtensionAPI) {
|
|
|
3988
2789
|
return;
|
|
3989
2790
|
}
|
|
3990
2791
|
|
|
2792
|
+
if (action === "guided-review") {
|
|
2793
|
+
try { await openGuidedReview(ctx, value, await readPreferences()); }
|
|
2794
|
+
catch (error) { ctx.ui.notify(errorMessage(error), "error"); }
|
|
2795
|
+
return;
|
|
2796
|
+
}
|
|
2797
|
+
|
|
3991
2798
|
if (action === "btw") {
|
|
3992
2799
|
if (!hasBroCustomUi(ctx)) {
|
|
3993
2800
|
ctx.ui.notify("Use /bro btw in Pi's interactive UI.", "warning");
|
|
@@ -4025,7 +2832,7 @@ export default async function bro(pi: ExtensionAPI) {
|
|
|
4025
2832
|
// an actionable notice pointing at the commands that replaced it, not a silent no-op.
|
|
4026
2833
|
if (parts.length > 1) {
|
|
4027
2834
|
ctx.ui.notify(
|
|
4028
|
-
"/bro advisor no longer has on/off/status controls -- bro_advisor is always registered and available whenever this host exposes and activates it. Use /bro config for its model/effort, /bro advisor-steer for
|
|
2835
|
+
"/bro advisor no longer has on/off/status controls -- bro_advisor is always registered and available whenever this host exposes and activates it. Use /bro config for its model/effort, /bro advisor-steer for session steering, or /bro doctor for full diagnostics.",
|
|
4029
2836
|
"warning",
|
|
4030
2837
|
);
|
|
4031
2838
|
return;
|
|
@@ -4033,8 +2840,8 @@ export default async function bro(pi: ExtensionAPI) {
|
|
|
4033
2840
|
const active = pi.getActiveTools().includes(ADVISOR_TOOL_NAME);
|
|
4034
2841
|
ctx.ui.notify(
|
|
4035
2842
|
active
|
|
4036
|
-
?
|
|
4037
|
-
: "Bro advisor is unavailable in this runtime. Use /bro doctor for full diagnostics, /bro config for its model/effort, or /bro advisor-steer for
|
|
2843
|
+
? `Bro advisor is available -- the executor agent can call bro_advisor. Use /bro config for its model/effort, /bro advisor-steer for session steering, ${ADVISOR_STEERING_FILE} for standing steering, or /bro doctor for full diagnostics.`
|
|
2844
|
+
: "Bro advisor is unavailable in this runtime. Use /bro doctor for full diagnostics, /bro config for its model/effort, or /bro advisor-steer for session steering.",
|
|
4038
2845
|
active ? "info" : "warning",
|
|
4039
2846
|
);
|
|
4040
2847
|
return;
|