pi-bro 0.20.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/bro.ts CHANGED
@@ -1,123 +1,84 @@
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 { lookup } from "node:dns/promises";
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 { extname, isAbsolute, join, relative, resolve, sep } from "node:path";
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, SettingsList, SelectList, Text, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component, type EditorTheme, type Focusable, type SelectItem, type SettingItem, type TUI } from "@earendil-works/pi-tui";
14
- import { convertToLlm, copyToClipboard, getAgentDir, getMarkdownTheme, getSelectListTheme, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
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 { extractText } from "unpdf";
20
- import { BRO_MODES, DEFAULT_BRO_MODE, 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
- agySelection,
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
- export { agyFailureMessage, agySelection, advisorFlagErrorHint, parseBtwAgyLine };
37
-
38
- const AGENT_DIR = getAgentDir();
39
- const ENV_MODEL = process.env.PI_BRO_MODEL?.trim();
40
- const DEFAULT_MODEL = ENV_MODEL || "gemini-3.7-flash";
41
- const PROMPT_FILE = join(AGENT_DIR, "bro-prompt.md");
42
- const SETTINGS_FILE = join(AGENT_DIR, "bro-settings.json");
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");
64
+ // Larger files are rejected before reading, even by the editor; the prompt limit is MAX_PREFERENCES_CHARS.
65
+ const MAX_PREFERENCES_FILE_BYTES = 64 * 1024;
66
+ const MAX_ADVISOR_STEERING_FILE_BYTES = 64 * 1024;
43
67
  const LOADING_TEXT = "Simplifying for my bro…";
44
- const MAX_FILE_BYTES = 10 * 1024 * 1024;
45
- const MAX_WEB_BYTES = 5 * 1024 * 1024;
46
- const MAX_WEB_ELEMENTS = 100_000;
47
- const MAX_WEB_REDIRECTS = 5;
48
- const WEB_TIMEOUT_MS = 25_000;
49
- const MAX_TEXT_LENGTH = 100_000;
50
68
  const BTW_CONTEXT_TURNS = 8;
51
69
  const BTW_CONTEXT_MAX = 40_000;
52
- const DEFAULT_SHOW_TURNS = 1;
53
70
  const SHOW_HTML_FILE_PATTERN = /^bro-show-[0-9a-f]{8}\.html$/;
54
- const TEXT_EXTENSIONS = new Set([".md", ".markdown", ".txt"]);
55
- const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
56
-
57
- type Theme = ExtensionCommandContext["ui"]["theme"];
58
- type TuiLike = {
59
- readonly mode: "regular" | "fullscreen";
60
- readonly terminal?: { rows?: number; write?: (data: string) => void };
61
- requestRender(): void;
62
- };
71
+
63
72
  type ModalKind = "loading" | "streaming" | "result" | "help" | "empty" | "error";
64
- type BroSource = { text: string; label?: string };
65
- // `mode` is the built-in explain mode that produced the text; absent for Show, Doctor, and a custom prompt.
66
- type BroResult = { source: BroSource; text: string; model?: string; mode?: BroMode };
67
- type ModalResult = { source?: BroSource; text: string; htmlPath?: string; model?: string; mode?: BroMode };
73
+ // `mode` is the built-in explain mode that produced the text; absent for Show and Doctor.
74
+ // `preferences` records whether bro-preferences.md shaped this result; like `mode`, it belongs to the result.
75
+ type BroResult = { source: BroSource; text: string; model?: string; mode?: BroMode; preferences?: boolean };
76
+ type ModalResult = { source?: BroSource; text: string; htmlPath?: string; model?: string; mode?: BroMode; preferences?: boolean };
68
77
  type BtwTurn = { question: string; answer: string };
69
78
  // `context` keeps the main-session seed so a fresh native session can be reseeded with the whole
70
- // thread; `sessionFull` is the access mode the native session last ran in.
71
- type BtwThread = { turns: BtwTurn[]; conversationId?: string; full: boolean; backend?: BackendName; model?: string; context?: string; sessionFull?: boolean };
72
- const EFFORTS = ["default", "low", "medium", "high"] as const;
73
- const BACKENDS = ["agy", "claude", "grok", "codex", "muse"] as const;
74
- type BackendName = (typeof BACKENDS)[number];
75
- type BroEffort = "default" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
76
- type AgyEffort = Exclude<BroEffort, "default" | "minimal" | "xhigh" | "max">;
77
- type ClaudeEffort = (typeof CLAUDE_EFFORTS)[number];
78
- export { GROK_EFFORTS, CODEX_EFFORTS, MUSE_EFFORTS };
79
- type GrokEffort = (typeof GROK_EFFORTS)[number];
80
- type CodexEffort = (typeof CODEX_EFFORTS)[number];
81
- type MuseEffort = (typeof MUSE_EFFORTS)[number];
82
- // "advisor" is a real capability (command, Agy invocation, Doctor check) like the other three;
83
- // see docs/plans/2026-09-19-bro-advisor-design.md. All four share one model/effort resolution,
84
- // override, and Doctor-check path via this single list -- there is no configuration-only tier.
85
- const CAPABILITIES = ["explain", "show", "btw", "advisor"] as const;
86
- type Capability = (typeof CAPABILITIES)[number];
87
- const CAPABILITY_LABELS: Record<Capability, string> = { explain: "Explain", show: "Show", btw: "Btw", advisor: "Advisor" };
88
- type ModelEffortPair = { backend?: BackendName; model: string; effort: BroEffort };
89
- type BroSettings = {
90
- backend?: BackendName;
91
- model: string;
92
- effort: BroEffort;
93
- mode: BroMode;
94
- showTurns: number;
95
- overrides: Partial<Record<Capability, ModelEffortPair>>;
96
- };
97
- // BackendSelection (shared execution routing selection) lives in backend.ts; the backend
98
- // field stays optional there so every legacy backend-less pair keeps meaning Agy.
99
- export const CLAUDE_MODELS = [
100
- { id: "sonnet", label: "Claude Sonnet" },
101
- { id: "opus", label: "Claude Opus" },
102
- ] as const;
103
- export const GROK_MODELS = [
104
- { id: "grok-4.7", label: "Grok 4.7" },
105
- { id: "grok-4.7-build-fast", label: "Grok 4.7 Build Fast" },
106
- ] as const;
107
- export const CODEX_MODELS = [
108
- { id: "gpt-5.5", label: "GPT-5.5" },
109
- { id: "gpt-5.4", label: "GPT-5.4" },
110
- ] as const;
111
- export const MUSE_MODELS = [
112
- { id: "muse-spark-1.3-contributor", label: "Muse Spark 1.3 Contributor" },
113
- { id: "muse-spark-1.3", label: "Muse Spark 1.3" },
114
- ] as const;
115
- type AgyModelFamily = {
116
- id: string;
117
- label: string;
118
- efforts: AgyEffort[];
119
- variants: Array<{ id: string; effort?: AgyEffort }>;
120
- };
79
+ // thread; `sessionFull` and `sessionPreferences` are the access mode and preferences the native
80
+ // session last ran with. `preferences` records whether the latest turn used them (header tag).
81
+ type BtwThread = { turns: BtwTurn[]; conversationId?: string; full: boolean; backend?: BackendName; model?: string; preferences?: boolean; context?: string; sessionFull?: boolean; sessionPreferences?: string };
121
82
  export function wheelDelta(data: string): number {
122
83
  const match = /^\x1b\[<(\d+);\d+;\d+[Mm]$/.exec(data);
123
84
  if (!match) return 0;
@@ -139,6 +100,8 @@ const COMMANDS = [
139
100
  { value: "doctor", label: "doctor", description: "Check whether Bro is ready" },
140
101
  { value: "show", label: "show", description: "Draw what happened in recent session turns as shapes" },
141
102
  { value: "mode", label: "mode", description: "View or choose explanation mode (brief, balanced, faithful)" },
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" },
142
105
  { value: "btw", label: "btw", description: "Open a side conversation (starts conversation-only; /mode toggles full permission)" },
143
106
  { value: "config", label: "config", description: "Configure shared defaults and per-capability model/effort overrides" },
144
107
  { value: "advisor", label: "advisor", description: "Check whether the executor's advisor tool is available right now" },
@@ -147,652 +110,6 @@ const COMMANDS = [
147
110
  ];
148
111
  const KNOWN_ACTIONS = new Set(COMMANDS.map((command) => command.value));
149
112
 
150
- function isRecord(value: unknown): value is Record<string, unknown> {
151
- return typeof value === "object" && value !== null && !Array.isArray(value);
152
- }
153
-
154
- function errorMessage(error: unknown): string {
155
- return error instanceof Error ? error.message : String(error);
156
- }
157
-
158
- function withDoctor(error: unknown): string {
159
- const message = errorMessage(error);
160
- return message.includes("/bro doctor") ? message : `${message}\n\nRun \`/bro doctor\` for setup help.`;
161
- }
162
-
163
- function fileError(path: string, error: unknown): Error {
164
- const code = (error as NodeJS.ErrnoException).code;
165
- if (code === "ENOENT") return new Error(`File not found: ${path}`);
166
- if (code === "EACCES" || code === "EPERM") return new Error(`File is not readable: ${path}`);
167
- return new Error(`Could not read ${path}: ${errorMessage(error)}`);
168
- }
169
-
170
- function unquote(value: string): string {
171
- if (value.length >= 2 && ((value[0] === '"' && value.at(-1) === '"') || (value[0] === "'" && value.at(-1) === "'"))) {
172
- return value.slice(1, -1);
173
- }
174
- return value;
175
- }
176
-
177
- export async function extractDocumentText(input: string, cwd: string, signal?: AbortSignal): Promise<string> {
178
- const requested = unquote(input.trim());
179
- if (!requested) throw new Error("Use /bro file <path>.");
180
-
181
- let root: string;
182
- let path: string;
183
- try {
184
- root = await realpath(cwd);
185
- path = await realpath(resolve(cwd, requested));
186
- } catch (error) {
187
- throw fileError(requested, error);
188
- }
189
-
190
- const fromRoot = relative(root, path);
191
- if (fromRoot === ".." || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) {
192
- throw new Error("Bro can read only files inside the current workspace.");
193
- }
194
-
195
- let info;
196
- try {
197
- info = await stat(path);
198
- } catch (error) {
199
- throw fileError(requested, error);
200
- }
201
- if (!info.isFile()) throw new Error(`Not a regular file: ${requested}`);
202
- if (info.size > MAX_FILE_BYTES) throw new Error("File is larger than Bro's 10 MiB limit.");
203
-
204
- let buffer: Buffer;
205
- try {
206
- buffer = await readFile(path, { signal });
207
- } catch (error) {
208
- if (signal?.aborted) throw new Error("Canceled.");
209
- throw fileError(requested, error);
210
- }
211
- if (buffer.byteLength > MAX_FILE_BYTES) throw new Error("File is larger than Bro's 10 MiB limit.");
212
- if (signal?.aborted) throw new Error("Canceled.");
213
-
214
- const extension = extname(path).toLowerCase();
215
- let text: string;
216
- try {
217
- if (TEXT_EXTENSIONS.has(extension)) {
218
- text = new TextDecoder("utf-8", { fatal: true }).decode(buffer);
219
- } else if (extension === ".pdf") {
220
- text = (await extractText(new Uint8Array(buffer), { mergePages: true })).text;
221
- } else if (extension === ".docx") {
222
- text = (await mammoth.extractRawText({ buffer })).value;
223
- } else {
224
- throw new Error("Unsupported file type. Use .md, .markdown, .txt, .pdf, or .docx.");
225
- }
226
- } catch (error) {
227
- if (error instanceof Error && error.message.startsWith("Unsupported file type.")) throw error;
228
- throw new Error(`Could not extract text from ${requested}: ${errorMessage(error)}`);
229
- }
230
-
231
- text = text.trim();
232
- if (!text) throw new Error("No readable text found. Scanned PDFs need OCR, which Bro does not support.");
233
- if (text.length > MAX_TEXT_LENGTH) throw new Error("Extracted text is longer than Bro's 100,000-character limit.");
234
- return text;
235
- }
236
-
237
- const SNIFFABLE_FILE_EXTENSIONS = new Set([...TEXT_EXTENSIONS, ".pdf", ".docx"]);
238
-
239
- async function isWorkspaceFile(input: string, cwd: string): Promise<boolean> {
240
- // ponytail: duplicates extractDocumentText's workspace guard rather than sharing its error semantics.
241
- try {
242
- const path = await realpath(resolve(cwd, input));
243
- const fromRoot = relative(await realpath(cwd), path);
244
- if (fromRoot === ".." || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) {
245
- return false;
246
- }
247
- const info = await stat(path);
248
- return info.isFile() && SNIFFABLE_FILE_EXTENSIONS.has(extname(path).toLowerCase());
249
- } catch {
250
- return false;
251
- }
252
- }
253
-
254
- const NON_PUBLIC_ADDRESSES = new BlockList();
255
- for (const [network, prefix] of [
256
- ["0.0.0.0", 8],
257
- ["10.0.0.0", 8],
258
- ["100.64.0.0", 10],
259
- ["127.0.0.0", 8],
260
- ["169.254.0.0", 16],
261
- ["172.16.0.0", 12],
262
- ["192.0.0.0", 24],
263
- ["192.0.2.0", 24],
264
- ["192.31.196.0", 24],
265
- ["192.52.193.0", 24],
266
- ["192.88.99.0", 24],
267
- ["192.168.0.0", 16],
268
- ["192.175.48.0", 24],
269
- ["198.18.0.0", 15],
270
- ["198.51.100.0", 24],
271
- ["203.0.113.0", 24],
272
- ["224.0.0.0", 4],
273
- ["240.0.0.0", 4],
274
- ] as const) {
275
- NON_PUBLIC_ADDRESSES.addSubnet(network, prefix, "ipv4");
276
- }
277
- for (const [network, prefix] of [
278
- ["::", 128],
279
- ["::1", 128],
280
- ["64:ff9b::", 96],
281
- ["64:ff9b:1::", 48],
282
- ["100::", 64],
283
- ["2001::", 23],
284
- ["2001:db8::", 32],
285
- ["2002::", 16],
286
- ["3fff::", 20],
287
- ["5f00::", 16],
288
- ["fc00::", 7],
289
- ["fe80::", 10],
290
- ["ff00::", 8],
291
- ] as const) {
292
- NON_PUBLIC_ADDRESSES.addSubnet(network, prefix, "ipv6");
293
- }
294
-
295
- export function isPublicWebAddress(address: string): boolean {
296
- const family = isIP(address);
297
- return family === 4
298
- ? !NON_PUBLIC_ADDRESSES.check(address, "ipv4")
299
- : family === 6
300
- ? !NON_PUBLIC_ADDRESSES.check(address, "ipv6")
301
- : false;
302
- }
303
-
304
- export function parseWebUrl(input: string): URL {
305
- const requested = unquote(input.trim());
306
- if (!requested) throw new Error("Use /bro url <url>.");
307
-
308
- let url: URL;
309
- try {
310
- url = new URL(requested);
311
- } catch {
312
- throw new Error("That is not a valid URL. Use /bro url https://example.com/article.");
313
- }
314
- if (url.protocol !== "http:" && url.protocol !== "https:") {
315
- throw new Error("Bro can read only public HTTP or HTTPS webpages.");
316
- }
317
- if (url.username || url.password) {
318
- throw new Error("Bro does not accept URLs containing usernames or passwords.");
319
- }
320
- url.hash = "";
321
- return url;
322
- }
323
-
324
- export function looksLikeWebUrl(input: string): boolean {
325
- // Structurally http(s) only: credential or syntax problems must surface as url errors, not text leaks.
326
- let url: URL;
327
- try {
328
- url = new URL(input);
329
- } catch {
330
- return false;
331
- }
332
- return url.protocol === "http:" || url.protocol === "https:";
333
- }
334
-
335
- export function parseWebRedirect(current: URL, location: string): URL {
336
- const next = parseWebUrl(new URL(location, current).href);
337
- if (current.protocol === "https:" && next.protocol !== "https:") {
338
- throw new Error("Bro refused an insecure HTTPS-to-HTTP redirect.");
339
- }
340
- return next;
341
- }
342
-
343
- function headerValue(value: string | string[] | undefined): string {
344
- return Array.isArray(value) ? value[0] ?? "" : value ?? "";
345
- }
346
-
347
- async function resolvePublicAddress(hostname: string): Promise<{ address: string; family: 4 | 6 }> {
348
- const host = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
349
- let addresses: Array<{ address: string; family: number }>;
350
- try {
351
- addresses = await lookup(host, { all: true, verbatim: true });
352
- } catch (error) {
353
- throw new Error(`Could not resolve webpage host: ${errorMessage(error)}`);
354
- }
355
- if (!addresses.length) throw new Error("The webpage host has no network address.");
356
- if (addresses.some((item) => !isPublicWebAddress(item.address))) {
357
- throw new Error("Bro cannot connect to local, private, or reserved network addresses.");
358
- }
359
- return { address: addresses[0].address, family: addresses[0].family === 6 ? 6 : 4 };
360
- }
361
-
362
- function requestWebPage(url: URL, address: { address: string; family: 4 | 6 }, signal: AbortSignal): Promise<IncomingMessage> {
363
- return new Promise((resolveResponse, rejectResponse) => {
364
- const request = (url.protocol === "https:" ? httpsRequest : httpRequest)(
365
- url,
366
- {
367
- method: "GET",
368
- signal,
369
- headers: {
370
- Accept: "text/html,application/xhtml+xml",
371
- "Accept-Encoding": "identity",
372
- "User-Agent": "pi-bro URL reader (+https://github.com/tranhoangnguyen03/pi-bro)",
373
- },
374
- lookup: (_hostname, options, callback) => {
375
- if (options.all) callback(null, [address]);
376
- else callback(null, address.address, address.family);
377
- },
378
- },
379
- resolveResponse,
380
- );
381
- request.once("error", rejectResponse);
382
- request.end();
383
- });
384
- }
385
-
386
- async function readWebBody(response: IncomingMessage): Promise<Buffer> {
387
- const contentEncoding = headerValue(response.headers["content-encoding"]).trim().toLowerCase();
388
- if (contentEncoding && contentEncoding !== "identity") {
389
- response.destroy();
390
- throw new Error(`Bro cannot read this page's ${contentEncoding} response encoding.`);
391
- }
392
-
393
- const contentLength = Number.parseInt(headerValue(response.headers["content-length"]), 10);
394
- if (Number.isFinite(contentLength) && contentLength > MAX_WEB_BYTES) {
395
- response.destroy();
396
- throw new Error("Webpage is larger than Bro's 5 MiB download limit.");
397
- }
398
-
399
- const chunks: Buffer[] = [];
400
- let size = 0;
401
- try {
402
- for await (const chunk of response) {
403
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
404
- size += buffer.byteLength;
405
- if (size > MAX_WEB_BYTES) throw new Error("Webpage is larger than Bro's 5 MiB download limit.");
406
- chunks.push(buffer);
407
- }
408
- } catch (error) {
409
- response.destroy();
410
- throw error;
411
- }
412
- return Buffer.concat(chunks, size);
413
- }
414
-
415
- function decodeWebHtml(buffer: Buffer, contentType: string): string {
416
- const headerCharset = /charset\s*=\s*["']?([^\s;"']+)/i.exec(contentType)?.[1];
417
- const head = new TextDecoder("latin1").decode(buffer.subarray(0, 2048));
418
- const metaCharset = /<meta[^>]+charset\s*=\s*["']?([^\s;"'>]+)/i.exec(head)?.[1]
419
- ?? /<meta[^>]+content\s*=\s*["'][^"']*charset=([^\s;"']+)/i.exec(head)?.[1];
420
- const charset = headerCharset ?? metaCharset ?? "utf-8";
421
- try {
422
- return new TextDecoder(charset).decode(buffer);
423
- } catch {
424
- throw new Error(`Bro does not support this page's ${charset} character encoding.`);
425
- }
426
- }
427
-
428
- function assertWebElementLimit(html: string): void {
429
- let count = 0;
430
- for (let index = 0; index < html.length - 1; index++) {
431
- if (html.charCodeAt(index) !== 60) continue;
432
- const next = html.charCodeAt(index + 1) | 32;
433
- if (next >= 97 && next <= 122 && ++count > MAX_WEB_ELEMENTS) {
434
- throw new Error("Webpage is too complex for Bro to read safely.");
435
- }
436
- }
437
- }
438
-
439
- async function fetchPublicHtml(startUrl: URL, signal: AbortSignal): Promise<{ html: string; url: URL }> {
440
- let url = startUrl;
441
- const visited = new Set<string>();
442
-
443
- for (let redirects = 0; ; redirects++) {
444
- if (visited.has(url.href)) throw new Error("Webpage redirect loop detected.");
445
- visited.add(url.href);
446
- const address = await resolvePublicAddress(url.hostname);
447
- let response: IncomingMessage;
448
- try {
449
- response = await requestWebPage(url, address, signal);
450
- } catch (error) {
451
- throw new Error(`Could not fetch webpage: ${errorMessage(error)}`);
452
- }
453
- const status = response.statusCode ?? 0;
454
-
455
- if (REDIRECT_STATUSES.has(status)) {
456
- response.destroy();
457
- if (redirects >= MAX_WEB_REDIRECTS) throw new Error("Webpage redirected too many times.");
458
- const location = headerValue(response.headers.location);
459
- if (!location) throw new Error(`Webpage returned HTTP ${status} without a redirect location.`);
460
- url = parseWebRedirect(url, location);
461
- continue;
462
- }
463
-
464
- if (status < 200 || status >= 300) {
465
- response.destroy();
466
- if (status === 401 || status === 403) {
467
- throw new Error(`Webpage returned HTTP ${status}. It may require a login or block automated readers.`);
468
- }
469
- if (status === 429) throw new Error("Webpage returned HTTP 429 and is limiting automated requests.");
470
- throw new Error(`Webpage returned HTTP ${status}.`);
471
- }
472
-
473
- const contentType = headerValue(response.headers["content-type"]);
474
- const mime = contentType.split(";", 1)[0].trim().toLowerCase();
475
- if (mime !== "text/html" && mime !== "application/xhtml+xml") {
476
- response.destroy();
477
- throw new Error(`Unsupported webpage content type: ${mime || "missing"}.`);
478
- }
479
-
480
- const html = decodeWebHtml(await readWebBody(response), contentType);
481
- assertWebElementLimit(html);
482
- return { html, url };
483
- }
484
- }
485
-
486
- export async function extractWebHtml(html: string, url: string): Promise<BroSource> {
487
- assertWebElementLimit(html);
488
- const parsedUrl = parseWebUrl(url);
489
- const { document } = parseHTML(html);
490
- const result = await Defuddle(document, parsedUrl.href, {
491
- markdown: true,
492
- removeImages: true,
493
- includeReplies: false,
494
- useAsync: false,
495
- });
496
- const text = (result.contentMarkdown || result.content || "").trim();
497
- if (!text) {
498
- throw new Error("Bro found no readable page content. The page may require JavaScript, a login, or block automated readers.");
499
- }
500
- if (text.length > MAX_TEXT_LENGTH) {
501
- throw new Error("Extracted webpage text is longer than Bro's 100,000-character limit.");
502
- }
503
- const title = result.title
504
- ? stripVTControlCharacters(result.title).replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").replace(/\s+/g, " ").trim().slice(0, 200)
505
- : undefined;
506
- return { text, label: [parsedUrl.hostname, title].filter(Boolean).join(" · ") };
507
- }
508
-
509
- export async function extractWebPage(input: string, signal?: AbortSignal): Promise<BroSource> {
510
- const timeout = AbortSignal.timeout(WEB_TIMEOUT_MS);
511
- const combinedSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
512
- try {
513
- const fetched = await fetchPublicHtml(parseWebUrl(input), combinedSignal);
514
- return await extractWebHtml(fetched.html, fetched.url.href);
515
- } catch (error) {
516
- if (signal?.aborted) throw new Error("Canceled.");
517
- if (timeout.aborted) throw new Error("Webpage took longer than 25 seconds to respond.");
518
- throw error;
519
- }
520
- }
521
-
522
- function parseBackend(value: unknown, context: string): BackendName {
523
- if (typeof value !== "string" || !BACKENDS.some((backend) => backend === value)) {
524
- throw new Error(`${context} backend must be "agy", "claude", "grok", "codex", or "muse".`);
525
- }
526
- return value as BackendName;
527
- }
528
-
529
- function isAgyEffort(effort: unknown): effort is AgyEffort | "default" {
530
- return EFFORTS.some((item) => item === effort);
531
- }
532
-
533
- function isClaudeEffort(effort: unknown): effort is ClaudeEffort | "default" {
534
- return effort === "default" || CLAUDE_EFFORTS.some((item) => item === effort);
535
- }
536
-
537
- function isGrokEffort(effort: unknown): effort is GrokEffort | "default" {
538
- return effort === "default" || GROK_EFFORTS.some((item) => item === effort);
539
- }
540
-
541
- function isCodexEffort(effort: unknown): effort is CodexEffort | "default" {
542
- return effort === "default" || CODEX_EFFORTS.some((item) => item === effort);
543
- }
544
-
545
- function isMuseEffort(effort: unknown): effort is MuseEffort | "default" {
546
- return effort === "default" || MUSE_EFFORTS.some((item) => item === effort);
547
- }
548
-
549
- function parseModelEffortPair(value: unknown, context: string): ModelEffortPair {
550
- if (!isRecord(value) || typeof value.model !== "string" || !value.model.trim()) {
551
- throw new Error(`${context} must contain a model and effort set to "default", "low", "medium", or "high".`);
552
- }
553
- const backend = value.backend === undefined ? undefined : parseBackend(value.backend, context);
554
- if (backend === "claude") {
555
- if (!isClaudeEffort(value.effort)) {
556
- throw new Error(`${context} must contain a model and effort set to "default", "low", "medium", "high", "xhigh", or "max".`);
557
- }
558
- return { backend, model: value.model.trim(), effort: value.effort };
559
- }
560
- if (backend === "grok") {
561
- if (!isGrokEffort(value.effort)) {
562
- throw new Error(`${context} must contain a model and effort set to "default", "low", "medium", "high", or "xhigh".`);
563
- }
564
- return { backend, model: value.model.trim(), effort: value.effort };
565
- }
566
- if (backend === "codex") {
567
- if (!isCodexEffort(value.effort)) {
568
- throw new Error(`${context} must contain a model and effort set to "default", "low", "medium", "high", or "xhigh".`);
569
- }
570
- return { backend, model: value.model.trim(), effort: value.effort };
571
- }
572
- if (backend === "muse") {
573
- if (!isMuseEffort(value.effort)) {
574
- throw new Error(`${context} must contain a model and effort set to "default", "minimal", "low", "medium", "high", "xhigh", or "max".`);
575
- }
576
- return { backend, model: value.model.trim(), effort: value.effort };
577
- }
578
- if (!isAgyEffort(value.effort)) {
579
- throw new Error(`${context} must contain a model and effort set to "default", "low", "medium", or "high".`);
580
- }
581
- const pair: ModelEffortPair = { model: value.model.trim(), effort: value.effort };
582
- if (backend !== undefined) pair.backend = backend;
583
- return pair;
584
- }
585
-
586
- function parseOverrides(value: unknown): Partial<Record<Capability, ModelEffortPair>> {
587
- if (value === undefined) return {};
588
- if (!isRecord(value)) throw new Error("Settings overrides must be an object.");
589
- const overrides: Partial<Record<Capability, ModelEffortPair>> = {};
590
- for (const capability of CAPABILITIES) {
591
- if (value[capability] === undefined) continue;
592
- overrides[capability] = parseModelEffortPair(value[capability], `Settings overrides.${capability}`);
593
- }
594
- return overrides;
595
- }
596
-
597
- export function parseBroSettings(value: unknown): BroSettings {
598
- if (!isRecord(value)) {
599
- throw new Error('Settings must contain a model and effort set to "default", "low", "medium", or "high".');
600
- }
601
- // v2 disk shape: { version: 2, default: { backend, model, effort }, mode, showTurns, overrides }.
602
- // Reads never rewrite: a legacy flat file keeps parsing into backend-less (Agy) settings.
603
- if (value.version !== undefined && value.version !== 2) throw new Error(`Unsupported settings version ${JSON.stringify(value.version)}.`);
604
- if (value.version === 2 && value.default === undefined) throw new Error("Settings version 2 requires default.");
605
- if (value.default !== undefined) {
606
- if (!isRecord(value.default)) throw new Error("Settings default must contain a model and effort.");
607
- if (value.version !== undefined && value.version !== 2) {
608
- throw new Error(`Unsupported settings version ${JSON.stringify(value.version)}.`);
609
- }
610
- const pair = parseModelEffortPair(value.default, "Settings default");
611
- const mode = value.mode === undefined ? DEFAULT_BRO_MODE : parseBroMode(value.mode);
612
- if (!mode) throw new Error('Settings mode must be "brief", "balanced", or "faithful".');
613
- const showTurns = value.showTurns === undefined ? DEFAULT_SHOW_TURNS : value.showTurns;
614
- if (typeof showTurns !== "number" || !Number.isInteger(showTurns) || showTurns < 1) {
615
- throw new Error("Settings showTurns must be a positive whole number of turns.");
616
- }
617
- const overrides = parseOverrides(value.overrides);
618
- const settings: BroSettings = { model: pair.model, effort: pair.effort, mode, showTurns, overrides };
619
- if (pair.backend !== undefined) settings.backend = pair.backend;
620
- return settings;
621
- }
622
- if (typeof value.model !== "string" || !value.model.trim()) {
623
- throw new Error('Settings must contain a model and effort set to "default", "low", "medium", or "high".');
624
- }
625
- const backend = value.backend === undefined ? undefined : parseBackend(value.backend, "Settings");
626
- const effortOk =
627
- backend === "claude"
628
- ? isClaudeEffort(value.effort)
629
- : backend === "grok"
630
- ? isGrokEffort(value.effort)
631
- : backend === "codex"
632
- ? isCodexEffort(value.effort)
633
- : backend === "muse"
634
- ? isMuseEffort(value.effort)
635
- : isAgyEffort(value.effort);
636
- if (!effortOk) {
637
- throw new Error(
638
- backend === "claude"
639
- ? 'Settings must contain a model and effort set to "default", "low", "medium", "high", "xhigh", or "max".'
640
- : backend === "grok" || backend === "codex"
641
- ? 'Settings must contain a model and effort set to "default", "low", "medium", "high", or "xhigh".'
642
- : backend === "muse"
643
- ? 'Settings must contain a model and effort set to "default", "minimal", "low", "medium", "high", "xhigh", or "max".'
644
- : 'Settings must contain a model and effort set to "default", "low", "medium", or "high".',
645
- );
646
- }
647
- const mode = value.mode === undefined ? DEFAULT_BRO_MODE : parseBroMode(value.mode);
648
- if (!mode) throw new Error('Settings mode must be "brief", "balanced", or "faithful".');
649
- const showTurns = value.showTurns === undefined ? DEFAULT_SHOW_TURNS : value.showTurns;
650
- if (typeof showTurns !== "number" || !Number.isInteger(showTurns) || showTurns < 1) {
651
- throw new Error("Settings showTurns must be a positive whole number of turns.");
652
- }
653
- const overrides = parseOverrides(value.overrides);
654
- const settings: BroSettings = {
655
- model: value.model.trim(),
656
- effort: value.effort as BroSettings["effort"],
657
- mode,
658
- showTurns,
659
- overrides,
660
- };
661
- if (backend !== undefined) settings.backend = backend;
662
- return settings;
663
- }
664
-
665
- function capabilityOverride(settings: BroSettings, capability: Capability): ModelEffortPair | undefined {
666
- return settings.overrides[capability];
667
- }
668
-
669
- function capabilityPair(settings: BroSettings, capability: Capability): ModelEffortPair {
670
- return capabilityOverride(settings, capability) ?? { ...(settings.backend ? { backend: settings.backend } : {}), model: settings.model, effort: settings.effort };
671
- }
672
-
673
- // An override always pins both model and effort together (never just one), so a capability's
674
- // setting is either fully inherited or fully its own — no partial-inheritance edge cases.
675
- //
676
- // An override is cleared ONLY by an explicit "Default" selection (pair === undefined), never
677
- // automatically because it happens to match the shared default: a user who deliberately pins a
678
- // capability to the model that currently IS the shared default must keep that pin — unchanged —
679
- // if the shared default is later changed to something else. Silently dropping an override that
680
- // merely coincides with the default would make that pin impossible to express.
681
- export function withCapabilityOverride(
682
- settings: BroSettings,
683
- capability: Capability,
684
- pair: ModelEffortPair | undefined,
685
- ): BroSettings {
686
- const overrides = { ...settings.overrides };
687
- if (!pair) delete overrides[capability];
688
- else overrides[capability] = pair;
689
- return { ...settings, overrides };
690
- }
691
-
692
- export function resolveModelEffort(
693
- pair: ModelEffortPair,
694
- families: AgyModelFamily[],
695
- ): { pair: ModelEffortPair; family?: AgyModelFamily } {
696
- // Claude/Grok/Codex/Muse selections never resolve through the Agy catalog; they pass through untouched.
697
- if (pair.backend === "claude" || pair.backend === "grok" || pair.backend === "codex" || pair.backend === "muse") return { pair };
698
- const family = families.find((item) => item.id === pair.model || item.variants.some((variant) => variant.id === pair.model));
699
- if (!family) return { pair };
700
- const variant = family.variants.find((item) => item.id === pair.model);
701
- const resolved: ModelEffortPair = {
702
- model: family.id,
703
- effort: pair.effort === "default" && variant?.effort ? variant.effort : pair.effort,
704
- };
705
- if (pair.backend !== undefined) resolved.backend = pair.backend;
706
- return { family, pair: resolved };
707
- }
708
-
709
- // A capability's effective backend: an explicit override is a complete selection, so a
710
- // backend-less override still means Agy (all legacy stays Agy) and never inherits the
711
- // shared default's backend. Only a capability without an override inherits the default.
712
- export function capabilityBackend(settings: BroSettings, capability: Capability): BackendName {
713
- const override = settings.overrides[capability];
714
- if (override) return override.backend ?? "agy";
715
- return settings.backend ?? "agy";
716
- }
717
-
718
- // Sonnet/opus aliases resolve case-insensitively; any other non-empty string passes
719
- // through untouched as an explicit user-entered model ID.
720
- export function resolveClaudeModel(input: string): string {
721
- const trimmed = input.trim();
722
- if (!trimmed) throw new Error("Claude model must be a non-empty model ID (sonnet, opus, or an explicit model ID).");
723
- const alias = CLAUDE_MODELS.find((model) => model.id === trimmed.toLowerCase());
724
- return alias ? alias.id : trimmed;
725
- }
726
-
727
- // Grok seeds resolve to themselves; any other non-empty string passes through
728
- // untouched as an explicit user-entered model ID (installed `grok models` confirms).
729
- export function resolveGrokModel(input: string): string {
730
- const trimmed = input.trim();
731
- 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).");
732
- return trimmed;
733
- }
734
-
735
- // Codex seeds resolve to themselves; any other non-empty string passes through
736
- // untouched as an explicit user-entered model ID.
737
- export function resolveCodexModel(input: string): string {
738
- const trimmed = input.trim();
739
- 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).");
740
- return trimmed;
741
- }
742
-
743
- // Muse seeds resolve to themselves; any other non-empty string passes through
744
- // untouched as an explicit user-entered model ID.
745
- export function resolveMuseModel(input: string): string {
746
- const trimmed = input.trim();
747
- 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).");
748
- return trimmed;
749
- }
750
-
751
- // Routes one capability's whole pair to its backend execution selection. Agy keeps the
752
- // existing model/effort split; Claude/Grok/Codex/Muse carry the model plus an optional effort
753
- // ("default" means the CLI's own default and is omitted). Throws for an effort the
754
- // resolved backend does not support instead of silently sending a mismatched pair.
755
- export function selectionForCapability(settings: BroSettings, capability: Capability): BackendSelection {
756
- const pair = capabilityPair(settings, capability);
757
- const backend = capabilityBackend(settings, capability);
758
- if (backend === "claude" || backend === "grok" || backend === "codex" || backend === "muse") {
759
- const valid =
760
- backend === "claude"
761
- ? isClaudeEffort(pair.effort)
762
- : backend === "grok"
763
- ? isGrokEffort(pair.effort)
764
- : backend === "codex"
765
- ? isCodexEffort(pair.effort)
766
- : isMuseEffort(pair.effort);
767
- if (!valid) {
768
- const label = backend === "claude" ? "Claude" : backend === "grok" ? "Grok" : backend === "codex" ? "Codex" : "Muse";
769
- throw new Error(`\`${pair.effort}\` is not supported on the ${label} backend. Run \`/bro config\` to fix this.`);
770
- }
771
- return (
772
- pair.effort === "default"
773
- ? { backend, model: pair.model }
774
- : { backend, model: pair.model, effort: pair.effort }
775
- ) as BackendSelection;
776
- }
777
- if (!isAgyEffort(pair.effort)) {
778
- throw new Error(`\`${pair.effort}\` is not supported on the Agy backend. Run \`/bro config\` to fix this.`);
779
- }
780
- return agySelection({ model: pair.model, effort: pair.effort });
781
- }
782
-
783
- // The modal label for the exact selection a request runs with: model plus effort, where an
784
- // omitted effort (the model's own default) reads simply "default".
785
- export function selectionLabel(selection: BackendSelection): string {
786
- return `${selection.model} · ${selection.effort ?? "default"}`;
787
- }
788
-
789
- function resolveCapabilitySettings(
790
- settings: BroSettings,
791
- capability: Capability,
792
- families: AgyModelFamily[],
793
- ): { pair: ModelEffortPair; family?: AgyModelFamily } {
794
- return resolveModelEffort(capabilityPair(settings, capability), families);
795
- }
796
113
 
797
114
  // The steering brief is stored as a session `custom` entry — extension state that never
798
115
  // participates in LLM context (see docs/plans/2026-09-19-bro-advisor-design.md). Writes go
@@ -825,52 +142,6 @@ export function resolveAdvisorState(branch: readonly SessionEntry[]): AdvisorSta
825
142
  return { steering };
826
143
  }
827
144
 
828
- async function ensureSettingsFile(): Promise<void> {
829
- await mkdir(AGENT_DIR, { recursive: true });
830
- try {
831
- await writeFile(
832
- SETTINGS_FILE,
833
- `${JSON.stringify({ model: DEFAULT_MODEL, effort: ENV_MODEL ? "default" : "low", mode: DEFAULT_BRO_MODE, showTurns: DEFAULT_SHOW_TURNS }, null, 2)}\n`,
834
- { encoding: "utf8", flag: "wx", mode: 0o600 },
835
- );
836
- } catch (error) {
837
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
838
- }
839
- }
840
-
841
- async function readSettings(): Promise<BroSettings> {
842
- await ensureSettingsFile();
843
- try {
844
- return parseBroSettings(JSON.parse(await readFile(SETTINGS_FILE, "utf8")));
845
- } catch (error) {
846
- if (error instanceof SyntaxError) throw new Error(`${SETTINGS_FILE} is not valid JSON.`);
847
- if (error instanceof Error) throw new Error(`${SETTINGS_FILE}: ${error.message}`);
848
- throw error;
849
- }
850
- }
851
-
852
- // Exported so persistence stays testable without touching the filesystem. This only ever omits
853
- // the `overrides` key itself when there are no overrides at all — it does NOT deduplicate or drop
854
- // any individual override that happens to match the shared default; see withCapabilityOverride
855
- // for why an explicit override is always kept until the user clears it back to "Default".
856
- export function settingsPayload(settings: BroSettings): Record<string, unknown> {
857
- const overrides: Record<string, unknown> = {};
858
- for (const [capability, pair] of Object.entries(settings.overrides)) {
859
- if (pair) overrides[capability] = { backend: pair.backend ?? "agy", model: pair.model, effort: pair.effort };
860
- }
861
- return {
862
- version: 2,
863
- default: { backend: settings.backend ?? "agy", model: settings.model, effort: settings.effort },
864
- mode: settings.mode,
865
- showTurns: settings.showTurns,
866
- ...(Object.keys(overrides).length ? { overrides } : {}),
867
- };
868
- }
869
-
870
- async function writeSettings(settings: BroSettings): Promise<void> {
871
- // ponytail: last writer wins across concurrent Pi processes; add locking only if that becomes a common workflow.
872
- await writeFile(SETTINGS_FILE, `${JSON.stringify(settingsPayload(settings), null, 2)}\n`, "utf8");
873
- }
874
145
 
875
146
  export function formatAgyUsage(value: unknown): string {
876
147
  if (!isRecord(value) || value.status !== "SUCCESS" || typeof value.response !== "string") {
@@ -972,25 +243,21 @@ function resolveCatalogSettings(
972
243
  return { family: resolved.family, settings: { ...settings, ...resolved.pair } };
973
244
  }
974
245
 
975
- function preferredEffort(family: AgyModelFamily): BroEffort {
976
- return family.efforts.includes("low") ? "low" : (family.efforts[0] ?? "default");
977
- }
978
-
979
- async function checkClaudeVersion(pi: ExtensionAPI, signal: AbortSignal): Promise<string> {
246
+ async function checkCliVersion(pi: ExtensionAPI, binary: string, displayName: string, signal: AbortSignal): Promise<string> {
980
247
  const runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
981
248
  try {
982
- const result = await pi.exec("claude", ["--version"], { cwd: runDirectory, signal, timeout: 10_000 });
249
+ const result = await pi.exec(binary, ["--version"], { cwd: runDirectory, signal, timeout: 10_000 });
983
250
  if (signal.aborted) throw new Error("Canceled.");
984
251
  if (result.killed || result.code !== 0) {
985
252
  const detail = result.stderr.trim() || result.stdout.trim();
986
253
  throw new Error(
987
254
  detail
988
- ? `Claude could not start: ${detail}\n\nRun \`/bro doctor\` for setup help.`
989
- : "Claude could not start. Make sure Claude is installed and on PATH, then run `/bro doctor`.",
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\`.`,
990
257
  );
991
258
  }
992
259
  const version = result.stdout.trim() || result.stderr.trim();
993
- if (!version) throw new Error("Claude returned no version information. Update Claude, then run `/bro doctor` again.");
260
+ if (!version) throw new Error(`${displayName} returned no version information. Update ${displayName}, then run \`/bro doctor\` again.`);
994
261
  return version;
995
262
  } finally {
996
263
  await rm(runDirectory, { recursive: true, force: true });
@@ -1017,48 +284,6 @@ async function checkClaudeAuth(pi: ExtensionAPI, signal: AbortSignal): Promise<s
1017
284
  }
1018
285
  }
1019
286
 
1020
- async function checkGrokVersion(pi: ExtensionAPI, signal: AbortSignal): Promise<string> {
1021
- const runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
1022
- try {
1023
- const result = await pi.exec("grok", ["--version"], { cwd: runDirectory, signal, timeout: 10_000 });
1024
- if (signal.aborted) throw new Error("Canceled.");
1025
- if (result.killed || result.code !== 0) {
1026
- const detail = result.stderr.trim() || result.stdout.trim();
1027
- throw new Error(
1028
- detail
1029
- ? `Grok could not start: ${detail}\n\nRun \`/bro doctor\` for setup help.`
1030
- : "Grok could not start. Make sure Grok is installed and on PATH, then run `/bro doctor`.",
1031
- );
1032
- }
1033
- const version = result.stdout.trim() || result.stderr.trim();
1034
- if (!version) throw new Error("Grok returned no version information. Update Grok, then run `/bro doctor` again.");
1035
- return version;
1036
- } finally {
1037
- await rm(runDirectory, { recursive: true, force: true });
1038
- }
1039
- }
1040
-
1041
- async function checkCodexVersion(pi: ExtensionAPI, signal: AbortSignal): Promise<string> {
1042
- const runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
1043
- try {
1044
- const result = await pi.exec("codex", ["--version"], { cwd: runDirectory, signal, timeout: 10_000 });
1045
- if (signal.aborted) throw new Error("Canceled.");
1046
- if (result.killed || result.code !== 0) {
1047
- const detail = result.stderr.trim() || result.stdout.trim();
1048
- throw new Error(
1049
- detail
1050
- ? `Codex could not start: ${detail}\n\nRun \`/bro doctor\` for setup help.`
1051
- : "Codex could not start. Make sure Codex is installed and on PATH, then run `/bro doctor`.",
1052
- );
1053
- }
1054
- const version = result.stdout.trim() || result.stderr.trim();
1055
- if (!version) throw new Error("Codex returned no version information. Update Codex, then run `/bro doctor` again.");
1056
- return version;
1057
- } finally {
1058
- await rm(runDirectory, { recursive: true, force: true });
1059
- }
1060
- }
1061
-
1062
287
  // Auth status only: a version/auth answer without a model request. A passing answer here
1063
288
  // says the CLI starts and reports signed-in state -- it does not imply connectivity.
1064
289
  async function checkCodexAuth(pi: ExtensionAPI, signal: AbortSignal): Promise<string> {
@@ -1078,27 +303,6 @@ async function checkCodexAuth(pi: ExtensionAPI, signal: AbortSignal): Promise<st
1078
303
  }
1079
304
  }
1080
305
 
1081
- async function checkMuseVersion(pi: ExtensionAPI, signal: AbortSignal): Promise<string> {
1082
- const runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
1083
- try {
1084
- const result = await pi.exec("muse", ["--version"], { cwd: runDirectory, signal, timeout: 10_000 });
1085
- if (signal.aborted) throw new Error("Canceled.");
1086
- if (result.killed || result.code !== 0) {
1087
- const detail = result.stderr.trim() || result.stdout.trim();
1088
- throw new Error(
1089
- detail
1090
- ? `Muse could not start: ${detail}\n\nRun \`/bro doctor\` for setup help.`
1091
- : "Muse could not start. Make sure Muse is installed and on PATH, then run `/bro doctor`.",
1092
- );
1093
- }
1094
- const version = result.stdout.trim() || result.stderr.trim();
1095
- if (!version) throw new Error("Muse returned no version information. Update Muse, then run `/bro doctor` again.");
1096
- return version;
1097
- } finally {
1098
- await rm(runDirectory, { recursive: true, force: true });
1099
- }
1100
- }
1101
-
1102
306
  async function checkAgyUsage(pi: ExtensionAPI, signal: AbortSignal): Promise<string> {
1103
307
  const runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
1104
308
  try {
@@ -1119,7 +323,7 @@ async function checkAgyUsage(pi: ExtensionAPI, signal: AbortSignal): Promise<str
1119
323
  }
1120
324
  }
1121
325
 
1122
- async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, signal: AbortSignal): Promise<string> {
326
+ export async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, signal: AbortSignal): Promise<string> {
1123
327
  const lines: string[] = [];
1124
328
  let failed = false;
1125
329
  let settings: BroSettings | undefined;
@@ -1139,19 +343,22 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
1139
343
  }
1140
344
 
1141
345
  try {
1142
- const prompt = await promptFor("", settings?.mode ?? DEFAULT_BRO_MODE);
1143
- pass("Prompt", prompt.custom ? "valid custom override" : `valid built-in ${settings?.mode ?? DEFAULT_BRO_MODE} mode`);
346
+ const preferences = await readPreferences();
347
+ pass("Preferences", preferences ? `${preferences.length.toLocaleString("en-US")} characters · used by explain, show, btw` : "none — /bro preferences to add some");
1144
348
  } catch (error) {
1145
- fail("Prompt", error);
349
+ fail("Preferences", error);
1146
350
  }
1147
351
 
1148
352
  // Probe only the backends some feature actually selects: a Claude/Grok/Codex/Muse-only setup never
1149
353
  // requires Agy to be installed, and vice versa.
1150
- const agyInUse = !settings || capabilityBackend(settings, "explain") === "agy" || capabilityBackend(settings, "show") === "agy" || capabilityBackend(settings, "btw") === "agy" || capabilityBackend(settings, "advisor") === "agy" || (settings.backend ?? "agy") === "agy";
1151
- const claudeInUse = !!settings && (capabilityBackend(settings, "explain") === "claude" || capabilityBackend(settings, "show") === "claude" || capabilityBackend(settings, "btw") === "claude" || capabilityBackend(settings, "advisor") === "claude" || settings.backend === "claude");
1152
- const grokInUse = !!settings && (capabilityBackend(settings, "explain") === "grok" || capabilityBackend(settings, "show") === "grok" || capabilityBackend(settings, "btw") === "grok" || capabilityBackend(settings, "advisor") === "grok" || settings.backend === "grok");
1153
- const codexInUse = !!settings && (capabilityBackend(settings, "explain") === "codex" || capabilityBackend(settings, "show") === "codex" || capabilityBackend(settings, "btw") === "codex" || capabilityBackend(settings, "advisor") === "codex" || settings.backend === "codex");
1154
- const museInUse = !!settings && (capabilityBackend(settings, "explain") === "muse" || capabilityBackend(settings, "show") === "muse" || capabilityBackend(settings, "btw") === "muse" || capabilityBackend(settings, "advisor") === "muse" || settings.backend === "muse");
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");
1155
362
 
1156
363
  let agyStarted = false;
1157
364
  if (agyInUse) {
@@ -1189,7 +396,7 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
1189
396
  // starts and reports signed-in state -- it does not imply connectivity.
1190
397
  if (claudeInUse) {
1191
398
  try {
1192
- pass("Claude", await checkClaudeVersion(pi, signal));
399
+ pass("Claude", await checkCliVersion(pi, "claude", "Claude", signal));
1193
400
  } catch (error) {
1194
401
  if (signal.aborted) throw error;
1195
402
  fail("Claude", error);
@@ -1208,7 +415,7 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
1208
415
  // version says the CLI starts — it says nothing about auth or connectivity.
1209
416
  if (grokInUse) {
1210
417
  try {
1211
- pass("Grok", await checkGrokVersion(pi, signal));
418
+ pass("Grok", await checkCliVersion(pi, "grok", "Grok", signal));
1212
419
  } catch (error) {
1213
420
  if (signal.aborted) throw error;
1214
421
  fail("Grok", error);
@@ -1221,7 +428,7 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
1221
428
  // Version and auth status only, never a model request.
1222
429
  if (codexInUse) {
1223
430
  try {
1224
- pass("Codex", await checkCodexVersion(pi, signal));
431
+ pass("Codex", await checkCliVersion(pi, "codex", "Codex", signal));
1225
432
  } catch (error) {
1226
433
  if (signal.aborted) throw error;
1227
434
  fail("Codex", error);
@@ -1240,7 +447,7 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
1240
447
  // version says the CLI starts — it says nothing about auth or connectivity.
1241
448
  if (museInUse) {
1242
449
  try {
1243
- pass("Muse", await checkMuseVersion(pi, signal));
450
+ pass("Muse", await checkCliVersion(pi, "muse", "Muse", signal));
1244
451
  } catch (error) {
1245
452
  if (signal.aborted) throw error;
1246
453
  fail("Muse", error);
@@ -1310,6 +517,8 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
1310
517
  const override = capabilityOverride(settings, capability);
1311
518
  const backend = capabilityBackend(settings, capability);
1312
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; }
1313
522
  if (backend === "claude") {
1314
523
  if (!isClaudeEffort(pair.effort)) {
1315
524
  fail(label, `\`${pair.effort}\` is unsupported for claude \`${pair.model}\`. Run \`/bro config\` to fix this.`);
@@ -1391,7 +600,14 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
1391
600
  const advisorActive = pi.getActiveTools().includes(ADVISOR_TOOL_NAME);
1392
601
  if (advisorExposed && !advisorActive) fail("Advisor tool", "bro_advisor is exposed but not active in this session. Run /reload.");
1393
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)");
1394
- pass("Advisor steering", resolveAdvisorState(ctx.sessionManager.getBranch()).steering.trim() ? "present" : "none");
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
+ }
1395
611
  if (settings && capabilityBackend(settings, "advisor") === "claude") {
1396
612
  pass("Advisor compatibility", "Claude backend — no Agy version floor applies");
1397
613
  } else if (settings && capabilityBackend(settings, "advisor") === "grok") {
@@ -1596,20 +812,69 @@ function openShowHtml(path: string): boolean {
1596
812
  return result.status === 0;
1597
813
  }
1598
814
 
1599
- async function promptFor(response: string, mode: BroMode): Promise<{ text: string; custom: boolean }> {
1600
- let template: string;
815
+ // Raw file text for the editor, undefined when there is no file: never rejects for length, so an
816
+ // oversize file can be opened and trimmed.
817
+ export async function readPreferencesRaw(): Promise<string | undefined> {
818
+ let size: number;
1601
819
  try {
1602
- template = await readFile(PROMPT_FILE, "utf8");
820
+ size = (await stat(PREFERENCES_FILE)).size;
1603
821
  } catch (error) {
1604
- if ((error as NodeJS.ErrnoException).code === "ENOENT") {
1605
- return { text: buildDefaultPrompt(response, mode), custom: false };
1606
- }
1607
- throw error;
822
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
823
+ throw new Error(`Cannot read ${PREFERENCES_FILE}: ${errorMessage(error)}`);
824
+ }
825
+ if (size > MAX_PREFERENCES_FILE_BYTES) throw new Error(`${PREFERENCES_FILE} is larger than 64 KB. Edit or delete it directly.`);
826
+ try {
827
+ return (await readFile(PREFERENCES_FILE, "utf8")).replace(/^\uFEFF/, "");
828
+ } catch (error) {
829
+ throw new Error(`Cannot read ${PREFERENCES_FILE}: ${errorMessage(error)}`);
830
+ }
831
+ }
832
+
833
+ // Preferences for prompts: "" when absent or blank. Re-read on every request. An oversize file stops
834
+ // the request before any backend call instead of being silently truncated.
835
+ export async function readPreferences(): Promise<string> {
836
+ const text = ((await readPreferencesRaw()) ?? "").trim();
837
+ if (text.length > MAX_PREFERENCES_CHARS) {
838
+ throw new Error(`${PREFERENCES_FILE} is ${text.length.toLocaleString("en-US")} characters; keep it under ${MAX_PREFERENCES_CHARS.toLocaleString("en-US")} (it's sent with every request). Use /bro preferences to trim it.`);
839
+ }
840
+ return text;
841
+ }
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.`);
1608
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
+ }
1609
867
 
1610
- const parts = template.split("{{response}}");
1611
- if (parts.length !== 2) throw new Error(`${PROMPT_FILE} must contain {{response}} exactly once.`);
1612
- return { text: parts.join(JSON.stringify(response)), custom: true };
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;
1613
878
  }
1614
879
 
1615
880
  async function simplify(
@@ -1618,10 +883,11 @@ async function simplify(
1618
883
  settings: BroSettings,
1619
884
  onProgress?: (text: string) => void,
1620
885
  mode = settings.mode,
1621
- ): Promise<{ text: string; model: string; mode?: BroMode }> {
886
+ ): Promise<{ text: string; model: string; mode: BroMode; preferences: boolean }> {
1622
887
  const selection = selectionForCapability(settings, "explain");
1623
- const prompt = await promptFor(response, mode);
1624
- return { text: await runAgyText(prompt.text, selection, signal, onProgress), model: selectionLabel(selection), ...(prompt.custom ? {} : { mode }) };
888
+ const preferences = await readPreferences();
889
+ const text = await runBackendText(buildDefaultPrompt(response, mode, preferences), selection, signal, onProgress);
890
+ return { text, model: selectionLabel(selection), mode, preferences: Boolean(preferences) };
1625
891
  }
1626
892
 
1627
893
  async function runShowExplanation(
@@ -1630,15 +896,17 @@ async function runShowExplanation(
1630
896
  signal: AbortSignal,
1631
897
  settings: BroSettings,
1632
898
  onProgress?: (text: string) => void,
1633
- ): Promise<{ text: string; model: string }> {
899
+ ): Promise<{ text: string; model: string; preferences: boolean }> {
1634
900
  const selection = selectionForCapability(settings, "show");
1635
- return { text: await runAgyText(buildShowPrompt(transcript, steering), selection, signal, onProgress, "show"), model: selectionLabel(selection) };
901
+ const preferences = await readPreferences();
902
+ const text = await runBackendText(buildShowPrompt(transcript, steering, preferences), selection, signal, onProgress, "show");
903
+ return { text, model: selectionLabel(selection), preferences: Boolean(preferences) };
1636
904
  }
1637
905
 
1638
906
  // Thin presentation-boundary wrapper around the shared backend: coalesces raw text progress to the
1639
- // existing 75ms cadence (unchanged from before the backend extraction) and translates the backend's
1640
- // tagged outcome back into this function's existing throw-on-failure contract.
1641
- async function runAgyText(
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(
1642
910
  prompt: string,
1643
911
  selection: BackendSelection,
1644
912
  signal: AbortSignal,
@@ -1889,10 +1157,11 @@ export type AdvisorToolDetails = {
1889
1157
  durationMs?: number;
1890
1158
  cwd?: string;
1891
1159
  steeringIncluded?: boolean;
1160
+ steeringSources?: { durable: boolean; session: boolean };
1892
1161
  snapshotChars?: number;
1893
1162
  broTruncated?: false;
1894
1163
  omissions?: string;
1895
- // Real Agy-reported activity for the current attempt only -- reset to empty whenever a new
1164
+ // Real backend-reported activity for the current attempt only -- reset to empty whenever a new
1896
1165
  // attempt (including a retry) starts, never carried over from a prior failed attempt.
1897
1166
  activity?: string[];
1898
1167
  lastActivityAt?: number;
@@ -1910,9 +1179,9 @@ export type AdvisorRunResult = { advice: string; attempts: number; durationMs: n
1910
1179
  // keeps ticking independently.
1911
1180
  const ADVISOR_ACTIVITY_THROTTLE_MS = 250;
1912
1181
 
1913
- // `consult`/`delayFn` are injectable so tests can swap in a fake agy spawn and a fake clock
1182
+ // `consult`/`delayFn` are injectable so tests can swap in a fake process spawn and a fake clock
1914
1183
  // instead of spawning real processes and waiting 15 real seconds. Every attempt sends the
1915
- // identical prompt/selection/cwd to a fresh, standalone Agy process — never resumed via
1184
+ // identical prompt/selection/cwd to a fresh, standalone backend process — never resumed via
1916
1185
  // --conversation, even across retries.
1917
1186
  export async function runAdvisorWithRetries(
1918
1187
  prompt: string,
@@ -2033,475 +1302,6 @@ export function advisorAttemptLabel(details: AdvisorToolDetails): string {
2033
1302
  return `Bro advisor · running · ${Math.floor(details.elapsedMs / 1_000)}s · attempt ${details.attempt}/${details.of} · ${activityLabel}`;
2034
1303
  }
2035
1304
 
2036
- const SHOW_TURNS_PRESETS = [1, 2, 3, 5, 8];
2037
-
2038
- function showTurnsValues(current: number): string[] {
2039
- return [...new Set([...SHOW_TURNS_PRESETS, current])].sort((a, b) => a - b).map(String);
2040
- }
2041
-
2042
- // A resolved pair's display string: distinguishes a model that is genuinely fixed-effort from
2043
- // one that simply isn't in the current catalog (both used to render as "fixed", which read as
2044
- // falsely healthy for an unavailable model), and flags a stored effort that isn't one of the
2045
- // resolved family's supported efforts instead of silently showing it as if it were valid.
2046
- function effortDisplay(resolved: { pair: ModelEffortPair; family?: AgyModelFamily }, backend: BackendName): string {
2047
- // Claude/Grok/Codex/Muse selections never touch the Agy catalog: the stored effort is valid exactly
2048
- // when it is one of that backend's levels (or "default" for the CLI's own default).
2049
- if (backend === "claude") return isClaudeEffort(resolved.pair.effort) ? resolved.pair.effort : `${resolved.pair.effort} (unsupported)`;
2050
- if (backend === "grok") return isGrokEffort(resolved.pair.effort) ? resolved.pair.effort : `${resolved.pair.effort} (unsupported)`;
2051
- if (backend === "codex") return isCodexEffort(resolved.pair.effort) ? resolved.pair.effort : `${resolved.pair.effort} (unsupported)`;
2052
- if (backend === "muse") return isMuseEffort(resolved.pair.effort) ? resolved.pair.effort : `${resolved.pair.effort} (unsupported)`;
2053
- if (!resolved.family) return "unavailable";
2054
- const fixed = !resolved.family.efforts.length;
2055
- const valid = fixed ? resolved.pair.effort === "default" : resolved.family.efforts.includes(resolved.pair.effort as AgyEffort);
2056
- if (!valid) return `${resolved.pair.effort} (unsupported)`;
2057
- return fixed ? "fixed" : resolved.pair.effort;
2058
- }
2059
-
2060
- // Model-picker values that switch backend carry a "claude:"/"grok:"/"codex:"/"muse:" prefix so one
2061
- // atomic picker commit changes backend+model together -- cancelling the picker (Esc)
2062
- // leaves both unchanged via the existing submenu-cancel path.
2063
- const CLAUDE_OPTION_PREFIX = "claude:";
2064
- function parseClaudeOption(value: string): string | undefined {
2065
- return value.startsWith(CLAUDE_OPTION_PREFIX) && value.length > CLAUDE_OPTION_PREFIX.length
2066
- ? value.slice(CLAUDE_OPTION_PREFIX.length)
2067
- : undefined;
2068
- }
2069
- const GROK_OPTION_PREFIX = "grok:";
2070
- function parseGrokOption(value: string): string | undefined {
2071
- return value.startsWith(GROK_OPTION_PREFIX) && value.length > GROK_OPTION_PREFIX.length
2072
- ? value.slice(GROK_OPTION_PREFIX.length)
2073
- : undefined;
2074
- }
2075
- const CODEX_OPTION_PREFIX = "codex:";
2076
- function parseCodexOption(value: string): string | undefined {
2077
- return value.startsWith(CODEX_OPTION_PREFIX) && value.length > CODEX_OPTION_PREFIX.length
2078
- ? value.slice(CODEX_OPTION_PREFIX.length)
2079
- : undefined;
2080
- }
2081
- const MUSE_OPTION_PREFIX = "muse:";
2082
- function parseMuseOption(value: string): string | undefined {
2083
- return value.startsWith(MUSE_OPTION_PREFIX) && value.length > MUSE_OPTION_PREFIX.length
2084
- ? value.slice(MUSE_OPTION_PREFIX.length)
2085
- : undefined;
2086
- }
2087
- function parseBackendOption(value: string): { backend: "claude" | "grok" | "codex" | "muse"; id: string } | undefined {
2088
- const claudeId = parseClaudeOption(value);
2089
- if (claudeId !== undefined) return { backend: "claude", id: claudeId };
2090
- const grokId = parseGrokOption(value);
2091
- if (grokId !== undefined) return { backend: "grok", id: grokId };
2092
- const codexId = parseCodexOption(value);
2093
- if (codexId !== undefined) return { backend: "codex", id: codexId };
2094
- const museId = parseMuseOption(value);
2095
- if (museId !== undefined) return { backend: "muse", id: museId };
2096
- return undefined;
2097
- }
2098
-
2099
- // Testable core: takes settings/catalog/persist as plain arguments so smoke tests can drive
2100
- // the exact interaction (submenus, cancel, cycling, save failure) without a real Agy process
2101
- // or settings file. showBroConfigModal below wires this to the real ctx/pi/filesystem.
2102
- export function createConfigModal(
2103
- initialSettings: BroSettings,
2104
- families: AgyModelFamily[],
2105
- persistSettings: (settings: BroSettings) => Promise<void>,
2106
- ): (tui: TuiLike, theme: Theme, keybindings: unknown, done: (value?: void) => void) => Component & { dispose?(): void } {
2107
- return (tui, theme, _keybindings, done) => {
2108
- let settings = initialSettings;
2109
- // The last settings actually confirmed on disk. A failed save reverts `settings` (and the
2110
- // whole displayed row set) back to this, so the screen never shows state that doesn't exist.
2111
- let savedSettings = initialSettings;
2112
- // Only one persistSettings call is ever in flight. A change that arrives while one is
2113
- // already running is coalesced into `queued` (overwriting any earlier queued change) rather
2114
- // than firing a second concurrent write — this is what keeps writes serialized and makes
2115
- // sure the on-disk file always converges on the latest intent instead of a stale one that
2116
- // happened to finish last.
2117
- let saving = false;
2118
- let queued: BroSettings | undefined;
2119
- // Esc while a save is in flight must not close past an unshown result: it requests a close
2120
- // that only actually happens once the in-flight (and any coalesced) save has settled, and
2121
- // only if it succeeded — a failure cancels the pending close so its notice stays visible.
2122
- let closeRequested = false;
2123
-
2124
- const findFamily = (modelId: string) =>
2125
- families.find((item) => item.id === modelId || item.variants.some((variant) => variant.id === modelId));
2126
-
2127
- const modelPicker = (current: string, pickerDone: (value?: string) => void, capability?: Capability) => {
2128
- const defaultResolved = resolveModelEffort({ model: settings.model, effort: settings.effort }, families);
2129
- const options: SelectItem[] = [
2130
- ...(capability ? [{ value: "__default__", label: `Default (${defaultResolved.family?.label ?? settings.model})` }] : []),
2131
- ...families.map((family) => ({
2132
- value: family.id,
2133
- label: `${family.label}${family.efforts.length ? "" : " · fixed effort"}`,
2134
- })),
2135
- // Alternative CLI entries stay last so existing Agy keyboard navigation is unaffected.
2136
- ...CLAUDE_MODELS.map((model) => ({
2137
- value: `${CLAUDE_OPTION_PREFIX}${model.id}`,
2138
- label: `${model.label} · claude`,
2139
- })),
2140
- ...GROK_MODELS.map((model) => ({
2141
- value: `${GROK_OPTION_PREFIX}${model.id}`,
2142
- label: `${model.label} · grok`,
2143
- })),
2144
- ...CODEX_MODELS.map((model) => ({
2145
- value: `${CODEX_OPTION_PREFIX}${model.id}`,
2146
- label: `${model.label} · codex`,
2147
- })),
2148
- ...MUSE_MODELS.map((model) => ({
2149
- value: `${MUSE_OPTION_PREFIX}${model.id}`,
2150
- label: `${model.label} · muse`,
2151
- })),
2152
- ];
2153
- options.push({ value: "__claude_custom__", label: "Claude · custom model ID…" });
2154
- options.push({ value: "__grok_custom__", label: "Grok · custom model ID…" });
2155
- options.push({ value: "__codex_custom__", label: "Codex · custom model ID…" });
2156
- options.push({ value: "__muse_custom__", label: "Muse · custom model ID…" });
2157
- const input = new Input();
2158
- let enteringBackend: "claude" | "grok" | "codex" | "muse" | undefined;
2159
- input.onSubmit = (value) => {
2160
- if (value.trim() && enteringBackend) {
2161
- const prefix =
2162
- enteringBackend === "claude"
2163
- ? CLAUDE_OPTION_PREFIX
2164
- : enteringBackend === "grok"
2165
- ? GROK_OPTION_PREFIX
2166
- : enteringBackend === "codex"
2167
- ? CODEX_OPTION_PREFIX
2168
- : MUSE_OPTION_PREFIX;
2169
- pickerDone(`${prefix}${value.trim()}`);
2170
- }
2171
- };
2172
- const picker = new SelectList(options, Math.min(options.length, 8), getSelectListTheme());
2173
- const selectedIndex = capability && current === "Default" ? 0 : options.findIndex((option) => option.value === current);
2174
- picker.setSelectedIndex(Math.max(0, selectedIndex));
2175
- picker.onSelect = (item) => {
2176
- if (item.value === "__claude_custom__") { enteringBackend = "claude"; tui.requestRender(); }
2177
- else if (item.value === "__grok_custom__") { enteringBackend = "grok"; tui.requestRender(); }
2178
- else if (item.value === "__codex_custom__") { enteringBackend = "codex"; tui.requestRender(); }
2179
- else if (item.value === "__muse_custom__") { enteringBackend = "muse"; tui.requestRender(); }
2180
- else pickerDone(item.value);
2181
- };
2182
- picker.onCancel = () => pickerDone();
2183
- const customLabel = () => enteringBackend === "claude" ? "Claude" : enteringBackend === "grok" ? "Grok" : enteringBackend === "codex" ? "Codex" : "Muse";
2184
- return {
2185
- render: (width: number) => enteringBackend ? [`${customLabel()} model ID (Enter saves, Esc cancels)`, ...input.render(width)] : picker.render(width),
2186
- invalidate: () => { picker.invalidate(); input.invalidate(); },
2187
- handleInput: (data: string) => {
2188
- if (enteringBackend && matchesKey(data, "escape")) pickerDone();
2189
- else if (enteringBackend) input.handleInput(data);
2190
- else picker.handleInput(data);
2191
- },
2192
- };
2193
- };
2194
-
2195
- const modelItem: SettingItem = { id: "model", label: "Default model", currentValue: settings.model, submenu: modelPicker };
2196
- const effortItem: SettingItem = { id: "effort", label: "Default effort", currentValue: "" };
2197
- const modeItem: SettingItem = { id: "mode", label: "Explain mode", currentValue: settings.mode, values: [...BRO_MODES] };
2198
- const showTurnsItem: SettingItem = {
2199
- id: "showTurns",
2200
- label: "Show turns",
2201
- currentValue: String(settings.showTurns),
2202
- values: showTurnsValues(settings.showTurns),
2203
- };
2204
- const capabilityItems = Object.fromEntries(
2205
- CAPABILITIES.map((capability) => [
2206
- capability,
2207
- {
2208
- model: {
2209
- id: `${capability}Model`,
2210
- label: `${CAPABILITY_LABELS[capability]} model`,
2211
- currentValue: "Default",
2212
- submenu: (current: string, pickerDone: (value?: string) => void) => modelPicker(current, pickerDone, capability),
2213
- } as SettingItem,
2214
- effort: {
2215
- id: `${capability}Effort`,
2216
- label: `${CAPABILITY_LABELS[capability]} effort`,
2217
- currentValue: "",
2218
- } as SettingItem,
2219
- },
2220
- ]),
2221
- ) as Record<Capability, { model: SettingItem; effort: SettingItem }>;
2222
-
2223
- function refresh(): void {
2224
- const defaultBackend = settings.backend ?? "agy";
2225
- const def = resolveModelEffort({ backend: settings.backend, model: settings.model, effort: settings.effort }, families);
2226
- modelItem.currentValue =
2227
- defaultBackend === "claude"
2228
- ? `${CLAUDE_OPTION_PREFIX}${settings.model}`
2229
- : defaultBackend === "grok"
2230
- ? `${GROK_OPTION_PREFIX}${settings.model}`
2231
- : defaultBackend === "codex"
2232
- ? `${CODEX_OPTION_PREFIX}${settings.model}`
2233
- : defaultBackend === "muse"
2234
- ? `${MUSE_OPTION_PREFIX}${settings.model}`
2235
- : (def.family?.id ?? settings.model);
2236
- effortItem.currentValue = effortDisplay(def, defaultBackend);
2237
- effortItem.values =
2238
- defaultBackend === "claude"
2239
- ? ["default", ...CLAUDE_EFFORTS]
2240
- : defaultBackend === "grok"
2241
- ? ["default", ...GROK_EFFORTS]
2242
- : defaultBackend === "codex"
2243
- ? ["default", ...CODEX_EFFORTS]
2244
- : defaultBackend === "muse"
2245
- ? ["default", ...MUSE_EFFORTS]
2246
- : def.family?.efforts.length
2247
- ? [...def.family.efforts]
2248
- : undefined;
2249
- modeItem.currentValue = settings.mode;
2250
- showTurnsItem.currentValue = String(settings.showTurns);
2251
- showTurnsItem.values = showTurnsValues(settings.showTurns);
2252
-
2253
- for (const capability of CAPABILITIES) {
2254
- const override = capabilityOverride(settings, capability);
2255
- const backend = capabilityBackend(settings, capability);
2256
- const resolved = resolveModelEffort(capabilityPair(settings, capability), families);
2257
- const rows = capabilityItems[capability];
2258
- rows.model.currentValue = !override
2259
- ? "Default"
2260
- : backend === "claude"
2261
- ? `${CLAUDE_OPTION_PREFIX}${override.model}`
2262
- : backend === "grok"
2263
- ? `${GROK_OPTION_PREFIX}${override.model}`
2264
- : backend === "codex"
2265
- ? `${CODEX_OPTION_PREFIX}${override.model}`
2266
- : backend === "muse"
2267
- ? `${MUSE_OPTION_PREFIX}${override.model}`
2268
- : (resolved.family?.id ?? override.model);
2269
- rows.effort.currentValue = effortDisplay(resolved, backend);
2270
- rows.effort.values =
2271
- backend === "claude"
2272
- ? ["default", ...CLAUDE_EFFORTS]
2273
- : backend === "grok"
2274
- ? ["default", ...GROK_EFFORTS]
2275
- : backend === "codex"
2276
- ? ["default", ...CODEX_EFFORTS]
2277
- : backend === "muse"
2278
- ? ["default", ...MUSE_EFFORTS]
2279
- : resolved.family?.efforts.length
2280
- ? [...resolved.family.efforts]
2281
- : undefined;
2282
- }
2283
- }
2284
- refresh();
2285
-
2286
- const items: SettingItem[] = [
2287
- modelItem,
2288
- effortItem,
2289
- modeItem,
2290
- showTurnsItem,
2291
- ...CAPABILITIES.flatMap((capability) => [capabilityItems[capability].model, capabilityItems[capability].effort]),
2292
- ];
2293
-
2294
- const noticeText = new Text("");
2295
-
2296
- function runSave(toSave: BroSettings): void {
2297
- saving = true;
2298
- void persistSettings(toSave)
2299
- .then(() => {
2300
- savedSettings = toSave;
2301
- noticeText.setText("");
2302
- })
2303
- .catch((error: unknown) => {
2304
- // Restore the last state that is actually on disk: showing the failed, unsaved
2305
- // value would let the screen claim a setting that doesn't really exist.
2306
- settings = savedSettings;
2307
- queued = undefined;
2308
- closeRequested = false;
2309
- refresh();
2310
- noticeText.setText(theme.fg("warning", `Could not save settings: ${errorMessage(error)}. Reverted to the last saved settings.`));
2311
- })
2312
- .finally(() => {
2313
- saving = false;
2314
- tui.requestRender();
2315
- if (queued !== undefined) {
2316
- const next = queued;
2317
- queued = undefined;
2318
- runSave(next);
2319
- } else if (closeRequested) {
2320
- closeRequested = false;
2321
- done(undefined);
2322
- }
2323
- });
2324
- }
2325
-
2326
- function persist(toSave: BroSettings): void {
2327
- if (saving) {
2328
- queued = toSave;
2329
- return;
2330
- }
2331
- runSave(toSave);
2332
- }
2333
-
2334
- const onChange = (id: string, newValue: string) => {
2335
- if (id === "model") {
2336
- const switched = parseBackendOption(newValue);
2337
- if (switched !== undefined) {
2338
- if (switched.backend === "claude") {
2339
- const effort = settings.backend === "claude" && isClaudeEffort(settings.effort) ? settings.effort : "default";
2340
- settings = { ...settings, backend: "claude", model: resolveClaudeModel(switched.id), effort };
2341
- } else if (switched.backend === "grok") {
2342
- const effort = settings.backend === "grok" && isGrokEffort(settings.effort) ? settings.effort : "default";
2343
- settings = { ...settings, backend: "grok", model: resolveGrokModel(switched.id), effort };
2344
- } else if (switched.backend === "codex") {
2345
- const effort = settings.backend === "codex" && isCodexEffort(settings.effort) ? settings.effort : "default";
2346
- settings = { ...settings, backend: "codex", model: resolveCodexModel(switched.id), effort };
2347
- } else {
2348
- const effort = settings.backend === "muse" && isMuseEffort(settings.effort) ? settings.effort : "default";
2349
- settings = { ...settings, backend: "muse", model: resolveMuseModel(switched.id), effort };
2350
- }
2351
- } else {
2352
- const family = findFamily(newValue);
2353
- if (!family) return;
2354
- const keepCurrent = (settings.backend ?? "agy") === "agy" && (settings.effort === "default" ? !family.efforts.length : family.efforts.includes(settings.effort as AgyEffort));
2355
- // Backend-less internal pairs mean Agy; disk saves still tag them explicitly.
2356
- const { backend: _dropped, ...rest } = settings;
2357
- settings = { ...rest, model: family.id, effort: keepCurrent ? settings.effort : preferredEffort(family) };
2358
- }
2359
- } else if (id === "effort") {
2360
- if ((settings.backend ?? "agy") === "claude") {
2361
- if (!isClaudeEffort(newValue)) return;
2362
- settings = { ...settings, effort: newValue };
2363
- } else if ((settings.backend ?? "agy") === "grok") {
2364
- if (!isGrokEffort(newValue)) return;
2365
- settings = { ...settings, effort: newValue };
2366
- } else if ((settings.backend ?? "agy") === "codex") {
2367
- if (!isCodexEffort(newValue)) return;
2368
- settings = { ...settings, effort: newValue };
2369
- } else if ((settings.backend ?? "agy") === "muse") {
2370
- if (!isMuseEffort(newValue)) return;
2371
- settings = { ...settings, effort: newValue };
2372
- } else {
2373
- // Effort-only edit: pin the resolved family's canonical id, same reasoning as the
2374
- // capability-override effort-only edit below -- otherwise a shared default created
2375
- // from a suffixed variant id (e.g. "gemini-x-low") would end up paired with an
2376
- // unrelated effort instead of its actual family id.
2377
- const resolved = resolveModelEffort({ model: settings.model, effort: settings.effort }, families);
2378
- settings = { ...settings, model: resolved.family?.id ?? settings.model, effort: newValue as BroEffort };
2379
- }
2380
- } else if (id === "mode") {
2381
- const mode = parseBroMode(newValue);
2382
- if (!mode) return;
2383
- settings = { ...settings, mode };
2384
- } else if (id === "showTurns") {
2385
- const turns = Number(newValue);
2386
- if (!Number.isInteger(turns) || turns < 1) return;
2387
- settings = { ...settings, showTurns: turns };
2388
- } else {
2389
- const capability = CAPABILITIES.find((item) => id === `${item}Model` || id === `${item}Effort`);
2390
- if (!capability) return;
2391
- if (id === `${capability}Model`) {
2392
- if (newValue === "__default__") {
2393
- settings = withCapabilityOverride(settings, capability, undefined);
2394
- } else {
2395
- const switched = parseBackendOption(newValue);
2396
- if (switched !== undefined) {
2397
- const currentEffort = capabilityPair(settings, capability).effort;
2398
- if (switched.backend === "claude") {
2399
- settings = withCapabilityOverride(settings, capability, {
2400
- backend: "claude",
2401
- model: resolveClaudeModel(switched.id),
2402
- effort: capabilityBackend(settings, capability) === "claude" && isClaudeEffort(currentEffort) ? currentEffort : "default",
2403
- });
2404
- } else if (switched.backend === "grok") {
2405
- settings = withCapabilityOverride(settings, capability, {
2406
- backend: "grok",
2407
- model: resolveGrokModel(switched.id),
2408
- effort: capabilityBackend(settings, capability) === "grok" && isGrokEffort(currentEffort) ? currentEffort : "default",
2409
- });
2410
- } else if (switched.backend === "codex") {
2411
- settings = withCapabilityOverride(settings, capability, {
2412
- backend: "codex",
2413
- model: resolveCodexModel(switched.id),
2414
- effort: capabilityBackend(settings, capability) === "codex" && isCodexEffort(currentEffort) ? currentEffort : "default",
2415
- });
2416
- } else {
2417
- settings = withCapabilityOverride(settings, capability, {
2418
- backend: "muse",
2419
- model: resolveMuseModel(switched.id),
2420
- effort: capabilityBackend(settings, capability) === "muse" && isMuseEffort(currentEffort) ? currentEffort : "default",
2421
- });
2422
- }
2423
- } else {
2424
- const family = findFamily(newValue);
2425
- if (!family) return;
2426
- const currentEffort = capabilityPair(settings, capability).effort;
2427
- const keepCurrent = capabilityBackend(settings, capability) === "agy" && (currentEffort === "default" ? !family.efforts.length : family.efforts.includes(currentEffort as AgyEffort));
2428
- settings = withCapabilityOverride(settings, capability, {
2429
- model: family.id,
2430
- effort: keepCurrent ? currentEffort : preferredEffort(family),
2431
- });
2432
- }
2433
- }
2434
- } else if (capabilityBackend(settings, capability) === "claude") {
2435
- if (!isClaudeEffort(newValue)) return;
2436
- const existing = capabilityOverride(settings, capability);
2437
- const model = existing?.model ?? settings.model;
2438
- settings = withCapabilityOverride(settings, capability, { backend: "claude", model, effort: newValue });
2439
- } else if (capabilityBackend(settings, capability) === "grok") {
2440
- if (!isGrokEffort(newValue)) return;
2441
- const existing = capabilityOverride(settings, capability);
2442
- const model = existing?.model ?? settings.model;
2443
- settings = withCapabilityOverride(settings, capability, { backend: "grok", model, effort: newValue });
2444
- } else if (capabilityBackend(settings, capability) === "codex") {
2445
- if (!isCodexEffort(newValue)) return;
2446
- const existing = capabilityOverride(settings, capability);
2447
- const model = existing?.model ?? settings.model;
2448
- settings = withCapabilityOverride(settings, capability, { backend: "codex", model, effort: newValue });
2449
- } else if (capabilityBackend(settings, capability) === "muse") {
2450
- if (!isMuseEffort(newValue)) return;
2451
- const existing = capabilityOverride(settings, capability);
2452
- const model = existing?.model ?? settings.model;
2453
- settings = withCapabilityOverride(settings, capability, { backend: "muse", model, effort: newValue });
2454
- } else {
2455
- // Effort-only edit: pin the resolved family's canonical id, never whatever raw
2456
- // string happens to sit in settings.model/override.model (which — for a shared
2457
- // default created from a suffixed variant id such as "gemini-x-low" with
2458
- // effort "default" — is not the family id). Storing the raw string here would
2459
- // pair a mismatched model/effort (e.g. a "-low"-suffixed id with effort "high").
2460
- const resolved = resolveModelEffort(capabilityPair(settings, capability), families);
2461
- const existing = capabilityOverride(settings, capability);
2462
- const model = resolved.family?.id ?? existing?.model ?? settings.model;
2463
- settings = withCapabilityOverride(settings, capability, { model, effort: newValue as BroEffort });
2464
- }
2465
- }
2466
- refresh();
2467
- tui.requestRender();
2468
- persist(settings);
2469
- };
2470
-
2471
- const requestClose = () => {
2472
- if (saving) {
2473
- closeRequested = true;
2474
- return;
2475
- }
2476
- done(undefined);
2477
- };
2478
-
2479
- const settingsList = new SettingsList(items, Math.min(items.length + 2, 18), getSettingsListTheme(), onChange, requestClose);
2480
- const container = new Container();
2481
- container.addChild(new Text(theme.fg("accent", theme.bold("Bro · config"))));
2482
- container.addChild(new Text(theme.fg("dim", "Shared defaults, with optional overrides per capability")));
2483
- container.addChild(settingsList);
2484
- container.addChild(noticeText);
2485
- container.addChild(new Text(theme.fg("dim", "↑/↓ navigate · Enter select/change · Esc back/close")));
2486
-
2487
- return {
2488
- render: (w: number) => {
2489
- const inner = Math.max(1, w - 4);
2490
- const border = (left: string, right: string) => theme.fg("border", left + "─".repeat(inner + 2) + right);
2491
- return [border("┌", "┐"), ...container.render(inner).map(line => {
2492
- const text = truncateToWidth(line, inner, "");
2493
- return theme.fg("border", "│") + " " + text + " ".repeat(Math.max(0, inner - visibleWidth(text))) + " " + theme.fg("border", "│");
2494
- }), border("└", "┘")];
2495
- },
2496
- invalidate: () => container.invalidate(),
2497
- handleInput: (data: string) => {
2498
- settingsList.handleInput?.(data);
2499
- tui.requestRender();
2500
- },
2501
- };
2502
- };
2503
- }
2504
-
2505
1305
  export async function showBroConfigModal(ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> {
2506
1306
  if (!hasBroCustomUi(ctx)) {
2507
1307
  ctx.ui.notify("Use /bro config in Pi's interactive UI.", "warning");
@@ -2534,31 +1334,71 @@ export async function showBroConfigModal(ctx: ExtensionCommandContext, pi: Exten
2534
1334
  });
2535
1335
  }
2536
1336
 
2537
- // Testable core for /bro advisor-steer: persistence only happens on Ctrl+S/Ctrl+K.
2538
- // Esc leaves the stored brief untouched; only the in-memory draft is discarded.
2539
- export function createAdvisorSteerModal(
2540
- initialText: string,
2541
- onSave: (text: string) => void,
2542
- onClear: () => void,
2543
- copy: (text: string) => Promise<void> = copyToClipboard,
1337
+ type TextEditorModalOptions = {
1338
+ title: string;
1339
+ subtitle: string;
1340
+ initialText: string;
1341
+ initialNotice?: string;
1342
+ // Returns an error message to refuse the save without calling onSave.
1343
+ validate?: (text: string) => string | undefined;
1344
+ // Either may be async; the modal then waits, ignores further edits, and reports success only once it settles.
1345
+ onSave: (text: string) => void | Promise<void>;
1346
+ onClear: () => void | Promise<void>;
1347
+ copy?: (text: string) => Promise<void>;
1348
+ };
1349
+
1350
+ // Testable core shared by /bro advisor-steer and /bro preferences: persistence only happens on
1351
+ // Ctrl+S/Ctrl+K. Esc leaves the stored text untouched; only the in-memory draft is discarded.
1352
+ export function createTextEditorModal(
1353
+ options: TextEditorModalOptions,
2544
1354
  ): (tui: TUI, theme: Theme, keybindings: unknown, done: (value?: void) => void) => Component & { dispose?(): void } {
1355
+ const { onSave, onClear, validate, copy = copyToClipboard } = options;
2545
1356
  return (tui, theme, _keybindings, done) => {
2546
1357
  const editorTheme: EditorTheme = { borderColor: (s: string) => theme.fg("border", s), selectList: getSelectListTheme() };
2547
1358
  const editor = new Editor(tui, editorTheme);
2548
1359
  editor.focused = true;
2549
- editor.setText(initialText);
1360
+ editor.setText(options.initialText);
2550
1361
  let disposed = false;
2551
- const notice = new Text("");
1362
+ let pending = false;
1363
+ const notice = new Text(options.initialNotice ? theme.fg("accent", options.initialNotice) : "");
2552
1364
  const showNotice = (message: string, color: "success" | "error") => {
2553
1365
  if (disposed) return;
2554
1366
  notice.setText(theme.fg(color, message));
2555
1367
  tui.requestRender();
2556
1368
  };
2557
1369
  editor.onChange = () => notice.setText("");
1370
+ const persist = (action: () => void | Promise<void>, success: string, failure: string, after?: () => void) => {
1371
+ const fail = (error: unknown) => showNotice(`${failure}: ${errorMessage(error)}`, "error");
1372
+ let result: void | Promise<void>;
1373
+ try {
1374
+ result = action();
1375
+ } catch (error) {
1376
+ fail(error);
1377
+ return;
1378
+ }
1379
+ if (!(result instanceof Promise)) {
1380
+ after?.();
1381
+ showNotice(success, "success");
1382
+ return;
1383
+ }
1384
+ pending = true;
1385
+ result.then(
1386
+ () => {
1387
+ pending = false;
1388
+ if (disposed) return;
1389
+ after?.();
1390
+ showNotice(success, "success");
1391
+ },
1392
+ (error) => {
1393
+ pending = false;
1394
+ fail(error);
1395
+ },
1396
+ );
1397
+ };
2558
1398
 
2559
1399
  const container = new Container();
2560
- container.addChild(new Text(theme.fg("accent", theme.bold("Bro · advisor steer"))));
2561
- container.addChild(new Text(theme.fg("dim", "One persistent steering brief the advisor always sees — never sent to the main model.")));
1400
+ container.addChild(new Text(theme.fg("accent", theme.bold(options.title))));
1401
+ container.addChild(new Text(theme.fg("dim", options.subtitle)));
2562
1402
  container.addChild(editor);
2563
1403
  container.addChild(notice);
2564
1404
  container.addChild(new Text(theme.fg("dim", "Ctrl+S save · Enter newline · Ctrl+K clear · Ctrl+C copy · Esc close")));
@@ -2579,23 +1419,19 @@ export function createAdvisorSteerModal(
2579
1419
  done(undefined);
2580
1420
  return;
2581
1421
  }
1422
+ if (pending) return;
2582
1423
  if (matchesKey(data, "ctrl+s")) {
2583
- try {
2584
- onSave(editor.getExpandedText());
2585
- showNotice("Saved", "success");
2586
- } catch (error) {
2587
- showNotice(`Save failed: ${errorMessage(error)}`, "error");
1424
+ const text = editor.getExpandedText();
1425
+ const invalid = validate?.(text);
1426
+ if (invalid) {
1427
+ showNotice(invalid, "error");
1428
+ return;
2588
1429
  }
1430
+ persist(() => onSave(text), "Saved", "Save failed");
2589
1431
  return;
2590
1432
  }
2591
1433
  if (matchesKey(data, "ctrl+k")) {
2592
- try {
2593
- onClear();
2594
- editor.setText("");
2595
- showNotice("Cleared", "success");
2596
- } catch (error) {
2597
- showNotice(`Clear failed: ${errorMessage(error)}`, "error");
2598
- }
1434
+ persist(onClear, "Cleared", "Clear failed", () => editor.setText(""));
2599
1435
  return;
2600
1436
  }
2601
1437
  if (matchesKey(data, "ctrl+c")) {
@@ -2619,6 +1455,83 @@ export function createAdvisorSteerModal(
2619
1455
  };
2620
1456
  }
2621
1457
 
1458
+ export function createAdvisorSteerModal(
1459
+ initialText: string,
1460
+ onSave: (text: string) => void,
1461
+ onClear: () => void,
1462
+ copy: (text: string) => Promise<void> = copyToClipboard,
1463
+ ): ReturnType<typeof createTextEditorModal> {
1464
+ return createTextEditorModal({
1465
+ title: "Bro · advisor steer",
1466
+ subtitle: `Session-specific steering brief for the advisor (takes precedence over ${ADVISOR_STEERING_FILE}) — never sent to the main model.`,
1467
+ initialText,
1468
+ onSave,
1469
+ onClear,
1470
+ copy,
1471
+ });
1472
+ }
1473
+
1474
+ function preferencesTooLong(text: string): string | undefined {
1475
+ const length = text.replace(/^\uFEFF/, "").trim().length;
1476
+ return length > MAX_PREFERENCES_CHARS
1477
+ ? `${length.toLocaleString("en-US")}/${MAX_PREFERENCES_CHARS.toLocaleString("en-US")} characters — trim before saving`
1478
+ : undefined;
1479
+ }
1480
+
1481
+ // Testable core for /bro preferences. A missing file opens with the unsaved starter text; an
1482
+ // unreadable or oversize-on-disk file opens empty with the error, and Clear still works.
1483
+ export function createPreferencesModal(
1484
+ loaded: { text?: string; error?: string },
1485
+ persist: { save: (text: string) => Promise<void>; clear: () => Promise<void> },
1486
+ copy?: (text: string) => Promise<void>,
1487
+ ): ReturnType<typeof createTextEditorModal> {
1488
+ const starter = loaded.text === undefined && !loaded.error;
1489
+ return createTextEditorModal({
1490
+ title: "Bro · preferences",
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.",
1492
+ initialText: starter ? STARTER_PREFERENCES : (loaded.text ?? ""),
1493
+ initialNotice: loaded.error ?? (starter ? "Starter text — not saved. Ctrl+S saves it; Esc leaves no file." : undefined),
1494
+ validate: preferencesTooLong,
1495
+ onSave: persist.save,
1496
+ onClear: persist.clear,
1497
+ copy,
1498
+ });
1499
+ }
1500
+
1501
+ // Saves and deletes run one at a time, even across a closed and reopened editor, so a slow delete
1502
+ // from an earlier editor can never remove a file saved by a later one.
1503
+ let preferencesWrites: Promise<void> = Promise.resolve();
1504
+ export function queuePreferencesWrite(write: () => Promise<void>): Promise<void> {
1505
+ const next = preferencesWrites.then(write);
1506
+ preferencesWrites = next.catch(() => {});
1507
+ return next;
1508
+ }
1509
+
1510
+ export async function showPreferencesModal(ctx: ExtensionCommandContext): Promise<void> {
1511
+ if (!hasBroCustomUi(ctx)) {
1512
+ ctx.ui.notify(`Edit ${PREFERENCES_FILE} directly.`, "warning");
1513
+ return;
1514
+ }
1515
+ let loaded: { text?: string; error?: string };
1516
+ try {
1517
+ const text = await readPreferencesRaw();
1518
+ loaded = text === undefined ? {} : { text };
1519
+ } catch (error) {
1520
+ loaded = { error: `${errorMessage(error)} Ctrl+K deletes it.` };
1521
+ }
1522
+ await ctx.ui.custom<void>(
1523
+ createPreferencesModal(loaded, {
1524
+ // ponytail: last writer wins against external edits while the editor is open, like writeSettings.
1525
+ save: (text) => queuePreferencesWrite(() => writeFile(PREFERENCES_FILE, `${text.trim()}\n`, "utf8")),
1526
+ clear: () => queuePreferencesWrite(() => rm(PREFERENCES_FILE, { force: true })),
1527
+ }),
1528
+ {
1529
+ overlay: true,
1530
+ overlayOptions: { width: "78%", minWidth: 48, maxHeight: "60%", anchor: "top-center", margin: { top: 1, left: 2, right: 2 } },
1531
+ },
1532
+ );
1533
+ }
1534
+
2622
1535
  export async function showAdvisorSteerModal(ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> {
2623
1536
  if (!hasBroCustomUi(ctx)) {
2624
1537
  ctx.ui.notify("Use /bro advisor-steer in Pi's interactive UI.", "warning");
@@ -2638,7 +1551,9 @@ export async function showAdvisorSteerModal(ctx: ExtensionCommandContext, pi: Ex
2638
1551
  );
2639
1552
  }
2640
1553
 
2641
- export function helpText(settings?: BroSettings, settingsError?: string): string {
1554
+ // `preferences` is a ready status ("off", "on (312 characters)", or "error: …") so a preferences
1555
+ // problem never hides Help or mixes with settings errors.
1556
+ export function helpText(settings?: BroSettings, settingsError?: string, preferences = "off"): string {
2642
1557
  const overrideLines = settings
2643
1558
  ? CAPABILITIES.map((capability) => {
2644
1559
  const override = capabilityOverride(settings, capability);
@@ -2664,6 +1579,13 @@ Quick reference. The README is the full user guide: https://github.com/tranhoang
2664
1579
  - \`/bro open\` — reopen the latest explanation without a new request
2665
1580
  - \`/bro show [n-turns] [query]\` — draw recent turns' conversation text as shapes; an optional query steers the focus
2666
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
+
2667
1589
  ## Side conversation
2668
1590
 
2669
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.
@@ -2674,18 +1596,21 @@ Quick reference. The README is the full user guide: https://github.com/tranhoang
2674
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
2675
1597
  - \`/bro advisor\` — whether \`bro_advisor\` is available right now
2676
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)
2677
1600
 
2678
1601
  ## Configure and check
2679
1602
 
2680
- - \`/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.
2681
1605
  - \`/bro mode [brief|balanced|faithful]\` — view or choose the explanation mode
2682
- - \`/bro doctor\` — check settings, prompt, and every selected backend without running a model turn
1606
+ - \`/bro doctor\` — check settings, preferences, and every selected backend without running a model turn
2683
1607
 
2684
1608
  ## Current settings
2685
1609
 
2686
1610
  ${settingsSummary}
1611
+ - **Preferences:** ${preferences} — \`/bro preferences\`
2687
1612
 
2688
- Saved in \`${SETTINGS_FILE}\`.
1613
+ Saved in \`${settingsFile()}\`, \`${PREFERENCES_FILE}\`, and \`${ADVISOR_STEERING_FILE}\`.
2689
1614
 
2690
1615
  ## Explanation modes
2691
1616
 
@@ -2695,7 +1620,7 @@ Saved in \`${SETTINGS_FILE}\`.
2695
1620
 
2696
1621
  Press **M** in an explanation to re-simplify it in the next mode without changing the saved one.
2697
1622
 
2698
- A valid \`${PROMPT_FILE}\` (with \`{{response}}\` exactly once) overrides the modes; the saved mode stays inactive until you remove or rename it. Show always uses its own prompt.
1623
+ The mode decides how much of the source to keep; your preferences decide who it's written for, its tone, and its language. Neither overrides Bro's source rules.
2699
1624
 
2700
1625
  ## Controls
2701
1626
 
@@ -2708,7 +1633,7 @@ A valid \`${PROMPT_FILE}\` (with \`{{response}}\` exactly once) overrides the mo
2708
1633
 
2709
1634
  ## Privacy
2710
1635
 
2711
- 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. Nothing is added to Pi's conversation unless you insert it. Access controls differ by backend; see the README.`;
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.`;
2712
1637
  }
2713
1638
 
2714
1639
  // The overlay framing pattern is adapted from pi-btw (MIT); see THIRD_PARTY_NOTICES.md.
@@ -2729,6 +1654,8 @@ class BroModal implements Focusable {
2729
1654
  private htmlPath = "";
2730
1655
  // Survives loading/streaming so the header names the mode being produced; empty disables M.
2731
1656
  private modeLabel = "";
1657
+ // Set only with a result, so the tag never claims preferences for work still in flight.
1658
+ private preferencesUsed = false;
2732
1659
 
2733
1660
  constructor(
2734
1661
  private readonly tui: TuiLike,
@@ -2764,6 +1691,11 @@ class BroModal implements Focusable {
2764
1691
  this.tui.requestRender();
2765
1692
  }
2766
1693
 
1694
+ setPreferences(used: boolean): void {
1695
+ this.preferencesUsed = used;
1696
+ this.tui.requestRender();
1697
+ }
1698
+
2767
1699
  setStatic(kind: "help" | "empty", text: string, copyable: boolean): void {
2768
1700
  this.setContent(kind, text, text, copyable, false);
2769
1701
  }
@@ -2849,7 +1781,7 @@ class BroModal implements Focusable {
2849
1781
  this.borderLine(innerWidth, "top"),
2850
1782
  this.frameLine(
2851
1783
  this.theme.fg("accent", this.theme.bold(`Bro${this.sourceLabel ? ` · ${this.sourceLabel}` : ""}`)) +
2852
- this.theme.fg("dim", `${this.modelLabel ? ` · ${this.modelLabel}` : ""}${this.modeLabel ? ` · ${this.modeLabel}` : ""}${scroll}`),
1784
+ this.theme.fg("dim", `${this.modelLabel ? ` · ${this.modelLabel}` : ""}${this.modeLabel ? ` · ${this.modeLabel}` : ""}${this.preferencesUsed ? " · prefs" : ""}${scroll}`),
2853
1785
  innerWidth,
2854
1786
  ),
2855
1787
  this.ruleLine(innerWidth),
@@ -2996,6 +1928,7 @@ async function showBroModal(ctx: ExtensionCommandContext, options: BroModalOptio
2996
1928
  modal.setResult(display, options.retryable ?? Boolean(options.run), notice, result.source?.label, result.text, result.model);
2997
1929
  modal.setHtmlPath(result.htmlPath ?? "");
2998
1930
  modal.setMode(result.mode);
1931
+ modal.setPreferences(Boolean(result.preferences));
2999
1932
  };
3000
1933
 
3001
1934
  execute = (source?: BroSource, mode?: BroMode, notice = "") => {
@@ -3006,6 +1939,7 @@ async function showBroModal(ctx: ExtensionCommandContext, options: BroModalOptio
3006
1939
  inFlightMode = mode;
3007
1940
  modal.setLoading(options.loadingText);
3008
1941
  modal.setMode(mode);
1942
+ modal.setPreferences(false);
3009
1943
 
3010
1944
  void options
3011
1945
  .run(nextController.signal, source, (text) => {
@@ -3080,7 +2014,7 @@ export function resolveBtwThread(existing: BtwThread | undefined): BtwThread {
3080
2014
 
3081
2015
  export function bindBtwBackend(thread: BtwThread, backend: BackendName): boolean {
3082
2016
  const changed = thread.backend !== undefined && thread.backend !== backend;
3083
- if (changed) { thread.turns = []; thread.conversationId = undefined; thread.context = undefined; thread.sessionFull = undefined; }
2017
+ if (changed) { thread.turns = []; thread.conversationId = undefined; thread.context = undefined; thread.sessionFull = undefined; thread.sessionPreferences = undefined; }
3084
2018
  thread.backend = backend;
3085
2019
  return changed;
3086
2020
  }
@@ -3099,13 +2033,20 @@ export function toggleBtwMode(thread: BtwThread): void {
3099
2033
  // /mode switches. An Agy conversation stays bound to the workspace it started in (a sandbox scratch
3100
2034
  // dir vs. the repo), so an access change drops it; the next turn reseeds a fresh native session
3101
2035
  // with the main-session context and the whole thread.
3102
- export function nativeBtwContinuation(thread: BtwThread, backend: BackendName): string | undefined {
2036
+ // A native session remembers every earlier prompt, including old preferences, so a preferences
2037
+ // change (or deletion) on any backend starts a fresh session reseeded with the quoted thread.
2038
+ export function nativeBtwContinuation(thread: BtwThread, backend: BackendName, preferences = ""): string | undefined {
3103
2039
  if (backend === "agy" && thread.conversationId && thread.sessionFull !== undefined && thread.sessionFull !== thread.full) {
3104
2040
  thread.conversationId = undefined;
3105
2041
  }
2042
+ if (thread.conversationId && (thread.sessionPreferences ?? "") !== preferences) thread.conversationId = undefined;
3106
2043
  return thread.conversationId;
3107
2044
  }
3108
2045
 
2046
+ export function btwHeaderLabel(thread: Pick<BtwThread, "model" | "preferences">): string {
2047
+ return thread.model ? `${thread.model}${thread.preferences ? " · prefs" : ""}` : "";
2048
+ }
2049
+
3109
2050
  export function formatBtwTranscript(turns: readonly BtwTurn[]): string {
3110
2051
  return turns
3111
2052
  .map((turn) => {
@@ -3362,7 +2303,8 @@ async function openBtwModal(
3362
2303
  controller?.abort();
3363
2304
  });
3364
2305
 
3365
- const runTurn = async (question: string) => {
2306
+ // `replaced` is the turn /retry removed; it is put back if the turn never starts.
2307
+ const runTurn = async (question: string, replaced?: BtwTurn) => {
3366
2308
  if (controller) return;
3367
2309
  const turnController = new AbortController();
3368
2310
  controller = turnController;
@@ -3370,13 +2312,23 @@ async function openBtwModal(
3370
2312
  modal.clearComposer();
3371
2313
 
3372
2314
  let settings: BroSettings;
2315
+ let preferences: string;
3373
2316
  try {
3374
2317
  settings = await readSettings();
3375
- const backend = capabilityBackend(settings, "btw");
3376
- if (bindBtwBackend(thread, backend)) modal.setNotice("Backend changed — started a fresh side thread.");
2318
+ preferences = await readPreferences();
3377
2319
  } catch (error) {
3378
- controller = undefined; modal.setRunning(false); modal.setNotice(errorMessage(error)); return;
2320
+ if (replaced) thread.turns.push(replaced);
2321
+ if (controller === turnController) controller = undefined;
2322
+ if (!closed) { modal.setRunning(false); modal.setNotice(errorMessage(error)); }
2323
+ return;
2324
+ }
2325
+ // Closing (or reopening) during the reads above must not touch the shared thread.
2326
+ if (closed || turnController.signal.aborted) {
2327
+ if (replaced) thread.turns.push(replaced);
2328
+ if (controller === turnController) controller = undefined;
2329
+ return;
3379
2330
  }
2331
+ if (bindBtwBackend(thread, capabilityBackend(settings, "btw"))) modal.setNotice("Backend changed — started a fresh side thread.");
3380
2332
  if (thread.turns.length === 0) {
3381
2333
  thread.conversationId = undefined;
3382
2334
  const context = captureShowTranscript(ctx, BTW_CONTEXT_TURNS, BTW_CONTEXT_MAX)?.text;
@@ -3384,7 +2336,7 @@ async function openBtwModal(
3384
2336
  }
3385
2337
  // Resume natively when possible; otherwise seed the fresh native session with the main-session
3386
2338
  // context plus every earlier turn so nothing is silently lost.
3387
- const conversationId = nativeBtwContinuation(thread, capabilityBackend(settings, "btw"));
2339
+ const conversationId = nativeBtwContinuation(thread, capabilityBackend(settings, "btw"), preferences);
3388
2340
  const history = !conversationId && thread.turns.length ? formatBtwTranscript(thread.turns) : undefined;
3389
2341
  const context = conversationId ? undefined : thread.context;
3390
2342
  const full = thread.full;
@@ -3395,9 +2347,10 @@ async function openBtwModal(
3395
2347
  try {
3396
2348
  const selection = selectionForCapability(settings, "btw");
3397
2349
  thread.model = selectionLabel(selection);
3398
- modal.setModel(thread.model);
2350
+ thread.preferences = Boolean(preferences);
2351
+ modal.setModel(btwHeaderLabel(thread));
3399
2352
  const result = await runBtwTurn(
3400
- buildBtwPrompt(context, question, { full, history }),
2353
+ buildBtwPrompt(context, question, { full, history, preferences }),
3401
2354
  selection,
3402
2355
  { full, cwd: ctx.cwd, conversationId },
3403
2356
  turnController.signal,
@@ -3411,6 +2364,7 @@ async function openBtwModal(
3411
2364
  if (result.conversationId) {
3412
2365
  thread.conversationId = result.conversationId;
3413
2366
  thread.sessionFull = full;
2367
+ thread.sessionPreferences = preferences;
3414
2368
  }
3415
2369
  thread.turns[thread.turns.length - 1]!.answer = result.text;
3416
2370
  } catch (error) {
@@ -3436,6 +2390,8 @@ async function openBtwModal(
3436
2390
  thread.turns = [];
3437
2391
  thread.conversationId = undefined;
3438
2392
  thread.context = undefined;
2393
+ thread.sessionFull = undefined;
2394
+ thread.sessionPreferences = undefined;
3439
2395
  modal.clearComposer();
3440
2396
  modal.setNotice("");
3441
2397
  modal.setText("");
@@ -3449,7 +2405,7 @@ async function openBtwModal(
3449
2405
  return;
3450
2406
  }
3451
2407
  thread.turns.pop();
3452
- void runTurn(last.question);
2408
+ void runTurn(last.question, last);
3453
2409
  };
3454
2410
 
3455
2411
  const copyOut = async (all: boolean) => {
@@ -3521,7 +2477,7 @@ async function openBtwModal(
3521
2477
  }
3522
2478
 
3523
2479
  modal.setFull(thread.full);
3524
- modal.setModel(thread.model ?? "");
2480
+ modal.setModel(btwHeaderLabel(thread));
3525
2481
  modal.setText(transcript());
3526
2482
 
3527
2483
  if (options.initialQuestion) void runTurn(options.initialQuestion);
@@ -3552,6 +2508,8 @@ export default async function bro(pi: ExtensionAPI) {
3552
2508
  }
3553
2509
  };
3554
2510
 
2511
+ pi.on("session_shutdown", async () => { stopGuidedReviews(); });
2512
+
3555
2513
  pi.on("session_start", async (_event, _ctx) => {
3556
2514
  lastResult = undefined;
3557
2515
  btwThread = undefined;
@@ -3576,11 +2534,14 @@ export default async function bro(pi: ExtensionAPI) {
3576
2534
  }),
3577
2535
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
3578
2536
  const state = resolveAdvisorState(ctx.sessionManager.getBranch());
2537
+ const durableSteering = await readAdvisorSteering();
3579
2538
  const settings = await readSettings();
3580
2539
  const selection = selectionForCapability(settings, "advisor");
3581
2540
  const backend = selection.backend ?? "agy";
3582
2541
  const snapshot = buildAdvisorSnapshot(ctx, pi);
3583
- const prompt = buildAdvisorPrompt(state.steering, snapshot.text, params.question);
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());
3584
2545
  let lastAttempt: AdvisorToolDetails = { status: "investigating", attempt: 1, of: 3, elapsedMs: 0, backend };
3585
2546
  try {
3586
2547
  const run = await runAdvisorWithRetries(
@@ -3617,7 +2578,8 @@ export default async function bro(pi: ExtensionAPI) {
3617
2578
  effort,
3618
2579
  durationMs: run.durationMs,
3619
2580
  cwd: ctx.cwd,
3620
- steeringIncluded: Boolean(state.steering.trim()),
2581
+ steeringIncluded: hasDurable || hasSession,
2582
+ steeringSources: { durable: hasDurable, session: hasSession },
3621
2583
  snapshotChars: snapshot.text.length,
3622
2584
  broTruncated: false,
3623
2585
  omissions,
@@ -3627,7 +2589,14 @@ export default async function bro(pi: ExtensionAPI) {
3627
2589
  };
3628
2590
  // The backend qualifier appears only off Agy, keeping the Agy header byte-identical.
3629
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`;
3630
- const context = `Context · cwd: ${JSON.stringify(ctx.cwd)} · steering: ${details.steeringIncluded ? "included" : "none"} · snapshot: ${snapshot.text.length} chars · Bro truncation: none · omissions: ${omissions}`;
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}`;
3631
2600
  return { content: [{ type: "text", text: `${header}\n${context}\n\n${run.advice}` }], details };
3632
2601
  } finally {
3633
2602
  ctx.ui.setStatus(ADVISOR_STATUS_BAR_KEY, undefined);
@@ -3716,15 +2685,14 @@ export default async function bro(pi: ExtensionAPI) {
3716
2685
  if (!captured) {
3717
2686
  return { text: "**Nothing to show yet**\n\nThis session has no conversation turns to draw. Run something first, then press **R**." };
3718
2687
  }
3719
- let text: string;
3720
- let model: string;
2688
+ let result: Awaited<ReturnType<typeof runShowExplanation>>;
3721
2689
  try {
3722
- ({ text, model } = await runShowExplanation(captured.text, steering, signal, await readSettings(), onProgress));
2690
+ result = await runShowExplanation(captured.text, steering, signal, await readSettings(), onProgress);
3723
2691
  } catch (error) {
3724
2692
  throw new Error(withDoctor(error));
3725
2693
  }
3726
- const html = extractShowHtml(text);
3727
- return { source: captured, text, model, ...(html ? { htmlPath: await writeShowHtml(html) } : {}) };
2694
+ const html = extractShowHtml(result.text);
2695
+ return { source: captured, ...result, ...(html ? { htmlPath: await writeShowHtml(html) } : {}) };
3728
2696
  };
3729
2697
  try {
3730
2698
  await showBroModal(ctx, {
@@ -3821,6 +2789,12 @@ export default async function bro(pi: ExtensionAPI) {
3821
2789
  return;
3822
2790
  }
3823
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
+
3824
2798
  if (action === "btw") {
3825
2799
  if (!hasBroCustomUi(ctx)) {
3826
2800
  ctx.ui.notify("Use /bro btw in Pi's interactive UI.", "warning");
@@ -3858,7 +2832,7 @@ export default async function bro(pi: ExtensionAPI) {
3858
2832
  // an actionable notice pointing at the commands that replaced it, not a silent no-op.
3859
2833
  if (parts.length > 1) {
3860
2834
  ctx.ui.notify(
3861
- "/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 its steering brief, or /bro doctor for full diagnostics.",
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.",
3862
2836
  "warning",
3863
2837
  );
3864
2838
  return;
@@ -3866,8 +2840,8 @@ export default async function bro(pi: ExtensionAPI) {
3866
2840
  const active = pi.getActiveTools().includes(ADVISOR_TOOL_NAME);
3867
2841
  ctx.ui.notify(
3868
2842
  active
3869
- ? "Bro advisor is available -- the executor agent can call bro_advisor. Use /bro config for its model/effort, /bro advisor-steer for its steering brief, or /bro doctor for full diagnostics."
3870
- : "Bro advisor is unavailable in this runtime. Use /bro doctor for full diagnostics, /bro config for its model/effort, or /bro advisor-steer for its steering brief.",
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.",
3871
2845
  active ? "info" : "warning",
3872
2846
  );
3873
2847
  return;
@@ -3882,6 +2856,15 @@ export default async function bro(pi: ExtensionAPI) {
3882
2856
  return;
3883
2857
  }
3884
2858
 
2859
+ if (action === "preferences") {
2860
+ if (parts.length !== 1) {
2861
+ ctx.ui.notify("Use /bro preferences.", "warning");
2862
+ return;
2863
+ }
2864
+ await showPreferencesModal(ctx);
2865
+ return;
2866
+ }
2867
+
3885
2868
  if (action === "help") {
3886
2869
  if (parts.length !== 1) {
3887
2870
  ctx.ui.notify("Use /bro help.", "warning");
@@ -3894,7 +2877,14 @@ export default async function bro(pi: ExtensionAPI) {
3894
2877
  } catch (error) {
3895
2878
  settingsError = errorMessage(error);
3896
2879
  }
3897
- await showBroModal(ctx, { text: helpText(settings, settingsError), kind: "help", copyable: true });
2880
+ let preferences: string;
2881
+ try {
2882
+ const text = await readPreferences();
2883
+ preferences = text ? `on (${text.length.toLocaleString("en-US")} characters)` : "off";
2884
+ } catch (error) {
2885
+ preferences = `error: ${errorMessage(error)}`;
2886
+ }
2887
+ await showBroModal(ctx, { text: helpText(settings, settingsError, preferences), kind: "help", copyable: true });
3898
2888
  return;
3899
2889
  }
3900
2890