pi-bro 0.16.0 → 0.17.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 +17 -0
  2. package/README.md +55 -27
  3. package/backend.ts +277 -7
  4. package/bro.ts +342 -115
  5. package/package.json +2 -2
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,18 @@ 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
+ type BtwThread = { turns: BtwTurn[]; conversationId?: string; full: boolean; backend?: BackendName; model?: string };
66
67
  const EFFORTS = ["default", "low", "medium", "high"] as const;
67
- const BACKENDS = ["agy", "claude"] as const;
68
+ const BACKENDS = ["agy", "claude", "grok"] as const;
68
69
  type BackendName = (typeof BACKENDS)[number];
69
70
  type BroEffort = "default" | "low" | "medium" | "high" | "xhigh" | "max";
70
71
  type AgyEffort = Exclude<BroEffort, "default" | "xhigh" | "max">;
71
72
  type ClaudeEffort = (typeof CLAUDE_EFFORTS)[number];
73
+ export { GROK_EFFORTS };
74
+ type GrokEffort = (typeof GROK_EFFORTS)[number];
72
75
  // "advisor" is a real capability (command, Agy invocation, Doctor check) like the other three;
73
76
  // see docs/plans/2026-09-19-bro-advisor-design.md. All four share one model/effort resolution,
74
77
  // override, and Doctor-check path via this single list -- there is no configuration-only tier.
@@ -90,6 +93,11 @@ export const CLAUDE_MODELS = [
90
93
  { id: "sonnet", label: "Claude Sonnet" },
91
94
  { id: "opus", label: "Claude Opus" },
92
95
  ] as const;
96
+ export const GROK_MODELS = [
97
+ { id: "grok-4.7", label: "Grok 4.7" },
98
+ { id: "grok-4.7-build-fast", label: "Grok 4.7 Build Fast" },
99
+ ] as const;
100
+ export { backendSupports as supportsBackend };
93
101
  type AgyModelFamily = {
94
102
  id: string;
95
103
  label: string;
@@ -114,12 +122,11 @@ const COMMANDS = [
114
122
  { value: "url", label: "url", description: "Explain a public webpage" },
115
123
  { value: "open", label: "open", description: "Reopen the last explanation" },
116
124
  { 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" },
125
+ { value: "model", label: "model", description: "View or choose the shared default model" },
119
126
  { 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 (context-only intent; --full invites workspace access)" },
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,6 +1073,20 @@ 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") {
@@ -1025,6 +1098,15 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
1025
1098
  } else {
1026
1099
  pass("Reasoning effort", settings.effort);
1027
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 effort\` to choose another.`);
1105
+ } else if (settings.effort === "default") {
1106
+ pass("Reasoning effort", "built into the selected model");
1107
+ } else {
1108
+ pass("Reasoning effort", settings.effort);
1109
+ }
1028
1110
  } else if (models) {
1029
1111
  const current = resolveCatalogSettings(settings, models);
1030
1112
  if (!current.family) {
@@ -1064,6 +1146,23 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
1064
1146
  );
1065
1147
  continue;
1066
1148
  }
1149
+ if (backend === "grok") {
1150
+ if (!backendSupports("grok", capability)) {
1151
+ fail(label, `\`${capability}\` is not supported on the Grok backend for this capability. Run \`/bro config\` to give it another backend.`);
1152
+ continue;
1153
+ }
1154
+ if (!isGrokEffort(pair.effort)) {
1155
+ fail(label, `\`${pair.effort}\` is unsupported for grok \`${pair.model}\`. Run \`/bro config\` to fix this.`);
1156
+ continue;
1157
+ }
1158
+ pass(
1159
+ label,
1160
+ override
1161
+ ? `override grok \`${pair.model}\`${pair.effort === "default" ? "" : ` (${pair.effort})`}`
1162
+ : `grok \`${pair.model}\`${pair.effort === "default" ? "" : ` (${pair.effort})`} · using the shared default`,
1163
+ );
1164
+ continue;
1165
+ }
1067
1166
  if (!models) continue;
1068
1167
  const resolved = resolveCapabilitySettings(settings, capability, models);
1069
1168
  if (!resolved.family) {
@@ -1096,13 +1195,15 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
1096
1195
  pass("Advisor steering", resolveAdvisorState(ctx.sessionManager.getBranch()).steering.trim() ? "present" : "none");
1097
1196
  if (settings && capabilityBackend(settings, "advisor") === "claude") {
1098
1197
  pass("Advisor compatibility", "Claude backend — no Agy version floor applies");
1198
+ } else if (settings && capabilityBackend(settings, "advisor") === "grok") {
1199
+ pass("Advisor compatibility", "Grok backend — no Agy version floor applies; auth/connectivity unverified (no probe)");
1099
1200
  } else if (agyVersion && advisorAgyCompatible(agyVersion)) pass("Advisor compatibility", ADVISOR_COMPATIBILITY);
1100
1201
  else if (agyVersion) fail("Advisor compatibility", `installed \`${agyVersion}\`; requires Agy >=1.1.15. Run \`agy update\`.`);
1101
1202
 
1102
1203
  return `# Bro doctor\n\n${lines.join("\n")}\n\n**${failed ? "Bro needs attention." : "Bro is ready."}**\n\n${
1103
1204
  failed
1104
1205
  ? "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."
1206
+ : "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
1207
  }`;
1107
1208
  }
1108
1209
 
@@ -1295,8 +1396,9 @@ async function simplify(
1295
1396
  signal: AbortSignal,
1296
1397
  settings: BroSettings,
1297
1398
  onProgress?: (text: string) => void,
1298
- ): Promise<string> {
1299
- return runAgyText((await promptFor(response, settings.mode)).text, selectionForCapability(settings, "explain"), signal, onProgress);
1399
+ ): Promise<{ text: string; model: string }> {
1400
+ const selection = selectionForCapability(settings, "explain");
1401
+ return { text: await runAgyText((await promptFor(response, settings.mode)).text, selection, signal, onProgress), model: selectionLabel(selection) };
1300
1402
  }
1301
1403
 
1302
1404
  async function runShowExplanation(
@@ -1305,8 +1407,9 @@ async function runShowExplanation(
1305
1407
  signal: AbortSignal,
1306
1408
  settings: BroSettings,
1307
1409
  onProgress?: (text: string) => void,
1308
- ): Promise<string> {
1309
- return runAgyText(buildShowPrompt(transcript, steering), selectionForCapability(settings, "show"), signal, onProgress, "show");
1410
+ ): Promise<{ text: string; model: string }> {
1411
+ const selection = selectionForCapability(settings, "show");
1412
+ return { text: await runAgyText(buildShowPrompt(transcript, steering), selection, signal, onProgress, "show"), model: selectionLabel(selection) };
1310
1413
  }
1311
1414
 
1312
1415
  // Thin presentation-boundary wrapper around the shared backend: coalesces raw text progress to the
@@ -1688,7 +1791,7 @@ export function advisorAttemptLabel(details: AdvisorToolDetails): string {
1688
1791
  return `Bro advisor · ${details.model ?? "unknown model"} · ${details.attempt} attempt${details.attempt === 1 ? "" : "s"} · ${Math.ceil((details.durationMs ?? details.elapsedMs) / 1_000)}s`;
1689
1792
  }
1690
1793
  const latest = details.activity?.at(-1);
1691
- const backendLabel = details.backend === "claude" ? "Claude" : "Agy";
1794
+ const backendLabel = details.backend === "claude" ? "Claude" : details.backend === "grok" ? "Grok" : "Agy";
1692
1795
  // "last reported" + freshness, never a claim about what the backend is doing right now and never
1693
1796
  // "stalled" -- silence since lastActivityAt is not itself evidence of a stuck run.
1694
1797
  const activityLabel = latest
@@ -1708,9 +1811,10 @@ function showTurnsValues(current: number): string[] {
1708
1811
  // falsely healthy for an unavailable model), and flags a stored effort that isn't one of the
1709
1812
  // resolved family's supported efforts instead of silently showing it as if it were valid.
1710
1813
  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).
1814
+ // Claude/Grok selections never touch the Agy catalog: the stored effort is valid exactly
1815
+ // when it is one of that backend's levels (or "default" for the CLI's own default).
1713
1816
  if (backend === "claude") return isClaudeEffort(resolved.pair.effort) ? resolved.pair.effort : `${resolved.pair.effort} (unsupported)`;
1817
+ if (backend === "grok") return isGrokEffort(resolved.pair.effort) ? resolved.pair.effort : `${resolved.pair.effort} (unsupported)`;
1714
1818
  if (!resolved.family) return "unavailable";
1715
1819
  const fixed = !resolved.family.efforts.length;
1716
1820
  const valid = fixed ? resolved.pair.effort === "default" : resolved.family.efforts.includes(resolved.pair.effort as AgyEffort);
@@ -1718,7 +1822,7 @@ function effortDisplay(resolved: { pair: ModelEffortPair; family?: AgyModelFamil
1718
1822
  return fixed ? "fixed" : resolved.pair.effort;
1719
1823
  }
1720
1824
 
1721
- // Model-picker values that switch to the Claude backend carry a "claude:" prefix so one
1825
+ // Model-picker values that switch backend carry a "claude:"/"grok:" prefix so one
1722
1826
  // atomic picker commit changes backend+model together -- cancelling the picker (Esc)
1723
1827
  // leaves both unchanged via the existing submenu-cancel path.
1724
1828
  const CLAUDE_OPTION_PREFIX = "claude:";
@@ -1727,6 +1831,19 @@ function parseClaudeOption(value: string): string | undefined {
1727
1831
  ? value.slice(CLAUDE_OPTION_PREFIX.length)
1728
1832
  : undefined;
1729
1833
  }
1834
+ const GROK_OPTION_PREFIX = "grok:";
1835
+ function parseGrokOption(value: string): string | undefined {
1836
+ return value.startsWith(GROK_OPTION_PREFIX) && value.length > GROK_OPTION_PREFIX.length
1837
+ ? value.slice(GROK_OPTION_PREFIX.length)
1838
+ : undefined;
1839
+ }
1840
+ function parseBackendOption(value: string): { backend: "claude" | "grok"; id: string } | undefined {
1841
+ const claudeId = parseClaudeOption(value);
1842
+ if (claudeId !== undefined) return { backend: "claude", id: claudeId };
1843
+ const grokId = parseGrokOption(value);
1844
+ if (grokId !== undefined) return { backend: "grok", id: grokId };
1845
+ return undefined;
1846
+ }
1730
1847
 
1731
1848
  // Testable core: takes settings/catalog/persist as plain arguments so smoke tests can drive
1732
1849
  // the exact interaction (submenus, cancel, cycling, save failure) without a real Agy process
@@ -1764,30 +1881,38 @@ export function createConfigModal(
1764
1881
  value: family.id,
1765
1882
  label: `${family.label}${family.efforts.length ? "" : " · fixed effort"}`,
1766
1883
  })),
1767
- // Claude entries stay last so existing Agy keyboard navigation is unaffected.
1884
+ // Claude/Grok entries stay last so existing Agy keyboard navigation is unaffected.
1768
1885
  ...CLAUDE_MODELS.map((model) => ({
1769
1886
  value: `${CLAUDE_OPTION_PREFIX}${model.id}`,
1770
1887
  label: `${model.label} · claude`,
1771
1888
  })),
1889
+ ...GROK_MODELS.map((model) => ({
1890
+ value: `${GROK_OPTION_PREFIX}${model.id}`,
1891
+ label: `${model.label} · grok`,
1892
+ })),
1772
1893
  ];
1773
1894
  options.push({ value: "__claude_custom__", label: "Claude · custom model ID…" });
1895
+ options.push({ value: "__grok_custom__", label: "Grok · custom model ID…" });
1774
1896
  const input = new Input();
1775
- let enteringModel = false;
1776
- input.onSubmit = (value) => { if (value.trim()) pickerDone(`${CLAUDE_OPTION_PREFIX}${value.trim()}`); };
1897
+ let enteringBackend: "claude" | "grok" | undefined;
1898
+ input.onSubmit = (value) => {
1899
+ if (value.trim() && enteringBackend) pickerDone(`${enteringBackend === "claude" ? CLAUDE_OPTION_PREFIX : GROK_OPTION_PREFIX}${value.trim()}`);
1900
+ };
1777
1901
  const picker = new SelectList(options, Math.min(options.length, 8), getSelectListTheme());
1778
1902
  const selectedIndex = capability && current === "Default" ? 0 : options.findIndex((option) => option.value === current);
1779
1903
  picker.setSelectedIndex(Math.max(0, selectedIndex));
1780
1904
  picker.onSelect = (item) => {
1781
- if (item.value === "__claude_custom__") { enteringModel = true; tui.requestRender(); }
1905
+ if (item.value === "__claude_custom__") { enteringBackend = "claude"; tui.requestRender(); }
1906
+ else if (item.value === "__grok_custom__") { enteringBackend = "grok"; tui.requestRender(); }
1782
1907
  else pickerDone(item.value);
1783
1908
  };
1784
1909
  picker.onCancel = () => pickerDone();
1785
1910
  return {
1786
- render: (width: number) => enteringModel ? ["Claude model ID (Enter saves, Esc cancels)", ...input.render(width)] : picker.render(width),
1911
+ render: (width: number) => enteringBackend ? [`${enteringBackend === "claude" ? "Claude" : "Grok"} model ID (Enter saves, Esc cancels)`, ...input.render(width)] : picker.render(width),
1787
1912
  invalidate: () => { picker.invalidate(); input.invalidate(); },
1788
1913
  handleInput: (data: string) => {
1789
- if (enteringModel && matchesKey(data, "escape")) pickerDone();
1790
- else if (enteringModel) input.handleInput(data);
1914
+ if (enteringBackend && matchesKey(data, "escape")) pickerDone();
1915
+ else if (enteringBackend) input.handleInput(data);
1791
1916
  else picker.handleInput(data);
1792
1917
  },
1793
1918
  };
@@ -1823,10 +1948,10 @@ export function createConfigModal(
1823
1948
 
1824
1949
  function refresh(): void {
1825
1950
  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);
1951
+ const def = resolveModelEffort({ backend: settings.backend, model: settings.model, effort: settings.effort }, families);
1952
+ modelItem.currentValue = defaultBackend === "claude" ? `${CLAUDE_OPTION_PREFIX}${settings.model}` : defaultBackend === "grok" ? `${GROK_OPTION_PREFIX}${settings.model}` : (def.family?.id ?? settings.model);
1828
1953
  effortItem.currentValue = effortDisplay(def, defaultBackend);
1829
- effortItem.values = defaultBackend === "claude" ? ["default", ...CLAUDE_EFFORTS] : def.family?.efforts.length ? [...def.family.efforts] : undefined;
1954
+ effortItem.values = defaultBackend === "claude" ? ["default", ...CLAUDE_EFFORTS] : defaultBackend === "grok" ? ["default", ...GROK_EFFORTS] : def.family?.efforts.length ? [...def.family.efforts] : undefined;
1830
1955
  modeItem.currentValue = settings.mode;
1831
1956
  showTurnsItem.currentValue = String(settings.showTurns);
1832
1957
  showTurnsItem.values = showTurnsValues(settings.showTurns);
@@ -1840,9 +1965,11 @@ export function createConfigModal(
1840
1965
  ? "Default"
1841
1966
  : backend === "claude"
1842
1967
  ? `${CLAUDE_OPTION_PREFIX}${override.model}`
1843
- : (resolved.family?.id ?? override.model);
1968
+ : backend === "grok"
1969
+ ? `${GROK_OPTION_PREFIX}${override.model}`
1970
+ : (resolved.family?.id ?? override.model);
1844
1971
  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;
1972
+ rows.effort.values = backend === "claude" ? ["default", ...CLAUDE_EFFORTS] : backend === "grok" ? ["default", ...GROK_EFFORTS] : resolved.family?.efforts.length ? [...resolved.family.efforts] : undefined;
1846
1973
  }
1847
1974
  }
1848
1975
  refresh();
@@ -1897,14 +2024,19 @@ export function createConfigModal(
1897
2024
 
1898
2025
  const onChange = (id: string, newValue: string) => {
1899
2026
  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 };
2027
+ const switched = parseBackendOption(newValue);
2028
+ if (switched !== undefined) {
2029
+ if (switched.backend === "claude") {
2030
+ const effort = settings.backend === "claude" && isClaudeEffort(settings.effort) ? settings.effort : "default";
2031
+ settings = { ...settings, backend: "claude", model: resolveClaudeModel(switched.id), effort };
2032
+ } else {
2033
+ const effort = settings.backend === "grok" && isGrokEffort(settings.effort) ? settings.effort : "default";
2034
+ settings = { ...settings, backend: "grok", model: resolveGrokModel(switched.id), effort };
2035
+ }
1904
2036
  } else {
1905
2037
  const family = findFamily(newValue);
1906
2038
  if (!family) return;
1907
- const keepCurrent = settings.backend !== "claude" && (settings.effort === "default" ? !family.efforts.length : family.efforts.includes(settings.effort as AgyEffort));
2039
+ const keepCurrent = (settings.backend ?? "agy") === "agy" && (settings.effort === "default" ? !family.efforts.length : family.efforts.includes(settings.effort as AgyEffort));
1908
2040
  // Backend-less internal pairs mean Agy; disk saves still tag them explicitly.
1909
2041
  const { backend: _dropped, ...rest } = settings;
1910
2042
  settings = { ...rest, model: family.id, effort: keepCurrent ? settings.effort : preferredEffort(family) };
@@ -1913,6 +2045,9 @@ export function createConfigModal(
1913
2045
  if ((settings.backend ?? "agy") === "claude") {
1914
2046
  if (!isClaudeEffort(newValue)) return;
1915
2047
  settings = { ...settings, effort: newValue };
2048
+ } else if ((settings.backend ?? "agy") === "grok") {
2049
+ if (!isGrokEffort(newValue)) return;
2050
+ settings = { ...settings, effort: newValue };
1916
2051
  } else {
1917
2052
  // Effort-only edit: pin the resolved family's canonical id, same reasoning as the
1918
2053
  // capability-override effort-only edit below -- otherwise a shared default created
@@ -1936,14 +2071,22 @@ export function createConfigModal(
1936
2071
  if (newValue === "__default__") {
1937
2072
  settings = withCapabilityOverride(settings, capability, undefined);
1938
2073
  } else {
1939
- const claudeId = parseClaudeOption(newValue);
1940
- if (claudeId !== undefined) {
2074
+ const switched = parseBackendOption(newValue);
2075
+ if (switched !== undefined) {
1941
2076
  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
- });
2077
+ if (switched.backend === "claude") {
2078
+ settings = withCapabilityOverride(settings, capability, {
2079
+ backend: "claude",
2080
+ model: resolveClaudeModel(switched.id),
2081
+ effort: capabilityBackend(settings, capability) === "claude" && isClaudeEffort(currentEffort) ? currentEffort : "default",
2082
+ });
2083
+ } else {
2084
+ settings = withCapabilityOverride(settings, capability, {
2085
+ backend: "grok",
2086
+ model: resolveGrokModel(switched.id),
2087
+ effort: capabilityBackend(settings, capability) === "grok" && isGrokEffort(currentEffort) ? currentEffort : "default",
2088
+ });
2089
+ }
1947
2090
  } else {
1948
2091
  const family = findFamily(newValue);
1949
2092
  if (!family) return;
@@ -1960,6 +2103,11 @@ export function createConfigModal(
1960
2103
  const existing = capabilityOverride(settings, capability);
1961
2104
  const model = existing?.model ?? settings.model;
1962
2105
  settings = withCapabilityOverride(settings, capability, { backend: "claude", model, effort: newValue });
2106
+ } else if (capabilityBackend(settings, capability) === "grok") {
2107
+ if (!isGrokEffort(newValue)) return;
2108
+ const existing = capabilityOverride(settings, capability);
2109
+ const model = existing?.model ?? settings.model;
2110
+ settings = withCapabilityOverride(settings, capability, { backend: "grok", model, effort: newValue });
1963
2111
  } else {
1964
2112
  // Effort-only edit: pin the resolved family's canonical id, never whatever raw
1965
2113
  // string happens to sit in settings.model/override.model (which — for a shared
@@ -2023,14 +2171,13 @@ export async function showBroConfigModal(ctx: ExtensionCommandContext, pi: Exten
2023
2171
  ctx.ui.notify(withDoctor(error), "error");
2024
2172
  return;
2025
2173
  }
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.
2174
+ // Keep configuration repair accessible even when the Agy catalog is unavailable.
2028
2175
  let families: AgyModelFamily[];
2029
2176
  try {
2030
2177
  families = await listAgyModels(pi);
2031
2178
  } catch (error) {
2032
2179
  families = [];
2033
- ctx.ui.notify("Agy model catalog unavailable — showing Claude settings without Agy choices.", "warning");
2180
+ ctx.ui.notify("Agy model catalog unavailable — showing Claude/Grok settings without Agy choices.", "warning");
2034
2181
  }
2035
2182
  await ctx.ui.custom<void>(createConfigModal(settings, families, writeSettings), {
2036
2183
  overlay: true,
@@ -2161,10 +2308,11 @@ export function helpText(settings?: BroSettings, settingsError?: string): string
2161
2308
  ? `- **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
2309
  : `Bro could not read its settings: ${settingsError}\n\nRun \`/bro doctor\` for setup help.`;
2163
2310
  const advisorBackend = settings ? capabilityBackend(settings, "advisor") : "agy";
2164
- const advisorProcess = advisorBackend === "claude" ? "Claude process" : "Agy process";
2311
+ const advisorProcess = advisorBackend === "claude" ? "Claude process" : advisorBackend === "grok" ? "Grok process" : "Agy process";
2312
+ const advisorName = advisorBackend === "claude" ? "Claude" : advisorBackend === "grok" ? "Grok" : "Agy";
2165
2313
  return `# Bro
2166
2314
 
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.
2315
+ 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 separate side conversation with \`/bro btw\` — without adding anything to Pi's conversation.
2168
2316
 
2169
2317
  ## Explain
2170
2318
 
@@ -2182,24 +2330,23 @@ Press **R** to simplify the captured source again. Run a new \`/bro text\`, \`/b
2182
2330
  ## Check and configure
2183
2331
 
2184
2332
  - \`/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)
2333
+ - \`/bro model [id]\` — view or choose the shared default model (Agy catalog, sonnet/opus/explicit IDs on Claude, grok-4.7/grok-4.7-build-fast/explicit IDs on Grok)
2334
+ - \`/bro effort [low|medium|high|xhigh|max]\` — view or choose the shared default reasoning effort (xhigh on Claude/Grok, max on Claude only)
2188
2335
  - \`/bro mode [brief|balanced|faithful]\` — view or choose explanation mode
2189
2336
  - \`/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
2337
 
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.
2338
+ \`/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\` runs on Agy or Grok (Claude continuation is pending).
2192
2339
 
2193
2340
  ## Side conversation
2194
2341
 
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.
2342
+ - \`/bro btw [--fresh] [--full] [question]\` — open a side conversation. Conversation-only intent by default (Agy sandbox controls; Grok prompt request, not enforced); 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.
2196
2343
 
2197
2344
  ## Advisor
2198
2345
 
2199
2346
  - \`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
2347
  - \`/bro advisor\` — a quick notice of whether \`bro_advisor\` is available right now, pointing at \`/bro config\`, \`/bro advisor-steer\`, and \`/bro doctor\`
2201
2348
  - \`/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
2349
+ - \`/bro doctor\` — the full advisor diagnostic: whether this host exposes and activates \`bro_advisor\`, its resolved model/effort, steering presence, and backend compatibility
2203
2350
 
2204
2351
  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.
2205
2352
 
@@ -2232,10 +2379,10 @@ Bro temporarily captures mouse input while the modal is open. Native mouse selec
2232
2379
  - 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
2380
  - 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
2381
  - 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.
2382
+ - 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 is requested not to investigate the repository; Grok retains normal tools, so this is not enforced isolation. On a remote or headless session with no display, pressing **O** reports a failure instead of opening the diagram.
2236
2383
  - 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.
2384
+ - 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 \`--conversation\` or Grok \`--resume\`.
2385
+ - Advisor consultations run with real tool access and auto-approved permissions (Grok: \`--sandbox off --permission-mode bypassPermissions\`; Agy/Claude: \`--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 ${advisorName}'s own diagnostic as the failure.
2239
2386
 
2240
2387
  ## Privacy and safety
2241
2388
 
@@ -2243,10 +2390,10 @@ Bro sends the selected assistant reply, pasted text, locally extracted document
2243
2390
 
2244
2391
  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
2392
 
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.
2393
+ Bro asks explain/show backends to use supplied context; Grok retains tool authority, so this is behavioral rather than enforced. \`/bro btw\` uses Agy sandbox controls or Grok conversation-only prompt instructions 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
2394
  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
2395
 
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.
2396
+ Doctor checks contact only the selected backends, but never send source text or run a model turn. Pressing **C** sends the explanation to your system clipboard.
2250
2397
 
2251
2398
  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.
2252
2399
 
@@ -2264,6 +2411,7 @@ class BroModal implements Focusable {
2264
2411
  private kind: ModalKind = "loading";
2265
2412
  private rawText = "";
2266
2413
  private sourceLabel = "";
2414
+ private modelLabel = "";
2267
2415
  private notice = "";
2268
2416
  private offset = 0;
2269
2417
  private maxOffset = 0;
@@ -2292,8 +2440,8 @@ class BroModal implements Focusable {
2292
2440
  this.setContent("streaming", text, "", false, false);
2293
2441
  }
2294
2442
 
2295
- setResult(text: string, retryable: boolean, notice = "", sourceLabel = "", rawText = text): void {
2296
- this.setContent("result", text, rawText, true, retryable, notice, sourceLabel);
2443
+ setResult(text: string, retryable: boolean, notice = "", sourceLabel = "", rawText = text, modelLabel = ""): void {
2444
+ this.setContent("result", text, rawText, true, retryable, notice, sourceLabel, modelLabel);
2297
2445
  }
2298
2446
 
2299
2447
  setHtmlPath(path: string): void {
@@ -2317,6 +2465,7 @@ class BroModal implements Focusable {
2317
2465
  retryable: boolean,
2318
2466
  notice = "",
2319
2467
  sourceLabel = "",
2468
+ modelLabel = "",
2320
2469
  ): void {
2321
2470
  this.kind = kind;
2322
2471
  if (kind !== "result") this.htmlPath = "";
@@ -2328,6 +2477,7 @@ class BroModal implements Focusable {
2328
2477
  .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
2329
2478
  .replace(/\s+/g, " ")
2330
2479
  .trim();
2480
+ this.modelLabel = modelLabel;
2331
2481
  if (kind !== "streaming") this.offset = 0;
2332
2482
  this.markdown.setText(text);
2333
2483
  this.tui.requestRender();
@@ -2377,7 +2527,11 @@ class BroModal implements Focusable {
2377
2527
 
2378
2528
  const lines = [
2379
2529
  this.borderLine(innerWidth, "top"),
2380
- this.frameLine(this.theme.fg("accent", this.theme.bold(`Bro${this.sourceLabel ? ` · ${this.sourceLabel}` : ""}${scroll}`)), innerWidth),
2530
+ this.frameLine(
2531
+ this.theme.fg("accent", this.theme.bold(`Bro${this.sourceLabel ? ` · ${this.sourceLabel}` : ""}`)) +
2532
+ this.theme.fg("dim", `${this.modelLabel ? ` · ${this.modelLabel}` : ""}${scroll}`),
2533
+ innerWidth,
2534
+ ),
2381
2535
  this.ruleLine(innerWidth),
2382
2536
  ];
2383
2537
 
@@ -2518,7 +2672,7 @@ async function showBroModal(ctx: ExtensionCommandContext, options: BroModalOptio
2518
2672
  current = result;
2519
2673
  options.onResult?.(result);
2520
2674
  const display = result.htmlPath ? stripShowHtmlFence(result.text) : result.text;
2521
- modal.setResult(display, options.retryable ?? true, "", result.source?.label, result.text);
2675
+ modal.setResult(display, options.retryable ?? true, "", result.source?.label, result.text, result.model);
2522
2676
  if (result.htmlPath) modal.setHtmlPath(result.htmlPath);
2523
2677
  })
2524
2678
  .catch((error) => {
@@ -2526,7 +2680,7 @@ async function showBroModal(ctx: ExtensionCommandContext, options: BroModalOptio
2526
2680
  const message = error instanceof Error ? error.message : String(error);
2527
2681
  if (previous) {
2528
2682
  current = previous;
2529
- modal.setResult(previous.text, options.retryable ?? true, `Retry failed: ${message}`, previous.source?.label);
2683
+ modal.setResult(previous.text, options.retryable ?? true, `Retry failed: ${message}`, previous.source?.label, previous.text, previous.model);
2530
2684
  } else {
2531
2685
  modal.setError(message);
2532
2686
  }
@@ -2539,7 +2693,7 @@ async function showBroModal(ctx: ExtensionCommandContext, options: BroModalOptio
2539
2693
  if (options.text !== undefined) {
2540
2694
  modal.setStatic(options.kind ?? "help", options.text, options.copyable ?? false);
2541
2695
  } else if (current) {
2542
- modal.setResult(current.text, options.retryable ?? Boolean(options.run), "", current.source?.label);
2696
+ modal.setResult(current.text, options.retryable ?? Boolean(options.run), "", current.source?.label, current.text, current.model);
2543
2697
  } else {
2544
2698
  execute();
2545
2699
  }
@@ -2581,6 +2735,13 @@ export function resolveBtwThread(existing: BtwThread | undefined, parsed: { fres
2581
2735
  return !existing || startFresh ? { turns: [], full: targetFull } : existing;
2582
2736
  }
2583
2737
 
2738
+ export function bindBtwBackend(thread: BtwThread, backend: BackendName): boolean {
2739
+ const changed = thread.backend !== undefined && thread.backend !== backend;
2740
+ if (changed) { thread.turns = []; thread.conversationId = undefined; }
2741
+ thread.backend = backend;
2742
+ return changed;
2743
+ }
2744
+
2584
2745
  export function formatBtwTranscript(turns: readonly BtwTurn[]): string {
2585
2746
  return turns
2586
2747
  .map((turn) => {
@@ -2630,11 +2791,11 @@ async function runBtwTurn(
2630
2791
  signal: AbortSignal,
2631
2792
  onProgress?: (text: string) => void,
2632
2793
  ): 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.
2794
+ // Claude continuation is not wired yet; Agy and Grok use native sessions.
2795
+ // Reject Claude explicitly instead of falling back to another backend.
2636
2796
  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.");
2797
+ const name = "Claude";
2798
+ throw new Error(`\`btw\` is not supported on the ${name} backend. Run \`/bro config\` to give it an Agy model.`);
2638
2799
  }
2639
2800
  let updateTimer: ReturnType<typeof setTimeout> | undefined;
2640
2801
  let latest: string | undefined;
@@ -2657,7 +2818,7 @@ async function runBtwTurn(
2657
2818
  feature: "btw",
2658
2819
  prompt,
2659
2820
  access: options.full ? "workspace-full" : "restricted",
2660
- cwd: options.full ? options.cwd : undefined,
2821
+ cwd: options.cwd,
2661
2822
  continuation: options.conversationId ? { id: options.conversationId } : undefined,
2662
2823
  },
2663
2824
  selection,
@@ -2681,6 +2842,7 @@ class BtwModal implements Focusable {
2681
2842
  private bodyHeight = 1;
2682
2843
  private running = false;
2683
2844
  private full = false;
2845
+ private model = "";
2684
2846
  private disposed = false;
2685
2847
 
2686
2848
  get focused(): boolean {
@@ -2721,6 +2883,11 @@ class BtwModal implements Focusable {
2721
2883
  this.tui.requestRender();
2722
2884
  }
2723
2885
 
2886
+ setModel(model: string): void {
2887
+ this.model = model;
2888
+ this.tui.requestRender();
2889
+ }
2890
+
2724
2891
  setFull(full: boolean): void {
2725
2892
  this.full = full;
2726
2893
  this.tui.requestRender();
@@ -2782,10 +2949,9 @@ class BtwModal implements Focusable {
2782
2949
  const hiddenBelow = Math.max(0, this.maxOffset - this.offset);
2783
2950
  const scroll = this.maxOffset > 0 ? ` · ↑${this.offset} ↓${hiddenBelow}` : "";
2784
2951
 
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}`);
2952
+ const model = this.model ? this.theme.fg("dim", ` · ${this.model}`) : "";
2953
+ const mode = this.full ? this.theme.fg("dim", " · ") + this.theme.fg("accent", this.theme.bold("full · edits repo")) : "";
2954
+ const header = this.theme.fg("accent", this.theme.bold("Bro · btw")) + model + mode + this.theme.fg("dim", scroll);
2789
2955
 
2790
2956
  const composer = this.input.render(innerWidth)[0] ?? "";
2791
2957
 
@@ -2847,6 +3013,14 @@ async function openBtwModal(
2847
3013
  modal.setRunning(true);
2848
3014
  modal.clearComposer();
2849
3015
 
3016
+ let settings: BroSettings;
3017
+ try {
3018
+ settings = await readSettings();
3019
+ const backend = capabilityBackend(settings, "btw");
3020
+ if (bindBtwBackend(thread, backend)) modal.setNotice("Backend changed — started a fresh side thread.");
3021
+ } catch (error) {
3022
+ controller = undefined; modal.setRunning(false); modal.setNotice(errorMessage(error)); return;
3023
+ }
2850
3024
  const first = thread.turns.length === 0;
2851
3025
  let context: string | undefined;
2852
3026
  if (first && options.seed) {
@@ -2861,10 +3035,12 @@ async function openBtwModal(
2861
3035
  modal.setText(transcript());
2862
3036
 
2863
3037
  try {
2864
- const settings = await readSettings();
3038
+ const selection = selectionForCapability(settings, "btw");
3039
+ thread.model = selectionLabel(selection);
3040
+ modal.setModel(thread.model);
2865
3041
  const result = await runBtwTurn(
2866
3042
  buildBtwPrompt(context, question),
2867
- selectionForCapability(settings, "btw"),
3043
+ selection,
2868
3044
  { full: thread.full, cwd: ctx.cwd, conversationId: thread.conversationId },
2869
3045
  turnController.signal,
2870
3046
  (partial) => {
@@ -2962,6 +3138,7 @@ async function openBtwModal(
2962
3138
  }
2963
3139
 
2964
3140
  modal.setFull(thread.full);
3141
+ modal.setModel(thread.model ?? "");
2965
3142
  modal.setText(transcript());
2966
3143
 
2967
3144
  if (options.initialQuestion) void runTurn(options.initialQuestion);
@@ -2985,7 +3162,7 @@ export default async function bro(pi: ExtensionAPI) {
2985
3162
  let lastResult: BroResult | undefined;
2986
3163
  let btwThread: BtwThread | undefined;
2987
3164
  const remember = (result: ModalResult) => {
2988
- if (result.source) lastResult = { source: result.source, text: result.text };
3165
+ if (result.source) lastResult = { source: result.source, text: result.text, model: result.model };
2989
3166
  };
2990
3167
 
2991
3168
  pi.on("session_start", async (_event, _ctx) => {
@@ -3061,8 +3238,8 @@ export default async function bro(pi: ExtensionAPI) {
3061
3238
  // the activity throttle window.
3062
3239
  activityCount: run.activityCount,
3063
3240
  };
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`;
3241
+ // The backend qualifier appears only off Agy, keeping the Agy header byte-identical.
3242
+ 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
3243
  const context = `Context · cwd: ${JSON.stringify(ctx.cwd)} · steering: ${details.steeringIncluded ? "included" : "none"} · snapshot: ${snapshot.text.length} chars · Bro truncation: none · omissions: ${omissions}`;
3067
3244
  return { content: [{ type: "text", text: `${header}\n${context}\n\n${run.advice}` }], details };
3068
3245
  } finally {
@@ -3142,13 +3319,14 @@ export default async function bro(pi: ExtensionAPI) {
3142
3319
  return { text: "**Nothing to show yet**\n\nThis session has no conversation turns to draw. Run something first, then press **R**." };
3143
3320
  }
3144
3321
  let text: string;
3322
+ let model: string;
3145
3323
  try {
3146
- text = await runShowExplanation(captured.text, steering, signal, await readSettings(), onProgress);
3324
+ ({ text, model } = await runShowExplanation(captured.text, steering, signal, await readSettings(), onProgress));
3147
3325
  } catch (error) {
3148
3326
  throw new Error(withDoctor(error));
3149
3327
  }
3150
3328
  const html = extractShowHtml(text);
3151
- return { source: captured, text, ...(html ? { htmlPath: await writeShowHtml(html) } : {}) };
3329
+ return { source: captured, text, model, ...(html ? { htmlPath: await writeShowHtml(html) } : {}) };
3152
3330
  };
3153
3331
  try {
3154
3332
  await showBroModal(ctx, {
@@ -3179,7 +3357,7 @@ export default async function bro(pi: ExtensionAPI) {
3179
3357
  try {
3180
3358
  return {
3181
3359
  source: target,
3182
- text: await simplify(target.text, signal, await readSettings(), onProgress),
3360
+ ...(await simplify(target.text, signal, await readSettings(), onProgress)),
3183
3361
  };
3184
3362
  } catch (error) {
3185
3363
  throw new Error(withDoctor(error));
@@ -3215,24 +3393,6 @@ export default async function bro(pi: ExtensionAPI) {
3215
3393
  return;
3216
3394
  }
3217
3395
 
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
3396
  if (action === "mode") {
3237
3397
  const requested = parts[1];
3238
3398
  if (parts.length > 2 || (requested && !parseBroMode(requested))) {
@@ -3270,7 +3430,44 @@ export default async function bro(pi: ExtensionAPI) {
3270
3430
  try {
3271
3431
  const settings = await readSettings();
3272
3432
  // /bro model operates on the effective shared backend: no Agy catalog
3273
- // when the shared default is Claude.
3433
+ // when the shared default is Claude/Grok.
3434
+ if ((settings.backend ?? "agy") === "grok") {
3435
+ const requested = value.trim();
3436
+ let model: string | undefined;
3437
+ if (requested) {
3438
+ try {
3439
+ model = resolveGrokModel(requested);
3440
+ } catch {
3441
+ ctx.ui.notify("Use /bro model or /bro model <grok-4.7|grok-4.7-build-fast|model-id>.", "warning");
3442
+ return;
3443
+ }
3444
+ } else {
3445
+ if (ctx.mode !== "tui") {
3446
+ ctx.ui.notify("Use /bro model <grok-4.7|grok-4.7-build-fast|model-id> outside Pi's interactive UI.", "warning");
3447
+ return;
3448
+ }
3449
+ const choices = [
3450
+ ...GROK_MODELS.map(
3451
+ (entry) => `${entry.id} — ${entry.label}${entry.id === settings.model ? " (current)" : ""}`,
3452
+ ),
3453
+ "Custom — enter a model ID…",
3454
+ ];
3455
+ const choice = await ctx.ui.select(`Grok model (current: ${settings.model})`, choices);
3456
+ if (!choice) return;
3457
+ if (choice.startsWith("Custom")) {
3458
+ const input = await ctx.ui.input("Grok model", "grok-4.7, grok-4.7-build-fast, or an explicit model ID");
3459
+ if (!input?.trim()) return;
3460
+ model = resolveGrokModel(input);
3461
+ } else {
3462
+ model = GROK_MODELS[choices.indexOf(choice)]?.id;
3463
+ }
3464
+ }
3465
+ if (!model) return;
3466
+ const effort = settings.backend === "grok" && isGrokEffort(settings.effort) ? settings.effort : "default";
3467
+ await writeSettings({ ...settings, backend: "grok", model, effort });
3468
+ ctx.ui.notify(`Bro model: grok ${model}${effort === "default" ? "" : ` (${effort})`}`, "info");
3469
+ return;
3470
+ }
3274
3471
  if ((settings.backend ?? "agy") === "claude") {
3275
3472
  const requested = value.trim();
3276
3473
  let model: string | undefined;
@@ -3363,7 +3560,7 @@ export default async function bro(pi: ExtensionAPI) {
3363
3560
 
3364
3561
  if (action === "effort") {
3365
3562
  const requested = parts[1];
3366
- // Claude levels pass this gate; each backend branch below validates strictly
3563
+ // Claude/Grok levels pass this gate; each backend branch below validates strictly
3367
3564
  // (the Agy branch still rejects xhigh/max with its supports-list warning).
3368
3565
  if (parts.length > 2 || (requested && !isClaudeEffort(requested) && !EFFORTS.some((effort) => effort === requested))) {
3369
3566
  ctx.ui.notify("Use /bro effort, or choose low, medium, or high.", "warning");
@@ -3372,7 +3569,35 @@ export default async function bro(pi: ExtensionAPI) {
3372
3569
  try {
3373
3570
  const settings = await readSettings();
3374
3571
  // /bro effort operates on the effective shared backend: no Agy catalog
3375
- // when the shared default is Claude.
3572
+ // when the shared default is Claude/Grok.
3573
+ if ((settings.backend ?? "agy") === "grok") {
3574
+ const allowed = ["default", ...GROK_EFFORTS] as const;
3575
+ if (requested && !allowed.some((effort) => effort === requested)) {
3576
+ ctx.ui.notify("Use /bro effort, or choose default, low, medium, high, or xhigh.", "warning");
3577
+ return;
3578
+ }
3579
+ let selected = requested as BroEffort | undefined;
3580
+ if (!selected) {
3581
+ if (ctx.mode !== "tui") {
3582
+ ctx.ui.notify("Use /bro effort <default|low|medium|high|xhigh> outside Pi's interactive UI.", "warning");
3583
+ return;
3584
+ }
3585
+ const efforts = [...allowed].sort(
3586
+ (a, b) => Number(b === settings.effort) - Number(a === settings.effort),
3587
+ );
3588
+ const choices = efforts.map((effort) => `${effort}${effort === settings.effort ? " (current)" : ""}`);
3589
+ const choice = await ctx.ui.select(`Grok reasoning effort (current: ${settings.effort})`, choices);
3590
+ if (!choice) return;
3591
+ selected = efforts[choices.indexOf(choice)];
3592
+ }
3593
+ if (!selected || !isGrokEffort(selected)) return;
3594
+ await writeSettings({ ...settings, backend: "grok", model: settings.model, effort: selected });
3595
+ ctx.ui.notify(
3596
+ selected === "default" ? "Bro reasoning effort: built into the selected model" : `Bro reasoning effort: ${selected}`,
3597
+ "info",
3598
+ );
3599
+ return;
3600
+ }
3376
3601
  if ((settings.backend ?? "agy") === "claude") {
3377
3602
  const allowed = ["default", ...CLAUDE_EFFORTS] as const;
3378
3603
  if (requested && !allowed.some((effort) => effort === requested)) {
@@ -3455,8 +3680,10 @@ export default async function bro(pi: ExtensionAPI) {
3455
3680
  return;
3456
3681
  }
3457
3682
  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.");
3683
+ const btwBackend = capabilityBackend(await readSettings(), "btw");
3684
+ if (btwBackend === "claude") throw new Error(`${btwBackend === "claude" ? "Claude" : "Grok"} does not support /bro btw yet; choose an Agy or Grok override in /bro config.`);
3459
3685
  const thread = resolveBtwThread(btwThread, parsed);
3686
+ if (bindBtwBackend(thread, btwBackend)) ctx.ui.notify("Backend changed — started a fresh side thread.", "info");
3460
3687
  btwThread = thread;
3461
3688
  await openBtwModal(ctx, { thread, initialQuestion: parsed.question, seed: !parsed.fresh });
3462
3689
  } catch (error) {
@@ -3535,7 +3762,7 @@ export default async function bro(pi: ExtensionAPI) {
3535
3762
  const settings = await readSettings();
3536
3763
  return {
3537
3764
  source: target,
3538
- text: await simplify(target.text, signal, settings, onProgress),
3765
+ ...(await simplify(target.text, signal, settings, onProgress)),
3539
3766
  };
3540
3767
  } catch (error) {
3541
3768
  throw new Error(withDoctor(error));