pi-bro 0.13.1 → 0.14.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 +19 -0
- package/README.md +109 -8
- package/bro.ts +1274 -38
- package/package.json +4 -3
- package/prompt.ts +29 -0
package/bro.ts
CHANGED
|
@@ -9,14 +9,15 @@ import { homedir, tmpdir } from "node:os";
|
|
|
9
9
|
import { extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
10
10
|
import { createInterface } from "node:readline";
|
|
11
11
|
import { stripVTControlCharacters } from "node:util";
|
|
12
|
-
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
12
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
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";
|
|
14
|
+
import { convertToLlm, copyToClipboard, getMarkdownTheme, getSelectListTheme, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
|
|
15
15
|
import { Defuddle } from "defuddle/node";
|
|
16
16
|
import { parseHTML } from "linkedom";
|
|
17
17
|
import mammoth from "mammoth";
|
|
18
|
+
import { Type } from "typebox";
|
|
18
19
|
import { extractText } from "unpdf";
|
|
19
|
-
import { BRO_MODES, DEFAULT_BRO_MODE, buildBtwPrompt, buildDefaultPrompt, buildShowPrompt, parseBroMode, type BroMode } from "./prompt.ts";
|
|
20
|
+
import { BRO_MODES, DEFAULT_BRO_MODE, buildAdvisorPrompt, buildBtwPrompt, buildDefaultPrompt, buildShowPrompt, parseBroMode, type BroMode } from "./prompt.ts";
|
|
20
21
|
|
|
21
22
|
const AGENT_DIR = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
|
22
23
|
const ENV_MODEL = process.env.PI_BRO_MODEL?.trim();
|
|
@@ -52,7 +53,20 @@ type BtwThread = { turns: BtwTurn[]; conversationId?: string; full: boolean };
|
|
|
52
53
|
const EFFORTS = ["default", "low", "medium", "high"] as const;
|
|
53
54
|
type BroEffort = (typeof EFFORTS)[number];
|
|
54
55
|
type AgyEffort = Exclude<BroEffort, "default">;
|
|
55
|
-
|
|
56
|
+
// "advisor" is a real capability (command, Agy invocation, Doctor check) like the other three;
|
|
57
|
+
// see docs/plans/2026-09-19-bro-advisor-design.md. All four share one model/effort resolution,
|
|
58
|
+
// override, and Doctor-check path via this single list -- there is no configuration-only tier.
|
|
59
|
+
const CAPABILITIES = ["explain", "show", "btw", "advisor"] as const;
|
|
60
|
+
type Capability = (typeof CAPABILITIES)[number];
|
|
61
|
+
const CAPABILITY_LABELS: Record<Capability, string> = { explain: "Explain", show: "Show", btw: "Btw", advisor: "Advisor" };
|
|
62
|
+
type ModelEffortPair = { model: string; effort: BroEffort };
|
|
63
|
+
type BroSettings = {
|
|
64
|
+
model: string;
|
|
65
|
+
effort: BroEffort;
|
|
66
|
+
mode: BroMode;
|
|
67
|
+
showTurns: number;
|
|
68
|
+
overrides: Partial<Record<Capability, ModelEffortPair>>;
|
|
69
|
+
};
|
|
56
70
|
type AgyModelFamily = {
|
|
57
71
|
id: string;
|
|
58
72
|
label: string;
|
|
@@ -91,12 +105,15 @@ const COMMANDS = [
|
|
|
91
105
|
{ value: "show", label: "show", description: "Draw what happened in recent session turns as shapes" },
|
|
92
106
|
{ value: "mode", label: "mode", description: "Choose brief, balanced, or faithful explanations" },
|
|
93
107
|
{ value: "btw", label: "btw", description: "Open a side conversation (sandboxed by default; --full edits files)" },
|
|
108
|
+
{ value: "config", label: "config", description: "Configure shared defaults and per-capability model/effort overrides" },
|
|
109
|
+
{ value: "advisor", label: "advisor", description: "Check whether the executor's advisor tool is available right now" },
|
|
110
|
+
{ value: "advisor-steer", label: "advisor-steer", description: "View, edit, save, or clear the advisor's persistent steering brief" },
|
|
94
111
|
{ value: "help", label: "help", description: "Learn what Bro does and what it can access" },
|
|
95
112
|
];
|
|
96
113
|
const KNOWN_ACTIONS = new Set(COMMANDS.map((command) => command.value));
|
|
97
114
|
|
|
98
115
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
99
|
-
return typeof value === "object" && value !== null;
|
|
116
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
100
117
|
}
|
|
101
118
|
|
|
102
119
|
function errorMessage(error: unknown): string {
|
|
@@ -477,6 +494,24 @@ export function agyFailureMessage(
|
|
|
477
494
|
return `Agy could not ${action}. Make sure Agy is installed and signed in, then run \`/bro doctor\`.`;
|
|
478
495
|
}
|
|
479
496
|
|
|
497
|
+
function parseModelEffortPair(value: unknown, context: string): ModelEffortPair {
|
|
498
|
+
if (!isRecord(value) || typeof value.model !== "string" || !value.model.trim() || !EFFORTS.some((effort) => effort === value.effort)) {
|
|
499
|
+
throw new Error(`${context} must contain a model and effort set to "default", "low", "medium", or "high".`);
|
|
500
|
+
}
|
|
501
|
+
return { model: value.model.trim(), effort: value.effort as BroEffort };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function parseOverrides(value: unknown): Partial<Record<Capability, ModelEffortPair>> {
|
|
505
|
+
if (value === undefined) return {};
|
|
506
|
+
if (!isRecord(value)) throw new Error("Settings overrides must be an object.");
|
|
507
|
+
const overrides: Partial<Record<Capability, ModelEffortPair>> = {};
|
|
508
|
+
for (const capability of CAPABILITIES) {
|
|
509
|
+
if (value[capability] === undefined) continue;
|
|
510
|
+
overrides[capability] = parseModelEffortPair(value[capability], `Settings overrides.${capability}`);
|
|
511
|
+
}
|
|
512
|
+
return overrides;
|
|
513
|
+
}
|
|
514
|
+
|
|
480
515
|
export function parseBroSettings(value: unknown): BroSettings {
|
|
481
516
|
if (
|
|
482
517
|
!isRecord(value) ||
|
|
@@ -492,7 +527,87 @@ export function parseBroSettings(value: unknown): BroSettings {
|
|
|
492
527
|
if (typeof showTurns !== "number" || !Number.isInteger(showTurns) || showTurns < 1) {
|
|
493
528
|
throw new Error("Settings showTurns must be a positive whole number of turns.");
|
|
494
529
|
}
|
|
495
|
-
|
|
530
|
+
const overrides = parseOverrides(value.overrides);
|
|
531
|
+
return { model: value.model.trim(), effort: value.effort as BroSettings["effort"], mode, showTurns, overrides };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function capabilityOverride(settings: BroSettings, capability: Capability): ModelEffortPair | undefined {
|
|
535
|
+
return settings.overrides[capability];
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function capabilityPair(settings: BroSettings, capability: Capability): ModelEffortPair {
|
|
539
|
+
return capabilityOverride(settings, capability) ?? { model: settings.model, effort: settings.effort };
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// An override always pins both model and effort together (never just one), so a capability's
|
|
543
|
+
// setting is either fully inherited or fully its own — no partial-inheritance edge cases.
|
|
544
|
+
//
|
|
545
|
+
// An override is cleared ONLY by an explicit "Default" selection (pair === undefined), never
|
|
546
|
+
// automatically because it happens to match the shared default: a user who deliberately pins a
|
|
547
|
+
// capability to the model that currently IS the shared default must keep that pin — unchanged —
|
|
548
|
+
// if the shared default is later changed to something else. Silently dropping an override that
|
|
549
|
+
// merely coincides with the default would make that pin impossible to express.
|
|
550
|
+
export function withCapabilityOverride(
|
|
551
|
+
settings: BroSettings,
|
|
552
|
+
capability: Capability,
|
|
553
|
+
pair: ModelEffortPair | undefined,
|
|
554
|
+
): BroSettings {
|
|
555
|
+
const overrides = { ...settings.overrides };
|
|
556
|
+
if (!pair) delete overrides[capability];
|
|
557
|
+
else overrides[capability] = pair;
|
|
558
|
+
return { ...settings, overrides };
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
export function resolveModelEffort(
|
|
562
|
+
pair: ModelEffortPair,
|
|
563
|
+
families: AgyModelFamily[],
|
|
564
|
+
): { pair: ModelEffortPair; family?: AgyModelFamily } {
|
|
565
|
+
const family = families.find((item) => item.id === pair.model || item.variants.some((variant) => variant.id === pair.model));
|
|
566
|
+
if (!family) return { pair };
|
|
567
|
+
const variant = family.variants.find((item) => item.id === pair.model);
|
|
568
|
+
return {
|
|
569
|
+
family,
|
|
570
|
+
pair: { model: family.id, effort: pair.effort === "default" && variant?.effort ? variant.effort : pair.effort },
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function resolveCapabilitySettings(
|
|
575
|
+
settings: BroSettings,
|
|
576
|
+
capability: Capability,
|
|
577
|
+
families: AgyModelFamily[],
|
|
578
|
+
): { pair: ModelEffortPair; family?: AgyModelFamily } {
|
|
579
|
+
return resolveModelEffort(capabilityPair(settings, capability), families);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// The steering brief is stored as a session `custom` entry — extension state that never
|
|
583
|
+
// participates in LLM context (see docs/plans/2026-09-19-bro-advisor-design.md). Writes go
|
|
584
|
+
// through pi.appendEntry(); ctx.sessionManager is read-only and has no append methods.
|
|
585
|
+
//
|
|
586
|
+
// bro_advisor has no on/off activation state: it is registered once at extension load like any
|
|
587
|
+
// other tool and never gated via pi.setActiveTools(). Whether the executor can call it is purely a
|
|
588
|
+
// function of this host's own tool restrictions (pi.getAllTools() / pi.getActiveTools()), which
|
|
589
|
+
// this code only ever reads, never overrides. A session may still carry historical
|
|
590
|
+
// "bro-advisor-active" entries from before this simplification; they are silently ignored.
|
|
591
|
+
const ADVISOR_STEERING_ENTRY = "bro-advisor-steering";
|
|
592
|
+
export const ADVISOR_TOOL_NAME = "bro_advisor";
|
|
593
|
+
// setStatus() key for mirroring live advisor progress to the footer/status bar -- see the execute()
|
|
594
|
+
// handler below for why this fallback surface exists alongside onUpdate's tool-card renderResult.
|
|
595
|
+
const ADVISOR_STATUS_BAR_KEY = "bro-advisor";
|
|
596
|
+
const ADVISOR_COMPATIBILITY = "Pi >=0.84.2; Agy >=1.1.15";
|
|
597
|
+
|
|
598
|
+
export type AdvisorState = { steering: string };
|
|
599
|
+
|
|
600
|
+
// getBranch() walks root-to-leaf (see latestAssistant above for the same convention), so a forward
|
|
601
|
+
// scan taking the last match of ADVISOR_STEERING_ENTRY resolves "latest wins". Forking clones the
|
|
602
|
+
// branch's entries into a new session file, so this same resolution gives fork inheritance and
|
|
603
|
+
// independent post-fork edits for free, with no special-case fork logic.
|
|
604
|
+
export function resolveAdvisorState(branch: readonly SessionEntry[]): AdvisorState {
|
|
605
|
+
let steering = "";
|
|
606
|
+
for (const entry of branch) {
|
|
607
|
+
if (entry.type !== "custom" || entry.customType !== ADVISOR_STEERING_ENTRY) continue;
|
|
608
|
+
if (isRecord(entry.data) && typeof entry.data.text === "string") steering = entry.data.text;
|
|
609
|
+
}
|
|
610
|
+
return { steering };
|
|
496
611
|
}
|
|
497
612
|
|
|
498
613
|
async function ensureSettingsFile(): Promise<void> {
|
|
@@ -519,9 +634,18 @@ async function readSettings(): Promise<BroSettings> {
|
|
|
519
634
|
}
|
|
520
635
|
}
|
|
521
636
|
|
|
637
|
+
// Exported so persistence stays testable without touching the filesystem. This only ever omits
|
|
638
|
+
// the `overrides` key itself when there are no overrides at all — it does NOT deduplicate or drop
|
|
639
|
+
// any individual override that happens to match the shared default; see withCapabilityOverride
|
|
640
|
+
// for why an explicit override is always kept until the user clears it back to "Default".
|
|
641
|
+
export function settingsPayload(settings: BroSettings): Record<string, unknown> {
|
|
642
|
+
const { overrides, ...rest } = settings;
|
|
643
|
+
return Object.keys(overrides).length ? { ...rest, overrides } : rest;
|
|
644
|
+
}
|
|
645
|
+
|
|
522
646
|
async function writeSettings(settings: BroSettings): Promise<void> {
|
|
523
647
|
// ponytail: last writer wins across concurrent Pi processes; add locking only if that becomes a common workflow.
|
|
524
|
-
await writeFile(SETTINGS_FILE, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
|
|
648
|
+
await writeFile(SETTINGS_FILE, `${JSON.stringify(settingsPayload(settings), null, 2)}\n`, "utf8");
|
|
525
649
|
}
|
|
526
650
|
|
|
527
651
|
export function formatAgyUsage(value: unknown): string {
|
|
@@ -604,35 +728,36 @@ async function checkAgyVersion(pi: ExtensionAPI, signal: AbortSignal): Promise<s
|
|
|
604
728
|
}
|
|
605
729
|
}
|
|
606
730
|
|
|
731
|
+
export function advisorAgyCompatible(version: string): boolean | undefined {
|
|
732
|
+
const match = /(?:^|\D)(\d+)\.(\d+)\.(\d+)(?:\D|$)/.exec(version);
|
|
733
|
+
if (!match) return undefined;
|
|
734
|
+
const installed = match.slice(1, 4).map(Number);
|
|
735
|
+
const minimum = [1, 1, 15];
|
|
736
|
+
for (let index = 0; index < minimum.length; index++) {
|
|
737
|
+
if (installed[index]! !== minimum[index]!) return installed[index]! > minimum[index]!;
|
|
738
|
+
}
|
|
739
|
+
return true;
|
|
740
|
+
}
|
|
741
|
+
|
|
607
742
|
function resolveCatalogSettings(
|
|
608
743
|
settings: BroSettings,
|
|
609
744
|
families: AgyModelFamily[],
|
|
610
745
|
): { settings: BroSettings; family?: AgyModelFamily } {
|
|
611
|
-
const
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
if (!family) return { settings };
|
|
615
|
-
const variant = family.variants.find((item) => item.id === settings.model);
|
|
616
|
-
return {
|
|
617
|
-
family,
|
|
618
|
-
settings: {
|
|
619
|
-
...settings,
|
|
620
|
-
model: family.id,
|
|
621
|
-
effort: settings.effort === "default" && variant?.effort ? variant.effort : settings.effort,
|
|
622
|
-
},
|
|
623
|
-
};
|
|
746
|
+
const resolved = resolveModelEffort({ model: settings.model, effort: settings.effort }, families);
|
|
747
|
+
if (!resolved.family) return { settings };
|
|
748
|
+
return { family: resolved.family, settings: { ...settings, ...resolved.pair } };
|
|
624
749
|
}
|
|
625
750
|
|
|
626
751
|
function preferredEffort(family: AgyModelFamily): BroEffort {
|
|
627
752
|
return family.efforts.includes("low") ? "low" : (family.efforts[0] ?? "default");
|
|
628
753
|
}
|
|
629
754
|
|
|
630
|
-
export function agySelection(
|
|
631
|
-
if (
|
|
632
|
-
const suffix = (["low", "medium", "high"] as const).find((effort) =>
|
|
755
|
+
export function agySelection(pair: ModelEffortPair): { model: string; effort?: AgyEffort } {
|
|
756
|
+
if (pair.effort === "default") return { model: pair.model };
|
|
757
|
+
const suffix = (["low", "medium", "high"] as const).find((effort) => pair.model.endsWith(`-${effort}`));
|
|
633
758
|
return {
|
|
634
|
-
model: suffix ?
|
|
635
|
-
effort:
|
|
759
|
+
model: suffix ? pair.model.slice(0, -suffix.length - 1) : pair.model,
|
|
760
|
+
effort: pair.effort,
|
|
636
761
|
};
|
|
637
762
|
}
|
|
638
763
|
|
|
@@ -656,11 +781,12 @@ async function checkAgyUsage(pi: ExtensionAPI, signal: AbortSignal): Promise<str
|
|
|
656
781
|
}
|
|
657
782
|
}
|
|
658
783
|
|
|
659
|
-
async function doctorReport(pi: ExtensionAPI, signal: AbortSignal): Promise<string> {
|
|
784
|
+
async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, signal: AbortSignal): Promise<string> {
|
|
660
785
|
const lines: string[] = [];
|
|
661
786
|
let failed = false;
|
|
662
787
|
let settings: BroSettings | undefined;
|
|
663
788
|
let models: AgyModelFamily[] | undefined;
|
|
789
|
+
let agyVersion: string | undefined;
|
|
664
790
|
const pass = (name: string, detail: string) => lines.push(`- ✓ **${name}:** ${detail}`);
|
|
665
791
|
const fail = (name: string, error: unknown) => {
|
|
666
792
|
failed = true;
|
|
@@ -683,7 +809,8 @@ async function doctorReport(pi: ExtensionAPI, signal: AbortSignal): Promise<stri
|
|
|
683
809
|
|
|
684
810
|
let agyStarted = false;
|
|
685
811
|
try {
|
|
686
|
-
|
|
812
|
+
agyVersion = await checkAgyVersion(pi, signal);
|
|
813
|
+
pass("Agy", agyVersion);
|
|
687
814
|
agyStarted = true;
|
|
688
815
|
} catch (error) {
|
|
689
816
|
if (signal.aborted) throw error;
|
|
@@ -723,8 +850,42 @@ async function doctorReport(pi: ExtensionAPI, signal: AbortSignal): Promise<stri
|
|
|
723
850
|
fail("Reasoning effort", `\`${effort}\` is unsupported. Run \`/bro effort\` to choose another.`);
|
|
724
851
|
}
|
|
725
852
|
}
|
|
853
|
+
|
|
854
|
+
for (const capability of CAPABILITIES) {
|
|
855
|
+
const label = CAPABILITY_LABELS[capability];
|
|
856
|
+
const override = capabilityOverride(settings, capability);
|
|
857
|
+
const resolved = resolveCapabilitySettings(settings, capability, models);
|
|
858
|
+
if (!resolved.family) {
|
|
859
|
+
fail(label, `\`${resolved.pair.model}\` is unavailable. Run \`/bro config\` to fix this override.`);
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
const effortOk = !resolved.family.efforts.length
|
|
863
|
+
? resolved.pair.effort === "default"
|
|
864
|
+
: resolved.pair.effort !== "default" && resolved.family.efforts.includes(resolved.pair.effort);
|
|
865
|
+
if (!effortOk) {
|
|
866
|
+
fail(label, `\`${resolved.pair.effort}\` is unsupported for \`${resolved.family.id}\`. Run \`/bro config\` to fix this.`);
|
|
867
|
+
continue;
|
|
868
|
+
}
|
|
869
|
+
pass(
|
|
870
|
+
label,
|
|
871
|
+
override
|
|
872
|
+
? `override \`${resolved.family.id}\`${resolved.pair.effort === "default" ? "" : ` (${resolved.pair.effort})`}`
|
|
873
|
+
: "using the shared default",
|
|
874
|
+
);
|
|
875
|
+
}
|
|
726
876
|
}
|
|
727
877
|
|
|
878
|
+
// bro_advisor has no on/off preference to compare against -- it is only ever gated by this
|
|
879
|
+
// host's own tool restrictions, which this reads via pi.getAllTools()/pi.getActiveTools() and
|
|
880
|
+
// never overrides. Exposed-but-inactive is the one genuine anomaly worth failing on.
|
|
881
|
+
const advisorExposed = pi.getAllTools().some((tool) => tool.name === ADVISOR_TOOL_NAME);
|
|
882
|
+
const advisorActive = pi.getActiveTools().includes(ADVISOR_TOOL_NAME);
|
|
883
|
+
if (advisorExposed && !advisorActive) fail("Advisor tool", "bro_advisor is exposed but not active in this session. Run /reload.");
|
|
884
|
+
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
|
+
pass("Advisor steering", resolveAdvisorState(ctx.sessionManager.getBranch()).steering.trim() ? "present" : "none");
|
|
886
|
+
if (agyVersion && advisorAgyCompatible(agyVersion)) pass("Advisor compatibility", ADVISOR_COMPATIBILITY);
|
|
887
|
+
else if (agyVersion) fail("Advisor compatibility", `installed \`${agyVersion}\`; requires Agy >=1.1.15. Run \`agy update\`.`);
|
|
888
|
+
|
|
728
889
|
return `# Bro doctor\n\n${lines.join("\n")}\n\n**${failed ? "Bro needs attention." : "Bro is ready."}**\n\n${
|
|
729
890
|
failed ? "Fix the failed items, then press **R** to check again." : "No assistant response was sent and no model turn was run."
|
|
730
891
|
}`;
|
|
@@ -946,7 +1107,7 @@ async function simplify(
|
|
|
946
1107
|
settings: BroSettings,
|
|
947
1108
|
onProgress?: (text: string) => void,
|
|
948
1109
|
): Promise<string> {
|
|
949
|
-
return runAgyText((await promptFor(response, settings.mode)).text, agySelection(settings), signal, onProgress);
|
|
1110
|
+
return runAgyText((await promptFor(response, settings.mode)).text, agySelection(capabilityPair(settings, "explain")), signal, onProgress);
|
|
950
1111
|
}
|
|
951
1112
|
|
|
952
1113
|
async function runShowExplanation(
|
|
@@ -956,7 +1117,7 @@ async function runShowExplanation(
|
|
|
956
1117
|
settings: BroSettings,
|
|
957
1118
|
onProgress?: (text: string) => void,
|
|
958
1119
|
): Promise<string> {
|
|
959
|
-
return runAgyText(buildShowPrompt(transcript, steering), agySelection(settings), signal, onProgress);
|
|
1120
|
+
return runAgyText(buildShowPrompt(transcript, steering), agySelection(capabilityPair(settings, "show")), signal, onProgress);
|
|
960
1121
|
}
|
|
961
1122
|
|
|
962
1123
|
async function runAgyText(
|
|
@@ -1067,9 +1228,917 @@ async function runAgyText(
|
|
|
1067
1228
|
}
|
|
1068
1229
|
}
|
|
1069
1230
|
|
|
1070
|
-
|
|
1231
|
+
// Harness-neutral advisor context snapshot: plain role/text/tool-name+args/tool-result-text, not
|
|
1232
|
+
// Pi's internal message objects serialized as-is, so the shape isn't a Pi-specific contract. Every
|
|
1233
|
+
// piece of free-text message content is JSON-quoted (the same "quote the source as data" convention
|
|
1234
|
+
// SOURCE_GUARD/buildShowPrompt/buildBtwPrompt already use in prompt.ts) rather than pasted in raw
|
|
1235
|
+
// after a bare "## role" heading: unquoted text under a heading would let pasted text or tool output
|
|
1236
|
+
// forge a fake "## user"/"## assistant" section that looks like a real turn boundary. See
|
|
1237
|
+
// docs/plans/2026-09-19-bro-advisor-design.md, "Context snapshot".
|
|
1238
|
+
type AdvisorMessageLike = { role?: unknown; content?: unknown; toolName?: unknown; toolCallId?: unknown; isError?: unknown };
|
|
1239
|
+
|
|
1240
|
+
// Parameter element type of Pi's own AgentMessage → LLM converter (core/messages.ts), reused below
|
|
1241
|
+
// instead of redeclaring BashExecutionMessage's shape (it isn't exported from the package root).
|
|
1242
|
+
type AdvisorAgentMessage = Parameters<typeof convertToLlm>[0][number];
|
|
1243
|
+
|
|
1244
|
+
function advisorQuoted(text: string): string {
|
|
1245
|
+
return JSON.stringify(text);
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// `resolvedToolCallIds` lets a still-pending bro_advisor call (the one currently in flight for this
|
|
1249
|
+
// very consultation, which by definition has no result yet) be dropped instead of rendered as an
|
|
1250
|
+
// orphaned, unresolved call — a past, completed bro_advisor call is unaffected and renders normally.
|
|
1251
|
+
function advisorContentText(content: unknown, resolvedToolCallIds: ReadonlySet<string>): string {
|
|
1252
|
+
if (typeof content === "string") {
|
|
1253
|
+
const text = content.trim();
|
|
1254
|
+
return text ? advisorQuoted(text) : "";
|
|
1255
|
+
}
|
|
1256
|
+
if (!Array.isArray(content)) return "";
|
|
1257
|
+
const parts: string[] = [];
|
|
1258
|
+
for (const part of content) {
|
|
1259
|
+
if (!part || typeof part !== "object") continue;
|
|
1260
|
+
const type = (part as { type?: string }).type;
|
|
1261
|
+
if (type === "text" && typeof (part as { text?: unknown }).text === "string") {
|
|
1262
|
+
const text = (part as { text: string }).text.trim();
|
|
1263
|
+
if (text) parts.push(advisorQuoted(text));
|
|
1264
|
+
} else if (type === "image") {
|
|
1265
|
+
parts.push("[image omitted]");
|
|
1266
|
+
} else if (type === "thinking") {
|
|
1267
|
+
parts.push("[reasoning omitted]");
|
|
1268
|
+
} else if (type === "toolCall") {
|
|
1269
|
+
const call = part as { id?: unknown; name?: unknown; arguments?: unknown };
|
|
1270
|
+
const id = typeof call.id === "string" ? call.id : undefined;
|
|
1271
|
+
const name = typeof call.name === "string" ? call.name : "(unknown)";
|
|
1272
|
+
if (name === ADVISOR_TOOL_NAME && (!id || !resolvedToolCallIds.has(id))) continue;
|
|
1273
|
+
parts.push(`[tool call${id ? ` #${id}` : ""}] ${name}(${JSON.stringify(call.arguments ?? {})})`);
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
return parts.join("\n").trim();
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
function renderAdvisorMessage(message: AdvisorMessageLike, resolvedToolCallIds: ReadonlySet<string>): string {
|
|
1280
|
+
if (message.role === "user") {
|
|
1281
|
+
const text = advisorContentText(message.content, resolvedToolCallIds);
|
|
1282
|
+
return text ? `## user\n${text}` : "";
|
|
1283
|
+
}
|
|
1284
|
+
if (message.role === "assistant") {
|
|
1285
|
+
const text = advisorContentText(message.content, resolvedToolCallIds);
|
|
1286
|
+
return text ? `## assistant\n${text}` : "";
|
|
1287
|
+
}
|
|
1288
|
+
if (message.role === "toolResult") {
|
|
1289
|
+
const name = typeof message.toolName === "string" ? message.toolName : "(unknown)";
|
|
1290
|
+
const id = typeof message.toolCallId === "string" ? message.toolCallId : undefined;
|
|
1291
|
+
const header = `## tool result${id ? ` [#${id}]` : ""}: ${name}${message.isError ? " · error" : ""}`;
|
|
1292
|
+
const text = advisorContentText(message.content, resolvedToolCallIds);
|
|
1293
|
+
return text ? `${header}\n${text}` : header;
|
|
1294
|
+
}
|
|
1295
|
+
if (message.role === "bashExecution") {
|
|
1296
|
+
// Reuse Pi's own AgentMessage -> LLM conversion instead of hand-rolling one: it already
|
|
1297
|
+
// drops a command run with the `!!` prefix (excludeFromContext) and formats the rest exactly
|
|
1298
|
+
// as Pi's own context builder would, so a `!`-run command an executor can see is not silently
|
|
1299
|
+
// missing from what the advisor sees.
|
|
1300
|
+
const [converted] = convertToLlm([message as AdvisorAgentMessage]);
|
|
1301
|
+
if (!converted) return "";
|
|
1302
|
+
const text = advisorContentText(converted.content, resolvedToolCallIds);
|
|
1303
|
+
return text ? `## bash execution\n${text}` : "";
|
|
1304
|
+
}
|
|
1305
|
+
return "";
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
export function buildAdvisorSnapshot(ctx: ExtensionContext, pi: ExtensionAPI): { text: string; hadCompaction: boolean } {
|
|
1309
|
+
const systemPrompt = ctx.getSystemPrompt().trim();
|
|
1310
|
+
|
|
1311
|
+
const activeToolNames = new Set(pi.getActiveTools().filter((name) => name !== ADVISOR_TOOL_NAME));
|
|
1312
|
+
const tools = pi.getAllTools().filter((tool) => activeToolNames.has(tool.name)).sort((a, b) => a.name.localeCompare(b.name));
|
|
1313
|
+
// No truncation of tool descriptions: Bro imposes no size cap on the snapshot (see the design
|
|
1314
|
+
// doc) and an arbitrary slice would silently drop part of a tool's actual behavior contract.
|
|
1315
|
+
const toolsSection = tools.length
|
|
1316
|
+
? tools.map((tool) => `- ${tool.name}: ${tool.description.replace(/\s+/g, " ").trim()}`).join("\n")
|
|
1317
|
+
: "(none)";
|
|
1318
|
+
|
|
1319
|
+
const entries = ctx.sessionManager.buildContextEntries();
|
|
1320
|
+
const resolvedToolCallIds = new Set<string>();
|
|
1321
|
+
for (const entry of entries) {
|
|
1322
|
+
if (entry.type !== "message") continue;
|
|
1323
|
+
const message = entry.message as AdvisorMessageLike;
|
|
1324
|
+
if (message.role === "toolResult" && typeof message.toolCallId === "string") resolvedToolCallIds.add(message.toolCallId);
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
const lines: string[] = [];
|
|
1328
|
+
// Tracked from the actual entry type, not a substring search over the rendered snapshot text --
|
|
1329
|
+
// free-text content (a file the executor read, a pasted paragraph) is JSON-quoted but not
|
|
1330
|
+
// stripped of "#", so a substring check could be forged by source data that happens to contain
|
|
1331
|
+
// the literal heading text. Only a real `compaction` entry may set this.
|
|
1332
|
+
let hadCompaction = false;
|
|
1333
|
+
for (const entry of entries) {
|
|
1334
|
+
if (entry.type === "message") {
|
|
1335
|
+
const rendered = renderAdvisorMessage(entry.message as AdvisorMessageLike, resolvedToolCallIds);
|
|
1336
|
+
if (rendered) lines.push(rendered);
|
|
1337
|
+
} else if (entry.type === "compaction") {
|
|
1338
|
+
// buildContextEntries() already includes every real entry after the compaction point;
|
|
1339
|
+
// the summary is all there is to represent for what it replaced.
|
|
1340
|
+
hadCompaction = true;
|
|
1341
|
+
lines.push(`## compacted earlier context\n${advisorQuoted(entry.summary)}`);
|
|
1342
|
+
} else if (entry.type === "branch_summary") {
|
|
1343
|
+
lines.push(`## abandoned branch summary\n${advisorQuoted(entry.summary)}`);
|
|
1344
|
+
} else if (entry.type === "custom_message") {
|
|
1345
|
+
const text = advisorContentText(entry.content, resolvedToolCallIds);
|
|
1346
|
+
if (text) lines.push(`## extension message: ${entry.customType}\n${text}`);
|
|
1347
|
+
}
|
|
1348
|
+
// "custom" entries (including our own advisor activation/steering state) are intentionally
|
|
1349
|
+
// skipped — they never participate in LLM context and must not leak into the snapshot either.
|
|
1350
|
+
}
|
|
1351
|
+
const conversationSection = lines.length ? lines.join("\n\n") : "(no conversation content captured)";
|
|
1352
|
+
|
|
1353
|
+
const text = `## Executor's system instructions\n\n${systemPrompt ? advisorQuoted(systemPrompt) : "(none)"}\n\n## Executor's active tools\n\n${toolsSection}\n\n## Conversation so far (message text is quoted as JSON strings; includes tool calls and results, correlated by call id; image and reasoning content is noted but omitted; the still-pending advisor call for this very consultation is omitted)\n\n${conversationSection}`;
|
|
1354
|
+
return { text, hadCompaction };
|
|
1355
|
+
}
|
|
1356
|
+
|
|
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
|
+
const MAX_ADVISOR_ACTIVITY_LINES = 4;
|
|
1395
|
+
const ADVISOR_ACTIVITY_PREVIEW_CHARS = 100;
|
|
1396
|
+
|
|
1397
|
+
function advisorActivityPreview(label: string): string {
|
|
1398
|
+
return label.length > ADVISOR_ACTIVITY_PREVIEW_CHARS ? `${label.slice(0, ADVISOR_ACTIVITY_PREVIEW_CHARS).trimEnd()}…` : label;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
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
|
+
export type AdvisorActivityCallback = (label: string, timestamp: number) => void;
|
|
1411
|
+
|
|
1412
|
+
export async function runAdvisorConsultation(
|
|
1413
|
+
prompt: string,
|
|
1414
|
+
selection: ReturnType<typeof agySelection>,
|
|
1415
|
+
cwd: string,
|
|
1416
|
+
signal: AbortSignal,
|
|
1417
|
+
killEscalationMs = 5_000,
|
|
1418
|
+
onActivity?: AdvisorActivityCallback,
|
|
1419
|
+
): Promise<string> {
|
|
1420
|
+
const child = spawn(
|
|
1421
|
+
"agy",
|
|
1422
|
+
[
|
|
1423
|
+
"--dangerously-skip-permissions",
|
|
1424
|
+
"--disable-slash-commands",
|
|
1425
|
+
"--output-format", "stream-json",
|
|
1426
|
+
"--input-format", "stream-json",
|
|
1427
|
+
"--model", selection.model,
|
|
1428
|
+
...(selection.effort ? ["--effort", selection.effort] : []),
|
|
1429
|
+
"--print-timeout", "10m",
|
|
1430
|
+
],
|
|
1431
|
+
// detached: true (POSIX only) makes the child its own process-group leader, so a signal to
|
|
1432
|
+
// -child.pid below reaches it AND every grandchild it spawned -- not just the immediate
|
|
1433
|
+
// process. Without this, killing only the immediate child can leave a grandchild holding the
|
|
1434
|
+
// inherited stdio pipes open, and the "close" event this function waits on never fires until
|
|
1435
|
+
// that orphan exits on its own.
|
|
1436
|
+
{ cwd, timeout: 610_000, stdio: ["pipe", "pipe", "pipe"], windowsHide: true, detached: process.platform !== "win32" },
|
|
1437
|
+
);
|
|
1438
|
+
|
|
1439
|
+
let processError: Error | undefined;
|
|
1440
|
+
let stderr = "";
|
|
1441
|
+
let final: string | undefined;
|
|
1442
|
+
let terminalError: string | undefined;
|
|
1443
|
+
let protocolError: string | undefined;
|
|
1444
|
+
let sawTerminal = false;
|
|
1445
|
+
let stdoutBuffer = "";
|
|
1446
|
+
|
|
1447
|
+
// Signal the whole process group when possible so a misbehaving grandchild dies too, not just
|
|
1448
|
+
// the immediate agy process; child.kill() alone only ever reaches the immediate child.
|
|
1449
|
+
const killAdvisorChild = (signalName: NodeJS.Signals) => {
|
|
1450
|
+
if (process.platform !== "win32" && typeof child.pid === "number") {
|
|
1451
|
+
try {
|
|
1452
|
+
process.kill(-child.pid, signalName);
|
|
1453
|
+
return;
|
|
1454
|
+
} catch {
|
|
1455
|
+
// Group may already be gone (e.g. the child already exited) -- fall through.
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
child.kill(signalName);
|
|
1459
|
+
};
|
|
1460
|
+
|
|
1461
|
+
// Relying on spawn({signal}) alone only ever sends one SIGTERM and gives up if the child (or a
|
|
1462
|
+
// misbehaving grandchild it spawned) ignores it, hanging this promise forever. Escalate to
|
|
1463
|
+
// SIGKILL -- which cannot be ignored -- if the child hasn't exited shortly after.
|
|
1464
|
+
let killEscalationTimer: ReturnType<typeof setTimeout> | undefined;
|
|
1465
|
+
const onAbort = () => {
|
|
1466
|
+
killAdvisorChild("SIGTERM");
|
|
1467
|
+
killEscalationTimer = setTimeout(() => {
|
|
1468
|
+
killAdvisorChild("SIGKILL");
|
|
1469
|
+
}, killEscalationMs);
|
|
1470
|
+
};
|
|
1471
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1472
|
+
if (signal.aborted) onAbort();
|
|
1473
|
+
|
|
1474
|
+
child.stderr.setEncoding("utf8");
|
|
1475
|
+
child.stderr.on("data", (chunk: string) => {
|
|
1476
|
+
stderr += chunk;
|
|
1477
|
+
});
|
|
1478
|
+
child.once("error", (error) => {
|
|
1479
|
+
processError = error;
|
|
1480
|
+
});
|
|
1481
|
+
child.stdin.on("error", () => {
|
|
1482
|
+
// agy exiting before it reads stdin is reported through the close/error path below.
|
|
1483
|
+
});
|
|
1484
|
+
child.stdin.end(`${JSON.stringify({ event: "user", message: { content: prompt } })}\n`);
|
|
1485
|
+
|
|
1486
|
+
const handleLine = (line: string) => {
|
|
1487
|
+
if (!line.trim() || sawTerminal) return;
|
|
1488
|
+
const event = parseAdvisorLine(line);
|
|
1489
|
+
const activity = advisorActivityFromEvent(event);
|
|
1490
|
+
if (activity) onActivity?.(activity, Date.now());
|
|
1491
|
+
if (event.event !== "result") return;
|
|
1492
|
+
sawTerminal = true;
|
|
1493
|
+
const result = event.result;
|
|
1494
|
+
const status = typeof result?.status === "string" ? result.status.trim().toUpperCase() : undefined;
|
|
1495
|
+
if (status === "SUCCESS" && typeof result?.response === "string") {
|
|
1496
|
+
final = result.response;
|
|
1497
|
+
} else {
|
|
1498
|
+
const detail = typeof result?.error === "string" && result.error.trim() ? `: ${result.error.trim()}` : "";
|
|
1499
|
+
terminalError = `Agy failed with status ${status ?? "(missing)"}${detail}`;
|
|
1500
|
+
}
|
|
1501
|
+
};
|
|
1502
|
+
|
|
1503
|
+
child.stdout.setEncoding("utf8");
|
|
1504
|
+
child.stdout.on("data", (chunk: string) => {
|
|
1505
|
+
stdoutBuffer += chunk;
|
|
1506
|
+
const parts = stdoutBuffer.split(/\r?\n/);
|
|
1507
|
+
stdoutBuffer = parts.pop() ?? "";
|
|
1508
|
+
if (stdoutBuffer.length > ADVISOR_MAX_STDOUT_LINE_CHARS || parts.some((line) => line.length > ADVISOR_MAX_STDOUT_LINE_CHARS)) {
|
|
1509
|
+
protocolError ??= `Agy emitted a stdout line over ${ADVISOR_MAX_STDOUT_LINE_CHARS} characters; the stream is unparseable.`;
|
|
1510
|
+
stdoutBuffer = "";
|
|
1511
|
+
killAdvisorChild("SIGTERM");
|
|
1512
|
+
return;
|
|
1513
|
+
}
|
|
1514
|
+
for (const line of parts) {
|
|
1515
|
+
try {
|
|
1516
|
+
handleLine(line);
|
|
1517
|
+
} catch (error) {
|
|
1518
|
+
protocolError ??= errorMessage(error);
|
|
1519
|
+
killAdvisorChild("SIGTERM");
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
});
|
|
1524
|
+
|
|
1525
|
+
const { code, exitSignal } = await new Promise<{ code: number | null; exitSignal: NodeJS.Signals | null }>((resolve) => {
|
|
1526
|
+
child.once("close", (code, exitSignal) => {
|
|
1527
|
+
if (stdoutBuffer.trim() && !sawTerminal) {
|
|
1528
|
+
try {
|
|
1529
|
+
handleLine(stdoutBuffer);
|
|
1530
|
+
} catch (error) {
|
|
1531
|
+
protocolError ??= errorMessage(error);
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
resolve({ code, exitSignal });
|
|
1535
|
+
});
|
|
1536
|
+
});
|
|
1537
|
+
signal.removeEventListener("abort", onAbort);
|
|
1538
|
+
if (killEscalationTimer) clearTimeout(killEscalationTimer);
|
|
1539
|
+
|
|
1540
|
+
if (signal.aborted) throw new Error("Canceled.");
|
|
1541
|
+
if (protocolError) throw new Error(withDoctor(protocolError));
|
|
1542
|
+
if (processError) {
|
|
1543
|
+
const missing = (processError as NodeJS.ErrnoException).code === "ENOENT";
|
|
1544
|
+
throw new Error(
|
|
1545
|
+
missing
|
|
1546
|
+
? "Agy could not start. Make sure Agy is installed and on PATH, then run `/bro doctor`."
|
|
1547
|
+
: `Agy could not start: ${processError.message}\n\nRun \`/bro doctor\` for setup help.`,
|
|
1548
|
+
);
|
|
1549
|
+
}
|
|
1550
|
+
if (exitSignal || code === null) {
|
|
1551
|
+
throw new Error("Agy timed out during the advisor consultation. Run `/bro doctor` for setup help.");
|
|
1552
|
+
}
|
|
1553
|
+
if (!sawTerminal) {
|
|
1554
|
+
const hint = advisorFlagErrorHint(stderr);
|
|
1555
|
+
throw new Error(withDoctor(hint ?? (stderr.trim() ? `Agy exited without a terminal result event: ${stderr.trim()}` : "Agy exited without a terminal result event.")));
|
|
1556
|
+
}
|
|
1557
|
+
if (terminalError) throw new Error(withDoctor(terminalError));
|
|
1558
|
+
if (code !== 0) throw new Error(agyFailureMessage("complete the advisor consultation", { code, killed: false, stderr }));
|
|
1559
|
+
|
|
1560
|
+
const text = final?.trim();
|
|
1561
|
+
if (!text) throw new Error(withDoctor(stderr.trim() || "Agy returned no advice."));
|
|
1562
|
+
return text;
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
function advisorDelay(ms: number, signal: AbortSignal, onTick?: (remainingMs: number) => void): Promise<void> {
|
|
1566
|
+
return new Promise((resolve, reject) => {
|
|
1567
|
+
if (signal.aborted) {
|
|
1568
|
+
reject(new Error("Canceled."));
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
const startedAt = Date.now();
|
|
1572
|
+
const interval = onTick ? setInterval(() => onTick(Math.max(0, ms - (Date.now() - startedAt))), 1_000) : undefined;
|
|
1573
|
+
interval?.unref();
|
|
1574
|
+
const cleanup = () => {
|
|
1575
|
+
clearTimeout(timer);
|
|
1576
|
+
if (interval) clearInterval(interval);
|
|
1577
|
+
signal.removeEventListener("abort", onAbort);
|
|
1578
|
+
};
|
|
1579
|
+
const onAbort = () => {
|
|
1580
|
+
cleanup();
|
|
1581
|
+
reject(new Error("Canceled."));
|
|
1582
|
+
};
|
|
1583
|
+
const timer = setTimeout(() => {
|
|
1584
|
+
cleanup();
|
|
1585
|
+
resolve();
|
|
1586
|
+
}, ms);
|
|
1587
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1588
|
+
});
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
export type AdvisorConsult = (
|
|
1592
|
+
prompt: string,
|
|
1593
|
+
selection: ReturnType<typeof agySelection>,
|
|
1594
|
+
cwd: string,
|
|
1595
|
+
signal: AbortSignal,
|
|
1596
|
+
killEscalationMs?: number,
|
|
1597
|
+
onActivity?: AdvisorActivityCallback,
|
|
1598
|
+
) => Promise<string>;
|
|
1599
|
+
export type AdvisorDelayFn = (ms: number, signal: AbortSignal, onTick?: (remainingMs: number) => void) => Promise<void>;
|
|
1600
|
+
export type AdvisorToolDetails = {
|
|
1601
|
+
status: "investigating" | "retrying" | "done";
|
|
1602
|
+
attempt: number;
|
|
1603
|
+
of: number;
|
|
1604
|
+
elapsedMs: number;
|
|
1605
|
+
error?: string;
|
|
1606
|
+
retryInMs?: number;
|
|
1607
|
+
model?: string;
|
|
1608
|
+
effort?: string;
|
|
1609
|
+
durationMs?: number;
|
|
1610
|
+
cwd?: string;
|
|
1611
|
+
steeringIncluded?: boolean;
|
|
1612
|
+
snapshotChars?: number;
|
|
1613
|
+
broTruncated?: false;
|
|
1614
|
+
omissions?: string;
|
|
1615
|
+
// Real Agy-reported activity for the current attempt only -- reset to empty whenever a new
|
|
1616
|
+
// attempt (including a retry) starts, never carried over from a prior failed attempt.
|
|
1617
|
+
activity?: string[];
|
|
1618
|
+
lastActivityAt?: number;
|
|
1619
|
+
// Total count of accepted activity events (tool calls AND user-facing text deltas -- "activity"
|
|
1620
|
+
// is the honest label, not "tool calls") observed across the whole consultation, including every
|
|
1621
|
+
// retry attempt. Unlike `activity` above, this never resets on a retry; the final "done" details
|
|
1622
|
+
// carry the same running total.
|
|
1623
|
+
activityCount?: number;
|
|
1624
|
+
};
|
|
1625
|
+
export type AdvisorProgressCallback = (details: AdvisorToolDetails) => void;
|
|
1626
|
+
export type AdvisorRunResult = { advice: string; attempts: number; durationMs: number; activityCount: number };
|
|
1627
|
+
|
|
1628
|
+
// Bursts of chunked NDJSON activity events are coalesced to this cadence so a fast stream of tool
|
|
1629
|
+
// calls/text deltas doesn't flood onProgress; the 1s elapsed-time heartbeat below is unaffected and
|
|
1630
|
+
// keeps ticking independently.
|
|
1631
|
+
const ADVISOR_ACTIVITY_THROTTLE_MS = 250;
|
|
1632
|
+
|
|
1633
|
+
// `consult`/`delayFn` are injectable so tests can swap in a fake agy spawn and a fake clock
|
|
1634
|
+
// instead of spawning real processes and waiting 15 real seconds. Every attempt sends the
|
|
1635
|
+
// identical prompt/selection/cwd to a fresh, standalone Agy process — never resumed via
|
|
1636
|
+
// --conversation, even across retries.
|
|
1637
|
+
export async function runAdvisorWithRetries(
|
|
1638
|
+
prompt: string,
|
|
1639
|
+
selection: ReturnType<typeof agySelection>,
|
|
1640
|
+
cwd: string,
|
|
1641
|
+
signal: AbortSignal,
|
|
1642
|
+
consult: AdvisorConsult = runAdvisorConsultation,
|
|
1643
|
+
delayFn: AdvisorDelayFn = advisorDelay,
|
|
1644
|
+
onProgress?: AdvisorProgressCallback,
|
|
1645
|
+
progressIntervalMs = 1_000,
|
|
1646
|
+
activityThrottleMs = ADVISOR_ACTIVITY_THROTTLE_MS,
|
|
1647
|
+
): Promise<AdvisorRunResult> {
|
|
1648
|
+
const delays = [5_000, 10_000];
|
|
1649
|
+
const totalAttempts = delays.length + 1;
|
|
1650
|
+
const startedAt = Date.now();
|
|
1651
|
+
let lastError: unknown;
|
|
1652
|
+
// Cumulative across every attempt in this consultation, including retries -- unlike `activity`
|
|
1653
|
+
// below, a retry must never reset this back to zero.
|
|
1654
|
+
let totalActivityCount = 0;
|
|
1655
|
+
for (let attempt = 0; attempt <= delays.length; attempt++) {
|
|
1656
|
+
if (signal.aborted) throw new Error("Canceled.");
|
|
1657
|
+
// Fresh per attempt: a retry must never show the previous attempt's activity trail.
|
|
1658
|
+
const activity: string[] = [];
|
|
1659
|
+
let lastActivityAt: number | undefined;
|
|
1660
|
+
let lastActivityEmitAt = 0;
|
|
1661
|
+
let pendingActivityTimer: ReturnType<typeof setTimeout> | undefined;
|
|
1662
|
+
|
|
1663
|
+
const running = (): AdvisorToolDetails => ({
|
|
1664
|
+
status: "investigating",
|
|
1665
|
+
attempt: attempt + 1,
|
|
1666
|
+
of: totalAttempts,
|
|
1667
|
+
elapsedMs: Date.now() - startedAt,
|
|
1668
|
+
activity: [...activity],
|
|
1669
|
+
lastActivityAt,
|
|
1670
|
+
activityCount: totalActivityCount,
|
|
1671
|
+
});
|
|
1672
|
+
const emitRunning = () => onProgress?.(running());
|
|
1673
|
+
emitRunning();
|
|
1674
|
+
const progressTimer = onProgress ? setInterval(emitRunning, progressIntervalMs) : undefined;
|
|
1675
|
+
progressTimer?.unref();
|
|
1676
|
+
|
|
1677
|
+
const stopActivityThrottle = () => {
|
|
1678
|
+
if (pendingActivityTimer) {
|
|
1679
|
+
clearTimeout(pendingActivityTimer);
|
|
1680
|
+
pendingActivityTimer = undefined;
|
|
1681
|
+
}
|
|
1682
|
+
};
|
|
1683
|
+
const onActivity: AdvisorActivityCallback = (label, timestamp) => {
|
|
1684
|
+
const normalized = label.replace(/\s+/g, " ").trim();
|
|
1685
|
+
if (!normalized) return;
|
|
1686
|
+
activity.push(normalized);
|
|
1687
|
+
if (activity.length > MAX_ADVISOR_ACTIVITY_LINES) activity.splice(0, activity.length - MAX_ADVISOR_ACTIVITY_LINES);
|
|
1688
|
+
totalActivityCount += 1;
|
|
1689
|
+
lastActivityAt = timestamp;
|
|
1690
|
+
if (!onProgress) return;
|
|
1691
|
+
const elapsed = Date.now() - lastActivityEmitAt;
|
|
1692
|
+
if (elapsed >= activityThrottleMs) {
|
|
1693
|
+
lastActivityEmitAt = Date.now();
|
|
1694
|
+
emitRunning();
|
|
1695
|
+
return;
|
|
1696
|
+
}
|
|
1697
|
+
if (!pendingActivityTimer) {
|
|
1698
|
+
pendingActivityTimer = setTimeout(() => {
|
|
1699
|
+
pendingActivityTimer = undefined;
|
|
1700
|
+
lastActivityEmitAt = Date.now();
|
|
1701
|
+
emitRunning();
|
|
1702
|
+
}, activityThrottleMs - elapsed);
|
|
1703
|
+
pendingActivityTimer.unref?.();
|
|
1704
|
+
}
|
|
1705
|
+
};
|
|
1706
|
+
|
|
1707
|
+
try {
|
|
1708
|
+
const advice = await consult(prompt, selection, cwd, signal, undefined, onActivity);
|
|
1709
|
+
// `totalActivityCount` is read directly here rather than from the last onProgress snapshot,
|
|
1710
|
+
// which may lag behind by up to `activityThrottleMs` -- the final count must be exact.
|
|
1711
|
+
return { advice, attempts: attempt + 1, durationMs: Date.now() - startedAt, activityCount: totalActivityCount };
|
|
1712
|
+
} catch (error) {
|
|
1713
|
+
if (progressTimer) clearInterval(progressTimer);
|
|
1714
|
+
stopActivityThrottle();
|
|
1715
|
+
if (signal.aborted || errorMessage(error) === "Canceled.") throw error;
|
|
1716
|
+
lastError = error;
|
|
1717
|
+
if (attempt === delays.length) break;
|
|
1718
|
+
const delay = delays[attempt]!;
|
|
1719
|
+
const retrying = (remainingMs: number): AdvisorToolDetails => ({
|
|
1720
|
+
status: "retrying",
|
|
1721
|
+
attempt: attempt + 2,
|
|
1722
|
+
of: totalAttempts,
|
|
1723
|
+
elapsedMs: Date.now() - startedAt,
|
|
1724
|
+
error: errorMessage(error),
|
|
1725
|
+
retryInMs: remainingMs,
|
|
1726
|
+
activityCount: totalActivityCount,
|
|
1727
|
+
});
|
|
1728
|
+
onProgress?.(retrying(delay));
|
|
1729
|
+
await delayFn(delay, signal, (remainingMs) => onProgress?.(retrying(remainingMs)));
|
|
1730
|
+
} finally {
|
|
1731
|
+
if (progressTimer) clearInterval(progressTimer);
|
|
1732
|
+
stopActivityThrottle();
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
throw lastError;
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
export function advisorAttemptLabel(details: AdvisorToolDetails): string {
|
|
1739
|
+
if (details.status === "retrying") {
|
|
1740
|
+
return `Bro advisor · retrying in ${Math.ceil((details.retryInMs ?? 0) / 1_000)}s · attempt ${details.attempt}/${details.of}${details.error ? ` — ${details.error}` : ""}`;
|
|
1741
|
+
}
|
|
1742
|
+
if (details.status === "done") {
|
|
1743
|
+
return `Bro advisor · ${details.model ?? "unknown model"} · ${details.attempt} attempt${details.attempt === 1 ? "" : "s"} · ${Math.ceil((details.durationMs ?? details.elapsedMs) / 1_000)}s`;
|
|
1744
|
+
}
|
|
1745
|
+
const latest = details.activity?.at(-1);
|
|
1746
|
+
// "last reported" + freshness, never a claim about what Agy is doing right now and never
|
|
1747
|
+
// "stalled" -- silence since lastActivityAt is not itself evidence of a stuck run.
|
|
1748
|
+
const activityLabel = latest
|
|
1749
|
+
? `last reported: ${advisorActivityPreview(latest)} (${Math.max(0, Math.floor((Date.now() - (details.lastActivityAt ?? Date.now())) / 1_000))}s ago)`
|
|
1750
|
+
: "awaiting first activity from Agy";
|
|
1751
|
+
return `Bro advisor · running · ${Math.floor(details.elapsedMs / 1_000)}s · attempt ${details.attempt}/${details.of} · ${activityLabel}`;
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
const SHOW_TURNS_PRESETS = [1, 2, 3, 5, 8];
|
|
1755
|
+
|
|
1756
|
+
function showTurnsValues(current: number): string[] {
|
|
1757
|
+
return [...new Set([...SHOW_TURNS_PRESETS, current])].sort((a, b) => a - b).map(String);
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
// A resolved pair's display string: distinguishes a model that is genuinely fixed-effort from
|
|
1761
|
+
// one that simply isn't in the current catalog (both used to render as "fixed", which read as
|
|
1762
|
+
// falsely healthy for an unavailable model), and flags a stored effort that isn't one of the
|
|
1763
|
+
// resolved family's supported efforts instead of silently showing it as if it were valid.
|
|
1764
|
+
function effortDisplay(resolved: { pair: ModelEffortPair; family?: AgyModelFamily }): string {
|
|
1765
|
+
if (!resolved.family) return "unavailable";
|
|
1766
|
+
const fixed = !resolved.family.efforts.length;
|
|
1767
|
+
const valid = fixed ? resolved.pair.effort === "default" : resolved.family.efforts.includes(resolved.pair.effort as AgyEffort);
|
|
1768
|
+
if (!valid) return `${resolved.pair.effort} (unsupported)`;
|
|
1769
|
+
return fixed ? "fixed" : resolved.pair.effort;
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
// Testable core: takes settings/catalog/persist as plain arguments so smoke tests can drive
|
|
1773
|
+
// the exact interaction (submenus, cancel, cycling, save failure) without a real Agy process
|
|
1774
|
+
// or settings file. showBroConfigModal below wires this to the real ctx/pi/filesystem.
|
|
1775
|
+
export function createConfigModal(
|
|
1776
|
+
initialSettings: BroSettings,
|
|
1777
|
+
families: AgyModelFamily[],
|
|
1778
|
+
persistSettings: (settings: BroSettings) => Promise<void>,
|
|
1779
|
+
): (tui: TuiLike, theme: Theme, keybindings: unknown, done: (value?: void) => void) => Component & { dispose?(): void } {
|
|
1780
|
+
return (tui, theme, _keybindings, done) => {
|
|
1781
|
+
let settings = initialSettings;
|
|
1782
|
+
// The last settings actually confirmed on disk. A failed save reverts `settings` (and the
|
|
1783
|
+
// whole displayed row set) back to this, so the screen never shows state that doesn't exist.
|
|
1784
|
+
let savedSettings = initialSettings;
|
|
1785
|
+
// Only one persistSettings call is ever in flight. A change that arrives while one is
|
|
1786
|
+
// already running is coalesced into `queued` (overwriting any earlier queued change) rather
|
|
1787
|
+
// than firing a second concurrent write — this is what keeps writes serialized and makes
|
|
1788
|
+
// sure the on-disk file always converges on the latest intent instead of a stale one that
|
|
1789
|
+
// happened to finish last.
|
|
1790
|
+
let saving = false;
|
|
1791
|
+
let queued: BroSettings | undefined;
|
|
1792
|
+
// Esc while a save is in flight must not close past an unshown result: it requests a close
|
|
1793
|
+
// that only actually happens once the in-flight (and any coalesced) save has settled, and
|
|
1794
|
+
// only if it succeeded — a failure cancels the pending close so its notice stays visible.
|
|
1795
|
+
let closeRequested = false;
|
|
1796
|
+
|
|
1797
|
+
const findFamily = (modelId: string) =>
|
|
1798
|
+
families.find((item) => item.id === modelId || item.variants.some((variant) => variant.id === modelId));
|
|
1799
|
+
|
|
1800
|
+
const modelPicker = (current: string, pickerDone: (value?: string) => void, capability?: Capability) => {
|
|
1801
|
+
const defaultResolved = resolveModelEffort({ model: settings.model, effort: settings.effort }, families);
|
|
1802
|
+
const options: SelectItem[] = [
|
|
1803
|
+
...(capability ? [{ value: "__default__", label: `Default (${defaultResolved.family?.label ?? settings.model})` }] : []),
|
|
1804
|
+
...families.map((family) => ({
|
|
1805
|
+
value: family.id,
|
|
1806
|
+
label: `${family.label}${family.efforts.length ? "" : " · fixed effort"}`,
|
|
1807
|
+
})),
|
|
1808
|
+
];
|
|
1809
|
+
const picker = new SelectList(options, Math.min(options.length, 8), getSelectListTheme());
|
|
1810
|
+
const selectedIndex = capability && current === "Default" ? 0 : options.findIndex((option) => option.value === current);
|
|
1811
|
+
picker.setSelectedIndex(Math.max(0, selectedIndex));
|
|
1812
|
+
picker.onSelect = (item) => pickerDone(item.value);
|
|
1813
|
+
picker.onCancel = () => pickerDone();
|
|
1814
|
+
return picker;
|
|
1815
|
+
};
|
|
1816
|
+
|
|
1817
|
+
const modelItem: SettingItem = { id: "model", label: "Default model", currentValue: settings.model, submenu: modelPicker };
|
|
1818
|
+
const effortItem: SettingItem = { id: "effort", label: "Default effort", currentValue: "" };
|
|
1819
|
+
const modeItem: SettingItem = { id: "mode", label: "Explain mode", currentValue: settings.mode, values: [...BRO_MODES] };
|
|
1820
|
+
const showTurnsItem: SettingItem = {
|
|
1821
|
+
id: "showTurns",
|
|
1822
|
+
label: "Show turns",
|
|
1823
|
+
currentValue: String(settings.showTurns),
|
|
1824
|
+
values: showTurnsValues(settings.showTurns),
|
|
1825
|
+
};
|
|
1826
|
+
const capabilityItems = Object.fromEntries(
|
|
1827
|
+
CAPABILITIES.map((capability) => [
|
|
1828
|
+
capability,
|
|
1829
|
+
{
|
|
1830
|
+
model: {
|
|
1831
|
+
id: `${capability}Model`,
|
|
1832
|
+
label: `${CAPABILITY_LABELS[capability]} model`,
|
|
1833
|
+
currentValue: "Default",
|
|
1834
|
+
submenu: (current: string, pickerDone: (value?: string) => void) => modelPicker(current, pickerDone, capability),
|
|
1835
|
+
} as SettingItem,
|
|
1836
|
+
effort: {
|
|
1837
|
+
id: `${capability}Effort`,
|
|
1838
|
+
label: `${CAPABILITY_LABELS[capability]} effort`,
|
|
1839
|
+
currentValue: "",
|
|
1840
|
+
} as SettingItem,
|
|
1841
|
+
},
|
|
1842
|
+
]),
|
|
1843
|
+
) as Record<Capability, { model: SettingItem; effort: SettingItem }>;
|
|
1844
|
+
|
|
1845
|
+
function refresh(): void {
|
|
1846
|
+
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;
|
|
1850
|
+
modeItem.currentValue = settings.mode;
|
|
1851
|
+
showTurnsItem.currentValue = String(settings.showTurns);
|
|
1852
|
+
showTurnsItem.values = showTurnsValues(settings.showTurns);
|
|
1853
|
+
|
|
1854
|
+
for (const capability of CAPABILITIES) {
|
|
1855
|
+
const override = capabilityOverride(settings, capability);
|
|
1856
|
+
const resolved = resolveModelEffort(capabilityPair(settings, capability), families);
|
|
1857
|
+
const rows = capabilityItems[capability];
|
|
1858
|
+
rows.model.currentValue = override ? (resolved.family?.id ?? override.model) : "Default";
|
|
1859
|
+
rows.effort.currentValue = effortDisplay(resolved);
|
|
1860
|
+
rows.effort.values = resolved.family?.efforts.length ? [...resolved.family.efforts] : undefined;
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
refresh();
|
|
1864
|
+
|
|
1865
|
+
const items: SettingItem[] = [
|
|
1866
|
+
modelItem,
|
|
1867
|
+
effortItem,
|
|
1868
|
+
modeItem,
|
|
1869
|
+
showTurnsItem,
|
|
1870
|
+
...CAPABILITIES.flatMap((capability) => [capabilityItems[capability].model, capabilityItems[capability].effort]),
|
|
1871
|
+
];
|
|
1872
|
+
|
|
1873
|
+
const noticeText = new Text("");
|
|
1874
|
+
|
|
1875
|
+
function runSave(toSave: BroSettings): void {
|
|
1876
|
+
saving = true;
|
|
1877
|
+
void persistSettings(toSave)
|
|
1878
|
+
.then(() => {
|
|
1879
|
+
savedSettings = toSave;
|
|
1880
|
+
noticeText.setText("");
|
|
1881
|
+
})
|
|
1882
|
+
.catch((error: unknown) => {
|
|
1883
|
+
// Restore the last state that is actually on disk: showing the failed, unsaved
|
|
1884
|
+
// value would let the screen claim a setting that doesn't really exist.
|
|
1885
|
+
settings = savedSettings;
|
|
1886
|
+
queued = undefined;
|
|
1887
|
+
closeRequested = false;
|
|
1888
|
+
refresh();
|
|
1889
|
+
noticeText.setText(theme.fg("warning", `Could not save settings: ${errorMessage(error)}. Reverted to the last saved settings.`));
|
|
1890
|
+
})
|
|
1891
|
+
.finally(() => {
|
|
1892
|
+
saving = false;
|
|
1893
|
+
tui.requestRender();
|
|
1894
|
+
if (queued !== undefined) {
|
|
1895
|
+
const next = queued;
|
|
1896
|
+
queued = undefined;
|
|
1897
|
+
runSave(next);
|
|
1898
|
+
} else if (closeRequested) {
|
|
1899
|
+
closeRequested = false;
|
|
1900
|
+
done(undefined);
|
|
1901
|
+
}
|
|
1902
|
+
});
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
function persist(toSave: BroSettings): void {
|
|
1906
|
+
if (saving) {
|
|
1907
|
+
queued = toSave;
|
|
1908
|
+
return;
|
|
1909
|
+
}
|
|
1910
|
+
runSave(toSave);
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
const onChange = (id: string, newValue: string) => {
|
|
1914
|
+
if (id === "model") {
|
|
1915
|
+
const family = findFamily(newValue);
|
|
1916
|
+
if (!family) return;
|
|
1917
|
+
const keepCurrent = settings.effort === "default" ? !family.efforts.length : family.efforts.includes(settings.effort as AgyEffort);
|
|
1918
|
+
settings = { ...settings, model: family.id, effort: keepCurrent ? settings.effort : preferredEffort(family) };
|
|
1919
|
+
} else if (id === "effort") {
|
|
1920
|
+
// Effort-only edit: pin the resolved family's canonical id, same reasoning as the
|
|
1921
|
+
// capability-override effort-only edit below -- otherwise a shared default created
|
|
1922
|
+
// from a suffixed variant id (e.g. "gemini-x-low") would end up paired with an
|
|
1923
|
+
// unrelated effort instead of its actual family id.
|
|
1924
|
+
const resolved = resolveModelEffort({ model: settings.model, effort: settings.effort }, families);
|
|
1925
|
+
settings = { ...settings, model: resolved.family?.id ?? settings.model, effort: newValue as BroEffort };
|
|
1926
|
+
} else if (id === "mode") {
|
|
1927
|
+
const mode = parseBroMode(newValue);
|
|
1928
|
+
if (!mode) return;
|
|
1929
|
+
settings = { ...settings, mode };
|
|
1930
|
+
} else if (id === "showTurns") {
|
|
1931
|
+
const turns = Number(newValue);
|
|
1932
|
+
if (!Number.isInteger(turns) || turns < 1) return;
|
|
1933
|
+
settings = { ...settings, showTurns: turns };
|
|
1934
|
+
} else {
|
|
1935
|
+
const capability = CAPABILITIES.find((item) => id === `${item}Model` || id === `${item}Effort`);
|
|
1936
|
+
if (!capability) return;
|
|
1937
|
+
if (id === `${capability}Model`) {
|
|
1938
|
+
if (newValue === "__default__") {
|
|
1939
|
+
settings = withCapabilityOverride(settings, capability, undefined);
|
|
1940
|
+
} else {
|
|
1941
|
+
const family = findFamily(newValue);
|
|
1942
|
+
if (!family) return;
|
|
1943
|
+
const currentEffort = capabilityPair(settings, capability).effort;
|
|
1944
|
+
const keepCurrent = currentEffort === "default" ? !family.efforts.length : family.efforts.includes(currentEffort as AgyEffort);
|
|
1945
|
+
settings = withCapabilityOverride(settings, capability, {
|
|
1946
|
+
model: family.id,
|
|
1947
|
+
effort: keepCurrent ? currentEffort : preferredEffort(family),
|
|
1948
|
+
});
|
|
1949
|
+
}
|
|
1950
|
+
} else {
|
|
1951
|
+
// Effort-only edit: pin the resolved family's canonical id, never whatever raw
|
|
1952
|
+
// string happens to sit in settings.model/override.model (which — for a shared
|
|
1953
|
+
// default created from a suffixed variant id such as "gemini-x-low" with
|
|
1954
|
+
// effort "default" — is not the family id). Storing the raw string here would
|
|
1955
|
+
// pair a mismatched model/effort (e.g. a "-low"-suffixed id with effort "high").
|
|
1956
|
+
const resolved = resolveModelEffort(capabilityPair(settings, capability), families);
|
|
1957
|
+
const existing = capabilityOverride(settings, capability);
|
|
1958
|
+
const model = resolved.family?.id ?? existing?.model ?? settings.model;
|
|
1959
|
+
settings = withCapabilityOverride(settings, capability, { model, effort: newValue as BroEffort });
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
refresh();
|
|
1963
|
+
tui.requestRender();
|
|
1964
|
+
persist(settings);
|
|
1965
|
+
};
|
|
1966
|
+
|
|
1967
|
+
const requestClose = () => {
|
|
1968
|
+
if (saving) {
|
|
1969
|
+
closeRequested = true;
|
|
1970
|
+
return;
|
|
1971
|
+
}
|
|
1972
|
+
done(undefined);
|
|
1973
|
+
};
|
|
1974
|
+
|
|
1975
|
+
const settingsList = new SettingsList(items, Math.min(items.length + 2, 18), getSettingsListTheme(), onChange, requestClose);
|
|
1976
|
+
const container = new Container();
|
|
1977
|
+
container.addChild(new Text(theme.fg("accent", theme.bold("Bro · config"))));
|
|
1978
|
+
container.addChild(new Text(theme.fg("dim", "Shared defaults, with optional overrides per capability")));
|
|
1979
|
+
container.addChild(settingsList);
|
|
1980
|
+
container.addChild(noticeText);
|
|
1981
|
+
container.addChild(new Text(theme.fg("dim", "↑/↓ navigate · Enter select/change · Esc back/close")));
|
|
1982
|
+
|
|
1983
|
+
return {
|
|
1984
|
+
render: (w: number) => {
|
|
1985
|
+
const inner = Math.max(1, w - 4);
|
|
1986
|
+
const border = (left: string, right: string) => theme.fg("border", left + "─".repeat(inner + 2) + right);
|
|
1987
|
+
return [border("┌", "┐"), ...container.render(inner).map(line => {
|
|
1988
|
+
const text = truncateToWidth(line, inner, "");
|
|
1989
|
+
return theme.fg("border", "│") + " " + text + " ".repeat(Math.max(0, inner - visibleWidth(text))) + " " + theme.fg("border", "│");
|
|
1990
|
+
}), border("└", "┘")];
|
|
1991
|
+
},
|
|
1992
|
+
invalidate: () => container.invalidate(),
|
|
1993
|
+
handleInput: (data: string) => {
|
|
1994
|
+
settingsList.handleInput?.(data);
|
|
1995
|
+
tui.requestRender();
|
|
1996
|
+
},
|
|
1997
|
+
};
|
|
1998
|
+
};
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
export async function showBroConfigModal(ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> {
|
|
2002
|
+
if (ctx.mode !== "tui") {
|
|
2003
|
+
ctx.ui.notify("Use /bro config in Pi's interactive UI.", "warning");
|
|
2004
|
+
return;
|
|
2005
|
+
}
|
|
2006
|
+
let settings: BroSettings;
|
|
2007
|
+
let families: AgyModelFamily[];
|
|
2008
|
+
try {
|
|
2009
|
+
settings = await readSettings();
|
|
2010
|
+
families = await listAgyModels(pi);
|
|
2011
|
+
} catch (error) {
|
|
2012
|
+
ctx.ui.notify(withDoctor(error), "error");
|
|
2013
|
+
return;
|
|
2014
|
+
}
|
|
2015
|
+
await ctx.ui.custom<void>(createConfigModal(settings, families, writeSettings), {
|
|
2016
|
+
overlay: true,
|
|
2017
|
+
overlayOptions: {
|
|
2018
|
+
width: "78%",
|
|
2019
|
+
minWidth: 48,
|
|
2020
|
+
maxHeight: "78%",
|
|
2021
|
+
anchor: "top-center",
|
|
2022
|
+
margin: { top: 1, left: 2, right: 2 },
|
|
2023
|
+
},
|
|
2024
|
+
});
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
// Testable core for /bro advisor-steer: persistence only happens on Ctrl+S/Ctrl+K.
|
|
2028
|
+
// Esc leaves the stored brief untouched; only the in-memory draft is discarded.
|
|
2029
|
+
export function createAdvisorSteerModal(
|
|
2030
|
+
initialText: string,
|
|
2031
|
+
onSave: (text: string) => void,
|
|
2032
|
+
onClear: () => void,
|
|
2033
|
+
copy: (text: string) => Promise<void> = copyToClipboard,
|
|
2034
|
+
): (tui: TUI, theme: Theme, keybindings: unknown, done: (value?: void) => void) => Component & { dispose?(): void } {
|
|
2035
|
+
return (tui, theme, _keybindings, done) => {
|
|
2036
|
+
const editorTheme: EditorTheme = { borderColor: (s: string) => theme.fg("border", s), selectList: getSelectListTheme() };
|
|
2037
|
+
const editor = new Editor(tui, editorTheme);
|
|
2038
|
+
editor.focused = true;
|
|
2039
|
+
editor.setText(initialText);
|
|
2040
|
+
let disposed = false;
|
|
2041
|
+
const notice = new Text("");
|
|
2042
|
+
const showNotice = (message: string, color: "success" | "error") => {
|
|
2043
|
+
if (disposed) return;
|
|
2044
|
+
notice.setText(theme.fg(color, message));
|
|
2045
|
+
tui.requestRender();
|
|
2046
|
+
};
|
|
2047
|
+
editor.onChange = () => notice.setText("");
|
|
2048
|
+
|
|
2049
|
+
const container = new Container();
|
|
2050
|
+
container.addChild(new Text(theme.fg("accent", theme.bold("Bro · advisor steer"))));
|
|
2051
|
+
container.addChild(new Text(theme.fg("dim", "One persistent steering brief the advisor always sees — never sent to the main model.")));
|
|
2052
|
+
container.addChild(editor);
|
|
2053
|
+
container.addChild(notice);
|
|
2054
|
+
container.addChild(new Text(theme.fg("dim", "Ctrl+S save · Enter newline · Ctrl+K clear · Ctrl+C copy · Esc close")));
|
|
2055
|
+
|
|
2056
|
+
return {
|
|
2057
|
+
render: (w: number) => {
|
|
2058
|
+
const inner = Math.max(1, w - 4);
|
|
2059
|
+
const border = (left: string, right: string) => theme.fg("border", left + "─".repeat(inner + 2) + right);
|
|
2060
|
+
return [border("┌", "┐"), ...container.render(inner).map((line) => {
|
|
2061
|
+
const text = truncateToWidth(line, inner, "");
|
|
2062
|
+
return theme.fg("border", "│") + " " + text + " ".repeat(Math.max(0, inner - visibleWidth(text))) + " " + theme.fg("border", "│");
|
|
2063
|
+
}), border("└", "┘")];
|
|
2064
|
+
},
|
|
2065
|
+
invalidate: () => container.invalidate(),
|
|
2066
|
+
handleInput: (data: string) => {
|
|
2067
|
+
if (matchesKey(data, "escape")) {
|
|
2068
|
+
disposed = true;
|
|
2069
|
+
done(undefined);
|
|
2070
|
+
return;
|
|
2071
|
+
}
|
|
2072
|
+
if (matchesKey(data, "ctrl+s")) {
|
|
2073
|
+
try {
|
|
2074
|
+
onSave(editor.getExpandedText());
|
|
2075
|
+
showNotice("Saved", "success");
|
|
2076
|
+
} catch (error) {
|
|
2077
|
+
showNotice(`Save failed: ${errorMessage(error)}`, "error");
|
|
2078
|
+
}
|
|
2079
|
+
return;
|
|
2080
|
+
}
|
|
2081
|
+
if (matchesKey(data, "ctrl+k")) {
|
|
2082
|
+
try {
|
|
2083
|
+
onClear();
|
|
2084
|
+
editor.setText("");
|
|
2085
|
+
showNotice("Cleared", "success");
|
|
2086
|
+
} catch (error) {
|
|
2087
|
+
showNotice(`Clear failed: ${errorMessage(error)}`, "error");
|
|
2088
|
+
}
|
|
2089
|
+
return;
|
|
2090
|
+
}
|
|
2091
|
+
if (matchesKey(data, "ctrl+c")) {
|
|
2092
|
+
const text = editor.getExpandedText();
|
|
2093
|
+
void Promise.resolve()
|
|
2094
|
+
.then(() => copy(text))
|
|
2095
|
+
.then(() => showNotice("Copied", "success"))
|
|
2096
|
+
.catch((error) => showNotice(`Copy failed: ${errorMessage(error)}`, "error"));
|
|
2097
|
+
return;
|
|
2098
|
+
}
|
|
2099
|
+
if (matchesKey(data, "enter")) {
|
|
2100
|
+
editor.insertTextAtCursor("\n");
|
|
2101
|
+
tui.requestRender();
|
|
2102
|
+
return;
|
|
2103
|
+
}
|
|
2104
|
+
editor.handleInput(data);
|
|
2105
|
+
tui.requestRender();
|
|
2106
|
+
},
|
|
2107
|
+
dispose: () => { disposed = true; },
|
|
2108
|
+
};
|
|
2109
|
+
};
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
export async function showAdvisorSteerModal(ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> {
|
|
2113
|
+
if (ctx.mode !== "tui") {
|
|
2114
|
+
ctx.ui.notify("Use /bro advisor-steer in Pi's interactive UI.", "warning");
|
|
2115
|
+
return;
|
|
2116
|
+
}
|
|
2117
|
+
const { steering } = resolveAdvisorState(ctx.sessionManager.getBranch());
|
|
2118
|
+
await ctx.ui.custom<void>(
|
|
2119
|
+
createAdvisorSteerModal(
|
|
2120
|
+
steering,
|
|
2121
|
+
(text) => pi.appendEntry(ADVISOR_STEERING_ENTRY, { text }),
|
|
2122
|
+
() => pi.appendEntry(ADVISOR_STEERING_ENTRY, { text: "" }),
|
|
2123
|
+
),
|
|
2124
|
+
{
|
|
2125
|
+
overlay: true,
|
|
2126
|
+
overlayOptions: { width: "78%", minWidth: 48, maxHeight: "60%", anchor: "top-center", margin: { top: 1, left: 2, right: 2 } },
|
|
2127
|
+
},
|
|
2128
|
+
);
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
export function helpText(settings?: BroSettings, settingsError?: string): string {
|
|
2132
|
+
const overrideLines = settings
|
|
2133
|
+
? CAPABILITIES.map((capability) => {
|
|
2134
|
+
const override = capabilityOverride(settings, capability);
|
|
2135
|
+
return override
|
|
2136
|
+
? `- **${CAPABILITY_LABELS[capability]} override:** \`${override.model}\`${override.effort === "default" ? "" : ` (${override.effort})`}`
|
|
2137
|
+
: undefined;
|
|
2138
|
+
}).filter((line): line is string => line !== undefined)
|
|
2139
|
+
: [];
|
|
1071
2140
|
const settingsSummary = settings
|
|
1072
|
-
? `- **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}`
|
|
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")}` : ""}`
|
|
1073
2142
|
: `Bro could not read its settings: ${settingsError}\n\nRun \`/bro doctor\` for setup help.`;
|
|
1074
2143
|
return `# Bro
|
|
1075
2144
|
|
|
@@ -1092,14 +2161,26 @@ Press **R** to simplify the captured source again. Run a new \`/bro text\`, \`/b
|
|
|
1092
2161
|
|
|
1093
2162
|
- \`/bro doctor\` — check settings, Agy, account, model, effort, and mode
|
|
1094
2163
|
- \`/bro usage [--provider agy]\` — show current Agy limits
|
|
1095
|
-
- \`/bro model [id]\` — view or choose the Agy model
|
|
1096
|
-
- \`/bro effort [low|medium|high]\` — view or choose reasoning effort
|
|
2164
|
+
- \`/bro model [id]\` — view or choose the shared default Agy model
|
|
2165
|
+
- \`/bro effort [low|medium|high]\` — view or choose the shared default reasoning effort
|
|
1097
2166
|
- \`/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.
|
|
2168
|
+
|
|
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.
|
|
1098
2170
|
|
|
1099
2171
|
## Side conversation
|
|
1100
2172
|
|
|
1101
2173
|
- \`/bro btw [--fresh] [--full] [question]\` — open a side conversation. Sandboxed (read-only) by default; add \`--full\` to let it read and edit the workspace, and \`--fresh\` to start without main-session context. Inside the side thread, type questions and press Enter (empty Enter re-asks); \`/copy\` copies the latest answer to the main editor without submitting (use \`/copy!\` to replace an existing draft), \`/copy-all\` the full thread, \`/retry\` re-asks the last question, and \`/clear\` resets the thread. Esc closes.
|
|
1102
2174
|
|
|
2175
|
+
## Advisor
|
|
2176
|
+
|
|
2177
|
+
- \`bro_advisor\` — a tool the executor agent can voluntarily call mid-task for a second opinion from a fresh Agy process before or after a non-trivial decision. It is registered like any other tool and has no on/off switch of its own; whether the executor can actually call it depends entirely on this host's own tool restrictions
|
|
2178
|
+
- \`/bro advisor\` — a quick notice of whether \`bro_advisor\` is available right now, pointing at \`/bro config\`, \`/bro advisor-steer\`, and \`/bro doctor\`
|
|
2179
|
+
- \`/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
|
+
- \`/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
|
+
|
|
2182
|
+
Each consultation is a fresh, standalone Agy process — never resumed, never looping, never automatically triggered. Bro captures the context snapshot (system instructions, active tools, and the conversation so far) 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 only ever returns advice, and the executor stays responsible for any actual change. 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
|
+
|
|
1103
2184
|
## Current settings
|
|
1104
2185
|
|
|
1105
2186
|
${settingsSummary}
|
|
@@ -1132,6 +2213,7 @@ Bro temporarily captures mouse input while the modal is open. Native mouse selec
|
|
|
1132
2213
|
- 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.
|
|
1133
2214
|
- Show reflects what was reported in the conversation, not independent verification against the actual code or system state.
|
|
1134
2215
|
- 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 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.
|
|
1135
2217
|
|
|
1136
2218
|
## Privacy and safety
|
|
1137
2219
|
|
|
@@ -1144,6 +2226,8 @@ For webpages, it connects directly to the site without browser cookies; the site
|
|
|
1144
2226
|
|
|
1145
2227
|
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.
|
|
1146
2228
|
|
|
2229
|
+
Each advisor consultation sends the executor's system instructions, active tool list, ordered conversation (including tool calls and results, since the advisor needs to verify claims), your steering brief, and the executor's optional question to Agy and your model provider; the advisor process itself can read and edit the workspace with no permission prompts. The steering brief and activation state are stored as session-only extension data — never added to the main conversation Pi or the model sees.
|
|
2230
|
+
|
|
1147
2231
|
## Custom prompt
|
|
1148
2232
|
|
|
1149
2233
|
Create or edit \`${PROMPT_FILE}\` and include \`{{response}}\` exactly once. Bro reads it on the next explanation and never modifies it. Existing valid custom prompts continue working unchanged.
|
|
@@ -1475,6 +2559,18 @@ export function resolveBtwThread(existing: BtwThread | undefined, parsed: { fres
|
|
|
1475
2559
|
return !existing || startFresh ? { turns: [], full: targetFull } : existing;
|
|
1476
2560
|
}
|
|
1477
2561
|
|
|
2562
|
+
export function formatBtwTranscript(turns: readonly BtwTurn[]): string {
|
|
2563
|
+
return turns
|
|
2564
|
+
.map((turn) => {
|
|
2565
|
+
const question = turn.question.split(/\r?\n/).map((line) => (line ? `> ${line}` : ">")).join("\n");
|
|
2566
|
+
// A turn aborted mid-stream can end inside an unclosed code fence, which would swallow the
|
|
2567
|
+
// separator and every later turn; close it so each turn renders as its own block.
|
|
2568
|
+
const answer = (turn.answer.match(/^```/gm)?.length ?? 0) % 2 === 1 ? `${turn.answer}\n\`\`\`` : turn.answer;
|
|
2569
|
+
return `> **You**\n>\n${question}\n\n**Bro**\n\n${answer}`;
|
|
2570
|
+
})
|
|
2571
|
+
.join("\n\n---\n\n");
|
|
2572
|
+
}
|
|
2573
|
+
|
|
1478
2574
|
export function parseBtwAgyLine(line: string): { delta?: string; result?: string; conversationId?: string; error?: string } {
|
|
1479
2575
|
let event: AgyEvent;
|
|
1480
2576
|
try {
|
|
@@ -1791,7 +2887,7 @@ async function openBtwModal(
|
|
|
1791
2887
|
let closed = false;
|
|
1792
2888
|
let controller: AbortController | undefined;
|
|
1793
2889
|
|
|
1794
|
-
const transcript = () => thread.turns
|
|
2890
|
+
const transcript = () => formatBtwTranscript(thread.turns);
|
|
1795
2891
|
|
|
1796
2892
|
const close = () => {
|
|
1797
2893
|
if (closed) return;
|
|
@@ -1829,7 +2925,7 @@ async function openBtwModal(
|
|
|
1829
2925
|
const settings = await readSettings();
|
|
1830
2926
|
const result = await runBtwTurn(
|
|
1831
2927
|
buildBtwPrompt(context, question),
|
|
1832
|
-
agySelection(settings),
|
|
2928
|
+
agySelection(capabilityPair(settings, "btw")),
|
|
1833
2929
|
{ full: thread.full, cwd: ctx.cwd, conversationId: thread.conversationId },
|
|
1834
2930
|
turnController.signal,
|
|
1835
2931
|
(partial) => {
|
|
@@ -1934,11 +3030,112 @@ export default async function bro(pi: ExtensionAPI) {
|
|
|
1934
3030
|
if (result.source) lastResult = { source: result.source, text: result.text };
|
|
1935
3031
|
};
|
|
1936
3032
|
|
|
1937
|
-
pi.on("session_start", async () => {
|
|
3033
|
+
pi.on("session_start", async (_event, _ctx) => {
|
|
1938
3034
|
lastResult = undefined;
|
|
1939
3035
|
btwThread = undefined;
|
|
1940
3036
|
});
|
|
1941
3037
|
|
|
3038
|
+
pi.registerTool({
|
|
3039
|
+
name: ADVISOR_TOOL_NAME,
|
|
3040
|
+
label: "Bro advisor",
|
|
3041
|
+
description:
|
|
3042
|
+
"Consult a fresh, independent Agy process for a second opinion mid-task. It has real, unsandboxed tool access in the current workspace (read files, search, run commands) with permissions auto-approved, and is instructed to investigate before advising and to leave edits to you — that is a behavioral instruction to the advisor, not an enforced restriction, so treat its findings as advice rather than a delegated implementation. You never need to prepare a summary or evidence first: Bro automatically captures your system instructions, active tools, and the conversation so far, plus any human-set steering priorities, and sends them to the advisor.",
|
|
3043
|
+
promptSnippet: "bro_advisor({question?}): consult a fresh Agy process for a second opinion; it investigates the workspace itself and returns advice",
|
|
3044
|
+
promptGuidelines: [
|
|
3045
|
+
"Call bro_advisor before or after a non-trivial design or scope decision, or when genuinely uncertain, for a second opinion from a fresh, independent Agy process.",
|
|
3046
|
+
"question is optional — never delay a call to first prepare a summary or evidence; Bro captures your context automatically.",
|
|
3047
|
+
"Any human-set steering priorities are applied automatically by the advisor; you don't need to relay or repeat them.",
|
|
3048
|
+
"The 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.",
|
|
3049
|
+
],
|
|
3050
|
+
parameters: Type.Object({
|
|
3051
|
+
question: Type.Optional(
|
|
3052
|
+
Type.String({ description: "Optional question to focus the consultation on. Leave unset to ask for general advice on the current state." }),
|
|
3053
|
+
),
|
|
3054
|
+
}),
|
|
3055
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
3056
|
+
const state = resolveAdvisorState(ctx.sessionManager.getBranch());
|
|
3057
|
+
const settings = await readSettings();
|
|
3058
|
+
const selection = agySelection(capabilityPair(settings, "advisor"));
|
|
3059
|
+
const snapshot = buildAdvisorSnapshot(ctx, pi);
|
|
3060
|
+
const prompt = buildAdvisorPrompt(state.steering, snapshot.text, params.question);
|
|
3061
|
+
let lastAttempt: AdvisorToolDetails = { status: "investigating", attempt: 1, of: 3, elapsedMs: 0 };
|
|
3062
|
+
try {
|
|
3063
|
+
const run = await runAdvisorWithRetries(
|
|
3064
|
+
prompt,
|
|
3065
|
+
selection,
|
|
3066
|
+
ctx.cwd,
|
|
3067
|
+
signal ?? new AbortController().signal,
|
|
3068
|
+
undefined,
|
|
3069
|
+
undefined,
|
|
3070
|
+
(details) => {
|
|
3071
|
+
lastAttempt = details;
|
|
3072
|
+
const label = advisorAttemptLabel(lastAttempt);
|
|
3073
|
+
onUpdate?.({
|
|
3074
|
+
content: [{ type: "text", text: label }],
|
|
3075
|
+
details: lastAttempt,
|
|
3076
|
+
});
|
|
3077
|
+
// A host's tool-card renderer may replace onUpdate's partial renderResult with its
|
|
3078
|
+
// own generic placeholder for the whole run (observed with pi-cc-extensions' default
|
|
3079
|
+
// mode, which hardcodes "Pending…" for every isPartial tool result unless the tool
|
|
3080
|
+
// is opted into its excludeRenderers list). The footer/status bar is a separate,
|
|
3081
|
+
// host-owned surface that no such tool-card override touches, so mirror progress
|
|
3082
|
+
// there too as a fallback the user can see regardless of that renderer choice.
|
|
3083
|
+
ctx.ui.setStatus(ADVISOR_STATUS_BAR_KEY, label);
|
|
3084
|
+
},
|
|
3085
|
+
);
|
|
3086
|
+
const effort = selection.effort ?? "model default";
|
|
3087
|
+
const omissions = `image/reasoning bodies omitted when present; ${snapshot.hadCompaction ? "Pi compaction summaries replace earlier turns" : "no Pi compaction summary present"}`;
|
|
3088
|
+
const details: AdvisorToolDetails = {
|
|
3089
|
+
...lastAttempt,
|
|
3090
|
+
status: "done",
|
|
3091
|
+
attempt: run.attempts,
|
|
3092
|
+
model: selection.model,
|
|
3093
|
+
effort,
|
|
3094
|
+
durationMs: run.durationMs,
|
|
3095
|
+
cwd: ctx.cwd,
|
|
3096
|
+
steeringIncluded: Boolean(state.steering.trim()),
|
|
3097
|
+
snapshotChars: snapshot.text.length,
|
|
3098
|
+
broTruncated: false,
|
|
3099
|
+
omissions,
|
|
3100
|
+
// run.activityCount is the exact final total; lastAttempt's may lag behind by up to
|
|
3101
|
+
// the activity throttle window.
|
|
3102
|
+
activityCount: run.activityCount,
|
|
3103
|
+
};
|
|
3104
|
+
const header = `Bro advisor · model: ${selection.model} · effort: ${effort} · ${run.attempts} attempt${run.attempts === 1 ? "" : "s"} · ${Math.ceil(run.durationMs / 1_000)}s`;
|
|
3105
|
+
const context = `Context · cwd: ${JSON.stringify(ctx.cwd)} · steering: ${details.steeringIncluded ? "included" : "none"} · snapshot: ${snapshot.text.length} chars · Bro truncation: none · omissions: ${omissions}`;
|
|
3106
|
+
return { content: [{ type: "text", text: `${header}\n${context}\n\n${run.advice}` }], details };
|
|
3107
|
+
} finally {
|
|
3108
|
+
ctx.ui.setStatus(ADVISOR_STATUS_BAR_KEY, undefined);
|
|
3109
|
+
}
|
|
3110
|
+
},
|
|
3111
|
+
renderCall(args, theme) {
|
|
3112
|
+
const question = args.question?.trim();
|
|
3113
|
+
return new Text(theme.fg("dim", question ? `Bro advisor · ${question}` : "Bro advisor · consulting…"));
|
|
3114
|
+
},
|
|
3115
|
+
renderResult(result, options, theme) {
|
|
3116
|
+
const details = result.details as AdvisorToolDetails | undefined;
|
|
3117
|
+
if (options.isPartial) {
|
|
3118
|
+
const label = new Text(theme.fg("dim", details ? advisorAttemptLabel(details) : "Bro advisor · investigating…"));
|
|
3119
|
+
// Expanded + running: the simplest native tail -- one dim line per bounded recent
|
|
3120
|
+
// activity label, nothing fancier than the compact renderer above.
|
|
3121
|
+
if (!options.expanded || !details?.activity?.length) return label;
|
|
3122
|
+
const container = new Container();
|
|
3123
|
+
container.addChild(label);
|
|
3124
|
+
for (const line of details.activity) {
|
|
3125
|
+
container.addChild(new Text(theme.fg("dim", ` ${advisorActivityPreview(line)}`)));
|
|
3126
|
+
}
|
|
3127
|
+
return container;
|
|
3128
|
+
}
|
|
3129
|
+
if (!options.expanded && details?.status === "done") return new Text(theme.fg("accent", advisorAttemptLabel(details)));
|
|
3130
|
+
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
|
|
3131
|
+
if (!options.expanded) {
|
|
3132
|
+
const firstLine = text.split("\n").find((line) => line.trim()) ?? "(no advice)";
|
|
3133
|
+
return new Text(`${theme.fg("accent", "Bro advisor")}${theme.fg("dim", ` · ${firstLine}`)}`);
|
|
3134
|
+
}
|
|
3135
|
+
return new Markdown(text, 0, 0, getMarkdownTheme());
|
|
3136
|
+
},
|
|
3137
|
+
});
|
|
3138
|
+
|
|
1942
3139
|
pi.registerCommand("bro", {
|
|
1943
3140
|
description: "Explain replies, pasted text, documents, and webpages, draw recent session turns, or open a sandboxed side conversation with /bro btw",
|
|
1944
3141
|
getArgumentCompletions: (prefix) => {
|
|
@@ -2049,7 +3246,7 @@ export default async function bro(pi: ExtensionAPI) {
|
|
|
2049
3246
|
loadingText: "Checking Bro setup…",
|
|
2050
3247
|
retryable: true,
|
|
2051
3248
|
retryLabel: "check again",
|
|
2052
|
-
run: async (signal) => ({ text: await doctorReport(pi, signal) }),
|
|
3249
|
+
run: async (signal) => ({ text: await doctorReport(pi, ctx, signal) }),
|
|
2053
3250
|
});
|
|
2054
3251
|
} catch (error) {
|
|
2055
3252
|
ctx.ui.notify(errorMessage(error), "error");
|
|
@@ -2235,6 +3432,45 @@ export default async function bro(pi: ExtensionAPI) {
|
|
|
2235
3432
|
return;
|
|
2236
3433
|
}
|
|
2237
3434
|
|
|
3435
|
+
if (action === "config") {
|
|
3436
|
+
if (parts.length !== 1) {
|
|
3437
|
+
ctx.ui.notify("Use /bro config.", "warning");
|
|
3438
|
+
return;
|
|
3439
|
+
}
|
|
3440
|
+
await showBroConfigModal(ctx, pi);
|
|
3441
|
+
return;
|
|
3442
|
+
}
|
|
3443
|
+
|
|
3444
|
+
if (action === "advisor") {
|
|
3445
|
+
// bro_advisor has no on/off/status controls of its own anymore -- it is always registered
|
|
3446
|
+
// and gated only by this host's own tool restrictions. An old on/off/status argument gets
|
|
3447
|
+
// an actionable notice pointing at the commands that replaced it, not a silent no-op.
|
|
3448
|
+
if (parts.length > 1) {
|
|
3449
|
+
ctx.ui.notify(
|
|
3450
|
+
"/bro advisor no longer has on/off/status controls -- bro_advisor is always registered and available whenever this host exposes and activates it. Use /bro config for its model/effort, /bro advisor-steer for its steering brief, or /bro doctor for full diagnostics.",
|
|
3451
|
+
"warning",
|
|
3452
|
+
);
|
|
3453
|
+
return;
|
|
3454
|
+
}
|
|
3455
|
+
const active = pi.getActiveTools().includes(ADVISOR_TOOL_NAME);
|
|
3456
|
+
ctx.ui.notify(
|
|
3457
|
+
active
|
|
3458
|
+
? "Bro advisor is available -- the executor agent can call bro_advisor. Use /bro config for its model/effort, /bro advisor-steer for its steering brief, or /bro doctor for full diagnostics."
|
|
3459
|
+
: "Bro advisor is unavailable in this runtime. Use /bro doctor for full diagnostics, /bro config for its model/effort, or /bro advisor-steer for its steering brief.",
|
|
3460
|
+
active ? "info" : "warning",
|
|
3461
|
+
);
|
|
3462
|
+
return;
|
|
3463
|
+
}
|
|
3464
|
+
|
|
3465
|
+
if (action === "advisor-steer") {
|
|
3466
|
+
if (parts.length !== 1) {
|
|
3467
|
+
ctx.ui.notify("Use /bro advisor-steer.", "warning");
|
|
3468
|
+
return;
|
|
3469
|
+
}
|
|
3470
|
+
await showAdvisorSteerModal(ctx, pi);
|
|
3471
|
+
return;
|
|
3472
|
+
}
|
|
3473
|
+
|
|
2238
3474
|
if (action === "help") {
|
|
2239
3475
|
if (parts.length !== 1) {
|
|
2240
3476
|
ctx.ui.notify("Use /bro help.", "warning");
|