pi-bro 0.23.0 → 0.23.1

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 CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  All notable changes to pi-bro are documented here.
4
4
 
5
+ ## [0.23.1] - 2026-10-15
6
+
7
+ ### Highlights
8
+
9
+ - Scroll Bro's explain, show, help and btw windows with the mouse wheel again on Pi 1.2.
10
+ - See the save messages and key hints in `/bro preferences`, `/bro advisor-steer` and `/bro config` on normal-size terminals; they were cut off at the bottom.
11
+ - btw keeps your place: it follows the newest answer while you are at the bottom and no longer jumps back to the top after each reply.
12
+
13
+ ### Fixed
14
+
15
+ - Mouse-wheel scrolling in the explain/show/doctor/help and btw windows on Pi 1.2 fullscreen. Pi 1.2 stopped passing raw wheel input to windows without a mouse handler; both windows now handle Pi's mouse events and keep the raw path for Pi 0.84 and regular mode.
16
+ - The wheel over the preferences, advisor-steer, config and Guided Review loading windows no longer scrolls the chat behind them on Pi 1.2.
17
+ - `/bro preferences`, `/bro advisor-steer` and `/bro config` fit their windows: no blank padding rows, a shorter editor hint line, the editors' height limit raised from 60% to 78% like the other Bro windows, and the config list sized to the terminal. At 24 rows the preferences editor now draws 18 rows instead of 27. Very short terminals (under about 22 rows) can still clip the editors, because Pi's editor has a minimum height.
18
+ - `/bro config` shows one key-hint line instead of two that disagreed, and its model picker shows its own hints.
19
+ - btw's hint while a reply runs now says Esc closes the panel and stops the reply, which is what it does.
20
+ - Guided Review saves once when you close it instead of twice; teardown without closing it (for example switching sessions) still saves.
21
+
22
+ ### Changed
23
+
24
+ - Internal: `src/bro.ts` is split into feature and modal modules (#133). No behaviour change.
25
+
5
26
  ## [0.23.0] - 2026-10-13
6
27
 
7
28
  ### Highlights
@@ -179,8 +179,9 @@ asked in.
179
179
 
180
180
  `/insert` and `/insert-all` never replace an existing main-editor draft:
181
181
  edit or clear it first. Any other text, including other slash-prefixed input
182
- such as `/send`, is sent as a question. Esc closes the modal (or cancels a
183
- running turn). The header shows the model and reasoning effort the latest
182
+ such as `/send`, is sent as a question. Esc closes the modal and stops a
183
+ running turn; the thread is kept. The view follows the newest text while you
184
+ are at the bottom; scroll up to read earlier turns. The header shows the model and reasoning effort the latest
184
185
  turn used (`default` when the model's own effort applies) and the current
185
186
  mode.
186
187
  - A turn is capped at 2 minutes in conversation-only mode and 10 minutes in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-bro",
3
- "version": "0.23.0",
3
+ "version": "0.23.1",
4
4
  "description": "A sidekick for you and your Pi agent: plain-language explanations, side questions, diagrams, PR review and second opinions, without cluttering the agent's context.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -42,6 +42,11 @@
42
42
  ],
43
43
  "files": [
44
44
  "src/bro.ts",
45
+ "src/doctor.ts",
46
+ "src/show.ts",
47
+ "src/advisor.ts",
48
+ "src/btw.ts",
49
+ "src/explain-modal.ts",
45
50
  "src/review.ts",
46
51
  "src/review-source.ts",
47
52
  "src/review-ui.ts",
package/src/advisor.ts ADDED
@@ -0,0 +1,573 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
4
+ import { convertToLlm, getAgentDir, getMarkdownTheme } from "@earendil-works/pi-coding-agent";
5
+ import { Container, Markdown, Text } from "@earendil-works/pi-tui";
6
+ import { Type } from "typebox";
7
+ import { execute as executeBackend, type BackendSelection } from "./backend.ts";
8
+ import { MAX_ADVISOR_STEERING_CHARS, buildAdvisorPrompt } from "./prompt.ts";
9
+ import { type BackendName, readSettings, selectionForCapability } from "./settings.ts";
10
+ import { errorMessage, isRecord } from "./util.ts";
11
+
12
+ // The steering brief is stored as a session `custom` entry — extension state that never
13
+ // participates in LLM context (see docs/plans/2026-09-19-bro-advisor-design.md). Writes go
14
+ // through pi.appendEntry(); ctx.sessionManager is read-only and has no append methods.
15
+ //
16
+ // bro_advisor has no on/off activation state: it is registered once at extension load like any
17
+ // other tool and never gated via pi.setActiveTools(). Whether the executor can call it is purely a
18
+ // function of this host's own tool restrictions (pi.getAllTools() / pi.getActiveTools()), which
19
+ // this code only ever reads, never overrides. A session may still carry historical
20
+ // "bro-advisor-active" entries from before this simplification; they are silently ignored.
21
+ export const ADVISOR_STEERING_ENTRY = "bro-advisor-steering";
22
+ export const ADVISOR_TOOL_NAME = "bro_advisor";
23
+ // setStatus() key for mirroring live advisor progress to the footer/status bar -- see the execute()
24
+ // handler below for why this fallback surface exists alongside onUpdate's tool-card renderResult.
25
+ export const ADVISOR_STATUS_BAR_KEY = "bro-advisor";
26
+ export const ADVISOR_COMPATIBILITY = "Pi >=0.84.2; Agy >=1.1.15";
27
+ export const ADVISOR_STEERING_FILE = join(getAgentDir(), "bro-advisor.md");
28
+ const MAX_ADVISOR_STEERING_FILE_BYTES = 64 * 1024;
29
+
30
+ export type AdvisorState = { steering: string };
31
+
32
+ // getBranch() walks root-to-leaf (see latestAssistant above for the same convention), so a forward
33
+ // scan taking the last match of ADVISOR_STEERING_ENTRY resolves "latest wins". Forking clones the
34
+ // branch's entries into a new session file, so this same resolution gives fork inheritance and
35
+ // independent post-fork edits for free, with no special-case fork logic.
36
+ export function resolveAdvisorState(branch: readonly SessionEntry[]): AdvisorState {
37
+ let steering = "";
38
+ for (const entry of branch) {
39
+ if (entry.type !== "custom" || entry.customType !== ADVISOR_STEERING_ENTRY) continue;
40
+ if (isRecord(entry.data) && typeof entry.data.text === "string") steering = entry.data.text;
41
+ }
42
+ return { steering };
43
+ }
44
+
45
+ export function advisorAgyCompatible(version: string): boolean | undefined {
46
+ const match = /(?:^|\D)(\d+)\.(\d+)\.(\d+)(?:\D|$)/.exec(version);
47
+ if (!match) return undefined;
48
+ const installed = match.slice(1, 4).map(Number);
49
+ const minimum = [1, 1, 15];
50
+ for (let index = 0; index < minimum.length; index++) {
51
+ if (installed[index]! !== minimum[index]!) return installed[index]! > minimum[index]!;
52
+ }
53
+ return true;
54
+ }
55
+
56
+ // Raw durable advisor steering text, undefined when there is no file. Rejects non-regular files and
57
+ // files > 64 KB before read.
58
+ export async function readAdvisorSteeringRaw(): Promise<string | undefined> {
59
+ let stats: Awaited<ReturnType<typeof stat>>;
60
+ try {
61
+ stats = await stat(ADVISOR_STEERING_FILE);
62
+ } catch (error) {
63
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
64
+ throw new Error(`Cannot read ${ADVISOR_STEERING_FILE}: ${errorMessage(error)}`);
65
+ }
66
+ if (!stats.isFile()) {
67
+ throw new Error(`${ADVISOR_STEERING_FILE} is not a regular file. Edit or delete it directly.`);
68
+ }
69
+ if (stats.size > MAX_ADVISOR_STEERING_FILE_BYTES) {
70
+ throw new Error(`${ADVISOR_STEERING_FILE} is larger than 64 KB. Edit or delete it directly.`);
71
+ }
72
+ try {
73
+ const bytes = await readFile(ADVISOR_STEERING_FILE);
74
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes).replace(/^\uFEFF/, "");
75
+ } catch (error) {
76
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
77
+ throw new Error(`Cannot read ${ADVISOR_STEERING_FILE}: ${errorMessage(error)}`);
78
+ }
79
+ }
80
+
81
+ // Durable steering for the advisor tool: "" when absent or blank. Re-read on every consultation.
82
+ // An oversize or unreadable file throws an actionable message for the executor agent before spawn.
83
+ export async function readAdvisorSteering(): Promise<string> {
84
+ const text = ((await readAdvisorSteeringRaw()) ?? "").trim();
85
+ if (text.length > MAX_ADVISOR_STEERING_CHARS) {
86
+ throw new Error(
87
+ `Bro advisor steering file ${ADVISOR_STEERING_FILE} is ${text.length.toLocaleString("en-US")} characters (keep under ${MAX_ADVISOR_STEERING_CHARS.toLocaleString("en-US")}). Tell the human to edit or delete it; retrying won't help.`
88
+ );
89
+ }
90
+ return text;
91
+ }
92
+
93
+ // Harness-neutral advisor context snapshot: plain role/text/tool-name+args/tool-result-text, not
94
+ // Pi's internal message objects serialized as-is, so the shape isn't a Pi-specific contract. Every
95
+ // piece of free-text message content is JSON-quoted (the same "quote the source as data" convention
96
+ // SOURCE_GUARD/buildShowPrompt/buildBtwPrompt already use in prompt.ts) rather than pasted in raw
97
+ // after a bare "## role" heading: unquoted text under a heading would let pasted text or tool output
98
+ // forge a fake "## user"/"## assistant" section that looks like a real turn boundary. See
99
+ // docs/plans/2026-09-19-bro-advisor-design.md, "Context snapshot".
100
+ type AdvisorMessageLike = { role?: unknown; content?: unknown; toolName?: unknown; toolCallId?: unknown; isError?: unknown };
101
+
102
+ // Parameter element type of Pi's own AgentMessage → LLM converter (core/messages.ts), reused below
103
+ // instead of redeclaring BashExecutionMessage's shape (it isn't exported from the package root).
104
+ type AdvisorAgentMessage = Parameters<typeof convertToLlm>[0][number];
105
+
106
+ function advisorQuoted(text: string): string {
107
+ return JSON.stringify(text);
108
+ }
109
+
110
+ // `resolvedToolCallIds` lets a still-pending bro_advisor call (the one currently in flight for this
111
+ // very consultation, which by definition has no result yet) be dropped instead of rendered as an
112
+ // orphaned, unresolved call — a past, completed bro_advisor call is unaffected and renders normally.
113
+ function advisorContentText(content: unknown, resolvedToolCallIds: ReadonlySet<string>): string {
114
+ if (typeof content === "string") {
115
+ const text = content.trim();
116
+ return text ? advisorQuoted(text) : "";
117
+ }
118
+ if (!Array.isArray(content)) return "";
119
+ const parts: string[] = [];
120
+ for (const part of content) {
121
+ if (!part || typeof part !== "object") continue;
122
+ const type = (part as { type?: string }).type;
123
+ if (type === "text" && typeof (part as { text?: unknown }).text === "string") {
124
+ const text = (part as { text: string }).text.trim();
125
+ if (text) parts.push(advisorQuoted(text));
126
+ } else if (type === "image") {
127
+ parts.push("[image omitted]");
128
+ } else if (type === "thinking") {
129
+ parts.push("[reasoning omitted]");
130
+ } else if (type === "toolCall") {
131
+ const call = part as { id?: unknown; name?: unknown; arguments?: unknown };
132
+ const id = typeof call.id === "string" ? call.id : undefined;
133
+ const name = typeof call.name === "string" ? call.name : "(unknown)";
134
+ if (name === ADVISOR_TOOL_NAME && (!id || !resolvedToolCallIds.has(id))) continue;
135
+ parts.push(`[tool call${id ? ` #${id}` : ""}] ${name}(${JSON.stringify(call.arguments ?? {})})`);
136
+ }
137
+ }
138
+ return parts.join("\n").trim();
139
+ }
140
+
141
+ function renderAdvisorMessage(message: AdvisorMessageLike, resolvedToolCallIds: ReadonlySet<string>): string {
142
+ if (message.role === "user") {
143
+ const text = advisorContentText(message.content, resolvedToolCallIds);
144
+ return text ? `## user\n${text}` : "";
145
+ }
146
+ if (message.role === "assistant") {
147
+ const text = advisorContentText(message.content, resolvedToolCallIds);
148
+ return text ? `## assistant\n${text}` : "";
149
+ }
150
+ if (message.role === "toolResult") {
151
+ const name = typeof message.toolName === "string" ? message.toolName : "(unknown)";
152
+ const id = typeof message.toolCallId === "string" ? message.toolCallId : undefined;
153
+ const header = `## tool result${id ? ` [#${id}]` : ""}: ${name}${message.isError ? " · error" : ""}`;
154
+ const text = advisorContentText(message.content, resolvedToolCallIds);
155
+ return text ? `${header}\n${text}` : header;
156
+ }
157
+ if (message.role === "bashExecution") {
158
+ // Reuse Pi's own AgentMessage -> LLM conversion instead of hand-rolling one: it already
159
+ // drops a command run with the `!!` prefix (excludeFromContext) and formats the rest exactly
160
+ // as Pi's own context builder would, so a `!`-run command an executor can see is not silently
161
+ // missing from what the advisor sees.
162
+ const [converted] = convertToLlm([message as AdvisorAgentMessage]);
163
+ if (!converted) return "";
164
+ const text = advisorContentText(converted.content, resolvedToolCallIds);
165
+ return text ? `## bash execution\n${text}` : "";
166
+ }
167
+ return "";
168
+ }
169
+
170
+ export function buildAdvisorSnapshot(ctx: ExtensionContext, pi: ExtensionAPI): { text: string; hadCompaction: boolean } {
171
+ const systemPrompt = ctx.getSystemPrompt().trim();
172
+
173
+ const activeToolNames = new Set(pi.getActiveTools().filter((name) => name !== ADVISOR_TOOL_NAME));
174
+ const tools = pi.getAllTools().filter((tool) => activeToolNames.has(tool.name)).sort((a, b) => a.name.localeCompare(b.name));
175
+ // No truncation of tool descriptions: Bro imposes no size cap on the snapshot (see the design
176
+ // doc) and an arbitrary slice would silently drop part of a tool's actual behavior contract.
177
+ const toolsSection = tools.length
178
+ ? tools.map((tool) => `- ${tool.name}: ${tool.description.replace(/\s+/g, " ").trim()}`).join("\n")
179
+ : "(none)";
180
+
181
+ const entries = ctx.sessionManager.buildContextEntries();
182
+ const resolvedToolCallIds = new Set<string>();
183
+ for (const entry of entries) {
184
+ if (entry.type !== "message") continue;
185
+ const message = entry.message as AdvisorMessageLike;
186
+ if (message.role === "toolResult" && typeof message.toolCallId === "string") resolvedToolCallIds.add(message.toolCallId);
187
+ }
188
+
189
+ const lines: string[] = [];
190
+ // Tracked from the actual entry type, not a substring search over the rendered snapshot text --
191
+ // free-text content (a file the executor read, a pasted paragraph) is JSON-quoted but not
192
+ // stripped of "#", so a substring check could be forged by source data that happens to contain
193
+ // the literal heading text. Only a real `compaction` entry may set this.
194
+ let hadCompaction = false;
195
+ for (const entry of entries) {
196
+ if (entry.type === "message") {
197
+ const rendered = renderAdvisorMessage(entry.message as AdvisorMessageLike, resolvedToolCallIds);
198
+ if (rendered) lines.push(rendered);
199
+ } else if (entry.type === "compaction") {
200
+ // buildContextEntries() already includes every real entry after the compaction point;
201
+ // the summary is all there is to represent for what it replaced.
202
+ hadCompaction = true;
203
+ lines.push(`## compacted earlier context\n${advisorQuoted(entry.summary)}`);
204
+ } else if (entry.type === "branch_summary") {
205
+ lines.push(`## abandoned branch summary\n${advisorQuoted(entry.summary)}`);
206
+ } else if (entry.type === "custom_message") {
207
+ const text = advisorContentText(entry.content, resolvedToolCallIds);
208
+ if (text) lines.push(`## extension message: ${entry.customType}\n${text}`);
209
+ }
210
+ // "custom" entries (including our own advisor activation/steering state) are intentionally
211
+ // skipped — they never participate in LLM context and must not leak into the snapshot either.
212
+ }
213
+ const conversationSection = lines.length ? lines.join("\n\n") : "(no conversation content captured)";
214
+
215
+ 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}`;
216
+ return { text, hadCompaction };
217
+ }
218
+
219
+ const MAX_ADVISOR_ACTIVITY_LINES = 4;
220
+ const ADVISOR_ACTIVITY_PREVIEW_CHARS = 100;
221
+
222
+ function advisorActivityPreview(label: string): string {
223
+ return label.length > ADVISOR_ACTIVITY_PREVIEW_CHARS ? `${label.slice(0, ADVISOR_ACTIVITY_PREVIEW_CHARS).trimEnd()}…` : label;
224
+ }
225
+
226
+ export type AdvisorActivityCallback = (label: string, timestamp: number) => void;
227
+
228
+ // Thin wrapper around the shared backend: advisor's stdin/stream-json transport, activity parsing,
229
+ // and process lifecycle now live in backend.ts (see docs/plans/2026-09-19-bro-advisor-design.md,
230
+ // "Transport" for why stdin rather than --print). This function keeps its exact existing
231
+ // signature/throw contract -- it is called directly by tests and by runAdvisorWithRetries below,
232
+ // which owns the 3-attempt retry/backoff policy the backend itself never performs.
233
+ export class TerminalConsultError extends Error {
234
+ readonly status: "failure" | "cancelled" | "timeout";
235
+ constructor(status: "failure" | "cancelled" | "timeout", message: string) {
236
+ super(message);
237
+ this.status = status;
238
+ }
239
+ }
240
+
241
+ export async function runAdvisorConsultation(
242
+ prompt: string,
243
+ selection: BackendSelection,
244
+ cwd: string,
245
+ signal: AbortSignal,
246
+ killEscalationMs = 5_000,
247
+ onActivity?: AdvisorActivityCallback,
248
+ deadlineMs?: number,
249
+ ): Promise<string> {
250
+ const outcome = await executeBackend(
251
+ { feature: "advisor", prompt, access: "workspace-full", cwd },
252
+ selection,
253
+ signal,
254
+ onActivity
255
+ ? (progress) => {
256
+ if (progress.kind === "activity") onActivity(progress.label, progress.timestamp);
257
+ }
258
+ : undefined,
259
+ { killEscalationMs, deadlineMs },
260
+ );
261
+ if (outcome.status === "success") return outcome.text;
262
+ throw new TerminalConsultError(outcome.status, outcome.message);
263
+ }
264
+
265
+ function advisorDelay(ms: number, signal: AbortSignal, onTick?: (remainingMs: number) => void): Promise<void> {
266
+ return new Promise((resolve, reject) => {
267
+ if (signal.aborted) {
268
+ reject(new Error("Canceled."));
269
+ return;
270
+ }
271
+ const startedAt = Date.now();
272
+ const interval = onTick ? setInterval(() => onTick(Math.max(0, ms - (Date.now() - startedAt))), 1_000) : undefined;
273
+ interval?.unref();
274
+ const cleanup = () => {
275
+ clearTimeout(timer);
276
+ if (interval) clearInterval(interval);
277
+ signal.removeEventListener("abort", onAbort);
278
+ };
279
+ const onAbort = () => {
280
+ cleanup();
281
+ reject(new Error("Canceled."));
282
+ };
283
+ const timer = setTimeout(() => {
284
+ cleanup();
285
+ resolve();
286
+ }, ms);
287
+ signal.addEventListener("abort", onAbort, { once: true });
288
+ });
289
+ }
290
+
291
+ export type AdvisorConsult = (
292
+ prompt: string,
293
+ selection: BackendSelection,
294
+ cwd: string,
295
+ signal: AbortSignal,
296
+ killEscalationMs?: number,
297
+ onActivity?: AdvisorActivityCallback,
298
+ ) => Promise<string>;
299
+ export type AdvisorDelayFn = (ms: number, signal: AbortSignal, onTick?: (remainingMs: number) => void) => Promise<void>;
300
+ export type AdvisorToolDetails = {
301
+ status: "investigating" | "retrying" | "done";
302
+ attempt: number;
303
+ of: number;
304
+ elapsedMs: number;
305
+ error?: string;
306
+ retryInMs?: number;
307
+ backend?: BackendName;
308
+ model?: string;
309
+ effort?: string;
310
+ durationMs?: number;
311
+ cwd?: string;
312
+ steeringIncluded?: boolean;
313
+ steeringSources?: { durable: boolean; session: boolean };
314
+ snapshotChars?: number;
315
+ broTruncated?: false;
316
+ omissions?: string;
317
+ // Real backend-reported activity for the current attempt only -- reset to empty whenever a new
318
+ // attempt (including a retry) starts, never carried over from a prior failed attempt.
319
+ activity?: string[];
320
+ lastActivityAt?: number;
321
+ // Total count of accepted activity events (tool calls AND user-facing text deltas -- "activity"
322
+ // is the honest label, not "tool calls") observed across the whole consultation, including every
323
+ // retry attempt. Unlike `activity` above, this never resets on a retry; the final "done" details
324
+ // carry the same running total.
325
+ activityCount?: number;
326
+ };
327
+ export type AdvisorProgressCallback = (details: AdvisorToolDetails) => void;
328
+ export type AdvisorRunResult = { advice: string; attempts: number; durationMs: number; activityCount: number };
329
+
330
+ // Bursts of chunked NDJSON activity events are coalesced to this cadence so a fast stream of tool
331
+ // calls/text deltas doesn't flood onProgress; the 1s elapsed-time heartbeat below is unaffected and
332
+ // keeps ticking independently.
333
+ const ADVISOR_ACTIVITY_THROTTLE_MS = 250;
334
+
335
+ // `consult`/`delayFn` are injectable so tests can swap in a fake process spawn and a fake clock
336
+ // instead of spawning real processes and waiting 15 real seconds. Every attempt sends the
337
+ // identical prompt/selection/cwd to a fresh, standalone backend process — never resumed via
338
+ // --conversation, even across retries.
339
+ export async function runAdvisorWithRetries(
340
+ prompt: string,
341
+ selection: BackendSelection,
342
+ cwd: string,
343
+ signal: AbortSignal,
344
+ consult: AdvisorConsult = runAdvisorConsultation,
345
+ delayFn: AdvisorDelayFn = advisorDelay,
346
+ onProgress?: AdvisorProgressCallback,
347
+ progressIntervalMs = 1_000,
348
+ activityThrottleMs = ADVISOR_ACTIVITY_THROTTLE_MS,
349
+ ): Promise<AdvisorRunResult> {
350
+ const delays = [5_000, 10_000];
351
+ const totalAttempts = delays.length + 1;
352
+ const startedAt = Date.now();
353
+ let lastError: unknown;
354
+ // Cumulative across every attempt in this consultation, including retries -- unlike `activity`
355
+ // below, a retry must never reset this back to zero.
356
+ let totalActivityCount = 0;
357
+ for (let attempt = 0; attempt <= delays.length; attempt++) {
358
+ if (signal.aborted) throw new Error("Canceled.");
359
+ // Fresh per attempt: a retry must never show the previous attempt's activity trail.
360
+ const activity: string[] = [];
361
+ let lastActivityAt: number | undefined;
362
+ let lastActivityEmitAt = 0;
363
+ let pendingActivityTimer: ReturnType<typeof setTimeout> | undefined;
364
+
365
+ const running = (): AdvisorToolDetails => ({
366
+ status: "investigating",
367
+ attempt: attempt + 1,
368
+ of: totalAttempts,
369
+ elapsedMs: Date.now() - startedAt,
370
+ activity: [...activity],
371
+ lastActivityAt,
372
+ activityCount: totalActivityCount,
373
+ });
374
+ const emitRunning = () => onProgress?.(running());
375
+ emitRunning();
376
+ const progressTimer = onProgress ? setInterval(emitRunning, progressIntervalMs) : undefined;
377
+ progressTimer?.unref();
378
+
379
+ const stopActivityThrottle = () => {
380
+ if (pendingActivityTimer) {
381
+ clearTimeout(pendingActivityTimer);
382
+ pendingActivityTimer = undefined;
383
+ }
384
+ };
385
+ const onActivity: AdvisorActivityCallback = (label, timestamp) => {
386
+ const normalized = label.replace(/\s+/g, " ").trim();
387
+ if (!normalized) return;
388
+ activity.push(normalized);
389
+ if (activity.length > MAX_ADVISOR_ACTIVITY_LINES) activity.splice(0, activity.length - MAX_ADVISOR_ACTIVITY_LINES);
390
+ totalActivityCount += 1;
391
+ lastActivityAt = timestamp;
392
+ if (!onProgress) return;
393
+ const elapsed = Date.now() - lastActivityEmitAt;
394
+ if (elapsed >= activityThrottleMs) {
395
+ lastActivityEmitAt = Date.now();
396
+ emitRunning();
397
+ return;
398
+ }
399
+ if (!pendingActivityTimer) {
400
+ pendingActivityTimer = setTimeout(() => {
401
+ pendingActivityTimer = undefined;
402
+ lastActivityEmitAt = Date.now();
403
+ emitRunning();
404
+ }, activityThrottleMs - elapsed);
405
+ pendingActivityTimer.unref?.();
406
+ }
407
+ };
408
+
409
+ try {
410
+ const advice = await consult(prompt, selection, cwd, signal, undefined, onActivity);
411
+ // `totalActivityCount` is read directly here rather than from the last onProgress snapshot,
412
+ // which may lag behind by up to `activityThrottleMs` -- the final count must be exact.
413
+ return { advice, attempts: attempt + 1, durationMs: Date.now() - startedAt, activityCount: totalActivityCount };
414
+ } catch (error) {
415
+ if (progressTimer) clearInterval(progressTimer);
416
+ stopActivityThrottle();
417
+ if (signal.aborted || errorMessage(error) === "Canceled." ||
418
+ (error instanceof TerminalConsultError && error.status !== "failure")) throw error;
419
+ lastError = error;
420
+ if (attempt === delays.length) break;
421
+ const delay = delays[attempt]!;
422
+ const retrying = (remainingMs: number): AdvisorToolDetails => ({
423
+ status: "retrying",
424
+ attempt: attempt + 2,
425
+ of: totalAttempts,
426
+ elapsedMs: Date.now() - startedAt,
427
+ error: errorMessage(error),
428
+ retryInMs: remainingMs,
429
+ activityCount: totalActivityCount,
430
+ });
431
+ onProgress?.(retrying(delay));
432
+ await delayFn(delay, signal, (remainingMs) => onProgress?.(retrying(remainingMs)));
433
+ } finally {
434
+ if (progressTimer) clearInterval(progressTimer);
435
+ stopActivityThrottle();
436
+ }
437
+ }
438
+ throw lastError;
439
+ }
440
+
441
+ export function advisorAttemptLabel(details: AdvisorToolDetails): string {
442
+ if (details.status === "retrying") {
443
+ return `Bro advisor · retrying in ${Math.ceil((details.retryInMs ?? 0) / 1_000)}s · attempt ${details.attempt}/${details.of}${details.error ? ` — ${details.error}` : ""}`;
444
+ }
445
+ if (details.status === "done") {
446
+ return `Bro advisor · ${details.model ?? "unknown model"} · ${details.attempt} attempt${details.attempt === 1 ? "" : "s"} · ${Math.ceil((details.durationMs ?? details.elapsedMs) / 1_000)}s`;
447
+ }
448
+ const latest = details.activity?.at(-1);
449
+ const backendLabel = details.backend === "claude" ? "Claude" : details.backend === "grok" ? "Grok" : details.backend === "codex" ? "Codex" : details.backend === "muse" ? "Muse" : "Agy";
450
+ // "last reported" + freshness, never a claim about what the backend is doing right now and never
451
+ // "stalled" -- silence since lastActivityAt is not itself evidence of a stuck run.
452
+ const activityLabel = latest
453
+ ? `last reported: ${advisorActivityPreview(latest)} (${Math.max(0, Math.floor((Date.now() - (details.lastActivityAt ?? Date.now())) / 1_000))}s ago)`
454
+ : `awaiting first activity from ${backendLabel}`;
455
+ return `Bro advisor · running · ${Math.floor(details.elapsedMs / 1_000)}s · attempt ${details.attempt}/${details.of} · ${activityLabel}`;
456
+ }
457
+
458
+ export function registerAdvisorTool(pi: ExtensionAPI): void {
459
+ pi.registerTool({
460
+ name: ADVISOR_TOOL_NAME,
461
+ label: "Bro advisor",
462
+ description:
463
+ "Consult a fresh, independent process of the configured advisor backend for a second opinion mid-task. It has real, unsandboxed tool access in the current workspace (read files, search, run commands) with permissions auto-approved, and is instructed to investigate before advising and to leave edits to you — that is a behavioral instruction to the advisor, not an enforced restriction, so treat its findings as advice rather than a delegated implementation. You never need to prepare a summary or evidence first: Bro automatically captures your system instructions, active tools, and the conversation so far, plus any human-set steering priorities, and sends them to the advisor.",
464
+ promptSnippet: "Consult a fresh configured-backend process for a second opinion mid-task; it investigates the workspace itself and returns advice",
465
+ promptGuidelines: [
466
+ "Call bro_advisor before or after a non-trivial design or scope decision, or when genuinely uncertain, for a second opinion from a fresh, independent process of the configured advisor backend.",
467
+ "bro_advisor's question parameter is optional — never delay a call to first prepare a summary or evidence; Bro captures context automatically.",
468
+ "Any human-set steering priorities are applied automatically by bro_advisor; you don't need to relay or repeat them.",
469
+ "bro_advisor is instructed to only return advice and leave edits to you — that instruction is not enforced, so verify its findings yourself rather than treating them as a completed implementation.",
470
+ ],
471
+ parameters: Type.Object({
472
+ question: Type.Optional(
473
+ Type.String({ description: "Optional question to focus the consultation on. Leave unset to ask for general advice on the current state." }),
474
+ ),
475
+ }),
476
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
477
+ const state = resolveAdvisorState(ctx.sessionManager.getBranch());
478
+ const durableSteering = await readAdvisorSteering();
479
+ const settings = await readSettings();
480
+ const selection = selectionForCapability(settings, "advisor");
481
+ const backend = selection.backend ?? "agy";
482
+ const snapshot = buildAdvisorSnapshot(ctx, pi);
483
+ const prompt = buildAdvisorPrompt({ durable: durableSteering, session: state.steering }, snapshot.text, params?.question);
484
+ const hasDurable = Boolean(durableSteering.trim());
485
+ const hasSession = Boolean(state.steering.trim());
486
+ let lastAttempt: AdvisorToolDetails = { status: "investigating", attempt: 1, of: 3, elapsedMs: 0, backend };
487
+ try {
488
+ const run = await runAdvisorWithRetries(
489
+ prompt,
490
+ selection,
491
+ ctx.cwd,
492
+ signal ?? new AbortController().signal,
493
+ undefined,
494
+ undefined,
495
+ (details) => {
496
+ lastAttempt = { ...details, backend };
497
+ const label = advisorAttemptLabel(lastAttempt);
498
+ onUpdate?.({
499
+ content: [{ type: "text", text: label }],
500
+ details: lastAttempt,
501
+ });
502
+ // A host's tool-card renderer may replace onUpdate's partial renderResult with its
503
+ // own generic placeholder for the whole run (observed with pi-cc-extensions' default
504
+ // mode, which hardcodes "Pending…" for every isPartial tool result unless the tool
505
+ // is opted into its excludeRenderers list). The footer/status bar is a separate,
506
+ // host-owned surface that no such tool-card override touches, so mirror progress
507
+ // there too as a fallback the user can see regardless of that renderer choice.
508
+ ctx.ui.setStatus(ADVISOR_STATUS_BAR_KEY, label);
509
+ },
510
+ );
511
+ const effort = selection.effort ?? "model default";
512
+ const omissions = `image/reasoning bodies omitted when present; ${snapshot.hadCompaction ? "Pi compaction summaries replace earlier turns" : "no Pi compaction summary present"}`;
513
+ const details: AdvisorToolDetails = {
514
+ ...lastAttempt,
515
+ status: "done",
516
+ attempt: run.attempts,
517
+ backend,
518
+ model: selection.model,
519
+ effort,
520
+ durationMs: run.durationMs,
521
+ cwd: ctx.cwd,
522
+ steeringIncluded: hasDurable || hasSession,
523
+ steeringSources: { durable: hasDurable, session: hasSession },
524
+ snapshotChars: snapshot.text.length,
525
+ broTruncated: false,
526
+ omissions,
527
+ // run.activityCount is the exact final total; lastAttempt's may lag behind by up to
528
+ // the activity throttle window.
529
+ activityCount: run.activityCount,
530
+ };
531
+ // The backend qualifier appears only off Agy, keeping the Agy header byte-identical.
532
+ const header = `Bro advisor · ${backend === "agy" ? "" : `backend: ${backend} · `}model: ${selection.model} · effort: ${effort} · ${run.attempts} attempt${run.attempts === 1 ? "" : "s"} · ${Math.ceil(run.durationMs / 1_000)}s`;
533
+ const steeringSummary = hasDurable && hasSession
534
+ ? "included (durable+session)"
535
+ : hasDurable
536
+ ? "included (durable)"
537
+ : hasSession
538
+ ? "included"
539
+ : "none";
540
+ const context = `Context · cwd: ${JSON.stringify(ctx.cwd)} · steering: ${steeringSummary} · snapshot: ${snapshot.text.length} chars · Bro truncation: none · omissions: ${omissions}`;
541
+ return { content: [{ type: "text", text: `${header}\n${context}\n\n${run.advice}` }], details };
542
+ } finally {
543
+ ctx.ui.setStatus(ADVISOR_STATUS_BAR_KEY, undefined);
544
+ }
545
+ },
546
+ renderCall(args, theme) {
547
+ const question = args.question?.trim();
548
+ return new Text(theme.fg("dim", question ? `Bro advisor · ${question}` : "Bro advisor · consulting…"));
549
+ },
550
+ renderResult(result, options, theme) {
551
+ const details = result.details as AdvisorToolDetails | undefined;
552
+ if (options.isPartial) {
553
+ const label = new Text(theme.fg("dim", details ? advisorAttemptLabel(details) : "Bro advisor · investigating…"));
554
+ // Expanded + running: the simplest native tail -- one dim line per bounded recent
555
+ // activity label, nothing fancier than the compact renderer above.
556
+ if (!options.expanded || !details?.activity?.length) return label;
557
+ const container = new Container();
558
+ container.addChild(label);
559
+ for (const line of details.activity) {
560
+ container.addChild(new Text(theme.fg("dim", ` ${advisorActivityPreview(line)}`)));
561
+ }
562
+ return container;
563
+ }
564
+ if (!options.expanded && details?.status === "done") return new Text(theme.fg("accent", advisorAttemptLabel(details)));
565
+ const text = result.content[0]?.type === "text" ? result.content[0].text : "";
566
+ if (!options.expanded) {
567
+ const firstLine = text.split("\n").find((line) => line.trim()) ?? "(no advice)";
568
+ return new Text(`${theme.fg("accent", "Bro advisor")}${theme.fg("dim", ` · ${firstLine}`)}`);
569
+ }
570
+ return new Markdown(text, 0, 0, getMarkdownTheme());
571
+ },
572
+ });
573
+ }
package/src/backend.ts CHANGED
@@ -1423,3 +1423,38 @@ export async function execute(
1423
1423
  }
1424
1424
  return executeArgvPrint(request, selection, signal, onProgress, killEscalationMs, options?.deadlineMs);
1425
1425
  }
1426
+
1427
+ // Thin presentation-boundary wrapper around the shared backend: coalesces raw text progress to the
1428
+ // existing 75ms cadence and translates the backend's tagged outcome back into this function's
1429
+ // existing throw-on-failure contract.
1430
+ export async function runBackendText(
1431
+ prompt: string,
1432
+ selection: BackendSelection,
1433
+ signal: AbortSignal,
1434
+ onProgress?: (text: string) => void,
1435
+ feature: "explain" | "show" = "explain",
1436
+ ): Promise<string> {
1437
+ let updateTimer: ReturnType<typeof setTimeout> | undefined;
1438
+ let latest: string | undefined;
1439
+ const throttledProgress = onProgress
1440
+ ? (progress: BackendProgress) => {
1441
+ if (progress.kind !== "text") return;
1442
+ latest = progress.text;
1443
+ if (!updateTimer) {
1444
+ updateTimer = setTimeout(() => {
1445
+ updateTimer = undefined;
1446
+ if (!signal.aborted && latest !== undefined) onProgress(latest);
1447
+ }, 75);
1448
+ }
1449
+ }
1450
+ : undefined;
1451
+
1452
+ try {
1453
+ const outcome = await execute({ feature, prompt, access: "restricted" }, selection, signal, throttledProgress);
1454
+ if (outcome.status === "success") return outcome.text;
1455
+ throw new Error(outcome.message);
1456
+ } finally {
1457
+ if (updateTimer) clearTimeout(updateTimer);
1458
+ }
1459
+ }
1460
+