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/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
- const result = await runBtwTurn(
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 options: SelectItem[] = [
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
- ...families.map((family) => ({
79
- value: family.id,
80
- label: `${family.label}${family.efforts.length ? "" : " · fixed effort"}`,
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
- for (const meta of Object.values(EXTERNAL_BACKENDS)) {
91
- options.push({ value: `__${meta.name}_custom__`, label: `${meta.label} · custom model ID…` });
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 enteringBackend: ExternalBackend | undefined;
95
- input.onSubmit = (value) => {
96
- if (value.trim() && enteringBackend) {
97
- const meta = EXTERNAL_BACKENDS[enteringBackend];
98
- pickerDone(`${meta.prefix}${value.trim()}`);
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
- const picker = new SelectList(options, Math.min(options.length, 8, listRows), getSelectListTheme());
102
- const selectedIndex = capability && current === "Default" ? 0 : options.findIndex((option) => option.value === current);
103
- picker.setSelectedIndex(Math.max(0, selectedIndex));
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 custom = (Object.keys(EXTERNAL_BACKENDS) as ExternalBackend[]).find((b) => item.value === `__${b}_custom__`);
106
- if (custom) { enteringBackend = custom; tui.requestRender(); }
107
- else pickerDone(item.value);
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
- const customLabel = () => enteringBackend ? EXTERNAL_BACKENDS[enteringBackend].label : "";
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) => enteringBackend
114
- ? [
115
- `${customLabel()} model ID:`,
116
- ...input.render(width),
117
- "",
118
- truncateToWidth(theme.fg("dim", " Enter save · Esc back"), width),
119
- ]
120
- : [...picker.render(width), "", truncateToWidth(theme.fg("dim", " ↑/↓ choose · Enter select · Esc back"), width)],
121
- invalidate: () => { picker.invalidate(); input.invalidate(); },
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) => (enteringBackend ? delegateMouse(input, event, { top: 1, left: 0, width: tui.terminal?.columns ?? 80, height: 1 }) : delegateMouse(picker, event)),
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 (enteringBackend && matchesKey(data, "escape")) pickerDone();
126
- else if (enteringBackend) input.handleInput(data);
127
- else picker.handleInput(data);
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 in EXTERNAL_BACKENDS
165
- ? `${EXTERNAL_BACKENDS[defaultBackend as ExternalBackend].prefix}${settings.model}`
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
- defaultBackend in EXTERNAL_BACKENDS
170
- ? ["default", ...EXTERNAL_BACKENDS[defaultBackend as ExternalBackend].efforts]
171
- : def.family?.efforts.length
172
- ? [...def.family.efforts]
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 in EXTERNAL_BACKENDS
186
- ? `${EXTERNAL_BACKENDS[backend as ExternalBackend].prefix}${override.model}`
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 type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
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
- await openBroOverlay<void>(ctx, createConfigModal(settings, families, writeSettings));
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
  }
@@ -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