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.
- package/CHANGELOG.md +18 -0
- package/README.md +50 -28
- package/backend.ts +724 -0
- package/bro.ts +574 -554
- package/package.json +3 -2
package/bro.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
55
|
-
type
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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()
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
569
|
-
family,
|
|
570
|
-
|
|
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
|
|
643
|
-
|
|
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
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
1005
|
+
fail("Claude", error);
|
|
827
1006
|
}
|
|
828
|
-
|
|
829
1007
|
try {
|
|
830
|
-
await
|
|
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("
|
|
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
|
|
839
|
-
const
|
|
840
|
-
if (
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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,
|
|
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),
|
|
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:
|
|
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
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
1421
|
-
"
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
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
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
:
|
|
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) =>
|
|
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
|
|
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
|
|
1859
|
-
|
|
1860
|
-
|
|
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
|
|
1916
|
-
if (
|
|
1917
|
-
|
|
1918
|
-
|
|
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
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
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
|
|
1942
|
-
if (
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3056
|
-
promptSnippet: "Consult a fresh
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 });
|