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 +21 -0
- package/docs/user-guide.md +3 -2
- package/package.json +6 -1
- package/src/advisor.ts +573 -0
- package/src/backend.ts +35 -0
- package/src/bro.ts +37 -2623
- package/src/btw.ts +572 -0
- package/src/config-ui.ts +17 -7
- package/src/doctor.ts +541 -0
- package/src/explain-modal.ts +758 -0
- package/src/review-ui.ts +5 -1
- package/src/settings.ts +45 -3
- package/src/show.ts +243 -0
- package/src/ui-capabilities.ts +25 -0
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
|
package/docs/user-guide.md
CHANGED
|
@@ -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
|
|
183
|
-
running turn
|
|
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.
|
|
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
|
+
|