pi-bro 0.15.0 → 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.
Files changed (5) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +50 -28
  3. package/backend.ts +724 -0
  4. package/bro.ts +574 -554
  5. package/package.json +3 -2
package/bro.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { spawn, spawnSync } from "node:child_process";
1
+ import { spawnSync } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import { lookup } from "node:dns/promises";
4
4
  import { mkdir, mkdtemp, readdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
@@ -7,7 +7,6 @@ import { request as httpsRequest } from "node:https";
7
7
  import { BlockList, isIP } from "node:net";
8
8
  import { homedir, tmpdir } from "node:os";
9
9
  import { extname, isAbsolute, join, relative, resolve, sep } from "node:path";
10
- import { createInterface } from "node:readline";
11
10
  import { stripVTControlCharacters } from "node:util";
12
11
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
13
12
  import { Container, Editor, Input, Markdown, SettingsList, SelectList, Text, matchesKey, truncateToWidth, visibleWidth, type Component, type EditorTheme, type Focusable, type SelectItem, type SettingItem, type TUI } from "@earendil-works/pi-tui";
@@ -18,6 +17,20 @@ import mammoth from "mammoth";
18
17
  import { Type } from "typebox";
19
18
  import { extractText } from "unpdf";
20
19
  import { BRO_MODES, DEFAULT_BRO_MODE, buildAdvisorPrompt, buildBtwPrompt, buildDefaultPrompt, buildShowPrompt, parseBroMode, type BroMode } from "./prompt.ts";
20
+ import {
21
+ agyFailureMessage,
22
+ agySelection,
23
+ advisorFlagErrorHint,
24
+ CLAUDE_EFFORTS,
25
+ backendSupports,
26
+ execute as executeBackend,
27
+ parseBtwAgyLine,
28
+ type AgySelection,
29
+ type BackendProgress,
30
+ type BackendSelection,
31
+ } from "./backend.ts";
32
+
33
+ export { agyFailureMessage, agySelection, advisorFlagErrorHint, parseBtwAgyLine };
21
34
 
22
35
  const AGENT_DIR = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
23
36
  const ENV_MODEL = process.env.PI_BRO_MODEL?.trim();
@@ -51,36 +64,38 @@ type ModalResult = { source?: BroSource; text: string; htmlPath?: string };
51
64
  type BtwTurn = { question: string; answer: string };
52
65
  type BtwThread = { turns: BtwTurn[]; conversationId?: string; full: boolean };
53
66
  const EFFORTS = ["default", "low", "medium", "high"] as const;
54
- type BroEffort = (typeof EFFORTS)[number];
55
- 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];
56
72
  // "advisor" is a real capability (command, Agy invocation, Doctor check) like the other three;
57
73
  // see docs/plans/2026-09-19-bro-advisor-design.md. All four share one model/effort resolution,
58
74
  // override, and Doctor-check path via this single list -- there is no configuration-only tier.
59
75
  const CAPABILITIES = ["explain", "show", "btw", "advisor"] as const;
60
76
  type Capability = (typeof CAPABILITIES)[number];
61
77
  const CAPABILITY_LABELS: Record<Capability, string> = { explain: "Explain", show: "Show", btw: "Btw", advisor: "Advisor" };
62
- type ModelEffortPair = { model: string; effort: BroEffort };
78
+ type ModelEffortPair = { backend?: BackendName; model: string; effort: BroEffort };
63
79
  type BroSettings = {
80
+ backend?: BackendName;
64
81
  model: string;
65
82
  effort: BroEffort;
66
83
  mode: BroMode;
67
84
  showTurns: number;
68
85
  overrides: Partial<Record<Capability, ModelEffortPair>>;
69
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;
70
93
  type AgyModelFamily = {
71
94
  id: string;
72
95
  label: string;
73
96
  efforts: AgyEffort[];
74
97
  variants: Array<{ id: string; effort?: AgyEffort }>;
75
98
  };
76
- type AgyEvent = {
77
- event?: string;
78
- conversation_id?: string;
79
- init?: { model?: string; cwd?: string; permission_mode?: string; tools?: unknown };
80
- step_update?: { step_type?: string; text_delta?: unknown };
81
- result?: { status?: string; response?: unknown; error?: unknown; conversation_id?: string };
82
- };
83
-
84
99
  export function wheelDelta(data: string): number {
85
100
  const match = /^\x1b\[<(\d+);\d+;\d+[Mm]$/.exec(data);
86
101
  if (!match) return 0;
@@ -484,21 +499,38 @@ export async function extractWebPage(input: string, signal?: AbortSignal): Promi
484
499
  }
485
500
  }
486
501
 
487
- export function agyFailureMessage(
488
- action: string,
489
- result: { code: number; killed: boolean; stderr: string },
490
- ): string {
491
- if (result.killed) return `Agy timed out while trying to ${action}. Run \`/bro doctor\` for setup help.`;
492
- const detail = result.stderr.trim();
493
- if (detail) return `Agy could not ${action}: ${detail}\n\nRun \`/bro doctor\` for setup help.`;
494
- return `Agy could not ${action}. Make sure Agy is installed and signed in, then run \`/bro doctor\`.`;
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);
495
515
  }
496
516
 
497
517
  function parseModelEffortPair(value: unknown, context: string): ModelEffortPair {
498
- 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()) {
499
519
  throw new Error(`${context} must contain a model and effort set to "default", "low", "medium", or "high".`);
500
520
  }
501
- 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;
502
534
  }
503
535
 
504
536
  function parseOverrides(value: unknown): Partial<Record<Capability, ModelEffortPair>> {
@@ -513,14 +545,42 @@ function parseOverrides(value: unknown): Partial<Record<Capability, ModelEffortP
513
545
  }
514
546
 
515
547
  export function parseBroSettings(value: unknown): BroSettings {
516
- if (
517
- !isRecord(value) ||
518
- typeof value.model !== "string" ||
519
- !value.model.trim() ||
520
- !EFFORTS.some((effort) => effort === value.effort)
521
- ) {
548
+ if (!isRecord(value)) {
522
549
  throw new Error('Settings must contain a model and effort set to "default", "low", "medium", or "high".');
523
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
+ }
524
584
  const mode = value.mode === undefined ? DEFAULT_BRO_MODE : parseBroMode(value.mode);
525
585
  if (!mode) throw new Error('Settings mode must be "brief", "balanced", or "faithful".');
526
586
  const showTurns = value.showTurns === undefined ? DEFAULT_SHOW_TURNS : value.showTurns;
@@ -528,7 +588,15 @@ export function parseBroSettings(value: unknown): BroSettings {
528
588
  throw new Error("Settings showTurns must be a positive whole number of turns.");
529
589
  }
530
590
  const overrides = parseOverrides(value.overrides);
531
- 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;
532
600
  }
533
601
 
534
602
  function capabilityOverride(settings: BroSettings, capability: Capability): ModelEffortPair | undefined {
@@ -536,7 +604,7 @@ function capabilityOverride(settings: BroSettings, capability: Capability): Mode
536
604
  }
537
605
 
538
606
  function capabilityPair(settings: BroSettings, capability: Capability): ModelEffortPair {
539
- 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 };
540
608
  }
541
609
 
542
610
  // An override always pins both model and effort together (never just one), so a capability's
@@ -562,13 +630,55 @@ export function resolveModelEffort(
562
630
  pair: ModelEffortPair,
563
631
  families: AgyModelFamily[],
564
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 };
565
635
  const family = families.find((item) => item.id === pair.model || item.variants.some((variant) => variant.id === pair.model));
566
636
  if (!family) return { pair };
567
637
  const variant = family.variants.find((item) => item.id === pair.model);
568
- return {
569
- family,
570
- pair: { model: family.id, effort: pair.effort === "default" && variant?.effort ? variant.effort : pair.effort },
638
+ const resolved: ModelEffortPair = {
639
+ model: family.id,
640
+ effort: pair.effort === "default" && variant?.effort ? variant.effort : pair.effort,
571
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 });
572
682
  }
573
683
 
574
684
  function resolveCapabilitySettings(
@@ -639,8 +749,17 @@ async function readSettings(): Promise<BroSettings> {
639
749
  // any individual override that happens to match the shared default; see withCapabilityOverride
640
750
  // for why an explicit override is always kept until the user clears it back to "Default".
641
751
  export function settingsPayload(settings: BroSettings): Record<string, unknown> {
642
- const { overrides, ...rest } = settings;
643
- 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
+ };
644
763
  }
645
764
 
646
765
  async function writeSettings(settings: BroSettings): Promise<void> {
@@ -752,13 +871,45 @@ function preferredEffort(family: AgyModelFamily): BroEffort {
752
871
  return family.efforts.includes("low") ? "low" : (family.efforts[0] ?? "default");
753
872
  }
754
873
 
755
- export function agySelection(pair: ModelEffortPair): { model: string; effort?: AgyEffort } {
756
- if (pair.effort === "default") return { model: pair.model };
757
- const suffix = (["low", "medium", "high"] as const).find((effort) => pair.model.endsWith(`-${effort}`));
758
- return {
759
- model: suffix ? pair.model.slice(0, -suffix.length - 1) : pair.model,
760
- effort: pair.effort,
761
- };
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
+ }
762
913
  }
763
914
 
764
915
  async function checkAgyUsage(pi: ExtensionAPI, signal: AbortSignal): Promise<string> {
@@ -807,53 +958,113 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
807
958
  fail("Prompt", error);
808
959
  }
809
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
+
810
966
  let agyStarted = false;
811
- try {
812
- agyVersion = await checkAgyVersion(pi, signal);
813
- pass("Agy", agyVersion);
814
- agyStarted = true;
815
- } catch (error) {
816
- if (signal.aborted) throw error;
817
- 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");
818
996
  }
819
997
 
820
- 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) {
821
1001
  try {
822
- models = await listAgyModels(pi, signal);
823
- pass("Model catalog", `${models.length} model${models.length === 1 ? "" : "s"} available`);
1002
+ pass("Claude", await checkClaudeVersion(pi, signal));
824
1003
  } catch (error) {
825
1004
  if (signal.aborted) throw error;
826
- fail("Model catalog", error);
1005
+ fail("Claude", error);
827
1006
  }
828
-
829
1007
  try {
830
- await checkAgyUsage(pi, signal);
831
- pass("Account", "connected");
1008
+ pass("Claude auth", await checkClaudeAuth(pi, signal));
832
1009
  } catch (error) {
833
1010
  if (signal.aborted) throw error;
834
- fail("Account", error);
1011
+ fail("Claude auth", error);
835
1012
  }
1013
+ } else {
1014
+ pass("Claude", "not probed — no feature selects the Claude backend");
836
1015
  }
837
1016
 
838
- if (settings && models) {
839
- const current = resolveCatalogSettings(settings, models);
840
- if (!current.family) {
841
- fail("Selected model", `\`${settings.model}\` is unavailable. Run \`/bro model\` to choose another.`);
842
- } else {
843
- pass("Selected model", `\`${current.family.id}\``);
844
- const effort = current.settings.effort;
845
- 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") {
846
1024
  pass("Reasoning effort", "built into the selected model");
847
- } else if (effort !== "default" && current.family.efforts.includes(effort)) {
848
- pass("Reasoning effort", effort);
849
1025
  } else {
850
- 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
+ }
851
1042
  }
852
1043
  }
853
1044
 
854
1045
  for (const capability of CAPABILITIES) {
855
1046
  const label = CAPABILITY_LABELS[capability];
856
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;
857
1068
  const resolved = resolveCapabilitySettings(settings, capability, models);
858
1069
  if (!resolved.family) {
859
1070
  fail(label, `\`${resolved.pair.model}\` is unavailable. Run \`/bro config\` to fix this override.`);
@@ -861,7 +1072,7 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
861
1072
  }
862
1073
  const effortOk = !resolved.family.efforts.length
863
1074
  ? resolved.pair.effort === "default"
864
- : 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);
865
1076
  if (!effortOk) {
866
1077
  fail(label, `\`${resolved.pair.effort}\` is unsupported for \`${resolved.family.id}\`. Run \`/bro config\` to fix this.`);
867
1078
  continue;
@@ -869,7 +1080,7 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
869
1080
  pass(
870
1081
  label,
871
1082
  override
872
- ? `override \`${resolved.family.id}\`${resolved.pair.effort === "default" ? "" : ` (${resolved.pair.effort})`}`
1083
+ ? `override agy \`${resolved.family.id}\`${resolved.pair.effort === "default" ? "" : ` (${resolved.pair.effort})`}`
873
1084
  : "using the shared default",
874
1085
  );
875
1086
  }
@@ -883,11 +1094,15 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
883
1094
  if (advisorExposed && !advisorActive) fail("Advisor tool", "bro_advisor is exposed but not active in this session. Run /reload.");
884
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)");
885
1096
  pass("Advisor steering", resolveAdvisorState(ctx.sessionManager.getBranch()).steering.trim() ? "present" : "none");
886
- 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);
887
1100
  else if (agyVersion) fail("Advisor compatibility", `installed \`${agyVersion}\`; requires Agy >=1.1.15. Run \`agy update\`.`);
888
1101
 
889
1102
  return `# Bro doctor\n\n${lines.join("\n")}\n\n**${failed ? "Bro needs attention." : "Bro is ready."}**\n\n${
890
- 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."
891
1106
  }`;
892
1107
  }
893
1108
 
@@ -1075,39 +1290,13 @@ async function promptFor(response: string, mode: BroMode): Promise<{ text: strin
1075
1290
  return { text: parts.join(JSON.stringify(response)), custom: true };
1076
1291
  }
1077
1292
 
1078
- function parseAgyLine(line: string): { delta?: string; result?: string } {
1079
- let event: AgyEvent;
1080
- try {
1081
- event = JSON.parse(line) as AgyEvent;
1082
- } catch {
1083
- throw new Error("Agy returned invalid streaming data.");
1084
- }
1085
-
1086
- if (
1087
- event.event === "step_update" &&
1088
- event.step_update?.step_type === "agent_response" &&
1089
- typeof event.step_update.text_delta === "string"
1090
- ) {
1091
- return { delta: event.step_update.text_delta };
1092
- }
1093
-
1094
- if (event.event === "result") {
1095
- if (event.result?.status !== "SUCCESS" || typeof event.result.response !== "string") {
1096
- throw new Error("Agy did not complete the explanation successfully.");
1097
- }
1098
- return { result: event.result.response };
1099
- }
1100
-
1101
- return {};
1102
- }
1103
-
1104
1293
  async function simplify(
1105
1294
  response: string,
1106
1295
  signal: AbortSignal,
1107
1296
  settings: BroSettings,
1108
1297
  onProgress?: (text: string) => void,
1109
1298
  ): Promise<string> {
1110
- 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);
1111
1300
  }
1112
1301
 
1113
1302
  async function runShowExplanation(
@@ -1117,114 +1306,40 @@ async function runShowExplanation(
1117
1306
  settings: BroSettings,
1118
1307
  onProgress?: (text: string) => void,
1119
1308
  ): Promise<string> {
1120
- return runAgyText(buildShowPrompt(transcript, steering), agySelection(capabilityPair(settings, "show")), signal, onProgress);
1309
+ return runAgyText(buildShowPrompt(transcript, steering), selectionForCapability(settings, "show"), signal, onProgress, "show");
1121
1310
  }
1122
1311
 
1312
+ // Thin presentation-boundary wrapper around the shared backend: coalesces raw text progress to the
1313
+ // existing 75ms cadence (unchanged from before the backend extraction) and translates the backend's
1314
+ // tagged outcome back into this function's existing throw-on-failure contract.
1123
1315
  async function runAgyText(
1124
1316
  prompt: string,
1125
- selection: ReturnType<typeof agySelection>,
1317
+ selection: BackendSelection,
1126
1318
  signal: AbortSignal,
1127
1319
  onProgress?: (text: string) => void,
1320
+ feature: "explain" | "show" = "explain",
1128
1321
  ): Promise<string> {
1129
- const runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
1130
1322
  let updateTimer: ReturnType<typeof setTimeout> | undefined;
1131
-
1132
- try {
1133
- const child = spawn(
1134
- "agy",
1135
- [
1136
- "--sandbox",
1137
- "--disable-slash-commands",
1138
- "--output-format",
1139
- "stream-json",
1140
- "--model",
1141
- selection.model,
1142
- ...(selection.effort ? ["--effort", selection.effort] : []),
1143
- "--print-timeout",
1144
- "2m",
1145
- "--print",
1146
- prompt,
1147
- ],
1148
- {
1149
- cwd: runDirectory,
1150
- signal,
1151
- timeout: 125_000,
1152
- stdio: ["ignore", "pipe", "pipe"],
1153
- windowsHide: true,
1154
- },
1155
- );
1156
-
1157
- let processError: Error | undefined;
1158
- let stderr = "";
1159
- let partial = "";
1160
- let final = "";
1161
- let parseError: Error | undefined;
1162
-
1163
- child.stderr.setEncoding("utf8");
1164
- child.stderr.on("data", (chunk: string) => {
1165
- stderr += chunk;
1166
- });
1167
- child.once("error", (error) => {
1168
- processError = error;
1169
- });
1170
-
1171
- const closed = new Promise<{ code: number | null; exitSignal: NodeJS.Signals | null }>((resolve) => {
1172
- child.once("close", (code, exitSignal) => resolve({ code, exitSignal }));
1173
- });
1174
-
1175
- const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
1176
- try {
1177
- for await (const line of lines) {
1178
- if (!line.trim()) continue;
1179
- try {
1180
- const event = parseAgyLine(line);
1181
- if (event.delta) {
1182
- partial += event.delta;
1183
- if (onProgress && !updateTimer) {
1184
- updateTimer = setTimeout(() => {
1185
- updateTimer = undefined;
1186
- if (!signal.aborted) onProgress(partial);
1187
- }, 75);
1188
- }
1189
- }
1190
- if (event.result !== undefined) final = event.result;
1191
- } catch (error) {
1192
- parseError = error instanceof Error ? error : new Error(String(error));
1193
- child.kill();
1194
- break;
1323
+ let latest: string | undefined;
1324
+ const throttledProgress = onProgress
1325
+ ? (progress: BackendProgress) => {
1326
+ if (progress.kind !== "text") return;
1327
+ latest = progress.text;
1328
+ if (!updateTimer) {
1329
+ updateTimer = setTimeout(() => {
1330
+ updateTimer = undefined;
1331
+ if (!signal.aborted && latest !== undefined) onProgress(latest);
1332
+ }, 75);
1195
1333
  }
1196
1334
  }
1197
- } finally {
1198
- lines.close();
1199
- }
1200
-
1201
- const { code, exitSignal } = await closed;
1202
- if (signal.aborted) throw new Error("Canceled.");
1203
- if (parseError) throw new Error(withDoctor(parseError));
1204
- if (processError) {
1205
- const missing = (processError as NodeJS.ErrnoException).code === "ENOENT";
1206
- throw new Error(
1207
- missing
1208
- ? "Agy could not start. Make sure Agy is installed and on PATH, then run `/bro doctor`."
1209
- : `Agy could not start: ${processError.message}\n\nRun \`/bro doctor\` for setup help.`,
1210
- );
1211
- }
1212
- if (exitSignal || code === null) {
1213
- throw new Error("Agy timed out while simplifying the response. Run `/bro doctor` for setup help.");
1214
- }
1215
- if (code !== 0) {
1216
- throw new Error(agyFailureMessage("simplify the response", { code, killed: false, stderr }));
1217
- }
1218
-
1219
- const text = final.trim();
1220
- if (!text) {
1221
- throw new Error(withDoctor(stderr.trim() || "Agy returned no final explanation."));
1222
- }
1335
+ : undefined;
1223
1336
 
1224
- return text;
1337
+ try {
1338
+ const outcome = await executeBackend({ feature, prompt, access: "restricted" }, selection, signal, throttledProgress);
1339
+ if (outcome.status === "success") return outcome.text;
1340
+ throw new Error(outcome.message);
1225
1341
  } finally {
1226
1342
  if (updateTimer) clearTimeout(updateTimer);
1227
- await rm(runDirectory, { recursive: true, force: true });
1228
1343
  }
1229
1344
  }
1230
1345
 
@@ -1354,43 +1469,6 @@ export function buildAdvisorSnapshot(ctx: ExtensionContext, pi: ExtensionAPI): {
1354
1469
  return { text, hadCompaction };
1355
1470
  }
1356
1471
 
1357
- // Advisor stdin/stream-json transport, confirmed against pi-flow-external's tested agy backend
1358
- // (src/core/agy.ts, test/agy-backend.test.ts): --input-format stream-json reads one NDJSON
1359
- // "user" message from stdin instead of a --print argv value (unbounded snapshot size would
1360
- // otherwise risk ARG_MAX), and --dangerously-skip-permissions gives the advisor real, unprompted
1361
- // tool access. See docs/plans/2026-09-19-bro-advisor-design.md, "Transport".
1362
- type AdvisorAgyEvent = {
1363
- event?: string;
1364
- step_update?: { tool_name?: unknown; step_type?: unknown; text_delta?: unknown };
1365
- result?: { status?: unknown; response?: unknown; error?: unknown };
1366
- };
1367
-
1368
- // Guards only against a single runaway line with no newline (a protocol break, not a real
1369
- // response size) — agy's real NDJSON lines are far smaller than this.
1370
- const ADVISOR_MAX_STDOUT_LINE_CHARS = 2_000_000;
1371
-
1372
- function parseAdvisorLine(line: string): AdvisorAgyEvent {
1373
- try {
1374
- return JSON.parse(line) as AdvisorAgyEvent;
1375
- } catch {
1376
- throw new Error("Agy emitted invalid stream-json output.");
1377
- }
1378
- }
1379
-
1380
- // Only a tool_name or a user-facing agent_response/assistant text_delta becomes an activity label
1381
- // -- hidden reasoning/thinking step_types and any other shape stay unreported, never leaked into
1382
- // progress.
1383
- function advisorActivityFromEvent(event: AdvisorAgyEvent): string | undefined {
1384
- if (event.event !== "step_update" || !event.step_update || typeof event.step_update !== "object") return undefined;
1385
- const update = event.step_update;
1386
- if (typeof update.tool_name === "string" && update.tool_name.trim()) return update.tool_name.trim();
1387
- const isUserFacingText = update.step_type === "agent_response" || update.step_type === "assistant";
1388
- if (isUserFacingText && typeof update.text_delta === "string" && update.text_delta.trim()) {
1389
- return update.text_delta.split("\n").find((line) => line.trim())?.trim();
1390
- }
1391
- return undefined;
1392
- }
1393
-
1394
1472
  const MAX_ADVISOR_ACTIVITY_LINES = 4;
1395
1473
  const ADVISOR_ACTIVITY_PREVIEW_CHARS = 100;
1396
1474
 
@@ -1398,168 +1476,34 @@ function advisorActivityPreview(label: string): string {
1398
1476
  return label.length > ADVISOR_ACTIVITY_PREVIEW_CHARS ? `${label.slice(0, ADVISOR_ACTIVITY_PREVIEW_CHARS).trimEnd()}…` : label;
1399
1477
  }
1400
1478
 
1401
- // Older agy CLIs reject --input-format with Go's flag-package usage dump and exit before running
1402
- // the agent at all (no terminal result event). Turn that into an actionable version hint instead
1403
- // of a bare "no terminal result" error.
1404
- export function advisorFlagErrorHint(stderr: string): string | undefined {
1405
- const match = /flags? provided but not defined: -([a-z0-9-]+)/i.exec(stderr);
1406
- if (!match) return undefined;
1407
- return `flag provided but not defined: -${match[1]} (installed Agy CLI is too old; the advisor needs Agy 1.1.15+ for --input-format stream-json — run \`agy update\`, then \`/bro doctor\`)`;
1408
- }
1409
-
1410
1479
  export type AdvisorActivityCallback = (label: string, timestamp: number) => void;
1411
1480
 
1481
+ // Thin wrapper around the shared backend: advisor's stdin/stream-json transport, activity parsing,
1482
+ // and process lifecycle now live in backend.ts (see docs/plans/2026-09-19-bro-advisor-design.md,
1483
+ // "Transport" for why stdin rather than --print). This function keeps its exact existing
1484
+ // signature/throw contract -- it is called directly by tests and by runAdvisorWithRetries below,
1485
+ // which owns the 3-attempt retry/backoff policy the backend itself never performs.
1412
1486
  export async function runAdvisorConsultation(
1413
1487
  prompt: string,
1414
- selection: ReturnType<typeof agySelection>,
1488
+ selection: BackendSelection,
1415
1489
  cwd: string,
1416
1490
  signal: AbortSignal,
1417
1491
  killEscalationMs = 5_000,
1418
1492
  onActivity?: AdvisorActivityCallback,
1419
1493
  ): Promise<string> {
1420
- const child = spawn(
1421
- "agy",
1422
- [
1423
- "--dangerously-skip-permissions",
1424
- "--disable-slash-commands",
1425
- "--output-format", "stream-json",
1426
- "--input-format", "stream-json",
1427
- "--model", selection.model,
1428
- ...(selection.effort ? ["--effort", selection.effort] : []),
1429
- "--print-timeout", "10m",
1430
- ],
1431
- // detached: true (POSIX only) makes the child its own process-group leader, so a signal to
1432
- // -child.pid below reaches it AND every grandchild it spawned -- not just the immediate
1433
- // process. Without this, killing only the immediate child can leave a grandchild holding the
1434
- // inherited stdio pipes open, and the "close" event this function waits on never fires until
1435
- // that orphan exits on its own.
1436
- { cwd, timeout: 610_000, stdio: ["pipe", "pipe", "pipe"], windowsHide: true, detached: process.platform !== "win32" },
1437
- );
1438
-
1439
- let processError: Error | undefined;
1440
- let stderr = "";
1441
- let final: string | undefined;
1442
- let terminalError: string | undefined;
1443
- let protocolError: string | undefined;
1444
- let sawTerminal = false;
1445
- let stdoutBuffer = "";
1446
-
1447
- // Signal the whole process group when possible so a misbehaving grandchild dies too, not just
1448
- // the immediate agy process; child.kill() alone only ever reaches the immediate child.
1449
- const killAdvisorChild = (signalName: NodeJS.Signals) => {
1450
- if (process.platform !== "win32" && typeof child.pid === "number") {
1451
- try {
1452
- process.kill(-child.pid, signalName);
1453
- return;
1454
- } catch {
1455
- // Group may already be gone (e.g. the child already exited) -- fall through.
1456
- }
1457
- }
1458
- child.kill(signalName);
1459
- };
1460
-
1461
- // Relying on spawn({signal}) alone only ever sends one SIGTERM and gives up if the child (or a
1462
- // misbehaving grandchild it spawned) ignores it, hanging this promise forever. Escalate to
1463
- // SIGKILL -- which cannot be ignored -- if the child hasn't exited shortly after.
1464
- let killEscalationTimer: ReturnType<typeof setTimeout> | undefined;
1465
- const onAbort = () => {
1466
- killAdvisorChild("SIGTERM");
1467
- killEscalationTimer = setTimeout(() => {
1468
- killAdvisorChild("SIGKILL");
1469
- }, killEscalationMs);
1470
- };
1471
- signal.addEventListener("abort", onAbort, { once: true });
1472
- if (signal.aborted) onAbort();
1473
-
1474
- child.stderr.setEncoding("utf8");
1475
- child.stderr.on("data", (chunk: string) => {
1476
- stderr += chunk;
1477
- });
1478
- child.once("error", (error) => {
1479
- processError = error;
1480
- });
1481
- child.stdin.on("error", () => {
1482
- // agy exiting before it reads stdin is reported through the close/error path below.
1483
- });
1484
- child.stdin.end(`${JSON.stringify({ event: "user", message: { content: prompt } })}\n`);
1485
-
1486
- const handleLine = (line: string) => {
1487
- if (!line.trim() || sawTerminal) return;
1488
- const event = parseAdvisorLine(line);
1489
- const activity = advisorActivityFromEvent(event);
1490
- if (activity) onActivity?.(activity, Date.now());
1491
- if (event.event !== "result") return;
1492
- sawTerminal = true;
1493
- const result = event.result;
1494
- const status = typeof result?.status === "string" ? result.status.trim().toUpperCase() : undefined;
1495
- if (status === "SUCCESS" && typeof result?.response === "string") {
1496
- final = result.response;
1497
- } else {
1498
- const detail = typeof result?.error === "string" && result.error.trim() ? `: ${result.error.trim()}` : "";
1499
- terminalError = `Agy failed with status ${status ?? "(missing)"}${detail}`;
1500
- }
1501
- };
1502
-
1503
- child.stdout.setEncoding("utf8");
1504
- child.stdout.on("data", (chunk: string) => {
1505
- stdoutBuffer += chunk;
1506
- const parts = stdoutBuffer.split(/\r?\n/);
1507
- stdoutBuffer = parts.pop() ?? "";
1508
- if (stdoutBuffer.length > ADVISOR_MAX_STDOUT_LINE_CHARS || parts.some((line) => line.length > ADVISOR_MAX_STDOUT_LINE_CHARS)) {
1509
- protocolError ??= `Agy emitted a stdout line over ${ADVISOR_MAX_STDOUT_LINE_CHARS} characters; the stream is unparseable.`;
1510
- stdoutBuffer = "";
1511
- killAdvisorChild("SIGTERM");
1512
- return;
1513
- }
1514
- for (const line of parts) {
1515
- try {
1516
- handleLine(line);
1517
- } catch (error) {
1518
- protocolError ??= errorMessage(error);
1519
- killAdvisorChild("SIGTERM");
1520
- return;
1521
- }
1522
- }
1523
- });
1524
-
1525
- const { code, exitSignal } = await new Promise<{ code: number | null; exitSignal: NodeJS.Signals | null }>((resolve) => {
1526
- child.once("close", (code, exitSignal) => {
1527
- if (stdoutBuffer.trim() && !sawTerminal) {
1528
- try {
1529
- handleLine(stdoutBuffer);
1530
- } catch (error) {
1531
- protocolError ??= errorMessage(error);
1494
+ const outcome = await executeBackend(
1495
+ { feature: "advisor", prompt, access: "workspace-full", cwd },
1496
+ selection,
1497
+ signal,
1498
+ onActivity
1499
+ ? (progress) => {
1500
+ if (progress.kind === "activity") onActivity(progress.label, progress.timestamp);
1532
1501
  }
1533
- }
1534
- resolve({ code, exitSignal });
1535
- });
1536
- });
1537
- signal.removeEventListener("abort", onAbort);
1538
- if (killEscalationTimer) clearTimeout(killEscalationTimer);
1539
-
1540
- if (signal.aborted) throw new Error("Canceled.");
1541
- if (protocolError) throw new Error(withDoctor(protocolError));
1542
- if (processError) {
1543
- const missing = (processError as NodeJS.ErrnoException).code === "ENOENT";
1544
- throw new Error(
1545
- missing
1546
- ? "Agy could not start. Make sure Agy is installed and on PATH, then run `/bro doctor`."
1547
- : `Agy could not start: ${processError.message}\n\nRun \`/bro doctor\` for setup help.`,
1548
- );
1549
- }
1550
- if (exitSignal || code === null) {
1551
- throw new Error("Agy timed out during the advisor consultation. Run `/bro doctor` for setup help.");
1552
- }
1553
- if (!sawTerminal) {
1554
- const hint = advisorFlagErrorHint(stderr);
1555
- throw new Error(withDoctor(hint ?? (stderr.trim() ? `Agy exited without a terminal result event: ${stderr.trim()}` : "Agy exited without a terminal result event.")));
1556
- }
1557
- if (terminalError) throw new Error(withDoctor(terminalError));
1558
- if (code !== 0) throw new Error(agyFailureMessage("complete the advisor consultation", { code, killed: false, stderr }));
1559
-
1560
- const text = final?.trim();
1561
- if (!text) throw new Error(withDoctor(stderr.trim() || "Agy returned no advice."));
1562
- return text;
1502
+ : undefined,
1503
+ { killEscalationMs },
1504
+ );
1505
+ if (outcome.status === "success") return outcome.text;
1506
+ throw new Error(outcome.message);
1563
1507
  }
1564
1508
 
1565
1509
  function advisorDelay(ms: number, signal: AbortSignal, onTick?: (remainingMs: number) => void): Promise<void> {
@@ -1590,7 +1534,7 @@ function advisorDelay(ms: number, signal: AbortSignal, onTick?: (remainingMs: nu
1590
1534
 
1591
1535
  export type AdvisorConsult = (
1592
1536
  prompt: string,
1593
- selection: ReturnType<typeof agySelection>,
1537
+ selection: BackendSelection,
1594
1538
  cwd: string,
1595
1539
  signal: AbortSignal,
1596
1540
  killEscalationMs?: number,
@@ -1604,6 +1548,7 @@ export type AdvisorToolDetails = {
1604
1548
  elapsedMs: number;
1605
1549
  error?: string;
1606
1550
  retryInMs?: number;
1551
+ backend?: BackendName;
1607
1552
  model?: string;
1608
1553
  effort?: string;
1609
1554
  durationMs?: number;
@@ -1636,7 +1581,7 @@ const ADVISOR_ACTIVITY_THROTTLE_MS = 250;
1636
1581
  // --conversation, even across retries.
1637
1582
  export async function runAdvisorWithRetries(
1638
1583
  prompt: string,
1639
- selection: ReturnType<typeof agySelection>,
1584
+ selection: BackendSelection,
1640
1585
  cwd: string,
1641
1586
  signal: AbortSignal,
1642
1587
  consult: AdvisorConsult = runAdvisorConsultation,
@@ -1743,11 +1688,12 @@ export function advisorAttemptLabel(details: AdvisorToolDetails): string {
1743
1688
  return `Bro advisor · ${details.model ?? "unknown model"} · ${details.attempt} attempt${details.attempt === 1 ? "" : "s"} · ${Math.ceil((details.durationMs ?? details.elapsedMs) / 1_000)}s`;
1744
1689
  }
1745
1690
  const latest = details.activity?.at(-1);
1746
- // "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
1747
1693
  // "stalled" -- silence since lastActivityAt is not itself evidence of a stuck run.
1748
1694
  const activityLabel = latest
1749
1695
  ? `last reported: ${advisorActivityPreview(latest)} (${Math.max(0, Math.floor((Date.now() - (details.lastActivityAt ?? Date.now())) / 1_000))}s ago)`
1750
- : "awaiting first activity from Agy";
1696
+ : `awaiting first activity from ${backendLabel}`;
1751
1697
  return `Bro advisor · running · ${Math.floor(details.elapsedMs / 1_000)}s · attempt ${details.attempt}/${details.of} · ${activityLabel}`;
1752
1698
  }
1753
1699
 
@@ -1761,7 +1707,10 @@ function showTurnsValues(current: number): string[] {
1761
1707
  // one that simply isn't in the current catalog (both used to render as "fixed", which read as
1762
1708
  // falsely healthy for an unavailable model), and flags a stored effort that isn't one of the
1763
1709
  // resolved family's supported efforts instead of silently showing it as if it were valid.
1764
- 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)`;
1765
1714
  if (!resolved.family) return "unavailable";
1766
1715
  const fixed = !resolved.family.efforts.length;
1767
1716
  const valid = fixed ? resolved.pair.effort === "default" : resolved.family.efforts.includes(resolved.pair.effort as AgyEffort);
@@ -1769,6 +1718,16 @@ function effortDisplay(resolved: { pair: ModelEffortPair; family?: AgyModelFamil
1769
1718
  return fixed ? "fixed" : resolved.pair.effort;
1770
1719
  }
1771
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
+
1772
1731
  // Testable core: takes settings/catalog/persist as plain arguments so smoke tests can drive
1773
1732
  // the exact interaction (submenus, cancel, cycling, save failure) without a real Agy process
1774
1733
  // or settings file. showBroConfigModal below wires this to the real ctx/pi/filesystem.
@@ -1805,13 +1764,33 @@ export function createConfigModal(
1805
1764
  value: family.id,
1806
1765
  label: `${family.label}${family.efforts.length ? "" : " · fixed effort"}`,
1807
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
+ })),
1808
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()}`); };
1809
1777
  const picker = new SelectList(options, Math.min(options.length, 8), getSelectListTheme());
1810
1778
  const selectedIndex = capability && current === "Default" ? 0 : options.findIndex((option) => option.value === current);
1811
1779
  picker.setSelectedIndex(Math.max(0, selectedIndex));
1812
- 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
+ };
1813
1784
  picker.onCancel = () => pickerDone();
1814
- 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
+ };
1815
1794
  };
1816
1795
 
1817
1796
  const modelItem: SettingItem = { id: "model", label: "Default model", currentValue: settings.model, submenu: modelPicker };
@@ -1843,21 +1822,27 @@ export function createConfigModal(
1843
1822
  ) as Record<Capability, { model: SettingItem; effort: SettingItem }>;
1844
1823
 
1845
1824
  function refresh(): void {
1825
+ const defaultBackend = settings.backend ?? "agy";
1846
1826
  const def = resolveModelEffort({ model: settings.model, effort: settings.effort }, families);
1847
- modelItem.currentValue = def.family?.id ?? settings.model;
1848
- effortItem.currentValue = effortDisplay(def);
1849
- effortItem.values = def.family?.efforts.length ? [...def.family.efforts] : undefined;
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;
1850
1830
  modeItem.currentValue = settings.mode;
1851
1831
  showTurnsItem.currentValue = String(settings.showTurns);
1852
1832
  showTurnsItem.values = showTurnsValues(settings.showTurns);
1853
1833
 
1854
1834
  for (const capability of CAPABILITIES) {
1855
1835
  const override = capabilityOverride(settings, capability);
1836
+ const backend = capabilityBackend(settings, capability);
1856
1837
  const resolved = resolveModelEffort(capabilityPair(settings, capability), families);
1857
1838
  const rows = capabilityItems[capability];
1858
- rows.model.currentValue = override ? (resolved.family?.id ?? override.model) : "Default";
1859
- rows.effort.currentValue = effortDisplay(resolved);
1860
- rows.effort.values = resolved.family?.efforts.length ? [...resolved.family.efforts] : undefined;
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;
1861
1846
  }
1862
1847
  }
1863
1848
  refresh();
@@ -1912,17 +1897,30 @@ export function createConfigModal(
1912
1897
 
1913
1898
  const onChange = (id: string, newValue: string) => {
1914
1899
  if (id === "model") {
1915
- const family = findFamily(newValue);
1916
- if (!family) return;
1917
- const keepCurrent = settings.effort === "default" ? !family.efforts.length : family.efforts.includes(settings.effort as AgyEffort);
1918
- settings = { ...settings, model: family.id, effort: keepCurrent ? settings.effort : preferredEffort(family) };
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
+ }
1919
1912
  } else if (id === "effort") {
1920
- // Effort-only edit: pin the resolved family's canonical id, same reasoning as the
1921
- // capability-override effort-only edit below -- otherwise a shared default created
1922
- // from a suffixed variant id (e.g. "gemini-x-low") would end up paired with an
1923
- // unrelated effort instead of its actual family id.
1924
- const resolved = resolveModelEffort({ model: settings.model, effort: settings.effort }, families);
1925
- settings = { ...settings, model: resolved.family?.id ?? settings.model, effort: newValue as BroEffort };
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
+ }
1926
1924
  } else if (id === "mode") {
1927
1925
  const mode = parseBroMode(newValue);
1928
1926
  if (!mode) return;
@@ -1938,15 +1936,30 @@ export function createConfigModal(
1938
1936
  if (newValue === "__default__") {
1939
1937
  settings = withCapabilityOverride(settings, capability, undefined);
1940
1938
  } else {
1941
- const family = findFamily(newValue);
1942
- if (!family) return;
1943
- const currentEffort = capabilityPair(settings, capability).effort;
1944
- const keepCurrent = currentEffort === "default" ? !family.efforts.length : family.efforts.includes(currentEffort as AgyEffort);
1945
- settings = withCapabilityOverride(settings, capability, {
1946
- model: family.id,
1947
- effort: keepCurrent ? currentEffort : preferredEffort(family),
1948
- });
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
+ }
1949
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 });
1950
1963
  } else {
1951
1964
  // Effort-only edit: pin the resolved family's canonical id, never whatever raw
1952
1965
  // string happens to sit in settings.model/override.model (which — for a shared
@@ -2004,14 +2017,21 @@ export async function showBroConfigModal(ctx: ExtensionCommandContext, pi: Exten
2004
2017
  return;
2005
2018
  }
2006
2019
  let settings: BroSettings;
2007
- let families: AgyModelFamily[];
2008
2020
  try {
2009
2021
  settings = await readSettings();
2010
- families = await listAgyModels(pi);
2011
2022
  } catch (error) {
2012
2023
  ctx.ui.notify(withDoctor(error), "error");
2013
2024
  return;
2014
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
+ }
2015
2035
  await ctx.ui.custom<void>(createConfigModal(settings, families, writeSettings), {
2016
2036
  overlay: true,
2017
2037
  overlayOptions: {
@@ -2133,13 +2153,15 @@ export function helpText(settings?: BroSettings, settingsError?: string): string
2133
2153
  ? CAPABILITIES.map((capability) => {
2134
2154
  const override = capabilityOverride(settings, capability);
2135
2155
  return override
2136
- ? `- **${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})`}`
2137
2157
  : undefined;
2138
2158
  }).filter((line): line is string => line !== undefined)
2139
2159
  : [];
2140
2160
  const settingsSummary = settings
2141
- ? `- **Model:** \`${settings.model}\`\n- **Reasoning effort:** ${settings.effort === "default" ? "built into the selected model" : settings.effort}\n- **Mode:** ${settings.mode}\n- **Show turns:** ${settings.showTurns}${overrideLines.length ? `\n${overrideLines.join("\n")}` : ""}`
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")}` : ""}`
2142
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";
2143
2165
  return `# Bro
2144
2166
 
2145
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.
@@ -2159,14 +2181,14 @@ Press **R** to simplify the captured source again. Run a new \`/bro text\`, \`/b
2159
2181
 
2160
2182
  ## Check and configure
2161
2183
 
2162
- - \`/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)
2163
2185
  - \`/bro usage [--provider agy]\` — show current Agy limits
2164
- - \`/bro model [id]\` — view or choose the shared default Agy model
2165
- - \`/bro effort [low|medium|high]\` — view or choose the shared default reasoning effort
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)
2166
2188
  - \`/bro mode [brief|balanced|faithful]\` — view or choose explanation mode
2167
- - \`/bro config\` — open an interactive settings screen for the shared default model/effort, explain mode, show turns, and per-capability (explain/show/btw/advisor) model and effort overrides. Changes save immediately; Esc on a picker cancels without changing anything, Esc on the screen closes it and keeps whatever was already saved.
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.
2168
2190
 
2169
- \`/bro model\` and \`/bro effort\` always change the shared default that explain, show, btw, and advisor fall back to when they have no override. Use \`/bro config\` to give one of them its own model or effort.
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.
2170
2192
 
2171
2193
  ## Side conversation
2172
2194
 
@@ -2174,12 +2196,12 @@ Press **R** to simplify the captured source again. Run a new \`/bro text\`, \`/b
2174
2196
 
2175
2197
  ## Advisor
2176
2198
 
2177
- - \`bro_advisor\` — a tool the executor agent can voluntarily call mid-task for a second opinion from a fresh Agy process before or after a non-trivial decision. It is registered like any other tool and has no on/off switch of its own; whether the executor can actually call it depends entirely on this host's own tool restrictions
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
2178
2200
  - \`/bro advisor\` — a quick notice of whether \`bro_advisor\` is available right now, pointing at \`/bro config\`, \`/bro advisor-steer\`, and \`/bro doctor\`
2179
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
2180
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
2181
2203
 
2182
- Each consultation is a fresh, standalone Agy process — never resumed, never looping, never automatically triggered. Bro captures the context snapshot (system instructions, active tools, and the conversation so far 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.
2183
2205
 
2184
2206
  ## Current settings
2185
2207
 
@@ -2213,11 +2235,11 @@ Bro temporarily captures mouse input while the modal is open. Native mouse selec
2213
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.
2214
2236
  - Show reflects what was reported in the conversation, not independent verification against the actual code or system state.
2215
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.
2216
- - Advisor consultations run with real tool access and auto-approved permissions (\`--dangerously-skip-permissions\`) — there is no enforced read-only isolation, only the advisor's own 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.
2217
2239
 
2218
2240
  ## Privacy and safety
2219
2241
 
2220
- 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.
2221
2243
 
2222
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.
2223
2245
 
@@ -2226,7 +2248,7 @@ For webpages, it connects directly to the site without browser cookies; the site
2226
2248
 
2227
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.
2228
2250
 
2229
- Each advisor consultation sends the executor's system instructions, active tool list, ordered conversation (including tool calls and results, since the advisor needs to verify claims), your steering brief, and the executor's optional question to Agy and your model provider; the advisor process itself can read and edit the workspace with no permission prompts. The steering brief 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.
2230
2252
 
2231
2253
  ## Custom prompt
2232
2254
 
@@ -2571,27 +2593,6 @@ export function formatBtwTranscript(turns: readonly BtwTurn[]): string {
2571
2593
  .join("\n\n---\n\n");
2572
2594
  }
2573
2595
 
2574
- export function parseBtwAgyLine(line: string): { delta?: string; result?: string; conversationId?: string; error?: string } {
2575
- let event: AgyEvent;
2576
- try {
2577
- event = JSON.parse(line) as AgyEvent;
2578
- } catch {
2579
- throw new Error("Agy returned invalid streaming data.");
2580
- }
2581
- const conversationId = event.conversation_id ?? event.result?.conversation_id;
2582
- if (event.event === "step_update" && event.step_update?.step_type === "agent_response" && typeof event.step_update.text_delta === "string") {
2583
- return { delta: event.step_update.text_delta, conversationId };
2584
- }
2585
- if (event.event === "result") {
2586
- if (event.result?.status !== "SUCCESS" || typeof event.result.response !== "string") {
2587
- const detail = typeof event.result?.error === "string" ? event.result.error : "Agy did not complete the turn successfully.";
2588
- return { error: detail, conversationId };
2589
- }
2590
- return { result: event.result.response, conversationId };
2591
- }
2592
- return { conversationId };
2593
- }
2594
-
2595
2596
  export type BtwComposerAction =
2596
2597
  | { kind: "clear" }
2597
2598
  | { kind: "retry" }
@@ -2617,112 +2618,56 @@ export function parseBtwComposerCommand(value: string): BtwComposerAction {
2617
2618
  return { kind: "question", text: command };
2618
2619
  }
2619
2620
 
2621
+ // Thin presentation-boundary wrapper around the shared backend: coalesces raw text progress to the
2622
+ // existing 75ms cadence and translates the backend's tagged outcome back into this function's
2623
+ // existing throw-on-failure / { text, conversationId } contract. A conversationId is only ever
2624
+ // returned on success (see backend.ts's continuation handling), preserving current behavior on
2625
+ // failed turns.
2620
2626
  async function runBtwTurn(
2621
2627
  prompt: string,
2622
- selection: ReturnType<typeof agySelection>,
2628
+ selection: BackendSelection,
2623
2629
  options: { full: boolean; cwd: string; conversationId?: string },
2624
2630
  signal: AbortSignal,
2625
2631
  onProgress?: (text: string) => void,
2626
2632
  ): Promise<{ text: string; conversationId?: string }> {
2627
- const runDirectory = options.full ? undefined : await mkdtemp(join(tmpdir(), "pi-bro-"));
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
+ }
2628
2639
  let updateTimer: ReturnType<typeof setTimeout> | undefined;
2629
- try {
2630
- const args = [
2631
- "--output-format", "stream-json",
2632
- "--disable-slash-commands",
2633
- "--model", selection.model,
2634
- ...(selection.effort ? ["--effort", selection.effort] : []),
2635
- "--print-timeout", options.full ? "10m" : "2m",
2636
- ...(options.conversationId ? ["--conversation", options.conversationId] : []),
2637
- ...(options.full ? ["--dangerously-skip-permissions"] : ["--sandbox"]),
2638
- "--print", prompt,
2639
- ];
2640
- const child = spawn("agy", args, {
2641
- cwd: options.full ? options.cwd : runDirectory,
2642
- signal,
2643
- timeout: options.full ? 610_000 : 130_000,
2644
- stdio: ["ignore", "pipe", "pipe"],
2645
- windowsHide: true,
2646
- });
2647
-
2648
- let processError: Error | undefined;
2649
- let stderr = "";
2650
- let partial = "";
2651
- let final = "";
2652
- let conversationId = options.conversationId;
2653
- let parseError: Error | undefined;
2654
-
2655
- child.stderr.setEncoding("utf8");
2656
- child.stderr.on("data", (chunk: string) => {
2657
- stderr += chunk;
2658
- });
2659
- child.once("error", (error) => {
2660
- processError = error;
2661
- });
2662
-
2663
- const closed = new Promise<{ code: number | null; exitSignal: NodeJS.Signals | null }>((resolve) => {
2664
- child.once("close", (code, exitSignal) => resolve({ code, exitSignal }));
2665
- });
2666
-
2667
- const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
2668
- try {
2669
- for await (const line of lines) {
2670
- if (!line.trim()) continue;
2671
- try {
2672
- const event = parseBtwAgyLine(line);
2673
- if (event.conversationId) conversationId = event.conversationId;
2674
- if (event.error) {
2675
- parseError = new Error(event.error);
2676
- child.kill();
2677
- break;
2678
- }
2679
- if (event.delta) {
2680
- partial += event.delta;
2681
- if (onProgress && !updateTimer) {
2682
- updateTimer = setTimeout(() => {
2683
- updateTimer = undefined;
2684
- if (!signal.aborted) onProgress(partial);
2685
- }, 75);
2686
- }
2687
- }
2688
- if (event.result !== undefined) final = event.result;
2689
- } catch (error) {
2690
- parseError = error instanceof Error ? error : new Error(String(error));
2691
- child.kill();
2692
- break;
2640
+ let latest: string | undefined;
2641
+ const throttledProgress = onProgress
2642
+ ? (progress: BackendProgress) => {
2643
+ if (progress.kind !== "text") return;
2644
+ latest = progress.text;
2645
+ if (!updateTimer) {
2646
+ updateTimer = setTimeout(() => {
2647
+ updateTimer = undefined;
2648
+ if (!signal.aborted && latest !== undefined) onProgress(latest);
2649
+ }, 75);
2693
2650
  }
2694
2651
  }
2695
- } finally {
2696
- lines.close();
2697
- }
2698
-
2699
- const { code, exitSignal } = await closed;
2700
- if (signal.aborted) throw new Error("Canceled.");
2701
- if (parseError) throw new Error(withDoctor(parseError));
2702
- if (processError) {
2703
- const missing = (processError as NodeJS.ErrnoException).code === "ENOENT";
2704
- throw new Error(
2705
- missing
2706
- ? "Agy could not start. Make sure Agy is installed and on PATH, then run `/bro doctor`."
2707
- : `Agy could not start: ${processError.message}\n\nRun \`/bro doctor\` for setup help.`,
2708
- );
2709
- }
2710
- if (exitSignal || code === null) {
2711
- throw new Error("Agy timed out during the side conversation. Run `/bro doctor` for setup help.");
2712
- }
2713
- if (code !== 0) {
2714
- throw new Error(agyFailureMessage("answer the side question", { code, killed: false, stderr }));
2715
- }
2716
-
2717
- const text = final.trim();
2718
- if (!text) {
2719
- throw new Error(withDoctor(stderr.trim() || "Agy returned no answer for the side question."));
2720
- }
2652
+ : undefined;
2721
2653
 
2722
- return { text, conversationId };
2654
+ try {
2655
+ const outcome = await executeBackend(
2656
+ {
2657
+ feature: "btw",
2658
+ prompt,
2659
+ access: options.full ? "workspace-full" : "restricted",
2660
+ cwd: options.full ? options.cwd : undefined,
2661
+ continuation: options.conversationId ? { id: options.conversationId } : undefined,
2662
+ },
2663
+ selection,
2664
+ signal,
2665
+ throttledProgress,
2666
+ );
2667
+ if (outcome.status === "success") return { text: outcome.text, conversationId: outcome.continuation?.id };
2668
+ throw new Error(outcome.message);
2723
2669
  } finally {
2724
2670
  if (updateTimer) clearTimeout(updateTimer);
2725
- if (runDirectory) await rm(runDirectory, { recursive: true, force: true });
2726
2671
  }
2727
2672
  }
2728
2673
 
@@ -2919,7 +2864,7 @@ async function openBtwModal(
2919
2864
  const settings = await readSettings();
2920
2865
  const result = await runBtwTurn(
2921
2866
  buildBtwPrompt(context, question),
2922
- agySelection(capabilityPair(settings, "btw")),
2867
+ selectionForCapability(settings, "btw"),
2923
2868
  { full: thread.full, cwd: ctx.cwd, conversationId: thread.conversationId },
2924
2869
  turnController.signal,
2925
2870
  (partial) => {
@@ -3052,10 +2997,10 @@ export default async function bro(pi: ExtensionAPI) {
3052
2997
  name: ADVISOR_TOOL_NAME,
3053
2998
  label: "Bro advisor",
3054
2999
  description:
3055
- "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.",
3056
- 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",
3057
3002
  promptGuidelines: [
3058
- "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.",
3059
3004
  "bro_advisor's question parameter is optional — never delay a call to first prepare a summary or evidence; Bro captures context automatically.",
3060
3005
  "Any human-set steering priorities are applied automatically by bro_advisor; you don't need to relay or repeat them.",
3061
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.",
@@ -3068,10 +3013,11 @@ export default async function bro(pi: ExtensionAPI) {
3068
3013
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
3069
3014
  const state = resolveAdvisorState(ctx.sessionManager.getBranch());
3070
3015
  const settings = await readSettings();
3071
- const selection = agySelection(capabilityPair(settings, "advisor"));
3016
+ const selection = selectionForCapability(settings, "advisor");
3017
+ const backend = selection.backend ?? "agy";
3072
3018
  const snapshot = buildAdvisorSnapshot(ctx, pi);
3073
3019
  const prompt = buildAdvisorPrompt(state.steering, snapshot.text, params.question);
3074
- let lastAttempt: AdvisorToolDetails = { status: "investigating", attempt: 1, of: 3, elapsedMs: 0 };
3020
+ let lastAttempt: AdvisorToolDetails = { status: "investigating", attempt: 1, of: 3, elapsedMs: 0, backend };
3075
3021
  try {
3076
3022
  const run = await runAdvisorWithRetries(
3077
3023
  prompt,
@@ -3081,7 +3027,7 @@ export default async function bro(pi: ExtensionAPI) {
3081
3027
  undefined,
3082
3028
  undefined,
3083
3029
  (details) => {
3084
- lastAttempt = details;
3030
+ lastAttempt = { ...details, backend };
3085
3031
  const label = advisorAttemptLabel(lastAttempt);
3086
3032
  onUpdate?.({
3087
3033
  content: [{ type: "text", text: label }],
@@ -3102,6 +3048,7 @@ export default async function bro(pi: ExtensionAPI) {
3102
3048
  ...lastAttempt,
3103
3049
  status: "done",
3104
3050
  attempt: run.attempts,
3051
+ backend,
3105
3052
  model: selection.model,
3106
3053
  effort,
3107
3054
  durationMs: run.durationMs,
@@ -3114,7 +3061,8 @@ export default async function bro(pi: ExtensionAPI) {
3114
3061
  // the activity throttle window.
3115
3062
  activityCount: run.activityCount,
3116
3063
  };
3117
- 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`;
3118
3066
  const context = `Context · cwd: ${JSON.stringify(ctx.cwd)} · steering: ${details.steeringIncluded ? "included" : "none"} · snapshot: ${snapshot.text.length} chars · Bro truncation: none · omissions: ${omissions}`;
3119
3067
  return { content: [{ type: "text", text: `${header}\n${context}\n\n${run.advice}` }], details };
3120
3068
  } finally {
@@ -3321,6 +3269,45 @@ export default async function bro(pi: ExtensionAPI) {
3321
3269
  }
3322
3270
  try {
3323
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
+ }
3324
3311
  const models = await listAgyModels(pi);
3325
3312
  const current = resolveCatalogSettings(settings, models);
3326
3313
  const requested = parts[1];
@@ -3360,7 +3347,7 @@ export default async function bro(pi: ExtensionAPI) {
3360
3347
  const currentEffort = current.settings.effort;
3361
3348
  const canKeepCurrent =
3362
3349
  current.family?.id === selected.id &&
3363
- (currentEffort === "default" ? !selected.efforts.length : selected.efforts.includes(currentEffort));
3350
+ (currentEffort === "default" ? !selected.efforts.length : selected.efforts.includes(currentEffort as AgyEffort));
3364
3351
  selectedEffort = canKeepCurrent ? currentEffort : preferredEffort(selected);
3365
3352
  }
3366
3353
  await writeSettings({ ...settings, model: selected.id, effort: selectedEffort });
@@ -3376,12 +3363,44 @@ export default async function bro(pi: ExtensionAPI) {
3376
3363
 
3377
3364
  if (action === "effort") {
3378
3365
  const requested = parts[1];
3379
- 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))) {
3380
3369
  ctx.ui.notify("Use /bro effort, or choose low, medium, or high.", "warning");
3381
3370
  return;
3382
3371
  }
3383
3372
  try {
3384
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
+ }
3385
3404
  const current = resolveCatalogSettings(settings, await listAgyModels(pi));
3386
3405
  if (!current.family) {
3387
3406
  ctx.ui.notify(`Model "${settings.model}" is not in Agy's current model list. Run /bro model first.`, "warning");
@@ -3436,6 +3455,7 @@ export default async function bro(pi: ExtensionAPI) {
3436
3455
  return;
3437
3456
  }
3438
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.");
3439
3459
  const thread = resolveBtwThread(btwThread, parsed);
3440
3460
  btwThread = thread;
3441
3461
  await openBtwModal(ctx, { thread, initialQuestion: parsed.question, seed: !parsed.fresh });