pi-bro 0.21.0 → 0.22.0

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