pi-bro 0.16.0 → 0.18.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 (6) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +206 -222
  3. package/backend.ts +314 -23
  4. package/bro.ts +371 -396
  5. package/package.json +2 -2
  6. package/prompt.ts +17 -5
package/bro.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  agySelection,
23
23
  advisorFlagErrorHint,
24
24
  CLAUDE_EFFORTS,
25
+ GROK_EFFORTS,
25
26
  backendSupports,
26
27
  execute as executeBackend,
27
28
  parseBtwAgyLine,
@@ -59,16 +60,20 @@ type TuiLike = {
59
60
  };
60
61
  type ModalKind = "loading" | "streaming" | "result" | "help" | "empty" | "error";
61
62
  type BroSource = { text: string; label?: string };
62
- type BroResult = { source: BroSource; text: string };
63
- type ModalResult = { source?: BroSource; text: string; htmlPath?: string };
63
+ type BroResult = { source: BroSource; text: string; model?: string };
64
+ type ModalResult = { source?: BroSource; text: string; htmlPath?: string; model?: string };
64
65
  type BtwTurn = { question: string; answer: string };
65
- type BtwThread = { turns: BtwTurn[]; conversationId?: string; full: boolean };
66
+ // `context` keeps the main-session seed so a fresh native session can be reseeded with the whole
67
+ // thread; `sessionFull` is the access mode the native session last ran in.
68
+ type BtwThread = { turns: BtwTurn[]; conversationId?: string; full: boolean; backend?: BackendName; model?: string; context?: string; sessionFull?: boolean };
66
69
  const EFFORTS = ["default", "low", "medium", "high"] as const;
67
- const BACKENDS = ["agy", "claude"] as const;
70
+ const BACKENDS = ["agy", "claude", "grok"] as const;
68
71
  type BackendName = (typeof BACKENDS)[number];
69
72
  type BroEffort = "default" | "low" | "medium" | "high" | "xhigh" | "max";
70
73
  type AgyEffort = Exclude<BroEffort, "default" | "xhigh" | "max">;
71
74
  type ClaudeEffort = (typeof CLAUDE_EFFORTS)[number];
75
+ export { GROK_EFFORTS };
76
+ type GrokEffort = (typeof GROK_EFFORTS)[number];
72
77
  // "advisor" is a real capability (command, Agy invocation, Doctor check) like the other three;
73
78
  // see docs/plans/2026-09-19-bro-advisor-design.md. All four share one model/effort resolution,
74
79
  // override, and Doctor-check path via this single list -- there is no configuration-only tier.
@@ -90,6 +95,11 @@ export const CLAUDE_MODELS = [
90
95
  { id: "sonnet", label: "Claude Sonnet" },
91
96
  { id: "opus", label: "Claude Opus" },
92
97
  ] as const;
98
+ export const GROK_MODELS = [
99
+ { id: "grok-4.7", label: "Grok 4.7" },
100
+ { id: "grok-4.7-build-fast", label: "Grok 4.7 Build Fast" },
101
+ ] as const;
102
+ export { backendSupports as supportsBackend };
93
103
  type AgyModelFamily = {
94
104
  id: string;
95
105
  label: string;
@@ -114,12 +124,9 @@ const COMMANDS = [
114
124
  { value: "url", label: "url", description: "Explain a public webpage" },
115
125
  { value: "open", label: "open", description: "Reopen the last explanation" },
116
126
  { value: "doctor", label: "doctor", description: "Check whether Bro is ready" },
117
- { value: "usage", label: "usage", description: "Show current Agy usage" },
118
- { value: "model", label: "model", description: "View or choose the shared default Agy model" },
119
- { value: "effort", label: "effort", description: "View or choose the shared default reasoning effort" },
120
127
  { value: "show", label: "show", description: "Draw what happened in recent session turns as shapes" },
121
128
  { value: "mode", label: "mode", description: "View or choose explanation mode (brief, balanced, faithful)" },
122
- { value: "btw", label: "btw", description: "Open a side conversation (sandboxed by default; --full edits files)" },
129
+ { value: "btw", label: "btw", description: "Open a side conversation (starts conversation-only; /mode toggles full permission)" },
123
130
  { value: "config", label: "config", description: "Configure shared defaults and per-capability model/effort overrides" },
124
131
  { value: "advisor", label: "advisor", description: "Check whether the executor's advisor tool is available right now" },
125
132
  { value: "advisor-steer", label: "advisor-steer", description: "View, edit, save, or clear the advisor's persistent steering brief" },
@@ -501,7 +508,7 @@ export async function extractWebPage(input: string, signal?: AbortSignal): Promi
501
508
 
502
509
  function parseBackend(value: unknown, context: string): BackendName {
503
510
  if (typeof value !== "string" || !BACKENDS.some((backend) => backend === value)) {
504
- throw new Error(`${context} backend must be "agy" or "claude".`);
511
+ throw new Error(`${context} backend must be "agy", "claude", or "grok".`);
505
512
  }
506
513
  return value as BackendName;
507
514
  }
@@ -514,6 +521,10 @@ function isClaudeEffort(effort: unknown): effort is ClaudeEffort | "default" {
514
521
  return effort === "default" || CLAUDE_EFFORTS.some((item) => item === effort);
515
522
  }
516
523
 
524
+ function isGrokEffort(effort: unknown): effort is GrokEffort | "default" {
525
+ return effort === "default" || GROK_EFFORTS.some((item) => item === effort);
526
+ }
527
+
517
528
  function parseModelEffortPair(value: unknown, context: string): ModelEffortPair {
518
529
  if (!isRecord(value) || typeof value.model !== "string" || !value.model.trim()) {
519
530
  throw new Error(`${context} must contain a model and effort set to "default", "low", "medium", or "high".`);
@@ -525,6 +536,12 @@ function parseModelEffortPair(value: unknown, context: string): ModelEffortPair
525
536
  }
526
537
  return { backend, model: value.model.trim(), effort: value.effort };
527
538
  }
539
+ if (backend === "grok") {
540
+ if (!isGrokEffort(value.effort)) {
541
+ throw new Error(`${context} must contain a model and effort set to "default", "low", "medium", "high", or "xhigh".`);
542
+ }
543
+ return { backend, model: value.model.trim(), effort: value.effort };
544
+ }
528
545
  if (!isAgyEffort(value.effort)) {
529
546
  throw new Error(`${context} must contain a model and effort set to "default", "low", "medium", or "high".`);
530
547
  }
@@ -573,12 +590,14 @@ export function parseBroSettings(value: unknown): BroSettings {
573
590
  throw new Error('Settings must contain a model and effort set to "default", "low", "medium", or "high".');
574
591
  }
575
592
  const backend = value.backend === undefined ? undefined : parseBackend(value.backend, "Settings");
576
- const effortOk = backend === "claude" ? isClaudeEffort(value.effort) : isAgyEffort(value.effort);
593
+ const effortOk = backend === "claude" ? isClaudeEffort(value.effort) : backend === "grok" ? isGrokEffort(value.effort) : isAgyEffort(value.effort);
577
594
  if (!effortOk) {
578
595
  throw new Error(
579
596
  backend === "claude"
580
597
  ? 'Settings must contain a model and effort set to "default", "low", "medium", "high", "xhigh", or "max".'
581
- : 'Settings must contain a model and effort set to "default", "low", "medium", or "high".',
598
+ : backend === "grok"
599
+ ? 'Settings must contain a model and effort set to "default", "low", "medium", "high", or "xhigh".'
600
+ : 'Settings must contain a model and effort set to "default", "low", "medium", or "high".',
582
601
  );
583
602
  }
584
603
  const mode = value.mode === undefined ? DEFAULT_BRO_MODE : parseBroMode(value.mode);
@@ -630,8 +649,8 @@ export function resolveModelEffort(
630
649
  pair: ModelEffortPair,
631
650
  families: AgyModelFamily[],
632
651
  ): { pair: ModelEffortPair; family?: AgyModelFamily } {
633
- // Claude selections never resolve through the Agy catalog; they pass through untouched.
634
- if (pair.backend === "claude") return { pair };
652
+ // Claude/Grok selections never resolve through the Agy catalog; they pass through untouched.
653
+ if (pair.backend === "claude" || pair.backend === "grok") return { pair };
635
654
  const family = families.find((item) => item.id === pair.model || item.variants.some((variant) => variant.id === pair.model));
636
655
  if (!family) return { pair };
637
656
  const variant = family.variants.find((item) => item.id === pair.model);
@@ -661,19 +680,31 @@ export function resolveClaudeModel(input: string): string {
661
680
  return alias ? alias.id : trimmed;
662
681
  }
663
682
 
683
+ // Grok seeds resolve to themselves; any other non-empty string passes through
684
+ // untouched as an explicit user-entered model ID (installed `grok models` confirms).
685
+ export function resolveGrokModel(input: string): string {
686
+ const trimmed = input.trim();
687
+ if (!trimmed) throw new Error("Grok model must be a non-empty model ID (grok-4.7, grok-4.7-build-fast, or an explicit model ID).");
688
+ return trimmed;
689
+ }
690
+
664
691
  // Routes one capability's whole pair to its backend execution selection. Agy keeps the
665
- // existing model/effort split; Claude carries the model plus an optional effort
692
+ // existing model/effort split; Claude/Grok carry the model plus an optional effort
666
693
  // ("default" means the CLI's own default and is omitted). Throws for an effort the
667
694
  // resolved backend does not support instead of silently sending a mismatched pair.
668
695
  export function selectionForCapability(settings: BroSettings, capability: Capability): BackendSelection {
669
696
  const pair = capabilityPair(settings, capability);
670
- if (capabilityBackend(settings, capability) === "claude") {
671
- if (!isClaudeEffort(pair.effort)) {
672
- throw new Error(`\`${pair.effort}\` is not supported on the Claude backend. Run \`/bro config\` to fix this.`);
697
+ const backend = capabilityBackend(settings, capability);
698
+ if (backend === "claude" || backend === "grok") {
699
+ const valid = backend === "claude" ? isClaudeEffort(pair.effort) : isGrokEffort(pair.effort);
700
+ if (!valid) {
701
+ throw new Error(`\`${pair.effort}\` is not supported on the ${backend === "claude" ? "Claude" : "Grok"} backend. Run \`/bro config\` to fix this.`);
673
702
  }
674
- return pair.effort === "default"
675
- ? { backend: "claude", model: pair.model }
676
- : { backend: "claude", model: pair.model, effort: pair.effort };
703
+ return (
704
+ pair.effort === "default"
705
+ ? { backend, model: pair.model }
706
+ : { backend, model: pair.model, effort: pair.effort }
707
+ ) as BackendSelection;
677
708
  }
678
709
  if (!isAgyEffort(pair.effort)) {
679
710
  throw new Error(`\`${pair.effort}\` is not supported on the Agy backend. Run \`/bro config\` to fix this.`);
@@ -681,6 +712,12 @@ export function selectionForCapability(settings: BroSettings, capability: Capabi
681
712
  return agySelection({ model: pair.model, effort: pair.effort });
682
713
  }
683
714
 
715
+ // The modal label for the exact selection a request runs with: model plus effort, where an
716
+ // omitted effort (the model's own default) reads simply "default".
717
+ export function selectionLabel(selection: BackendSelection): string {
718
+ return `${selection.model} · ${selection.effort ?? "default"}`;
719
+ }
720
+
684
721
  function resolveCapabilitySettings(
685
722
  settings: BroSettings,
686
723
  capability: Capability,
@@ -912,6 +949,27 @@ async function checkClaudeAuth(pi: ExtensionAPI, signal: AbortSignal): Promise<s
912
949
  }
913
950
  }
914
951
 
952
+ async function checkGrokVersion(pi: ExtensionAPI, signal: AbortSignal): Promise<string> {
953
+ const runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
954
+ try {
955
+ const result = await pi.exec("grok", ["--version"], { cwd: runDirectory, signal, timeout: 10_000 });
956
+ if (signal.aborted) throw new Error("Canceled.");
957
+ if (result.killed || result.code !== 0) {
958
+ const detail = result.stderr.trim() || result.stdout.trim();
959
+ throw new Error(
960
+ detail
961
+ ? `Grok could not start: ${detail}\n\nRun \`/bro doctor\` for setup help.`
962
+ : "Grok could not start. Make sure Grok is installed and on PATH, then run `/bro doctor`.",
963
+ );
964
+ }
965
+ const version = result.stdout.trim() || result.stderr.trim();
966
+ if (!version) throw new Error("Grok returned no version information. Update Grok, then run `/bro doctor` again.");
967
+ return version;
968
+ } finally {
969
+ await rm(runDirectory, { recursive: true, force: true });
970
+ }
971
+ }
972
+
915
973
  async function checkAgyUsage(pi: ExtensionAPI, signal: AbortSignal): Promise<string> {
916
974
  const runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
917
975
  try {
@@ -958,10 +1016,11 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
958
1016
  fail("Prompt", error);
959
1017
  }
960
1018
 
961
- // Probe only the backends some feature actually selects: a Claude-only setup never
1019
+ // Probe only the backends some feature actually selects: a Claude/Grok-only setup never
962
1020
  // requires Agy to be installed, and vice versa.
963
1021
  const agyInUse = !settings || capabilityBackend(settings, "explain") === "agy" || capabilityBackend(settings, "show") === "agy" || capabilityBackend(settings, "btw") === "agy" || capabilityBackend(settings, "advisor") === "agy" || (settings.backend ?? "agy") === "agy";
964
1022
  const claudeInUse = !!settings && (capabilityBackend(settings, "explain") === "claude" || capabilityBackend(settings, "show") === "claude" || capabilityBackend(settings, "btw") === "claude" || capabilityBackend(settings, "advisor") === "claude" || settings.backend === "claude");
1023
+ const grokInUse = !!settings && (capabilityBackend(settings, "explain") === "grok" || capabilityBackend(settings, "show") === "grok" || capabilityBackend(settings, "btw") === "grok" || capabilityBackend(settings, "advisor") === "grok" || settings.backend === "grok");
965
1024
 
966
1025
  let agyStarted = false;
967
1026
  if (agyInUse) {
@@ -1014,12 +1073,35 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
1014
1073
  pass("Claude", "not probed — no feature selects the Claude backend");
1015
1074
  }
1016
1075
 
1076
+ // Version only, never a model request. No Grok auth probe exists, so a passing
1077
+ // version says the CLI starts — it says nothing about auth or connectivity.
1078
+ if (grokInUse) {
1079
+ try {
1080
+ pass("Grok", await checkGrokVersion(pi, signal));
1081
+ } catch (error) {
1082
+ if (signal.aborted) throw error;
1083
+ fail("Grok", error);
1084
+ }
1085
+ pass("Grok auth", "unverified — no auth probe exists (version does not imply auth or connectivity)");
1086
+ } else {
1087
+ pass("Grok", "not probed — no feature selects the Grok backend");
1088
+ }
1089
+
1017
1090
  if (settings) {
1018
1091
  const defaultBackend = settings.backend ?? "agy";
1019
1092
  if (defaultBackend === "claude") {
1020
1093
  pass("Selected model", `claude \`${settings.model}\``);
1021
1094
  if (!isClaudeEffort(settings.effort)) {
1022
- fail("Reasoning effort", `\`${settings.effort}\` is unsupported. Run \`/bro effort\` to choose another.`);
1095
+ fail("Reasoning effort", `\`${settings.effort}\` is unsupported. Run \`/bro config\` to choose another.`);
1096
+ } else if (settings.effort === "default") {
1097
+ pass("Reasoning effort", "built into the selected model");
1098
+ } else {
1099
+ pass("Reasoning effort", settings.effort);
1100
+ }
1101
+ } else if (defaultBackend === "grok") {
1102
+ pass("Selected model", `grok \`${settings.model}\``);
1103
+ if (!isGrokEffort(settings.effort)) {
1104
+ fail("Reasoning effort", `\`${settings.effort}\` is unsupported. Run \`/bro config\` to choose another.`);
1023
1105
  } else if (settings.effort === "default") {
1024
1106
  pass("Reasoning effort", "built into the selected model");
1025
1107
  } else {
@@ -1028,7 +1110,7 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
1028
1110
  } else if (models) {
1029
1111
  const current = resolveCatalogSettings(settings, models);
1030
1112
  if (!current.family) {
1031
- fail("Selected model", `\`${settings.model}\` is unavailable. Run \`/bro model\` to choose another.`);
1113
+ fail("Selected model", `\`${settings.model}\` is unavailable. Run \`/bro config\` to choose another.`);
1032
1114
  } else {
1033
1115
  pass("Selected model", `agy \`${current.family.id}\``);
1034
1116
  const effort = current.settings.effort;
@@ -1037,7 +1119,7 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
1037
1119
  } else if (effort !== "default" && current.family.efforts.includes(effort as AgyEffort)) {
1038
1120
  pass("Reasoning effort", effort);
1039
1121
  } else {
1040
- fail("Reasoning effort", `\`${effort}\` is unsupported. Run \`/bro effort\` to choose another.`);
1122
+ fail("Reasoning effort", `\`${effort}\` is unsupported. Run \`/bro config\` to choose another.`);
1041
1123
  }
1042
1124
  }
1043
1125
  }
@@ -1048,10 +1130,6 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
1048
1130
  const backend = capabilityBackend(settings, capability);
1049
1131
  const pair = capabilityPair(settings, capability);
1050
1132
  if (backend === "claude") {
1051
- if (capability === "btw") {
1052
- fail(label, "`btw` is not supported on the Claude backend. Run `/bro config` to give it an Agy model.");
1053
- continue;
1054
- }
1055
1133
  if (!isClaudeEffort(pair.effort)) {
1056
1134
  fail(label, `\`${pair.effort}\` is unsupported for claude \`${pair.model}\`. Run \`/bro config\` to fix this.`);
1057
1135
  continue;
@@ -1064,6 +1142,23 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
1064
1142
  );
1065
1143
  continue;
1066
1144
  }
1145
+ if (backend === "grok") {
1146
+ if (!backendSupports("grok", capability)) {
1147
+ fail(label, `\`${capability}\` is not supported on the Grok backend for this capability. Run \`/bro config\` to give it another backend.`);
1148
+ continue;
1149
+ }
1150
+ if (!isGrokEffort(pair.effort)) {
1151
+ fail(label, `\`${pair.effort}\` is unsupported for grok \`${pair.model}\`. Run \`/bro config\` to fix this.`);
1152
+ continue;
1153
+ }
1154
+ pass(
1155
+ label,
1156
+ override
1157
+ ? `override grok \`${pair.model}\`${pair.effort === "default" ? "" : ` (${pair.effort})`}`
1158
+ : `grok \`${pair.model}\`${pair.effort === "default" ? "" : ` (${pair.effort})`} · using the shared default`,
1159
+ );
1160
+ continue;
1161
+ }
1067
1162
  if (!models) continue;
1068
1163
  const resolved = resolveCapabilitySettings(settings, capability, models);
1069
1164
  if (!resolved.family) {
@@ -1096,13 +1191,15 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
1096
1191
  pass("Advisor steering", resolveAdvisorState(ctx.sessionManager.getBranch()).steering.trim() ? "present" : "none");
1097
1192
  if (settings && capabilityBackend(settings, "advisor") === "claude") {
1098
1193
  pass("Advisor compatibility", "Claude backend — no Agy version floor applies");
1194
+ } else if (settings && capabilityBackend(settings, "advisor") === "grok") {
1195
+ pass("Advisor compatibility", "Grok backend — no Agy version floor applies; auth/connectivity unverified (no probe)");
1099
1196
  } else if (agyVersion && advisorAgyCompatible(agyVersion)) pass("Advisor compatibility", ADVISOR_COMPATIBILITY);
1100
1197
  else if (agyVersion) fail("Advisor compatibility", `installed \`${agyVersion}\`; requires Agy >=1.1.15. Run \`agy update\`.`);
1101
1198
 
1102
1199
  return `# Bro doctor\n\n${lines.join("\n")}\n\n**${failed ? "Bro needs attention." : "Bro is ready."}**\n\n${
1103
1200
  failed
1104
1201
  ? "Fix the failed items, then press **R** to check again."
1105
- : "No assistant response was sent and no model turn was run. Claude version/auth status does not imply connectivity."
1202
+ : "No assistant response was sent and no model turn was run. Version/auth checks do not imply connectivity (Grok auth is unverified — no probe exists)."
1106
1203
  }`;
1107
1204
  }
1108
1205
 
@@ -1295,8 +1392,9 @@ async function simplify(
1295
1392
  signal: AbortSignal,
1296
1393
  settings: BroSettings,
1297
1394
  onProgress?: (text: string) => void,
1298
- ): Promise<string> {
1299
- return runAgyText((await promptFor(response, settings.mode)).text, selectionForCapability(settings, "explain"), signal, onProgress);
1395
+ ): Promise<{ text: string; model: string }> {
1396
+ const selection = selectionForCapability(settings, "explain");
1397
+ return { text: await runAgyText((await promptFor(response, settings.mode)).text, selection, signal, onProgress), model: selectionLabel(selection) };
1300
1398
  }
1301
1399
 
1302
1400
  async function runShowExplanation(
@@ -1305,8 +1403,9 @@ async function runShowExplanation(
1305
1403
  signal: AbortSignal,
1306
1404
  settings: BroSettings,
1307
1405
  onProgress?: (text: string) => void,
1308
- ): Promise<string> {
1309
- return runAgyText(buildShowPrompt(transcript, steering), selectionForCapability(settings, "show"), signal, onProgress, "show");
1406
+ ): Promise<{ text: string; model: string }> {
1407
+ const selection = selectionForCapability(settings, "show");
1408
+ return { text: await runAgyText(buildShowPrompt(transcript, steering), selection, signal, onProgress, "show"), model: selectionLabel(selection) };
1310
1409
  }
1311
1410
 
1312
1411
  // Thin presentation-boundary wrapper around the shared backend: coalesces raw text progress to the
@@ -1688,7 +1787,7 @@ export function advisorAttemptLabel(details: AdvisorToolDetails): string {
1688
1787
  return `Bro advisor · ${details.model ?? "unknown model"} · ${details.attempt} attempt${details.attempt === 1 ? "" : "s"} · ${Math.ceil((details.durationMs ?? details.elapsedMs) / 1_000)}s`;
1689
1788
  }
1690
1789
  const latest = details.activity?.at(-1);
1691
- const backendLabel = details.backend === "claude" ? "Claude" : "Agy";
1790
+ const backendLabel = details.backend === "claude" ? "Claude" : details.backend === "grok" ? "Grok" : "Agy";
1692
1791
  // "last reported" + freshness, never a claim about what the backend is doing right now and never
1693
1792
  // "stalled" -- silence since lastActivityAt is not itself evidence of a stuck run.
1694
1793
  const activityLabel = latest
@@ -1708,9 +1807,10 @@ function showTurnsValues(current: number): string[] {
1708
1807
  // falsely healthy for an unavailable model), and flags a stored effort that isn't one of the
1709
1808
  // resolved family's supported efforts instead of silently showing it as if it were valid.
1710
1809
  function effortDisplay(resolved: { pair: ModelEffortPair; family?: AgyModelFamily }, backend: BackendName): string {
1711
- // Claude selections never touch the Agy catalog: the stored effort is valid exactly
1712
- // when it is one of the Claude levels (or "default" for the CLI's own default).
1810
+ // Claude/Grok selections never touch the Agy catalog: the stored effort is valid exactly
1811
+ // when it is one of that backend's levels (or "default" for the CLI's own default).
1713
1812
  if (backend === "claude") return isClaudeEffort(resolved.pair.effort) ? resolved.pair.effort : `${resolved.pair.effort} (unsupported)`;
1813
+ if (backend === "grok") return isGrokEffort(resolved.pair.effort) ? resolved.pair.effort : `${resolved.pair.effort} (unsupported)`;
1714
1814
  if (!resolved.family) return "unavailable";
1715
1815
  const fixed = !resolved.family.efforts.length;
1716
1816
  const valid = fixed ? resolved.pair.effort === "default" : resolved.family.efforts.includes(resolved.pair.effort as AgyEffort);
@@ -1718,7 +1818,7 @@ function effortDisplay(resolved: { pair: ModelEffortPair; family?: AgyModelFamil
1718
1818
  return fixed ? "fixed" : resolved.pair.effort;
1719
1819
  }
1720
1820
 
1721
- // Model-picker values that switch to the Claude backend carry a "claude:" prefix so one
1821
+ // Model-picker values that switch backend carry a "claude:"/"grok:" prefix so one
1722
1822
  // atomic picker commit changes backend+model together -- cancelling the picker (Esc)
1723
1823
  // leaves both unchanged via the existing submenu-cancel path.
1724
1824
  const CLAUDE_OPTION_PREFIX = "claude:";
@@ -1727,6 +1827,19 @@ function parseClaudeOption(value: string): string | undefined {
1727
1827
  ? value.slice(CLAUDE_OPTION_PREFIX.length)
1728
1828
  : undefined;
1729
1829
  }
1830
+ const GROK_OPTION_PREFIX = "grok:";
1831
+ function parseGrokOption(value: string): string | undefined {
1832
+ return value.startsWith(GROK_OPTION_PREFIX) && value.length > GROK_OPTION_PREFIX.length
1833
+ ? value.slice(GROK_OPTION_PREFIX.length)
1834
+ : undefined;
1835
+ }
1836
+ function parseBackendOption(value: string): { backend: "claude" | "grok"; id: string } | undefined {
1837
+ const claudeId = parseClaudeOption(value);
1838
+ if (claudeId !== undefined) return { backend: "claude", id: claudeId };
1839
+ const grokId = parseGrokOption(value);
1840
+ if (grokId !== undefined) return { backend: "grok", id: grokId };
1841
+ return undefined;
1842
+ }
1730
1843
 
1731
1844
  // Testable core: takes settings/catalog/persist as plain arguments so smoke tests can drive
1732
1845
  // the exact interaction (submenus, cancel, cycling, save failure) without a real Agy process
@@ -1764,30 +1877,38 @@ export function createConfigModal(
1764
1877
  value: family.id,
1765
1878
  label: `${family.label}${family.efforts.length ? "" : " · fixed effort"}`,
1766
1879
  })),
1767
- // Claude entries stay last so existing Agy keyboard navigation is unaffected.
1880
+ // Claude/Grok entries stay last so existing Agy keyboard navigation is unaffected.
1768
1881
  ...CLAUDE_MODELS.map((model) => ({
1769
1882
  value: `${CLAUDE_OPTION_PREFIX}${model.id}`,
1770
1883
  label: `${model.label} · claude`,
1771
1884
  })),
1885
+ ...GROK_MODELS.map((model) => ({
1886
+ value: `${GROK_OPTION_PREFIX}${model.id}`,
1887
+ label: `${model.label} · grok`,
1888
+ })),
1772
1889
  ];
1773
1890
  options.push({ value: "__claude_custom__", label: "Claude · custom model ID…" });
1891
+ options.push({ value: "__grok_custom__", label: "Grok · custom model ID…" });
1774
1892
  const input = new Input();
1775
- let enteringModel = false;
1776
- input.onSubmit = (value) => { if (value.trim()) pickerDone(`${CLAUDE_OPTION_PREFIX}${value.trim()}`); };
1893
+ let enteringBackend: "claude" | "grok" | undefined;
1894
+ input.onSubmit = (value) => {
1895
+ if (value.trim() && enteringBackend) pickerDone(`${enteringBackend === "claude" ? CLAUDE_OPTION_PREFIX : GROK_OPTION_PREFIX}${value.trim()}`);
1896
+ };
1777
1897
  const picker = new SelectList(options, Math.min(options.length, 8), getSelectListTheme());
1778
1898
  const selectedIndex = capability && current === "Default" ? 0 : options.findIndex((option) => option.value === current);
1779
1899
  picker.setSelectedIndex(Math.max(0, selectedIndex));
1780
1900
  picker.onSelect = (item) => {
1781
- if (item.value === "__claude_custom__") { enteringModel = true; tui.requestRender(); }
1901
+ if (item.value === "__claude_custom__") { enteringBackend = "claude"; tui.requestRender(); }
1902
+ else if (item.value === "__grok_custom__") { enteringBackend = "grok"; tui.requestRender(); }
1782
1903
  else pickerDone(item.value);
1783
1904
  };
1784
1905
  picker.onCancel = () => pickerDone();
1785
1906
  return {
1786
- render: (width: number) => enteringModel ? ["Claude model ID (Enter saves, Esc cancels)", ...input.render(width)] : picker.render(width),
1907
+ render: (width: number) => enteringBackend ? [`${enteringBackend === "claude" ? "Claude" : "Grok"} model ID (Enter saves, Esc cancels)`, ...input.render(width)] : picker.render(width),
1787
1908
  invalidate: () => { picker.invalidate(); input.invalidate(); },
1788
1909
  handleInput: (data: string) => {
1789
- if (enteringModel && matchesKey(data, "escape")) pickerDone();
1790
- else if (enteringModel) input.handleInput(data);
1910
+ if (enteringBackend && matchesKey(data, "escape")) pickerDone();
1911
+ else if (enteringBackend) input.handleInput(data);
1791
1912
  else picker.handleInput(data);
1792
1913
  },
1793
1914
  };
@@ -1823,10 +1944,10 @@ export function createConfigModal(
1823
1944
 
1824
1945
  function refresh(): void {
1825
1946
  const defaultBackend = settings.backend ?? "agy";
1826
- const def = resolveModelEffort({ model: settings.model, effort: settings.effort }, families);
1827
- modelItem.currentValue = defaultBackend === "claude" ? `${CLAUDE_OPTION_PREFIX}${settings.model}` : (def.family?.id ?? settings.model);
1947
+ const def = resolveModelEffort({ backend: settings.backend, model: settings.model, effort: settings.effort }, families);
1948
+ modelItem.currentValue = defaultBackend === "claude" ? `${CLAUDE_OPTION_PREFIX}${settings.model}` : defaultBackend === "grok" ? `${GROK_OPTION_PREFIX}${settings.model}` : (def.family?.id ?? settings.model);
1828
1949
  effortItem.currentValue = effortDisplay(def, defaultBackend);
1829
- effortItem.values = defaultBackend === "claude" ? ["default", ...CLAUDE_EFFORTS] : def.family?.efforts.length ? [...def.family.efforts] : undefined;
1950
+ effortItem.values = defaultBackend === "claude" ? ["default", ...CLAUDE_EFFORTS] : defaultBackend === "grok" ? ["default", ...GROK_EFFORTS] : def.family?.efforts.length ? [...def.family.efforts] : undefined;
1830
1951
  modeItem.currentValue = settings.mode;
1831
1952
  showTurnsItem.currentValue = String(settings.showTurns);
1832
1953
  showTurnsItem.values = showTurnsValues(settings.showTurns);
@@ -1840,9 +1961,11 @@ export function createConfigModal(
1840
1961
  ? "Default"
1841
1962
  : backend === "claude"
1842
1963
  ? `${CLAUDE_OPTION_PREFIX}${override.model}`
1843
- : (resolved.family?.id ?? override.model);
1964
+ : backend === "grok"
1965
+ ? `${GROK_OPTION_PREFIX}${override.model}`
1966
+ : (resolved.family?.id ?? override.model);
1844
1967
  rows.effort.currentValue = backendSupports(backend, capability) ? effortDisplay(resolved, backend) : "unsupported backend";
1845
- rows.effort.values = backend === "claude" ? ["default", ...CLAUDE_EFFORTS] : resolved.family?.efforts.length ? [...resolved.family.efforts] : undefined;
1968
+ rows.effort.values = backend === "claude" ? ["default", ...CLAUDE_EFFORTS] : backend === "grok" ? ["default", ...GROK_EFFORTS] : resolved.family?.efforts.length ? [...resolved.family.efforts] : undefined;
1846
1969
  }
1847
1970
  }
1848
1971
  refresh();
@@ -1897,14 +2020,19 @@ export function createConfigModal(
1897
2020
 
1898
2021
  const onChange = (id: string, newValue: string) => {
1899
2022
  if (id === "model") {
1900
- const claudeId = parseClaudeOption(newValue);
1901
- if (claudeId !== undefined) {
1902
- const effort = settings.backend === "claude" && isClaudeEffort(settings.effort) ? settings.effort : "default";
1903
- settings = { ...settings, backend: "claude", model: resolveClaudeModel(claudeId), effort };
2023
+ const switched = parseBackendOption(newValue);
2024
+ if (switched !== undefined) {
2025
+ if (switched.backend === "claude") {
2026
+ const effort = settings.backend === "claude" && isClaudeEffort(settings.effort) ? settings.effort : "default";
2027
+ settings = { ...settings, backend: "claude", model: resolveClaudeModel(switched.id), effort };
2028
+ } else {
2029
+ const effort = settings.backend === "grok" && isGrokEffort(settings.effort) ? settings.effort : "default";
2030
+ settings = { ...settings, backend: "grok", model: resolveGrokModel(switched.id), effort };
2031
+ }
1904
2032
  } else {
1905
2033
  const family = findFamily(newValue);
1906
2034
  if (!family) return;
1907
- const keepCurrent = settings.backend !== "claude" && (settings.effort === "default" ? !family.efforts.length : family.efforts.includes(settings.effort as AgyEffort));
2035
+ const keepCurrent = (settings.backend ?? "agy") === "agy" && (settings.effort === "default" ? !family.efforts.length : family.efforts.includes(settings.effort as AgyEffort));
1908
2036
  // Backend-less internal pairs mean Agy; disk saves still tag them explicitly.
1909
2037
  const { backend: _dropped, ...rest } = settings;
1910
2038
  settings = { ...rest, model: family.id, effort: keepCurrent ? settings.effort : preferredEffort(family) };
@@ -1913,6 +2041,9 @@ export function createConfigModal(
1913
2041
  if ((settings.backend ?? "agy") === "claude") {
1914
2042
  if (!isClaudeEffort(newValue)) return;
1915
2043
  settings = { ...settings, effort: newValue };
2044
+ } else if ((settings.backend ?? "agy") === "grok") {
2045
+ if (!isGrokEffort(newValue)) return;
2046
+ settings = { ...settings, effort: newValue };
1916
2047
  } else {
1917
2048
  // Effort-only edit: pin the resolved family's canonical id, same reasoning as the
1918
2049
  // capability-override effort-only edit below -- otherwise a shared default created
@@ -1936,14 +2067,22 @@ export function createConfigModal(
1936
2067
  if (newValue === "__default__") {
1937
2068
  settings = withCapabilityOverride(settings, capability, undefined);
1938
2069
  } else {
1939
- const claudeId = parseClaudeOption(newValue);
1940
- if (claudeId !== undefined) {
2070
+ const switched = parseBackendOption(newValue);
2071
+ if (switched !== undefined) {
1941
2072
  const currentEffort = capabilityPair(settings, capability).effort;
1942
- settings = withCapabilityOverride(settings, capability, {
1943
- backend: "claude",
1944
- model: resolveClaudeModel(claudeId),
1945
- effort: capabilityBackend(settings, capability) === "claude" && isClaudeEffort(currentEffort) ? currentEffort : "default",
1946
- });
2073
+ if (switched.backend === "claude") {
2074
+ settings = withCapabilityOverride(settings, capability, {
2075
+ backend: "claude",
2076
+ model: resolveClaudeModel(switched.id),
2077
+ effort: capabilityBackend(settings, capability) === "claude" && isClaudeEffort(currentEffort) ? currentEffort : "default",
2078
+ });
2079
+ } else {
2080
+ settings = withCapabilityOverride(settings, capability, {
2081
+ backend: "grok",
2082
+ model: resolveGrokModel(switched.id),
2083
+ effort: capabilityBackend(settings, capability) === "grok" && isGrokEffort(currentEffort) ? currentEffort : "default",
2084
+ });
2085
+ }
1947
2086
  } else {
1948
2087
  const family = findFamily(newValue);
1949
2088
  if (!family) return;
@@ -1960,6 +2099,11 @@ export function createConfigModal(
1960
2099
  const existing = capabilityOverride(settings, capability);
1961
2100
  const model = existing?.model ?? settings.model;
1962
2101
  settings = withCapabilityOverride(settings, capability, { backend: "claude", model, effort: newValue });
2102
+ } else if (capabilityBackend(settings, capability) === "grok") {
2103
+ if (!isGrokEffort(newValue)) return;
2104
+ const existing = capabilityOverride(settings, capability);
2105
+ const model = existing?.model ?? settings.model;
2106
+ settings = withCapabilityOverride(settings, capability, { backend: "grok", model, effort: newValue });
1963
2107
  } else {
1964
2108
  // Effort-only edit: pin the resolved family's canonical id, never whatever raw
1965
2109
  // string happens to sit in settings.model/override.model (which — for a shared
@@ -2023,14 +2167,13 @@ export async function showBroConfigModal(ctx: ExtensionCommandContext, pi: Exten
2023
2167
  ctx.ui.notify(withDoctor(error), "error");
2024
2168
  return;
2025
2169
  }
2026
- // A Claude-only setup (shared default and every override on Claude) opens without the
2027
- // Agy catalog; anything still on Agy keeps the previous hard requirement.
2170
+ // Keep configuration repair accessible even when the Agy catalog is unavailable.
2028
2171
  let families: AgyModelFamily[];
2029
2172
  try {
2030
2173
  families = await listAgyModels(pi);
2031
2174
  } catch (error) {
2032
2175
  families = [];
2033
- ctx.ui.notify("Agy model catalog unavailable — showing Claude settings without Agy choices.", "warning");
2176
+ ctx.ui.notify("Agy model catalog unavailable — showing Claude/Grok settings without Agy choices.", "warning");
2034
2177
  }
2035
2178
  await ctx.ui.custom<void>(createConfigModal(settings, families, writeSettings), {
2036
2179
  overlay: true,
@@ -2160,101 +2303,62 @@ export function helpText(settings?: BroSettings, settingsError?: string): string
2160
2303
  const settingsSummary = settings
2161
2304
  ? `- **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")}` : ""}`
2162
2305
  : `Bro could not read its settings: ${settingsError}\n\nRun \`/bro doctor\` for setup help.`;
2163
- const advisorBackend = settings ? capabilityBackend(settings, "advisor") : "agy";
2164
- const advisorProcess = advisorBackend === "claude" ? "Claude process" : "Agy process";
2165
2306
  return `# Bro
2166
2307
 
2167
- Bro explains a dense assistant reply, pasted text, local document, or public webpage in plain language, draws recent session turns as shapes, or opens a sandboxed side conversation with \`/bro btw\` — without adding anything to Pi's conversation.
2308
+ Quick reference. The README is the full user guide: https://github.com/tranhoangnguyen03/pi-bro#readme
2168
2309
 
2169
- ## Explain
2310
+ ## Explain and show
2170
2311
 
2171
2312
  - \`/bro\` — explain the latest completed assistant reply
2172
2313
  - \`/bro text [text]\` — explain pasted text, or the latest reply when text is omitted
2173
- - \`/bro file <path>\` — explain a Markdown, text, PDF, or DOCX file
2314
+ - \`/bro file <path>\` — explain a workspace \`.md\`, \`.markdown\`, \`.txt\`, \`.pdf\`, or \`.docx\` file
2174
2315
  - \`/bro url <url>\` — explain one public webpage
2175
- - \`/bro open\` — reopen the latest explanation
2176
- - \`/bro show [n-turns] [query]\` — draw the last few session turns (default 1) as shapes, from user and assistant conversation text only (tool calls, tool results, reasoning, and images are omitted); add a query to steer what the shapes focus on
2177
-
2178
- Any other input is the source itself: a lone URL explains that webpage, an existing workspace file with a supported extension explains that file, and anything else is explained as pasted text. Quoted paths with spaces are routed too when the file exists.
2179
-
2180
- Press **R** to simplify the captured source again. Run a new \`/bro text\`, \`/bro file\`, \`/bro url\`, or \`/bro show\` command — or give \`/bro\` the input directly — to capture a new source.
2181
-
2182
- ## Check and configure
2183
-
2184
- - \`/bro doctor\` — check settings, backends, account, model, effort, and mode (per-feature backend/model/effort)
2185
- - \`/bro usage [--provider agy]\` — show current Agy limits
2186
- - \`/bro model [id]\` — view or choose the shared default model (Agy catalog, or sonnet/opus/explicit IDs on the Claude backend)
2187
- - \`/bro effort [low|medium|high|xhigh|max]\` — view or choose the shared default reasoning effort (xhigh/max are Claude-only)
2188
- - \`/bro mode [brief|balanced|faithful]\` — view or choose explanation mode
2189
- - \`/bro config\` — open an interactive settings screen for the shared default backend/model/effort, explain mode, show turns, and per-capability (explain/show/btw/advisor) backend, 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.
2190
-
2191
- \`/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 backend, model, or effort. \`btw\` is not supported on the Claude backend.
2316
+ - \`/bro <input>\` — a lone URL, an existing supported file, or anything else as pasted text
2317
+ - \`/bro open\` — reopen the latest explanation without a new request
2318
+ - \`/bro show [n-turns] [query]\` — draw recent turns' conversation text as shapes; an optional query steers the focus
2192
2319
 
2193
2320
  ## Side conversation
2194
2321
 
2195
- - \`/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. Reopening preserves the thread's access mode (even with \`--fresh\`); use \`--sandbox\` to return to sandbox mode. Changing access mode starts a new thread. Inside the side thread, type questions and press Enter (empty Enter re-asks); exact commands \`/copy\` and \`/copy-all\` copy the latest answer or full thread to the system clipboard; exact commands \`/insert\` and \`/insert-all\` insert into the main editor without submitting (use \`/insert!\` or \`/insert-all!\` to replace an existing draft); \`/retry\` re-asks the last question; \`/clear\` resets the thread. Any other input is sent as a question. Esc closes.
2322
+ - \`/bro btw [question]\` — open a side conversation seeded with recent main-session context. It starts conversation-only; reopening keeps the thread and its mode.
2323
+ - 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.
2196
2324
 
2197
2325
  ## Advisor
2198
2326
 
2199
- - \`bro_advisor\` — a tool the executor agent can voluntarily call mid-task for a second opinion from a fresh ${advisorProcess} 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
2200
- - \`/bro advisor\` — a quick notice of whether \`bro_advisor\` is available right now, pointing at \`/bro config\`, \`/bro advisor-steer\`, and \`/bro doctor\`
2201
- - \`/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
2202
- - \`/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
2327
+ - \`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
2328
+ - \`/bro advisor\` — whether \`bro_advisor\` is available right now
2329
+ - \`/bro advisor-steer\` — edit the session's persistent steering brief (**Ctrl+S** save, **Ctrl+K** clear, **Ctrl+C** copy, **Esc** close)
2330
+
2331
+ ## Configure and check
2203
2332
 
2204
- Each consultation is a fresh, standalone ${advisorProcess} — never resumed, never looping, never automatically triggered. Bro captures the context snapshot (system instructions, active tools, and the conversation so far including tool calls and results) 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 is instructed to only return advice and leave edits to the executor, but that instruction is behavioral rather than an enforced sandbox constraint. 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.
2333
+ - \`/bro config\` — shared default and per-capability (explain/show/btw/advisor) backend, model, and effort; explain mode; show turns. Changes save immediately.
2334
+ - \`/bro mode [brief|balanced|faithful]\` — view or choose the explanation mode
2335
+ - \`/bro doctor\` — check settings, prompt, and every selected backend without running a model turn
2205
2336
 
2206
2337
  ## Current settings
2207
2338
 
2208
2339
  ${settingsSummary}
2209
2340
 
2210
- Saved in \`${SETTINGS_FILE}\`. Use the commands above, configure interactively via \`/bro config\`, or edit the file directly. Changes apply to future explanations. \`showTurns\` can be configured interactively in \`/bro config\` or edited directly in \`${SETTINGS_FILE}\`, and overridden per run with \`/bro show <n-turns>\`. Add a query after the count — or on its own, e.g. \`/bro show what changed in the auth flow\` — to steer what the shapes focus on.
2341
+ Saved in \`${SETTINGS_FILE}\`.
2211
2342
 
2212
2343
  ## Explanation modes
2213
2344
 
2214
2345
  - brief — the main point and next action, with no fixed word target
2215
2346
  - balanced — default; material detail with clearer structure
2216
2347
  - faithful — closest to the source, with no fixed word limit
2217
- /bro show uses its own built-in draw prompt; the modes and \`bro-prompt.md\` do not affect it.
2218
- If \`${PROMPT_FILE}\` exists and is valid, the selected mode stays saved but inactive because the custom prompt fully overrides it. Remove or rename \`bro-prompt.md\` to use the saved built-in mode again.
2348
+
2349
+ A valid \`${PROMPT_FILE}\` (with \`{{response}}\` exactly once) overrides the modes; the saved mode stays inactive until you remove or rename it. Show always uses its own prompt.
2219
2350
 
2220
2351
  ## Controls
2221
2352
 
2222
- - **Mouse wheel / trackpad** — scroll
2223
- - **↑ / ↓** — scroll
2353
+ - **Mouse wheel / trackpad**, **↑ / ↓** — scroll
2224
2354
  - **C** — copy the full explanation
2225
- - **R** — repeat the current action\n- **O** — open the HTML diagram when a show reply contains one
2355
+ - **R** — repeat the current action
2356
+ - **O** — open the HTML diagram when a show reply contains one
2226
2357
  - **Esc** — close, or cancel while Bro is working
2227
2358
 
2228
- Bro temporarily captures mouse input while the modal is open. Native mouse selection may be unavailable or extend outside the modal; press **C** to copy everything reliably.
2229
-
2230
- ## Important limits
2231
-
2232
- - Documents must be inside the current workspace, are limited to 10 MiB and 100,000 extracted characters, and must be \`.md\`, \`.markdown\`, \`.txt\`, \`.pdf\`, or \`.docx\`. Scanned PDFs need OCR first.
2233
- - Web input is limited to one public HTML page. Bro cannot sign in, run page JavaScript, bypass paywalls or blocks, follow pagination, or understand images and video.
2234
- - If a webpage fails, copy it into a text file or save it as a PDF, then use \`/bro file\`.
2235
- - 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.
2236
- - Show reflects what was reported in the conversation, not independent verification against the actual code or system state.
2237
- - 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.
2238
- - 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 behavioral 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 ${advisorBackend === "claude" ? "Claude" : "Agy"}'s own diagnostic as the failure.
2239
-
2240
- ## Privacy and safety
2241
-
2242
- Bro sends the selected assistant reply, pasted text, locally extracted document or webpage text, or recent session conversation text (tool calls, tool results, reasoning, and images omitted) to the selected backend and its model provider. They may retain request data under their own policies.
2243
-
2244
- Bro never adds the explanation to Pi's conversation, session file, or main-agent context. The captured source and latest explanation stay in process memory until you change sessions, reload extensions, or exit Pi.
2245
-
2246
- Bro's explain, show, file, and url commands never modify project files. \`/bro btw\` runs sandboxed (read-only) by default; with \`--full\` it can read and edit the workspace, so use \`--full\` only when you want the side conversation to touch your project.
2247
- For webpages, it connects directly to the site without browser cookies; the site sees your IP address and Bro's user agent. Do not use private or signed URLs.
2248
-
2249
- 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.
2250
-
2251
- 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 the selected backend and its model provider; the advisor process itself can read and edit the workspace with no permission prompts. The steering brief is stored as session-only extension data — never added to the main conversation Pi or the model sees; the advisor tool has no separate activation state.
2359
+ ## Privacy
2252
2360
 
2253
- ## Custom prompt
2254
-
2255
- 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.
2256
-
2257
- A valid custom prompt fully overrides all built-in mode instructions. \`/bro mode\` still changes the saved mode, but that mode remains inactive until you remove or rename \`bro-prompt.md\`. An invalid custom prompt blocks explanations; run \`/bro doctor\` for the exact problem.`;
2361
+ Bro sends the captured source (or, for the advisor, the executor's instructions, tools, and conversation) to the selected backend and its model provider, which may retain it under their own policies. Nothing is added to Pi's conversation unless you insert it. Access controls differ by backend; see the README.`;
2258
2362
  }
2259
2363
 
2260
2364
  // The overlay framing pattern is adapted from pi-btw (MIT); see THIRD_PARTY_NOTICES.md.
@@ -2264,6 +2368,7 @@ class BroModal implements Focusable {
2264
2368
  private kind: ModalKind = "loading";
2265
2369
  private rawText = "";
2266
2370
  private sourceLabel = "";
2371
+ private modelLabel = "";
2267
2372
  private notice = "";
2268
2373
  private offset = 0;
2269
2374
  private maxOffset = 0;
@@ -2292,8 +2397,8 @@ class BroModal implements Focusable {
2292
2397
  this.setContent("streaming", text, "", false, false);
2293
2398
  }
2294
2399
 
2295
- setResult(text: string, retryable: boolean, notice = "", sourceLabel = "", rawText = text): void {
2296
- this.setContent("result", text, rawText, true, retryable, notice, sourceLabel);
2400
+ setResult(text: string, retryable: boolean, notice = "", sourceLabel = "", rawText = text, modelLabel = ""): void {
2401
+ this.setContent("result", text, rawText, true, retryable, notice, sourceLabel, modelLabel);
2297
2402
  }
2298
2403
 
2299
2404
  setHtmlPath(path: string): void {
@@ -2317,6 +2422,7 @@ class BroModal implements Focusable {
2317
2422
  retryable: boolean,
2318
2423
  notice = "",
2319
2424
  sourceLabel = "",
2425
+ modelLabel = "",
2320
2426
  ): void {
2321
2427
  this.kind = kind;
2322
2428
  if (kind !== "result") this.htmlPath = "";
@@ -2328,6 +2434,7 @@ class BroModal implements Focusable {
2328
2434
  .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
2329
2435
  .replace(/\s+/g, " ")
2330
2436
  .trim();
2437
+ this.modelLabel = modelLabel;
2331
2438
  if (kind !== "streaming") this.offset = 0;
2332
2439
  this.markdown.setText(text);
2333
2440
  this.tui.requestRender();
@@ -2377,7 +2484,11 @@ class BroModal implements Focusable {
2377
2484
 
2378
2485
  const lines = [
2379
2486
  this.borderLine(innerWidth, "top"),
2380
- this.frameLine(this.theme.fg("accent", this.theme.bold(`Bro${this.sourceLabel ? ` · ${this.sourceLabel}` : ""}${scroll}`)), innerWidth),
2487
+ this.frameLine(
2488
+ this.theme.fg("accent", this.theme.bold(`Bro${this.sourceLabel ? ` · ${this.sourceLabel}` : ""}`)) +
2489
+ this.theme.fg("dim", `${this.modelLabel ? ` · ${this.modelLabel}` : ""}${scroll}`),
2490
+ innerWidth,
2491
+ ),
2381
2492
  this.ruleLine(innerWidth),
2382
2493
  ];
2383
2494
 
@@ -2518,7 +2629,7 @@ async function showBroModal(ctx: ExtensionCommandContext, options: BroModalOptio
2518
2629
  current = result;
2519
2630
  options.onResult?.(result);
2520
2631
  const display = result.htmlPath ? stripShowHtmlFence(result.text) : result.text;
2521
- modal.setResult(display, options.retryable ?? true, "", result.source?.label, result.text);
2632
+ modal.setResult(display, options.retryable ?? true, "", result.source?.label, result.text, result.model);
2522
2633
  if (result.htmlPath) modal.setHtmlPath(result.htmlPath);
2523
2634
  })
2524
2635
  .catch((error) => {
@@ -2526,7 +2637,7 @@ async function showBroModal(ctx: ExtensionCommandContext, options: BroModalOptio
2526
2637
  const message = error instanceof Error ? error.message : String(error);
2527
2638
  if (previous) {
2528
2639
  current = previous;
2529
- modal.setResult(previous.text, options.retryable ?? true, `Retry failed: ${message}`, previous.source?.label);
2640
+ modal.setResult(previous.text, options.retryable ?? true, `Retry failed: ${message}`, previous.source?.label, previous.text, previous.model);
2530
2641
  } else {
2531
2642
  modal.setError(message);
2532
2643
  }
@@ -2539,7 +2650,7 @@ async function showBroModal(ctx: ExtensionCommandContext, options: BroModalOptio
2539
2650
  if (options.text !== undefined) {
2540
2651
  modal.setStatic(options.kind ?? "help", options.text, options.copyable ?? false);
2541
2652
  } else if (current) {
2542
- modal.setResult(current.text, options.retryable ?? Boolean(options.run), "", current.source?.label);
2653
+ modal.setResult(current.text, options.retryable ?? Boolean(options.run), "", current.source?.label, current.text, current.model);
2543
2654
  } else {
2544
2655
  execute();
2545
2656
  }
@@ -2559,26 +2670,50 @@ async function showBroModal(ctx: ExtensionCommandContext, options: BroModalOptio
2559
2670
  );
2560
2671
  }
2561
2672
 
2562
- export function parseBtwArguments(value: string): { fresh: boolean; full?: boolean; question: string; invalid?: string } {
2563
- let rest = value.trim();
2564
- let fresh = false;
2565
- let full: boolean | undefined;
2566
- while (rest.startsWith("--")) {
2567
- const space = rest.search(/\s/);
2568
- const token = space === -1 ? rest : rest.slice(0, space);
2569
- if (token === "--fresh") fresh = true;
2570
- else if (token === "--full") full = true;
2571
- else if (token === "--sandbox") full = false;
2572
- else return { fresh, full, question: "", invalid: `Unknown /bro btw flag: ${token}` };
2573
- rest = space === -1 ? "" : rest.slice(space).replace(/^\s+/, "");
2673
+ export function parseBtwArguments(value: string): { question: string; invalid?: string } {
2674
+ const rest = value.trim();
2675
+ if (!rest.startsWith("--")) return { question: rest };
2676
+ const token = rest.split(/\s/, 1)[0]!;
2677
+ if (token === "--full" || token === "--sandbox") {
2678
+ return { question: "", invalid: `${token} was removed. Open /bro btw and type /mode to switch between conversation-only and full permission.` };
2574
2679
  }
2575
- return { fresh, full, question: rest };
2680
+ if (token === "--fresh") {
2681
+ return { question: "", invalid: "--fresh was removed. Every new thread starts with main-session context; type /clear inside /bro btw to start over." };
2682
+ }
2683
+ return { question: "", invalid: `Unknown /bro btw flag: ${token}` };
2684
+ }
2685
+
2686
+ // A new thread starts conversation-only; reopening keeps the thread and its mode.
2687
+ export function resolveBtwThread(existing: BtwThread | undefined): BtwThread {
2688
+ return existing ?? { turns: [], full: false };
2689
+ }
2690
+
2691
+ export function bindBtwBackend(thread: BtwThread, backend: BackendName): boolean {
2692
+ const changed = thread.backend !== undefined && thread.backend !== backend;
2693
+ if (changed) { thread.turns = []; thread.conversationId = undefined; thread.context = undefined; thread.sessionFull = undefined; }
2694
+ thread.backend = backend;
2695
+ return changed;
2696
+ }
2697
+
2698
+ export function btwModeLabel(full: boolean): string {
2699
+ return full ? "full permission" : "conversation-only";
2700
+ }
2701
+
2702
+ // /mode keeps the transcript; whether the native session survives is decided by
2703
+ // nativeBtwContinuation on the next turn.
2704
+ export function toggleBtwMode(thread: BtwThread): void {
2705
+ thread.full = !thread.full;
2576
2706
  }
2577
2707
 
2578
- export function resolveBtwThread(existing: BtwThread | undefined, parsed: { fresh: boolean; full?: boolean }): BtwThread {
2579
- const targetFull = parsed.full ?? existing?.full ?? false;
2580
- const startFresh = parsed.fresh || (parsed.full !== undefined && existing !== undefined && existing.full !== parsed.full);
2581
- return !existing || startFresh ? { turns: [], full: targetFull } : existing;
2708
+ // Claude and Grok run btw in the workspace for both modes and resume one native session across
2709
+ // /mode switches. An Agy conversation stays bound to the workspace it started in (a sandbox scratch
2710
+ // dir vs. the repo), so an access change drops it; the next turn reseeds a fresh native session
2711
+ // with the main-session context and the whole thread.
2712
+ export function nativeBtwContinuation(thread: BtwThread, backend: BackendName): string | undefined {
2713
+ if (backend === "agy" && thread.conversationId && thread.sessionFull !== undefined && thread.sessionFull !== thread.full) {
2714
+ thread.conversationId = undefined;
2715
+ }
2716
+ return thread.conversationId;
2582
2717
  }
2583
2718
 
2584
2719
  export function formatBtwTranscript(turns: readonly BtwTurn[]): string {
@@ -2595,26 +2730,23 @@ export function formatBtwTranscript(turns: readonly BtwTurn[]): string {
2595
2730
 
2596
2731
  export type BtwComposerAction =
2597
2732
  | { kind: "clear" }
2733
+ | { kind: "mode" }
2598
2734
  | { kind: "retry" }
2599
2735
  | { kind: "clipboard"; all: boolean }
2600
- | { kind: "insert"; all: boolean; force: boolean }
2736
+ | { kind: "insert"; all: boolean }
2737
+ | { kind: "removed"; command: string }
2601
2738
  | { kind: "question"; text: string };
2602
2739
 
2603
2740
  export function parseBtwComposerCommand(value: string): BtwComposerAction {
2604
2741
  const command = value.trim();
2605
2742
  if (command === "/clear") return { kind: "clear" };
2743
+ if (command === "/mode") return { kind: "mode" };
2606
2744
  if (command === "/retry" || command === "") return { kind: "retry" };
2607
2745
  if (command === "/copy" || command === "/copy-all") {
2608
2746
  return { kind: "clipboard", all: command === "/copy-all" };
2609
2747
  }
2610
- if (
2611
- command === "/insert" ||
2612
- command === "/insert!" ||
2613
- command === "/insert-all" ||
2614
- command === "/insert-all!"
2615
- ) {
2616
- return { kind: "insert", all: command.startsWith("/insert-all"), force: command.endsWith("!") };
2617
- }
2748
+ if (command === "/insert" || command === "/insert-all") return { kind: "insert", all: command === "/insert-all" };
2749
+ if (command === "/insert!" || command === "/insert-all!") return { kind: "removed", command };
2618
2750
  return { kind: "question", text: command };
2619
2751
  }
2620
2752
 
@@ -2630,12 +2762,6 @@ async function runBtwTurn(
2630
2762
  signal: AbortSignal,
2631
2763
  onProgress?: (text: string) => void,
2632
2764
  ): Promise<{ text: string; conversationId?: string }> {
2633
- // Claude supports explain/show (restricted, fresh) and advisor (workspace-full, fresh);
2634
- // btw is rejected. Fail before spawning anything so a Claude btw selection can never
2635
- // reach the Agy CLI.
2636
- if (selection.backend === "claude") {
2637
- throw new Error("`btw` is not supported on the Claude backend. Run `/bro config` to give it an Agy model.");
2638
- }
2639
2765
  let updateTimer: ReturnType<typeof setTimeout> | undefined;
2640
2766
  let latest: string | undefined;
2641
2767
  const throttledProgress = onProgress
@@ -2657,7 +2783,7 @@ async function runBtwTurn(
2657
2783
  feature: "btw",
2658
2784
  prompt,
2659
2785
  access: options.full ? "workspace-full" : "restricted",
2660
- cwd: options.full ? options.cwd : undefined,
2786
+ cwd: options.cwd,
2661
2787
  continuation: options.conversationId ? { id: options.conversationId } : undefined,
2662
2788
  },
2663
2789
  selection,
@@ -2681,6 +2807,7 @@ class BtwModal implements Focusable {
2681
2807
  private bodyHeight = 1;
2682
2808
  private running = false;
2683
2809
  private full = false;
2810
+ private model = "";
2684
2811
  private disposed = false;
2685
2812
 
2686
2813
  get focused(): boolean {
@@ -2721,6 +2848,11 @@ class BtwModal implements Focusable {
2721
2848
  this.tui.requestRender();
2722
2849
  }
2723
2850
 
2851
+ setModel(model: string): void {
2852
+ this.model = model;
2853
+ this.tui.requestRender();
2854
+ }
2855
+
2724
2856
  setFull(full: boolean): void {
2725
2857
  this.full = full;
2726
2858
  this.tui.requestRender();
@@ -2782,16 +2914,15 @@ class BtwModal implements Focusable {
2782
2914
  const hiddenBelow = Math.max(0, this.maxOffset - this.offset);
2783
2915
  const scroll = this.maxOffset > 0 ? ` · ↑${this.offset} ↓${hiddenBelow}` : "";
2784
2916
 
2785
- const mode = this.full
2786
- ? this.theme.fg("accent", this.theme.bold("full · edits repo"))
2787
- : this.theme.fg("dim", "sandbox");
2788
- const header = this.theme.fg("accent", this.theme.bold("Bro · btw")) + this.theme.fg("dim", ` · ${mode}${scroll}`);
2917
+ const model = this.model ? this.theme.fg("dim", ` · ${this.model}`) : "";
2918
+ const mode = this.theme.fg("dim", " · ") + (this.full ? this.theme.fg("accent", this.theme.bold(btwModeLabel(true))) : this.theme.fg("dim", btwModeLabel(false)));
2919
+ const header = this.theme.fg("accent", this.theme.bold("Bro · btw")) + model + mode + this.theme.fg("dim", scroll);
2789
2920
 
2790
2921
  const composer = this.input.render(innerWidth)[0] ?? "";
2791
2922
 
2792
2923
  const controls = this.running
2793
2924
  ? this.theme.fg("dim", "Thinking… · Esc cancel")
2794
- : this.theme.fg("dim", "Enter ask · Esc close · /copy · /copy-all · /insert · /insert-all · /clear · /retry");
2925
+ : this.theme.fg("dim", "Enter ask · Esc close · /mode · /copy · /copy-all · /insert · /insert-all · /clear · /retry");
2795
2926
 
2796
2927
  const lines = [
2797
2928
  this.borderLine(innerWidth, "top"),
@@ -2817,7 +2948,7 @@ class BtwModal implements Focusable {
2817
2948
 
2818
2949
  async function openBtwModal(
2819
2950
  ctx: ExtensionCommandContext,
2820
- options: { thread: BtwThread; initialQuestion?: string; seed: boolean },
2951
+ options: { thread: BtwThread; initialQuestion?: string },
2821
2952
  ): Promise<void> {
2822
2953
  const thread = options.thread;
2823
2954
 
@@ -2847,25 +2978,40 @@ async function openBtwModal(
2847
2978
  modal.setRunning(true);
2848
2979
  modal.clearComposer();
2849
2980
 
2850
- const first = thread.turns.length === 0;
2851
- let context: string | undefined;
2852
- if (first && options.seed) {
2853
- const captured = captureShowTranscript(ctx, BTW_CONTEXT_TURNS);
2854
- context = captured?.text;
2981
+ let settings: BroSettings;
2982
+ try {
2983
+ settings = await readSettings();
2984
+ const backend = capabilityBackend(settings, "btw");
2985
+ if (bindBtwBackend(thread, backend)) modal.setNotice("Backend changed — started a fresh side thread.");
2986
+ } catch (error) {
2987
+ controller = undefined; modal.setRunning(false); modal.setNotice(errorMessage(error)); return;
2988
+ }
2989
+ if (thread.turns.length === 0) {
2990
+ thread.conversationId = undefined;
2991
+ let context = captureShowTranscript(ctx, BTW_CONTEXT_TURNS)?.text;
2855
2992
  if (context && context.length > BTW_CONTEXT_MAX) {
2856
2993
  context = `${context.slice(0, BTW_CONTEXT_MAX)}\n[… context truncated …]`;
2857
2994
  }
2995
+ thread.context = context;
2858
2996
  }
2997
+ // Resume natively when possible; otherwise seed the fresh native session with the main-session
2998
+ // context plus every earlier turn so nothing is silently lost.
2999
+ const conversationId = nativeBtwContinuation(thread, capabilityBackend(settings, "btw"));
3000
+ const history = !conversationId && thread.turns.length ? formatBtwTranscript(thread.turns) : undefined;
3001
+ const context = conversationId ? undefined : thread.context;
3002
+ const full = thread.full;
2859
3003
 
2860
3004
  thread.turns.push({ question, answer: "…" });
2861
3005
  modal.setText(transcript());
2862
3006
 
2863
3007
  try {
2864
- const settings = await readSettings();
3008
+ const selection = selectionForCapability(settings, "btw");
3009
+ thread.model = selectionLabel(selection);
3010
+ modal.setModel(thread.model);
2865
3011
  const result = await runBtwTurn(
2866
- buildBtwPrompt(context, question),
2867
- selectionForCapability(settings, "btw"),
2868
- { full: thread.full, cwd: ctx.cwd, conversationId: thread.conversationId },
3012
+ buildBtwPrompt(context, question, { full, history }),
3013
+ selection,
3014
+ { full, cwd: ctx.cwd, conversationId },
2869
3015
  turnController.signal,
2870
3016
  (partial) => {
2871
3017
  if (closed || turnController.signal.aborted) return;
@@ -2874,7 +3020,10 @@ async function openBtwModal(
2874
3020
  },
2875
3021
  );
2876
3022
  if (turnController.signal.aborted) return;
2877
- if (result.conversationId) thread.conversationId = result.conversationId;
3023
+ if (result.conversationId) {
3024
+ thread.conversationId = result.conversationId;
3025
+ thread.sessionFull = full;
3026
+ }
2878
3027
  thread.turns[thread.turns.length - 1]!.answer = result.text;
2879
3028
  } catch (error) {
2880
3029
  if (turnController.signal.aborted || closed) return;
@@ -2889,9 +3038,16 @@ async function openBtwModal(
2889
3038
  }
2890
3039
  };
2891
3040
 
3041
+ const toggleMode = () => {
3042
+ toggleBtwMode(thread);
3043
+ modal.setFull(thread.full);
3044
+ modal.setNotice(`Mode: ${btwModeLabel(thread.full)}`);
3045
+ };
3046
+
2892
3047
  const clear = () => {
2893
3048
  thread.turns = [];
2894
3049
  thread.conversationId = undefined;
3050
+ thread.context = undefined;
2895
3051
  modal.clearComposer();
2896
3052
  modal.setNotice("");
2897
3053
  modal.setText("");
@@ -2922,14 +3078,14 @@ async function openBtwModal(
2922
3078
  }
2923
3079
  };
2924
3080
 
2925
- const insert = (all: boolean, force: boolean) => {
3081
+ const insert = (all: boolean) => {
2926
3082
  const text = all ? transcript() : (thread.turns.at(-1)?.answer ?? "");
2927
3083
  if (!text.trim()) {
2928
3084
  modal.setNotice("Nothing to insert yet.");
2929
3085
  return;
2930
3086
  }
2931
- if (ctx.ui.getEditorText().trim() && !force) {
2932
- modal.setNotice("Main editor has a draft. Use /insert! (or /insert-all!) to replace it.");
3087
+ if (ctx.ui.getEditorText().trim()) {
3088
+ modal.setNotice("Main editor has a draft. Edit or clear it first, then insert again.");
2933
3089
  return;
2934
3090
  }
2935
3091
  ctx.ui.setEditorText(text);
@@ -2943,6 +3099,11 @@ async function openBtwModal(
2943
3099
  clear();
2944
3100
  return;
2945
3101
  }
3102
+ if (action.kind === "mode") {
3103
+ modal.clearComposer();
3104
+ toggleMode();
3105
+ return;
3106
+ }
2946
3107
  if (action.kind === "clipboard") {
2947
3108
  modal.clearComposer();
2948
3109
  void copyOut(action.all);
@@ -2950,7 +3111,12 @@ async function openBtwModal(
2950
3111
  }
2951
3112
  if (action.kind === "insert") {
2952
3113
  modal.clearComposer();
2953
- insert(action.all, action.force);
3114
+ insert(action.all);
3115
+ return;
3116
+ }
3117
+ if (action.kind === "removed") {
3118
+ modal.clearComposer();
3119
+ modal.setNotice(`${action.command} was removed. Edit or clear the main editor draft, then use /insert or /insert-all.`);
2954
3120
  return;
2955
3121
  }
2956
3122
  if (action.kind === "retry") {
@@ -2962,6 +3128,7 @@ async function openBtwModal(
2962
3128
  }
2963
3129
 
2964
3130
  modal.setFull(thread.full);
3131
+ modal.setModel(thread.model ?? "");
2965
3132
  modal.setText(transcript());
2966
3133
 
2967
3134
  if (options.initialQuestion) void runTurn(options.initialQuestion);
@@ -2985,7 +3152,7 @@ export default async function bro(pi: ExtensionAPI) {
2985
3152
  let lastResult: BroResult | undefined;
2986
3153
  let btwThread: BtwThread | undefined;
2987
3154
  const remember = (result: ModalResult) => {
2988
- if (result.source) lastResult = { source: result.source, text: result.text };
3155
+ if (result.source) lastResult = { source: result.source, text: result.text, model: result.model };
2989
3156
  };
2990
3157
 
2991
3158
  pi.on("session_start", async (_event, _ctx) => {
@@ -3061,8 +3228,8 @@ export default async function bro(pi: ExtensionAPI) {
3061
3228
  // the activity throttle window.
3062
3229
  activityCount: run.activityCount,
3063
3230
  };
3064
- // The backend qualifier appears only for Claude, keeping the Agy header byte-identical.
3065
- const header = `Bro advisor · ${backend === "claude" ? "backend: claude · " : ""}model: ${selection.model} · effort: ${effort} · ${run.attempts} attempt${run.attempts === 1 ? "" : "s"} · ${Math.ceil(run.durationMs / 1_000)}s`;
3231
+ // The backend qualifier appears only off Agy, keeping the Agy header byte-identical.
3232
+ 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`;
3066
3233
  const context = `Context · cwd: ${JSON.stringify(ctx.cwd)} · steering: ${details.steeringIncluded ? "included" : "none"} · snapshot: ${snapshot.text.length} chars · Bro truncation: none · omissions: ${omissions}`;
3067
3234
  return { content: [{ type: "text", text: `${header}\n${context}\n\n${run.advice}` }], details };
3068
3235
  } finally {
@@ -3111,6 +3278,12 @@ export default async function bro(pi: ExtensionAPI) {
3111
3278
  let action = parts[0] ?? "";
3112
3279
  let value = raw.slice(raw.split(/\s+/, 1)[0]?.length ?? 0).trim();
3113
3280
 
3281
+ // Removed commands must never fall through to a paid text explanation.
3282
+ if (action === "model" || action === "effort") {
3283
+ ctx.ui.notify(`/bro ${action} was removed. Use /bro config to choose the shared default and per-capability ${action}; use /bro text <text> to explain text.`, "warning");
3284
+ return;
3285
+ }
3286
+
3114
3287
  // An unknown first word means the whole input is the source: route it by shape.
3115
3288
  if (action && !KNOWN_ACTIONS.has(action)) {
3116
3289
  const candidate = unquote(raw);
@@ -3142,13 +3315,14 @@ export default async function bro(pi: ExtensionAPI) {
3142
3315
  return { text: "**Nothing to show yet**\n\nThis session has no conversation turns to draw. Run something first, then press **R**." };
3143
3316
  }
3144
3317
  let text: string;
3318
+ let model: string;
3145
3319
  try {
3146
- text = await runShowExplanation(captured.text, steering, signal, await readSettings(), onProgress);
3320
+ ({ text, model } = await runShowExplanation(captured.text, steering, signal, await readSettings(), onProgress));
3147
3321
  } catch (error) {
3148
3322
  throw new Error(withDoctor(error));
3149
3323
  }
3150
3324
  const html = extractShowHtml(text);
3151
- return { source: captured, text, ...(html ? { htmlPath: await writeShowHtml(html) } : {}) };
3325
+ return { source: captured, text, model, ...(html ? { htmlPath: await writeShowHtml(html) } : {}) };
3152
3326
  };
3153
3327
  try {
3154
3328
  await showBroModal(ctx, {
@@ -3179,7 +3353,7 @@ export default async function bro(pi: ExtensionAPI) {
3179
3353
  try {
3180
3354
  return {
3181
3355
  source: target,
3182
- text: await simplify(target.text, signal, await readSettings(), onProgress),
3356
+ ...(await simplify(target.text, signal, await readSettings(), onProgress)),
3183
3357
  };
3184
3358
  } catch (error) {
3185
3359
  throw new Error(withDoctor(error));
@@ -3215,24 +3389,6 @@ export default async function bro(pi: ExtensionAPI) {
3215
3389
  return;
3216
3390
  }
3217
3391
 
3218
- if (action === "usage") {
3219
- const valid = parts.length === 1 || (parts.length === 3 && parts[1] === "--provider" && parts[2] === "agy");
3220
- if (!valid) {
3221
- ctx.ui.notify("Use /bro usage or /bro usage --provider agy.", "warning");
3222
- return;
3223
- }
3224
- try {
3225
- await showBroModal(ctx, {
3226
- loadingText: "Checking Agy usage…",
3227
- retryable: false,
3228
- run: async (signal) => ({ text: await checkAgyUsage(pi, signal) }),
3229
- });
3230
- } catch (error) {
3231
- ctx.ui.notify(withDoctor(error), "error");
3232
- }
3233
- return;
3234
- }
3235
-
3236
3392
  if (action === "mode") {
3237
3393
  const requested = parts[1];
3238
3394
  if (parts.length > 2 || (requested && !parseBroMode(requested))) {
@@ -3262,188 +3418,6 @@ export default async function bro(pi: ExtensionAPI) {
3262
3418
  return;
3263
3419
  }
3264
3420
 
3265
- if (action === "model") {
3266
- if (parts.length > 2) {
3267
- ctx.ui.notify("Use /bro model or /bro model <id>.", "warning");
3268
- return;
3269
- }
3270
- try {
3271
- const settings = await readSettings();
3272
- // /bro model operates on the effective shared backend: no Agy catalog
3273
- // when the shared default is Claude.
3274
- if ((settings.backend ?? "agy") === "claude") {
3275
- const requested = value.trim();
3276
- let model: string | undefined;
3277
- if (requested) {
3278
- try {
3279
- model = resolveClaudeModel(requested);
3280
- } catch {
3281
- ctx.ui.notify("Use /bro model or /bro model <sonnet|opus|model-id>.", "warning");
3282
- return;
3283
- }
3284
- } else {
3285
- if (ctx.mode !== "tui") {
3286
- ctx.ui.notify("Use /bro model <sonnet|opus|model-id> outside Pi's interactive UI.", "warning");
3287
- return;
3288
- }
3289
- const choices = [
3290
- ...CLAUDE_MODELS.map(
3291
- (entry) => `${entry.id} — ${entry.label}${entry.id === settings.model ? " (current)" : ""}`,
3292
- ),
3293
- "Custom — enter a model ID…",
3294
- ];
3295
- const choice = await ctx.ui.select(`Claude model (current: ${settings.model})`, choices);
3296
- if (!choice) return;
3297
- if (choice.startsWith("Custom")) {
3298
- const input = await ctx.ui.input("Claude model", "sonnet, opus, or an explicit model ID");
3299
- if (!input?.trim()) return;
3300
- model = resolveClaudeModel(input);
3301
- } else {
3302
- model = CLAUDE_MODELS[choices.indexOf(choice)]?.id;
3303
- }
3304
- }
3305
- if (!model) return;
3306
- const effort = settings.backend === "claude" && isClaudeEffort(settings.effort) ? settings.effort : "default";
3307
- await writeSettings({ ...settings, backend: "claude", model, effort });
3308
- ctx.ui.notify(`Bro model: claude ${model}${effort === "default" ? "" : ` (${effort})`}`, "info");
3309
- return;
3310
- }
3311
- const models = await listAgyModels(pi);
3312
- const current = resolveCatalogSettings(settings, models);
3313
- const requested = parts[1];
3314
- let selected: AgyModelFamily | undefined;
3315
- let selectedEffort: BroEffort | undefined;
3316
- if (requested) {
3317
- selected = models.find((item) => item.id.toLowerCase() === requested);
3318
- if (!selected) {
3319
- for (const family of models) {
3320
- const variant = family.variants.find((item) => item.id.toLowerCase() === requested);
3321
- if (variant) {
3322
- selected = family;
3323
- selectedEffort = variant.effort ?? "default";
3324
- break;
3325
- }
3326
- }
3327
- }
3328
- if (!selected) {
3329
- ctx.ui.notify(`Unknown Agy model "${requested}". Run /bro model to see available choices.`, "warning");
3330
- return;
3331
- }
3332
- } else {
3333
- if (ctx.mode !== "tui") {
3334
- ctx.ui.notify("Use /bro model <id> outside Pi's interactive UI.", "warning");
3335
- return;
3336
- }
3337
- const ordered = [...models].sort((a, b) => Number(b.id === current.family?.id) - Number(a.id === current.family?.id));
3338
- const choices = ordered.map(
3339
- (item) =>
3340
- `${item.id} — ${item.label} · ${item.efforts.length ? item.efforts.join("/") : "fixed effort"}${item.id === current.family?.id ? " (current)" : ""}`,
3341
- );
3342
- const choice = await ctx.ui.select(`Agy model (current: ${current.settings.model})`, choices);
3343
- if (!choice) return;
3344
- selected = ordered[choices.indexOf(choice)];
3345
- }
3346
- if (!selectedEffort) {
3347
- const currentEffort = current.settings.effort;
3348
- const canKeepCurrent =
3349
- current.family?.id === selected.id &&
3350
- (currentEffort === "default" ? !selected.efforts.length : selected.efforts.includes(currentEffort as AgyEffort));
3351
- selectedEffort = canKeepCurrent ? currentEffort : preferredEffort(selected);
3352
- }
3353
- await writeSettings({ ...settings, model: selected.id, effort: selectedEffort });
3354
- ctx.ui.notify(
3355
- `Bro model: ${selected.id}${selectedEffort === "default" ? "" : ` (${selectedEffort})`}`,
3356
- "info",
3357
- );
3358
- } catch (error) {
3359
- ctx.ui.notify(withDoctor(error), "error");
3360
- }
3361
- return;
3362
- }
3363
-
3364
- if (action === "effort") {
3365
- const requested = parts[1];
3366
- // Claude levels pass this gate; each backend branch below validates strictly
3367
- // (the Agy branch still rejects xhigh/max with its supports-list warning).
3368
- if (parts.length > 2 || (requested && !isClaudeEffort(requested) && !EFFORTS.some((effort) => effort === requested))) {
3369
- ctx.ui.notify("Use /bro effort, or choose low, medium, or high.", "warning");
3370
- return;
3371
- }
3372
- try {
3373
- const settings = await readSettings();
3374
- // /bro effort operates on the effective shared backend: no Agy catalog
3375
- // when the shared default is Claude.
3376
- if ((settings.backend ?? "agy") === "claude") {
3377
- const allowed = ["default", ...CLAUDE_EFFORTS] as const;
3378
- if (requested && !allowed.some((effort) => effort === requested)) {
3379
- ctx.ui.notify("Use /bro effort, or choose default, low, medium, high, xhigh, or max.", "warning");
3380
- return;
3381
- }
3382
- let selected = requested as BroEffort | undefined;
3383
- if (!selected) {
3384
- if (ctx.mode !== "tui") {
3385
- ctx.ui.notify("Use /bro effort <default|low|medium|high|xhigh|max> outside Pi's interactive UI.", "warning");
3386
- return;
3387
- }
3388
- const efforts = [...CLAUDE_EFFORTS].sort(
3389
- (a, b) => Number(b === settings.effort) - Number(a === settings.effort),
3390
- );
3391
- const choices = efforts.map((effort) => `${effort}${effort === settings.effort ? " (current)" : ""}`);
3392
- const choice = await ctx.ui.select(`Claude reasoning effort (current: ${settings.effort})`, choices);
3393
- if (!choice) return;
3394
- selected = efforts[choices.indexOf(choice)];
3395
- }
3396
- if (!selected || !isClaudeEffort(selected)) return;
3397
- await writeSettings({ ...settings, backend: "claude", model: settings.model, effort: selected });
3398
- ctx.ui.notify(
3399
- selected === "default" ? "Bro reasoning effort: built into the selected model" : `Bro reasoning effort: ${selected}`,
3400
- "info",
3401
- );
3402
- return;
3403
- }
3404
- const current = resolveCatalogSettings(settings, await listAgyModels(pi));
3405
- if (!current.family) {
3406
- ctx.ui.notify(`Model "${settings.model}" is not in Agy's current model list. Run /bro model first.`, "warning");
3407
- return;
3408
- }
3409
- if (!current.family.efforts.length) {
3410
- if (requested && requested !== "default") {
3411
- ctx.ui.notify(`${current.family.label} uses a fixed effort level.`, "warning");
3412
- return;
3413
- }
3414
- await writeSettings({ ...current.settings, model: current.family.id, effort: "default" });
3415
- ctx.ui.notify(`${current.family.label} uses its built-in effort level.`, "info");
3416
- return;
3417
- }
3418
- if (requested === "default" || (requested && !current.family.efforts.includes(requested as AgyEffort))) {
3419
- ctx.ui.notify(
3420
- `${current.family.label} supports ${current.family.efforts.join(" or ")} effort.`,
3421
- "warning",
3422
- );
3423
- return;
3424
- }
3425
- let selected = requested as AgyEffort | undefined;
3426
- if (!selected) {
3427
- if (ctx.mode !== "tui") {
3428
- ctx.ui.notify("Use /bro effort <low|medium|high> outside Pi's interactive UI.", "warning");
3429
- return;
3430
- }
3431
- const efforts = [...current.family.efforts].sort(
3432
- (a, b) => Number(b === current.settings.effort) - Number(a === current.settings.effort),
3433
- );
3434
- const choices = efforts.map((effort) => `${effort}${effort === current.settings.effort ? " (current)" : ""}`);
3435
- const choice = await ctx.ui.select(`Agy reasoning effort (current: ${current.settings.effort})`, choices);
3436
- if (!choice) return;
3437
- selected = efforts[choices.indexOf(choice)];
3438
- }
3439
- await writeSettings({ ...current.settings, model: current.family.id, effort: selected });
3440
- ctx.ui.notify(`Bro reasoning effort: ${selected}`, "info");
3441
- } catch (error) {
3442
- ctx.ui.notify(withDoctor(error), "error");
3443
- }
3444
- return;
3445
- }
3446
-
3447
3421
  if (action === "btw") {
3448
3422
  if (ctx.mode !== "tui") {
3449
3423
  ctx.ui.notify("Use /bro btw in Pi's interactive UI.", "warning");
@@ -3455,10 +3429,11 @@ export default async function bro(pi: ExtensionAPI) {
3455
3429
  return;
3456
3430
  }
3457
3431
  try {
3458
- if (capabilityBackend(await readSettings(), "btw") === "claude") throw new Error("Claude does not support /bro btw yet; choose an Agy override in /bro config.");
3459
- const thread = resolveBtwThread(btwThread, parsed);
3432
+ const btwBackend = capabilityBackend(await readSettings(), "btw");
3433
+ const thread = resolveBtwThread(btwThread);
3434
+ if (bindBtwBackend(thread, btwBackend)) ctx.ui.notify("Backend changed — started a fresh side thread.", "info");
3460
3435
  btwThread = thread;
3461
- await openBtwModal(ctx, { thread, initialQuestion: parsed.question, seed: !parsed.fresh });
3436
+ await openBtwModal(ctx, { thread, initialQuestion: parsed.question });
3462
3437
  } catch (error) {
3463
3438
  ctx.ui.notify(withDoctor(error), "error");
3464
3439
  }
@@ -3535,7 +3510,7 @@ export default async function bro(pi: ExtensionAPI) {
3535
3510
  const settings = await readSettings();
3536
3511
  return {
3537
3512
  source: target,
3538
- text: await simplify(target.text, signal, settings, onProgress),
3513
+ ...(await simplify(target.text, signal, settings, onProgress)),
3539
3514
  };
3540
3515
  } catch (error) {
3541
3516
  throw new Error(withDoctor(error));