pi-bro 0.23.0 → 0.24.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/src/bro.ts CHANGED
@@ -1,95 +1,59 @@
1
- import { spawnSync } from "node:child_process";
2
- import { hasBroCustomUi, broModalRows, canBroInsertIntoEditor, insertBroDesktopText } from "./ui-capabilities.ts";
3
- import { createHash } from "node:crypto";
4
- import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
5
- import { tmpdir } from "node:os";
6
- import { join } from "node:path";
7
- import { stripVTControlCharacters } from "node:util";
8
- import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
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";
11
- import { Type } from "typebox";
12
- import { BRO_MODES, MAX_PREFERENCES_CHARS, MAX_ADVISOR_STEERING_CHARS, STARTER_PREFERENCES, buildAdvisorPrompt, buildBtwPrompt, buildDefaultPrompt, buildShowPrompt, nextBroMode, parseBroMode, type BroMode } from "./prompt.ts";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { ADVISOR_STEERING_FILE, ADVISOR_TOOL_NAME, registerAdvisorTool } from "./advisor.ts";
13
3
  import {
14
- agyFailureMessage,
15
- reviewBackendError,
16
- execute as executeBackend,
17
- type BackendProgress,
18
- type BackendSelection,
19
- } from "./backend.ts";
20
- import { isRecord, errorMessage, withDoctor, unquote, isRenderableSequence } from "./util.ts";
4
+ type BtwThread,
5
+ bindBtwBackend,
6
+ openBtwModal,
7
+ parseBtwArguments,
8
+ resolveBtwThread,
9
+ } from "./btw.ts";
10
+ import { doctorReport, showBroConfigModal } from "./doctor.ts";
11
+ import {
12
+ type BroResult,
13
+ type ModalResult,
14
+ helpText,
15
+ latestAssistant,
16
+ showAdvisorSteerModal,
17
+ showBroModal,
18
+ showPreferencesModal,
19
+ simplify,
20
+ } from "./explain-modal.ts";
21
+ import { BRO_MODES, type BroMode, parseBroMode } from "./prompt.ts";
22
+ import { openGuidedReview, stopGuidedReviews } from "./review-ui.ts";
21
23
  import {
22
- type BackendName,
23
24
  type BroSettings,
24
- type AgyModelFamily,
25
- type AgyEffort,
26
- EFFORTS,
27
- CAPABILITIES,
28
- CAPABILITY_LABELS,
29
25
  capabilityBackend,
30
- capabilityOverride,
31
- capabilityPair,
32
26
  ensureSettingsFile,
33
- isClaudeEffort,
34
- isGrokEffort,
35
- isCodexEffort,
36
- isMuseEffort,
27
+ readPreferences,
37
28
  readSettings,
38
- resolveCapabilitySettings,
39
- resolveModelEffort,
40
- selectionForCapability,
41
- selectionLabel,
42
- settingsFile,
43
29
  writeSettings,
44
30
  } from "./settings.ts";
31
+ import {
32
+ captureShowTranscript,
33
+ parseShowArguments,
34
+ runShowExplanation,
35
+ showDiagramPath,
36
+ } from "./show.ts";
45
37
  import {
46
38
  type BroSource,
47
39
  extractDocumentText,
48
40
  extractWebPage,
49
41
  isWorkspaceFile,
50
42
  looksLikeWebUrl,
51
- MAX_TEXT_LENGTH,
52
43
  } from "./sources.ts";
53
- import { createConfigModal, type Theme, type TuiLike } from "./config-ui.ts";
54
-
55
- import { openGuidedReview, stopGuidedReviews } from "./review-ui.ts";
44
+ import { hasBroCustomUi } from "./ui-capabilities.ts";
45
+ import { errorMessage, unquote, withDoctor } from "./util.ts";
56
46
 
57
47
  export * from "./util.ts";
48
+ export * from "./ui-capabilities.ts";
58
49
  export * from "./settings.ts";
59
50
  export * from "./sources.ts";
60
51
  export * from "./config-ui.ts";
61
-
62
- const PREFERENCES_FILE = join(getAgentDir(), "bro-preferences.md");
63
- const ADVISOR_STEERING_FILE = join(getAgentDir(), "bro-advisor.md");
64
- // Larger files are rejected before reading, even by the editor; the prompt limit is MAX_PREFERENCES_CHARS.
65
- const MAX_PREFERENCES_FILE_BYTES = 64 * 1024;
66
- const MAX_ADVISOR_STEERING_FILE_BYTES = 64 * 1024;
67
- const LOADING_TEXT = "Simplifying for my bro…";
68
- const BTW_CONTEXT_TURNS = 8;
69
- const BTW_CONTEXT_MAX = 40_000;
70
- const SHOW_HTML_FILE_PATTERN = /^bro-show-[0-9a-f]{8}\.html$/;
71
-
72
- type ModalKind = "loading" | "streaming" | "result" | "help" | "empty" | "error";
73
- // `mode` is the built-in explain mode that produced the text; absent for Show and Doctor.
74
- // `preferences` records whether bro-preferences.md shaped this result; like `mode`, it belongs to the result.
75
- type BroResult = { source: BroSource; text: string; model?: string; mode?: BroMode; preferences?: boolean };
76
- type ModalResult = { source?: BroSource; text: string; htmlPath?: string; model?: string; mode?: BroMode; preferences?: boolean };
77
- type BtwTurn = { question: string; answer: string };
78
- // `context` keeps the main-session seed so a fresh native session can be reseeded with the whole
79
- // thread; `sessionFull` and `sessionPreferences` are the access mode and preferences the native
80
- // session last ran with. `preferences` records whether the latest turn used them (header tag).
81
- type BtwThread = { turns: BtwTurn[]; conversationId?: string; full: boolean; backend?: BackendName; model?: string; preferences?: boolean; context?: string; sessionFull?: boolean; sessionPreferences?: string };
82
- export function wheelDelta(data: string): number {
83
- const match = /^\x1b\[<(\d+);\d+;\d+[Mm]$/.exec(data);
84
- if (!match) return 0;
85
- const button = Number.parseInt(match[1], 10);
86
- if ((button & 64) === 0) return 0;
87
- return (button & 3) === 0 ? -3 : (button & 3) === 1 ? 3 : 0;
88
- }
89
-
90
- export function setRegularMouseReporting(tui: Pick<TuiLike, "mode" | "terminal">, enabled: boolean): void {
91
- if (tui.mode === "regular") tui.terminal?.write?.(`\x1b[?1000${enabled ? "h" : "l"}\x1b[?1006${enabled ? "h" : "l"}`);
92
- }
52
+ export * from "./show.ts";
53
+ export * from "./advisor.ts";
54
+ export * from "./doctor.ts";
55
+ export * from "./btw.ts";
56
+ export * from "./explain-modal.ts";
93
57
 
94
58
  const COMMANDS = [
95
59
  { value: "simplify", label: "simplify", description: "Explain the latest completed assistant reply (same as /bro)" },
@@ -110,2443 +74,6 @@ const COMMANDS = [
110
74
  ];
111
75
  const KNOWN_ACTIONS = new Set(COMMANDS.map((command) => command.value));
112
76
 
113
-
114
- // The steering brief is stored as a session `custom` entry — extension state that never
115
- // participates in LLM context (see docs/plans/2026-09-19-bro-advisor-design.md). Writes go
116
- // through pi.appendEntry(); ctx.sessionManager is read-only and has no append methods.
117
- //
118
- // bro_advisor has no on/off activation state: it is registered once at extension load like any
119
- // other tool and never gated via pi.setActiveTools(). Whether the executor can call it is purely a
120
- // function of this host's own tool restrictions (pi.getAllTools() / pi.getActiveTools()), which
121
- // this code only ever reads, never overrides. A session may still carry historical
122
- // "bro-advisor-active" entries from before this simplification; they are silently ignored.
123
- const ADVISOR_STEERING_ENTRY = "bro-advisor-steering";
124
- export const ADVISOR_TOOL_NAME = "bro_advisor";
125
- // setStatus() key for mirroring live advisor progress to the footer/status bar -- see the execute()
126
- // handler below for why this fallback surface exists alongside onUpdate's tool-card renderResult.
127
- const ADVISOR_STATUS_BAR_KEY = "bro-advisor";
128
- const ADVISOR_COMPATIBILITY = "Pi >=0.84.2; Agy >=1.1.15";
129
-
130
- export type AdvisorState = { steering: string };
131
-
132
- // getBranch() walks root-to-leaf (see latestAssistant above for the same convention), so a forward
133
- // scan taking the last match of ADVISOR_STEERING_ENTRY resolves "latest wins". Forking clones the
134
- // branch's entries into a new session file, so this same resolution gives fork inheritance and
135
- // independent post-fork edits for free, with no special-case fork logic.
136
- export function resolveAdvisorState(branch: readonly SessionEntry[]): AdvisorState {
137
- let steering = "";
138
- for (const entry of branch) {
139
- if (entry.type !== "custom" || entry.customType !== ADVISOR_STEERING_ENTRY) continue;
140
- if (isRecord(entry.data) && typeof entry.data.text === "string") steering = entry.data.text;
141
- }
142
- return { steering };
143
- }
144
-
145
-
146
- export function formatAgyUsage(value: unknown): string {
147
- if (!isRecord(value) || value.status !== "SUCCESS" || typeof value.response !== "string") {
148
- throw new Error("Agy returned invalid usage data.");
149
- }
150
-
151
- const groups = new Map<string, string[]>();
152
- for (const line of value.response.trim().split("\n")) {
153
- const [group, limit, remaining, resetTime] = line.split("\t");
154
- if (!group || !limit || !remaining) throw new Error("Agy returned invalid usage data.");
155
- const reset = resetTime ? new Date(resetTime) : undefined;
156
- const resetText = reset && !Number.isNaN(reset.getTime()) ? ` — resets ${reset.toLocaleString()}` : "";
157
- const items = groups.get(group) ?? [];
158
- items.push(`- **${limit}:** ${remaining}${resetText}`);
159
- groups.set(group, items);
160
- }
161
- if (!groups.size) throw new Error("Agy returned no usage information.");
162
-
163
- const sections = [...groups].map(([group, items]) => `## ${group}\n\n${items.join("\n")}`);
164
- return `# Agy usage\n\n${sections.join("\n\n")}`;
165
- }
166
-
167
- export function parseAgyModels(output: string): AgyModelFamily[] {
168
- // ponytail: Agy 1.1.13 exposes a tab-separated variant list; use structured catalog data when available here.
169
- const families = new Map<string, AgyModelFamily>();
170
- for (const line of output.split(/\r?\n/)) {
171
- const [rawId, ...rawLabel] = line.split("\t");
172
- if (!rawId?.trim() || !rawLabel.length) continue;
173
- const id = rawId.trim();
174
- const label = rawLabel.join(" ").trim();
175
- const effort = (["low", "medium", "high"] as const).find(
176
- (value) => id.endsWith(`-${value}`) && label.endsWith(`(${value[0].toUpperCase()}${value.slice(1)})`),
177
- );
178
- const familyId = effort ? id.slice(0, -effort.length - 1) : id;
179
- const family = families.get(familyId) ?? {
180
- id: familyId,
181
- label: effort ? label.replace(/\s+\((Low|Medium|High)\)$/, "") : label,
182
- efforts: [],
183
- variants: [],
184
- };
185
- if (effort && !family.efforts.includes(effort)) family.efforts.push(effort);
186
- family.variants.push({ id, effort });
187
- families.set(familyId, family);
188
- }
189
- if (!families.size) throw new Error("Agy returned no available models.");
190
- for (const family of families.values()) {
191
- family.efforts.sort((a, b) => EFFORTS.indexOf(a) - EFFORTS.indexOf(b));
192
- }
193
- return [...families.values()];
194
- }
195
-
196
- async function listAgyModels(pi: ExtensionAPI, signal?: AbortSignal): Promise<AgyModelFamily[]> {
197
- const runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
198
- try {
199
- const result = await pi.exec("agy", ["models"], { cwd: runDirectory, signal, timeout: 30_000 });
200
- if (signal?.aborted) throw new Error("Canceled.");
201
- if (result.killed || result.code !== 0) throw new Error(agyFailureMessage("list models", result));
202
- try {
203
- return parseAgyModels(result.stdout);
204
- } catch (error) {
205
- throw new Error(withDoctor(error));
206
- }
207
- } finally {
208
- await rm(runDirectory, { recursive: true, force: true });
209
- }
210
- }
211
-
212
- async function checkAgyVersion(pi: ExtensionAPI, signal: AbortSignal): Promise<string> {
213
- const runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
214
- try {
215
- const result = await pi.exec("agy", ["--version"], { cwd: runDirectory, signal, timeout: 10_000 });
216
- if (signal.aborted) throw new Error("Canceled.");
217
- if (result.killed || result.code !== 0) throw new Error(agyFailureMessage("start", result));
218
- const version = result.stdout.trim() || result.stderr.trim();
219
- if (!version) throw new Error("Agy returned no version information. Update Agy, then run `/bro doctor` again.");
220
- return version;
221
- } finally {
222
- await rm(runDirectory, { recursive: true, force: true });
223
- }
224
- }
225
-
226
- export function advisorAgyCompatible(version: string): boolean | undefined {
227
- const match = /(?:^|\D)(\d+)\.(\d+)\.(\d+)(?:\D|$)/.exec(version);
228
- if (!match) return undefined;
229
- const installed = match.slice(1, 4).map(Number);
230
- const minimum = [1, 1, 15];
231
- for (let index = 0; index < minimum.length; index++) {
232
- if (installed[index]! !== minimum[index]!) return installed[index]! > minimum[index]!;
233
- }
234
- return true;
235
- }
236
-
237
- function resolveCatalogSettings(
238
- settings: BroSettings,
239
- families: AgyModelFamily[],
240
- ): { settings: BroSettings; family?: AgyModelFamily } {
241
- const resolved = resolveModelEffort({ model: settings.model, effort: settings.effort }, families);
242
- if (!resolved.family) return { settings };
243
- return { family: resolved.family, settings: { ...settings, ...resolved.pair } };
244
- }
245
-
246
- async function checkCliVersion(pi: ExtensionAPI, binary: string, displayName: string, signal: AbortSignal): Promise<string> {
247
- const runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
248
- try {
249
- const result = await pi.exec(binary, ["--version"], { cwd: runDirectory, signal, timeout: 10_000 });
250
- if (signal.aborted) throw new Error("Canceled.");
251
- if (result.killed || result.code !== 0) {
252
- const detail = result.stderr.trim() || result.stdout.trim();
253
- throw new Error(
254
- detail
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\`.`,
257
- );
258
- }
259
- const version = result.stdout.trim() || result.stderr.trim();
260
- if (!version) throw new Error(`${displayName} returned no version information. Update ${displayName}, then run \`/bro doctor\` again.`);
261
- return version;
262
- } finally {
263
- await rm(runDirectory, { recursive: true, force: true });
264
- }
265
- }
266
-
267
- // Auth status only: a version/auth answer without a model request. A passing answer here
268
- // says the CLI starts and reports signed-in state -- it does not imply connectivity.
269
- async function checkClaudeAuth(pi: ExtensionAPI, signal: AbortSignal): Promise<string> {
270
- const runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
271
- try {
272
- const result = await pi.exec("claude", ["auth", "status"], { cwd: runDirectory, signal, timeout: 15_000 });
273
- if (signal.aborted) throw new Error("Canceled.");
274
- if (result.killed || result.code !== 0) {
275
- const detail = result.stderr.trim() || result.stdout.trim();
276
- throw new Error(detail ? `Claude auth status: ${detail}` : "Claude auth status is unknown. Sign in, then run `/bro doctor`.");
277
- }
278
- let status: unknown;
279
- try { status = JSON.parse(result.stdout); } catch { throw new Error("Claude returned unreadable auth status; sign in and retry /bro doctor."); }
280
- if (!isRecord(status) || status.loggedIn !== true) throw new Error("Claude is not signed in. Run `claude auth login`.");
281
- return "authentication configured (not a connectivity test)";
282
- } finally {
283
- await rm(runDirectory, { recursive: true, force: true });
284
- }
285
- }
286
-
287
- // Auth status only: a version/auth answer without a model request. A passing answer here
288
- // says the CLI starts and reports signed-in state -- it does not imply connectivity.
289
- async function checkCodexAuth(pi: ExtensionAPI, signal: AbortSignal): Promise<string> {
290
- const runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
291
- try {
292
- const result = await pi.exec("codex", ["login", "status"], { cwd: runDirectory, signal, timeout: 15_000 });
293
- if (signal.aborted) throw new Error("Canceled.");
294
- if (result.killed || result.code !== 0) {
295
- const detail = result.stderr.trim() || result.stdout.trim();
296
- throw new Error(detail ? `Codex auth status: ${detail}` : "Codex is not logged in. Run `codex login`.");
297
- }
298
- const status = result.stdout.trim() || result.stderr.trim();
299
- if (!status || !/logged in/i.test(status)) throw new Error("Codex is not logged in. Run `codex login`.");
300
- return "authentication configured (not a connectivity test)";
301
- } finally {
302
- await rm(runDirectory, { recursive: true, force: true });
303
- }
304
- }
305
-
306
- async function checkAgyUsage(pi: ExtensionAPI, signal: AbortSignal): Promise<string> {
307
- const runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
308
- try {
309
- const result = await pi.exec(
310
- "agy",
311
- ["-p", "/usage", "--output-format", "json", "--print-timeout", "30s", "--sandbox"],
312
- { cwd: runDirectory, signal, timeout: 35_000 },
313
- );
314
- if (signal.aborted) throw new Error("Canceled.");
315
- if (result.killed || result.code !== 0) throw new Error(agyFailureMessage("check account usage", result));
316
- try {
317
- return formatAgyUsage(JSON.parse(result.stdout));
318
- } catch (error) {
319
- throw new Error(withDoctor(error instanceof SyntaxError ? "Agy returned invalid usage data." : error));
320
- }
321
- } finally {
322
- await rm(runDirectory, { recursive: true, force: true });
323
- }
324
- }
325
-
326
- export async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, signal: AbortSignal): Promise<string> {
327
- const lines: string[] = [];
328
- let failed = false;
329
- let settings: BroSettings | undefined;
330
- let models: AgyModelFamily[] | undefined;
331
- let agyVersion: string | undefined;
332
- const pass = (name: string, detail: string) => lines.push(`- ✓ **${name}:** ${detail}`);
333
- const fail = (name: string, error: unknown) => {
334
- failed = true;
335
- lines.push(`- ✗ **${name}:** ${errorMessage(error)}`);
336
- };
337
-
338
- try {
339
- settings = await readSettings();
340
- pass("Settings", `valid · mode: ${settings.mode}`);
341
- } catch (error) {
342
- fail("Settings", error);
343
- }
344
-
345
- try {
346
- const preferences = await readPreferences();
347
- pass("Preferences", preferences ? `${preferences.length.toLocaleString("en-US")} characters · used by explain, show, btw` : "none — /bro preferences to add some");
348
- } catch (error) {
349
- fail("Preferences", error);
350
- }
351
-
352
- // Probe only the backends some feature actually selects: a Claude/Grok/Codex/Muse-only setup never
353
- // requires Agy to be installed, and vice versa.
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");
362
-
363
- let agyStarted = false;
364
- if (agyInUse) {
365
- try {
366
- agyVersion = await checkAgyVersion(pi, signal);
367
- pass("Agy", agyVersion);
368
- agyStarted = true;
369
- } catch (error) {
370
- if (signal.aborted) throw error;
371
- fail("Agy", error);
372
- }
373
-
374
- if (agyStarted) {
375
- try {
376
- models = await listAgyModels(pi, signal);
377
- pass("Model catalog", `${models.length} model${models.length === 1 ? "" : "s"} available`);
378
- } catch (error) {
379
- if (signal.aborted) throw error;
380
- fail("Model catalog", error);
381
- }
382
-
383
- try {
384
- await checkAgyUsage(pi, signal);
385
- pass("Account", "connected");
386
- } catch (error) {
387
- if (signal.aborted) throw error;
388
- fail("Account", error);
389
- }
390
- }
391
- } else {
392
- pass("Agy", "not probed — no feature selects the Agy backend");
393
- }
394
-
395
- // Version and auth status only, never a model request. A passing answer says the CLI
396
- // starts and reports signed-in state -- it does not imply connectivity.
397
- if (claudeInUse) {
398
- try {
399
- pass("Claude", await checkCliVersion(pi, "claude", "Claude", signal));
400
- } catch (error) {
401
- if (signal.aborted) throw error;
402
- fail("Claude", error);
403
- }
404
- try {
405
- pass("Claude auth", await checkClaudeAuth(pi, signal));
406
- } catch (error) {
407
- if (signal.aborted) throw error;
408
- fail("Claude auth", error);
409
- }
410
- } else {
411
- pass("Claude", "not probed — no feature selects the Claude backend");
412
- }
413
-
414
- // Version only, never a model request. No Grok auth probe exists, so a passing
415
- // version says the CLI starts — it says nothing about auth or connectivity.
416
- if (grokInUse) {
417
- try {
418
- pass("Grok", await checkCliVersion(pi, "grok", "Grok", signal));
419
- } catch (error) {
420
- if (signal.aborted) throw error;
421
- fail("Grok", error);
422
- }
423
- pass("Grok auth", "unverified — no auth probe exists (version does not imply auth or connectivity)");
424
- } else {
425
- pass("Grok", "not probed — no feature selects the Grok backend");
426
- }
427
-
428
- // Version and auth status only, never a model request.
429
- if (codexInUse) {
430
- try {
431
- pass("Codex", await checkCliVersion(pi, "codex", "Codex", signal));
432
- } catch (error) {
433
- if (signal.aborted) throw error;
434
- fail("Codex", error);
435
- }
436
- try {
437
- pass("Codex auth", await checkCodexAuth(pi, signal));
438
- } catch (error) {
439
- if (signal.aborted) throw error;
440
- fail("Codex auth", error);
441
- }
442
- } else {
443
- pass("Codex", "not probed — no feature selects the Codex backend");
444
- }
445
-
446
- // Version only, never a model request. No Muse auth probe exists, so a passing
447
- // version says the CLI starts — it says nothing about auth or connectivity.
448
- if (museInUse) {
449
- try {
450
- pass("Muse", await checkCliVersion(pi, "muse", "Muse", signal));
451
- } catch (error) {
452
- if (signal.aborted) throw error;
453
- fail("Muse", error);
454
- }
455
- pass("Muse auth", "unverified — no auth probe exists (version does not imply auth or connectivity)");
456
- } else {
457
- pass("Muse", "not probed — no feature selects the Muse backend");
458
- }
459
-
460
- if (settings) {
461
- const defaultBackend = settings.backend ?? "agy";
462
- if (defaultBackend === "claude") {
463
- pass("Selected model", `claude \`${settings.model}\``);
464
- if (!isClaudeEffort(settings.effort)) {
465
- fail("Reasoning effort", `\`${settings.effort}\` is unsupported. Run \`/bro config\` to choose another.`);
466
- } else if (settings.effort === "default") {
467
- pass("Reasoning effort", "built into the selected model");
468
- } else {
469
- pass("Reasoning effort", settings.effort);
470
- }
471
- } else if (defaultBackend === "grok") {
472
- pass("Selected model", `grok \`${settings.model}\``);
473
- if (!isGrokEffort(settings.effort)) {
474
- fail("Reasoning effort", `\`${settings.effort}\` is unsupported. Run \`/bro config\` to choose another.`);
475
- } else if (settings.effort === "default") {
476
- pass("Reasoning effort", "built into the selected model");
477
- } else {
478
- pass("Reasoning effort", settings.effort);
479
- }
480
- } else if (defaultBackend === "codex") {
481
- pass("Selected model", `codex \`${settings.model}\``);
482
- if (!isCodexEffort(settings.effort)) {
483
- fail("Reasoning effort", `\`${settings.effort}\` is unsupported. Run \`/bro config\` to choose another.`);
484
- } else if (settings.effort === "default") {
485
- pass("Reasoning effort", "built into the selected model");
486
- } else {
487
- pass("Reasoning effort", settings.effort);
488
- }
489
- } else if (defaultBackend === "muse") {
490
- pass("Selected model", `muse \`${settings.model}\``);
491
- if (!isMuseEffort(settings.effort)) {
492
- fail("Reasoning effort", `\`${settings.effort}\` is unsupported. Run \`/bro config\` to choose another.`);
493
- } else if (settings.effort === "default") {
494
- pass("Reasoning effort", "built into the selected model");
495
- } else {
496
- pass("Reasoning effort", settings.effort);
497
- }
498
- } else if (models) {
499
- const current = resolveCatalogSettings(settings, models);
500
- if (!current.family) {
501
- fail("Selected model", `\`${settings.model}\` is unavailable. Run \`/bro config\` to choose another.`);
502
- } else {
503
- pass("Selected model", `agy \`${current.family.id}\``);
504
- const effort = current.settings.effort;
505
- if (!current.family.efforts.length && effort === "default") {
506
- pass("Reasoning effort", "built into the selected model");
507
- } else if (effort !== "default" && current.family.efforts.includes(effort as AgyEffort)) {
508
- pass("Reasoning effort", effort);
509
- } else {
510
- fail("Reasoning effort", `\`${effort}\` is unsupported. Run \`/bro config\` to choose another.`);
511
- }
512
- }
513
- }
514
-
515
- for (const capability of CAPABILITIES) {
516
- const label = CAPABILITY_LABELS[capability];
517
- const override = capabilityOverride(settings, capability);
518
- const backend = capabilityBackend(settings, capability);
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; }
522
- if (backend === "claude") {
523
- if (!isClaudeEffort(pair.effort)) {
524
- fail(label, `\`${pair.effort}\` is unsupported for claude \`${pair.model}\`. Run \`/bro config\` to fix this.`);
525
- continue;
526
- }
527
- pass(
528
- label,
529
- override
530
- ? `override claude \`${pair.model}\`${pair.effort === "default" ? "" : ` (${pair.effort})`}`
531
- : `claude \`${pair.model}\`${pair.effort === "default" ? "" : ` (${pair.effort})`} · using the shared default`,
532
- );
533
- continue;
534
- }
535
- if (backend === "grok") {
536
- if (!isGrokEffort(pair.effort)) {
537
- fail(label, `\`${pair.effort}\` is unsupported for grok \`${pair.model}\`. Run \`/bro config\` to fix this.`);
538
- continue;
539
- }
540
- pass(
541
- label,
542
- override
543
- ? `override grok \`${pair.model}\`${pair.effort === "default" ? "" : ` (${pair.effort})`}`
544
- : `grok \`${pair.model}\`${pair.effort === "default" ? "" : ` (${pair.effort})`} · using the shared default`,
545
- );
546
- continue;
547
- }
548
- if (backend === "codex") {
549
- if (!isCodexEffort(pair.effort)) {
550
- fail(label, `\`${pair.effort}\` is unsupported for codex \`${pair.model}\`. Run \`/bro config\` to fix this.`);
551
- continue;
552
- }
553
- pass(
554
- label,
555
- override
556
- ? `override codex \`${pair.model}\`${pair.effort === "default" ? "" : ` (${pair.effort})`}`
557
- : `codex \`${pair.model}\`${pair.effort === "default" ? "" : ` (${pair.effort})`} · using the shared default`,
558
- );
559
- continue;
560
- }
561
- if (backend === "muse") {
562
- if (!isMuseEffort(pair.effort)) {
563
- fail(label, `\`${pair.effort}\` is unsupported for muse \`${pair.model}\`. Run \`/bro config\` to fix this.`);
564
- continue;
565
- }
566
- pass(
567
- label,
568
- override
569
- ? `override muse \`${pair.model}\`${pair.effort === "default" ? "" : ` (${pair.effort})`}`
570
- : `muse \`${pair.model}\`${pair.effort === "default" ? "" : ` (${pair.effort})`} · using the shared default`,
571
- );
572
- continue;
573
- }
574
- if (!models) continue;
575
- const resolved = resolveCapabilitySettings(settings, capability, models);
576
- if (!resolved.family) {
577
- fail(label, `\`${resolved.pair.model}\` is unavailable. Run \`/bro config\` to fix this override.`);
578
- continue;
579
- }
580
- const effortOk = !resolved.family.efforts.length
581
- ? resolved.pair.effort === "default"
582
- : resolved.pair.effort !== "default" && resolved.family.efforts.includes(resolved.pair.effort as AgyEffort);
583
- if (!effortOk) {
584
- fail(label, `\`${resolved.pair.effort}\` is unsupported for \`${resolved.family.id}\`. Run \`/bro config\` to fix this.`);
585
- continue;
586
- }
587
- pass(
588
- label,
589
- override
590
- ? `override agy \`${resolved.family.id}\`${resolved.pair.effort === "default" ? "" : ` (${resolved.pair.effort})`}`
591
- : "using the shared default",
592
- );
593
- }
594
- }
595
-
596
- // bro_advisor has no on/off preference to compare against -- it is only ever gated by this
597
- // host's own tool restrictions, which this reads via pi.getAllTools()/pi.getActiveTools() and
598
- // never overrides. Exposed-but-inactive is the one genuine anomaly worth failing on.
599
- const advisorExposed = pi.getAllTools().some((tool) => tool.name === ADVISOR_TOOL_NAME);
600
- const advisorActive = pi.getActiveTools().includes(ADVISOR_TOOL_NAME);
601
- if (advisorExposed && !advisorActive) fail("Advisor tool", "bro_advisor is exposed but not active in this session. Run /reload.");
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)");
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
- }
611
- if (settings && capabilityBackend(settings, "advisor") === "claude") {
612
- pass("Advisor compatibility", "Claude backend — no Agy version floor applies");
613
- } else if (settings && capabilityBackend(settings, "advisor") === "grok") {
614
- pass("Advisor compatibility", "Grok backend — no Agy version floor applies; auth/connectivity unverified (no probe)");
615
- } else if (settings && capabilityBackend(settings, "advisor") === "codex") {
616
- pass("Advisor compatibility", "Codex backend — no Agy version floor applies");
617
- } else if (settings && capabilityBackend(settings, "advisor") === "muse") {
618
- pass("Advisor compatibility", "Muse backend — no Agy version floor applies; auth/connectivity unverified (no probe)");
619
- } else if (agyVersion && advisorAgyCompatible(agyVersion)) pass("Advisor compatibility", ADVISOR_COMPATIBILITY);
620
- else if (agyVersion) fail("Advisor compatibility", `installed \`${agyVersion}\`; requires Agy >=1.1.15. Run \`agy update\`.`);
621
-
622
- return `# Bro doctor\n\n${lines.join("\n")}\n\n**${failed ? "Bro needs attention." : "Bro is ready."}**\n\n${
623
- failed
624
- ? "Fix the failed items, then press **R** to check again."
625
- : "No assistant response was sent and no model turn was run. Version/auth checks do not imply connectivity (Grok and Muse auth are unverified — no probe exists)."
626
- }`;
627
- }
628
-
629
- function latestAssistant(ctx: ExtensionCommandContext): BroSource | undefined {
630
- const branch = ctx.sessionManager.getBranch();
631
-
632
- for (let i = branch.length - 1; i >= 0; i--) {
633
- const entry = branch[i];
634
- if (entry.type !== "message" || entry.message.role !== "assistant" || entry.message.stopReason !== "stop") {
635
- continue;
636
- }
637
-
638
- const text = entry.message.content
639
- .filter((part): part is { type: "text"; text: string } => part.type === "text")
640
- .map((part) => part.text)
641
- .join("\n")
642
- .trim();
643
-
644
- if (text) return { text };
645
- }
646
- }
647
-
648
- // /bro show: capture only the user- and assistant-visible conversation text of
649
- // recent session turns and let the show prompt draw it as shapes. Tool calls,
650
- // tool results, reasoning, and images never leave the session -- this is a
651
- // structural role/content-type filter, not semantic or keyword-based. See
652
- // docs/plans/2026-09-07-bro-show-visual-design.md (predates this change).
653
- type ShowTurn = { entries: string[]; startsTurn: boolean };
654
-
655
- function showTextContent(content: unknown): string {
656
- if (typeof content === "string") return content.trim();
657
- if (!Array.isArray(content)) return "";
658
- let text = "";
659
- for (const part of content) {
660
- if (part && typeof part === "object" && (part as { type?: string }).type === "text") {
661
- text += `${(part as { text?: string }).text ?? ""}\n`;
662
- }
663
- }
664
- return text.trim();
665
- }
666
-
667
- export function showEntriesForMessage(message: { role?: string; content?: unknown }): string[] {
668
- if (message.role === "user") {
669
- const text = showTextContent(message.content);
670
- return text ? [`## user\n${JSON.stringify(text)}`] : [];
671
- }
672
- if (message.role === "assistant") {
673
- const text = showTextContent(message.content);
674
- return text ? [`## assistant\n${JSON.stringify(text)}`] : [];
675
- }
676
- return [];
677
- }
678
-
679
- function serializeShowTurns(turns: readonly ShowTurn[]): string {
680
- return turns.flatMap((turn) => turn.entries).join("\n\n");
681
- }
682
-
683
- export function captureShowTranscript(ctx: ExtensionCommandContext, turnsRequested: number, maxLength = MAX_TEXT_LENGTH): BroSource | undefined {
684
- if (!Number.isSafeInteger(maxLength) || maxLength < 128) throw new Error("Transcript limit must be an integer of at least 128 characters.");
685
- const turns: ShowTurn[] = [];
686
- for (const entry of ctx.sessionManager.getBranch()) {
687
- if (entry.type !== "message") continue;
688
- const message = entry.message as { role?: string; stopReason?: string };
689
- // Aborted assistant text is a half-written claim, not a report.
690
- if (message.role === "assistant" && message.stopReason === "abort") continue;
691
- const entries = showEntriesForMessage(message);
692
- // A user message always marks a turn boundary even when it has no
693
- // capturable text (image-only, whitespace-only): the turn must still
694
- // count, or /bro show 1 would silently over-capture earlier turns.
695
- if (message.role === "user") turns.push({ entries, startsTurn: true });
696
- else if (entries.length) turns.push({ entries, startsTurn: false });
697
- }
698
-
699
- let start = 0;
700
- let seen = 0;
701
- for (let index = turns.length - 1; index >= 0; index -= 1) {
702
- if (!turns[index]!.startsTurn) continue;
703
- seen += 1;
704
- if (seen === turnsRequested) {
705
- start = index;
706
- break;
707
- }
708
- }
709
- if (seen === 0) return undefined;
710
-
711
- let text = serializeShowTurns(turns.slice(start));
712
- while (text.length > maxLength && start < turns.length - 1) {
713
- let next = turns.length;
714
- for (let index = start + 1; index < turns.length; index += 1) {
715
- if (turns[index]!.startsTurn) {
716
- next = index;
717
- break;
718
- }
719
- }
720
- if (next >= turns.length) break;
721
- start = next;
722
- text = serializeShowTurns(turns.slice(start));
723
- }
724
- if (text.length > maxLength) {
725
- // Prefer the newest complete messages even within an oversized single turn.
726
- const entries = turns.slice(start).flatMap(turn => turn.entries);
727
- const notice = "[… earlier messages truncated …]\n";
728
- while (entries.length > 1 && entries.join("\n\n").length + notice.length > maxLength) entries.shift();
729
- text = entries.join("\n\n");
730
- if (text.length + notice.length > maxLength) {
731
- const newline = text.indexOf("\n");
732
- const header = text.slice(0, newline);
733
- const content = JSON.parse(text.slice(newline + 1)) as string;
734
- const marker = "[… earlier text truncated …] ";
735
- let tail = content.slice(-(maxLength - header.length - marker.length - 8));
736
- while (`${header}\n${JSON.stringify(marker + tail)}`.length > maxLength) tail = tail.slice(Math.max(1, Math.floor(tail.length / 10)));
737
- text = `${header}\n${JSON.stringify(marker + tail)}`;
738
- } else text = notice + text;
739
- }
740
- const kept = turns.slice(start).filter((turn) => turn.startsTurn).length;
741
- return { text: text.trim(), label: `last ${Math.max(1, kept)} turn${kept > 1 ? "s" : ""} · conversation only` };
742
- }
743
-
744
- export interface ParsedShowArguments {
745
- // undefined means "use the configured default"; invalid leading numeric tokens report `invalid` instead.
746
- requested?: string;
747
- steering: string;
748
- invalid: boolean;
749
- }
750
-
751
- // A leading token is only ever treated as the turn count, never as the start of the query — so
752
- // "/bro show 1 404 handler" is count 1, query "404 handler", not an ambiguous double-numeric query.
753
- export function parseShowArguments(value: string): ParsedShowArguments {
754
- const firstSpace = value.search(/\s/);
755
- const firstToken = firstSpace === -1 ? value : value.slice(0, firstSpace);
756
- const looksLikeCount = firstToken !== "" && /^-?\d+(?:\.\d+)?$/.test(firstToken);
757
- if (!looksLikeCount) return { steering: value, invalid: false };
758
-
759
- // Slicing the raw remainder (instead of split(/\s+/).join(" ")) keeps the query's original spacing intact.
760
- const steering = firstSpace === -1 ? "" : value.slice(firstSpace).replace(/^\s+/, "");
761
- const valid = /^[1-9]\d*$/.test(firstToken) && Number.isSafeInteger(Number(firstToken));
762
- return { requested: valid ? firstToken : undefined, steering, invalid: !valid };
763
- }
764
-
765
- export type ShowDiagram = { kind: "html" | "mermaid"; source: string };
766
- const SHOW_DIAGRAM_FENCE = /^```(html|mermaid)[^\S\r\n]*\r?\n([\s\S]*?)^```[^\S\r\n]*$/gm;
767
-
768
- export function extractShowDiagram(text: string): ShowDiagram | undefined {
769
- const last = [...text.matchAll(SHOW_DIAGRAM_FENCE)].at(-1);
770
- if (!last) return undefined;
771
- const diagram = { kind: last[1] as ShowDiagram["kind"], source: last[2]!.trim() };
772
- // Any other Mermaid stays in the modal as a plain text block, visible rather than hidden.
773
- return diagram.kind === "html" || isRenderableSequence(diagram.source) ? diagram : undefined;
774
- }
775
-
776
- export function stripShowDiagramFence(text: string): string {
777
- const last = [...text.matchAll(SHOW_DIAGRAM_FENCE)].at(-1);
778
- if (!last || last.index === undefined) return text;
779
- const shown = last[1] === "mermaid" ? `${last[0]}\n[Diagram saved — press O to render]` : "[HTML diagram saved — press O to open]";
780
- return text.slice(0, last.index) + shown + text.slice(last.index + last[0].length);
781
- }
782
-
783
- export function showHtmlDirectory(): string {
784
- // ponytail: per-uid directory so keep-one cleanup never touches other
785
- // users' files in a shared /tmp, and readdir stays small.
786
- return join(tmpdir(), `pi-bro-${typeof process.getuid === "function" ? process.getuid() : "user"}`);
787
- }
788
-
789
- function withShowCsp(html: string): string {
790
- // Defense in depth: the prompt forbids external resources and scripts;
791
- // a meta CSP blocks them anyway if the model slips. Always inserted: multiple
792
- // policies are enforced together, so a model-supplied CSP can only tighten it.
793
- // Skip only simple leading comments: browsers accept comment terminators that
794
- // a general regex misses. Ambiguous markup gets the policy prepended instead.
795
- const meta = '<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; style-src \'unsafe-inline\'; img-src data:;">';
796
- return html.replace(/^(\s*(?:<!--[^<>]*-->\s*)*(?:<!doctype[^>]*>\s*)?)/i, `$1\n${meta}\n`);
797
- }
798
-
799
- // Bro, not the model, owns every script on a Mermaid page: the CSP allows only these two exact
800
- // scripts by hash, and the model's source is inserted as escaped text that Mermaid parses itself.
801
- const MERMAID_INIT = 'mermaid.initialize({startOnLoad:true,securityLevel:"strict",htmlLabels:false,flowchart:{htmlLabels:false}});';
802
-
803
- async function vendoredMermaid(): Promise<string> {
804
- const library = await readFile(new URL("./vendor/mermaid.min.js", import.meta.url), "utf8");
805
- // Inlined in <script>: "<!--" or "<script" would change how HTML parses the element. Every
806
- // occurrence sits inside a JS string or regex literal, where \x3C is the same character.
807
- return library.replace(/<(?=!--|\/?script)/gi, "\\x3C");
808
- }
809
-
810
- function escapeHtml(text: string): string {
811
- return text.replace(/[&<>"]/g, (character) => `&#${character.charCodeAt(0)};`);
812
- }
813
-
814
- export async function mermaidShowPage(source: string): Promise<string> {
815
- const library = await vendoredMermaid();
816
- const hash = (script: string) => `'sha256-${createHash("sha256").update(script).digest("base64")}'`;
817
- const escaped = escapeHtml(source);
818
- return `<!doctype html>
819
- <meta charset="utf-8">
820
- <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; script-src ${hash(library)} ${hash(MERMAID_INIT)}">
821
- <title>Bro diagram</title>
822
- <body style="margin:16px;font-family:system-ui,sans-serif">
823
- <style>pre.mermaid svg{max-height:calc(100vh - 64px)}</style>
824
- <pre class="mermaid">${escaped}</pre>
825
- <details><summary>Diagram source</summary><pre>${escaped}</pre></details>
826
- <script>${library}</script>
827
- <script>${MERMAID_INIT}</script>
828
- </body>`;
829
- }
830
-
831
- export async function writeShowDiagram(diagram: ShowDiagram): Promise<string> {
832
- const page = diagram.kind === "mermaid" ? await mermaidShowPage(diagram.source) : withShowCsp(diagram.source);
833
- const slug = createHash("sha256").update(page).digest("hex").slice(0, 8);
834
- const directory = showHtmlDirectory();
835
- await mkdir(directory, { recursive: true, mode: 0o700 });
836
- for (const name of await readdir(directory)) {
837
- if (SHOW_HTML_FILE_PATTERN.test(name)) await rm(join(directory, name), { force: true });
838
- }
839
- const path = join(directory, `bro-show-${slug}.html`);
840
- await writeFile(path, `${page}\n`, "utf8");
841
- return path;
842
- }
843
-
844
- // A diagram that cannot be written must not cost the reply: keep the text and warn instead.
845
- async function showDiagramPath(text: string, warn: (message: string) => void): Promise<string | undefined> {
846
- const diagram = extractShowDiagram(text);
847
- if (!diagram) return undefined;
848
- try { return await writeShowDiagram(diagram); }
849
- catch (error) {
850
- warn(`Diagram file unavailable: ${errorMessage(error)}`);
851
- return undefined;
852
- }
853
- }
854
-
855
- function openShowHtml(path: string): boolean {
856
- if (process.platform === "win32") {
857
- const result = spawnSync("cmd", ["/c", "start", "", path], { stdio: "ignore", timeout: 5_000 });
858
- return result.status === 0;
859
- }
860
- const opener = process.platform === "darwin" ? "open" : "xdg-open";
861
- const result = spawnSync(opener, [path], { stdio: "ignore", timeout: 5_000 });
862
- return result.status === 0;
863
- }
864
-
865
- // Raw file text for the editor, undefined when there is no file: never rejects for length, so an
866
- // oversize file can be opened and trimmed.
867
- export async function readPreferencesRaw(): Promise<string | undefined> {
868
- let size: number;
869
- try {
870
- size = (await stat(PREFERENCES_FILE)).size;
871
- } catch (error) {
872
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
873
- throw new Error(`Cannot read ${PREFERENCES_FILE}: ${errorMessage(error)}`);
874
- }
875
- if (size > MAX_PREFERENCES_FILE_BYTES) throw new Error(`${PREFERENCES_FILE} is larger than 64 KB. Edit or delete it directly.`);
876
- try {
877
- return (await readFile(PREFERENCES_FILE, "utf8")).replace(/^\uFEFF/, "");
878
- } catch (error) {
879
- throw new Error(`Cannot read ${PREFERENCES_FILE}: ${errorMessage(error)}`);
880
- }
881
- }
882
-
883
- // Preferences for prompts: "" when absent or blank. Re-read on every request. An oversize file stops
884
- // the request before any backend call instead of being silently truncated.
885
- export async function readPreferences(): Promise<string> {
886
- const text = ((await readPreferencesRaw()) ?? "").trim();
887
- if (text.length > MAX_PREFERENCES_CHARS) {
888
- throw new Error(`${PREFERENCES_FILE} is ${text.length.toLocaleString("en-US")} characters; keep it under ${MAX_PREFERENCES_CHARS.toLocaleString("en-US")} (it's sent with every request). Use /bro preferences to trim it.`);
889
- }
890
- return text;
891
- }
892
-
893
- // Raw durable advisor steering text, undefined when there is no file. Rejects non-regular files and
894
- // files > 64 KB before read.
895
- export async function readAdvisorSteeringRaw(): Promise<string | undefined> {
896
- let stats: Awaited<ReturnType<typeof stat>>;
897
- try {
898
- stats = await stat(ADVISOR_STEERING_FILE);
899
- } catch (error) {
900
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
901
- throw new Error(`Cannot read ${ADVISOR_STEERING_FILE}: ${errorMessage(error)}`);
902
- }
903
- if (!stats.isFile()) {
904
- throw new Error(`${ADVISOR_STEERING_FILE} is not a regular file. Edit or delete it directly.`);
905
- }
906
- if (stats.size > MAX_ADVISOR_STEERING_FILE_BYTES) {
907
- throw new Error(`${ADVISOR_STEERING_FILE} is larger than 64 KB. Edit or delete it directly.`);
908
- }
909
- try {
910
- const bytes = await readFile(ADVISOR_STEERING_FILE);
911
- return new TextDecoder("utf-8", { fatal: true }).decode(bytes).replace(/^\uFEFF/, "");
912
- } catch (error) {
913
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
914
- throw new Error(`Cannot read ${ADVISOR_STEERING_FILE}: ${errorMessage(error)}`);
915
- }
916
- }
917
-
918
- // Durable steering for the advisor tool: "" when absent or blank. Re-read on every consultation.
919
- // An oversize or unreadable file throws an actionable message for the executor agent before spawn.
920
- export async function readAdvisorSteering(): Promise<string> {
921
- const text = ((await readAdvisorSteeringRaw()) ?? "").trim();
922
- if (text.length > MAX_ADVISOR_STEERING_CHARS) {
923
- throw new Error(
924
- `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.`
925
- );
926
- }
927
- return text;
928
- }
929
-
930
- async function simplify(
931
- response: string,
932
- signal: AbortSignal,
933
- settings: BroSettings,
934
- onProgress?: (text: string) => void,
935
- mode = settings.mode,
936
- ): Promise<{ text: string; model: string; mode: BroMode; preferences: boolean }> {
937
- const selection = selectionForCapability(settings, "explain");
938
- const preferences = await readPreferences();
939
- const text = await runBackendText(buildDefaultPrompt(response, mode, preferences), selection, signal, onProgress);
940
- return { text, model: selectionLabel(selection), mode, preferences: Boolean(preferences) };
941
- }
942
-
943
- async function runShowExplanation(
944
- transcript: string,
945
- steering: string,
946
- signal: AbortSignal,
947
- settings: BroSettings,
948
- onProgress?: (text: string) => void,
949
- ): Promise<{ text: string; model: string; preferences: boolean }> {
950
- const selection = selectionForCapability(settings, "show");
951
- const preferences = await readPreferences();
952
- const text = await runBackendText(buildShowPrompt(transcript, steering, preferences), selection, signal, onProgress, "show");
953
- return { text, model: selectionLabel(selection), preferences: Boolean(preferences) };
954
- }
955
-
956
- // Thin presentation-boundary wrapper around the shared backend: coalesces raw text progress to the
957
- // existing 75ms cadence and translates the backend's tagged outcome back into this function's
958
- // existing throw-on-failure contract.
959
- async function runBackendText(
960
- prompt: string,
961
- selection: BackendSelection,
962
- signal: AbortSignal,
963
- onProgress?: (text: string) => void,
964
- feature: "explain" | "show" = "explain",
965
- ): Promise<string> {
966
- let updateTimer: ReturnType<typeof setTimeout> | undefined;
967
- let latest: string | undefined;
968
- const throttledProgress = onProgress
969
- ? (progress: BackendProgress) => {
970
- if (progress.kind !== "text") return;
971
- latest = progress.text;
972
- if (!updateTimer) {
973
- updateTimer = setTimeout(() => {
974
- updateTimer = undefined;
975
- if (!signal.aborted && latest !== undefined) onProgress(latest);
976
- }, 75);
977
- }
978
- }
979
- : undefined;
980
-
981
- try {
982
- const outcome = await executeBackend({ feature, prompt, access: "restricted" }, selection, signal, throttledProgress);
983
- if (outcome.status === "success") return outcome.text;
984
- throw new Error(outcome.message);
985
- } finally {
986
- if (updateTimer) clearTimeout(updateTimer);
987
- }
988
- }
989
-
990
- // Harness-neutral advisor context snapshot: plain role/text/tool-name+args/tool-result-text, not
991
- // Pi's internal message objects serialized as-is, so the shape isn't a Pi-specific contract. Every
992
- // piece of free-text message content is JSON-quoted (the same "quote the source as data" convention
993
- // SOURCE_GUARD/buildShowPrompt/buildBtwPrompt already use in prompt.ts) rather than pasted in raw
994
- // after a bare "## role" heading: unquoted text under a heading would let pasted text or tool output
995
- // forge a fake "## user"/"## assistant" section that looks like a real turn boundary. See
996
- // docs/plans/2026-09-19-bro-advisor-design.md, "Context snapshot".
997
- type AdvisorMessageLike = { role?: unknown; content?: unknown; toolName?: unknown; toolCallId?: unknown; isError?: unknown };
998
-
999
- // Parameter element type of Pi's own AgentMessage → LLM converter (core/messages.ts), reused below
1000
- // instead of redeclaring BashExecutionMessage's shape (it isn't exported from the package root).
1001
- type AdvisorAgentMessage = Parameters<typeof convertToLlm>[0][number];
1002
-
1003
- function advisorQuoted(text: string): string {
1004
- return JSON.stringify(text);
1005
- }
1006
-
1007
- // `resolvedToolCallIds` lets a still-pending bro_advisor call (the one currently in flight for this
1008
- // very consultation, which by definition has no result yet) be dropped instead of rendered as an
1009
- // orphaned, unresolved call — a past, completed bro_advisor call is unaffected and renders normally.
1010
- function advisorContentText(content: unknown, resolvedToolCallIds: ReadonlySet<string>): string {
1011
- if (typeof content === "string") {
1012
- const text = content.trim();
1013
- return text ? advisorQuoted(text) : "";
1014
- }
1015
- if (!Array.isArray(content)) return "";
1016
- const parts: string[] = [];
1017
- for (const part of content) {
1018
- if (!part || typeof part !== "object") continue;
1019
- const type = (part as { type?: string }).type;
1020
- if (type === "text" && typeof (part as { text?: unknown }).text === "string") {
1021
- const text = (part as { text: string }).text.trim();
1022
- if (text) parts.push(advisorQuoted(text));
1023
- } else if (type === "image") {
1024
- parts.push("[image omitted]");
1025
- } else if (type === "thinking") {
1026
- parts.push("[reasoning omitted]");
1027
- } else if (type === "toolCall") {
1028
- const call = part as { id?: unknown; name?: unknown; arguments?: unknown };
1029
- const id = typeof call.id === "string" ? call.id : undefined;
1030
- const name = typeof call.name === "string" ? call.name : "(unknown)";
1031
- if (name === ADVISOR_TOOL_NAME && (!id || !resolvedToolCallIds.has(id))) continue;
1032
- parts.push(`[tool call${id ? ` #${id}` : ""}] ${name}(${JSON.stringify(call.arguments ?? {})})`);
1033
- }
1034
- }
1035
- return parts.join("\n").trim();
1036
- }
1037
-
1038
- function renderAdvisorMessage(message: AdvisorMessageLike, resolvedToolCallIds: ReadonlySet<string>): string {
1039
- if (message.role === "user") {
1040
- const text = advisorContentText(message.content, resolvedToolCallIds);
1041
- return text ? `## user\n${text}` : "";
1042
- }
1043
- if (message.role === "assistant") {
1044
- const text = advisorContentText(message.content, resolvedToolCallIds);
1045
- return text ? `## assistant\n${text}` : "";
1046
- }
1047
- if (message.role === "toolResult") {
1048
- const name = typeof message.toolName === "string" ? message.toolName : "(unknown)";
1049
- const id = typeof message.toolCallId === "string" ? message.toolCallId : undefined;
1050
- const header = `## tool result${id ? ` [#${id}]` : ""}: ${name}${message.isError ? " · error" : ""}`;
1051
- const text = advisorContentText(message.content, resolvedToolCallIds);
1052
- return text ? `${header}\n${text}` : header;
1053
- }
1054
- if (message.role === "bashExecution") {
1055
- // Reuse Pi's own AgentMessage -> LLM conversion instead of hand-rolling one: it already
1056
- // drops a command run with the `!!` prefix (excludeFromContext) and formats the rest exactly
1057
- // as Pi's own context builder would, so a `!`-run command an executor can see is not silently
1058
- // missing from what the advisor sees.
1059
- const [converted] = convertToLlm([message as AdvisorAgentMessage]);
1060
- if (!converted) return "";
1061
- const text = advisorContentText(converted.content, resolvedToolCallIds);
1062
- return text ? `## bash execution\n${text}` : "";
1063
- }
1064
- return "";
1065
- }
1066
-
1067
- export function buildAdvisorSnapshot(ctx: ExtensionContext, pi: ExtensionAPI): { text: string; hadCompaction: boolean } {
1068
- const systemPrompt = ctx.getSystemPrompt().trim();
1069
-
1070
- const activeToolNames = new Set(pi.getActiveTools().filter((name) => name !== ADVISOR_TOOL_NAME));
1071
- const tools = pi.getAllTools().filter((tool) => activeToolNames.has(tool.name)).sort((a, b) => a.name.localeCompare(b.name));
1072
- // No truncation of tool descriptions: Bro imposes no size cap on the snapshot (see the design
1073
- // doc) and an arbitrary slice would silently drop part of a tool's actual behavior contract.
1074
- const toolsSection = tools.length
1075
- ? tools.map((tool) => `- ${tool.name}: ${tool.description.replace(/\s+/g, " ").trim()}`).join("\n")
1076
- : "(none)";
1077
-
1078
- const entries = ctx.sessionManager.buildContextEntries();
1079
- const resolvedToolCallIds = new Set<string>();
1080
- for (const entry of entries) {
1081
- if (entry.type !== "message") continue;
1082
- const message = entry.message as AdvisorMessageLike;
1083
- if (message.role === "toolResult" && typeof message.toolCallId === "string") resolvedToolCallIds.add(message.toolCallId);
1084
- }
1085
-
1086
- const lines: string[] = [];
1087
- // Tracked from the actual entry type, not a substring search over the rendered snapshot text --
1088
- // free-text content (a file the executor read, a pasted paragraph) is JSON-quoted but not
1089
- // stripped of "#", so a substring check could be forged by source data that happens to contain
1090
- // the literal heading text. Only a real `compaction` entry may set this.
1091
- let hadCompaction = false;
1092
- for (const entry of entries) {
1093
- if (entry.type === "message") {
1094
- const rendered = renderAdvisorMessage(entry.message as AdvisorMessageLike, resolvedToolCallIds);
1095
- if (rendered) lines.push(rendered);
1096
- } else if (entry.type === "compaction") {
1097
- // buildContextEntries() already includes every real entry after the compaction point;
1098
- // the summary is all there is to represent for what it replaced.
1099
- hadCompaction = true;
1100
- lines.push(`## compacted earlier context\n${advisorQuoted(entry.summary)}`);
1101
- } else if (entry.type === "branch_summary") {
1102
- lines.push(`## abandoned branch summary\n${advisorQuoted(entry.summary)}`);
1103
- } else if (entry.type === "custom_message") {
1104
- const text = advisorContentText(entry.content, resolvedToolCallIds);
1105
- if (text) lines.push(`## extension message: ${entry.customType}\n${text}`);
1106
- }
1107
- // "custom" entries (including our own advisor activation/steering state) are intentionally
1108
- // skipped — they never participate in LLM context and must not leak into the snapshot either.
1109
- }
1110
- const conversationSection = lines.length ? lines.join("\n\n") : "(no conversation content captured)";
1111
-
1112
- const text = `## Executor's system instructions\n\n${systemPrompt ? advisorQuoted(systemPrompt) : "(none)"}\n\n## Executor's active tools\n\n${toolsSection}\n\n## Conversation so far (message text is quoted as JSON strings; includes tool calls and results, correlated by call id; image and reasoning content is noted but omitted; the still-pending advisor call for this very consultation is omitted)\n\n${conversationSection}`;
1113
- return { text, hadCompaction };
1114
- }
1115
-
1116
- const MAX_ADVISOR_ACTIVITY_LINES = 4;
1117
- const ADVISOR_ACTIVITY_PREVIEW_CHARS = 100;
1118
-
1119
- function advisorActivityPreview(label: string): string {
1120
- return label.length > ADVISOR_ACTIVITY_PREVIEW_CHARS ? `${label.slice(0, ADVISOR_ACTIVITY_PREVIEW_CHARS).trimEnd()}…` : label;
1121
- }
1122
-
1123
- export type AdvisorActivityCallback = (label: string, timestamp: number) => void;
1124
-
1125
- // Thin wrapper around the shared backend: advisor's stdin/stream-json transport, activity parsing,
1126
- // and process lifecycle now live in backend.ts (see docs/plans/2026-09-19-bro-advisor-design.md,
1127
- // "Transport" for why stdin rather than --print). This function keeps its exact existing
1128
- // signature/throw contract -- it is called directly by tests and by runAdvisorWithRetries below,
1129
- // which owns the 3-attempt retry/backoff policy the backend itself never performs.
1130
- export class TerminalConsultError extends Error {
1131
- readonly status: "failure" | "cancelled" | "timeout";
1132
- constructor(status: "failure" | "cancelled" | "timeout", message: string) {
1133
- super(message);
1134
- this.status = status;
1135
- }
1136
- }
1137
-
1138
- export async function runAdvisorConsultation(
1139
- prompt: string,
1140
- selection: BackendSelection,
1141
- cwd: string,
1142
- signal: AbortSignal,
1143
- killEscalationMs = 5_000,
1144
- onActivity?: AdvisorActivityCallback,
1145
- deadlineMs?: number,
1146
- ): Promise<string> {
1147
- const outcome = await executeBackend(
1148
- { feature: "advisor", prompt, access: "workspace-full", cwd },
1149
- selection,
1150
- signal,
1151
- onActivity
1152
- ? (progress) => {
1153
- if (progress.kind === "activity") onActivity(progress.label, progress.timestamp);
1154
- }
1155
- : undefined,
1156
- { killEscalationMs, deadlineMs },
1157
- );
1158
- if (outcome.status === "success") return outcome.text;
1159
- throw new TerminalConsultError(outcome.status, outcome.message);
1160
- }
1161
-
1162
- function advisorDelay(ms: number, signal: AbortSignal, onTick?: (remainingMs: number) => void): Promise<void> {
1163
- return new Promise((resolve, reject) => {
1164
- if (signal.aborted) {
1165
- reject(new Error("Canceled."));
1166
- return;
1167
- }
1168
- const startedAt = Date.now();
1169
- const interval = onTick ? setInterval(() => onTick(Math.max(0, ms - (Date.now() - startedAt))), 1_000) : undefined;
1170
- interval?.unref();
1171
- const cleanup = () => {
1172
- clearTimeout(timer);
1173
- if (interval) clearInterval(interval);
1174
- signal.removeEventListener("abort", onAbort);
1175
- };
1176
- const onAbort = () => {
1177
- cleanup();
1178
- reject(new Error("Canceled."));
1179
- };
1180
- const timer = setTimeout(() => {
1181
- cleanup();
1182
- resolve();
1183
- }, ms);
1184
- signal.addEventListener("abort", onAbort, { once: true });
1185
- });
1186
- }
1187
-
1188
- export type AdvisorConsult = (
1189
- prompt: string,
1190
- selection: BackendSelection,
1191
- cwd: string,
1192
- signal: AbortSignal,
1193
- killEscalationMs?: number,
1194
- onActivity?: AdvisorActivityCallback,
1195
- ) => Promise<string>;
1196
- export type AdvisorDelayFn = (ms: number, signal: AbortSignal, onTick?: (remainingMs: number) => void) => Promise<void>;
1197
- export type AdvisorToolDetails = {
1198
- status: "investigating" | "retrying" | "done";
1199
- attempt: number;
1200
- of: number;
1201
- elapsedMs: number;
1202
- error?: string;
1203
- retryInMs?: number;
1204
- backend?: BackendName;
1205
- model?: string;
1206
- effort?: string;
1207
- durationMs?: number;
1208
- cwd?: string;
1209
- steeringIncluded?: boolean;
1210
- steeringSources?: { durable: boolean; session: boolean };
1211
- snapshotChars?: number;
1212
- broTruncated?: false;
1213
- omissions?: string;
1214
- // Real backend-reported activity for the current attempt only -- reset to empty whenever a new
1215
- // attempt (including a retry) starts, never carried over from a prior failed attempt.
1216
- activity?: string[];
1217
- lastActivityAt?: number;
1218
- // Total count of accepted activity events (tool calls AND user-facing text deltas -- "activity"
1219
- // is the honest label, not "tool calls") observed across the whole consultation, including every
1220
- // retry attempt. Unlike `activity` above, this never resets on a retry; the final "done" details
1221
- // carry the same running total.
1222
- activityCount?: number;
1223
- };
1224
- export type AdvisorProgressCallback = (details: AdvisorToolDetails) => void;
1225
- export type AdvisorRunResult = { advice: string; attempts: number; durationMs: number; activityCount: number };
1226
-
1227
- // Bursts of chunked NDJSON activity events are coalesced to this cadence so a fast stream of tool
1228
- // calls/text deltas doesn't flood onProgress; the 1s elapsed-time heartbeat below is unaffected and
1229
- // keeps ticking independently.
1230
- const ADVISOR_ACTIVITY_THROTTLE_MS = 250;
1231
-
1232
- // `consult`/`delayFn` are injectable so tests can swap in a fake process spawn and a fake clock
1233
- // instead of spawning real processes and waiting 15 real seconds. Every attempt sends the
1234
- // identical prompt/selection/cwd to a fresh, standalone backend process — never resumed via
1235
- // --conversation, even across retries.
1236
- export async function runAdvisorWithRetries(
1237
- prompt: string,
1238
- selection: BackendSelection,
1239
- cwd: string,
1240
- signal: AbortSignal,
1241
- consult: AdvisorConsult = runAdvisorConsultation,
1242
- delayFn: AdvisorDelayFn = advisorDelay,
1243
- onProgress?: AdvisorProgressCallback,
1244
- progressIntervalMs = 1_000,
1245
- activityThrottleMs = ADVISOR_ACTIVITY_THROTTLE_MS,
1246
- ): Promise<AdvisorRunResult> {
1247
- const delays = [5_000, 10_000];
1248
- const totalAttempts = delays.length + 1;
1249
- const startedAt = Date.now();
1250
- let lastError: unknown;
1251
- // Cumulative across every attempt in this consultation, including retries -- unlike `activity`
1252
- // below, a retry must never reset this back to zero.
1253
- let totalActivityCount = 0;
1254
- for (let attempt = 0; attempt <= delays.length; attempt++) {
1255
- if (signal.aborted) throw new Error("Canceled.");
1256
- // Fresh per attempt: a retry must never show the previous attempt's activity trail.
1257
- const activity: string[] = [];
1258
- let lastActivityAt: number | undefined;
1259
- let lastActivityEmitAt = 0;
1260
- let pendingActivityTimer: ReturnType<typeof setTimeout> | undefined;
1261
-
1262
- const running = (): AdvisorToolDetails => ({
1263
- status: "investigating",
1264
- attempt: attempt + 1,
1265
- of: totalAttempts,
1266
- elapsedMs: Date.now() - startedAt,
1267
- activity: [...activity],
1268
- lastActivityAt,
1269
- activityCount: totalActivityCount,
1270
- });
1271
- const emitRunning = () => onProgress?.(running());
1272
- emitRunning();
1273
- const progressTimer = onProgress ? setInterval(emitRunning, progressIntervalMs) : undefined;
1274
- progressTimer?.unref();
1275
-
1276
- const stopActivityThrottle = () => {
1277
- if (pendingActivityTimer) {
1278
- clearTimeout(pendingActivityTimer);
1279
- pendingActivityTimer = undefined;
1280
- }
1281
- };
1282
- const onActivity: AdvisorActivityCallback = (label, timestamp) => {
1283
- const normalized = label.replace(/\s+/g, " ").trim();
1284
- if (!normalized) return;
1285
- activity.push(normalized);
1286
- if (activity.length > MAX_ADVISOR_ACTIVITY_LINES) activity.splice(0, activity.length - MAX_ADVISOR_ACTIVITY_LINES);
1287
- totalActivityCount += 1;
1288
- lastActivityAt = timestamp;
1289
- if (!onProgress) return;
1290
- const elapsed = Date.now() - lastActivityEmitAt;
1291
- if (elapsed >= activityThrottleMs) {
1292
- lastActivityEmitAt = Date.now();
1293
- emitRunning();
1294
- return;
1295
- }
1296
- if (!pendingActivityTimer) {
1297
- pendingActivityTimer = setTimeout(() => {
1298
- pendingActivityTimer = undefined;
1299
- lastActivityEmitAt = Date.now();
1300
- emitRunning();
1301
- }, activityThrottleMs - elapsed);
1302
- pendingActivityTimer.unref?.();
1303
- }
1304
- };
1305
-
1306
- try {
1307
- const advice = await consult(prompt, selection, cwd, signal, undefined, onActivity);
1308
- // `totalActivityCount` is read directly here rather than from the last onProgress snapshot,
1309
- // which may lag behind by up to `activityThrottleMs` -- the final count must be exact.
1310
- return { advice, attempts: attempt + 1, durationMs: Date.now() - startedAt, activityCount: totalActivityCount };
1311
- } catch (error) {
1312
- if (progressTimer) clearInterval(progressTimer);
1313
- stopActivityThrottle();
1314
- if (signal.aborted || errorMessage(error) === "Canceled." ||
1315
- (error instanceof TerminalConsultError && error.status !== "failure")) throw error;
1316
- lastError = error;
1317
- if (attempt === delays.length) break;
1318
- const delay = delays[attempt]!;
1319
- const retrying = (remainingMs: number): AdvisorToolDetails => ({
1320
- status: "retrying",
1321
- attempt: attempt + 2,
1322
- of: totalAttempts,
1323
- elapsedMs: Date.now() - startedAt,
1324
- error: errorMessage(error),
1325
- retryInMs: remainingMs,
1326
- activityCount: totalActivityCount,
1327
- });
1328
- onProgress?.(retrying(delay));
1329
- await delayFn(delay, signal, (remainingMs) => onProgress?.(retrying(remainingMs)));
1330
- } finally {
1331
- if (progressTimer) clearInterval(progressTimer);
1332
- stopActivityThrottle();
1333
- }
1334
- }
1335
- throw lastError;
1336
- }
1337
-
1338
- export function advisorAttemptLabel(details: AdvisorToolDetails): string {
1339
- if (details.status === "retrying") {
1340
- return `Bro advisor · retrying in ${Math.ceil((details.retryInMs ?? 0) / 1_000)}s · attempt ${details.attempt}/${details.of}${details.error ? ` — ${details.error}` : ""}`;
1341
- }
1342
- if (details.status === "done") {
1343
- return `Bro advisor · ${details.model ?? "unknown model"} · ${details.attempt} attempt${details.attempt === 1 ? "" : "s"} · ${Math.ceil((details.durationMs ?? details.elapsedMs) / 1_000)}s`;
1344
- }
1345
- const latest = details.activity?.at(-1);
1346
- const backendLabel = details.backend === "claude" ? "Claude" : details.backend === "grok" ? "Grok" : details.backend === "codex" ? "Codex" : details.backend === "muse" ? "Muse" : "Agy";
1347
- // "last reported" + freshness, never a claim about what the backend is doing right now and never
1348
- // "stalled" -- silence since lastActivityAt is not itself evidence of a stuck run.
1349
- const activityLabel = latest
1350
- ? `last reported: ${advisorActivityPreview(latest)} (${Math.max(0, Math.floor((Date.now() - (details.lastActivityAt ?? Date.now())) / 1_000))}s ago)`
1351
- : `awaiting first activity from ${backendLabel}`;
1352
- return `Bro advisor · running · ${Math.floor(details.elapsedMs / 1_000)}s · attempt ${details.attempt}/${details.of} · ${activityLabel}`;
1353
- }
1354
-
1355
- export async function showBroConfigModal(ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> {
1356
- if (!hasBroCustomUi(ctx)) {
1357
- ctx.ui.notify("Use /bro config in Pi's interactive UI.", "warning");
1358
- return;
1359
- }
1360
- let settings: BroSettings;
1361
- try {
1362
- settings = await readSettings();
1363
- } catch (error) {
1364
- ctx.ui.notify(withDoctor(error), "error");
1365
- return;
1366
- }
1367
- // Keep configuration repair accessible even when the Agy catalog is unavailable.
1368
- let families: AgyModelFamily[];
1369
- try {
1370
- families = await listAgyModels(pi);
1371
- } catch (error) {
1372
- families = [];
1373
- ctx.ui.notify("Agy model catalog unavailable — showing other backend settings without Agy choices.", "warning");
1374
- }
1375
- await ctx.ui.custom<void>(createConfigModal(settings, families, writeSettings), {
1376
- overlay: true,
1377
- overlayOptions: {
1378
- width: "78%",
1379
- minWidth: 48,
1380
- maxHeight: "78%",
1381
- anchor: "top-center",
1382
- margin: { top: 1, left: 2, right: 2 },
1383
- },
1384
- });
1385
- }
1386
-
1387
- type TextEditorModalOptions = {
1388
- title: string;
1389
- subtitle: string;
1390
- initialText: string;
1391
- initialNotice?: string;
1392
- // Returns an error message to refuse the save without calling onSave.
1393
- validate?: (text: string) => string | undefined;
1394
- // Either may be async; the modal then waits, ignores further edits, and reports success only once it settles.
1395
- onSave: (text: string) => void | Promise<void>;
1396
- onClear: () => void | Promise<void>;
1397
- copy?: (text: string) => Promise<void>;
1398
- };
1399
-
1400
- // Testable core shared by /bro advisor-steer and /bro preferences: persistence only happens on
1401
- // Ctrl+S/Ctrl+K. Esc leaves the stored text untouched; only the in-memory draft is discarded.
1402
- export function createTextEditorModal(
1403
- options: TextEditorModalOptions,
1404
- ): (tui: TUI, theme: Theme, keybindings: unknown, done: (value?: void) => void) => Component & { dispose?(): void } {
1405
- const { onSave, onClear, validate, copy = copyToClipboard } = options;
1406
- return (tui, theme, _keybindings, done) => {
1407
- const editorTheme: EditorTheme = { borderColor: (s: string) => theme.fg("border", s), selectList: getSelectListTheme() };
1408
- const editor = new Editor(tui, editorTheme);
1409
- editor.focused = true;
1410
- editor.setText(options.initialText);
1411
- let disposed = false;
1412
- let pending = false;
1413
- const notice = new Text(options.initialNotice ? theme.fg("accent", options.initialNotice) : "");
1414
- const showNotice = (message: string, color: "success" | "error") => {
1415
- if (disposed) return;
1416
- notice.setText(theme.fg(color, message));
1417
- tui.requestRender();
1418
- };
1419
- editor.onChange = () => notice.setText("");
1420
- const persist = (action: () => void | Promise<void>, success: string, failure: string, after?: () => void) => {
1421
- const fail = (error: unknown) => showNotice(`${failure}: ${errorMessage(error)}`, "error");
1422
- let result: void | Promise<void>;
1423
- try {
1424
- result = action();
1425
- } catch (error) {
1426
- fail(error);
1427
- return;
1428
- }
1429
- if (!(result instanceof Promise)) {
1430
- after?.();
1431
- showNotice(success, "success");
1432
- return;
1433
- }
1434
- pending = true;
1435
- result.then(
1436
- () => {
1437
- pending = false;
1438
- if (disposed) return;
1439
- after?.();
1440
- showNotice(success, "success");
1441
- },
1442
- (error) => {
1443
- pending = false;
1444
- fail(error);
1445
- },
1446
- );
1447
- };
1448
-
1449
- const container = new Container();
1450
- container.addChild(new Text(theme.fg("accent", theme.bold(options.title))));
1451
- container.addChild(new Text(theme.fg("dim", options.subtitle)));
1452
- container.addChild(editor);
1453
- container.addChild(notice);
1454
- container.addChild(new Text(theme.fg("dim", "Ctrl+S save · Enter newline · Ctrl+K clear · Ctrl+C copy · Esc close")));
1455
-
1456
- return {
1457
- render: (w: number) => {
1458
- const inner = Math.max(1, w - 4);
1459
- const border = (left: string, right: string) => theme.fg("border", left + "─".repeat(inner + 2) + right);
1460
- return [border("┌", "┐"), ...container.render(inner).map((line) => {
1461
- const text = truncateToWidth(line, inner, "");
1462
- return theme.fg("border", "│") + " " + text + " ".repeat(Math.max(0, inner - visibleWidth(text))) + " " + theme.fg("border", "│");
1463
- }), border("└", "┘")];
1464
- },
1465
- invalidate: () => container.invalidate(),
1466
- handleInput: (data: string) => {
1467
- if (matchesKey(data, "escape")) {
1468
- disposed = true;
1469
- done(undefined);
1470
- return;
1471
- }
1472
- if (pending) return;
1473
- if (matchesKey(data, "ctrl+s")) {
1474
- const text = editor.getExpandedText();
1475
- const invalid = validate?.(text);
1476
- if (invalid) {
1477
- showNotice(invalid, "error");
1478
- return;
1479
- }
1480
- persist(() => onSave(text), "Saved", "Save failed");
1481
- return;
1482
- }
1483
- if (matchesKey(data, "ctrl+k")) {
1484
- persist(onClear, "Cleared", "Clear failed", () => editor.setText(""));
1485
- return;
1486
- }
1487
- if (matchesKey(data, "ctrl+c")) {
1488
- const text = editor.getExpandedText();
1489
- void Promise.resolve()
1490
- .then(() => copy(text))
1491
- .then(() => showNotice("Copied", "success"))
1492
- .catch((error) => showNotice(`Copy failed: ${errorMessage(error)}`, "error"));
1493
- return;
1494
- }
1495
- if (matchesKey(data, "enter")) {
1496
- editor.insertTextAtCursor("\n");
1497
- tui.requestRender();
1498
- return;
1499
- }
1500
- editor.handleInput(data);
1501
- tui.requestRender();
1502
- },
1503
- dispose: () => { disposed = true; },
1504
- };
1505
- };
1506
- }
1507
-
1508
- export function createAdvisorSteerModal(
1509
- initialText: string,
1510
- onSave: (text: string) => void,
1511
- onClear: () => void,
1512
- copy: (text: string) => Promise<void> = copyToClipboard,
1513
- ): ReturnType<typeof createTextEditorModal> {
1514
- return createTextEditorModal({
1515
- title: "Bro · advisor steer",
1516
- subtitle: `Session-specific steering brief for the advisor (takes precedence over ${ADVISOR_STEERING_FILE}) — never sent to the main model.`,
1517
- initialText,
1518
- onSave,
1519
- onClear,
1520
- copy,
1521
- });
1522
- }
1523
-
1524
- function preferencesTooLong(text: string): string | undefined {
1525
- const length = text.replace(/^\uFEFF/, "").trim().length;
1526
- return length > MAX_PREFERENCES_CHARS
1527
- ? `${length.toLocaleString("en-US")}/${MAX_PREFERENCES_CHARS.toLocaleString("en-US")} characters — trim before saving`
1528
- : undefined;
1529
- }
1530
-
1531
- // Testable core for /bro preferences. A missing file opens with the unsaved starter text; an
1532
- // unreadable or oversize-on-disk file opens empty with the error, and Clear still works.
1533
- export function createPreferencesModal(
1534
- loaded: { text?: string; error?: string },
1535
- persist: { save: (text: string) => Promise<void>; clear: () => Promise<void> },
1536
- copy?: (text: string) => Promise<void>,
1537
- ): ReturnType<typeof createTextEditorModal> {
1538
- const starter = loaded.text === undefined && !loaded.error;
1539
- return createTextEditorModal({
1540
- title: "Bro · preferences",
1541
- 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.",
1542
- initialText: starter ? STARTER_PREFERENCES : (loaded.text ?? ""),
1543
- initialNotice: loaded.error ?? (starter ? "Starter text — not saved. Ctrl+S saves it; Esc leaves no file." : undefined),
1544
- validate: preferencesTooLong,
1545
- onSave: persist.save,
1546
- onClear: persist.clear,
1547
- copy,
1548
- });
1549
- }
1550
-
1551
- // Saves and deletes run one at a time, even across a closed and reopened editor, so a slow delete
1552
- // from an earlier editor can never remove a file saved by a later one.
1553
- let preferencesWrites: Promise<void> = Promise.resolve();
1554
- export function queuePreferencesWrite(write: () => Promise<void>): Promise<void> {
1555
- const next = preferencesWrites.then(write);
1556
- preferencesWrites = next.catch(() => {});
1557
- return next;
1558
- }
1559
-
1560
- export async function showPreferencesModal(ctx: ExtensionCommandContext): Promise<void> {
1561
- if (!hasBroCustomUi(ctx)) {
1562
- ctx.ui.notify(`Edit ${PREFERENCES_FILE} directly.`, "warning");
1563
- return;
1564
- }
1565
- let loaded: { text?: string; error?: string };
1566
- try {
1567
- const text = await readPreferencesRaw();
1568
- loaded = text === undefined ? {} : { text };
1569
- } catch (error) {
1570
- loaded = { error: `${errorMessage(error)} Ctrl+K deletes it.` };
1571
- }
1572
- await ctx.ui.custom<void>(
1573
- createPreferencesModal(loaded, {
1574
- // ponytail: last writer wins against external edits while the editor is open, like writeSettings.
1575
- save: (text) => queuePreferencesWrite(() => writeFile(PREFERENCES_FILE, `${text.trim()}\n`, "utf8")),
1576
- clear: () => queuePreferencesWrite(() => rm(PREFERENCES_FILE, { force: true })),
1577
- }),
1578
- {
1579
- overlay: true,
1580
- overlayOptions: { width: "78%", minWidth: 48, maxHeight: "60%", anchor: "top-center", margin: { top: 1, left: 2, right: 2 } },
1581
- },
1582
- );
1583
- }
1584
-
1585
- export async function showAdvisorSteerModal(ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> {
1586
- if (!hasBroCustomUi(ctx)) {
1587
- ctx.ui.notify("Use /bro advisor-steer in Pi's interactive UI.", "warning");
1588
- return;
1589
- }
1590
- const { steering } = resolveAdvisorState(ctx.sessionManager.getBranch());
1591
- await ctx.ui.custom<void>(
1592
- createAdvisorSteerModal(
1593
- steering,
1594
- (text) => pi.appendEntry(ADVISOR_STEERING_ENTRY, { text }),
1595
- () => pi.appendEntry(ADVISOR_STEERING_ENTRY, { text: "" }),
1596
- ),
1597
- {
1598
- overlay: true,
1599
- overlayOptions: { width: "78%", minWidth: 48, maxHeight: "60%", anchor: "top-center", margin: { top: 1, left: 2, right: 2 } },
1600
- },
1601
- );
1602
- }
1603
-
1604
- // `preferences` is a ready status ("off", "on (312 characters)", or "error: …") so a preferences
1605
- // problem never hides Help or mixes with settings errors.
1606
- export function helpText(settings?: BroSettings, settingsError?: string, preferences = "off"): string {
1607
- const overrideLines = settings
1608
- ? CAPABILITIES.map((capability) => {
1609
- const override = capabilityOverride(settings, capability);
1610
- return override
1611
- ? `- **${CAPABILITY_LABELS[capability]} override:** ${override.backend ?? "agy"} \`${override.model}\`${override.effort === "default" ? "" : ` (${override.effort})`}`
1612
- : undefined;
1613
- }).filter((line): line is string => line !== undefined)
1614
- : [];
1615
- const settingsSummary = settings
1616
- ? `- **Backend:** ${settings.backend ?? "agy"}\n- **Model:** \`${settings.model}\`\n- **Reasoning effort:** ${settings.effort === "default" ? "built into the selected model" : settings.effort}\n- **Mode:** ${settings.mode}\n- **Show turns:** ${settings.showTurns}${overrideLines.length ? `\n${overrideLines.join("\n")}` : ""}`
1617
- : `Bro could not read its settings: ${settingsError}\n\nRun \`/bro doctor\` for setup help.`;
1618
- return `# Bro
1619
-
1620
- Quick reference. Full user guide: https://github.com/tranhoangnguyen03/pi-bro/blob/main/docs/user-guide.md
1621
-
1622
- ## Explain and show
1623
-
1624
- - \`/bro\` or \`/bro simplify\` — explain the latest completed assistant reply
1625
- - \`/bro text [text]\` — explain pasted text, or the latest reply when text is omitted
1626
- - \`/bro file <path>\` — explain a workspace \`.md\`, \`.markdown\`, \`.txt\`, \`.pdf\`, or \`.docx\` file
1627
- - \`/bro url <url>\` — explain one public webpage
1628
- - \`/bro <input>\` — a lone URL, an existing supported file, or anything else as pasted text
1629
- - \`/bro open\` — reopen the latest explanation without a new request
1630
- - \`/bro show [n-turns] [query]\` — draw recent turns' conversation text as shapes; an optional query steers the focus
1631
-
1632
- ## Guided Review
1633
-
1634
- - \`/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).
1635
- - 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.
1636
- - 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.
1637
- - 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.
1638
-
1639
- ## Side conversation
1640
-
1641
- - \`/bro btw [question]\` — open a side conversation seeded with recent main-session context. It starts conversation-only; reopening keeps the thread and its mode.
1642
- - Inside the modal: Enter asks (empty Enter re-asks); \`/mode\` toggles conversation-only / full permission (read and edit the workspace) and keeps the thread; \`/copy\` and \`/copy-all\` copy to the clipboard; \`/insert\` and \`/insert-all\` insert into an empty main editor without submitting; \`/retry\` re-asks; \`/clear\` resets. Any other text is sent as a question. Esc closes.
1643
-
1644
- ## Advisor
1645
-
1646
- - \`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
1647
- - \`/bro advisor\` — whether \`bro_advisor\` is available right now
1648
- - \`/bro advisor-steer\` — edit the session's persistent steering brief (**Ctrl+S** save, **Ctrl+K** clear, **Ctrl+C** copy, **Esc** close)
1649
- - Standing advisor steering: \`${ADVISOR_STEERING_FILE}\` sets standing priorities across sessions (session steering takes precedence)
1650
-
1651
- ## Configure and check
1652
-
1653
- - \`/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)
1654
- - \`/bro config\` — shared default and per-capability (explain/show/btw/advisor/review) backend, model, and effort; explain mode; show turns. Changes save immediately.
1655
- - \`/bro mode [brief|balanced|faithful]\` — view or choose the explanation mode
1656
- - \`/bro doctor\` — check settings, preferences, and every selected backend without running a model turn
1657
-
1658
- ## Current settings
1659
-
1660
- ${settingsSummary}
1661
- - **Preferences:** ${preferences} — \`/bro preferences\`
1662
-
1663
- Saved in \`${settingsFile()}\`, \`${PREFERENCES_FILE}\`, and \`${ADVISOR_STEERING_FILE}\`.
1664
-
1665
- ## Explanation modes
1666
-
1667
- - brief — the main point and next action, with no fixed word target
1668
- - balanced — default; material detail with clearer structure
1669
- - faithful — closest to the source, with no fixed word limit
1670
-
1671
- Press **M** in an explanation to re-simplify it in the next mode without changing the saved one.
1672
-
1673
- The mode decides how much of the source to keep; your preferences decide who it's written for, its tone, and its language. Neither overrides Bro's source rules.
1674
-
1675
- ## Controls
1676
-
1677
- - **Mouse wheel / trackpad**, **↑ / ↓** — scroll
1678
- - **C** — copy the full explanation
1679
- - **R** — repeat the current action (an explanation keeps its mode)
1680
- - **M** — re-simplify in the next mode (brief → balanced → faithful); not saved
1681
- - **O** — open the diagram (HTML layout or sequence diagram) when a show reply contains one
1682
- - **Esc** — close, or cancel while Bro is working
1683
-
1684
- ## Privacy
1685
-
1686
- 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, except advisor answers, which return to the agent as tool results. Access controls differ by backend; see the user guide.`;
1687
- }
1688
-
1689
- // The overlay framing pattern is adapted from pi-btw (MIT); see THIRD_PARTY_NOTICES.md.
1690
- class BroModal implements Focusable {
1691
- focused = false;
1692
- private readonly markdown = new Markdown("", 0, 0, getMarkdownTheme());
1693
- private kind: ModalKind = "loading";
1694
- private rawText = "";
1695
- private sourceLabel = "";
1696
- private modelLabel = "";
1697
- private notice = "";
1698
- private offset = 0;
1699
- private maxOffset = 0;
1700
- private bodyHeight = 1;
1701
- private copyable = false;
1702
- private retryable = false;
1703
- private disposed = false;
1704
- private htmlPath = "";
1705
- // Survives loading/streaming so the header names the mode being produced; empty disables M.
1706
- private modeLabel = "";
1707
- // Set only with a result, so the tag never claims preferences for work still in flight.
1708
- private preferencesUsed = false;
1709
-
1710
- constructor(
1711
- private readonly tui: TuiLike,
1712
- private readonly theme: Theme,
1713
- private readonly onClose: () => void,
1714
- private readonly onRetry: () => void,
1715
- private readonly onDispose: () => void,
1716
- private readonly retryLabel: string,
1717
- private readonly onSwitchMode: () => void = () => {},
1718
- ) {
1719
- setRegularMouseReporting(this.tui, true);
1720
- }
1721
-
1722
- setLoading(text = LOADING_TEXT): void {
1723
- this.setContent("loading", `**${text}**`, "", false, false);
1724
- }
1725
-
1726
- setStreaming(text: string): void {
1727
- this.setContent("streaming", text, "", false, false);
1728
- }
1729
-
1730
- setResult(text: string, retryable: boolean, notice = "", sourceLabel = "", rawText = text, modelLabel = ""): void {
1731
- this.setContent("result", text, rawText, true, retryable, notice, sourceLabel, modelLabel);
1732
- }
1733
-
1734
- setHtmlPath(path: string): void {
1735
- this.htmlPath = path;
1736
- this.tui.requestRender();
1737
- }
1738
-
1739
- setMode(mode: BroMode | undefined): void {
1740
- this.modeLabel = mode ?? "";
1741
- this.tui.requestRender();
1742
- }
1743
-
1744
- setPreferences(used: boolean): void {
1745
- this.preferencesUsed = used;
1746
- this.tui.requestRender();
1747
- }
1748
-
1749
- setStatic(kind: "help" | "empty", text: string, copyable: boolean): void {
1750
- this.setContent(kind, text, text, copyable, false);
1751
- }
1752
-
1753
- setError(message: string): void {
1754
- this.setContent("error", `# Bro ran into a problem\n\n${message}`, "", false, true);
1755
- }
1756
-
1757
- private setContent(
1758
- kind: ModalKind,
1759
- text: string,
1760
- rawText: string,
1761
- copyable: boolean,
1762
- retryable: boolean,
1763
- notice = "",
1764
- sourceLabel = "",
1765
- modelLabel = "",
1766
- ): void {
1767
- this.kind = kind;
1768
- if (kind !== "result") this.htmlPath = "";
1769
- this.rawText = rawText;
1770
- this.copyable = copyable;
1771
- this.retryable = retryable;
1772
- this.notice = notice;
1773
- this.sourceLabel = stripVTControlCharacters(sourceLabel)
1774
- .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
1775
- .replace(/\s+/g, " ")
1776
- .trim();
1777
- this.modelLabel = modelLabel;
1778
- if (kind !== "streaming") this.offset = 0;
1779
- this.markdown.setText(text);
1780
- this.tui.requestRender();
1781
- }
1782
-
1783
- private frameLine(content: string, innerWidth: number): string {
1784
- const truncated = truncateToWidth(content, innerWidth, "");
1785
- const padding = Math.max(0, innerWidth - visibleWidth(truncated));
1786
- return `${this.theme.fg("border", "│")}${truncated}${" ".repeat(padding)}${this.theme.fg("border", "│")}`;
1787
- }
1788
-
1789
- private borderLine(innerWidth: number, edge: "top" | "bottom"): string {
1790
- const left = edge === "top" ? "┌" : "└";
1791
- const right = edge === "top" ? "┐" : "┘";
1792
- return this.theme.fg("border", `${left}${"─".repeat(innerWidth)}${right}`);
1793
- }
1794
-
1795
- private ruleLine(innerWidth: number): string {
1796
- return this.theme.fg("border", `├${"─".repeat(innerWidth)}┤`);
1797
- }
1798
-
1799
- private canSwitchMode(): boolean {
1800
- return Boolean(this.modeLabel) && (this.kind === "loading" || this.kind === "streaming" || this.kind === "result");
1801
- }
1802
-
1803
- private controls(): string {
1804
- const mode = this.canSwitchMode() ? " · M mode" : "";
1805
- if (this.kind === "loading") return mode ? "M mode · Esc cancel" : "Esc cancel";
1806
- if (this.kind === "streaming") return `Simplifying… · ↑/↓ scroll${mode} · Esc cancel`;
1807
- if (this.kind === "result") {
1808
- return `↑/↓ scroll · C copy${this.htmlPath ? " · O open diagram" : ""}${mode}${this.retryable ? ` · R ${this.retryLabel}` : ""} · Esc close`;
1809
- }
1810
- if (this.kind === "help") return "↑/↓ scroll · C copy · Esc close";
1811
- if (this.kind === "error") return "R try again · Esc close";
1812
- return "Esc close";
1813
- }
1814
-
1815
- render(width: number): string[] {
1816
- const dialogWidth = Math.max(24, width);
1817
- const innerWidth = Math.max(22, dialogWidth - 2);
1818
- const terminalRows = broModalRows(this.tui, process.stdout.rows);
1819
- const dialogHeight = Math.min(32, Math.max(7, Math.floor(terminalRows * 0.78)));
1820
- this.bodyHeight = Math.max(1, dialogHeight - 6);
1821
-
1822
- const rendered = this.markdown.render(innerWidth);
1823
- this.maxOffset = Math.max(0, rendered.length - this.bodyHeight);
1824
- this.offset = Math.max(0, Math.min(this.offset, this.maxOffset));
1825
- const visible = rendered.slice(this.offset, this.offset + this.bodyHeight);
1826
- const hiddenBelow = Math.max(0, this.maxOffset - this.offset);
1827
- const scroll = this.maxOffset > 0 ? ` · ↑${this.offset} ↓${hiddenBelow}` : "";
1828
- const controls = this.notice ? `${this.notice} · ${this.controls()}` : this.controls();
1829
-
1830
- const lines = [
1831
- this.borderLine(innerWidth, "top"),
1832
- this.frameLine(
1833
- this.theme.fg("accent", this.theme.bold(`Bro${this.sourceLabel ? ` · ${this.sourceLabel}` : ""}`)) +
1834
- this.theme.fg("dim", `${this.modelLabel ? ` · ${this.modelLabel}` : ""}${this.modeLabel ? ` · ${this.modeLabel}` : ""}${this.preferencesUsed ? " · prefs" : ""}${scroll}`),
1835
- innerWidth,
1836
- ),
1837
- this.ruleLine(innerWidth),
1838
- ];
1839
-
1840
- for (const line of visible) lines.push(this.frameLine(line, innerWidth));
1841
- for (let i = visible.length; i < this.bodyHeight; i++) lines.push(this.frameLine("", innerWidth));
1842
-
1843
- lines.push(this.ruleLine(innerWidth));
1844
- lines.push(this.frameLine(this.theme.fg("dim", controls), innerWidth));
1845
- lines.push(this.borderLine(innerWidth, "bottom"));
1846
- return lines;
1847
- }
1848
-
1849
- invalidate(): void {
1850
- this.markdown.invalidate();
1851
- }
1852
-
1853
- handleInput(data: string): void {
1854
- if (matchesKey(data, "escape")) {
1855
- this.onClose();
1856
- return;
1857
- }
1858
-
1859
- const delta = wheelDelta(data) || (matchesKey(data, "up") ? -1 : matchesKey(data, "down") ? 1 : 0);
1860
- if (delta) {
1861
- this.offset = Math.max(0, Math.min(this.offset + delta, this.maxOffset));
1862
- this.notice = "";
1863
- this.tui.requestRender();
1864
- return;
1865
- }
1866
-
1867
- if ((matchesKey(data, "c") || matchesKey(data, "shift+c")) && this.copyable && this.rawText) {
1868
- void copyToClipboard(this.rawText)
1869
- .then(() => {
1870
- if (!this.disposed) {
1871
- this.notice = "Copied";
1872
- this.tui.requestRender();
1873
- }
1874
- })
1875
- .catch((error) => {
1876
- if (!this.disposed) {
1877
- this.notice = `Copy failed: ${error instanceof Error ? error.message : String(error)}`;
1878
- this.tui.requestRender();
1879
- }
1880
- });
1881
- return;
1882
- }
1883
-
1884
- if (
1885
- (matchesKey(data, "r") || matchesKey(data, "shift+r")) &&
1886
- this.retryable &&
1887
- this.kind !== "loading"
1888
- ) {
1889
- this.onRetry();
1890
- return;
1891
- }
1892
-
1893
- if ((matchesKey(data, "m") || matchesKey(data, "shift+m")) && this.canSwitchMode()) {
1894
- this.onSwitchMode();
1895
- return;
1896
- }
1897
-
1898
- if ((matchesKey(data, "o") || matchesKey(data, "shift+o")) && this.htmlPath && this.kind === "result") {
1899
- this.notice = openShowHtml(this.htmlPath)
1900
- ? "Opening diagram"
1901
- : `Could not open ${this.htmlPath}`;
1902
- this.tui.requestRender();
1903
- }
1904
- }
1905
-
1906
- dispose(): void {
1907
- if (this.disposed) return;
1908
- this.disposed = true;
1909
- setRegularMouseReporting(this.tui, false);
1910
- this.onDispose();
1911
- }
1912
- }
1913
-
1914
- interface BroModalOptions {
1915
- text?: string;
1916
- kind?: "help" | "empty";
1917
- copyable?: boolean;
1918
- result?: ModalResult;
1919
- run?: (
1920
- signal: AbortSignal,
1921
- source?: BroSource,
1922
- onProgress?: (text: string) => void,
1923
- mode?: BroMode,
1924
- ) => Promise<ModalResult>;
1925
- onResult?: (result: ModalResult) => void;
1926
- loadingText?: string;
1927
- retryable?: boolean;
1928
- retryLabel?: string;
1929
- }
1930
-
1931
- async function showBroModal(ctx: ExtensionCommandContext, options: BroModalOptions): Promise<void> {
1932
- if (!hasBroCustomUi(ctx)) {
1933
- if (options.run && !options.result && options.text === undefined) {
1934
- const result = await options.run(new AbortController().signal);
1935
- options.onResult?.(result);
1936
- }
1937
- return;
1938
- }
1939
-
1940
- await ctx.ui.custom<void>(
1941
- (tui, theme, _keybindings, done) => {
1942
- let closed = false;
1943
- let controller: AbortController | undefined;
1944
- let current = options.result;
1945
- let inFlightMode: BroMode | undefined;
1946
- let execute: (source?: BroSource, mode?: BroMode, notice?: string) => void = () => {};
1947
-
1948
- const close = () => {
1949
- if (closed) return;
1950
- closed = true;
1951
- controller?.abort();
1952
- done(undefined);
1953
- };
1954
-
1955
- const modal = new BroModal(
1956
- tui,
1957
- theme,
1958
- close,
1959
- () => execute(current?.source, current?.mode),
1960
- () => {
1961
- closed = true;
1962
- controller?.abort();
1963
- },
1964
- options.retryLabel ?? "simplify again",
1965
- // M advances from the mode in flight, so repeated presses cancel and skip ahead; the saved default is untouched.
1966
- () => {
1967
- const from = inFlightMode ?? current?.mode;
1968
- if (!from || !current?.source || closed) return;
1969
- const next = nextBroMode(from);
1970
- controller?.abort();
1971
- controller = undefined;
1972
- execute(current.source, next, `Mode: ${next} (not saved)`);
1973
- },
1974
- );
1975
-
1976
- const present = (result: ModalResult, notice = "") => {
1977
- const display = result.htmlPath ? stripShowDiagramFence(result.text) : result.text;
1978
- modal.setResult(display, options.retryable ?? Boolean(options.run), notice, result.source?.label, result.text, result.model);
1979
- modal.setHtmlPath(result.htmlPath ?? "");
1980
- modal.setMode(result.mode);
1981
- modal.setPreferences(Boolean(result.preferences));
1982
- };
1983
-
1984
- execute = (source?: BroSource, mode?: BroMode, notice = "") => {
1985
- if (!options.run || controller || closed) return;
1986
- const previous = current;
1987
- const nextController = new AbortController();
1988
- controller = nextController;
1989
- inFlightMode = mode;
1990
- modal.setLoading(options.loadingText);
1991
- modal.setMode(mode);
1992
- modal.setPreferences(false);
1993
-
1994
- void options
1995
- .run(nextController.signal, source, (text) => {
1996
- if (closed || nextController.signal.aborted || controller !== nextController) return;
1997
- modal.setStreaming(text);
1998
- }, mode)
1999
- .then((result) => {
2000
- if (closed || nextController.signal.aborted) return;
2001
- current = result;
2002
- options.onResult?.(result);
2003
- present(result, notice);
2004
- })
2005
- .catch((error) => {
2006
- if (closed || nextController.signal.aborted) return;
2007
- const message = error instanceof Error ? error.message : String(error);
2008
- if (previous) {
2009
- current = previous;
2010
- present(previous, `Retry failed: ${message}`);
2011
- } else {
2012
- modal.setMode(undefined);
2013
- modal.setError(message);
2014
- }
2015
- })
2016
- .finally(() => {
2017
- if (controller === nextController) {
2018
- controller = undefined;
2019
- inFlightMode = undefined;
2020
- }
2021
- });
2022
- };
2023
-
2024
- if (options.text !== undefined) {
2025
- modal.setStatic(options.kind ?? "help", options.text, options.copyable ?? false);
2026
- } else if (current) {
2027
- present(current);
2028
- } else {
2029
- execute();
2030
- }
2031
-
2032
- return modal;
2033
- },
2034
- {
2035
- overlay: true,
2036
- overlayOptions: {
2037
- width: "78%",
2038
- minWidth: 48,
2039
- maxHeight: "78%",
2040
- anchor: "top-center",
2041
- margin: { top: 1, left: 2, right: 2 },
2042
- },
2043
- },
2044
- );
2045
- }
2046
-
2047
- export function parseBtwArguments(value: string): { question: string; invalid?: string } {
2048
- const rest = value.trim();
2049
- if (!rest.startsWith("--")) return { question: rest };
2050
- const token = rest.split(/\s/, 1)[0]!;
2051
- if (token === "--full" || token === "--sandbox") {
2052
- return { question: "", invalid: `${token} was removed. Open /bro btw and type /mode to switch between conversation-only and full permission.` };
2053
- }
2054
- if (token === "--fresh") {
2055
- return { question: "", invalid: "--fresh was removed. Every new thread starts with main-session context; type /clear inside /bro btw to start over." };
2056
- }
2057
- return { question: "", invalid: `Unknown /bro btw flag: ${token}` };
2058
- }
2059
-
2060
- // A new thread starts conversation-only; reopening keeps the thread and its mode.
2061
- export function resolveBtwThread(existing: BtwThread | undefined): BtwThread {
2062
- return existing ?? { turns: [], full: false };
2063
- }
2064
-
2065
- export function bindBtwBackend(thread: BtwThread, backend: BackendName): boolean {
2066
- const changed = thread.backend !== undefined && thread.backend !== backend;
2067
- if (changed) { thread.turns = []; thread.conversationId = undefined; thread.context = undefined; thread.sessionFull = undefined; thread.sessionPreferences = undefined; }
2068
- thread.backend = backend;
2069
- return changed;
2070
- }
2071
-
2072
- export function btwModeLabel(full: boolean): string {
2073
- return full ? "full permission" : "conversation-only";
2074
- }
2075
-
2076
- // /mode keeps the transcript; whether the native session survives is decided by
2077
- // nativeBtwContinuation on the next turn.
2078
- export function toggleBtwMode(thread: BtwThread): void {
2079
- thread.full = !thread.full;
2080
- }
2081
-
2082
- // Claude, Grok, Codex, and Muse run btw in the workspace for both modes and resume one native session across
2083
- // /mode switches. An Agy conversation stays bound to the workspace it started in (a sandbox scratch
2084
- // dir vs. the repo), so an access change drops it; the next turn reseeds a fresh native session
2085
- // with the main-session context and the whole thread.
2086
- // A native session remembers every earlier prompt, including old preferences, so a preferences
2087
- // change (or deletion) on any backend starts a fresh session reseeded with the quoted thread.
2088
- export function nativeBtwContinuation(thread: BtwThread, backend: BackendName, preferences = ""): string | undefined {
2089
- if (backend === "agy" && thread.conversationId && thread.sessionFull !== undefined && thread.sessionFull !== thread.full) {
2090
- thread.conversationId = undefined;
2091
- }
2092
- if (thread.conversationId && (thread.sessionPreferences ?? "") !== preferences) thread.conversationId = undefined;
2093
- return thread.conversationId;
2094
- }
2095
-
2096
- export function btwHeaderLabel(thread: Pick<BtwThread, "model" | "preferences">): string {
2097
- return thread.model ? `${thread.model}${thread.preferences ? " · prefs" : ""}` : "";
2098
- }
2099
-
2100
- export function formatBtwTranscript(turns: readonly BtwTurn[]): string {
2101
- return turns
2102
- .map((turn) => {
2103
- const question = turn.question.split(/\r?\n/).map((line) => (line ? `> ${line}` : ">")).join("\n");
2104
- // A turn aborted mid-stream can end inside an unclosed code fence, which would swallow the
2105
- // separator and every later turn; close it so each turn renders as its own block.
2106
- const answer = (turn.answer.match(/^```/gm)?.length ?? 0) % 2 === 1 ? `${turn.answer}\n\`\`\`` : turn.answer;
2107
- return `> **You**\n>\n${question}\n\n**Bro**\n\n${answer}`;
2108
- })
2109
- .join("\n\n---\n\n");
2110
- }
2111
-
2112
- export type BtwComposerAction =
2113
- | { kind: "clear" }
2114
- | { kind: "mode" }
2115
- | { kind: "retry" }
2116
- | { kind: "clipboard"; all: boolean }
2117
- | { kind: "insert"; all: boolean }
2118
- | { kind: "removed"; command: string }
2119
- | { kind: "question"; text: string };
2120
-
2121
- export function parseBtwComposerCommand(value: string): BtwComposerAction {
2122
- const command = value.trim();
2123
- if (command === "/clear") return { kind: "clear" };
2124
- if (command === "/mode") return { kind: "mode" };
2125
- if (command === "/retry" || command === "") return { kind: "retry" };
2126
- if (command === "/copy" || command === "/copy-all") {
2127
- return { kind: "clipboard", all: command === "/copy-all" };
2128
- }
2129
- if (command === "/insert" || command === "/insert-all") return { kind: "insert", all: command === "/insert-all" };
2130
- if (command === "/insert!" || command === "/insert-all!") return { kind: "removed", command };
2131
- return { kind: "question", text: command };
2132
- }
2133
-
2134
- // Thin presentation-boundary wrapper around the shared backend: coalesces raw text progress to the
2135
- // existing 75ms cadence and translates the backend's tagged outcome back into this function's
2136
- // existing throw-on-failure / { text, conversationId } contract. A conversationId is only ever
2137
- // returned on success (see backend.ts's continuation handling), preserving current behavior on
2138
- // failed turns.
2139
- async function runBtwTurn(
2140
- prompt: string,
2141
- selection: BackendSelection,
2142
- options: { full: boolean; cwd: string; conversationId?: string },
2143
- signal: AbortSignal,
2144
- onProgress?: (text: string) => void,
2145
- ): Promise<{ text: string; conversationId?: string }> {
2146
- let updateTimer: ReturnType<typeof setTimeout> | undefined;
2147
- let latest: string | undefined;
2148
- const throttledProgress = onProgress
2149
- ? (progress: BackendProgress) => {
2150
- if (progress.kind !== "text") return;
2151
- latest = progress.text;
2152
- if (!updateTimer) {
2153
- updateTimer = setTimeout(() => {
2154
- updateTimer = undefined;
2155
- if (!signal.aborted && latest !== undefined) onProgress(latest);
2156
- }, 75);
2157
- }
2158
- }
2159
- : undefined;
2160
-
2161
- try {
2162
- const outcome = await executeBackend(
2163
- {
2164
- feature: "btw",
2165
- prompt,
2166
- access: options.full ? "workspace-full" : "restricted",
2167
- cwd: options.cwd,
2168
- continuation: options.conversationId ? { id: options.conversationId } : undefined,
2169
- },
2170
- selection,
2171
- signal,
2172
- throttledProgress,
2173
- );
2174
- if (outcome.status === "success") return { text: outcome.text, conversationId: outcome.continuation?.id };
2175
- throw new Error(outcome.message);
2176
- } finally {
2177
- if (updateTimer) clearTimeout(updateTimer);
2178
- }
2179
- }
2180
-
2181
- class BtwModal implements Focusable {
2182
- private _focused = false;
2183
- private readonly markdown = new Markdown("", 0, 0, getMarkdownTheme());
2184
- private readonly input = new Input();
2185
- private notice = "";
2186
- private offset = 0;
2187
- private maxOffset = 0;
2188
- private bodyHeight = 1;
2189
- private running = false;
2190
- private full = false;
2191
- private model = "";
2192
- private disposed = false;
2193
-
2194
- get focused(): boolean {
2195
- return this._focused;
2196
- }
2197
-
2198
- set focused(value: boolean) {
2199
- this._focused = value;
2200
- this.input.focused = value;
2201
- }
2202
-
2203
- constructor(
2204
- private readonly tui: TuiLike,
2205
- private readonly theme: Theme,
2206
- private readonly onClose: () => void,
2207
- private readonly onSubmit: (value: string) => void,
2208
- private readonly onDispose: () => void,
2209
- ) {
2210
- setRegularMouseReporting(this.tui, true);
2211
- this.input.onSubmit = (value) => {
2212
- if (!this.running) this.onSubmit(value);
2213
- };
2214
- }
2215
-
2216
- setText(text: string): void {
2217
- this.markdown.setText(text);
2218
- if (!this.running) this.offset = 0;
2219
- this.tui.requestRender();
2220
- }
2221
-
2222
- setNotice(notice: string): void {
2223
- if (this.disposed) return;
2224
- this.notice = notice;
2225
- this.tui.requestRender();
2226
- }
2227
-
2228
- setRunning(running: boolean): void {
2229
- this.running = running;
2230
- this.tui.requestRender();
2231
- }
2232
-
2233
- setModel(model: string): void {
2234
- this.model = model;
2235
- this.tui.requestRender();
2236
- }
2237
-
2238
- setFull(full: boolean): void {
2239
- this.full = full;
2240
- this.tui.requestRender();
2241
- }
2242
-
2243
- clearComposer(): void {
2244
- this.input.setValue("");
2245
- this.tui.requestRender();
2246
- }
2247
-
2248
- invalidate(): void {
2249
- this.markdown.invalidate();
2250
- this.input.invalidate();
2251
- }
2252
-
2253
- handleInput(data: string): void {
2254
- if (matchesKey(data, "escape")) {
2255
- this.onClose();
2256
- return;
2257
- }
2258
- const delta = wheelDelta(data) || (matchesKey(data, "up") ? -1 : matchesKey(data, "down") ? 1 : 0);
2259
- if (delta) {
2260
- this.offset = Math.max(0, Math.min(this.offset + delta, this.maxOffset));
2261
- this.notice = "";
2262
- this.tui.requestRender();
2263
- return;
2264
- }
2265
- this.input.handleInput(data);
2266
- this.tui.requestRender();
2267
- }
2268
-
2269
- private frameLine(content: string, innerWidth: number): string {
2270
- const truncated = truncateToWidth(content, innerWidth, "");
2271
- const padding = Math.max(0, innerWidth - visibleWidth(truncated));
2272
- return `${this.theme.fg("border", "│")}${truncated}${" ".repeat(padding)}${this.theme.fg("border", "│")}`;
2273
- }
2274
-
2275
- private borderLine(innerWidth: number, edge: "top" | "bottom"): string {
2276
- const left = edge === "top" ? "┌" : "└";
2277
- const right = edge === "top" ? "┐" : "┘";
2278
- return this.theme.fg("border", `${left}${"─".repeat(innerWidth)}${right}`);
2279
- }
2280
-
2281
- private ruleLine(innerWidth: number): string {
2282
- return this.theme.fg("border", `├${"─".repeat(innerWidth)}┤`);
2283
- }
2284
-
2285
- render(width: number): string[] {
2286
- const dialogWidth = Math.max(24, width);
2287
- const innerWidth = Math.max(22, dialogWidth - 2);
2288
- const terminalRows = broModalRows(this.tui, process.stdout.rows);
2289
- const dialogHeight = Math.min(34, Math.max(8, Math.floor(terminalRows * 0.82)));
2290
- const footer = [
2291
- ...wrapTextWithAnsi(this.theme.fg("accent", this.theme.bold("Your message to Bro")), innerWidth),
2292
- this.input.render(innerWidth)[0] ?? "",
2293
- ...wrapTextWithAnsi(this.running ? "Bro is thinking… · Esc: cancel response" : "Enter: send message · Esc: close panel", innerWidth),
2294
- ...wrapTextWithAnsi("Commands: /mode · /copy · /copy-all · /insert · /insert-all · /clear · /retry", innerWidth),
2295
- ...(this.notice ? wrapTextWithAnsi(this.theme.fg("accent", this.notice), innerWidth) : []),
2296
- ];
2297
- this.bodyHeight = Math.max(1, dialogHeight - 5 - footer.length);
2298
-
2299
- const rendered = this.markdown.render(innerWidth);
2300
- this.maxOffset = Math.max(0, rendered.length - this.bodyHeight);
2301
- this.offset = Math.max(0, Math.min(this.offset, this.maxOffset));
2302
- const visible = rendered.slice(this.offset, this.offset + this.bodyHeight);
2303
- const hiddenBelow = Math.max(0, this.maxOffset - this.offset);
2304
- const scroll = this.maxOffset > 0 ? ` · ↑${this.offset} ↓${hiddenBelow}` : "";
2305
-
2306
- const model = this.model ? this.theme.fg("dim", ` · ${this.model}`) : "";
2307
- const mode = this.theme.fg("dim", " · ") + (this.full ? this.theme.fg("accent", this.theme.bold(btwModeLabel(true))) : this.theme.fg("dim", btwModeLabel(false)));
2308
- const header = this.theme.fg("accent", this.theme.bold("Bro · btw")) + model + mode + this.theme.fg("dim", scroll);
2309
-
2310
- const lines = [
2311
- this.borderLine(innerWidth, "top"),
2312
- this.frameLine(header, innerWidth),
2313
- this.ruleLine(innerWidth),
2314
- ];
2315
- for (const line of visible) lines.push(this.frameLine(line, innerWidth));
2316
- for (let i = visible.length; i < this.bodyHeight; i++) lines.push(this.frameLine("", innerWidth));
2317
- lines.push(this.ruleLine(innerWidth));
2318
- for (const line of footer) lines.push(this.frameLine(line, innerWidth));
2319
- lines.push(this.borderLine(innerWidth, "bottom"));
2320
- return lines;
2321
- }
2322
-
2323
- dispose(): void {
2324
- if (this.disposed) return;
2325
- this.disposed = true;
2326
- setRegularMouseReporting(this.tui, false);
2327
- this.onDispose();
2328
- }
2329
- }
2330
-
2331
- async function openBtwModal(
2332
- ctx: ExtensionCommandContext,
2333
- options: { thread: BtwThread; initialQuestion?: string },
2334
- ): Promise<void> {
2335
- const thread = options.thread;
2336
-
2337
- await ctx.ui.custom<void>(
2338
- (tui, theme, _keybindings, done) => {
2339
- let closed = false;
2340
- let controller: AbortController | undefined;
2341
-
2342
- const transcript = () => formatBtwTranscript(thread.turns);
2343
-
2344
- const close = () => {
2345
- if (closed) return;
2346
- closed = true;
2347
- controller?.abort();
2348
- done(undefined);
2349
- };
2350
-
2351
- const modal = new BtwModal(tui, theme, close, submit, () => {
2352
- closed = true;
2353
- controller?.abort();
2354
- });
2355
-
2356
- // `replaced` is the turn /retry removed; it is put back if the turn never starts.
2357
- const runTurn = async (question: string, replaced?: BtwTurn) => {
2358
- if (controller) return;
2359
- const turnController = new AbortController();
2360
- controller = turnController;
2361
- modal.setRunning(true);
2362
- modal.clearComposer();
2363
-
2364
- let settings: BroSettings;
2365
- let preferences: string;
2366
- try {
2367
- settings = await readSettings();
2368
- preferences = await readPreferences();
2369
- } catch (error) {
2370
- if (replaced) thread.turns.push(replaced);
2371
- if (controller === turnController) controller = undefined;
2372
- if (!closed) { modal.setRunning(false); modal.setNotice(errorMessage(error)); }
2373
- return;
2374
- }
2375
- // Closing (or reopening) during the reads above must not touch the shared thread.
2376
- if (closed || turnController.signal.aborted) {
2377
- if (replaced) thread.turns.push(replaced);
2378
- if (controller === turnController) controller = undefined;
2379
- return;
2380
- }
2381
- if (bindBtwBackend(thread, capabilityBackend(settings, "btw"))) modal.setNotice("Backend changed — started a fresh side thread.");
2382
- if (thread.turns.length === 0) {
2383
- thread.conversationId = undefined;
2384
- const context = captureShowTranscript(ctx, BTW_CONTEXT_TURNS, BTW_CONTEXT_MAX)?.text;
2385
- thread.context = context;
2386
- }
2387
- // Resume natively when possible; otherwise seed the fresh native session with the main-session
2388
- // context plus every earlier turn so nothing is silently lost.
2389
- const conversationId = nativeBtwContinuation(thread, capabilityBackend(settings, "btw"), preferences);
2390
- const history = !conversationId && thread.turns.length ? formatBtwTranscript(thread.turns) : undefined;
2391
- const context = conversationId ? undefined : thread.context;
2392
- const full = thread.full;
2393
-
2394
- thread.turns.push({ question, answer: "…" });
2395
- modal.setText(transcript());
2396
-
2397
- try {
2398
- const selection = selectionForCapability(settings, "btw");
2399
- thread.model = selectionLabel(selection);
2400
- thread.preferences = Boolean(preferences);
2401
- modal.setModel(btwHeaderLabel(thread));
2402
- const result = await runBtwTurn(
2403
- buildBtwPrompt(context, question, { full, history, preferences }),
2404
- selection,
2405
- { full, cwd: ctx.cwd, conversationId },
2406
- turnController.signal,
2407
- (partial) => {
2408
- if (closed || turnController.signal.aborted) return;
2409
- thread.turns[thread.turns.length - 1]!.answer = partial;
2410
- modal.setText(transcript());
2411
- },
2412
- );
2413
- if (turnController.signal.aborted) return;
2414
- if (result.conversationId) {
2415
- thread.conversationId = result.conversationId;
2416
- thread.sessionFull = full;
2417
- thread.sessionPreferences = preferences;
2418
- }
2419
- thread.turns[thread.turns.length - 1]!.answer = result.text;
2420
- } catch (error) {
2421
- if (turnController.signal.aborted || closed) return;
2422
- thread.turns[thread.turns.length - 1]!.answer = `_${errorMessage(error)}_`;
2423
- modal.setNotice(errorMessage(error));
2424
- } finally {
2425
- if (controller === turnController) controller = undefined;
2426
- if (!closed) {
2427
- modal.setRunning(false);
2428
- modal.setText(transcript());
2429
- }
2430
- }
2431
- };
2432
-
2433
- const toggleMode = () => {
2434
- toggleBtwMode(thread);
2435
- modal.setFull(thread.full);
2436
- modal.setNotice(`Mode: ${btwModeLabel(thread.full)}`);
2437
- };
2438
-
2439
- const clear = () => {
2440
- thread.turns = [];
2441
- thread.conversationId = undefined;
2442
- thread.context = undefined;
2443
- thread.sessionFull = undefined;
2444
- thread.sessionPreferences = undefined;
2445
- modal.clearComposer();
2446
- modal.setNotice("");
2447
- modal.setText("");
2448
- };
2449
-
2450
- const retry = () => {
2451
- if (controller) return;
2452
- const last = thread.turns.at(-1);
2453
- if (!last) {
2454
- modal.setNotice("Nothing to retry yet.");
2455
- return;
2456
- }
2457
- thread.turns.pop();
2458
- void runTurn(last.question, last);
2459
- };
2460
-
2461
- const copyOut = async (all: boolean) => {
2462
- const text = all ? transcript() : (thread.turns.at(-1)?.answer ?? "");
2463
- if (!text.trim()) {
2464
- modal.setNotice("Nothing to copy yet.");
2465
- return;
2466
- }
2467
- try {
2468
- await copyToClipboard(text);
2469
- modal.setNotice(all ? "Copied the full thread to the clipboard." : "Copied the latest answer to the clipboard.");
2470
- } catch (error) {
2471
- modal.setNotice(`Copy failed: ${errorMessage(error)}`);
2472
- }
2473
- };
2474
-
2475
- const insert = async (all: boolean) => {
2476
- const text = all ? transcript() : (thread.turns.at(-1)?.answer ?? "");
2477
- if (!text.trim()) {
2478
- modal.setNotice("Nothing to insert yet.");
2479
- return;
2480
- }
2481
- if (!canBroInsertIntoEditor(ctx)) {
2482
- const result = await insertBroDesktopText(ctx, text);
2483
- modal.setNotice(result === "inserted" ? "Inserted into the main editor. Review it before sending." : result === "not_empty" ? "Main editor has a draft or attachment. Clear it first, then insert again." : "Could not confirm insertion. Check the main editor before trying again.");
2484
- return;
2485
- }
2486
- if (ctx.ui.getEditorText().trim()) {
2487
- modal.setNotice("Main editor has a draft. Edit or clear it first, then insert again.");
2488
- return;
2489
- }
2490
- ctx.ui.setEditorText(text);
2491
- modal.setNotice(all ? "Inserted the full thread into the main editor." : "Inserted the latest answer into the main editor.");
2492
- };
2493
-
2494
- function submit(value: string): void {
2495
- const action = parseBtwComposerCommand(value);
2496
- if (action.kind === "clear") {
2497
- modal.clearComposer();
2498
- clear();
2499
- return;
2500
- }
2501
- if (action.kind === "mode") {
2502
- modal.clearComposer();
2503
- toggleMode();
2504
- return;
2505
- }
2506
- if (action.kind === "clipboard") {
2507
- modal.clearComposer();
2508
- void copyOut(action.all);
2509
- return;
2510
- }
2511
- if (action.kind === "insert") {
2512
- modal.clearComposer();
2513
- insert(action.all);
2514
- return;
2515
- }
2516
- if (action.kind === "removed") {
2517
- modal.clearComposer();
2518
- modal.setNotice(`${action.command} was removed. Edit or clear the main editor draft, then use /insert or /insert-all.`);
2519
- return;
2520
- }
2521
- if (action.kind === "retry") {
2522
- modal.clearComposer();
2523
- retry();
2524
- return;
2525
- }
2526
- void runTurn(action.text);
2527
- }
2528
-
2529
- modal.setFull(thread.full);
2530
- modal.setModel(btwHeaderLabel(thread));
2531
- modal.setText(transcript());
2532
-
2533
- if (options.initialQuestion) void runTurn(options.initialQuestion);
2534
-
2535
- return modal;
2536
- },
2537
- {
2538
- overlay: true,
2539
- overlayOptions: {
2540
- width: "78%",
2541
- minWidth: 48,
2542
- maxHeight: "82%",
2543
- anchor: "top-center",
2544
- margin: { top: 1, left: 2, right: 2 },
2545
- },
2546
- },
2547
- );
2548
- }
2549
-
2550
77
  export default async function bro(pi: ExtensionAPI) {
2551
78
  let lastResult: ModalResult | undefined;
2552
79
  let lastShowSteering: string | undefined;
@@ -2565,120 +92,7 @@ export default async function bro(pi: ExtensionAPI) {
2565
92
  btwThread = undefined;
2566
93
  });
2567
94
 
2568
- pi.registerTool({
2569
- name: ADVISOR_TOOL_NAME,
2570
- label: "Bro advisor",
2571
- description:
2572
- "Consult a fresh, independent process of the configured advisor backend for a second opinion mid-task. It has real, unsandboxed tool access in the current workspace (read files, search, run commands) with permissions auto-approved, and is instructed to investigate before advising and to leave edits to you — that is a behavioral instruction to the advisor, not an enforced restriction, so treat its findings as advice rather than a delegated implementation. You never need to prepare a summary or evidence first: Bro automatically captures your system instructions, active tools, and the conversation so far, plus any human-set steering priorities, and sends them to the advisor.",
2573
- promptSnippet: "Consult a fresh configured-backend process for a second opinion mid-task; it investigates the workspace itself and returns advice",
2574
- promptGuidelines: [
2575
- "Call bro_advisor before or after a non-trivial design or scope decision, or when genuinely uncertain, for a second opinion from a fresh, independent process of the configured advisor backend.",
2576
- "bro_advisor's question parameter is optional — never delay a call to first prepare a summary or evidence; Bro captures context automatically.",
2577
- "Any human-set steering priorities are applied automatically by bro_advisor; you don't need to relay or repeat them.",
2578
- "bro_advisor is instructed to only return advice and leave edits to you — that instruction is not enforced, so verify its findings yourself rather than treating them as a completed implementation.",
2579
- ],
2580
- parameters: Type.Object({
2581
- question: Type.Optional(
2582
- Type.String({ description: "Optional question to focus the consultation on. Leave unset to ask for general advice on the current state." }),
2583
- ),
2584
- }),
2585
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
2586
- const state = resolveAdvisorState(ctx.sessionManager.getBranch());
2587
- const durableSteering = await readAdvisorSteering();
2588
- const settings = await readSettings();
2589
- const selection = selectionForCapability(settings, "advisor");
2590
- const backend = selection.backend ?? "agy";
2591
- const snapshot = buildAdvisorSnapshot(ctx, pi);
2592
- const prompt = buildAdvisorPrompt({ durable: durableSteering, session: state.steering }, snapshot.text, params?.question);
2593
- const hasDurable = Boolean(durableSteering.trim());
2594
- const hasSession = Boolean(state.steering.trim());
2595
- let lastAttempt: AdvisorToolDetails = { status: "investigating", attempt: 1, of: 3, elapsedMs: 0, backend };
2596
- try {
2597
- const run = await runAdvisorWithRetries(
2598
- prompt,
2599
- selection,
2600
- ctx.cwd,
2601
- signal ?? new AbortController().signal,
2602
- undefined,
2603
- undefined,
2604
- (details) => {
2605
- lastAttempt = { ...details, backend };
2606
- const label = advisorAttemptLabel(lastAttempt);
2607
- onUpdate?.({
2608
- content: [{ type: "text", text: label }],
2609
- details: lastAttempt,
2610
- });
2611
- // A host's tool-card renderer may replace onUpdate's partial renderResult with its
2612
- // own generic placeholder for the whole run (observed with pi-cc-extensions' default
2613
- // mode, which hardcodes "Pending…" for every isPartial tool result unless the tool
2614
- // is opted into its excludeRenderers list). The footer/status bar is a separate,
2615
- // host-owned surface that no such tool-card override touches, so mirror progress
2616
- // there too as a fallback the user can see regardless of that renderer choice.
2617
- ctx.ui.setStatus(ADVISOR_STATUS_BAR_KEY, label);
2618
- },
2619
- );
2620
- const effort = selection.effort ?? "model default";
2621
- const omissions = `image/reasoning bodies omitted when present; ${snapshot.hadCompaction ? "Pi compaction summaries replace earlier turns" : "no Pi compaction summary present"}`;
2622
- const details: AdvisorToolDetails = {
2623
- ...lastAttempt,
2624
- status: "done",
2625
- attempt: run.attempts,
2626
- backend,
2627
- model: selection.model,
2628
- effort,
2629
- durationMs: run.durationMs,
2630
- cwd: ctx.cwd,
2631
- steeringIncluded: hasDurable || hasSession,
2632
- steeringSources: { durable: hasDurable, session: hasSession },
2633
- snapshotChars: snapshot.text.length,
2634
- broTruncated: false,
2635
- omissions,
2636
- // run.activityCount is the exact final total; lastAttempt's may lag behind by up to
2637
- // the activity throttle window.
2638
- activityCount: run.activityCount,
2639
- };
2640
- // The backend qualifier appears only off Agy, keeping the Agy header byte-identical.
2641
- 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`;
2642
- const steeringSummary = hasDurable && hasSession
2643
- ? "included (durable+session)"
2644
- : hasDurable
2645
- ? "included (durable)"
2646
- : hasSession
2647
- ? "included"
2648
- : "none";
2649
- const context = `Context · cwd: ${JSON.stringify(ctx.cwd)} · steering: ${steeringSummary} · snapshot: ${snapshot.text.length} chars · Bro truncation: none · omissions: ${omissions}`;
2650
- return { content: [{ type: "text", text: `${header}\n${context}\n\n${run.advice}` }], details };
2651
- } finally {
2652
- ctx.ui.setStatus(ADVISOR_STATUS_BAR_KEY, undefined);
2653
- }
2654
- },
2655
- renderCall(args, theme) {
2656
- const question = args.question?.trim();
2657
- return new Text(theme.fg("dim", question ? `Bro advisor · ${question}` : "Bro advisor · consulting…"));
2658
- },
2659
- renderResult(result, options, theme) {
2660
- const details = result.details as AdvisorToolDetails | undefined;
2661
- if (options.isPartial) {
2662
- const label = new Text(theme.fg("dim", details ? advisorAttemptLabel(details) : "Bro advisor · investigating…"));
2663
- // Expanded + running: the simplest native tail -- one dim line per bounded recent
2664
- // activity label, nothing fancier than the compact renderer above.
2665
- if (!options.expanded || !details?.activity?.length) return label;
2666
- const container = new Container();
2667
- container.addChild(label);
2668
- for (const line of details.activity) {
2669
- container.addChild(new Text(theme.fg("dim", ` ${advisorActivityPreview(line)}`)));
2670
- }
2671
- return container;
2672
- }
2673
- if (!options.expanded && details?.status === "done") return new Text(theme.fg("accent", advisorAttemptLabel(details)));
2674
- const text = result.content[0]?.type === "text" ? result.content[0].text : "";
2675
- if (!options.expanded) {
2676
- const firstLine = text.split("\n").find((line) => line.trim()) ?? "(no advice)";
2677
- return new Text(`${theme.fg("accent", "Bro advisor")}${theme.fg("dim", ` · ${firstLine}`)}`);
2678
- }
2679
- return new Markdown(text, 0, 0, getMarkdownTheme());
2680
- },
2681
- });
95
+ registerAdvisorTool(pi);
2682
96
 
2683
97
  pi.registerCommand("bro", {
2684
98
  description: "Explain text, documents, and webpages; draw session turns; open a side conversation; configure Bro and the executor's advisor tool",