pi-bro 0.13.1 → 0.14.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.
Files changed (5) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +109 -8
  3. package/bro.ts +1274 -38
  4. package/package.json +4 -3
  5. package/prompt.ts +29 -0
package/bro.ts CHANGED
@@ -9,14 +9,15 @@ import { homedir, tmpdir } from "node:os";
9
9
  import { extname, isAbsolute, join, relative, resolve, sep } from "node:path";
10
10
  import { createInterface } from "node:readline";
11
11
  import { stripVTControlCharacters } from "node:util";
12
- import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
13
- import { copyToClipboard, getMarkdownTheme } from "@earendil-works/pi-coding-agent";
14
- import { Input, Markdown, matchesKey, truncateToWidth, visibleWidth, type Focusable } from "@earendil-works/pi-tui";
12
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
13
+ import { Container, Editor, Input, Markdown, SettingsList, SelectList, Text, matchesKey, truncateToWidth, visibleWidth, type Component, type EditorTheme, type Focusable, type SelectItem, type SettingItem, type TUI } from "@earendil-works/pi-tui";
14
+ import { convertToLlm, copyToClipboard, getMarkdownTheme, getSelectListTheme, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
15
15
  import { Defuddle } from "defuddle/node";
16
16
  import { parseHTML } from "linkedom";
17
17
  import mammoth from "mammoth";
18
+ import { Type } from "typebox";
18
19
  import { extractText } from "unpdf";
19
- import { BRO_MODES, DEFAULT_BRO_MODE, buildBtwPrompt, buildDefaultPrompt, buildShowPrompt, parseBroMode, type BroMode } from "./prompt.ts";
20
+ import { BRO_MODES, DEFAULT_BRO_MODE, buildAdvisorPrompt, buildBtwPrompt, buildDefaultPrompt, buildShowPrompt, parseBroMode, type BroMode } from "./prompt.ts";
20
21
 
21
22
  const AGENT_DIR = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
22
23
  const ENV_MODEL = process.env.PI_BRO_MODEL?.trim();
@@ -52,7 +53,20 @@ type BtwThread = { turns: BtwTurn[]; conversationId?: string; full: boolean };
52
53
  const EFFORTS = ["default", "low", "medium", "high"] as const;
53
54
  type BroEffort = (typeof EFFORTS)[number];
54
55
  type AgyEffort = Exclude<BroEffort, "default">;
55
- type BroSettings = { model: string; effort: BroEffort; mode: BroMode; showTurns: number };
56
+ // "advisor" is a real capability (command, Agy invocation, Doctor check) like the other three;
57
+ // see docs/plans/2026-09-19-bro-advisor-design.md. All four share one model/effort resolution,
58
+ // override, and Doctor-check path via this single list -- there is no configuration-only tier.
59
+ const CAPABILITIES = ["explain", "show", "btw", "advisor"] as const;
60
+ type Capability = (typeof CAPABILITIES)[number];
61
+ const CAPABILITY_LABELS: Record<Capability, string> = { explain: "Explain", show: "Show", btw: "Btw", advisor: "Advisor" };
62
+ type ModelEffortPair = { model: string; effort: BroEffort };
63
+ type BroSettings = {
64
+ model: string;
65
+ effort: BroEffort;
66
+ mode: BroMode;
67
+ showTurns: number;
68
+ overrides: Partial<Record<Capability, ModelEffortPair>>;
69
+ };
56
70
  type AgyModelFamily = {
57
71
  id: string;
58
72
  label: string;
@@ -91,12 +105,15 @@ const COMMANDS = [
91
105
  { value: "show", label: "show", description: "Draw what happened in recent session turns as shapes" },
92
106
  { value: "mode", label: "mode", description: "Choose brief, balanced, or faithful explanations" },
93
107
  { value: "btw", label: "btw", description: "Open a side conversation (sandboxed by default; --full edits files)" },
108
+ { value: "config", label: "config", description: "Configure shared defaults and per-capability model/effort overrides" },
109
+ { value: "advisor", label: "advisor", description: "Check whether the executor's advisor tool is available right now" },
110
+ { value: "advisor-steer", label: "advisor-steer", description: "View, edit, save, or clear the advisor's persistent steering brief" },
94
111
  { value: "help", label: "help", description: "Learn what Bro does and what it can access" },
95
112
  ];
96
113
  const KNOWN_ACTIONS = new Set(COMMANDS.map((command) => command.value));
97
114
 
98
115
  function isRecord(value: unknown): value is Record<string, unknown> {
99
- return typeof value === "object" && value !== null;
116
+ return typeof value === "object" && value !== null && !Array.isArray(value);
100
117
  }
101
118
 
102
119
  function errorMessage(error: unknown): string {
@@ -477,6 +494,24 @@ export function agyFailureMessage(
477
494
  return `Agy could not ${action}. Make sure Agy is installed and signed in, then run \`/bro doctor\`.`;
478
495
  }
479
496
 
497
+ function parseModelEffortPair(value: unknown, context: string): ModelEffortPair {
498
+ if (!isRecord(value) || typeof value.model !== "string" || !value.model.trim() || !EFFORTS.some((effort) => effort === value.effort)) {
499
+ throw new Error(`${context} must contain a model and effort set to "default", "low", "medium", or "high".`);
500
+ }
501
+ return { model: value.model.trim(), effort: value.effort as BroEffort };
502
+ }
503
+
504
+ function parseOverrides(value: unknown): Partial<Record<Capability, ModelEffortPair>> {
505
+ if (value === undefined) return {};
506
+ if (!isRecord(value)) throw new Error("Settings overrides must be an object.");
507
+ const overrides: Partial<Record<Capability, ModelEffortPair>> = {};
508
+ for (const capability of CAPABILITIES) {
509
+ if (value[capability] === undefined) continue;
510
+ overrides[capability] = parseModelEffortPair(value[capability], `Settings overrides.${capability}`);
511
+ }
512
+ return overrides;
513
+ }
514
+
480
515
  export function parseBroSettings(value: unknown): BroSettings {
481
516
  if (
482
517
  !isRecord(value) ||
@@ -492,7 +527,87 @@ export function parseBroSettings(value: unknown): BroSettings {
492
527
  if (typeof showTurns !== "number" || !Number.isInteger(showTurns) || showTurns < 1) {
493
528
  throw new Error("Settings showTurns must be a positive whole number of turns.");
494
529
  }
495
- return { model: value.model.trim(), effort: value.effort as BroSettings["effort"], mode, showTurns };
530
+ const overrides = parseOverrides(value.overrides);
531
+ return { model: value.model.trim(), effort: value.effort as BroSettings["effort"], mode, showTurns, overrides };
532
+ }
533
+
534
+ function capabilityOverride(settings: BroSettings, capability: Capability): ModelEffortPair | undefined {
535
+ return settings.overrides[capability];
536
+ }
537
+
538
+ function capabilityPair(settings: BroSettings, capability: Capability): ModelEffortPair {
539
+ return capabilityOverride(settings, capability) ?? { model: settings.model, effort: settings.effort };
540
+ }
541
+
542
+ // An override always pins both model and effort together (never just one), so a capability's
543
+ // setting is either fully inherited or fully its own — no partial-inheritance edge cases.
544
+ //
545
+ // An override is cleared ONLY by an explicit "Default" selection (pair === undefined), never
546
+ // automatically because it happens to match the shared default: a user who deliberately pins a
547
+ // capability to the model that currently IS the shared default must keep that pin — unchanged —
548
+ // if the shared default is later changed to something else. Silently dropping an override that
549
+ // merely coincides with the default would make that pin impossible to express.
550
+ export function withCapabilityOverride(
551
+ settings: BroSettings,
552
+ capability: Capability,
553
+ pair: ModelEffortPair | undefined,
554
+ ): BroSettings {
555
+ const overrides = { ...settings.overrides };
556
+ if (!pair) delete overrides[capability];
557
+ else overrides[capability] = pair;
558
+ return { ...settings, overrides };
559
+ }
560
+
561
+ export function resolveModelEffort(
562
+ pair: ModelEffortPair,
563
+ families: AgyModelFamily[],
564
+ ): { pair: ModelEffortPair; family?: AgyModelFamily } {
565
+ const family = families.find((item) => item.id === pair.model || item.variants.some((variant) => variant.id === pair.model));
566
+ if (!family) return { pair };
567
+ const variant = family.variants.find((item) => item.id === pair.model);
568
+ return {
569
+ family,
570
+ pair: { model: family.id, effort: pair.effort === "default" && variant?.effort ? variant.effort : pair.effort },
571
+ };
572
+ }
573
+
574
+ function resolveCapabilitySettings(
575
+ settings: BroSettings,
576
+ capability: Capability,
577
+ families: AgyModelFamily[],
578
+ ): { pair: ModelEffortPair; family?: AgyModelFamily } {
579
+ return resolveModelEffort(capabilityPair(settings, capability), families);
580
+ }
581
+
582
+ // The steering brief is stored as a session `custom` entry — extension state that never
583
+ // participates in LLM context (see docs/plans/2026-09-19-bro-advisor-design.md). Writes go
584
+ // through pi.appendEntry(); ctx.sessionManager is read-only and has no append methods.
585
+ //
586
+ // bro_advisor has no on/off activation state: it is registered once at extension load like any
587
+ // other tool and never gated via pi.setActiveTools(). Whether the executor can call it is purely a
588
+ // function of this host's own tool restrictions (pi.getAllTools() / pi.getActiveTools()), which
589
+ // this code only ever reads, never overrides. A session may still carry historical
590
+ // "bro-advisor-active" entries from before this simplification; they are silently ignored.
591
+ const ADVISOR_STEERING_ENTRY = "bro-advisor-steering";
592
+ export const ADVISOR_TOOL_NAME = "bro_advisor";
593
+ // setStatus() key for mirroring live advisor progress to the footer/status bar -- see the execute()
594
+ // handler below for why this fallback surface exists alongside onUpdate's tool-card renderResult.
595
+ const ADVISOR_STATUS_BAR_KEY = "bro-advisor";
596
+ const ADVISOR_COMPATIBILITY = "Pi >=0.84.2; Agy >=1.1.15";
597
+
598
+ export type AdvisorState = { steering: string };
599
+
600
+ // getBranch() walks root-to-leaf (see latestAssistant above for the same convention), so a forward
601
+ // scan taking the last match of ADVISOR_STEERING_ENTRY resolves "latest wins". Forking clones the
602
+ // branch's entries into a new session file, so this same resolution gives fork inheritance and
603
+ // independent post-fork edits for free, with no special-case fork logic.
604
+ export function resolveAdvisorState(branch: readonly SessionEntry[]): AdvisorState {
605
+ let steering = "";
606
+ for (const entry of branch) {
607
+ if (entry.type !== "custom" || entry.customType !== ADVISOR_STEERING_ENTRY) continue;
608
+ if (isRecord(entry.data) && typeof entry.data.text === "string") steering = entry.data.text;
609
+ }
610
+ return { steering };
496
611
  }
497
612
 
498
613
  async function ensureSettingsFile(): Promise<void> {
@@ -519,9 +634,18 @@ async function readSettings(): Promise<BroSettings> {
519
634
  }
520
635
  }
521
636
 
637
+ // Exported so persistence stays testable without touching the filesystem. This only ever omits
638
+ // the `overrides` key itself when there are no overrides at all — it does NOT deduplicate or drop
639
+ // any individual override that happens to match the shared default; see withCapabilityOverride
640
+ // for why an explicit override is always kept until the user clears it back to "Default".
641
+ export function settingsPayload(settings: BroSettings): Record<string, unknown> {
642
+ const { overrides, ...rest } = settings;
643
+ return Object.keys(overrides).length ? { ...rest, overrides } : rest;
644
+ }
645
+
522
646
  async function writeSettings(settings: BroSettings): Promise<void> {
523
647
  // ponytail: last writer wins across concurrent Pi processes; add locking only if that becomes a common workflow.
524
- await writeFile(SETTINGS_FILE, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
648
+ await writeFile(SETTINGS_FILE, `${JSON.stringify(settingsPayload(settings), null, 2)}\n`, "utf8");
525
649
  }
526
650
 
527
651
  export function formatAgyUsage(value: unknown): string {
@@ -604,35 +728,36 @@ async function checkAgyVersion(pi: ExtensionAPI, signal: AbortSignal): Promise<s
604
728
  }
605
729
  }
606
730
 
731
+ export function advisorAgyCompatible(version: string): boolean | undefined {
732
+ const match = /(?:^|\D)(\d+)\.(\d+)\.(\d+)(?:\D|$)/.exec(version);
733
+ if (!match) return undefined;
734
+ const installed = match.slice(1, 4).map(Number);
735
+ const minimum = [1, 1, 15];
736
+ for (let index = 0; index < minimum.length; index++) {
737
+ if (installed[index]! !== minimum[index]!) return installed[index]! > minimum[index]!;
738
+ }
739
+ return true;
740
+ }
741
+
607
742
  function resolveCatalogSettings(
608
743
  settings: BroSettings,
609
744
  families: AgyModelFamily[],
610
745
  ): { settings: BroSettings; family?: AgyModelFamily } {
611
- const family = families.find(
612
- (item) => item.id === settings.model || item.variants.some((variant) => variant.id === settings.model),
613
- );
614
- if (!family) return { settings };
615
- const variant = family.variants.find((item) => item.id === settings.model);
616
- return {
617
- family,
618
- settings: {
619
- ...settings,
620
- model: family.id,
621
- effort: settings.effort === "default" && variant?.effort ? variant.effort : settings.effort,
622
- },
623
- };
746
+ const resolved = resolveModelEffort({ model: settings.model, effort: settings.effort }, families);
747
+ if (!resolved.family) return { settings };
748
+ return { family: resolved.family, settings: { ...settings, ...resolved.pair } };
624
749
  }
625
750
 
626
751
  function preferredEffort(family: AgyModelFamily): BroEffort {
627
752
  return family.efforts.includes("low") ? "low" : (family.efforts[0] ?? "default");
628
753
  }
629
754
 
630
- export function agySelection(settings: BroSettings): { model: string; effort?: AgyEffort } {
631
- if (settings.effort === "default") return { model: settings.model };
632
- const suffix = (["low", "medium", "high"] as const).find((effort) => settings.model.endsWith(`-${effort}`));
755
+ export function agySelection(pair: ModelEffortPair): { model: string; effort?: AgyEffort } {
756
+ if (pair.effort === "default") return { model: pair.model };
757
+ const suffix = (["low", "medium", "high"] as const).find((effort) => pair.model.endsWith(`-${effort}`));
633
758
  return {
634
- model: suffix ? settings.model.slice(0, -suffix.length - 1) : settings.model,
635
- effort: settings.effort,
759
+ model: suffix ? pair.model.slice(0, -suffix.length - 1) : pair.model,
760
+ effort: pair.effort,
636
761
  };
637
762
  }
638
763
 
@@ -656,11 +781,12 @@ async function checkAgyUsage(pi: ExtensionAPI, signal: AbortSignal): Promise<str
656
781
  }
657
782
  }
658
783
 
659
- async function doctorReport(pi: ExtensionAPI, signal: AbortSignal): Promise<string> {
784
+ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, signal: AbortSignal): Promise<string> {
660
785
  const lines: string[] = [];
661
786
  let failed = false;
662
787
  let settings: BroSettings | undefined;
663
788
  let models: AgyModelFamily[] | undefined;
789
+ let agyVersion: string | undefined;
664
790
  const pass = (name: string, detail: string) => lines.push(`- ✓ **${name}:** ${detail}`);
665
791
  const fail = (name: string, error: unknown) => {
666
792
  failed = true;
@@ -683,7 +809,8 @@ async function doctorReport(pi: ExtensionAPI, signal: AbortSignal): Promise<stri
683
809
 
684
810
  let agyStarted = false;
685
811
  try {
686
- pass("Agy", await checkAgyVersion(pi, signal));
812
+ agyVersion = await checkAgyVersion(pi, signal);
813
+ pass("Agy", agyVersion);
687
814
  agyStarted = true;
688
815
  } catch (error) {
689
816
  if (signal.aborted) throw error;
@@ -723,8 +850,42 @@ async function doctorReport(pi: ExtensionAPI, signal: AbortSignal): Promise<stri
723
850
  fail("Reasoning effort", `\`${effort}\` is unsupported. Run \`/bro effort\` to choose another.`);
724
851
  }
725
852
  }
853
+
854
+ for (const capability of CAPABILITIES) {
855
+ const label = CAPABILITY_LABELS[capability];
856
+ const override = capabilityOverride(settings, capability);
857
+ const resolved = resolveCapabilitySettings(settings, capability, models);
858
+ if (!resolved.family) {
859
+ fail(label, `\`${resolved.pair.model}\` is unavailable. Run \`/bro config\` to fix this override.`);
860
+ continue;
861
+ }
862
+ const effortOk = !resolved.family.efforts.length
863
+ ? resolved.pair.effort === "default"
864
+ : resolved.pair.effort !== "default" && resolved.family.efforts.includes(resolved.pair.effort);
865
+ if (!effortOk) {
866
+ fail(label, `\`${resolved.pair.effort}\` is unsupported for \`${resolved.family.id}\`. Run \`/bro config\` to fix this.`);
867
+ continue;
868
+ }
869
+ pass(
870
+ label,
871
+ override
872
+ ? `override \`${resolved.family.id}\`${resolved.pair.effort === "default" ? "" : ` (${resolved.pair.effort})`}`
873
+ : "using the shared default",
874
+ );
875
+ }
726
876
  }
727
877
 
878
+ // bro_advisor has no on/off preference to compare against -- it is only ever gated by this
879
+ // host's own tool restrictions, which this reads via pi.getAllTools()/pi.getActiveTools() and
880
+ // never overrides. Exposed-but-inactive is the one genuine anomaly worth failing on.
881
+ const advisorExposed = pi.getAllTools().some((tool) => tool.name === ADVISOR_TOOL_NAME);
882
+ const advisorActive = pi.getActiveTools().includes(ADVISOR_TOOL_NAME);
883
+ if (advisorExposed && !advisorActive) fail("Advisor tool", "bro_advisor is exposed but not active in this session. Run /reload.");
884
+ 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)");
885
+ pass("Advisor steering", resolveAdvisorState(ctx.sessionManager.getBranch()).steering.trim() ? "present" : "none");
886
+ if (agyVersion && advisorAgyCompatible(agyVersion)) pass("Advisor compatibility", ADVISOR_COMPATIBILITY);
887
+ else if (agyVersion) fail("Advisor compatibility", `installed \`${agyVersion}\`; requires Agy >=1.1.15. Run \`agy update\`.`);
888
+
728
889
  return `# Bro doctor\n\n${lines.join("\n")}\n\n**${failed ? "Bro needs attention." : "Bro is ready."}**\n\n${
729
890
  failed ? "Fix the failed items, then press **R** to check again." : "No assistant response was sent and no model turn was run."
730
891
  }`;
@@ -946,7 +1107,7 @@ async function simplify(
946
1107
  settings: BroSettings,
947
1108
  onProgress?: (text: string) => void,
948
1109
  ): Promise<string> {
949
- return runAgyText((await promptFor(response, settings.mode)).text, agySelection(settings), signal, onProgress);
1110
+ return runAgyText((await promptFor(response, settings.mode)).text, agySelection(capabilityPair(settings, "explain")), signal, onProgress);
950
1111
  }
951
1112
 
952
1113
  async function runShowExplanation(
@@ -956,7 +1117,7 @@ async function runShowExplanation(
956
1117
  settings: BroSettings,
957
1118
  onProgress?: (text: string) => void,
958
1119
  ): Promise<string> {
959
- return runAgyText(buildShowPrompt(transcript, steering), agySelection(settings), signal, onProgress);
1120
+ return runAgyText(buildShowPrompt(transcript, steering), agySelection(capabilityPair(settings, "show")), signal, onProgress);
960
1121
  }
961
1122
 
962
1123
  async function runAgyText(
@@ -1067,9 +1228,917 @@ async function runAgyText(
1067
1228
  }
1068
1229
  }
1069
1230
 
1070
- function helpText(settings?: BroSettings, settingsError?: string): string {
1231
+ // Harness-neutral advisor context snapshot: plain role/text/tool-name+args/tool-result-text, not
1232
+ // Pi's internal message objects serialized as-is, so the shape isn't a Pi-specific contract. Every
1233
+ // piece of free-text message content is JSON-quoted (the same "quote the source as data" convention
1234
+ // SOURCE_GUARD/buildShowPrompt/buildBtwPrompt already use in prompt.ts) rather than pasted in raw
1235
+ // after a bare "## role" heading: unquoted text under a heading would let pasted text or tool output
1236
+ // forge a fake "## user"/"## assistant" section that looks like a real turn boundary. See
1237
+ // docs/plans/2026-09-19-bro-advisor-design.md, "Context snapshot".
1238
+ type AdvisorMessageLike = { role?: unknown; content?: unknown; toolName?: unknown; toolCallId?: unknown; isError?: unknown };
1239
+
1240
+ // Parameter element type of Pi's own AgentMessage → LLM converter (core/messages.ts), reused below
1241
+ // instead of redeclaring BashExecutionMessage's shape (it isn't exported from the package root).
1242
+ type AdvisorAgentMessage = Parameters<typeof convertToLlm>[0][number];
1243
+
1244
+ function advisorQuoted(text: string): string {
1245
+ return JSON.stringify(text);
1246
+ }
1247
+
1248
+ // `resolvedToolCallIds` lets a still-pending bro_advisor call (the one currently in flight for this
1249
+ // very consultation, which by definition has no result yet) be dropped instead of rendered as an
1250
+ // orphaned, unresolved call — a past, completed bro_advisor call is unaffected and renders normally.
1251
+ function advisorContentText(content: unknown, resolvedToolCallIds: ReadonlySet<string>): string {
1252
+ if (typeof content === "string") {
1253
+ const text = content.trim();
1254
+ return text ? advisorQuoted(text) : "";
1255
+ }
1256
+ if (!Array.isArray(content)) return "";
1257
+ const parts: string[] = [];
1258
+ for (const part of content) {
1259
+ if (!part || typeof part !== "object") continue;
1260
+ const type = (part as { type?: string }).type;
1261
+ if (type === "text" && typeof (part as { text?: unknown }).text === "string") {
1262
+ const text = (part as { text: string }).text.trim();
1263
+ if (text) parts.push(advisorQuoted(text));
1264
+ } else if (type === "image") {
1265
+ parts.push("[image omitted]");
1266
+ } else if (type === "thinking") {
1267
+ parts.push("[reasoning omitted]");
1268
+ } else if (type === "toolCall") {
1269
+ const call = part as { id?: unknown; name?: unknown; arguments?: unknown };
1270
+ const id = typeof call.id === "string" ? call.id : undefined;
1271
+ const name = typeof call.name === "string" ? call.name : "(unknown)";
1272
+ if (name === ADVISOR_TOOL_NAME && (!id || !resolvedToolCallIds.has(id))) continue;
1273
+ parts.push(`[tool call${id ? ` #${id}` : ""}] ${name}(${JSON.stringify(call.arguments ?? {})})`);
1274
+ }
1275
+ }
1276
+ return parts.join("\n").trim();
1277
+ }
1278
+
1279
+ function renderAdvisorMessage(message: AdvisorMessageLike, resolvedToolCallIds: ReadonlySet<string>): string {
1280
+ if (message.role === "user") {
1281
+ const text = advisorContentText(message.content, resolvedToolCallIds);
1282
+ return text ? `## user\n${text}` : "";
1283
+ }
1284
+ if (message.role === "assistant") {
1285
+ const text = advisorContentText(message.content, resolvedToolCallIds);
1286
+ return text ? `## assistant\n${text}` : "";
1287
+ }
1288
+ if (message.role === "toolResult") {
1289
+ const name = typeof message.toolName === "string" ? message.toolName : "(unknown)";
1290
+ const id = typeof message.toolCallId === "string" ? message.toolCallId : undefined;
1291
+ const header = `## tool result${id ? ` [#${id}]` : ""}: ${name}${message.isError ? " · error" : ""}`;
1292
+ const text = advisorContentText(message.content, resolvedToolCallIds);
1293
+ return text ? `${header}\n${text}` : header;
1294
+ }
1295
+ if (message.role === "bashExecution") {
1296
+ // Reuse Pi's own AgentMessage -> LLM conversion instead of hand-rolling one: it already
1297
+ // drops a command run with the `!!` prefix (excludeFromContext) and formats the rest exactly
1298
+ // as Pi's own context builder would, so a `!`-run command an executor can see is not silently
1299
+ // missing from what the advisor sees.
1300
+ const [converted] = convertToLlm([message as AdvisorAgentMessage]);
1301
+ if (!converted) return "";
1302
+ const text = advisorContentText(converted.content, resolvedToolCallIds);
1303
+ return text ? `## bash execution\n${text}` : "";
1304
+ }
1305
+ return "";
1306
+ }
1307
+
1308
+ export function buildAdvisorSnapshot(ctx: ExtensionContext, pi: ExtensionAPI): { text: string; hadCompaction: boolean } {
1309
+ const systemPrompt = ctx.getSystemPrompt().trim();
1310
+
1311
+ const activeToolNames = new Set(pi.getActiveTools().filter((name) => name !== ADVISOR_TOOL_NAME));
1312
+ const tools = pi.getAllTools().filter((tool) => activeToolNames.has(tool.name)).sort((a, b) => a.name.localeCompare(b.name));
1313
+ // No truncation of tool descriptions: Bro imposes no size cap on the snapshot (see the design
1314
+ // doc) and an arbitrary slice would silently drop part of a tool's actual behavior contract.
1315
+ const toolsSection = tools.length
1316
+ ? tools.map((tool) => `- ${tool.name}: ${tool.description.replace(/\s+/g, " ").trim()}`).join("\n")
1317
+ : "(none)";
1318
+
1319
+ const entries = ctx.sessionManager.buildContextEntries();
1320
+ const resolvedToolCallIds = new Set<string>();
1321
+ for (const entry of entries) {
1322
+ if (entry.type !== "message") continue;
1323
+ const message = entry.message as AdvisorMessageLike;
1324
+ if (message.role === "toolResult" && typeof message.toolCallId === "string") resolvedToolCallIds.add(message.toolCallId);
1325
+ }
1326
+
1327
+ const lines: string[] = [];
1328
+ // Tracked from the actual entry type, not a substring search over the rendered snapshot text --
1329
+ // free-text content (a file the executor read, a pasted paragraph) is JSON-quoted but not
1330
+ // stripped of "#", so a substring check could be forged by source data that happens to contain
1331
+ // the literal heading text. Only a real `compaction` entry may set this.
1332
+ let hadCompaction = false;
1333
+ for (const entry of entries) {
1334
+ if (entry.type === "message") {
1335
+ const rendered = renderAdvisorMessage(entry.message as AdvisorMessageLike, resolvedToolCallIds);
1336
+ if (rendered) lines.push(rendered);
1337
+ } else if (entry.type === "compaction") {
1338
+ // buildContextEntries() already includes every real entry after the compaction point;
1339
+ // the summary is all there is to represent for what it replaced.
1340
+ hadCompaction = true;
1341
+ lines.push(`## compacted earlier context\n${advisorQuoted(entry.summary)}`);
1342
+ } else if (entry.type === "branch_summary") {
1343
+ lines.push(`## abandoned branch summary\n${advisorQuoted(entry.summary)}`);
1344
+ } else if (entry.type === "custom_message") {
1345
+ const text = advisorContentText(entry.content, resolvedToolCallIds);
1346
+ if (text) lines.push(`## extension message: ${entry.customType}\n${text}`);
1347
+ }
1348
+ // "custom" entries (including our own advisor activation/steering state) are intentionally
1349
+ // skipped — they never participate in LLM context and must not leak into the snapshot either.
1350
+ }
1351
+ const conversationSection = lines.length ? lines.join("\n\n") : "(no conversation content captured)";
1352
+
1353
+ 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}`;
1354
+ return { text, hadCompaction };
1355
+ }
1356
+
1357
+ // Advisor stdin/stream-json transport, confirmed against pi-flow-external's tested agy backend
1358
+ // (src/core/agy.ts, test/agy-backend.test.ts): --input-format stream-json reads one NDJSON
1359
+ // "user" message from stdin instead of a --print argv value (unbounded snapshot size would
1360
+ // otherwise risk ARG_MAX), and --dangerously-skip-permissions gives the advisor real, unprompted
1361
+ // tool access. See docs/plans/2026-09-19-bro-advisor-design.md, "Transport".
1362
+ type AdvisorAgyEvent = {
1363
+ event?: string;
1364
+ step_update?: { tool_name?: unknown; step_type?: unknown; text_delta?: unknown };
1365
+ result?: { status?: unknown; response?: unknown; error?: unknown };
1366
+ };
1367
+
1368
+ // Guards only against a single runaway line with no newline (a protocol break, not a real
1369
+ // response size) — agy's real NDJSON lines are far smaller than this.
1370
+ const ADVISOR_MAX_STDOUT_LINE_CHARS = 2_000_000;
1371
+
1372
+ function parseAdvisorLine(line: string): AdvisorAgyEvent {
1373
+ try {
1374
+ return JSON.parse(line) as AdvisorAgyEvent;
1375
+ } catch {
1376
+ throw new Error("Agy emitted invalid stream-json output.");
1377
+ }
1378
+ }
1379
+
1380
+ // Only a tool_name or a user-facing agent_response/assistant text_delta becomes an activity label
1381
+ // -- hidden reasoning/thinking step_types and any other shape stay unreported, never leaked into
1382
+ // progress.
1383
+ function advisorActivityFromEvent(event: AdvisorAgyEvent): string | undefined {
1384
+ if (event.event !== "step_update" || !event.step_update || typeof event.step_update !== "object") return undefined;
1385
+ const update = event.step_update;
1386
+ if (typeof update.tool_name === "string" && update.tool_name.trim()) return update.tool_name.trim();
1387
+ const isUserFacingText = update.step_type === "agent_response" || update.step_type === "assistant";
1388
+ if (isUserFacingText && typeof update.text_delta === "string" && update.text_delta.trim()) {
1389
+ return update.text_delta.split("\n").find((line) => line.trim())?.trim();
1390
+ }
1391
+ return undefined;
1392
+ }
1393
+
1394
+ const MAX_ADVISOR_ACTIVITY_LINES = 4;
1395
+ const ADVISOR_ACTIVITY_PREVIEW_CHARS = 100;
1396
+
1397
+ function advisorActivityPreview(label: string): string {
1398
+ return label.length > ADVISOR_ACTIVITY_PREVIEW_CHARS ? `${label.slice(0, ADVISOR_ACTIVITY_PREVIEW_CHARS).trimEnd()}…` : label;
1399
+ }
1400
+
1401
+ // Older agy CLIs reject --input-format with Go's flag-package usage dump and exit before running
1402
+ // the agent at all (no terminal result event). Turn that into an actionable version hint instead
1403
+ // of a bare "no terminal result" error.
1404
+ export function advisorFlagErrorHint(stderr: string): string | undefined {
1405
+ const match = /flags? provided but not defined: -([a-z0-9-]+)/i.exec(stderr);
1406
+ if (!match) return undefined;
1407
+ return `flag provided but not defined: -${match[1]} (installed Agy CLI is too old; the advisor needs Agy 1.1.15+ for --input-format stream-json — run \`agy update\`, then \`/bro doctor\`)`;
1408
+ }
1409
+
1410
+ export type AdvisorActivityCallback = (label: string, timestamp: number) => void;
1411
+
1412
+ export async function runAdvisorConsultation(
1413
+ prompt: string,
1414
+ selection: ReturnType<typeof agySelection>,
1415
+ cwd: string,
1416
+ signal: AbortSignal,
1417
+ killEscalationMs = 5_000,
1418
+ onActivity?: AdvisorActivityCallback,
1419
+ ): Promise<string> {
1420
+ const child = spawn(
1421
+ "agy",
1422
+ [
1423
+ "--dangerously-skip-permissions",
1424
+ "--disable-slash-commands",
1425
+ "--output-format", "stream-json",
1426
+ "--input-format", "stream-json",
1427
+ "--model", selection.model,
1428
+ ...(selection.effort ? ["--effort", selection.effort] : []),
1429
+ "--print-timeout", "10m",
1430
+ ],
1431
+ // detached: true (POSIX only) makes the child its own process-group leader, so a signal to
1432
+ // -child.pid below reaches it AND every grandchild it spawned -- not just the immediate
1433
+ // process. Without this, killing only the immediate child can leave a grandchild holding the
1434
+ // inherited stdio pipes open, and the "close" event this function waits on never fires until
1435
+ // that orphan exits on its own.
1436
+ { cwd, timeout: 610_000, stdio: ["pipe", "pipe", "pipe"], windowsHide: true, detached: process.platform !== "win32" },
1437
+ );
1438
+
1439
+ let processError: Error | undefined;
1440
+ let stderr = "";
1441
+ let final: string | undefined;
1442
+ let terminalError: string | undefined;
1443
+ let protocolError: string | undefined;
1444
+ let sawTerminal = false;
1445
+ let stdoutBuffer = "";
1446
+
1447
+ // Signal the whole process group when possible so a misbehaving grandchild dies too, not just
1448
+ // the immediate agy process; child.kill() alone only ever reaches the immediate child.
1449
+ const killAdvisorChild = (signalName: NodeJS.Signals) => {
1450
+ if (process.platform !== "win32" && typeof child.pid === "number") {
1451
+ try {
1452
+ process.kill(-child.pid, signalName);
1453
+ return;
1454
+ } catch {
1455
+ // Group may already be gone (e.g. the child already exited) -- fall through.
1456
+ }
1457
+ }
1458
+ child.kill(signalName);
1459
+ };
1460
+
1461
+ // Relying on spawn({signal}) alone only ever sends one SIGTERM and gives up if the child (or a
1462
+ // misbehaving grandchild it spawned) ignores it, hanging this promise forever. Escalate to
1463
+ // SIGKILL -- which cannot be ignored -- if the child hasn't exited shortly after.
1464
+ let killEscalationTimer: ReturnType<typeof setTimeout> | undefined;
1465
+ const onAbort = () => {
1466
+ killAdvisorChild("SIGTERM");
1467
+ killEscalationTimer = setTimeout(() => {
1468
+ killAdvisorChild("SIGKILL");
1469
+ }, killEscalationMs);
1470
+ };
1471
+ signal.addEventListener("abort", onAbort, { once: true });
1472
+ if (signal.aborted) onAbort();
1473
+
1474
+ child.stderr.setEncoding("utf8");
1475
+ child.stderr.on("data", (chunk: string) => {
1476
+ stderr += chunk;
1477
+ });
1478
+ child.once("error", (error) => {
1479
+ processError = error;
1480
+ });
1481
+ child.stdin.on("error", () => {
1482
+ // agy exiting before it reads stdin is reported through the close/error path below.
1483
+ });
1484
+ child.stdin.end(`${JSON.stringify({ event: "user", message: { content: prompt } })}\n`);
1485
+
1486
+ const handleLine = (line: string) => {
1487
+ if (!line.trim() || sawTerminal) return;
1488
+ const event = parseAdvisorLine(line);
1489
+ const activity = advisorActivityFromEvent(event);
1490
+ if (activity) onActivity?.(activity, Date.now());
1491
+ if (event.event !== "result") return;
1492
+ sawTerminal = true;
1493
+ const result = event.result;
1494
+ const status = typeof result?.status === "string" ? result.status.trim().toUpperCase() : undefined;
1495
+ if (status === "SUCCESS" && typeof result?.response === "string") {
1496
+ final = result.response;
1497
+ } else {
1498
+ const detail = typeof result?.error === "string" && result.error.trim() ? `: ${result.error.trim()}` : "";
1499
+ terminalError = `Agy failed with status ${status ?? "(missing)"}${detail}`;
1500
+ }
1501
+ };
1502
+
1503
+ child.stdout.setEncoding("utf8");
1504
+ child.stdout.on("data", (chunk: string) => {
1505
+ stdoutBuffer += chunk;
1506
+ const parts = stdoutBuffer.split(/\r?\n/);
1507
+ stdoutBuffer = parts.pop() ?? "";
1508
+ if (stdoutBuffer.length > ADVISOR_MAX_STDOUT_LINE_CHARS || parts.some((line) => line.length > ADVISOR_MAX_STDOUT_LINE_CHARS)) {
1509
+ protocolError ??= `Agy emitted a stdout line over ${ADVISOR_MAX_STDOUT_LINE_CHARS} characters; the stream is unparseable.`;
1510
+ stdoutBuffer = "";
1511
+ killAdvisorChild("SIGTERM");
1512
+ return;
1513
+ }
1514
+ for (const line of parts) {
1515
+ try {
1516
+ handleLine(line);
1517
+ } catch (error) {
1518
+ protocolError ??= errorMessage(error);
1519
+ killAdvisorChild("SIGTERM");
1520
+ return;
1521
+ }
1522
+ }
1523
+ });
1524
+
1525
+ const { code, exitSignal } = await new Promise<{ code: number | null; exitSignal: NodeJS.Signals | null }>((resolve) => {
1526
+ child.once("close", (code, exitSignal) => {
1527
+ if (stdoutBuffer.trim() && !sawTerminal) {
1528
+ try {
1529
+ handleLine(stdoutBuffer);
1530
+ } catch (error) {
1531
+ protocolError ??= errorMessage(error);
1532
+ }
1533
+ }
1534
+ resolve({ code, exitSignal });
1535
+ });
1536
+ });
1537
+ signal.removeEventListener("abort", onAbort);
1538
+ if (killEscalationTimer) clearTimeout(killEscalationTimer);
1539
+
1540
+ if (signal.aborted) throw new Error("Canceled.");
1541
+ if (protocolError) throw new Error(withDoctor(protocolError));
1542
+ if (processError) {
1543
+ const missing = (processError as NodeJS.ErrnoException).code === "ENOENT";
1544
+ throw new Error(
1545
+ missing
1546
+ ? "Agy could not start. Make sure Agy is installed and on PATH, then run `/bro doctor`."
1547
+ : `Agy could not start: ${processError.message}\n\nRun \`/bro doctor\` for setup help.`,
1548
+ );
1549
+ }
1550
+ if (exitSignal || code === null) {
1551
+ throw new Error("Agy timed out during the advisor consultation. Run `/bro doctor` for setup help.");
1552
+ }
1553
+ if (!sawTerminal) {
1554
+ const hint = advisorFlagErrorHint(stderr);
1555
+ throw new Error(withDoctor(hint ?? (stderr.trim() ? `Agy exited without a terminal result event: ${stderr.trim()}` : "Agy exited without a terminal result event.")));
1556
+ }
1557
+ if (terminalError) throw new Error(withDoctor(terminalError));
1558
+ if (code !== 0) throw new Error(agyFailureMessage("complete the advisor consultation", { code, killed: false, stderr }));
1559
+
1560
+ const text = final?.trim();
1561
+ if (!text) throw new Error(withDoctor(stderr.trim() || "Agy returned no advice."));
1562
+ return text;
1563
+ }
1564
+
1565
+ function advisorDelay(ms: number, signal: AbortSignal, onTick?: (remainingMs: number) => void): Promise<void> {
1566
+ return new Promise((resolve, reject) => {
1567
+ if (signal.aborted) {
1568
+ reject(new Error("Canceled."));
1569
+ return;
1570
+ }
1571
+ const startedAt = Date.now();
1572
+ const interval = onTick ? setInterval(() => onTick(Math.max(0, ms - (Date.now() - startedAt))), 1_000) : undefined;
1573
+ interval?.unref();
1574
+ const cleanup = () => {
1575
+ clearTimeout(timer);
1576
+ if (interval) clearInterval(interval);
1577
+ signal.removeEventListener("abort", onAbort);
1578
+ };
1579
+ const onAbort = () => {
1580
+ cleanup();
1581
+ reject(new Error("Canceled."));
1582
+ };
1583
+ const timer = setTimeout(() => {
1584
+ cleanup();
1585
+ resolve();
1586
+ }, ms);
1587
+ signal.addEventListener("abort", onAbort, { once: true });
1588
+ });
1589
+ }
1590
+
1591
+ export type AdvisorConsult = (
1592
+ prompt: string,
1593
+ selection: ReturnType<typeof agySelection>,
1594
+ cwd: string,
1595
+ signal: AbortSignal,
1596
+ killEscalationMs?: number,
1597
+ onActivity?: AdvisorActivityCallback,
1598
+ ) => Promise<string>;
1599
+ export type AdvisorDelayFn = (ms: number, signal: AbortSignal, onTick?: (remainingMs: number) => void) => Promise<void>;
1600
+ export type AdvisorToolDetails = {
1601
+ status: "investigating" | "retrying" | "done";
1602
+ attempt: number;
1603
+ of: number;
1604
+ elapsedMs: number;
1605
+ error?: string;
1606
+ retryInMs?: number;
1607
+ model?: string;
1608
+ effort?: string;
1609
+ durationMs?: number;
1610
+ cwd?: string;
1611
+ steeringIncluded?: boolean;
1612
+ snapshotChars?: number;
1613
+ broTruncated?: false;
1614
+ omissions?: string;
1615
+ // Real Agy-reported activity for the current attempt only -- reset to empty whenever a new
1616
+ // attempt (including a retry) starts, never carried over from a prior failed attempt.
1617
+ activity?: string[];
1618
+ lastActivityAt?: number;
1619
+ // Total count of accepted activity events (tool calls AND user-facing text deltas -- "activity"
1620
+ // is the honest label, not "tool calls") observed across the whole consultation, including every
1621
+ // retry attempt. Unlike `activity` above, this never resets on a retry; the final "done" details
1622
+ // carry the same running total.
1623
+ activityCount?: number;
1624
+ };
1625
+ export type AdvisorProgressCallback = (details: AdvisorToolDetails) => void;
1626
+ export type AdvisorRunResult = { advice: string; attempts: number; durationMs: number; activityCount: number };
1627
+
1628
+ // Bursts of chunked NDJSON activity events are coalesced to this cadence so a fast stream of tool
1629
+ // calls/text deltas doesn't flood onProgress; the 1s elapsed-time heartbeat below is unaffected and
1630
+ // keeps ticking independently.
1631
+ const ADVISOR_ACTIVITY_THROTTLE_MS = 250;
1632
+
1633
+ // `consult`/`delayFn` are injectable so tests can swap in a fake agy spawn and a fake clock
1634
+ // instead of spawning real processes and waiting 15 real seconds. Every attempt sends the
1635
+ // identical prompt/selection/cwd to a fresh, standalone Agy process — never resumed via
1636
+ // --conversation, even across retries.
1637
+ export async function runAdvisorWithRetries(
1638
+ prompt: string,
1639
+ selection: ReturnType<typeof agySelection>,
1640
+ cwd: string,
1641
+ signal: AbortSignal,
1642
+ consult: AdvisorConsult = runAdvisorConsultation,
1643
+ delayFn: AdvisorDelayFn = advisorDelay,
1644
+ onProgress?: AdvisorProgressCallback,
1645
+ progressIntervalMs = 1_000,
1646
+ activityThrottleMs = ADVISOR_ACTIVITY_THROTTLE_MS,
1647
+ ): Promise<AdvisorRunResult> {
1648
+ const delays = [5_000, 10_000];
1649
+ const totalAttempts = delays.length + 1;
1650
+ const startedAt = Date.now();
1651
+ let lastError: unknown;
1652
+ // Cumulative across every attempt in this consultation, including retries -- unlike `activity`
1653
+ // below, a retry must never reset this back to zero.
1654
+ let totalActivityCount = 0;
1655
+ for (let attempt = 0; attempt <= delays.length; attempt++) {
1656
+ if (signal.aborted) throw new Error("Canceled.");
1657
+ // Fresh per attempt: a retry must never show the previous attempt's activity trail.
1658
+ const activity: string[] = [];
1659
+ let lastActivityAt: number | undefined;
1660
+ let lastActivityEmitAt = 0;
1661
+ let pendingActivityTimer: ReturnType<typeof setTimeout> | undefined;
1662
+
1663
+ const running = (): AdvisorToolDetails => ({
1664
+ status: "investigating",
1665
+ attempt: attempt + 1,
1666
+ of: totalAttempts,
1667
+ elapsedMs: Date.now() - startedAt,
1668
+ activity: [...activity],
1669
+ lastActivityAt,
1670
+ activityCount: totalActivityCount,
1671
+ });
1672
+ const emitRunning = () => onProgress?.(running());
1673
+ emitRunning();
1674
+ const progressTimer = onProgress ? setInterval(emitRunning, progressIntervalMs) : undefined;
1675
+ progressTimer?.unref();
1676
+
1677
+ const stopActivityThrottle = () => {
1678
+ if (pendingActivityTimer) {
1679
+ clearTimeout(pendingActivityTimer);
1680
+ pendingActivityTimer = undefined;
1681
+ }
1682
+ };
1683
+ const onActivity: AdvisorActivityCallback = (label, timestamp) => {
1684
+ const normalized = label.replace(/\s+/g, " ").trim();
1685
+ if (!normalized) return;
1686
+ activity.push(normalized);
1687
+ if (activity.length > MAX_ADVISOR_ACTIVITY_LINES) activity.splice(0, activity.length - MAX_ADVISOR_ACTIVITY_LINES);
1688
+ totalActivityCount += 1;
1689
+ lastActivityAt = timestamp;
1690
+ if (!onProgress) return;
1691
+ const elapsed = Date.now() - lastActivityEmitAt;
1692
+ if (elapsed >= activityThrottleMs) {
1693
+ lastActivityEmitAt = Date.now();
1694
+ emitRunning();
1695
+ return;
1696
+ }
1697
+ if (!pendingActivityTimer) {
1698
+ pendingActivityTimer = setTimeout(() => {
1699
+ pendingActivityTimer = undefined;
1700
+ lastActivityEmitAt = Date.now();
1701
+ emitRunning();
1702
+ }, activityThrottleMs - elapsed);
1703
+ pendingActivityTimer.unref?.();
1704
+ }
1705
+ };
1706
+
1707
+ try {
1708
+ const advice = await consult(prompt, selection, cwd, signal, undefined, onActivity);
1709
+ // `totalActivityCount` is read directly here rather than from the last onProgress snapshot,
1710
+ // which may lag behind by up to `activityThrottleMs` -- the final count must be exact.
1711
+ return { advice, attempts: attempt + 1, durationMs: Date.now() - startedAt, activityCount: totalActivityCount };
1712
+ } catch (error) {
1713
+ if (progressTimer) clearInterval(progressTimer);
1714
+ stopActivityThrottle();
1715
+ if (signal.aborted || errorMessage(error) === "Canceled.") throw error;
1716
+ lastError = error;
1717
+ if (attempt === delays.length) break;
1718
+ const delay = delays[attempt]!;
1719
+ const retrying = (remainingMs: number): AdvisorToolDetails => ({
1720
+ status: "retrying",
1721
+ attempt: attempt + 2,
1722
+ of: totalAttempts,
1723
+ elapsedMs: Date.now() - startedAt,
1724
+ error: errorMessage(error),
1725
+ retryInMs: remainingMs,
1726
+ activityCount: totalActivityCount,
1727
+ });
1728
+ onProgress?.(retrying(delay));
1729
+ await delayFn(delay, signal, (remainingMs) => onProgress?.(retrying(remainingMs)));
1730
+ } finally {
1731
+ if (progressTimer) clearInterval(progressTimer);
1732
+ stopActivityThrottle();
1733
+ }
1734
+ }
1735
+ throw lastError;
1736
+ }
1737
+
1738
+ export function advisorAttemptLabel(details: AdvisorToolDetails): string {
1739
+ if (details.status === "retrying") {
1740
+ return `Bro advisor · retrying in ${Math.ceil((details.retryInMs ?? 0) / 1_000)}s · attempt ${details.attempt}/${details.of}${details.error ? ` — ${details.error}` : ""}`;
1741
+ }
1742
+ if (details.status === "done") {
1743
+ return `Bro advisor · ${details.model ?? "unknown model"} · ${details.attempt} attempt${details.attempt === 1 ? "" : "s"} · ${Math.ceil((details.durationMs ?? details.elapsedMs) / 1_000)}s`;
1744
+ }
1745
+ const latest = details.activity?.at(-1);
1746
+ // "last reported" + freshness, never a claim about what Agy is doing right now and never
1747
+ // "stalled" -- silence since lastActivityAt is not itself evidence of a stuck run.
1748
+ const activityLabel = latest
1749
+ ? `last reported: ${advisorActivityPreview(latest)} (${Math.max(0, Math.floor((Date.now() - (details.lastActivityAt ?? Date.now())) / 1_000))}s ago)`
1750
+ : "awaiting first activity from Agy";
1751
+ return `Bro advisor · running · ${Math.floor(details.elapsedMs / 1_000)}s · attempt ${details.attempt}/${details.of} · ${activityLabel}`;
1752
+ }
1753
+
1754
+ const SHOW_TURNS_PRESETS = [1, 2, 3, 5, 8];
1755
+
1756
+ function showTurnsValues(current: number): string[] {
1757
+ return [...new Set([...SHOW_TURNS_PRESETS, current])].sort((a, b) => a - b).map(String);
1758
+ }
1759
+
1760
+ // A resolved pair's display string: distinguishes a model that is genuinely fixed-effort from
1761
+ // one that simply isn't in the current catalog (both used to render as "fixed", which read as
1762
+ // falsely healthy for an unavailable model), and flags a stored effort that isn't one of the
1763
+ // resolved family's supported efforts instead of silently showing it as if it were valid.
1764
+ function effortDisplay(resolved: { pair: ModelEffortPair; family?: AgyModelFamily }): string {
1765
+ if (!resolved.family) return "unavailable";
1766
+ const fixed = !resolved.family.efforts.length;
1767
+ const valid = fixed ? resolved.pair.effort === "default" : resolved.family.efforts.includes(resolved.pair.effort as AgyEffort);
1768
+ if (!valid) return `${resolved.pair.effort} (unsupported)`;
1769
+ return fixed ? "fixed" : resolved.pair.effort;
1770
+ }
1771
+
1772
+ // Testable core: takes settings/catalog/persist as plain arguments so smoke tests can drive
1773
+ // the exact interaction (submenus, cancel, cycling, save failure) without a real Agy process
1774
+ // or settings file. showBroConfigModal below wires this to the real ctx/pi/filesystem.
1775
+ export function createConfigModal(
1776
+ initialSettings: BroSettings,
1777
+ families: AgyModelFamily[],
1778
+ persistSettings: (settings: BroSettings) => Promise<void>,
1779
+ ): (tui: TuiLike, theme: Theme, keybindings: unknown, done: (value?: void) => void) => Component & { dispose?(): void } {
1780
+ return (tui, theme, _keybindings, done) => {
1781
+ let settings = initialSettings;
1782
+ // The last settings actually confirmed on disk. A failed save reverts `settings` (and the
1783
+ // whole displayed row set) back to this, so the screen never shows state that doesn't exist.
1784
+ let savedSettings = initialSettings;
1785
+ // Only one persistSettings call is ever in flight. A change that arrives while one is
1786
+ // already running is coalesced into `queued` (overwriting any earlier queued change) rather
1787
+ // than firing a second concurrent write — this is what keeps writes serialized and makes
1788
+ // sure the on-disk file always converges on the latest intent instead of a stale one that
1789
+ // happened to finish last.
1790
+ let saving = false;
1791
+ let queued: BroSettings | undefined;
1792
+ // Esc while a save is in flight must not close past an unshown result: it requests a close
1793
+ // that only actually happens once the in-flight (and any coalesced) save has settled, and
1794
+ // only if it succeeded — a failure cancels the pending close so its notice stays visible.
1795
+ let closeRequested = false;
1796
+
1797
+ const findFamily = (modelId: string) =>
1798
+ families.find((item) => item.id === modelId || item.variants.some((variant) => variant.id === modelId));
1799
+
1800
+ const modelPicker = (current: string, pickerDone: (value?: string) => void, capability?: Capability) => {
1801
+ const defaultResolved = resolveModelEffort({ model: settings.model, effort: settings.effort }, families);
1802
+ const options: SelectItem[] = [
1803
+ ...(capability ? [{ value: "__default__", label: `Default (${defaultResolved.family?.label ?? settings.model})` }] : []),
1804
+ ...families.map((family) => ({
1805
+ value: family.id,
1806
+ label: `${family.label}${family.efforts.length ? "" : " · fixed effort"}`,
1807
+ })),
1808
+ ];
1809
+ const picker = new SelectList(options, Math.min(options.length, 8), getSelectListTheme());
1810
+ const selectedIndex = capability && current === "Default" ? 0 : options.findIndex((option) => option.value === current);
1811
+ picker.setSelectedIndex(Math.max(0, selectedIndex));
1812
+ picker.onSelect = (item) => pickerDone(item.value);
1813
+ picker.onCancel = () => pickerDone();
1814
+ return picker;
1815
+ };
1816
+
1817
+ const modelItem: SettingItem = { id: "model", label: "Default model", currentValue: settings.model, submenu: modelPicker };
1818
+ const effortItem: SettingItem = { id: "effort", label: "Default effort", currentValue: "" };
1819
+ const modeItem: SettingItem = { id: "mode", label: "Explain mode", currentValue: settings.mode, values: [...BRO_MODES] };
1820
+ const showTurnsItem: SettingItem = {
1821
+ id: "showTurns",
1822
+ label: "Show turns",
1823
+ currentValue: String(settings.showTurns),
1824
+ values: showTurnsValues(settings.showTurns),
1825
+ };
1826
+ const capabilityItems = Object.fromEntries(
1827
+ CAPABILITIES.map((capability) => [
1828
+ capability,
1829
+ {
1830
+ model: {
1831
+ id: `${capability}Model`,
1832
+ label: `${CAPABILITY_LABELS[capability]} model`,
1833
+ currentValue: "Default",
1834
+ submenu: (current: string, pickerDone: (value?: string) => void) => modelPicker(current, pickerDone, capability),
1835
+ } as SettingItem,
1836
+ effort: {
1837
+ id: `${capability}Effort`,
1838
+ label: `${CAPABILITY_LABELS[capability]} effort`,
1839
+ currentValue: "",
1840
+ } as SettingItem,
1841
+ },
1842
+ ]),
1843
+ ) as Record<Capability, { model: SettingItem; effort: SettingItem }>;
1844
+
1845
+ function refresh(): void {
1846
+ const def = resolveModelEffort({ model: settings.model, effort: settings.effort }, families);
1847
+ modelItem.currentValue = def.family?.id ?? settings.model;
1848
+ effortItem.currentValue = effortDisplay(def);
1849
+ effortItem.values = def.family?.efforts.length ? [...def.family.efforts] : undefined;
1850
+ modeItem.currentValue = settings.mode;
1851
+ showTurnsItem.currentValue = String(settings.showTurns);
1852
+ showTurnsItem.values = showTurnsValues(settings.showTurns);
1853
+
1854
+ for (const capability of CAPABILITIES) {
1855
+ const override = capabilityOverride(settings, capability);
1856
+ const resolved = resolveModelEffort(capabilityPair(settings, capability), families);
1857
+ const rows = capabilityItems[capability];
1858
+ rows.model.currentValue = override ? (resolved.family?.id ?? override.model) : "Default";
1859
+ rows.effort.currentValue = effortDisplay(resolved);
1860
+ rows.effort.values = resolved.family?.efforts.length ? [...resolved.family.efforts] : undefined;
1861
+ }
1862
+ }
1863
+ refresh();
1864
+
1865
+ const items: SettingItem[] = [
1866
+ modelItem,
1867
+ effortItem,
1868
+ modeItem,
1869
+ showTurnsItem,
1870
+ ...CAPABILITIES.flatMap((capability) => [capabilityItems[capability].model, capabilityItems[capability].effort]),
1871
+ ];
1872
+
1873
+ const noticeText = new Text("");
1874
+
1875
+ function runSave(toSave: BroSettings): void {
1876
+ saving = true;
1877
+ void persistSettings(toSave)
1878
+ .then(() => {
1879
+ savedSettings = toSave;
1880
+ noticeText.setText("");
1881
+ })
1882
+ .catch((error: unknown) => {
1883
+ // Restore the last state that is actually on disk: showing the failed, unsaved
1884
+ // value would let the screen claim a setting that doesn't really exist.
1885
+ settings = savedSettings;
1886
+ queued = undefined;
1887
+ closeRequested = false;
1888
+ refresh();
1889
+ noticeText.setText(theme.fg("warning", `Could not save settings: ${errorMessage(error)}. Reverted to the last saved settings.`));
1890
+ })
1891
+ .finally(() => {
1892
+ saving = false;
1893
+ tui.requestRender();
1894
+ if (queued !== undefined) {
1895
+ const next = queued;
1896
+ queued = undefined;
1897
+ runSave(next);
1898
+ } else if (closeRequested) {
1899
+ closeRequested = false;
1900
+ done(undefined);
1901
+ }
1902
+ });
1903
+ }
1904
+
1905
+ function persist(toSave: BroSettings): void {
1906
+ if (saving) {
1907
+ queued = toSave;
1908
+ return;
1909
+ }
1910
+ runSave(toSave);
1911
+ }
1912
+
1913
+ const onChange = (id: string, newValue: string) => {
1914
+ if (id === "model") {
1915
+ const family = findFamily(newValue);
1916
+ if (!family) return;
1917
+ const keepCurrent = settings.effort === "default" ? !family.efforts.length : family.efforts.includes(settings.effort as AgyEffort);
1918
+ settings = { ...settings, model: family.id, effort: keepCurrent ? settings.effort : preferredEffort(family) };
1919
+ } else if (id === "effort") {
1920
+ // Effort-only edit: pin the resolved family's canonical id, same reasoning as the
1921
+ // capability-override effort-only edit below -- otherwise a shared default created
1922
+ // from a suffixed variant id (e.g. "gemini-x-low") would end up paired with an
1923
+ // unrelated effort instead of its actual family id.
1924
+ const resolved = resolveModelEffort({ model: settings.model, effort: settings.effort }, families);
1925
+ settings = { ...settings, model: resolved.family?.id ?? settings.model, effort: newValue as BroEffort };
1926
+ } else if (id === "mode") {
1927
+ const mode = parseBroMode(newValue);
1928
+ if (!mode) return;
1929
+ settings = { ...settings, mode };
1930
+ } else if (id === "showTurns") {
1931
+ const turns = Number(newValue);
1932
+ if (!Number.isInteger(turns) || turns < 1) return;
1933
+ settings = { ...settings, showTurns: turns };
1934
+ } else {
1935
+ const capability = CAPABILITIES.find((item) => id === `${item}Model` || id === `${item}Effort`);
1936
+ if (!capability) return;
1937
+ if (id === `${capability}Model`) {
1938
+ if (newValue === "__default__") {
1939
+ settings = withCapabilityOverride(settings, capability, undefined);
1940
+ } else {
1941
+ const family = findFamily(newValue);
1942
+ if (!family) return;
1943
+ const currentEffort = capabilityPair(settings, capability).effort;
1944
+ const keepCurrent = currentEffort === "default" ? !family.efforts.length : family.efforts.includes(currentEffort as AgyEffort);
1945
+ settings = withCapabilityOverride(settings, capability, {
1946
+ model: family.id,
1947
+ effort: keepCurrent ? currentEffort : preferredEffort(family),
1948
+ });
1949
+ }
1950
+ } else {
1951
+ // Effort-only edit: pin the resolved family's canonical id, never whatever raw
1952
+ // string happens to sit in settings.model/override.model (which — for a shared
1953
+ // default created from a suffixed variant id such as "gemini-x-low" with
1954
+ // effort "default" — is not the family id). Storing the raw string here would
1955
+ // pair a mismatched model/effort (e.g. a "-low"-suffixed id with effort "high").
1956
+ const resolved = resolveModelEffort(capabilityPair(settings, capability), families);
1957
+ const existing = capabilityOverride(settings, capability);
1958
+ const model = resolved.family?.id ?? existing?.model ?? settings.model;
1959
+ settings = withCapabilityOverride(settings, capability, { model, effort: newValue as BroEffort });
1960
+ }
1961
+ }
1962
+ refresh();
1963
+ tui.requestRender();
1964
+ persist(settings);
1965
+ };
1966
+
1967
+ const requestClose = () => {
1968
+ if (saving) {
1969
+ closeRequested = true;
1970
+ return;
1971
+ }
1972
+ done(undefined);
1973
+ };
1974
+
1975
+ const settingsList = new SettingsList(items, Math.min(items.length + 2, 18), getSettingsListTheme(), onChange, requestClose);
1976
+ const container = new Container();
1977
+ container.addChild(new Text(theme.fg("accent", theme.bold("Bro · config"))));
1978
+ container.addChild(new Text(theme.fg("dim", "Shared defaults, with optional overrides per capability")));
1979
+ container.addChild(settingsList);
1980
+ container.addChild(noticeText);
1981
+ container.addChild(new Text(theme.fg("dim", "↑/↓ navigate · Enter select/change · Esc back/close")));
1982
+
1983
+ return {
1984
+ render: (w: number) => {
1985
+ const inner = Math.max(1, w - 4);
1986
+ const border = (left: string, right: string) => theme.fg("border", left + "─".repeat(inner + 2) + right);
1987
+ return [border("┌", "┐"), ...container.render(inner).map(line => {
1988
+ const text = truncateToWidth(line, inner, "");
1989
+ return theme.fg("border", "│") + " " + text + " ".repeat(Math.max(0, inner - visibleWidth(text))) + " " + theme.fg("border", "│");
1990
+ }), border("└", "┘")];
1991
+ },
1992
+ invalidate: () => container.invalidate(),
1993
+ handleInput: (data: string) => {
1994
+ settingsList.handleInput?.(data);
1995
+ tui.requestRender();
1996
+ },
1997
+ };
1998
+ };
1999
+ }
2000
+
2001
+ export async function showBroConfigModal(ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> {
2002
+ if (ctx.mode !== "tui") {
2003
+ ctx.ui.notify("Use /bro config in Pi's interactive UI.", "warning");
2004
+ return;
2005
+ }
2006
+ let settings: BroSettings;
2007
+ let families: AgyModelFamily[];
2008
+ try {
2009
+ settings = await readSettings();
2010
+ families = await listAgyModels(pi);
2011
+ } catch (error) {
2012
+ ctx.ui.notify(withDoctor(error), "error");
2013
+ return;
2014
+ }
2015
+ await ctx.ui.custom<void>(createConfigModal(settings, families, writeSettings), {
2016
+ overlay: true,
2017
+ overlayOptions: {
2018
+ width: "78%",
2019
+ minWidth: 48,
2020
+ maxHeight: "78%",
2021
+ anchor: "top-center",
2022
+ margin: { top: 1, left: 2, right: 2 },
2023
+ },
2024
+ });
2025
+ }
2026
+
2027
+ // Testable core for /bro advisor-steer: persistence only happens on Ctrl+S/Ctrl+K.
2028
+ // Esc leaves the stored brief untouched; only the in-memory draft is discarded.
2029
+ export function createAdvisorSteerModal(
2030
+ initialText: string,
2031
+ onSave: (text: string) => void,
2032
+ onClear: () => void,
2033
+ copy: (text: string) => Promise<void> = copyToClipboard,
2034
+ ): (tui: TUI, theme: Theme, keybindings: unknown, done: (value?: void) => void) => Component & { dispose?(): void } {
2035
+ return (tui, theme, _keybindings, done) => {
2036
+ const editorTheme: EditorTheme = { borderColor: (s: string) => theme.fg("border", s), selectList: getSelectListTheme() };
2037
+ const editor = new Editor(tui, editorTheme);
2038
+ editor.focused = true;
2039
+ editor.setText(initialText);
2040
+ let disposed = false;
2041
+ const notice = new Text("");
2042
+ const showNotice = (message: string, color: "success" | "error") => {
2043
+ if (disposed) return;
2044
+ notice.setText(theme.fg(color, message));
2045
+ tui.requestRender();
2046
+ };
2047
+ editor.onChange = () => notice.setText("");
2048
+
2049
+ const container = new Container();
2050
+ container.addChild(new Text(theme.fg("accent", theme.bold("Bro · advisor steer"))));
2051
+ container.addChild(new Text(theme.fg("dim", "One persistent steering brief the advisor always sees — never sent to the main model.")));
2052
+ container.addChild(editor);
2053
+ container.addChild(notice);
2054
+ container.addChild(new Text(theme.fg("dim", "Ctrl+S save · Enter newline · Ctrl+K clear · Ctrl+C copy · Esc close")));
2055
+
2056
+ return {
2057
+ render: (w: number) => {
2058
+ const inner = Math.max(1, w - 4);
2059
+ const border = (left: string, right: string) => theme.fg("border", left + "─".repeat(inner + 2) + right);
2060
+ return [border("┌", "┐"), ...container.render(inner).map((line) => {
2061
+ const text = truncateToWidth(line, inner, "");
2062
+ return theme.fg("border", "│") + " " + text + " ".repeat(Math.max(0, inner - visibleWidth(text))) + " " + theme.fg("border", "│");
2063
+ }), border("└", "┘")];
2064
+ },
2065
+ invalidate: () => container.invalidate(),
2066
+ handleInput: (data: string) => {
2067
+ if (matchesKey(data, "escape")) {
2068
+ disposed = true;
2069
+ done(undefined);
2070
+ return;
2071
+ }
2072
+ if (matchesKey(data, "ctrl+s")) {
2073
+ try {
2074
+ onSave(editor.getExpandedText());
2075
+ showNotice("Saved", "success");
2076
+ } catch (error) {
2077
+ showNotice(`Save failed: ${errorMessage(error)}`, "error");
2078
+ }
2079
+ return;
2080
+ }
2081
+ if (matchesKey(data, "ctrl+k")) {
2082
+ try {
2083
+ onClear();
2084
+ editor.setText("");
2085
+ showNotice("Cleared", "success");
2086
+ } catch (error) {
2087
+ showNotice(`Clear failed: ${errorMessage(error)}`, "error");
2088
+ }
2089
+ return;
2090
+ }
2091
+ if (matchesKey(data, "ctrl+c")) {
2092
+ const text = editor.getExpandedText();
2093
+ void Promise.resolve()
2094
+ .then(() => copy(text))
2095
+ .then(() => showNotice("Copied", "success"))
2096
+ .catch((error) => showNotice(`Copy failed: ${errorMessage(error)}`, "error"));
2097
+ return;
2098
+ }
2099
+ if (matchesKey(data, "enter")) {
2100
+ editor.insertTextAtCursor("\n");
2101
+ tui.requestRender();
2102
+ return;
2103
+ }
2104
+ editor.handleInput(data);
2105
+ tui.requestRender();
2106
+ },
2107
+ dispose: () => { disposed = true; },
2108
+ };
2109
+ };
2110
+ }
2111
+
2112
+ export async function showAdvisorSteerModal(ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> {
2113
+ if (ctx.mode !== "tui") {
2114
+ ctx.ui.notify("Use /bro advisor-steer in Pi's interactive UI.", "warning");
2115
+ return;
2116
+ }
2117
+ const { steering } = resolveAdvisorState(ctx.sessionManager.getBranch());
2118
+ await ctx.ui.custom<void>(
2119
+ createAdvisorSteerModal(
2120
+ steering,
2121
+ (text) => pi.appendEntry(ADVISOR_STEERING_ENTRY, { text }),
2122
+ () => pi.appendEntry(ADVISOR_STEERING_ENTRY, { text: "" }),
2123
+ ),
2124
+ {
2125
+ overlay: true,
2126
+ overlayOptions: { width: "78%", minWidth: 48, maxHeight: "60%", anchor: "top-center", margin: { top: 1, left: 2, right: 2 } },
2127
+ },
2128
+ );
2129
+ }
2130
+
2131
+ export function helpText(settings?: BroSettings, settingsError?: string): string {
2132
+ const overrideLines = settings
2133
+ ? CAPABILITIES.map((capability) => {
2134
+ const override = capabilityOverride(settings, capability);
2135
+ return override
2136
+ ? `- **${CAPABILITY_LABELS[capability]} override:** \`${override.model}\`${override.effort === "default" ? "" : ` (${override.effort})`}`
2137
+ : undefined;
2138
+ }).filter((line): line is string => line !== undefined)
2139
+ : [];
1071
2140
  const settingsSummary = settings
1072
- ? `- **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}`
2141
+ ? `- **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")}` : ""}`
1073
2142
  : `Bro could not read its settings: ${settingsError}\n\nRun \`/bro doctor\` for setup help.`;
1074
2143
  return `# Bro
1075
2144
 
@@ -1092,14 +2161,26 @@ Press **R** to simplify the captured source again. Run a new \`/bro text\`, \`/b
1092
2161
 
1093
2162
  - \`/bro doctor\` — check settings, Agy, account, model, effort, and mode
1094
2163
  - \`/bro usage [--provider agy]\` — show current Agy limits
1095
- - \`/bro model [id]\` — view or choose the Agy model
1096
- - \`/bro effort [low|medium|high]\` — view or choose reasoning effort
2164
+ - \`/bro model [id]\` — view or choose the shared default Agy model
2165
+ - \`/bro effort [low|medium|high]\` — view or choose the shared default reasoning effort
1097
2166
  - \`/bro mode [brief|balanced|faithful]\` — view or choose explanation mode
2167
+ - \`/bro config\` — open an interactive settings screen for the shared default model/effort, explain mode, show turns, and per-capability (explain/show/btw/advisor) model and effort overrides. Changes save immediately; Esc on a picker cancels without changing anything, Esc on the screen closes it and keeps whatever was already saved.
2168
+
2169
+ \`/bro model\` and \`/bro effort\` always change the shared default that explain, show, btw, and advisor fall back to when they have no override. Use \`/bro config\` to give one of them its own model or effort.
1098
2170
 
1099
2171
  ## Side conversation
1100
2172
 
1101
2173
  - \`/bro btw [--fresh] [--full] [question]\` — open a side conversation. Sandboxed (read-only) by default; add \`--full\` to let it read and edit the workspace, and \`--fresh\` to start without main-session context. Inside the side thread, type questions and press Enter (empty Enter re-asks); \`/copy\` copies the latest answer to the main editor without submitting (use \`/copy!\` to replace an existing draft), \`/copy-all\` the full thread, \`/retry\` re-asks the last question, and \`/clear\` resets the thread. Esc closes.
1102
2174
 
2175
+ ## Advisor
2176
+
2177
+ - \`bro_advisor\` — a tool the executor agent can voluntarily call mid-task for a second opinion from a fresh Agy process before or after a non-trivial decision. It is registered like any other tool and has no on/off switch of its own; whether the executor can actually call it depends entirely on this host's own tool restrictions
2178
+ - \`/bro advisor\` — a quick notice of whether \`bro_advisor\` is available right now, pointing at \`/bro config\`, \`/bro advisor-steer\`, and \`/bro doctor\`
2179
+ - \`/bro advisor-steer\` — open an editor for one persistent steering brief the advisor always sees. **Ctrl+S** saves, **Enter**/**Shift+Enter** insert newlines, **Ctrl+K** clears the saved brief and draft, **Ctrl+C** copies the full draft, and **Esc** closes without saving unsaved edits
2180
+ - \`/bro doctor\` — the full advisor diagnostic: whether this host exposes and activates \`bro_advisor\`, its resolved model/effort, steering presence, and the Agy compatibility floor
2181
+
2182
+ Each consultation is a fresh, standalone Agy process — never resumed, never looping, never automatically triggered. Bro captures the context snapshot (system instructions, active tools, and the conversation so far) automatically; the executor never has to assemble one. The advisor has real tool access in the workspace, running with permissions auto-approved, so it can verify claims itself — it only ever returns advice, and the executor stays responsible for any actual change. The steering brief persists in the session (not sent to the model) and is restored on resume or reload; forking a session inherits it, and edits after the fork are independent of the original branch.
2183
+
1103
2184
  ## Current settings
1104
2185
 
1105
2186
  ${settingsSummary}
@@ -1132,6 +2213,7 @@ Bro temporarily captures mouse input while the modal is open. Native mouse selec
1132
2213
  - Show draws only what already happened in this session — the conversation text of the last few turns, with tool calls, tool results, reasoning, and images always omitted — and cannot read the repository or other files on its own. On a remote or headless session with no display, pressing **O** reports a failure instead of opening the diagram.
1133
2214
  - Show reflects what was reported in the conversation, not independent verification against the actual code or system state.
1134
2215
  - Btw threads are memory-only and do not survive reloads or restarts. A turn is capped at 2 minutes in sandbox mode and 10 minutes in full mode; the side conversation resumes through Agy's \`--conversation\` support.
2216
+ - Advisor consultations run with real tool access and auto-approved permissions (\`--dangerously-skip-permissions\`) — there is no enforced read-only isolation, only the advisor's own instructions to advise rather than implement. On invocation failure (not a completed answer), Bro retries with the identical snapshot, steering, and question: once after 5 seconds, once more after 10 seconds, then returns Agy's own diagnostic as the failure.
1135
2217
 
1136
2218
  ## Privacy and safety
1137
2219
 
@@ -1144,6 +2226,8 @@ For webpages, it connects directly to the site without browser cookies; the site
1144
2226
 
1145
2227
  Usage and Doctor checks contact Agy but do not send source text or run a model turn. Pressing **C** sends the explanation to your system clipboard.
1146
2228
 
2229
+ Each advisor consultation sends the executor's system instructions, active tool list, ordered conversation (including tool calls and results, since the advisor needs to verify claims), your steering brief, and the executor's optional question to Agy and your model provider; the advisor process itself can read and edit the workspace with no permission prompts. The steering brief and activation state are stored as session-only extension data — never added to the main conversation Pi or the model sees.
2230
+
1147
2231
  ## Custom prompt
1148
2232
 
1149
2233
  Create or edit \`${PROMPT_FILE}\` and include \`{{response}}\` exactly once. Bro reads it on the next explanation and never modifies it. Existing valid custom prompts continue working unchanged.
@@ -1475,6 +2559,18 @@ export function resolveBtwThread(existing: BtwThread | undefined, parsed: { fres
1475
2559
  return !existing || startFresh ? { turns: [], full: targetFull } : existing;
1476
2560
  }
1477
2561
 
2562
+ export function formatBtwTranscript(turns: readonly BtwTurn[]): string {
2563
+ return turns
2564
+ .map((turn) => {
2565
+ const question = turn.question.split(/\r?\n/).map((line) => (line ? `> ${line}` : ">")).join("\n");
2566
+ // A turn aborted mid-stream can end inside an unclosed code fence, which would swallow the
2567
+ // separator and every later turn; close it so each turn renders as its own block.
2568
+ const answer = (turn.answer.match(/^```/gm)?.length ?? 0) % 2 === 1 ? `${turn.answer}\n\`\`\`` : turn.answer;
2569
+ return `> **You**\n>\n${question}\n\n**Bro**\n\n${answer}`;
2570
+ })
2571
+ .join("\n\n---\n\n");
2572
+ }
2573
+
1478
2574
  export function parseBtwAgyLine(line: string): { delta?: string; result?: string; conversationId?: string; error?: string } {
1479
2575
  let event: AgyEvent;
1480
2576
  try {
@@ -1791,7 +2887,7 @@ async function openBtwModal(
1791
2887
  let closed = false;
1792
2888
  let controller: AbortController | undefined;
1793
2889
 
1794
- const transcript = () => thread.turns.map((turn) => `## you\n${turn.question}\n\n${turn.answer}`).join("\n\n");
2890
+ const transcript = () => formatBtwTranscript(thread.turns);
1795
2891
 
1796
2892
  const close = () => {
1797
2893
  if (closed) return;
@@ -1829,7 +2925,7 @@ async function openBtwModal(
1829
2925
  const settings = await readSettings();
1830
2926
  const result = await runBtwTurn(
1831
2927
  buildBtwPrompt(context, question),
1832
- agySelection(settings),
2928
+ agySelection(capabilityPair(settings, "btw")),
1833
2929
  { full: thread.full, cwd: ctx.cwd, conversationId: thread.conversationId },
1834
2930
  turnController.signal,
1835
2931
  (partial) => {
@@ -1934,11 +3030,112 @@ export default async function bro(pi: ExtensionAPI) {
1934
3030
  if (result.source) lastResult = { source: result.source, text: result.text };
1935
3031
  };
1936
3032
 
1937
- pi.on("session_start", async () => {
3033
+ pi.on("session_start", async (_event, _ctx) => {
1938
3034
  lastResult = undefined;
1939
3035
  btwThread = undefined;
1940
3036
  });
1941
3037
 
3038
+ pi.registerTool({
3039
+ name: ADVISOR_TOOL_NAME,
3040
+ label: "Bro advisor",
3041
+ description:
3042
+ "Consult a fresh, independent Agy process 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.",
3043
+ promptSnippet: "bro_advisor({question?}): consult a fresh Agy process for a second opinion; it investigates the workspace itself and returns advice",
3044
+ promptGuidelines: [
3045
+ "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 Agy process.",
3046
+ "question is optional — never delay a call to first prepare a summary or evidence; Bro captures your context automatically.",
3047
+ "Any human-set steering priorities are applied automatically by the advisor; you don't need to relay or repeat them.",
3048
+ "The 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.",
3049
+ ],
3050
+ parameters: Type.Object({
3051
+ question: Type.Optional(
3052
+ Type.String({ description: "Optional question to focus the consultation on. Leave unset to ask for general advice on the current state." }),
3053
+ ),
3054
+ }),
3055
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
3056
+ const state = resolveAdvisorState(ctx.sessionManager.getBranch());
3057
+ const settings = await readSettings();
3058
+ const selection = agySelection(capabilityPair(settings, "advisor"));
3059
+ const snapshot = buildAdvisorSnapshot(ctx, pi);
3060
+ const prompt = buildAdvisorPrompt(state.steering, snapshot.text, params.question);
3061
+ let lastAttempt: AdvisorToolDetails = { status: "investigating", attempt: 1, of: 3, elapsedMs: 0 };
3062
+ try {
3063
+ const run = await runAdvisorWithRetries(
3064
+ prompt,
3065
+ selection,
3066
+ ctx.cwd,
3067
+ signal ?? new AbortController().signal,
3068
+ undefined,
3069
+ undefined,
3070
+ (details) => {
3071
+ lastAttempt = details;
3072
+ const label = advisorAttemptLabel(lastAttempt);
3073
+ onUpdate?.({
3074
+ content: [{ type: "text", text: label }],
3075
+ details: lastAttempt,
3076
+ });
3077
+ // A host's tool-card renderer may replace onUpdate's partial renderResult with its
3078
+ // own generic placeholder for the whole run (observed with pi-cc-extensions' default
3079
+ // mode, which hardcodes "Pending…" for every isPartial tool result unless the tool
3080
+ // is opted into its excludeRenderers list). The footer/status bar is a separate,
3081
+ // host-owned surface that no such tool-card override touches, so mirror progress
3082
+ // there too as a fallback the user can see regardless of that renderer choice.
3083
+ ctx.ui.setStatus(ADVISOR_STATUS_BAR_KEY, label);
3084
+ },
3085
+ );
3086
+ const effort = selection.effort ?? "model default";
3087
+ const omissions = `image/reasoning bodies omitted when present; ${snapshot.hadCompaction ? "Pi compaction summaries replace earlier turns" : "no Pi compaction summary present"}`;
3088
+ const details: AdvisorToolDetails = {
3089
+ ...lastAttempt,
3090
+ status: "done",
3091
+ attempt: run.attempts,
3092
+ model: selection.model,
3093
+ effort,
3094
+ durationMs: run.durationMs,
3095
+ cwd: ctx.cwd,
3096
+ steeringIncluded: Boolean(state.steering.trim()),
3097
+ snapshotChars: snapshot.text.length,
3098
+ broTruncated: false,
3099
+ omissions,
3100
+ // run.activityCount is the exact final total; lastAttempt's may lag behind by up to
3101
+ // the activity throttle window.
3102
+ activityCount: run.activityCount,
3103
+ };
3104
+ const header = `Bro advisor · model: ${selection.model} · effort: ${effort} · ${run.attempts} attempt${run.attempts === 1 ? "" : "s"} · ${Math.ceil(run.durationMs / 1_000)}s`;
3105
+ const context = `Context · cwd: ${JSON.stringify(ctx.cwd)} · steering: ${details.steeringIncluded ? "included" : "none"} · snapshot: ${snapshot.text.length} chars · Bro truncation: none · omissions: ${omissions}`;
3106
+ return { content: [{ type: "text", text: `${header}\n${context}\n\n${run.advice}` }], details };
3107
+ } finally {
3108
+ ctx.ui.setStatus(ADVISOR_STATUS_BAR_KEY, undefined);
3109
+ }
3110
+ },
3111
+ renderCall(args, theme) {
3112
+ const question = args.question?.trim();
3113
+ return new Text(theme.fg("dim", question ? `Bro advisor · ${question}` : "Bro advisor · consulting…"));
3114
+ },
3115
+ renderResult(result, options, theme) {
3116
+ const details = result.details as AdvisorToolDetails | undefined;
3117
+ if (options.isPartial) {
3118
+ const label = new Text(theme.fg("dim", details ? advisorAttemptLabel(details) : "Bro advisor · investigating…"));
3119
+ // Expanded + running: the simplest native tail -- one dim line per bounded recent
3120
+ // activity label, nothing fancier than the compact renderer above.
3121
+ if (!options.expanded || !details?.activity?.length) return label;
3122
+ const container = new Container();
3123
+ container.addChild(label);
3124
+ for (const line of details.activity) {
3125
+ container.addChild(new Text(theme.fg("dim", ` ${advisorActivityPreview(line)}`)));
3126
+ }
3127
+ return container;
3128
+ }
3129
+ if (!options.expanded && details?.status === "done") return new Text(theme.fg("accent", advisorAttemptLabel(details)));
3130
+ const text = result.content[0]?.type === "text" ? result.content[0].text : "";
3131
+ if (!options.expanded) {
3132
+ const firstLine = text.split("\n").find((line) => line.trim()) ?? "(no advice)";
3133
+ return new Text(`${theme.fg("accent", "Bro advisor")}${theme.fg("dim", ` · ${firstLine}`)}`);
3134
+ }
3135
+ return new Markdown(text, 0, 0, getMarkdownTheme());
3136
+ },
3137
+ });
3138
+
1942
3139
  pi.registerCommand("bro", {
1943
3140
  description: "Explain replies, pasted text, documents, and webpages, draw recent session turns, or open a sandboxed side conversation with /bro btw",
1944
3141
  getArgumentCompletions: (prefix) => {
@@ -2049,7 +3246,7 @@ export default async function bro(pi: ExtensionAPI) {
2049
3246
  loadingText: "Checking Bro setup…",
2050
3247
  retryable: true,
2051
3248
  retryLabel: "check again",
2052
- run: async (signal) => ({ text: await doctorReport(pi, signal) }),
3249
+ run: async (signal) => ({ text: await doctorReport(pi, ctx, signal) }),
2053
3250
  });
2054
3251
  } catch (error) {
2055
3252
  ctx.ui.notify(errorMessage(error), "error");
@@ -2235,6 +3432,45 @@ export default async function bro(pi: ExtensionAPI) {
2235
3432
  return;
2236
3433
  }
2237
3434
 
3435
+ if (action === "config") {
3436
+ if (parts.length !== 1) {
3437
+ ctx.ui.notify("Use /bro config.", "warning");
3438
+ return;
3439
+ }
3440
+ await showBroConfigModal(ctx, pi);
3441
+ return;
3442
+ }
3443
+
3444
+ if (action === "advisor") {
3445
+ // bro_advisor has no on/off/status controls of its own anymore -- it is always registered
3446
+ // and gated only by this host's own tool restrictions. An old on/off/status argument gets
3447
+ // an actionable notice pointing at the commands that replaced it, not a silent no-op.
3448
+ if (parts.length > 1) {
3449
+ ctx.ui.notify(
3450
+ "/bro advisor no longer has on/off/status controls -- bro_advisor is always registered and available whenever this host exposes and activates it. Use /bro config for its model/effort, /bro advisor-steer for its steering brief, or /bro doctor for full diagnostics.",
3451
+ "warning",
3452
+ );
3453
+ return;
3454
+ }
3455
+ const active = pi.getActiveTools().includes(ADVISOR_TOOL_NAME);
3456
+ ctx.ui.notify(
3457
+ active
3458
+ ? "Bro advisor is available -- the executor agent can call bro_advisor. Use /bro config for its model/effort, /bro advisor-steer for its steering brief, or /bro doctor for full diagnostics."
3459
+ : "Bro advisor is unavailable in this runtime. Use /bro doctor for full diagnostics, /bro config for its model/effort, or /bro advisor-steer for its steering brief.",
3460
+ active ? "info" : "warning",
3461
+ );
3462
+ return;
3463
+ }
3464
+
3465
+ if (action === "advisor-steer") {
3466
+ if (parts.length !== 1) {
3467
+ ctx.ui.notify("Use /bro advisor-steer.", "warning");
3468
+ return;
3469
+ }
3470
+ await showAdvisorSteerModal(ctx, pi);
3471
+ return;
3472
+ }
3473
+
2238
3474
  if (action === "help") {
2239
3475
  if (parts.length !== 1) {
2240
3476
  ctx.ui.notify("Use /bro help.", "warning");