pi-bro 0.25.1 → 0.26.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 +29 -0
- package/README.md +12 -10
- package/docs/getting-started.md +2 -2
- package/docs/guided-review.md +4 -4
- package/docs/reference/data-and-permissions.md +16 -3
- package/docs/reference/settings.md +7 -2
- package/docs/reference/troubleshooting.md +5 -0
- package/package.json +5 -3
- package/src/advisor.ts +4 -2
- package/src/backend.ts +511 -11
- package/src/bro.ts +5 -0
- package/src/btw.ts +28 -4
- package/src/config-ui.ts +149 -51
- package/src/doctor.ts +142 -3
- package/src/explain-modal.ts +2 -2
- package/src/settings.ts +85 -6
package/src/btw.ts
CHANGED
|
@@ -75,11 +75,13 @@ export function toggleBtwMode(thread: BtwThread): void {
|
|
|
75
75
|
// Claude, Grok, Codex, and Muse run btw in the workspace for both modes and resume one native session across
|
|
76
76
|
// /mode switches. An Agy conversation stays bound to the workspace it started in (a sandbox scratch
|
|
77
77
|
// dir vs. the repo), so an access change drops it; the next turn reseeds a fresh native session
|
|
78
|
-
// with the main-session context and the whole thread.
|
|
78
|
+
// with the main-session context and the whole thread. Pi behaves like Agy here: its restricted mode
|
|
79
|
+
// exposes no tools at all while full access uses read/bash/edit/write, so an access change must not
|
|
80
|
+
// continue a session whose tool loadout is now wrong.
|
|
79
81
|
// A native session remembers every earlier prompt, including old preferences, so a preferences
|
|
80
82
|
// change (or deletion) on any backend starts a fresh session reseeded with the quoted thread.
|
|
81
83
|
export function nativeBtwContinuation(thread: BtwThread, backend: BackendName, preferences = ""): string | undefined {
|
|
82
|
-
if (backend === "agy" && thread.conversationId && thread.sessionFull !== undefined && thread.sessionFull !== thread.full) {
|
|
84
|
+
if ((backend === "agy" || backend === "pi") && thread.conversationId && thread.sessionFull !== undefined && thread.sessionFull !== thread.full) {
|
|
83
85
|
thread.conversationId = undefined;
|
|
84
86
|
}
|
|
85
87
|
if (thread.conversationId && (thread.sessionPreferences ?? "") !== preferences) thread.conversationId = undefined;
|
|
@@ -129,13 +131,15 @@ export function parseBtwComposerCommand(value: string): BtwComposerAction {
|
|
|
129
131
|
// existing throw-on-failure / { text, conversationId } contract. A conversationId is only ever
|
|
130
132
|
// returned on success (see backend.ts's continuation handling), preserving current behavior on
|
|
131
133
|
// failed turns.
|
|
134
|
+
// An expired continuation is reported as `continuationExpired` so the caller can forget the dead id;
|
|
135
|
+
// otherwise every later turn would resend it and the thread would stay stuck.
|
|
132
136
|
export async function runBtwTurn(
|
|
133
137
|
prompt: string,
|
|
134
138
|
selection: BackendSelection,
|
|
135
139
|
options: { full: boolean; cwd: string; conversationId?: string },
|
|
136
140
|
signal: AbortSignal,
|
|
137
141
|
onProgress?: (text: string) => void,
|
|
138
|
-
): Promise<{ text: string; conversationId?: string }> {
|
|
142
|
+
): Promise<{ text: string; conversationId?: string; expiredContinuation?: true }> {
|
|
139
143
|
let updateTimer: ReturnType<typeof setTimeout> | undefined;
|
|
140
144
|
let latest: string | undefined;
|
|
141
145
|
const throttledProgress = onProgress
|
|
@@ -165,6 +169,7 @@ export async function runBtwTurn(
|
|
|
165
169
|
throttledProgress,
|
|
166
170
|
);
|
|
167
171
|
if (outcome.status === "success") return { text: outcome.text, conversationId: outcome.continuation?.id };
|
|
172
|
+
if (outcome.continuationExpired) return { text: "", expiredContinuation: true };
|
|
168
173
|
throw new Error(outcome.message);
|
|
169
174
|
} finally {
|
|
170
175
|
if (updateTimer) clearTimeout(updateTimer);
|
|
@@ -410,7 +415,7 @@ export async function openBtwModal(
|
|
|
410
415
|
thread.model = selectionLabel(selection);
|
|
411
416
|
thread.preferences = Boolean(preferences);
|
|
412
417
|
modal.setModel(btwHeaderLabel(thread));
|
|
413
|
-
|
|
418
|
+
let result = await runBtwTurn(
|
|
414
419
|
buildBtwPrompt(context, question, { full, history, preferences }),
|
|
415
420
|
selection,
|
|
416
421
|
{ full, cwd: ctx.cwd, conversationId },
|
|
@@ -422,6 +427,25 @@ export async function openBtwModal(
|
|
|
422
427
|
},
|
|
423
428
|
);
|
|
424
429
|
if (turnController.signal.aborted) return;
|
|
430
|
+
if (result.expiredContinuation) {
|
|
431
|
+
// The native session is gone. Drop the dead id, rebuild the prompt from this thread's own
|
|
432
|
+
// transcript AND its captured main-session context (the local `context` is undefined
|
|
433
|
+
// whenever a continuation existed), and run once more so nothing is silently lost.
|
|
434
|
+
thread.conversationId = undefined;
|
|
435
|
+
modal.setNotice("Side thread expired — reseeded from the transcript and session context.", "warning");
|
|
436
|
+
const reseeded = await runBtwTurn(
|
|
437
|
+
buildBtwPrompt(thread.context, question, { full, history: formatBtwTranscript(thread.turns.slice(0, -1)), preferences }),
|
|
438
|
+
selection,
|
|
439
|
+
{ full, cwd: ctx.cwd },
|
|
440
|
+
turnController.signal,
|
|
441
|
+
(partial) => {
|
|
442
|
+
if (closed || turnController.signal.aborted) return;
|
|
443
|
+
thread.turns[thread.turns.length - 1]!.answer = partial;
|
|
444
|
+
modal.setText(transcript());
|
|
445
|
+
},
|
|
446
|
+
);
|
|
447
|
+
result = reseeded;
|
|
448
|
+
}
|
|
425
449
|
if (result.conversationId) {
|
|
426
450
|
thread.conversationId = result.conversationId;
|
|
427
451
|
thread.sessionFull = full;
|
package/src/config-ui.ts
CHANGED
|
@@ -11,9 +11,11 @@ import {
|
|
|
11
11
|
import { getSelectListTheme, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
import {
|
|
13
13
|
type AgyModelFamily,
|
|
14
|
+
type BackendName,
|
|
14
15
|
type BroSettings,
|
|
15
16
|
type Capability,
|
|
16
17
|
type ExternalBackend,
|
|
18
|
+
type PiModelChoice,
|
|
17
19
|
CAPABILITIES,
|
|
18
20
|
CAPABILITY_LABELS,
|
|
19
21
|
EXTERNAL_BACKENDS,
|
|
@@ -23,6 +25,8 @@ import {
|
|
|
23
25
|
capabilityOverride,
|
|
24
26
|
capabilityPair,
|
|
25
27
|
effortDisplay,
|
|
28
|
+
externalModelDisplay,
|
|
29
|
+
isExternalBackend,
|
|
26
30
|
resolveModelEffort,
|
|
27
31
|
withCapabilityOverride,
|
|
28
32
|
} from "./settings.ts";
|
|
@@ -33,6 +37,21 @@ import { errorMessage } from "./util.ts";
|
|
|
33
37
|
|
|
34
38
|
const SHOW_TURNS_PRESETS = [1, 2, 3, 5, 8];
|
|
35
39
|
|
|
40
|
+
// The step-1 backend list, in display order. "Default" has no backend value and is only offered for
|
|
41
|
+
// per-capability rows (a capability can inherit the shared default). "agy" is included but has no
|
|
42
|
+
// EXTERNAL_BACKENDS entry: its models come from the live Agy catalog.
|
|
43
|
+
const BACKEND_CHOICES: readonly { value: BackendName; label: string }[] = [
|
|
44
|
+
{ value: "agy", label: "Agy" },
|
|
45
|
+
{ value: "claude", label: "Claude" },
|
|
46
|
+
{ value: "grok", label: "Grok" },
|
|
47
|
+
{ value: "codex", label: "Codex" },
|
|
48
|
+
{ value: "muse", label: "Muse" },
|
|
49
|
+
{ value: "pi", label: "Pi" },
|
|
50
|
+
] as const;
|
|
51
|
+
|
|
52
|
+
// The prefix used to carry a backend selection out of the step-1 picker and into applyModelChange.
|
|
53
|
+
const BACKEND_SENTINEL = "__backend__:";
|
|
54
|
+
|
|
36
55
|
function showTurnsValues(current: number): string[] {
|
|
37
56
|
return [...new Set([...SHOW_TURNS_PRESETS, current])].sort((a, b) => a - b).map(String);
|
|
38
57
|
}
|
|
@@ -44,6 +63,7 @@ export function createConfigModal(
|
|
|
44
63
|
initialSettings: BroSettings,
|
|
45
64
|
families: AgyModelFamily[],
|
|
46
65
|
persistSettings: (settings: BroSettings) => Promise<void>,
|
|
66
|
+
piModels: PiModelChoice[] = [],
|
|
47
67
|
): (tui: TuiLike, theme: Theme, keybindings: unknown, done: (value?: void) => void) => Component & { dispose?(): void } {
|
|
48
68
|
return (tui, theme, _keybindings, done) => {
|
|
49
69
|
// Size the host list once, then stop interaction if a later resize cannot show the whole
|
|
@@ -71,60 +91,140 @@ export function createConfigModal(
|
|
|
71
91
|
done(undefined);
|
|
72
92
|
};
|
|
73
93
|
|
|
94
|
+
// Two-step picker: backend first, then a model for that backend. Esc in step 2 returns to step 1
|
|
95
|
+
// without saving; only a complete model selection calls pickerDone, so a cancel never mutates
|
|
96
|
+
// settings. The Agy family list is step 2 for the Agy backend, keeping the catalog behavior.
|
|
74
97
|
const modelPicker = (current: string, pickerDone: (value?: string) => void, capability?: Capability) => {
|
|
75
98
|
const defaultResolved = resolveModelEffort({ model: settings.model, effort: settings.effort }, families);
|
|
76
|
-
const
|
|
99
|
+
const currentBackend = capability
|
|
100
|
+
? (capabilityOverride(settings, capability) ? capabilityBackend(settings, capability) : undefined)
|
|
101
|
+
: (settings.backend ?? "agy");
|
|
102
|
+
const backendOptions: SelectItem[] = [
|
|
77
103
|
...(capability ? [{ value: "__default__", label: `Default (${defaultResolved.family?.label ?? settings.model})` }] : []),
|
|
78
|
-
...
|
|
79
|
-
value:
|
|
80
|
-
label:
|
|
104
|
+
...BACKEND_CHOICES.map((choice) => ({
|
|
105
|
+
value: `${BACKEND_SENTINEL}${choice.value}`,
|
|
106
|
+
label: choice.label,
|
|
81
107
|
})),
|
|
82
|
-
// Alternative CLI entries stay last so existing Agy keyboard navigation is unaffected.
|
|
83
|
-
...Object.values(EXTERNAL_BACKENDS).flatMap((meta) =>
|
|
84
|
-
meta.models.map((model) => ({
|
|
85
|
-
value: `${meta.prefix}${model.id}`,
|
|
86
|
-
label: `${model.label} · ${meta.name}`,
|
|
87
|
-
})),
|
|
88
|
-
),
|
|
89
108
|
];
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
109
|
+
const backendIndex = capability && current === "Default" ? 0 : backendOptions.findIndex((option) => option.value === `${BACKEND_SENTINEL}${currentBackend ?? "agy"}`);
|
|
110
|
+
|
|
93
111
|
const input = new Input();
|
|
94
|
-
let
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
112
|
+
let step: "backend" | "model" | "custom" = "backend";
|
|
113
|
+
let chosenBackend: BackendName | undefined;
|
|
114
|
+
let customError: string | undefined;
|
|
115
|
+
|
|
116
|
+
const buildModelOptions = (backend: BackendName): SelectItem[] => {
|
|
117
|
+
if (backend === "agy") {
|
|
118
|
+
return families.map((family) => ({
|
|
119
|
+
value: family.id,
|
|
120
|
+
label: `${family.label}${family.efforts.length ? "" : " · fixed effort"}`,
|
|
121
|
+
}));
|
|
99
122
|
}
|
|
123
|
+
const meta = EXTERNAL_BACKENDS[backend];
|
|
124
|
+
const models = backend === "pi" ? piModels.map((choice) => ({ id: choice.value, label: choice.label })) : meta.models;
|
|
125
|
+
return [
|
|
126
|
+
...models.map((model) => ({ value: `${meta.prefix}${model.id}`, label: model.label })),
|
|
127
|
+
{ value: `__${backend}_custom__`, label: `${meta.label} · custom model ID…` },
|
|
128
|
+
];
|
|
100
129
|
};
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
130
|
+
|
|
131
|
+
let modelSelectList: SelectList | undefined;
|
|
132
|
+
const currentModelPicker = (): SelectList => {
|
|
133
|
+
if (!modelSelectList) {
|
|
134
|
+
const items = chosenBackend ? buildModelOptions(chosenBackend) : [];
|
|
135
|
+
modelSelectList = new SelectList(items, Math.min(items.length, 8, listRows), getSelectListTheme());
|
|
136
|
+
// Pi's list row shows the bare "<provider>/<id>" while the option value carries the transport
|
|
137
|
+
// prefix, so also try the prefixed form when matching the current selection.
|
|
138
|
+
const prefix = chosenBackend && chosenBackend !== "agy" ? EXTERNAL_BACKENDS[chosenBackend].prefix : "";
|
|
139
|
+
modelSelectList.setSelectedIndex(Math.max(0, items.findIndex((option) => option.value === current || option.value === `${prefix}${current}`)));
|
|
140
|
+
modelSelectList.onSelect = (item) => {
|
|
141
|
+
if (chosenBackend && chosenBackend !== "agy" && item.value === `__${chosenBackend}_custom__`) {
|
|
142
|
+
step = "custom";
|
|
143
|
+
customError = undefined;
|
|
144
|
+
tui.requestRender();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
pickerDone(item.value);
|
|
148
|
+
};
|
|
149
|
+
// Esc in step 2 goes back to the backend list; it never saves a half-made choice.
|
|
150
|
+
modelSelectList.onCancel = () => {
|
|
151
|
+
step = "backend";
|
|
152
|
+
chosenBackend = undefined;
|
|
153
|
+
customError = undefined;
|
|
154
|
+
tui.requestRender();
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
return modelSelectList;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const picker = new SelectList(backendOptions, Math.min(backendOptions.length, 8, listRows), getSelectListTheme());
|
|
161
|
+
picker.setSelectedIndex(Math.max(0, backendIndex));
|
|
104
162
|
picker.onSelect = (item) => {
|
|
105
|
-
const
|
|
106
|
-
if (
|
|
107
|
-
|
|
163
|
+
const value = item.value;
|
|
164
|
+
if (!value.startsWith(BACKEND_SENTINEL)) {
|
|
165
|
+
pickerDone(value); // "__default__" for a capability
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const backend = value.slice(BACKEND_SENTINEL.length) as BackendName;
|
|
169
|
+
chosenBackend = backend;
|
|
170
|
+
modelSelectList = undefined;
|
|
171
|
+
currentModelPicker();
|
|
172
|
+
step = "model";
|
|
173
|
+
tui.requestRender();
|
|
108
174
|
};
|
|
109
175
|
picker.onCancel = () => pickerDone();
|
|
110
|
-
|
|
176
|
+
|
|
177
|
+
input.onSubmit = (value) => {
|
|
178
|
+
if (!value.trim() || !chosenBackend || chosenBackend === "agy") return;
|
|
179
|
+
// Validate before leaving the step so a malformed ID (Pi needs "<provider>/<id>") shows an
|
|
180
|
+
// inline hint and leaves the field open, instead of throwing out of the input handler.
|
|
181
|
+
const choice = `${EXTERNAL_BACKENDS[chosenBackend].prefix}${value.trim()}`;
|
|
182
|
+
try {
|
|
183
|
+
EXTERNAL_BACKENDS[chosenBackend].resolveModel(value.trim());
|
|
184
|
+
} catch (error) {
|
|
185
|
+
customError = errorMessage(error);
|
|
186
|
+
tui.requestRender();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
customError = undefined;
|
|
190
|
+
pickerDone(choice);
|
|
191
|
+
};
|
|
192
|
+
|
|
111
193
|
return {
|
|
112
194
|
// SettingsList draws an open submenu instead of its own list and hints, so the picker brings its own.
|
|
113
|
-
render: (width: number) =>
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
195
|
+
render: (width: number) => {
|
|
196
|
+
if (step === "custom" && chosenBackend && chosenBackend !== "agy") {
|
|
197
|
+
return [
|
|
198
|
+
`${EXTERNAL_BACKENDS[chosenBackend].label} model ID:${chosenBackend === "pi" ? " <provider>/<id>" : ""}`,
|
|
199
|
+
...input.render(width),
|
|
200
|
+
...(customError ? [truncateToWidth(theme.fg("warning", ` ${customError}`), width)] : []),
|
|
201
|
+
"",
|
|
202
|
+
truncateToWidth(theme.fg("dim", " Enter save · Esc back"), width),
|
|
203
|
+
];
|
|
204
|
+
}
|
|
205
|
+
const list = step === "backend" ? picker : currentModelPicker();
|
|
206
|
+
const hint = step === "backend" ? " ↑/↓ choose backend · Enter select · Esc back" : " ↑/↓ choose model · Enter select · Esc back";
|
|
207
|
+
return [...list.render(width), "", truncateToWidth(theme.fg("dim", hint), width)];
|
|
208
|
+
},
|
|
209
|
+
invalidate: () => { picker.invalidate(); modelSelectList?.invalidate(); input.invalidate(); },
|
|
122
210
|
// SettingsList hands an open submenu its own events unchanged.
|
|
123
|
-
handleMouse: (event: BroMouseEvent) =>
|
|
211
|
+
handleMouse: (event: BroMouseEvent) =>
|
|
212
|
+
step === "custom"
|
|
213
|
+
? delegateMouse(input, event, { top: 1, left: 0, width: tui.terminal?.columns ?? 80, height: 1 })
|
|
214
|
+
: delegateMouse(step === "backend" ? picker : currentModelPicker(), event),
|
|
124
215
|
handleInput: (data: string) => {
|
|
125
|
-
if (
|
|
126
|
-
|
|
127
|
-
|
|
216
|
+
if (step === "custom") {
|
|
217
|
+
if (matchesKey(data, "escape")) {
|
|
218
|
+
step = "model";
|
|
219
|
+
customError = undefined;
|
|
220
|
+
tui.requestRender();
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
input.handleInput(data);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (step === "backend") picker.handleInput(data);
|
|
227
|
+
else currentModelPicker().handleInput(data);
|
|
128
228
|
},
|
|
129
229
|
};
|
|
130
230
|
};
|
|
@@ -160,17 +260,15 @@ export function createConfigModal(
|
|
|
160
260
|
function refresh(): void {
|
|
161
261
|
const defaultBackend = settings.backend ?? "agy";
|
|
162
262
|
const def = resolveModelEffort({ backend: settings.backend, model: settings.model, effort: settings.effort }, families);
|
|
163
|
-
modelItem.currentValue =
|
|
164
|
-
defaultBackend
|
|
165
|
-
|
|
166
|
-
: (def.family?.id ?? settings.model);
|
|
263
|
+
modelItem.currentValue = isExternalBackend(defaultBackend)
|
|
264
|
+
? externalModelDisplay(defaultBackend, settings.model)
|
|
265
|
+
: (def.family?.id ?? settings.model);
|
|
167
266
|
effortItem.currentValue = effortDisplay(def, defaultBackend);
|
|
168
|
-
effortItem.values =
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
: undefined;
|
|
267
|
+
effortItem.values = isExternalBackend(defaultBackend)
|
|
268
|
+
? ["default", ...EXTERNAL_BACKENDS[defaultBackend].efforts]
|
|
269
|
+
: def.family?.efforts.length
|
|
270
|
+
? [...def.family.efforts]
|
|
271
|
+
: undefined;
|
|
174
272
|
modeItem.currentValue = settings.mode;
|
|
175
273
|
showTurnsItem.currentValue = String(settings.showTurns);
|
|
176
274
|
showTurnsItem.values = showTurnsValues(settings.showTurns);
|
|
@@ -182,8 +280,8 @@ export function createConfigModal(
|
|
|
182
280
|
const rows = capabilityItems[capability];
|
|
183
281
|
rows.model.currentValue = !override
|
|
184
282
|
? "Default"
|
|
185
|
-
: backend
|
|
186
|
-
?
|
|
283
|
+
: isExternalBackend(backend)
|
|
284
|
+
? externalModelDisplay(backend, override.model)
|
|
187
285
|
: (resolved.family?.id ?? override.model);
|
|
188
286
|
rows.effort.currentValue = effortDisplay(resolved, backend);
|
|
189
287
|
rows.effort.values =
|
package/src/doctor.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import
|
|
4
|
+
import {
|
|
5
|
+
getAgentDir,
|
|
6
|
+
resolveModelScopeWithDiagnostics,
|
|
7
|
+
SettingsManager,
|
|
8
|
+
type ExtensionAPI,
|
|
9
|
+
type ExtensionCommandContext,
|
|
10
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
11
|
import {
|
|
6
12
|
ADVISOR_COMPATIBILITY,
|
|
7
13
|
ADVISOR_STEERING_FILE,
|
|
@@ -10,7 +16,7 @@ import {
|
|
|
10
16
|
readAdvisorSteering,
|
|
11
17
|
resolveAdvisorState,
|
|
12
18
|
} from "./advisor.ts";
|
|
13
|
-
import { agyFailureMessage, reviewBackendNotice } from "./backend.ts";
|
|
19
|
+
import { agyFailureMessage, bridgePiRuntimeForCatalog, piEffectiveEffort, reviewBackendNotice, splitPiModel } from "./backend.ts";
|
|
14
20
|
import { createConfigModal } from "./config-ui.ts";
|
|
15
21
|
import { openBroOverlay } from "./modal-kit.ts";
|
|
16
22
|
import {
|
|
@@ -18,6 +24,7 @@ import {
|
|
|
18
24
|
type AgyModelFamily,
|
|
19
25
|
type BackendName,
|
|
20
26
|
type BroSettings,
|
|
27
|
+
type PiModelChoice,
|
|
21
28
|
CAPABILITIES,
|
|
22
29
|
CAPABILITY_LABELS,
|
|
23
30
|
EFFORTS,
|
|
@@ -28,10 +35,12 @@ import {
|
|
|
28
35
|
isCodexEffort,
|
|
29
36
|
isGrokEffort,
|
|
30
37
|
isMuseEffort,
|
|
38
|
+
isPiEffort,
|
|
31
39
|
readPreferences,
|
|
32
40
|
readSettings,
|
|
33
41
|
resolveCapabilitySettings,
|
|
34
42
|
resolveModelEffort,
|
|
43
|
+
toPiModelChoices,
|
|
35
44
|
writeSettings,
|
|
36
45
|
} from "./settings.ts";
|
|
37
46
|
import { hasBroCustomUi } from "./ui-capabilities.ts";
|
|
@@ -103,6 +112,44 @@ export async function listAgyModels(pi: ExtensionAPI, signal?: AbortSignal): Pro
|
|
|
103
112
|
}
|
|
104
113
|
}
|
|
105
114
|
|
|
115
|
+
// The Pi backend's selectable models: the configured scoped models (enabledModels) when present,
|
|
116
|
+
// else every model with configured auth. Physical models only -- a virtual model routes elsewhere
|
|
117
|
+
// and cannot be driven as a standalone in-process session. This never makes a model request.
|
|
118
|
+
export async function listPiModels(
|
|
119
|
+
ctx: ExtensionCommandContext,
|
|
120
|
+
signal?: AbortSignal,
|
|
121
|
+
): Promise<{ models: PiModelChoice[]; scope: "scoped" | "available"; diagnostics: string[]; modelRuntime: Awaited<ReturnType<typeof bridgePiRuntimeForCatalog>> }> {
|
|
122
|
+
let projectTrusted = true;
|
|
123
|
+
try { projectTrusted = ctx.isProjectTrusted?.() ?? true; } catch { projectTrusted = false; }
|
|
124
|
+
const settingsManager = SettingsManager.create(ctx.cwd, getAgentDir(), { projectTrusted });
|
|
125
|
+
// Share the executor's bridge: without the host's runtime credentials this catalog would hide
|
|
126
|
+
// models that a run can actually use, so `/bro config` and `/bro doctor` would disagree with it.
|
|
127
|
+
const modelRuntime = await bridgePiRuntimeForCatalog(ctx, getAgentDir());
|
|
128
|
+
if (signal?.aborted) throw new Error("Canceled.");
|
|
129
|
+
|
|
130
|
+
const patterns = settingsManager.getEnabledModels() ?? [];
|
|
131
|
+
if (patterns.length) {
|
|
132
|
+
const resolved = await resolveModelScopeWithDiagnostics(patterns, modelRuntime);
|
|
133
|
+
const diagnostics = resolved.diagnostics.map((entry) => entry.message);
|
|
134
|
+
// A configured scope is authoritative even when it resolves to nothing: silently substituting
|
|
135
|
+
// every authenticated model would offer choices the user never scoped in, and would hide the
|
|
136
|
+
// diagnostics that explain why. The caller shows them.
|
|
137
|
+
return { models: toPiModelChoices(resolved.scopedModels), scope: "scoped", diagnostics, modelRuntime };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const available = modelRuntime.getAvailableSnapshot().map((model) => ({ model }));
|
|
141
|
+
return { models: toPiModelChoices(available), scope: "available", diagnostics: [], modelRuntime };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// The requested and effective Pi effort, computed rather than assumed: a model that cannot reason
|
|
145
|
+
// runs at "off", and a level the model does not offer is clamped by the SDK. Reporting the real
|
|
146
|
+
// value is what makes this line truthful instead of a blanket clamp claim.
|
|
147
|
+
function piEffortDetail(model: Parameters<typeof piEffectiveEffort>[0], effort: string): string {
|
|
148
|
+
const effective = piEffectiveEffort(model, effort);
|
|
149
|
+
if (effective === effort) return effort === "default" ? "Pi's configured default" : effort;
|
|
150
|
+
return `${effort} → ${effective} (clamped)`;
|
|
151
|
+
}
|
|
152
|
+
|
|
106
153
|
async function checkAgyVersion(pi: ExtensionAPI, signal: AbortSignal): Promise<string> {
|
|
107
154
|
const runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
|
|
108
155
|
try {
|
|
@@ -212,6 +259,13 @@ export async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContex
|
|
|
212
259
|
let settings: BroSettings | undefined;
|
|
213
260
|
let models: AgyModelFamily[] | undefined;
|
|
214
261
|
let agyVersion: string | undefined;
|
|
262
|
+
let piRuntime: Awaited<ReturnType<typeof bridgePiRuntimeForCatalog>> | undefined;
|
|
263
|
+
const findPiModel = (provider: string, id: string) => {
|
|
264
|
+
const model = piRuntime?.getModel(provider, id) ?? ctx.modelRegistry.find(provider, id);
|
|
265
|
+
return model?.api === "pi-virtual" ? undefined : model;
|
|
266
|
+
};
|
|
267
|
+
const hasPiAuth = (model: NonNullable<ReturnType<typeof findPiModel>>) =>
|
|
268
|
+
piRuntime?.hasConfiguredAuth(model.provider) || ctx.modelRegistry.hasConfiguredAuth(model);
|
|
215
269
|
const pass = (name: string, detail: string) => lines.push(`- ✓ **${name}:** ${detail}`);
|
|
216
270
|
const fail = (name: string, error: unknown) => {
|
|
217
271
|
failed = true;
|
|
@@ -242,6 +296,7 @@ export async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContex
|
|
|
242
296
|
const grokInUse = backendInUse("grok");
|
|
243
297
|
const codexInUse = backendInUse("codex");
|
|
244
298
|
const museInUse = backendInUse("muse");
|
|
299
|
+
const piInUse = backendInUse("pi");
|
|
245
300
|
|
|
246
301
|
let agyStarted = false;
|
|
247
302
|
if (agyInUse) {
|
|
@@ -340,6 +395,23 @@ export async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContex
|
|
|
340
395
|
pass("Muse", "not probed — no feature selects the Muse backend");
|
|
341
396
|
}
|
|
342
397
|
|
|
398
|
+
// Pi is in-process: validate the selected physical model against the bridged runtime without a
|
|
399
|
+
// CLI spawn or a live model turn. A passing answer means the model resolves and has configured
|
|
400
|
+
// auth; it is not a connectivity test.
|
|
401
|
+
if (piInUse) {
|
|
402
|
+
try {
|
|
403
|
+
const { models: piModels, scope, diagnostics, modelRuntime } = await listPiModels(ctx, signal);
|
|
404
|
+
piRuntime = modelRuntime;
|
|
405
|
+
pass("Pi model catalog", `${piModels.length} physical model${piModels.length === 1 ? "" : "s"} selectable · ${scope === "scoped" ? "from enabledModels (scoped)" : "all models with configured auth"}`);
|
|
406
|
+
for (const diagnostic of diagnostics) lines.push(`- ℹ **Pi scoped models:** ${diagnostic}`);
|
|
407
|
+
} catch (error) {
|
|
408
|
+
if (signal.aborted) throw error;
|
|
409
|
+
fail("Pi model catalog", error);
|
|
410
|
+
}
|
|
411
|
+
} else {
|
|
412
|
+
pass("Pi", "not probed — no feature selects the Pi backend");
|
|
413
|
+
}
|
|
414
|
+
|
|
343
415
|
if (settings) {
|
|
344
416
|
const defaultBackend = settings.backend ?? "agy";
|
|
345
417
|
if (defaultBackend === "claude") {
|
|
@@ -378,6 +450,25 @@ export async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContex
|
|
|
378
450
|
} else {
|
|
379
451
|
pass("Reasoning effort", settings.effort);
|
|
380
452
|
}
|
|
453
|
+
} else if (defaultBackend === "pi") {
|
|
454
|
+
const parts = splitPiModel(settings.model);
|
|
455
|
+
const physical = parts ? findPiModel(parts.providerId, parts.modelId) : undefined;
|
|
456
|
+
if (!parts) {
|
|
457
|
+
fail("Selected model", `\`${settings.model}\` is not a \`<provider>/<id>\` Pi model. Run \`/bro config\` to choose another.`);
|
|
458
|
+
} else if (!physical) {
|
|
459
|
+
fail("Selected model", `pi \`${settings.model}\` is unavailable or virtual. Run \`/bro config\` to choose another.`);
|
|
460
|
+
} else {
|
|
461
|
+
if (!hasPiAuth(physical)) {
|
|
462
|
+
fail("Selected model", `pi \`${settings.model}\` has no configured auth. Sign in with Pi's /login, then run \`/bro doctor\`.`);
|
|
463
|
+
} else {
|
|
464
|
+
pass("Selected model", `pi \`${settings.model}\``);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
if (!isPiEffort(settings.effort)) {
|
|
468
|
+
fail("Reasoning effort", `\`${settings.effort}\` is unsupported. Run \`/bro config\` to choose another.`);
|
|
469
|
+
} else {
|
|
470
|
+
pass("Reasoning effort", piEffortDetail(physical, settings.effort));
|
|
471
|
+
}
|
|
381
472
|
} else if (models) {
|
|
382
473
|
const current = resolveCatalogSettings(settings, models);
|
|
383
474
|
if (!current.family) {
|
|
@@ -455,6 +546,41 @@ export async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContex
|
|
|
455
546
|
);
|
|
456
547
|
continue;
|
|
457
548
|
}
|
|
549
|
+
if (backend === "pi") {
|
|
550
|
+
if (!isPiEffort(pair.effort)) {
|
|
551
|
+
fail(label, `\`${pair.effort}\` is unsupported for pi \`${pair.model}\`. Run \`/bro config\` to fix this.`);
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
const parts = splitPiModel(pair.model);
|
|
555
|
+
if (!parts) {
|
|
556
|
+
fail(label, `\`${pair.model}\` is not a \`<provider>/<id>\` Pi model. Run \`/bro config\` to fix this.`);
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
const physical = findPiModel(parts.providerId, parts.modelId);
|
|
560
|
+
if (!physical) {
|
|
561
|
+
fail(label, `\`${pair.model}\` is unavailable or virtual. Run \`/bro config\` to choose a physical model.`);
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
if (!hasPiAuth(physical)) {
|
|
565
|
+
fail(label, `\`${pair.model}\` resolves but has no configured auth. Sign in with Pi's /login, then run \`/bro doctor\`.`);
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
// Scope membership is reported separately from catalog membership: a model can be usable
|
|
569
|
+
// without being in enabledModels, and the picker would not offer it as a scoped choice.
|
|
570
|
+
const scoped = (ctx.scopedModels ?? []).some((entry) => entry.model.provider === parts.providerId && entry.model.id === parts.modelId);
|
|
571
|
+
const tools =
|
|
572
|
+
capability === "advisor"
|
|
573
|
+
? "read/bash/edit/write in the workspace, no nested delegation or MCP"
|
|
574
|
+
: capability === "btw"
|
|
575
|
+
? "none in conversation-only; read/bash/edit/write in the workspace in full permission"
|
|
576
|
+
: capability === "review"
|
|
577
|
+
? "read/grep/find/ls in the captured checkout, no shell"
|
|
578
|
+
: "no tools";
|
|
579
|
+
passCapability(
|
|
580
|
+
`${override ? "override " : ""}pi \`${pair.model}\` · effort ${piEffortDetail(physical, pair.effort)} · ${scoped ? "in your scoped models" : "not in your scoped models"} · tools: ${tools}`,
|
|
581
|
+
);
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
458
584
|
// No Agy catalog available: the Agy branch above is the only one that gets here, so there is
|
|
459
585
|
// nothing to validate, but a shell-capable review override still deserves its advisory.
|
|
460
586
|
if (!models) {
|
|
@@ -504,6 +630,8 @@ export async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContex
|
|
|
504
630
|
pass("Advisor compatibility", "Codex backend — no Agy version floor applies");
|
|
505
631
|
} else if (settings && capabilityBackend(settings, "advisor") === "muse") {
|
|
506
632
|
pass("Advisor compatibility", "Muse backend — no Agy version floor applies; auth/connectivity unverified (no probe)");
|
|
633
|
+
} else if (settings && capabilityBackend(settings, "advisor") === "pi") {
|
|
634
|
+
pass("Advisor compatibility", "Pi backend — runs in-process; no Agy version floor or CLI probe applies");
|
|
507
635
|
} else if (agyVersion && advisorAgyCompatible(agyVersion)) pass("Advisor compatibility", ADVISOR_COMPATIBILITY);
|
|
508
636
|
else if (agyVersion) fail("Advisor compatibility", `installed \`${agyVersion}\`; requires Agy >=1.1.15. Run \`agy update\`.`);
|
|
509
637
|
|
|
@@ -534,5 +662,16 @@ export async function showBroConfigModal(ctx: ExtensionCommandContext, pi: Exten
|
|
|
534
662
|
families = [];
|
|
535
663
|
ctx.ui.notify("Agy model catalog unavailable — showing other backend settings without Agy choices.", "warning");
|
|
536
664
|
}
|
|
537
|
-
|
|
665
|
+
// The Pi catalog is loaded independently so /bro config still works when Agy is offline.
|
|
666
|
+
let piModels: PiModelChoice[] = [];
|
|
667
|
+
try {
|
|
668
|
+
const catalog = await listPiModels(ctx);
|
|
669
|
+
piModels = catalog.models;
|
|
670
|
+
// A configured scope that resolves to nothing leaves the Pi model list empty; say why instead of
|
|
671
|
+
// showing a silently empty picker.
|
|
672
|
+
for (const diagnostic of catalog.diagnostics) ctx.ui.notify(`Pi scoped models: ${diagnostic}`, "warning");
|
|
673
|
+
} catch (error) {
|
|
674
|
+
ctx.ui.notify(`Pi model catalog unavailable — Pi choices will be empty. ${errorMessage(error)}`, "warning");
|
|
675
|
+
}
|
|
676
|
+
await openBroOverlay<void>(ctx, createConfigModal(settings, families, writeSettings, piModels));
|
|
538
677
|
}
|
package/src/explain-modal.ts
CHANGED
|
@@ -397,7 +397,7 @@ Quick reference. Full user guide: https://github.com/tranhoangnguyen03/pi-bro/bl
|
|
|
397
397
|
- \`/bro guided-review <PR number or URL>\` — automatically explain and assess a PR with captured code and optional private questions (local Pi interactive only; Git and authenticated gh required).
|
|
398
398
|
- Reviews save automatically. Reopen the same PR without a model call; bare \`/bro guided-review\` lists saved reviews (\`resume\` is an alias). Captured code stays unchanged.
|
|
399
399
|
- Enter opens items or activates focused actions. Tab changes focus; Esc goes back, then closes from Contents. Close saves; Stop and close cancels active work first.
|
|
400
|
-
- Copy finding uses the clipboard, not GitHub. Regenerate requires confirmation and reviews the same capture. For untrusted PRs, prefer Claude/Muse: file-only tools, no shell/writes/web. Codex/Grok allow shell in a read-only sandbox; Agy's sandbox permits project writes. All three show an advisory. Choose a review override in /bro config. Repository text can mislead; citations are not proof.
|
|
400
|
+
- Copy finding uses the clipboard, not GitHub. Regenerate requires confirmation and reviews the same capture. For untrusted PRs, prefer Claude/Muse/Pi: file-only tools, no shell/writes/web. Codex/Grok allow shell in a read-only sandbox; Agy's sandbox permits project writes. All three show an advisory. Choose a review override in /bro config. Repository text can mislead; citations are not proof.
|
|
401
401
|
|
|
402
402
|
## Elevate
|
|
403
403
|
|
|
@@ -420,7 +420,7 @@ Quick reference. Full user guide: https://github.com/tranhoangnguyen03/pi-bro/bl
|
|
|
420
420
|
## Configure and check
|
|
421
421
|
|
|
422
422
|
- \`/bro preferences\` — tell Bro about yourself and how you like answers; added to explain, show, btw, Elevate and Guided Review prompts, never the advisor (**Ctrl+S** save, **Ctrl+K** delete, **Ctrl+C** copy, **Esc** close)
|
|
423
|
-
- \`/bro config\` — shared default and per-capability (explain/show/btw/advisor/review) backend, model, and effort; explain mode; show turns. Changes save immediately. If the terminal is too small, Esc closes config after pending saves finish; a failed save keeps it open.
|
|
423
|
+
- \`/bro config\` — shared default and per-capability (explain/show/btw/advisor/review) backend, model, and effort; explain mode; show turns. Two steps: choose the backend (Agy, Claude, Grok, Codex, Muse, Pi), then its model; Esc steps back without saving. Changes save immediately. If the terminal is too small, Esc closes config after pending saves finish; a failed save keeps it open.
|
|
424
424
|
- \`/bro mode [brief|balanced|faithful]\` — view or choose the explanation mode
|
|
425
425
|
- \`/bro doctor\` — check settings, preferences, and every selected backend without running a model turn
|
|
426
426
|
|