pi-bro 0.15.1 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bro.ts CHANGED
@@ -21,10 +21,13 @@ import {
21
21
  agyFailureMessage,
22
22
  agySelection,
23
23
  advisorFlagErrorHint,
24
+ CLAUDE_EFFORTS,
25
+ backendSupports,
24
26
  execute as executeBackend,
25
27
  parseBtwAgyLine,
26
28
  type AgySelection,
27
29
  type BackendProgress,
30
+ type BackendSelection,
28
31
  } from "./backend.ts";
29
32
 
30
33
  export { agyFailureMessage, agySelection, advisorFlagErrorHint, parseBtwAgyLine };
@@ -61,22 +64,32 @@ type ModalResult = { source?: BroSource; text: string; htmlPath?: string };
61
64
  type BtwTurn = { question: string; answer: string };
62
65
  type BtwThread = { turns: BtwTurn[]; conversationId?: string; full: boolean };
63
66
  const EFFORTS = ["default", "low", "medium", "high"] as const;
64
- type BroEffort = (typeof EFFORTS)[number];
65
- type AgyEffort = Exclude<BroEffort, "default">;
67
+ const BACKENDS = ["agy", "claude"] as const;
68
+ type BackendName = (typeof BACKENDS)[number];
69
+ type BroEffort = "default" | "low" | "medium" | "high" | "xhigh" | "max";
70
+ type AgyEffort = Exclude<BroEffort, "default" | "xhigh" | "max">;
71
+ type ClaudeEffort = (typeof CLAUDE_EFFORTS)[number];
66
72
  // "advisor" is a real capability (command, Agy invocation, Doctor check) like the other three;
67
73
  // see docs/plans/2026-09-19-bro-advisor-design.md. All four share one model/effort resolution,
68
74
  // override, and Doctor-check path via this single list -- there is no configuration-only tier.
69
75
  const CAPABILITIES = ["explain", "show", "btw", "advisor"] as const;
70
76
  type Capability = (typeof CAPABILITIES)[number];
71
77
  const CAPABILITY_LABELS: Record<Capability, string> = { explain: "Explain", show: "Show", btw: "Btw", advisor: "Advisor" };
72
- type ModelEffortPair = { model: string; effort: BroEffort };
78
+ type ModelEffortPair = { backend?: BackendName; model: string; effort: BroEffort };
73
79
  type BroSettings = {
80
+ backend?: BackendName;
74
81
  model: string;
75
82
  effort: BroEffort;
76
83
  mode: BroMode;
77
84
  showTurns: number;
78
85
  overrides: Partial<Record<Capability, ModelEffortPair>>;
79
86
  };
87
+ // BackendSelection (shared execution routing selection) lives in backend.ts; the backend
88
+ // field stays optional there so every legacy backend-less pair keeps meaning Agy.
89
+ export const CLAUDE_MODELS = [
90
+ { id: "sonnet", label: "Claude Sonnet" },
91
+ { id: "opus", label: "Claude Opus" },
92
+ ] as const;
80
93
  type AgyModelFamily = {
81
94
  id: string;
82
95
  label: string;
@@ -486,11 +499,38 @@ export async function extractWebPage(input: string, signal?: AbortSignal): Promi
486
499
  }
487
500
  }
488
501
 
502
+ function parseBackend(value: unknown, context: string): BackendName {
503
+ if (typeof value !== "string" || !BACKENDS.some((backend) => backend === value)) {
504
+ throw new Error(`${context} backend must be "agy" or "claude".`);
505
+ }
506
+ return value as BackendName;
507
+ }
508
+
509
+ function isAgyEffort(effort: unknown): effort is AgyEffort | "default" {
510
+ return EFFORTS.some((item) => item === effort);
511
+ }
512
+
513
+ function isClaudeEffort(effort: unknown): effort is ClaudeEffort | "default" {
514
+ return effort === "default" || CLAUDE_EFFORTS.some((item) => item === effort);
515
+ }
516
+
489
517
  function parseModelEffortPair(value: unknown, context: string): ModelEffortPair {
490
- if (!isRecord(value) || typeof value.model !== "string" || !value.model.trim() || !EFFORTS.some((effort) => effort === value.effort)) {
518
+ if (!isRecord(value) || typeof value.model !== "string" || !value.model.trim()) {
491
519
  throw new Error(`${context} must contain a model and effort set to "default", "low", "medium", or "high".`);
492
520
  }
493
- return { model: value.model.trim(), effort: value.effort as BroEffort };
521
+ const backend = value.backend === undefined ? undefined : parseBackend(value.backend, context);
522
+ if (backend === "claude") {
523
+ if (!isClaudeEffort(value.effort)) {
524
+ throw new Error(`${context} must contain a model and effort set to "default", "low", "medium", "high", "xhigh", or "max".`);
525
+ }
526
+ return { backend, model: value.model.trim(), effort: value.effort };
527
+ }
528
+ if (!isAgyEffort(value.effort)) {
529
+ throw new Error(`${context} must contain a model and effort set to "default", "low", "medium", or "high".`);
530
+ }
531
+ const pair: ModelEffortPair = { model: value.model.trim(), effort: value.effort };
532
+ if (backend !== undefined) pair.backend = backend;
533
+ return pair;
494
534
  }
495
535
 
496
536
  function parseOverrides(value: unknown): Partial<Record<Capability, ModelEffortPair>> {
@@ -505,14 +545,42 @@ function parseOverrides(value: unknown): Partial<Record<Capability, ModelEffortP
505
545
  }
506
546
 
507
547
  export function parseBroSettings(value: unknown): BroSettings {
508
- if (
509
- !isRecord(value) ||
510
- typeof value.model !== "string" ||
511
- !value.model.trim() ||
512
- !EFFORTS.some((effort) => effort === value.effort)
513
- ) {
548
+ if (!isRecord(value)) {
514
549
  throw new Error('Settings must contain a model and effort set to "default", "low", "medium", or "high".');
515
550
  }
551
+ // v2 disk shape: { version: 2, default: { backend, model, effort }, mode, showTurns, overrides }.
552
+ // Reads never rewrite: a legacy flat file keeps parsing into backend-less (Agy) settings.
553
+ if (value.version !== undefined && value.version !== 2) throw new Error(`Unsupported settings version ${JSON.stringify(value.version)}.`);
554
+ if (value.version === 2 && value.default === undefined) throw new Error("Settings version 2 requires default.");
555
+ if (value.default !== undefined) {
556
+ if (!isRecord(value.default)) throw new Error("Settings default must contain a model and effort.");
557
+ if (value.version !== undefined && value.version !== 2) {
558
+ throw new Error(`Unsupported settings version ${JSON.stringify(value.version)}.`);
559
+ }
560
+ const pair = parseModelEffortPair(value.default, "Settings default");
561
+ const mode = value.mode === undefined ? DEFAULT_BRO_MODE : parseBroMode(value.mode);
562
+ if (!mode) throw new Error('Settings mode must be "brief", "balanced", or "faithful".');
563
+ const showTurns = value.showTurns === undefined ? DEFAULT_SHOW_TURNS : value.showTurns;
564
+ if (typeof showTurns !== "number" || !Number.isInteger(showTurns) || showTurns < 1) {
565
+ throw new Error("Settings showTurns must be a positive whole number of turns.");
566
+ }
567
+ const overrides = parseOverrides(value.overrides);
568
+ const settings: BroSettings = { model: pair.model, effort: pair.effort, mode, showTurns, overrides };
569
+ if (pair.backend !== undefined) settings.backend = pair.backend;
570
+ return settings;
571
+ }
572
+ if (typeof value.model !== "string" || !value.model.trim()) {
573
+ throw new Error('Settings must contain a model and effort set to "default", "low", "medium", or "high".');
574
+ }
575
+ const backend = value.backend === undefined ? undefined : parseBackend(value.backend, "Settings");
576
+ const effortOk = backend === "claude" ? isClaudeEffort(value.effort) : isAgyEffort(value.effort);
577
+ if (!effortOk) {
578
+ throw new Error(
579
+ backend === "claude"
580
+ ? '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".',
582
+ );
583
+ }
516
584
  const mode = value.mode === undefined ? DEFAULT_BRO_MODE : parseBroMode(value.mode);
517
585
  if (!mode) throw new Error('Settings mode must be "brief", "balanced", or "faithful".');
518
586
  const showTurns = value.showTurns === undefined ? DEFAULT_SHOW_TURNS : value.showTurns;
@@ -520,7 +588,15 @@ export function parseBroSettings(value: unknown): BroSettings {
520
588
  throw new Error("Settings showTurns must be a positive whole number of turns.");
521
589
  }
522
590
  const overrides = parseOverrides(value.overrides);
523
- return { model: value.model.trim(), effort: value.effort as BroSettings["effort"], mode, showTurns, overrides };
591
+ const settings: BroSettings = {
592
+ model: value.model.trim(),
593
+ effort: value.effort as BroSettings["effort"],
594
+ mode,
595
+ showTurns,
596
+ overrides,
597
+ };
598
+ if (backend !== undefined) settings.backend = backend;
599
+ return settings;
524
600
  }
525
601
 
526
602
  function capabilityOverride(settings: BroSettings, capability: Capability): ModelEffortPair | undefined {
@@ -528,7 +604,7 @@ function capabilityOverride(settings: BroSettings, capability: Capability): Mode
528
604
  }
529
605
 
530
606
  function capabilityPair(settings: BroSettings, capability: Capability): ModelEffortPair {
531
- return capabilityOverride(settings, capability) ?? { model: settings.model, effort: settings.effort };
607
+ return capabilityOverride(settings, capability) ?? { ...(settings.backend ? { backend: settings.backend } : {}), model: settings.model, effort: settings.effort };
532
608
  }
533
609
 
534
610
  // An override always pins both model and effort together (never just one), so a capability's
@@ -554,13 +630,55 @@ export function resolveModelEffort(
554
630
  pair: ModelEffortPair,
555
631
  families: AgyModelFamily[],
556
632
  ): { pair: ModelEffortPair; family?: AgyModelFamily } {
633
+ // Claude selections never resolve through the Agy catalog; they pass through untouched.
634
+ if (pair.backend === "claude") return { pair };
557
635
  const family = families.find((item) => item.id === pair.model || item.variants.some((variant) => variant.id === pair.model));
558
636
  if (!family) return { pair };
559
637
  const variant = family.variants.find((item) => item.id === pair.model);
560
- return {
561
- family,
562
- pair: { model: family.id, effort: pair.effort === "default" && variant?.effort ? variant.effort : pair.effort },
638
+ const resolved: ModelEffortPair = {
639
+ model: family.id,
640
+ effort: pair.effort === "default" && variant?.effort ? variant.effort : pair.effort,
563
641
  };
642
+ if (pair.backend !== undefined) resolved.backend = pair.backend;
643
+ return { family, pair: resolved };
644
+ }
645
+
646
+ // A capability's effective backend: an explicit override is a complete selection, so a
647
+ // backend-less override still means Agy (all legacy stays Agy) and never inherits the
648
+ // shared default's backend. Only a capability without an override inherits the default.
649
+ export function capabilityBackend(settings: BroSettings, capability: Capability): BackendName {
650
+ const override = settings.overrides[capability];
651
+ if (override) return override.backend ?? "agy";
652
+ return settings.backend ?? "agy";
653
+ }
654
+
655
+ // Sonnet/opus aliases resolve case-insensitively; any other non-empty string passes
656
+ // through untouched as an explicit user-entered model ID.
657
+ export function resolveClaudeModel(input: string): string {
658
+ const trimmed = input.trim();
659
+ if (!trimmed) throw new Error("Claude model must be a non-empty model ID (sonnet, opus, or an explicit model ID).");
660
+ const alias = CLAUDE_MODELS.find((model) => model.id === trimmed.toLowerCase());
661
+ return alias ? alias.id : trimmed;
662
+ }
663
+
664
+ // 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
666
+ // ("default" means the CLI's own default and is omitted). Throws for an effort the
667
+ // resolved backend does not support instead of silently sending a mismatched pair.
668
+ export function selectionForCapability(settings: BroSettings, capability: Capability): BackendSelection {
669
+ 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.`);
673
+ }
674
+ return pair.effort === "default"
675
+ ? { backend: "claude", model: pair.model }
676
+ : { backend: "claude", model: pair.model, effort: pair.effort };
677
+ }
678
+ if (!isAgyEffort(pair.effort)) {
679
+ throw new Error(`\`${pair.effort}\` is not supported on the Agy backend. Run \`/bro config\` to fix this.`);
680
+ }
681
+ return agySelection({ model: pair.model, effort: pair.effort });
564
682
  }
565
683
 
566
684
  function resolveCapabilitySettings(
@@ -631,8 +749,17 @@ async function readSettings(): Promise<BroSettings> {
631
749
  // any individual override that happens to match the shared default; see withCapabilityOverride
632
750
  // for why an explicit override is always kept until the user clears it back to "Default".
633
751
  export function settingsPayload(settings: BroSettings): Record<string, unknown> {
634
- const { overrides, ...rest } = settings;
635
- return Object.keys(overrides).length ? { ...rest, overrides } : rest;
752
+ const overrides: Record<string, unknown> = {};
753
+ for (const [capability, pair] of Object.entries(settings.overrides)) {
754
+ if (pair) overrides[capability] = { backend: pair.backend ?? "agy", model: pair.model, effort: pair.effort };
755
+ }
756
+ return {
757
+ version: 2,
758
+ default: { backend: settings.backend ?? "agy", model: settings.model, effort: settings.effort },
759
+ mode: settings.mode,
760
+ showTurns: settings.showTurns,
761
+ ...(Object.keys(overrides).length ? { overrides } : {}),
762
+ };
636
763
  }
637
764
 
638
765
  async function writeSettings(settings: BroSettings): Promise<void> {
@@ -744,6 +871,47 @@ function preferredEffort(family: AgyModelFamily): BroEffort {
744
871
  return family.efforts.includes("low") ? "low" : (family.efforts[0] ?? "default");
745
872
  }
746
873
 
874
+ async function checkClaudeVersion(pi: ExtensionAPI, signal: AbortSignal): Promise<string> {
875
+ const runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
876
+ try {
877
+ const result = await pi.exec("claude", ["--version"], { cwd: runDirectory, signal, timeout: 10_000 });
878
+ if (signal.aborted) throw new Error("Canceled.");
879
+ if (result.killed || result.code !== 0) {
880
+ const detail = result.stderr.trim() || result.stdout.trim();
881
+ throw new Error(
882
+ detail
883
+ ? `Claude could not start: ${detail}\n\nRun \`/bro doctor\` for setup help.`
884
+ : "Claude could not start. Make sure Claude is installed and on PATH, then run `/bro doctor`.",
885
+ );
886
+ }
887
+ const version = result.stdout.trim() || result.stderr.trim();
888
+ if (!version) throw new Error("Claude returned no version information. Update Claude, then run `/bro doctor` again.");
889
+ return version;
890
+ } finally {
891
+ await rm(runDirectory, { recursive: true, force: true });
892
+ }
893
+ }
894
+
895
+ // Auth status only: a version/auth answer without a model request. A passing answer here
896
+ // says the CLI starts and reports signed-in state -- it does not imply connectivity.
897
+ async function checkClaudeAuth(pi: ExtensionAPI, signal: AbortSignal): Promise<string> {
898
+ const runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
899
+ try {
900
+ const result = await pi.exec("claude", ["auth", "status"], { cwd: runDirectory, signal, timeout: 15_000 });
901
+ if (signal.aborted) throw new Error("Canceled.");
902
+ if (result.killed || result.code !== 0) {
903
+ const detail = result.stderr.trim() || result.stdout.trim();
904
+ throw new Error(detail ? `Claude auth status: ${detail}` : "Claude auth status is unknown. Sign in, then run `/bro doctor`.");
905
+ }
906
+ let status: unknown;
907
+ try { status = JSON.parse(result.stdout); } catch { throw new Error("Claude returned unreadable auth status; sign in and retry /bro doctor."); }
908
+ if (!isRecord(status) || status.loggedIn !== true) throw new Error("Claude is not signed in. Run `claude auth login`.");
909
+ return "authentication configured (not a connectivity test)";
910
+ } finally {
911
+ await rm(runDirectory, { recursive: true, force: true });
912
+ }
913
+ }
914
+
747
915
  async function checkAgyUsage(pi: ExtensionAPI, signal: AbortSignal): Promise<string> {
748
916
  const runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
749
917
  try {
@@ -790,53 +958,113 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
790
958
  fail("Prompt", error);
791
959
  }
792
960
 
961
+ // Probe only the backends some feature actually selects: a Claude-only setup never
962
+ // requires Agy to be installed, and vice versa.
963
+ const agyInUse = !settings || capabilityBackend(settings, "explain") === "agy" || capabilityBackend(settings, "show") === "agy" || capabilityBackend(settings, "btw") === "agy" || capabilityBackend(settings, "advisor") === "agy" || (settings.backend ?? "agy") === "agy";
964
+ const claudeInUse = !!settings && (capabilityBackend(settings, "explain") === "claude" || capabilityBackend(settings, "show") === "claude" || capabilityBackend(settings, "btw") === "claude" || capabilityBackend(settings, "advisor") === "claude" || settings.backend === "claude");
965
+
793
966
  let agyStarted = false;
794
- try {
795
- agyVersion = await checkAgyVersion(pi, signal);
796
- pass("Agy", agyVersion);
797
- agyStarted = true;
798
- } catch (error) {
799
- if (signal.aborted) throw error;
800
- fail("Agy", error);
967
+ if (agyInUse) {
968
+ try {
969
+ agyVersion = await checkAgyVersion(pi, signal);
970
+ pass("Agy", agyVersion);
971
+ agyStarted = true;
972
+ } catch (error) {
973
+ if (signal.aborted) throw error;
974
+ fail("Agy", error);
975
+ }
976
+
977
+ if (agyStarted) {
978
+ try {
979
+ models = await listAgyModels(pi, signal);
980
+ pass("Model catalog", `${models.length} model${models.length === 1 ? "" : "s"} available`);
981
+ } catch (error) {
982
+ if (signal.aborted) throw error;
983
+ fail("Model catalog", error);
984
+ }
985
+
986
+ try {
987
+ await checkAgyUsage(pi, signal);
988
+ pass("Account", "connected");
989
+ } catch (error) {
990
+ if (signal.aborted) throw error;
991
+ fail("Account", error);
992
+ }
993
+ }
994
+ } else {
995
+ pass("Agy", "not probed — no feature selects the Agy backend");
801
996
  }
802
997
 
803
- if (agyStarted) {
998
+ // Version and auth status only, never a model request. A passing answer says the CLI
999
+ // starts and reports signed-in state -- it does not imply connectivity.
1000
+ if (claudeInUse) {
804
1001
  try {
805
- models = await listAgyModels(pi, signal);
806
- pass("Model catalog", `${models.length} model${models.length === 1 ? "" : "s"} available`);
1002
+ pass("Claude", await checkClaudeVersion(pi, signal));
807
1003
  } catch (error) {
808
1004
  if (signal.aborted) throw error;
809
- fail("Model catalog", error);
1005
+ fail("Claude", error);
810
1006
  }
811
-
812
1007
  try {
813
- await checkAgyUsage(pi, signal);
814
- pass("Account", "connected");
1008
+ pass("Claude auth", await checkClaudeAuth(pi, signal));
815
1009
  } catch (error) {
816
1010
  if (signal.aborted) throw error;
817
- fail("Account", error);
1011
+ fail("Claude auth", error);
818
1012
  }
1013
+ } else {
1014
+ pass("Claude", "not probed — no feature selects the Claude backend");
819
1015
  }
820
1016
 
821
- if (settings && models) {
822
- const current = resolveCatalogSettings(settings, models);
823
- if (!current.family) {
824
- fail("Selected model", `\`${settings.model}\` is unavailable. Run \`/bro model\` to choose another.`);
825
- } else {
826
- pass("Selected model", `\`${current.family.id}\``);
827
- const effort = current.settings.effort;
828
- if (!current.family.efforts.length && effort === "default") {
1017
+ if (settings) {
1018
+ const defaultBackend = settings.backend ?? "agy";
1019
+ if (defaultBackend === "claude") {
1020
+ pass("Selected model", `claude \`${settings.model}\``);
1021
+ if (!isClaudeEffort(settings.effort)) {
1022
+ fail("Reasoning effort", `\`${settings.effort}\` is unsupported. Run \`/bro effort\` to choose another.`);
1023
+ } else if (settings.effort === "default") {
829
1024
  pass("Reasoning effort", "built into the selected model");
830
- } else if (effort !== "default" && current.family.efforts.includes(effort)) {
831
- pass("Reasoning effort", effort);
832
1025
  } else {
833
- fail("Reasoning effort", `\`${effort}\` is unsupported. Run \`/bro effort\` to choose another.`);
1026
+ pass("Reasoning effort", settings.effort);
1027
+ }
1028
+ } else if (models) {
1029
+ const current = resolveCatalogSettings(settings, models);
1030
+ if (!current.family) {
1031
+ fail("Selected model", `\`${settings.model}\` is unavailable. Run \`/bro model\` to choose another.`);
1032
+ } else {
1033
+ pass("Selected model", `agy \`${current.family.id}\``);
1034
+ const effort = current.settings.effort;
1035
+ if (!current.family.efforts.length && effort === "default") {
1036
+ pass("Reasoning effort", "built into the selected model");
1037
+ } else if (effort !== "default" && current.family.efforts.includes(effort as AgyEffort)) {
1038
+ pass("Reasoning effort", effort);
1039
+ } else {
1040
+ fail("Reasoning effort", `\`${effort}\` is unsupported. Run \`/bro effort\` to choose another.`);
1041
+ }
834
1042
  }
835
1043
  }
836
1044
 
837
1045
  for (const capability of CAPABILITIES) {
838
1046
  const label = CAPABILITY_LABELS[capability];
839
1047
  const override = capabilityOverride(settings, capability);
1048
+ const backend = capabilityBackend(settings, capability);
1049
+ const pair = capabilityPair(settings, capability);
1050
+ 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
+ if (!isClaudeEffort(pair.effort)) {
1056
+ fail(label, `\`${pair.effort}\` is unsupported for claude \`${pair.model}\`. Run \`/bro config\` to fix this.`);
1057
+ continue;
1058
+ }
1059
+ pass(
1060
+ label,
1061
+ override
1062
+ ? `override claude \`${pair.model}\`${pair.effort === "default" ? "" : ` (${pair.effort})`}`
1063
+ : `claude \`${pair.model}\`${pair.effort === "default" ? "" : ` (${pair.effort})`} · using the shared default`,
1064
+ );
1065
+ continue;
1066
+ }
1067
+ if (!models) continue;
840
1068
  const resolved = resolveCapabilitySettings(settings, capability, models);
841
1069
  if (!resolved.family) {
842
1070
  fail(label, `\`${resolved.pair.model}\` is unavailable. Run \`/bro config\` to fix this override.`);
@@ -844,7 +1072,7 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
844
1072
  }
845
1073
  const effortOk = !resolved.family.efforts.length
846
1074
  ? resolved.pair.effort === "default"
847
- : resolved.pair.effort !== "default" && resolved.family.efforts.includes(resolved.pair.effort);
1075
+ : resolved.pair.effort !== "default" && resolved.family.efforts.includes(resolved.pair.effort as AgyEffort);
848
1076
  if (!effortOk) {
849
1077
  fail(label, `\`${resolved.pair.effort}\` is unsupported for \`${resolved.family.id}\`. Run \`/bro config\` to fix this.`);
850
1078
  continue;
@@ -852,7 +1080,7 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
852
1080
  pass(
853
1081
  label,
854
1082
  override
855
- ? `override \`${resolved.family.id}\`${resolved.pair.effort === "default" ? "" : ` (${resolved.pair.effort})`}`
1083
+ ? `override agy \`${resolved.family.id}\`${resolved.pair.effort === "default" ? "" : ` (${resolved.pair.effort})`}`
856
1084
  : "using the shared default",
857
1085
  );
858
1086
  }
@@ -866,11 +1094,15 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
866
1094
  if (advisorExposed && !advisorActive) fail("Advisor tool", "bro_advisor is exposed but not active in this session. Run /reload.");
867
1095
  else pass("Advisor tool", advisorExposed ? "bro_advisor is exposed and active" : "bro_advisor is not exposed by this host (tool restriction, or the extension has not finished loading)");
868
1096
  pass("Advisor steering", resolveAdvisorState(ctx.sessionManager.getBranch()).steering.trim() ? "present" : "none");
869
- if (agyVersion && advisorAgyCompatible(agyVersion)) pass("Advisor compatibility", ADVISOR_COMPATIBILITY);
1097
+ if (settings && capabilityBackend(settings, "advisor") === "claude") {
1098
+ pass("Advisor compatibility", "Claude backend — no Agy version floor applies");
1099
+ } else if (agyVersion && advisorAgyCompatible(agyVersion)) pass("Advisor compatibility", ADVISOR_COMPATIBILITY);
870
1100
  else if (agyVersion) fail("Advisor compatibility", `installed \`${agyVersion}\`; requires Agy >=1.1.15. Run \`agy update\`.`);
871
1101
 
872
1102
  return `# Bro doctor\n\n${lines.join("\n")}\n\n**${failed ? "Bro needs attention." : "Bro is ready."}**\n\n${
873
- failed ? "Fix the failed items, then press **R** to check again." : "No assistant response was sent and no model turn was run."
1103
+ failed
1104
+ ? "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."
874
1106
  }`;
875
1107
  }
876
1108
 
@@ -1064,7 +1296,7 @@ async function simplify(
1064
1296
  settings: BroSettings,
1065
1297
  onProgress?: (text: string) => void,
1066
1298
  ): Promise<string> {
1067
- return runAgyText((await promptFor(response, settings.mode)).text, agySelection(capabilityPair(settings, "explain")), signal, onProgress);
1299
+ return runAgyText((await promptFor(response, settings.mode)).text, selectionForCapability(settings, "explain"), signal, onProgress);
1068
1300
  }
1069
1301
 
1070
1302
  async function runShowExplanation(
@@ -1074,7 +1306,7 @@ async function runShowExplanation(
1074
1306
  settings: BroSettings,
1075
1307
  onProgress?: (text: string) => void,
1076
1308
  ): Promise<string> {
1077
- return runAgyText(buildShowPrompt(transcript, steering), agySelection(capabilityPair(settings, "show")), signal, onProgress, "show");
1309
+ return runAgyText(buildShowPrompt(transcript, steering), selectionForCapability(settings, "show"), signal, onProgress, "show");
1078
1310
  }
1079
1311
 
1080
1312
  // Thin presentation-boundary wrapper around the shared backend: coalesces raw text progress to the
@@ -1082,7 +1314,7 @@ async function runShowExplanation(
1082
1314
  // tagged outcome back into this function's existing throw-on-failure contract.
1083
1315
  async function runAgyText(
1084
1316
  prompt: string,
1085
- selection: AgySelection,
1317
+ selection: BackendSelection,
1086
1318
  signal: AbortSignal,
1087
1319
  onProgress?: (text: string) => void,
1088
1320
  feature: "explain" | "show" = "explain",
@@ -1253,7 +1485,7 @@ export type AdvisorActivityCallback = (label: string, timestamp: number) => void
1253
1485
  // which owns the 3-attempt retry/backoff policy the backend itself never performs.
1254
1486
  export async function runAdvisorConsultation(
1255
1487
  prompt: string,
1256
- selection: AgySelection,
1488
+ selection: BackendSelection,
1257
1489
  cwd: string,
1258
1490
  signal: AbortSignal,
1259
1491
  killEscalationMs = 5_000,
@@ -1302,7 +1534,7 @@ function advisorDelay(ms: number, signal: AbortSignal, onTick?: (remainingMs: nu
1302
1534
 
1303
1535
  export type AdvisorConsult = (
1304
1536
  prompt: string,
1305
- selection: AgySelection,
1537
+ selection: BackendSelection,
1306
1538
  cwd: string,
1307
1539
  signal: AbortSignal,
1308
1540
  killEscalationMs?: number,
@@ -1316,6 +1548,7 @@ export type AdvisorToolDetails = {
1316
1548
  elapsedMs: number;
1317
1549
  error?: string;
1318
1550
  retryInMs?: number;
1551
+ backend?: BackendName;
1319
1552
  model?: string;
1320
1553
  effort?: string;
1321
1554
  durationMs?: number;
@@ -1348,7 +1581,7 @@ const ADVISOR_ACTIVITY_THROTTLE_MS = 250;
1348
1581
  // --conversation, even across retries.
1349
1582
  export async function runAdvisorWithRetries(
1350
1583
  prompt: string,
1351
- selection: AgySelection,
1584
+ selection: BackendSelection,
1352
1585
  cwd: string,
1353
1586
  signal: AbortSignal,
1354
1587
  consult: AdvisorConsult = runAdvisorConsultation,
@@ -1455,11 +1688,12 @@ export function advisorAttemptLabel(details: AdvisorToolDetails): string {
1455
1688
  return `Bro advisor · ${details.model ?? "unknown model"} · ${details.attempt} attempt${details.attempt === 1 ? "" : "s"} · ${Math.ceil((details.durationMs ?? details.elapsedMs) / 1_000)}s`;
1456
1689
  }
1457
1690
  const latest = details.activity?.at(-1);
1458
- // "last reported" + freshness, never a claim about what Agy is doing right now and never
1691
+ const backendLabel = details.backend === "claude" ? "Claude" : "Agy";
1692
+ // "last reported" + freshness, never a claim about what the backend is doing right now and never
1459
1693
  // "stalled" -- silence since lastActivityAt is not itself evidence of a stuck run.
1460
1694
  const activityLabel = latest
1461
1695
  ? `last reported: ${advisorActivityPreview(latest)} (${Math.max(0, Math.floor((Date.now() - (details.lastActivityAt ?? Date.now())) / 1_000))}s ago)`
1462
- : "awaiting first activity from Agy";
1696
+ : `awaiting first activity from ${backendLabel}`;
1463
1697
  return `Bro advisor · running · ${Math.floor(details.elapsedMs / 1_000)}s · attempt ${details.attempt}/${details.of} · ${activityLabel}`;
1464
1698
  }
1465
1699
 
@@ -1473,7 +1707,10 @@ function showTurnsValues(current: number): string[] {
1473
1707
  // one that simply isn't in the current catalog (both used to render as "fixed", which read as
1474
1708
  // falsely healthy for an unavailable model), and flags a stored effort that isn't one of the
1475
1709
  // resolved family's supported efforts instead of silently showing it as if it were valid.
1476
- function effortDisplay(resolved: { pair: ModelEffortPair; family?: AgyModelFamily }): string {
1710
+ 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).
1713
+ if (backend === "claude") return isClaudeEffort(resolved.pair.effort) ? resolved.pair.effort : `${resolved.pair.effort} (unsupported)`;
1477
1714
  if (!resolved.family) return "unavailable";
1478
1715
  const fixed = !resolved.family.efforts.length;
1479
1716
  const valid = fixed ? resolved.pair.effort === "default" : resolved.family.efforts.includes(resolved.pair.effort as AgyEffort);
@@ -1481,6 +1718,16 @@ function effortDisplay(resolved: { pair: ModelEffortPair; family?: AgyModelFamil
1481
1718
  return fixed ? "fixed" : resolved.pair.effort;
1482
1719
  }
1483
1720
 
1721
+ // Model-picker values that switch to the Claude backend carry a "claude:" prefix so one
1722
+ // atomic picker commit changes backend+model together -- cancelling the picker (Esc)
1723
+ // leaves both unchanged via the existing submenu-cancel path.
1724
+ const CLAUDE_OPTION_PREFIX = "claude:";
1725
+ function parseClaudeOption(value: string): string | undefined {
1726
+ return value.startsWith(CLAUDE_OPTION_PREFIX) && value.length > CLAUDE_OPTION_PREFIX.length
1727
+ ? value.slice(CLAUDE_OPTION_PREFIX.length)
1728
+ : undefined;
1729
+ }
1730
+
1484
1731
  // Testable core: takes settings/catalog/persist as plain arguments so smoke tests can drive
1485
1732
  // the exact interaction (submenus, cancel, cycling, save failure) without a real Agy process
1486
1733
  // or settings file. showBroConfigModal below wires this to the real ctx/pi/filesystem.
@@ -1517,13 +1764,33 @@ export function createConfigModal(
1517
1764
  value: family.id,
1518
1765
  label: `${family.label}${family.efforts.length ? "" : " · fixed effort"}`,
1519
1766
  })),
1767
+ // Claude entries stay last so existing Agy keyboard navigation is unaffected.
1768
+ ...CLAUDE_MODELS.map((model) => ({
1769
+ value: `${CLAUDE_OPTION_PREFIX}${model.id}`,
1770
+ label: `${model.label} · claude`,
1771
+ })),
1520
1772
  ];
1773
+ options.push({ value: "__claude_custom__", label: "Claude · custom model ID…" });
1774
+ const input = new Input();
1775
+ let enteringModel = false;
1776
+ input.onSubmit = (value) => { if (value.trim()) pickerDone(`${CLAUDE_OPTION_PREFIX}${value.trim()}`); };
1521
1777
  const picker = new SelectList(options, Math.min(options.length, 8), getSelectListTheme());
1522
1778
  const selectedIndex = capability && current === "Default" ? 0 : options.findIndex((option) => option.value === current);
1523
1779
  picker.setSelectedIndex(Math.max(0, selectedIndex));
1524
- picker.onSelect = (item) => pickerDone(item.value);
1780
+ picker.onSelect = (item) => {
1781
+ if (item.value === "__claude_custom__") { enteringModel = true; tui.requestRender(); }
1782
+ else pickerDone(item.value);
1783
+ };
1525
1784
  picker.onCancel = () => pickerDone();
1526
- return picker;
1785
+ return {
1786
+ render: (width: number) => enteringModel ? ["Claude model ID (Enter saves, Esc cancels)", ...input.render(width)] : picker.render(width),
1787
+ invalidate: () => { picker.invalidate(); input.invalidate(); },
1788
+ handleInput: (data: string) => {
1789
+ if (enteringModel && matchesKey(data, "escape")) pickerDone();
1790
+ else if (enteringModel) input.handleInput(data);
1791
+ else picker.handleInput(data);
1792
+ },
1793
+ };
1527
1794
  };
1528
1795
 
1529
1796
  const modelItem: SettingItem = { id: "model", label: "Default model", currentValue: settings.model, submenu: modelPicker };
@@ -1555,21 +1822,27 @@ export function createConfigModal(
1555
1822
  ) as Record<Capability, { model: SettingItem; effort: SettingItem }>;
1556
1823
 
1557
1824
  function refresh(): void {
1825
+ const defaultBackend = settings.backend ?? "agy";
1558
1826
  const def = resolveModelEffort({ model: settings.model, effort: settings.effort }, families);
1559
- modelItem.currentValue = def.family?.id ?? settings.model;
1560
- effortItem.currentValue = effortDisplay(def);
1561
- effortItem.values = def.family?.efforts.length ? [...def.family.efforts] : undefined;
1827
+ modelItem.currentValue = defaultBackend === "claude" ? `${CLAUDE_OPTION_PREFIX}${settings.model}` : (def.family?.id ?? settings.model);
1828
+ effortItem.currentValue = effortDisplay(def, defaultBackend);
1829
+ effortItem.values = defaultBackend === "claude" ? ["default", ...CLAUDE_EFFORTS] : def.family?.efforts.length ? [...def.family.efforts] : undefined;
1562
1830
  modeItem.currentValue = settings.mode;
1563
1831
  showTurnsItem.currentValue = String(settings.showTurns);
1564
1832
  showTurnsItem.values = showTurnsValues(settings.showTurns);
1565
1833
 
1566
1834
  for (const capability of CAPABILITIES) {
1567
1835
  const override = capabilityOverride(settings, capability);
1836
+ const backend = capabilityBackend(settings, capability);
1568
1837
  const resolved = resolveModelEffort(capabilityPair(settings, capability), families);
1569
1838
  const rows = capabilityItems[capability];
1570
- rows.model.currentValue = override ? (resolved.family?.id ?? override.model) : "Default";
1571
- rows.effort.currentValue = effortDisplay(resolved);
1572
- rows.effort.values = resolved.family?.efforts.length ? [...resolved.family.efforts] : undefined;
1839
+ rows.model.currentValue = !override
1840
+ ? "Default"
1841
+ : backend === "claude"
1842
+ ? `${CLAUDE_OPTION_PREFIX}${override.model}`
1843
+ : (resolved.family?.id ?? override.model);
1844
+ 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;
1573
1846
  }
1574
1847
  }
1575
1848
  refresh();
@@ -1624,17 +1897,30 @@ export function createConfigModal(
1624
1897
 
1625
1898
  const onChange = (id: string, newValue: string) => {
1626
1899
  if (id === "model") {
1627
- const family = findFamily(newValue);
1628
- if (!family) return;
1629
- const keepCurrent = settings.effort === "default" ? !family.efforts.length : family.efforts.includes(settings.effort as AgyEffort);
1630
- settings = { ...settings, model: family.id, effort: keepCurrent ? settings.effort : preferredEffort(family) };
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 };
1904
+ } else {
1905
+ const family = findFamily(newValue);
1906
+ if (!family) return;
1907
+ const keepCurrent = settings.backend !== "claude" && (settings.effort === "default" ? !family.efforts.length : family.efforts.includes(settings.effort as AgyEffort));
1908
+ // Backend-less internal pairs mean Agy; disk saves still tag them explicitly.
1909
+ const { backend: _dropped, ...rest } = settings;
1910
+ settings = { ...rest, model: family.id, effort: keepCurrent ? settings.effort : preferredEffort(family) };
1911
+ }
1631
1912
  } else if (id === "effort") {
1632
- // Effort-only edit: pin the resolved family's canonical id, same reasoning as the
1633
- // capability-override effort-only edit below -- otherwise a shared default created
1634
- // from a suffixed variant id (e.g. "gemini-x-low") would end up paired with an
1635
- // unrelated effort instead of its actual family id.
1636
- const resolved = resolveModelEffort({ model: settings.model, effort: settings.effort }, families);
1637
- settings = { ...settings, model: resolved.family?.id ?? settings.model, effort: newValue as BroEffort };
1913
+ if ((settings.backend ?? "agy") === "claude") {
1914
+ if (!isClaudeEffort(newValue)) return;
1915
+ settings = { ...settings, effort: newValue };
1916
+ } else {
1917
+ // Effort-only edit: pin the resolved family's canonical id, same reasoning as the
1918
+ // capability-override effort-only edit below -- otherwise a shared default created
1919
+ // from a suffixed variant id (e.g. "gemini-x-low") would end up paired with an
1920
+ // unrelated effort instead of its actual family id.
1921
+ const resolved = resolveModelEffort({ model: settings.model, effort: settings.effort }, families);
1922
+ settings = { ...settings, model: resolved.family?.id ?? settings.model, effort: newValue as BroEffort };
1923
+ }
1638
1924
  } else if (id === "mode") {
1639
1925
  const mode = parseBroMode(newValue);
1640
1926
  if (!mode) return;
@@ -1650,15 +1936,30 @@ export function createConfigModal(
1650
1936
  if (newValue === "__default__") {
1651
1937
  settings = withCapabilityOverride(settings, capability, undefined);
1652
1938
  } else {
1653
- const family = findFamily(newValue);
1654
- if (!family) return;
1655
- const currentEffort = capabilityPair(settings, capability).effort;
1656
- const keepCurrent = currentEffort === "default" ? !family.efforts.length : family.efforts.includes(currentEffort as AgyEffort);
1657
- settings = withCapabilityOverride(settings, capability, {
1658
- model: family.id,
1659
- effort: keepCurrent ? currentEffort : preferredEffort(family),
1660
- });
1939
+ const claudeId = parseClaudeOption(newValue);
1940
+ if (claudeId !== undefined) {
1941
+ 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
+ });
1947
+ } else {
1948
+ const family = findFamily(newValue);
1949
+ if (!family) return;
1950
+ const currentEffort = capabilityPair(settings, capability).effort;
1951
+ const keepCurrent = capabilityBackend(settings, capability) === "agy" && (currentEffort === "default" ? !family.efforts.length : family.efforts.includes(currentEffort as AgyEffort));
1952
+ settings = withCapabilityOverride(settings, capability, {
1953
+ model: family.id,
1954
+ effort: keepCurrent ? currentEffort : preferredEffort(family),
1955
+ });
1956
+ }
1661
1957
  }
1958
+ } else if (capabilityBackend(settings, capability) === "claude") {
1959
+ if (!isClaudeEffort(newValue)) return;
1960
+ const existing = capabilityOverride(settings, capability);
1961
+ const model = existing?.model ?? settings.model;
1962
+ settings = withCapabilityOverride(settings, capability, { backend: "claude", model, effort: newValue });
1662
1963
  } else {
1663
1964
  // Effort-only edit: pin the resolved family's canonical id, never whatever raw
1664
1965
  // string happens to sit in settings.model/override.model (which — for a shared
@@ -1716,14 +2017,21 @@ export async function showBroConfigModal(ctx: ExtensionCommandContext, pi: Exten
1716
2017
  return;
1717
2018
  }
1718
2019
  let settings: BroSettings;
1719
- let families: AgyModelFamily[];
1720
2020
  try {
1721
2021
  settings = await readSettings();
1722
- families = await listAgyModels(pi);
1723
2022
  } catch (error) {
1724
2023
  ctx.ui.notify(withDoctor(error), "error");
1725
2024
  return;
1726
2025
  }
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.
2028
+ let families: AgyModelFamily[];
2029
+ try {
2030
+ families = await listAgyModels(pi);
2031
+ } catch (error) {
2032
+ families = [];
2033
+ ctx.ui.notify("Agy model catalog unavailable — showing Claude settings without Agy choices.", "warning");
2034
+ }
1727
2035
  await ctx.ui.custom<void>(createConfigModal(settings, families, writeSettings), {
1728
2036
  overlay: true,
1729
2037
  overlayOptions: {
@@ -1845,13 +2153,15 @@ export function helpText(settings?: BroSettings, settingsError?: string): string
1845
2153
  ? CAPABILITIES.map((capability) => {
1846
2154
  const override = capabilityOverride(settings, capability);
1847
2155
  return override
1848
- ? `- **${CAPABILITY_LABELS[capability]} override:** \`${override.model}\`${override.effort === "default" ? "" : ` (${override.effort})`}`
2156
+ ? `- **${CAPABILITY_LABELS[capability]} override:** ${override.backend ?? "agy"} \`${override.model}\`${override.effort === "default" ? "" : ` (${override.effort})`}`
1849
2157
  : undefined;
1850
2158
  }).filter((line): line is string => line !== undefined)
1851
2159
  : [];
1852
2160
  const settingsSummary = settings
1853
- ? `- **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")}` : ""}`
2161
+ ? `- **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")}` : ""}`
1854
2162
  : `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";
1855
2165
  return `# Bro
1856
2166
 
1857
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.
@@ -1871,14 +2181,14 @@ Press **R** to simplify the captured source again. Run a new \`/bro text\`, \`/b
1871
2181
 
1872
2182
  ## Check and configure
1873
2183
 
1874
- - \`/bro doctor\` — check settings, Agy, account, model, effort, and mode
2184
+ - \`/bro doctor\` — check settings, backends, account, model, effort, and mode (per-feature backend/model/effort)
1875
2185
  - \`/bro usage [--provider agy]\` — show current Agy limits
1876
- - \`/bro model [id]\` — view or choose the shared default Agy model
1877
- - \`/bro effort [low|medium|high]\` — view or choose the shared default reasoning effort
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)
1878
2188
  - \`/bro mode [brief|balanced|faithful]\` — view or choose explanation mode
1879
- - \`/bro config\` — open an interactive settings screen for the shared default model/effort, explain mode, show turns, and per-capability (explain/show/btw/advisor) model and effort overrides. Changes save immediately; Esc on a picker cancels without changing anything, Esc on the screen closes it and keeps whatever was already saved.
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.
1880
2190
 
1881
- \`/bro model\` and \`/bro effort\` always change the shared default that explain, show, btw, and advisor fall back to when they have no override. Use \`/bro config\` to give one of them its own model or effort.
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.
1882
2192
 
1883
2193
  ## Side conversation
1884
2194
 
@@ -1886,12 +2196,12 @@ Press **R** to simplify the captured source again. Run a new \`/bro text\`, \`/b
1886
2196
 
1887
2197
  ## Advisor
1888
2198
 
1889
- - \`bro_advisor\` — a tool the executor agent can voluntarily call mid-task for a second opinion from a fresh Agy process before or after a non-trivial decision. It is registered like any other tool and has no on/off switch of its own; whether the executor can actually call it depends entirely on this host's own tool restrictions
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
1890
2200
  - \`/bro advisor\` — a quick notice of whether \`bro_advisor\` is available right now, pointing at \`/bro config\`, \`/bro advisor-steer\`, and \`/bro doctor\`
1891
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
1892
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
1893
2203
 
1894
- Each consultation is a fresh, standalone Agy process — never resumed, never looping, never automatically triggered. Bro captures the context snapshot (system instructions, active tools, and the conversation so far 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.
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.
1895
2205
 
1896
2206
  ## Current settings
1897
2207
 
@@ -1925,11 +2235,11 @@ Bro temporarily captures mouse input while the modal is open. Native mouse selec
1925
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.
1926
2236
  - Show reflects what was reported in the conversation, not independent verification against the actual code or system state.
1927
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.
1928
- - 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 Agy's own diagnostic as the failure.
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.
1929
2239
 
1930
2240
  ## Privacy and safety
1931
2241
 
1932
- 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 Agy and your model provider. They may retain request data under their own policies.
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.
1933
2243
 
1934
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.
1935
2245
 
@@ -1938,7 +2248,7 @@ For webpages, it connects directly to the site without browser cookies; the site
1938
2248
 
1939
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.
1940
2250
 
1941
- Each advisor consultation sends the executor's system instructions, active tool list, ordered conversation (including tool calls and results, since the advisor needs to verify claims), your steering brief, and the executor's optional question to Agy and your model provider; the advisor process itself can read and edit the workspace with no permission prompts. The steering brief 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.
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.
1942
2252
 
1943
2253
  ## Custom prompt
1944
2254
 
@@ -2315,11 +2625,17 @@ export function parseBtwComposerCommand(value: string): BtwComposerAction {
2315
2625
  // failed turns.
2316
2626
  async function runBtwTurn(
2317
2627
  prompt: string,
2318
- selection: AgySelection,
2628
+ selection: BackendSelection,
2319
2629
  options: { full: boolean; cwd: string; conversationId?: string },
2320
2630
  signal: AbortSignal,
2321
2631
  onProgress?: (text: string) => void,
2322
2632
  ): 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
+ }
2323
2639
  let updateTimer: ReturnType<typeof setTimeout> | undefined;
2324
2640
  let latest: string | undefined;
2325
2641
  const throttledProgress = onProgress
@@ -2548,7 +2864,7 @@ async function openBtwModal(
2548
2864
  const settings = await readSettings();
2549
2865
  const result = await runBtwTurn(
2550
2866
  buildBtwPrompt(context, question),
2551
- agySelection(capabilityPair(settings, "btw")),
2867
+ selectionForCapability(settings, "btw"),
2552
2868
  { full: thread.full, cwd: ctx.cwd, conversationId: thread.conversationId },
2553
2869
  turnController.signal,
2554
2870
  (partial) => {
@@ -2681,10 +2997,10 @@ export default async function bro(pi: ExtensionAPI) {
2681
2997
  name: ADVISOR_TOOL_NAME,
2682
2998
  label: "Bro advisor",
2683
2999
  description:
2684
- "Consult a fresh, independent Agy process for a second opinion mid-task. It has real, unsandboxed tool access in the current workspace (read files, search, run commands) with permissions auto-approved, and is instructed to investigate before advising and to leave edits to you — that is a behavioral instruction to the advisor, not an enforced restriction, so treat its findings as advice rather than a delegated implementation. You never need to prepare a summary or evidence first: Bro automatically captures your system instructions, active tools, and the conversation so far, plus any human-set steering priorities, and sends them to the advisor.",
2685
- promptSnippet: "Consult a fresh Agy process for a second opinion mid-task; it investigates the workspace itself and returns advice",
3000
+ "Consult a fresh, independent process of the configured advisor backend for a second opinion mid-task. It has real, unsandboxed tool access in the current workspace (read files, search, run commands) with permissions auto-approved, and is instructed to investigate before advising and to leave edits to you — that is a behavioral instruction to the advisor, not an enforced restriction, so treat its findings as advice rather than a delegated implementation. You never need to prepare a summary or evidence first: Bro automatically captures your system instructions, active tools, and the conversation so far, plus any human-set steering priorities, and sends them to the advisor.",
3001
+ promptSnippet: "Consult a fresh configured-backend process for a second opinion mid-task; it investigates the workspace itself and returns advice",
2686
3002
  promptGuidelines: [
2687
- "Call bro_advisor before or after a non-trivial design or scope decision, or when genuinely uncertain, for a second opinion from a fresh, independent Agy process.",
3003
+ "Call bro_advisor before or after a non-trivial design or scope decision, or when genuinely uncertain, for a second opinion from a fresh, independent process of the configured advisor backend.",
2688
3004
  "bro_advisor's question parameter is optional — never delay a call to first prepare a summary or evidence; Bro captures context automatically.",
2689
3005
  "Any human-set steering priorities are applied automatically by bro_advisor; you don't need to relay or repeat them.",
2690
3006
  "bro_advisor is instructed to only return advice and leave edits to you — that instruction is not enforced, so verify its findings yourself rather than treating them as a completed implementation.",
@@ -2697,10 +3013,11 @@ export default async function bro(pi: ExtensionAPI) {
2697
3013
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
2698
3014
  const state = resolveAdvisorState(ctx.sessionManager.getBranch());
2699
3015
  const settings = await readSettings();
2700
- const selection = agySelection(capabilityPair(settings, "advisor"));
3016
+ const selection = selectionForCapability(settings, "advisor");
3017
+ const backend = selection.backend ?? "agy";
2701
3018
  const snapshot = buildAdvisorSnapshot(ctx, pi);
2702
3019
  const prompt = buildAdvisorPrompt(state.steering, snapshot.text, params.question);
2703
- let lastAttempt: AdvisorToolDetails = { status: "investigating", attempt: 1, of: 3, elapsedMs: 0 };
3020
+ let lastAttempt: AdvisorToolDetails = { status: "investigating", attempt: 1, of: 3, elapsedMs: 0, backend };
2704
3021
  try {
2705
3022
  const run = await runAdvisorWithRetries(
2706
3023
  prompt,
@@ -2710,7 +3027,7 @@ export default async function bro(pi: ExtensionAPI) {
2710
3027
  undefined,
2711
3028
  undefined,
2712
3029
  (details) => {
2713
- lastAttempt = details;
3030
+ lastAttempt = { ...details, backend };
2714
3031
  const label = advisorAttemptLabel(lastAttempt);
2715
3032
  onUpdate?.({
2716
3033
  content: [{ type: "text", text: label }],
@@ -2731,6 +3048,7 @@ export default async function bro(pi: ExtensionAPI) {
2731
3048
  ...lastAttempt,
2732
3049
  status: "done",
2733
3050
  attempt: run.attempts,
3051
+ backend,
2734
3052
  model: selection.model,
2735
3053
  effort,
2736
3054
  durationMs: run.durationMs,
@@ -2743,7 +3061,8 @@ export default async function bro(pi: ExtensionAPI) {
2743
3061
  // the activity throttle window.
2744
3062
  activityCount: run.activityCount,
2745
3063
  };
2746
- const header = `Bro advisor · model: ${selection.model} · effort: ${effort} · ${run.attempts} attempt${run.attempts === 1 ? "" : "s"} · ${Math.ceil(run.durationMs / 1_000)}s`;
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`;
2747
3066
  const context = `Context · cwd: ${JSON.stringify(ctx.cwd)} · steering: ${details.steeringIncluded ? "included" : "none"} · snapshot: ${snapshot.text.length} chars · Bro truncation: none · omissions: ${omissions}`;
2748
3067
  return { content: [{ type: "text", text: `${header}\n${context}\n\n${run.advice}` }], details };
2749
3068
  } finally {
@@ -2950,6 +3269,45 @@ export default async function bro(pi: ExtensionAPI) {
2950
3269
  }
2951
3270
  try {
2952
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
+ }
2953
3311
  const models = await listAgyModels(pi);
2954
3312
  const current = resolveCatalogSettings(settings, models);
2955
3313
  const requested = parts[1];
@@ -2989,7 +3347,7 @@ export default async function bro(pi: ExtensionAPI) {
2989
3347
  const currentEffort = current.settings.effort;
2990
3348
  const canKeepCurrent =
2991
3349
  current.family?.id === selected.id &&
2992
- (currentEffort === "default" ? !selected.efforts.length : selected.efforts.includes(currentEffort));
3350
+ (currentEffort === "default" ? !selected.efforts.length : selected.efforts.includes(currentEffort as AgyEffort));
2993
3351
  selectedEffort = canKeepCurrent ? currentEffort : preferredEffort(selected);
2994
3352
  }
2995
3353
  await writeSettings({ ...settings, model: selected.id, effort: selectedEffort });
@@ -3005,12 +3363,44 @@ export default async function bro(pi: ExtensionAPI) {
3005
3363
 
3006
3364
  if (action === "effort") {
3007
3365
  const requested = parts[1];
3008
- if (parts.length > 2 || (requested && !EFFORTS.some((effort) => effort === requested))) {
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))) {
3009
3369
  ctx.ui.notify("Use /bro effort, or choose low, medium, or high.", "warning");
3010
3370
  return;
3011
3371
  }
3012
3372
  try {
3013
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
+ }
3014
3404
  const current = resolveCatalogSettings(settings, await listAgyModels(pi));
3015
3405
  if (!current.family) {
3016
3406
  ctx.ui.notify(`Model "${settings.model}" is not in Agy's current model list. Run /bro model first.`, "warning");
@@ -3065,6 +3455,7 @@ export default async function bro(pi: ExtensionAPI) {
3065
3455
  return;
3066
3456
  }
3067
3457
  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.");
3068
3459
  const thread = resolveBtwThread(btwThread, parsed);
3069
3460
  btwThread = thread;
3070
3461
  await openBtwModal(ctx, { thread, initialQuestion: parsed.question, seed: !parsed.fresh });