pi-bro 0.15.0 → 0.15.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 +11 -0
- package/README.md +7 -0
- package/backend.ts +507 -0
- package/bro.ts +85 -456
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to pi-bro are documented here.
|
|
4
4
|
|
|
5
|
+
## [0.15.1] - 2026-09-22
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Explain, show, BTW and advisor now share an internal Agy execution boundary. Agy remains the only backend; existing settings, prompts, access modes, continuation and advisor retries are unchanged.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Cancellation, host deadlines and malformed execution streams use bounded subprocess cleanup, with POSIX process-group termination and escalation. Unexpected signal exits are reported as failures rather than mislabeled timeouts. Windows cleanup remains limited to the direct child.
|
|
14
|
+
- Offline RPC smoke checks wait for command acknowledgements instead of relying on fixed delays to prevent overlapping requests and premature shutdown.
|
|
15
|
+
|
|
5
16
|
## [0.15.0] - 2026-09-22
|
|
6
17
|
|
|
7
18
|
### Changed
|
package/README.md
CHANGED
|
@@ -31,6 +31,13 @@ Restart Pi or run `/reload`, then try:
|
|
|
31
31
|
|
|
32
32
|
Run `/bro doctor` after installation or whenever Bro is not working.
|
|
33
33
|
|
|
34
|
+
Explain, show, BTW and advisor share an internal execution layer; Agy remains
|
|
35
|
+
its only backend and existing settings are unchanged. Cancellation, host deadlines
|
|
36
|
+
and invalid execution streams terminate the subprocess group on POSIX, escalating
|
|
37
|
+
after a five-second grace period. Windows cleanup targets the direct child only;
|
|
38
|
+
descendant termination is not guaranteed. Unexpected signal exits are reported as
|
|
39
|
+
failures, not timeouts.
|
|
40
|
+
|
|
34
41
|
To install from GitHub instead, use
|
|
35
42
|
`pi install git:github.com/tranhoangnguyen03/pi-bro`. To try Bro without
|
|
36
43
|
installing it, use `pi -e npm:pi-bro`.
|
package/backend.ts
ADDED
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
import { type ChildProcess, spawn } from "node:child_process";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { createInterface } from "node:readline";
|
|
6
|
+
|
|
7
|
+
// Shared internal execution boundary for all four Bro features (explain, show, btw, advisor).
|
|
8
|
+
// This is the Agy-only implementation of docs/plans/2026-09-22-shared-backend-design.md: it owns
|
|
9
|
+
// Agy CLI selection, process invocation, progress/outcome normalization, continuation, and
|
|
10
|
+
// single-attempt cleanup. Feature code (bro.ts) keeps retries, UI, source/session capture and
|
|
11
|
+
// settings.
|
|
12
|
+
|
|
13
|
+
export type BackendFeature = "explain" | "show" | "btw" | "advisor";
|
|
14
|
+
export type BackendAccess = "restricted" | "workspace-full";
|
|
15
|
+
export type AgySelection = { model: string; effort?: "low" | "medium" | "high" };
|
|
16
|
+
export type BackendContinuation = { id: string };
|
|
17
|
+
export type BackendProgress = { kind: "text"; text: string } | { kind: "activity"; label: string; timestamp: number };
|
|
18
|
+
export type BackendOnProgress = (progress: BackendProgress) => void;
|
|
19
|
+
|
|
20
|
+
export type BackendRequest = {
|
|
21
|
+
feature: BackendFeature;
|
|
22
|
+
prompt: string;
|
|
23
|
+
access: BackendAccess;
|
|
24
|
+
cwd?: string; // required for workspace-full
|
|
25
|
+
continuation?: BackendContinuation;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type BackendOutcome =
|
|
29
|
+
| { status: "success"; text: string; continuation?: BackendContinuation }
|
|
30
|
+
| { status: "failure" | "cancelled" | "timeout"; message: string; partialText?: string };
|
|
31
|
+
|
|
32
|
+
export type BackendExecuteOptions = {
|
|
33
|
+
// Injectable for offline tests only -- production always uses the defaults below.
|
|
34
|
+
killEscalationMs?: number;
|
|
35
|
+
deadlineMs?: number;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function errorMessage(error: unknown): string {
|
|
39
|
+
return error instanceof Error ? error.message : String(error);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function withDoctor(message: string): string {
|
|
43
|
+
return message.includes("/bro doctor") ? message : `${message}\n\nRun \`/bro doctor\` for setup help.`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function agyFailureMessage(action: string, result: { code: number; killed: boolean; stderr: string }): string {
|
|
47
|
+
if (result.killed) return `Agy timed out while trying to ${action}. Run \`/bro doctor\` for setup help.`;
|
|
48
|
+
const detail = result.stderr.trim();
|
|
49
|
+
if (detail) return `Agy could not ${action}: ${detail}\n\nRun \`/bro doctor\` for setup help.`;
|
|
50
|
+
return `Agy could not ${action}. Make sure Agy is installed and signed in, then run \`/bro doctor\`.`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Strips a "-low/-medium/-high" suffix from a stored model id and separates it back into a plain
|
|
54
|
+
// Agy `--model`/`--effort` pair; a "default" effort omits --effort entirely (Agy's own default).
|
|
55
|
+
export function agySelection(pair: { model: string; effort: "default" | "low" | "medium" | "high" }): AgySelection {
|
|
56
|
+
if (pair.effort === "default") return { model: pair.model };
|
|
57
|
+
const suffix = (["low", "medium", "high"] as const).find((effort) => pair.model.endsWith(`-${effort}`));
|
|
58
|
+
return {
|
|
59
|
+
model: suffix ? pair.model.slice(0, -suffix.length - 1) : pair.model,
|
|
60
|
+
effort: pair.effort,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function processStartMessage(processError: NodeJS.ErrnoException): string {
|
|
65
|
+
return processError.code === "ENOENT"
|
|
66
|
+
? "Agy could not start. Make sure Agy is installed and on PATH, then run `/bro doctor`."
|
|
67
|
+
: `Agy could not start: ${processError.message}\n\nRun \`/bro doctor\` for setup help.`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function unexpectedSignalMessage(exitSignal: NodeJS.Signals | null): string {
|
|
71
|
+
return withDoctor(`Agy exited unexpectedly${exitSignal ? ` (signal ${exitSignal})` : ""}, not from a request Bro made.`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Sends to the whole POSIX process group when possible so a misbehaving grandchild dies too, not
|
|
75
|
+
// just the immediate agy process -- child.kill() alone only ever reaches the immediate child.
|
|
76
|
+
function killAgyGroup(child: ChildProcess, signalName: NodeJS.Signals): void {
|
|
77
|
+
if (process.platform !== "win32" && typeof child.pid === "number") {
|
|
78
|
+
try {
|
|
79
|
+
process.kill(-child.pid, signalName);
|
|
80
|
+
return;
|
|
81
|
+
} catch {
|
|
82
|
+
// Group may already be gone (e.g. the child already exited) -- fall through.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
child.kill(signalName);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The three causes that stop an in-flight attempt: user cancellation, the host-imposed deadline,
|
|
89
|
+
// and a protocol failure (malformed/inconsistent Agy output). Exactly one is latched -- the first
|
|
90
|
+
// to occur -- and it is never relabeled by a later signal (e.g. a cancel arriving after a deadline
|
|
91
|
+
// already fired stays a timeout, not a cancellation).
|
|
92
|
+
type StopCause = "cancelled" | "timeout" | "protocol";
|
|
93
|
+
|
|
94
|
+
type Attempt = {
|
|
95
|
+
causeOf: () => StopCause | undefined;
|
|
96
|
+
stop: (cause: StopCause) => void;
|
|
97
|
+
closed: Promise<{ code: number | null; exitSignal: NodeJS.Signals | null }>;
|
|
98
|
+
dispose: () => void;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const DEFAULT_KILL_ESCALATION_MS = 5_000;
|
|
102
|
+
|
|
103
|
+
// Begins bounded lifecycle management for one already-spawned child: on cancellation, deadline, or
|
|
104
|
+
// protocol failure it signals the whole POSIX process group (SIGTERM, then SIGKILL after
|
|
105
|
+
// `killEscalationMs` if the child or a misbehaving grandchild ignores it). Windows only ever
|
|
106
|
+
// reaches the immediate child directly -- there is no process-tree guarantee there.
|
|
107
|
+
function beginAttempt(child: ChildProcess, signal: AbortSignal, deadlineMs: number, killEscalationMs: number): Attempt {
|
|
108
|
+
let cause: StopCause | undefined;
|
|
109
|
+
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
|
110
|
+
let finishClose: (value: { code: number | null; exitSignal: NodeJS.Signals | null }) => void;
|
|
111
|
+
const closed = new Promise<{ code: number | null; exitSignal: NodeJS.Signals | null }>((resolve) => {
|
|
112
|
+
finishClose = resolve;
|
|
113
|
+
child.once("close", (code, exitSignal) => resolve({ code, exitSignal }));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const stop = (next: StopCause) => {
|
|
117
|
+
if (cause) return; // latched: the first stop cause wins
|
|
118
|
+
cause = next;
|
|
119
|
+
killAgyGroup(child, "SIGTERM");
|
|
120
|
+
killTimer = setTimeout(() => {
|
|
121
|
+
killAgyGroup(child, "SIGKILL");
|
|
122
|
+
// Detached descendants (or Windows grandchildren) may retain inherited pipes.
|
|
123
|
+
// Stop waiting on those pipes after escalation; never promote this stop to success.
|
|
124
|
+
child.stdin?.destroy();
|
|
125
|
+
child.stdout?.destroy();
|
|
126
|
+
child.stderr?.destroy();
|
|
127
|
+
finishClose({ code: null, exitSignal: "SIGKILL" });
|
|
128
|
+
}, killEscalationMs);
|
|
129
|
+
killTimer.unref?.();
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const onAbort = () => stop("cancelled");
|
|
133
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
134
|
+
if (signal.aborted) onAbort();
|
|
135
|
+
|
|
136
|
+
const deadlineTimer = setTimeout(() => stop("timeout"), deadlineMs);
|
|
137
|
+
deadlineTimer.unref?.();
|
|
138
|
+
|
|
139
|
+
const dispose = () => {
|
|
140
|
+
signal.removeEventListener("abort", onAbort);
|
|
141
|
+
clearTimeout(deadlineTimer);
|
|
142
|
+
if (killTimer) clearTimeout(killTimer);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
return { causeOf: () => cause, stop, closed, dispose };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
type AgyEvent = {
|
|
149
|
+
event?: string;
|
|
150
|
+
conversation_id?: string;
|
|
151
|
+
step_update?: { step_type?: string; text_delta?: unknown };
|
|
152
|
+
result?: { status?: string; response?: unknown; error?: unknown; conversation_id?: string };
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
function parseExplainLine(line: string): { delta?: string; result?: string } {
|
|
156
|
+
let event: AgyEvent;
|
|
157
|
+
try {
|
|
158
|
+
event = JSON.parse(line) as AgyEvent;
|
|
159
|
+
} catch {
|
|
160
|
+
throw new Error("Agy returned invalid streaming data.");
|
|
161
|
+
}
|
|
162
|
+
if (event.event === "step_update" && event.step_update?.step_type === "agent_response" && typeof event.step_update.text_delta === "string") {
|
|
163
|
+
return { delta: event.step_update.text_delta };
|
|
164
|
+
}
|
|
165
|
+
if (event.event === "result") {
|
|
166
|
+
if (event.result?.status !== "SUCCESS" || typeof event.result.response !== "string") {
|
|
167
|
+
throw new Error("Agy did not complete the explanation successfully.");
|
|
168
|
+
}
|
|
169
|
+
return { result: event.result.response };
|
|
170
|
+
}
|
|
171
|
+
return {};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function parseBtwAgyLine(line: string): { delta?: string; result?: string; conversationId?: string; error?: string } {
|
|
175
|
+
let event: AgyEvent;
|
|
176
|
+
try {
|
|
177
|
+
event = JSON.parse(line) as AgyEvent;
|
|
178
|
+
} catch {
|
|
179
|
+
throw new Error("Agy returned invalid streaming data.");
|
|
180
|
+
}
|
|
181
|
+
const conversationId = event.conversation_id ?? event.result?.conversation_id;
|
|
182
|
+
if (event.event === "step_update" && event.step_update?.step_type === "agent_response" && typeof event.step_update.text_delta === "string") {
|
|
183
|
+
return { delta: event.step_update.text_delta, conversationId };
|
|
184
|
+
}
|
|
185
|
+
if (event.event === "result") {
|
|
186
|
+
if (event.result?.status !== "SUCCESS" || typeof event.result.response !== "string") {
|
|
187
|
+
const detail = typeof event.result?.error === "string" ? event.result.error : "Agy did not complete the turn successfully.";
|
|
188
|
+
return { error: detail, conversationId };
|
|
189
|
+
}
|
|
190
|
+
return { result: event.result.response, conversationId };
|
|
191
|
+
}
|
|
192
|
+
return { conversationId };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// explain/show and btw all print the prompt as a single --print argv value and read a stream-json
|
|
196
|
+
// result off stdout; only sandbox flag, cwd, timeout, and (btw only) --conversation differ.
|
|
197
|
+
async function executeArgvPrint(
|
|
198
|
+
request: BackendRequest,
|
|
199
|
+
selection: AgySelection,
|
|
200
|
+
signal: AbortSignal,
|
|
201
|
+
onProgress: BackendOnProgress | undefined,
|
|
202
|
+
killEscalationMs: number,
|
|
203
|
+
deadlineMsOverride: number | undefined,
|
|
204
|
+
): Promise<BackendOutcome> {
|
|
205
|
+
const isBtw = request.feature === "btw";
|
|
206
|
+
const full = isBtw && request.access === "workspace-full";
|
|
207
|
+
const deadlineMs = deadlineMsOverride ?? (isBtw ? (full ? 610_000 : 130_000) : 125_000);
|
|
208
|
+
const printTimeout = full ? "10m" : "2m";
|
|
209
|
+
const action = isBtw ? "answer the side question" : "simplify the response";
|
|
210
|
+
const timeoutVerb = isBtw ? "during the side conversation" : "while simplifying the response";
|
|
211
|
+
const emptyTextMessage = isBtw ? "Agy returned no answer for the side question." : "Agy returned no final explanation.";
|
|
212
|
+
|
|
213
|
+
if (signal.aborted) return { status: "cancelled", message: "Canceled." };
|
|
214
|
+
|
|
215
|
+
const runDirectory = full ? undefined : await mkdtemp(join(tmpdir(), "pi-bro-"));
|
|
216
|
+
if (signal.aborted) {
|
|
217
|
+
if (runDirectory) await rm(runDirectory, { recursive: true, force: true });
|
|
218
|
+
return { status: "cancelled", message: "Canceled." };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
const child = spawn(
|
|
223
|
+
"agy",
|
|
224
|
+
[
|
|
225
|
+
...(full ? ["--dangerously-skip-permissions"] : ["--sandbox"]),
|
|
226
|
+
"--disable-slash-commands",
|
|
227
|
+
"--output-format",
|
|
228
|
+
"stream-json",
|
|
229
|
+
"--model",
|
|
230
|
+
selection.model,
|
|
231
|
+
...(selection.effort ? ["--effort", selection.effort] : []),
|
|
232
|
+
"--print-timeout",
|
|
233
|
+
printTimeout,
|
|
234
|
+
...(request.continuation ? ["--conversation", request.continuation.id] : []),
|
|
235
|
+
"--print",
|
|
236
|
+
request.prompt,
|
|
237
|
+
],
|
|
238
|
+
{
|
|
239
|
+
cwd: full ? request.cwd : runDirectory,
|
|
240
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
241
|
+
windowsHide: true,
|
|
242
|
+
detached: process.platform !== "win32",
|
|
243
|
+
},
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
const attempt = beginAttempt(child, signal, deadlineMs, killEscalationMs);
|
|
247
|
+
let processError: Error | undefined;
|
|
248
|
+
let stderr = "";
|
|
249
|
+
let partial = "";
|
|
250
|
+
let final = "";
|
|
251
|
+
let conversationId = request.continuation?.id;
|
|
252
|
+
let parseError: string | undefined;
|
|
253
|
+
|
|
254
|
+
child.stderr?.setEncoding("utf8");
|
|
255
|
+
child.stderr?.on("data", (chunk: string) => {
|
|
256
|
+
stderr += chunk;
|
|
257
|
+
});
|
|
258
|
+
child.once("error", (error) => {
|
|
259
|
+
processError = error;
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const lines = createInterface({ input: child.stdout!, crlfDelay: Infinity });
|
|
263
|
+
try {
|
|
264
|
+
for await (const line of lines) {
|
|
265
|
+
if (!line.trim() || attempt.causeOf()) continue;
|
|
266
|
+
try {
|
|
267
|
+
if (isBtw) {
|
|
268
|
+
const parsed = parseBtwAgyLine(line);
|
|
269
|
+
if (parsed.conversationId) conversationId = parsed.conversationId;
|
|
270
|
+
if (parsed.error) {
|
|
271
|
+
parseError = parsed.error;
|
|
272
|
+
attempt.stop("protocol");
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
if (parsed.delta) {
|
|
276
|
+
partial += parsed.delta;
|
|
277
|
+
onProgress?.({ kind: "text", text: partial });
|
|
278
|
+
}
|
|
279
|
+
if (parsed.result !== undefined) final = parsed.result;
|
|
280
|
+
} else {
|
|
281
|
+
const parsed = parseExplainLine(line);
|
|
282
|
+
if (parsed.delta) {
|
|
283
|
+
partial += parsed.delta;
|
|
284
|
+
onProgress?.({ kind: "text", text: partial });
|
|
285
|
+
}
|
|
286
|
+
if (parsed.result !== undefined) final = parsed.result;
|
|
287
|
+
}
|
|
288
|
+
} catch (error) {
|
|
289
|
+
parseError = errorMessage(error);
|
|
290
|
+
attempt.stop("protocol");
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
} finally {
|
|
295
|
+
lines.close();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const { code, exitSignal } = await attempt.closed;
|
|
299
|
+
attempt.dispose();
|
|
300
|
+
const cause = attempt.causeOf();
|
|
301
|
+
|
|
302
|
+
if (cause === "cancelled") return { status: "cancelled", message: "Canceled.", partialText: partial || undefined };
|
|
303
|
+
if (cause === "timeout") {
|
|
304
|
+
return { status: "timeout", message: `Agy timed out ${timeoutVerb}. Run \`/bro doctor\` for setup help.`, partialText: partial || undefined };
|
|
305
|
+
}
|
|
306
|
+
if (parseError) return { status: "failure", message: withDoctor(parseError), partialText: partial || undefined };
|
|
307
|
+
if (processError) return { status: "failure", message: processStartMessage(processError as NodeJS.ErrnoException) };
|
|
308
|
+
if (exitSignal || code === null) {
|
|
309
|
+
return { status: "failure", message: unexpectedSignalMessage(exitSignal), partialText: partial || undefined };
|
|
310
|
+
}
|
|
311
|
+
if (code !== 0) return { status: "failure", message: agyFailureMessage(action, { code, killed: false, stderr }) };
|
|
312
|
+
|
|
313
|
+
const text = final.trim();
|
|
314
|
+
if (!text) return { status: "failure", message: withDoctor(stderr.trim() || emptyTextMessage) };
|
|
315
|
+
return { status: "success", text, continuation: isBtw && conversationId ? { id: conversationId } : undefined };
|
|
316
|
+
} finally {
|
|
317
|
+
if (runDirectory) await rm(runDirectory, { recursive: true, force: true });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Guards only against a single runaway line with no newline (a protocol break, not a real
|
|
322
|
+
// response size) -- agy's real NDJSON lines are far smaller than this.
|
|
323
|
+
const ADVISOR_MAX_STDOUT_LINE_CHARS = 2_000_000;
|
|
324
|
+
|
|
325
|
+
type AdvisorAgyEvent = {
|
|
326
|
+
event?: string;
|
|
327
|
+
step_update?: { tool_name?: unknown; step_type?: unknown; text_delta?: unknown };
|
|
328
|
+
result?: { status?: unknown; response?: unknown; error?: unknown };
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
// Only a tool_name or a user-facing agent_response/assistant text_delta becomes an activity label
|
|
332
|
+
// -- hidden reasoning/thinking step_types and any other shape stay unreported, never leaked into
|
|
333
|
+
// progress.
|
|
334
|
+
function advisorActivityFromEvent(event: AdvisorAgyEvent): string | undefined {
|
|
335
|
+
if (event.event !== "step_update" || !event.step_update || typeof event.step_update !== "object") return undefined;
|
|
336
|
+
const update = event.step_update;
|
|
337
|
+
if (typeof update.tool_name === "string" && update.tool_name.trim()) return update.tool_name.trim();
|
|
338
|
+
const isUserFacingText = update.step_type === "agent_response" || update.step_type === "assistant";
|
|
339
|
+
if (isUserFacingText && typeof update.text_delta === "string" && update.text_delta.trim()) {
|
|
340
|
+
return update.text_delta.split("\n").find((line) => line.trim())?.trim();
|
|
341
|
+
}
|
|
342
|
+
return undefined;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Older agy CLIs reject --input-format with Go's flag-package usage dump and exit before running
|
|
346
|
+
// the agent at all (no terminal result event). Turn that into an actionable version hint instead
|
|
347
|
+
// of a bare "no terminal result" error.
|
|
348
|
+
export function advisorFlagErrorHint(stderr: string): string | undefined {
|
|
349
|
+
const match = /flags? provided but not defined: -([a-z0-9-]+)/i.exec(stderr);
|
|
350
|
+
if (!match) return undefined;
|
|
351
|
+
return `flag provided but not defined: -${match[1]} (installed Agy CLI is too old; the advisor needs Agy 1.1.15+ for --input-format stream-json — run \`agy update\`, then \`/bro doctor\`)`;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Advisor speaks stdin NDJSON (never --print argv) and always runs fresh in the workspace with
|
|
355
|
+
// tools auto-approved; it never resumes and never passes --conversation.
|
|
356
|
+
async function executeAdvisorStdin(
|
|
357
|
+
request: BackendRequest,
|
|
358
|
+
selection: AgySelection,
|
|
359
|
+
signal: AbortSignal,
|
|
360
|
+
onProgress: BackendOnProgress | undefined,
|
|
361
|
+
killEscalationMs: number,
|
|
362
|
+
deadlineMsOverride: number | undefined,
|
|
363
|
+
): Promise<BackendOutcome> {
|
|
364
|
+
if (signal.aborted) return { status: "cancelled", message: "Canceled." };
|
|
365
|
+
|
|
366
|
+
const deadlineMs = deadlineMsOverride ?? 610_000;
|
|
367
|
+
const child = spawn(
|
|
368
|
+
"agy",
|
|
369
|
+
[
|
|
370
|
+
"--dangerously-skip-permissions",
|
|
371
|
+
"--disable-slash-commands",
|
|
372
|
+
"--output-format",
|
|
373
|
+
"stream-json",
|
|
374
|
+
"--input-format",
|
|
375
|
+
"stream-json",
|
|
376
|
+
"--model",
|
|
377
|
+
selection.model,
|
|
378
|
+
...(selection.effort ? ["--effort", selection.effort] : []),
|
|
379
|
+
"--print-timeout",
|
|
380
|
+
"10m",
|
|
381
|
+
],
|
|
382
|
+
{ cwd: request.cwd, stdio: ["pipe", "pipe", "pipe"], windowsHide: true, detached: process.platform !== "win32" },
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
const attempt = beginAttempt(child, signal, deadlineMs, killEscalationMs);
|
|
386
|
+
|
|
387
|
+
let processError: Error | undefined;
|
|
388
|
+
let stderr = "";
|
|
389
|
+
let final: string | undefined;
|
|
390
|
+
let terminalError: string | undefined;
|
|
391
|
+
let protocolError: string | undefined;
|
|
392
|
+
let sawTerminal = false;
|
|
393
|
+
let stdoutBuffer = "";
|
|
394
|
+
|
|
395
|
+
child.stderr?.setEncoding("utf8");
|
|
396
|
+
child.stderr?.on("data", (chunk: string) => {
|
|
397
|
+
stderr += chunk;
|
|
398
|
+
});
|
|
399
|
+
child.once("error", (error) => {
|
|
400
|
+
processError = error;
|
|
401
|
+
});
|
|
402
|
+
child.stdin?.on("error", () => {
|
|
403
|
+
// agy exiting before it reads stdin is reported through the close/error path below.
|
|
404
|
+
});
|
|
405
|
+
child.stdin?.end(`${JSON.stringify({ event: "user", message: { content: request.prompt } })}\n`);
|
|
406
|
+
|
|
407
|
+
const handleLine = (line: string) => {
|
|
408
|
+
if (!line.trim() || sawTerminal || attempt.causeOf()) return;
|
|
409
|
+
let event: AdvisorAgyEvent;
|
|
410
|
+
try {
|
|
411
|
+
event = JSON.parse(line) as AdvisorAgyEvent;
|
|
412
|
+
} catch {
|
|
413
|
+
throw new Error("Agy emitted invalid stream-json output.");
|
|
414
|
+
}
|
|
415
|
+
const activity = advisorActivityFromEvent(event);
|
|
416
|
+
if (activity) onProgress?.({ kind: "activity", label: activity, timestamp: Date.now() });
|
|
417
|
+
if (event.event !== "result") return;
|
|
418
|
+
sawTerminal = true;
|
|
419
|
+
const result = event.result;
|
|
420
|
+
const status = typeof result?.status === "string" ? result.status.trim().toUpperCase() : undefined;
|
|
421
|
+
if (status === "SUCCESS" && typeof result?.response === "string") {
|
|
422
|
+
final = result.response;
|
|
423
|
+
} else {
|
|
424
|
+
const detail = typeof result?.error === "string" && result.error.trim() ? `: ${result.error.trim()}` : "";
|
|
425
|
+
terminalError = `Agy failed with status ${status ?? "(missing)"}${detail}`;
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
child.stdout?.setEncoding("utf8");
|
|
430
|
+
child.stdout?.on("data", (chunk: string) => {
|
|
431
|
+
stdoutBuffer += chunk;
|
|
432
|
+
const parts = stdoutBuffer.split(/\r?\n/);
|
|
433
|
+
stdoutBuffer = parts.pop() ?? "";
|
|
434
|
+
if (stdoutBuffer.length > ADVISOR_MAX_STDOUT_LINE_CHARS || parts.some((line) => line.length > ADVISOR_MAX_STDOUT_LINE_CHARS)) {
|
|
435
|
+
protocolError ??= `Agy emitted a stdout line over ${ADVISOR_MAX_STDOUT_LINE_CHARS} characters; the stream is unparseable.`;
|
|
436
|
+
stdoutBuffer = "";
|
|
437
|
+
attempt.stop("protocol");
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
for (const line of parts) {
|
|
441
|
+
try {
|
|
442
|
+
handleLine(line);
|
|
443
|
+
} catch (error) {
|
|
444
|
+
protocolError ??= errorMessage(error);
|
|
445
|
+
attempt.stop("protocol");
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
const { code, exitSignal } = await attempt.closed;
|
|
452
|
+
attempt.dispose();
|
|
453
|
+
if (stdoutBuffer.trim() && !sawTerminal) {
|
|
454
|
+
try {
|
|
455
|
+
handleLine(stdoutBuffer);
|
|
456
|
+
} catch (error) {
|
|
457
|
+
protocolError ??= errorMessage(error);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const cause = attempt.causeOf();
|
|
462
|
+
if (cause === "cancelled") return { status: "cancelled", message: "Canceled." };
|
|
463
|
+
if (cause === "timeout") {
|
|
464
|
+
return { status: "timeout", message: "Agy timed out during the advisor consultation. Run `/bro doctor` for setup help." };
|
|
465
|
+
}
|
|
466
|
+
if (protocolError) return { status: "failure", message: withDoctor(protocolError) };
|
|
467
|
+
if (processError) return { status: "failure", message: processStartMessage(processError as NodeJS.ErrnoException) };
|
|
468
|
+
if (exitSignal || code === null) return { status: "failure", message: unexpectedSignalMessage(exitSignal) };
|
|
469
|
+
if (!sawTerminal) {
|
|
470
|
+
const hint = advisorFlagErrorHint(stderr);
|
|
471
|
+
return {
|
|
472
|
+
status: "failure",
|
|
473
|
+
message: withDoctor(hint ?? (stderr.trim() ? `Agy exited without a terminal result event: ${stderr.trim()}` : "Agy exited without a terminal result event.")),
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
if (terminalError) return { status: "failure", message: withDoctor(terminalError) };
|
|
477
|
+
if (code !== 0) return { status: "failure", message: agyFailureMessage("complete the advisor consultation", { code, killed: false, stderr }) };
|
|
478
|
+
|
|
479
|
+
const text = final?.trim();
|
|
480
|
+
if (!text) return { status: "failure", message: withDoctor(stderr.trim() || "Agy returned no advice.") };
|
|
481
|
+
return { status: "success", text };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Single-attempt executor shared by all four features. Never retries (retries are feature-owned,
|
|
485
|
+
// e.g. advisor's 3-attempt backoff in bro.ts); never spawns a pre-aborted request; on cancellation,
|
|
486
|
+
// host deadline, or a protocol failure, stops the whole POSIX process group (SIGTERM, then SIGKILL
|
|
487
|
+
// after a bounded grace period) before resolving. `options` is for offline tests only -- production
|
|
488
|
+
// callers never override the deadline or kill-escalation delay.
|
|
489
|
+
export async function execute(
|
|
490
|
+
request: BackendRequest,
|
|
491
|
+
selection: AgySelection,
|
|
492
|
+
signal: AbortSignal,
|
|
493
|
+
onProgress?: BackendOnProgress,
|
|
494
|
+
options?: BackendExecuteOptions,
|
|
495
|
+
): Promise<BackendOutcome> {
|
|
496
|
+
if (
|
|
497
|
+
(request.access === "workspace-full" && !request.cwd?.trim()) ||
|
|
498
|
+
(request.feature === "advisor" && request.access !== "workspace-full") ||
|
|
499
|
+
((request.feature === "explain" || request.feature === "show") && request.access !== "restricted") ||
|
|
500
|
+
(request.feature !== "btw" && request.continuation)
|
|
501
|
+
) return { status: "failure", message: "Unsupported execution request: check feature access, workspace cwd and continuation." };
|
|
502
|
+
const killEscalationMs = options?.killEscalationMs ?? DEFAULT_KILL_ESCALATION_MS;
|
|
503
|
+
if (request.feature === "advisor") {
|
|
504
|
+
return executeAdvisorStdin(request, selection, signal, onProgress, killEscalationMs, options?.deadlineMs);
|
|
505
|
+
}
|
|
506
|
+
return executeArgvPrint(request, selection, signal, onProgress, killEscalationMs, options?.deadlineMs);
|
|
507
|
+
}
|
package/bro.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { lookup } from "node:dns/promises";
|
|
4
4
|
import { mkdir, mkdtemp, readdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
|
|
@@ -7,7 +7,6 @@ import { request as httpsRequest } from "node:https";
|
|
|
7
7
|
import { BlockList, isIP } from "node:net";
|
|
8
8
|
import { homedir, tmpdir } from "node:os";
|
|
9
9
|
import { extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
10
|
-
import { createInterface } from "node:readline";
|
|
11
10
|
import { stripVTControlCharacters } from "node:util";
|
|
12
11
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
13
12
|
import { Container, Editor, Input, Markdown, SettingsList, SelectList, Text, matchesKey, truncateToWidth, visibleWidth, type Component, type EditorTheme, type Focusable, type SelectItem, type SettingItem, type TUI } from "@earendil-works/pi-tui";
|
|
@@ -18,6 +17,17 @@ import mammoth from "mammoth";
|
|
|
18
17
|
import { Type } from "typebox";
|
|
19
18
|
import { extractText } from "unpdf";
|
|
20
19
|
import { BRO_MODES, DEFAULT_BRO_MODE, buildAdvisorPrompt, buildBtwPrompt, buildDefaultPrompt, buildShowPrompt, parseBroMode, type BroMode } from "./prompt.ts";
|
|
20
|
+
import {
|
|
21
|
+
agyFailureMessage,
|
|
22
|
+
agySelection,
|
|
23
|
+
advisorFlagErrorHint,
|
|
24
|
+
execute as executeBackend,
|
|
25
|
+
parseBtwAgyLine,
|
|
26
|
+
type AgySelection,
|
|
27
|
+
type BackendProgress,
|
|
28
|
+
} from "./backend.ts";
|
|
29
|
+
|
|
30
|
+
export { agyFailureMessage, agySelection, advisorFlagErrorHint, parseBtwAgyLine };
|
|
21
31
|
|
|
22
32
|
const AGENT_DIR = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
|
23
33
|
const ENV_MODEL = process.env.PI_BRO_MODEL?.trim();
|
|
@@ -73,14 +83,6 @@ type AgyModelFamily = {
|
|
|
73
83
|
efforts: AgyEffort[];
|
|
74
84
|
variants: Array<{ id: string; effort?: AgyEffort }>;
|
|
75
85
|
};
|
|
76
|
-
type AgyEvent = {
|
|
77
|
-
event?: string;
|
|
78
|
-
conversation_id?: string;
|
|
79
|
-
init?: { model?: string; cwd?: string; permission_mode?: string; tools?: unknown };
|
|
80
|
-
step_update?: { step_type?: string; text_delta?: unknown };
|
|
81
|
-
result?: { status?: string; response?: unknown; error?: unknown; conversation_id?: string };
|
|
82
|
-
};
|
|
83
|
-
|
|
84
86
|
export function wheelDelta(data: string): number {
|
|
85
87
|
const match = /^\x1b\[<(\d+);\d+;\d+[Mm]$/.exec(data);
|
|
86
88
|
if (!match) return 0;
|
|
@@ -484,16 +486,6 @@ export async function extractWebPage(input: string, signal?: AbortSignal): Promi
|
|
|
484
486
|
}
|
|
485
487
|
}
|
|
486
488
|
|
|
487
|
-
export function agyFailureMessage(
|
|
488
|
-
action: string,
|
|
489
|
-
result: { code: number; killed: boolean; stderr: string },
|
|
490
|
-
): string {
|
|
491
|
-
if (result.killed) return `Agy timed out while trying to ${action}. Run \`/bro doctor\` for setup help.`;
|
|
492
|
-
const detail = result.stderr.trim();
|
|
493
|
-
if (detail) return `Agy could not ${action}: ${detail}\n\nRun \`/bro doctor\` for setup help.`;
|
|
494
|
-
return `Agy could not ${action}. Make sure Agy is installed and signed in, then run \`/bro doctor\`.`;
|
|
495
|
-
}
|
|
496
|
-
|
|
497
489
|
function parseModelEffortPair(value: unknown, context: string): ModelEffortPair {
|
|
498
490
|
if (!isRecord(value) || typeof value.model !== "string" || !value.model.trim() || !EFFORTS.some((effort) => effort === value.effort)) {
|
|
499
491
|
throw new Error(`${context} must contain a model and effort set to "default", "low", "medium", or "high".`);
|
|
@@ -752,15 +744,6 @@ function preferredEffort(family: AgyModelFamily): BroEffort {
|
|
|
752
744
|
return family.efforts.includes("low") ? "low" : (family.efforts[0] ?? "default");
|
|
753
745
|
}
|
|
754
746
|
|
|
755
|
-
export function agySelection(pair: ModelEffortPair): { model: string; effort?: AgyEffort } {
|
|
756
|
-
if (pair.effort === "default") return { model: pair.model };
|
|
757
|
-
const suffix = (["low", "medium", "high"] as const).find((effort) => pair.model.endsWith(`-${effort}`));
|
|
758
|
-
return {
|
|
759
|
-
model: suffix ? pair.model.slice(0, -suffix.length - 1) : pair.model,
|
|
760
|
-
effort: pair.effort,
|
|
761
|
-
};
|
|
762
|
-
}
|
|
763
|
-
|
|
764
747
|
async function checkAgyUsage(pi: ExtensionAPI, signal: AbortSignal): Promise<string> {
|
|
765
748
|
const runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
|
|
766
749
|
try {
|
|
@@ -1075,32 +1058,6 @@ async function promptFor(response: string, mode: BroMode): Promise<{ text: strin
|
|
|
1075
1058
|
return { text: parts.join(JSON.stringify(response)), custom: true };
|
|
1076
1059
|
}
|
|
1077
1060
|
|
|
1078
|
-
function parseAgyLine(line: string): { delta?: string; result?: string } {
|
|
1079
|
-
let event: AgyEvent;
|
|
1080
|
-
try {
|
|
1081
|
-
event = JSON.parse(line) as AgyEvent;
|
|
1082
|
-
} catch {
|
|
1083
|
-
throw new Error("Agy returned invalid streaming data.");
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
if (
|
|
1087
|
-
event.event === "step_update" &&
|
|
1088
|
-
event.step_update?.step_type === "agent_response" &&
|
|
1089
|
-
typeof event.step_update.text_delta === "string"
|
|
1090
|
-
) {
|
|
1091
|
-
return { delta: event.step_update.text_delta };
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
if (event.event === "result") {
|
|
1095
|
-
if (event.result?.status !== "SUCCESS" || typeof event.result.response !== "string") {
|
|
1096
|
-
throw new Error("Agy did not complete the explanation successfully.");
|
|
1097
|
-
}
|
|
1098
|
-
return { result: event.result.response };
|
|
1099
|
-
}
|
|
1100
|
-
|
|
1101
|
-
return {};
|
|
1102
|
-
}
|
|
1103
|
-
|
|
1104
1061
|
async function simplify(
|
|
1105
1062
|
response: string,
|
|
1106
1063
|
signal: AbortSignal,
|
|
@@ -1117,114 +1074,40 @@ async function runShowExplanation(
|
|
|
1117
1074
|
settings: BroSettings,
|
|
1118
1075
|
onProgress?: (text: string) => void,
|
|
1119
1076
|
): Promise<string> {
|
|
1120
|
-
return runAgyText(buildShowPrompt(transcript, steering), agySelection(capabilityPair(settings, "show")), signal, onProgress);
|
|
1077
|
+
return runAgyText(buildShowPrompt(transcript, steering), agySelection(capabilityPair(settings, "show")), signal, onProgress, "show");
|
|
1121
1078
|
}
|
|
1122
1079
|
|
|
1080
|
+
// Thin presentation-boundary wrapper around the shared backend: coalesces raw text progress to the
|
|
1081
|
+
// existing 75ms cadence (unchanged from before the backend extraction) and translates the backend's
|
|
1082
|
+
// tagged outcome back into this function's existing throw-on-failure contract.
|
|
1123
1083
|
async function runAgyText(
|
|
1124
1084
|
prompt: string,
|
|
1125
|
-
selection:
|
|
1085
|
+
selection: AgySelection,
|
|
1126
1086
|
signal: AbortSignal,
|
|
1127
1087
|
onProgress?: (text: string) => void,
|
|
1088
|
+
feature: "explain" | "show" = "explain",
|
|
1128
1089
|
): Promise<string> {
|
|
1129
|
-
const runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
|
|
1130
1090
|
let updateTimer: ReturnType<typeof setTimeout> | undefined;
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
selection.model,
|
|
1142
|
-
...(selection.effort ? ["--effort", selection.effort] : []),
|
|
1143
|
-
"--print-timeout",
|
|
1144
|
-
"2m",
|
|
1145
|
-
"--print",
|
|
1146
|
-
prompt,
|
|
1147
|
-
],
|
|
1148
|
-
{
|
|
1149
|
-
cwd: runDirectory,
|
|
1150
|
-
signal,
|
|
1151
|
-
timeout: 125_000,
|
|
1152
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
1153
|
-
windowsHide: true,
|
|
1154
|
-
},
|
|
1155
|
-
);
|
|
1156
|
-
|
|
1157
|
-
let processError: Error | undefined;
|
|
1158
|
-
let stderr = "";
|
|
1159
|
-
let partial = "";
|
|
1160
|
-
let final = "";
|
|
1161
|
-
let parseError: Error | undefined;
|
|
1162
|
-
|
|
1163
|
-
child.stderr.setEncoding("utf8");
|
|
1164
|
-
child.stderr.on("data", (chunk: string) => {
|
|
1165
|
-
stderr += chunk;
|
|
1166
|
-
});
|
|
1167
|
-
child.once("error", (error) => {
|
|
1168
|
-
processError = error;
|
|
1169
|
-
});
|
|
1170
|
-
|
|
1171
|
-
const closed = new Promise<{ code: number | null; exitSignal: NodeJS.Signals | null }>((resolve) => {
|
|
1172
|
-
child.once("close", (code, exitSignal) => resolve({ code, exitSignal }));
|
|
1173
|
-
});
|
|
1174
|
-
|
|
1175
|
-
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
1176
|
-
try {
|
|
1177
|
-
for await (const line of lines) {
|
|
1178
|
-
if (!line.trim()) continue;
|
|
1179
|
-
try {
|
|
1180
|
-
const event = parseAgyLine(line);
|
|
1181
|
-
if (event.delta) {
|
|
1182
|
-
partial += event.delta;
|
|
1183
|
-
if (onProgress && !updateTimer) {
|
|
1184
|
-
updateTimer = setTimeout(() => {
|
|
1185
|
-
updateTimer = undefined;
|
|
1186
|
-
if (!signal.aborted) onProgress(partial);
|
|
1187
|
-
}, 75);
|
|
1188
|
-
}
|
|
1189
|
-
}
|
|
1190
|
-
if (event.result !== undefined) final = event.result;
|
|
1191
|
-
} catch (error) {
|
|
1192
|
-
parseError = error instanceof Error ? error : new Error(String(error));
|
|
1193
|
-
child.kill();
|
|
1194
|
-
break;
|
|
1091
|
+
let latest: string | undefined;
|
|
1092
|
+
const throttledProgress = onProgress
|
|
1093
|
+
? (progress: BackendProgress) => {
|
|
1094
|
+
if (progress.kind !== "text") return;
|
|
1095
|
+
latest = progress.text;
|
|
1096
|
+
if (!updateTimer) {
|
|
1097
|
+
updateTimer = setTimeout(() => {
|
|
1098
|
+
updateTimer = undefined;
|
|
1099
|
+
if (!signal.aborted && latest !== undefined) onProgress(latest);
|
|
1100
|
+
}, 75);
|
|
1195
1101
|
}
|
|
1196
1102
|
}
|
|
1197
|
-
|
|
1198
|
-
lines.close();
|
|
1199
|
-
}
|
|
1200
|
-
|
|
1201
|
-
const { code, exitSignal } = await closed;
|
|
1202
|
-
if (signal.aborted) throw new Error("Canceled.");
|
|
1203
|
-
if (parseError) throw new Error(withDoctor(parseError));
|
|
1204
|
-
if (processError) {
|
|
1205
|
-
const missing = (processError as NodeJS.ErrnoException).code === "ENOENT";
|
|
1206
|
-
throw new Error(
|
|
1207
|
-
missing
|
|
1208
|
-
? "Agy could not start. Make sure Agy is installed and on PATH, then run `/bro doctor`."
|
|
1209
|
-
: `Agy could not start: ${processError.message}\n\nRun \`/bro doctor\` for setup help.`,
|
|
1210
|
-
);
|
|
1211
|
-
}
|
|
1212
|
-
if (exitSignal || code === null) {
|
|
1213
|
-
throw new Error("Agy timed out while simplifying the response. Run `/bro doctor` for setup help.");
|
|
1214
|
-
}
|
|
1215
|
-
if (code !== 0) {
|
|
1216
|
-
throw new Error(agyFailureMessage("simplify the response", { code, killed: false, stderr }));
|
|
1217
|
-
}
|
|
1218
|
-
|
|
1219
|
-
const text = final.trim();
|
|
1220
|
-
if (!text) {
|
|
1221
|
-
throw new Error(withDoctor(stderr.trim() || "Agy returned no final explanation."));
|
|
1222
|
-
}
|
|
1103
|
+
: undefined;
|
|
1223
1104
|
|
|
1224
|
-
|
|
1105
|
+
try {
|
|
1106
|
+
const outcome = await executeBackend({ feature, prompt, access: "restricted" }, selection, signal, throttledProgress);
|
|
1107
|
+
if (outcome.status === "success") return outcome.text;
|
|
1108
|
+
throw new Error(outcome.message);
|
|
1225
1109
|
} finally {
|
|
1226
1110
|
if (updateTimer) clearTimeout(updateTimer);
|
|
1227
|
-
await rm(runDirectory, { recursive: true, force: true });
|
|
1228
1111
|
}
|
|
1229
1112
|
}
|
|
1230
1113
|
|
|
@@ -1354,43 +1237,6 @@ export function buildAdvisorSnapshot(ctx: ExtensionContext, pi: ExtensionAPI): {
|
|
|
1354
1237
|
return { text, hadCompaction };
|
|
1355
1238
|
}
|
|
1356
1239
|
|
|
1357
|
-
// Advisor stdin/stream-json transport, confirmed against pi-flow-external's tested agy backend
|
|
1358
|
-
// (src/core/agy.ts, test/agy-backend.test.ts): --input-format stream-json reads one NDJSON
|
|
1359
|
-
// "user" message from stdin instead of a --print argv value (unbounded snapshot size would
|
|
1360
|
-
// otherwise risk ARG_MAX), and --dangerously-skip-permissions gives the advisor real, unprompted
|
|
1361
|
-
// tool access. See docs/plans/2026-09-19-bro-advisor-design.md, "Transport".
|
|
1362
|
-
type AdvisorAgyEvent = {
|
|
1363
|
-
event?: string;
|
|
1364
|
-
step_update?: { tool_name?: unknown; step_type?: unknown; text_delta?: unknown };
|
|
1365
|
-
result?: { status?: unknown; response?: unknown; error?: unknown };
|
|
1366
|
-
};
|
|
1367
|
-
|
|
1368
|
-
// Guards only against a single runaway line with no newline (a protocol break, not a real
|
|
1369
|
-
// response size) — agy's real NDJSON lines are far smaller than this.
|
|
1370
|
-
const ADVISOR_MAX_STDOUT_LINE_CHARS = 2_000_000;
|
|
1371
|
-
|
|
1372
|
-
function parseAdvisorLine(line: string): AdvisorAgyEvent {
|
|
1373
|
-
try {
|
|
1374
|
-
return JSON.parse(line) as AdvisorAgyEvent;
|
|
1375
|
-
} catch {
|
|
1376
|
-
throw new Error("Agy emitted invalid stream-json output.");
|
|
1377
|
-
}
|
|
1378
|
-
}
|
|
1379
|
-
|
|
1380
|
-
// Only a tool_name or a user-facing agent_response/assistant text_delta becomes an activity label
|
|
1381
|
-
// -- hidden reasoning/thinking step_types and any other shape stay unreported, never leaked into
|
|
1382
|
-
// progress.
|
|
1383
|
-
function advisorActivityFromEvent(event: AdvisorAgyEvent): string | undefined {
|
|
1384
|
-
if (event.event !== "step_update" || !event.step_update || typeof event.step_update !== "object") return undefined;
|
|
1385
|
-
const update = event.step_update;
|
|
1386
|
-
if (typeof update.tool_name === "string" && update.tool_name.trim()) return update.tool_name.trim();
|
|
1387
|
-
const isUserFacingText = update.step_type === "agent_response" || update.step_type === "assistant";
|
|
1388
|
-
if (isUserFacingText && typeof update.text_delta === "string" && update.text_delta.trim()) {
|
|
1389
|
-
return update.text_delta.split("\n").find((line) => line.trim())?.trim();
|
|
1390
|
-
}
|
|
1391
|
-
return undefined;
|
|
1392
|
-
}
|
|
1393
|
-
|
|
1394
1240
|
const MAX_ADVISOR_ACTIVITY_LINES = 4;
|
|
1395
1241
|
const ADVISOR_ACTIVITY_PREVIEW_CHARS = 100;
|
|
1396
1242
|
|
|
@@ -1398,168 +1244,34 @@ function advisorActivityPreview(label: string): string {
|
|
|
1398
1244
|
return label.length > ADVISOR_ACTIVITY_PREVIEW_CHARS ? `${label.slice(0, ADVISOR_ACTIVITY_PREVIEW_CHARS).trimEnd()}…` : label;
|
|
1399
1245
|
}
|
|
1400
1246
|
|
|
1401
|
-
// Older agy CLIs reject --input-format with Go's flag-package usage dump and exit before running
|
|
1402
|
-
// the agent at all (no terminal result event). Turn that into an actionable version hint instead
|
|
1403
|
-
// of a bare "no terminal result" error.
|
|
1404
|
-
export function advisorFlagErrorHint(stderr: string): string | undefined {
|
|
1405
|
-
const match = /flags? provided but not defined: -([a-z0-9-]+)/i.exec(stderr);
|
|
1406
|
-
if (!match) return undefined;
|
|
1407
|
-
return `flag provided but not defined: -${match[1]} (installed Agy CLI is too old; the advisor needs Agy 1.1.15+ for --input-format stream-json — run \`agy update\`, then \`/bro doctor\`)`;
|
|
1408
|
-
}
|
|
1409
|
-
|
|
1410
1247
|
export type AdvisorActivityCallback = (label: string, timestamp: number) => void;
|
|
1411
1248
|
|
|
1249
|
+
// Thin wrapper around the shared backend: advisor's stdin/stream-json transport, activity parsing,
|
|
1250
|
+
// and process lifecycle now live in backend.ts (see docs/plans/2026-09-19-bro-advisor-design.md,
|
|
1251
|
+
// "Transport" for why stdin rather than --print). This function keeps its exact existing
|
|
1252
|
+
// signature/throw contract -- it is called directly by tests and by runAdvisorWithRetries below,
|
|
1253
|
+
// which owns the 3-attempt retry/backoff policy the backend itself never performs.
|
|
1412
1254
|
export async function runAdvisorConsultation(
|
|
1413
1255
|
prompt: string,
|
|
1414
|
-
selection:
|
|
1256
|
+
selection: AgySelection,
|
|
1415
1257
|
cwd: string,
|
|
1416
1258
|
signal: AbortSignal,
|
|
1417
1259
|
killEscalationMs = 5_000,
|
|
1418
1260
|
onActivity?: AdvisorActivityCallback,
|
|
1419
1261
|
): Promise<string> {
|
|
1420
|
-
const
|
|
1421
|
-
"
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
"--model", selection.model,
|
|
1428
|
-
...(selection.effort ? ["--effort", selection.effort] : []),
|
|
1429
|
-
"--print-timeout", "10m",
|
|
1430
|
-
],
|
|
1431
|
-
// detached: true (POSIX only) makes the child its own process-group leader, so a signal to
|
|
1432
|
-
// -child.pid below reaches it AND every grandchild it spawned -- not just the immediate
|
|
1433
|
-
// process. Without this, killing only the immediate child can leave a grandchild holding the
|
|
1434
|
-
// inherited stdio pipes open, and the "close" event this function waits on never fires until
|
|
1435
|
-
// that orphan exits on its own.
|
|
1436
|
-
{ cwd, timeout: 610_000, stdio: ["pipe", "pipe", "pipe"], windowsHide: true, detached: process.platform !== "win32" },
|
|
1437
|
-
);
|
|
1438
|
-
|
|
1439
|
-
let processError: Error | undefined;
|
|
1440
|
-
let stderr = "";
|
|
1441
|
-
let final: string | undefined;
|
|
1442
|
-
let terminalError: string | undefined;
|
|
1443
|
-
let protocolError: string | undefined;
|
|
1444
|
-
let sawTerminal = false;
|
|
1445
|
-
let stdoutBuffer = "";
|
|
1446
|
-
|
|
1447
|
-
// Signal the whole process group when possible so a misbehaving grandchild dies too, not just
|
|
1448
|
-
// the immediate agy process; child.kill() alone only ever reaches the immediate child.
|
|
1449
|
-
const killAdvisorChild = (signalName: NodeJS.Signals) => {
|
|
1450
|
-
if (process.platform !== "win32" && typeof child.pid === "number") {
|
|
1451
|
-
try {
|
|
1452
|
-
process.kill(-child.pid, signalName);
|
|
1453
|
-
return;
|
|
1454
|
-
} catch {
|
|
1455
|
-
// Group may already be gone (e.g. the child already exited) -- fall through.
|
|
1456
|
-
}
|
|
1457
|
-
}
|
|
1458
|
-
child.kill(signalName);
|
|
1459
|
-
};
|
|
1460
|
-
|
|
1461
|
-
// Relying on spawn({signal}) alone only ever sends one SIGTERM and gives up if the child (or a
|
|
1462
|
-
// misbehaving grandchild it spawned) ignores it, hanging this promise forever. Escalate to
|
|
1463
|
-
// SIGKILL -- which cannot be ignored -- if the child hasn't exited shortly after.
|
|
1464
|
-
let killEscalationTimer: ReturnType<typeof setTimeout> | undefined;
|
|
1465
|
-
const onAbort = () => {
|
|
1466
|
-
killAdvisorChild("SIGTERM");
|
|
1467
|
-
killEscalationTimer = setTimeout(() => {
|
|
1468
|
-
killAdvisorChild("SIGKILL");
|
|
1469
|
-
}, killEscalationMs);
|
|
1470
|
-
};
|
|
1471
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
1472
|
-
if (signal.aborted) onAbort();
|
|
1473
|
-
|
|
1474
|
-
child.stderr.setEncoding("utf8");
|
|
1475
|
-
child.stderr.on("data", (chunk: string) => {
|
|
1476
|
-
stderr += chunk;
|
|
1477
|
-
});
|
|
1478
|
-
child.once("error", (error) => {
|
|
1479
|
-
processError = error;
|
|
1480
|
-
});
|
|
1481
|
-
child.stdin.on("error", () => {
|
|
1482
|
-
// agy exiting before it reads stdin is reported through the close/error path below.
|
|
1483
|
-
});
|
|
1484
|
-
child.stdin.end(`${JSON.stringify({ event: "user", message: { content: prompt } })}\n`);
|
|
1485
|
-
|
|
1486
|
-
const handleLine = (line: string) => {
|
|
1487
|
-
if (!line.trim() || sawTerminal) return;
|
|
1488
|
-
const event = parseAdvisorLine(line);
|
|
1489
|
-
const activity = advisorActivityFromEvent(event);
|
|
1490
|
-
if (activity) onActivity?.(activity, Date.now());
|
|
1491
|
-
if (event.event !== "result") return;
|
|
1492
|
-
sawTerminal = true;
|
|
1493
|
-
const result = event.result;
|
|
1494
|
-
const status = typeof result?.status === "string" ? result.status.trim().toUpperCase() : undefined;
|
|
1495
|
-
if (status === "SUCCESS" && typeof result?.response === "string") {
|
|
1496
|
-
final = result.response;
|
|
1497
|
-
} else {
|
|
1498
|
-
const detail = typeof result?.error === "string" && result.error.trim() ? `: ${result.error.trim()}` : "";
|
|
1499
|
-
terminalError = `Agy failed with status ${status ?? "(missing)"}${detail}`;
|
|
1500
|
-
}
|
|
1501
|
-
};
|
|
1502
|
-
|
|
1503
|
-
child.stdout.setEncoding("utf8");
|
|
1504
|
-
child.stdout.on("data", (chunk: string) => {
|
|
1505
|
-
stdoutBuffer += chunk;
|
|
1506
|
-
const parts = stdoutBuffer.split(/\r?\n/);
|
|
1507
|
-
stdoutBuffer = parts.pop() ?? "";
|
|
1508
|
-
if (stdoutBuffer.length > ADVISOR_MAX_STDOUT_LINE_CHARS || parts.some((line) => line.length > ADVISOR_MAX_STDOUT_LINE_CHARS)) {
|
|
1509
|
-
protocolError ??= `Agy emitted a stdout line over ${ADVISOR_MAX_STDOUT_LINE_CHARS} characters; the stream is unparseable.`;
|
|
1510
|
-
stdoutBuffer = "";
|
|
1511
|
-
killAdvisorChild("SIGTERM");
|
|
1512
|
-
return;
|
|
1513
|
-
}
|
|
1514
|
-
for (const line of parts) {
|
|
1515
|
-
try {
|
|
1516
|
-
handleLine(line);
|
|
1517
|
-
} catch (error) {
|
|
1518
|
-
protocolError ??= errorMessage(error);
|
|
1519
|
-
killAdvisorChild("SIGTERM");
|
|
1520
|
-
return;
|
|
1521
|
-
}
|
|
1522
|
-
}
|
|
1523
|
-
});
|
|
1524
|
-
|
|
1525
|
-
const { code, exitSignal } = await new Promise<{ code: number | null; exitSignal: NodeJS.Signals | null }>((resolve) => {
|
|
1526
|
-
child.once("close", (code, exitSignal) => {
|
|
1527
|
-
if (stdoutBuffer.trim() && !sawTerminal) {
|
|
1528
|
-
try {
|
|
1529
|
-
handleLine(stdoutBuffer);
|
|
1530
|
-
} catch (error) {
|
|
1531
|
-
protocolError ??= errorMessage(error);
|
|
1262
|
+
const outcome = await executeBackend(
|
|
1263
|
+
{ feature: "advisor", prompt, access: "workspace-full", cwd },
|
|
1264
|
+
selection,
|
|
1265
|
+
signal,
|
|
1266
|
+
onActivity
|
|
1267
|
+
? (progress) => {
|
|
1268
|
+
if (progress.kind === "activity") onActivity(progress.label, progress.timestamp);
|
|
1532
1269
|
}
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
if (killEscalationTimer) clearTimeout(killEscalationTimer);
|
|
1539
|
-
|
|
1540
|
-
if (signal.aborted) throw new Error("Canceled.");
|
|
1541
|
-
if (protocolError) throw new Error(withDoctor(protocolError));
|
|
1542
|
-
if (processError) {
|
|
1543
|
-
const missing = (processError as NodeJS.ErrnoException).code === "ENOENT";
|
|
1544
|
-
throw new Error(
|
|
1545
|
-
missing
|
|
1546
|
-
? "Agy could not start. Make sure Agy is installed and on PATH, then run `/bro doctor`."
|
|
1547
|
-
: `Agy could not start: ${processError.message}\n\nRun \`/bro doctor\` for setup help.`,
|
|
1548
|
-
);
|
|
1549
|
-
}
|
|
1550
|
-
if (exitSignal || code === null) {
|
|
1551
|
-
throw new Error("Agy timed out during the advisor consultation. Run `/bro doctor` for setup help.");
|
|
1552
|
-
}
|
|
1553
|
-
if (!sawTerminal) {
|
|
1554
|
-
const hint = advisorFlagErrorHint(stderr);
|
|
1555
|
-
throw new Error(withDoctor(hint ?? (stderr.trim() ? `Agy exited without a terminal result event: ${stderr.trim()}` : "Agy exited without a terminal result event.")));
|
|
1556
|
-
}
|
|
1557
|
-
if (terminalError) throw new Error(withDoctor(terminalError));
|
|
1558
|
-
if (code !== 0) throw new Error(agyFailureMessage("complete the advisor consultation", { code, killed: false, stderr }));
|
|
1559
|
-
|
|
1560
|
-
const text = final?.trim();
|
|
1561
|
-
if (!text) throw new Error(withDoctor(stderr.trim() || "Agy returned no advice."));
|
|
1562
|
-
return text;
|
|
1270
|
+
: undefined,
|
|
1271
|
+
{ killEscalationMs },
|
|
1272
|
+
);
|
|
1273
|
+
if (outcome.status === "success") return outcome.text;
|
|
1274
|
+
throw new Error(outcome.message);
|
|
1563
1275
|
}
|
|
1564
1276
|
|
|
1565
1277
|
function advisorDelay(ms: number, signal: AbortSignal, onTick?: (remainingMs: number) => void): Promise<void> {
|
|
@@ -1590,7 +1302,7 @@ function advisorDelay(ms: number, signal: AbortSignal, onTick?: (remainingMs: nu
|
|
|
1590
1302
|
|
|
1591
1303
|
export type AdvisorConsult = (
|
|
1592
1304
|
prompt: string,
|
|
1593
|
-
selection:
|
|
1305
|
+
selection: AgySelection,
|
|
1594
1306
|
cwd: string,
|
|
1595
1307
|
signal: AbortSignal,
|
|
1596
1308
|
killEscalationMs?: number,
|
|
@@ -1636,7 +1348,7 @@ const ADVISOR_ACTIVITY_THROTTLE_MS = 250;
|
|
|
1636
1348
|
// --conversation, even across retries.
|
|
1637
1349
|
export async function runAdvisorWithRetries(
|
|
1638
1350
|
prompt: string,
|
|
1639
|
-
selection:
|
|
1351
|
+
selection: AgySelection,
|
|
1640
1352
|
cwd: string,
|
|
1641
1353
|
signal: AbortSignal,
|
|
1642
1354
|
consult: AdvisorConsult = runAdvisorConsultation,
|
|
@@ -2571,27 +2283,6 @@ export function formatBtwTranscript(turns: readonly BtwTurn[]): string {
|
|
|
2571
2283
|
.join("\n\n---\n\n");
|
|
2572
2284
|
}
|
|
2573
2285
|
|
|
2574
|
-
export function parseBtwAgyLine(line: string): { delta?: string; result?: string; conversationId?: string; error?: string } {
|
|
2575
|
-
let event: AgyEvent;
|
|
2576
|
-
try {
|
|
2577
|
-
event = JSON.parse(line) as AgyEvent;
|
|
2578
|
-
} catch {
|
|
2579
|
-
throw new Error("Agy returned invalid streaming data.");
|
|
2580
|
-
}
|
|
2581
|
-
const conversationId = event.conversation_id ?? event.result?.conversation_id;
|
|
2582
|
-
if (event.event === "step_update" && event.step_update?.step_type === "agent_response" && typeof event.step_update.text_delta === "string") {
|
|
2583
|
-
return { delta: event.step_update.text_delta, conversationId };
|
|
2584
|
-
}
|
|
2585
|
-
if (event.event === "result") {
|
|
2586
|
-
if (event.result?.status !== "SUCCESS" || typeof event.result.response !== "string") {
|
|
2587
|
-
const detail = typeof event.result?.error === "string" ? event.result.error : "Agy did not complete the turn successfully.";
|
|
2588
|
-
return { error: detail, conversationId };
|
|
2589
|
-
}
|
|
2590
|
-
return { result: event.result.response, conversationId };
|
|
2591
|
-
}
|
|
2592
|
-
return { conversationId };
|
|
2593
|
-
}
|
|
2594
|
-
|
|
2595
2286
|
export type BtwComposerAction =
|
|
2596
2287
|
| { kind: "clear" }
|
|
2597
2288
|
| { kind: "retry" }
|
|
@@ -2617,112 +2308,50 @@ export function parseBtwComposerCommand(value: string): BtwComposerAction {
|
|
|
2617
2308
|
return { kind: "question", text: command };
|
|
2618
2309
|
}
|
|
2619
2310
|
|
|
2311
|
+
// Thin presentation-boundary wrapper around the shared backend: coalesces raw text progress to the
|
|
2312
|
+
// existing 75ms cadence and translates the backend's tagged outcome back into this function's
|
|
2313
|
+
// existing throw-on-failure / { text, conversationId } contract. A conversationId is only ever
|
|
2314
|
+
// returned on success (see backend.ts's continuation handling), preserving current behavior on
|
|
2315
|
+
// failed turns.
|
|
2620
2316
|
async function runBtwTurn(
|
|
2621
2317
|
prompt: string,
|
|
2622
|
-
selection:
|
|
2318
|
+
selection: AgySelection,
|
|
2623
2319
|
options: { full: boolean; cwd: string; conversationId?: string },
|
|
2624
2320
|
signal: AbortSignal,
|
|
2625
2321
|
onProgress?: (text: string) => void,
|
|
2626
2322
|
): Promise<{ text: string; conversationId?: string }> {
|
|
2627
|
-
const runDirectory = options.full ? undefined : await mkdtemp(join(tmpdir(), "pi-bro-"));
|
|
2628
2323
|
let updateTimer: ReturnType<typeof setTimeout> | undefined;
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
];
|
|
2640
|
-
const child = spawn("agy", args, {
|
|
2641
|
-
cwd: options.full ? options.cwd : runDirectory,
|
|
2642
|
-
signal,
|
|
2643
|
-
timeout: options.full ? 610_000 : 130_000,
|
|
2644
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
2645
|
-
windowsHide: true,
|
|
2646
|
-
});
|
|
2647
|
-
|
|
2648
|
-
let processError: Error | undefined;
|
|
2649
|
-
let stderr = "";
|
|
2650
|
-
let partial = "";
|
|
2651
|
-
let final = "";
|
|
2652
|
-
let conversationId = options.conversationId;
|
|
2653
|
-
let parseError: Error | undefined;
|
|
2654
|
-
|
|
2655
|
-
child.stderr.setEncoding("utf8");
|
|
2656
|
-
child.stderr.on("data", (chunk: string) => {
|
|
2657
|
-
stderr += chunk;
|
|
2658
|
-
});
|
|
2659
|
-
child.once("error", (error) => {
|
|
2660
|
-
processError = error;
|
|
2661
|
-
});
|
|
2662
|
-
|
|
2663
|
-
const closed = new Promise<{ code: number | null; exitSignal: NodeJS.Signals | null }>((resolve) => {
|
|
2664
|
-
child.once("close", (code, exitSignal) => resolve({ code, exitSignal }));
|
|
2665
|
-
});
|
|
2666
|
-
|
|
2667
|
-
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
2668
|
-
try {
|
|
2669
|
-
for await (const line of lines) {
|
|
2670
|
-
if (!line.trim()) continue;
|
|
2671
|
-
try {
|
|
2672
|
-
const event = parseBtwAgyLine(line);
|
|
2673
|
-
if (event.conversationId) conversationId = event.conversationId;
|
|
2674
|
-
if (event.error) {
|
|
2675
|
-
parseError = new Error(event.error);
|
|
2676
|
-
child.kill();
|
|
2677
|
-
break;
|
|
2678
|
-
}
|
|
2679
|
-
if (event.delta) {
|
|
2680
|
-
partial += event.delta;
|
|
2681
|
-
if (onProgress && !updateTimer) {
|
|
2682
|
-
updateTimer = setTimeout(() => {
|
|
2683
|
-
updateTimer = undefined;
|
|
2684
|
-
if (!signal.aborted) onProgress(partial);
|
|
2685
|
-
}, 75);
|
|
2686
|
-
}
|
|
2687
|
-
}
|
|
2688
|
-
if (event.result !== undefined) final = event.result;
|
|
2689
|
-
} catch (error) {
|
|
2690
|
-
parseError = error instanceof Error ? error : new Error(String(error));
|
|
2691
|
-
child.kill();
|
|
2692
|
-
break;
|
|
2324
|
+
let latest: string | undefined;
|
|
2325
|
+
const throttledProgress = onProgress
|
|
2326
|
+
? (progress: BackendProgress) => {
|
|
2327
|
+
if (progress.kind !== "text") return;
|
|
2328
|
+
latest = progress.text;
|
|
2329
|
+
if (!updateTimer) {
|
|
2330
|
+
updateTimer = setTimeout(() => {
|
|
2331
|
+
updateTimer = undefined;
|
|
2332
|
+
if (!signal.aborted && latest !== undefined) onProgress(latest);
|
|
2333
|
+
}, 75);
|
|
2693
2334
|
}
|
|
2694
2335
|
}
|
|
2695
|
-
|
|
2696
|
-
lines.close();
|
|
2697
|
-
}
|
|
2698
|
-
|
|
2699
|
-
const { code, exitSignal } = await closed;
|
|
2700
|
-
if (signal.aborted) throw new Error("Canceled.");
|
|
2701
|
-
if (parseError) throw new Error(withDoctor(parseError));
|
|
2702
|
-
if (processError) {
|
|
2703
|
-
const missing = (processError as NodeJS.ErrnoException).code === "ENOENT";
|
|
2704
|
-
throw new Error(
|
|
2705
|
-
missing
|
|
2706
|
-
? "Agy could not start. Make sure Agy is installed and on PATH, then run `/bro doctor`."
|
|
2707
|
-
: `Agy could not start: ${processError.message}\n\nRun \`/bro doctor\` for setup help.`,
|
|
2708
|
-
);
|
|
2709
|
-
}
|
|
2710
|
-
if (exitSignal || code === null) {
|
|
2711
|
-
throw new Error("Agy timed out during the side conversation. Run `/bro doctor` for setup help.");
|
|
2712
|
-
}
|
|
2713
|
-
if (code !== 0) {
|
|
2714
|
-
throw new Error(agyFailureMessage("answer the side question", { code, killed: false, stderr }));
|
|
2715
|
-
}
|
|
2716
|
-
|
|
2717
|
-
const text = final.trim();
|
|
2718
|
-
if (!text) {
|
|
2719
|
-
throw new Error(withDoctor(stderr.trim() || "Agy returned no answer for the side question."));
|
|
2720
|
-
}
|
|
2336
|
+
: undefined;
|
|
2721
2337
|
|
|
2722
|
-
|
|
2338
|
+
try {
|
|
2339
|
+
const outcome = await executeBackend(
|
|
2340
|
+
{
|
|
2341
|
+
feature: "btw",
|
|
2342
|
+
prompt,
|
|
2343
|
+
access: options.full ? "workspace-full" : "restricted",
|
|
2344
|
+
cwd: options.full ? options.cwd : undefined,
|
|
2345
|
+
continuation: options.conversationId ? { id: options.conversationId } : undefined,
|
|
2346
|
+
},
|
|
2347
|
+
selection,
|
|
2348
|
+
signal,
|
|
2349
|
+
throttledProgress,
|
|
2350
|
+
);
|
|
2351
|
+
if (outcome.status === "success") return { text: outcome.text, conversationId: outcome.continuation?.id };
|
|
2352
|
+
throw new Error(outcome.message);
|
|
2723
2353
|
} finally {
|
|
2724
2354
|
if (updateTimer) clearTimeout(updateTimer);
|
|
2725
|
-
if (runDirectory) await rm(runDirectory, { recursive: true, force: true });
|
|
2726
2355
|
}
|
|
2727
2356
|
}
|
|
2728
2357
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-bro",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.1",
|
|
4
4
|
"description": "An Earendil Pi extension that explains pasted text, assistant responses, local documents, public webpages, and recent session turns (as shapes) in a context-isolated window, opens a sandboxed side conversation with /bro btw, and provides a second-opinion advisor tool for executor agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
],
|
|
36
36
|
"files": [
|
|
37
37
|
"bro.ts",
|
|
38
|
+
"backend.ts",
|
|
38
39
|
"prompt.ts",
|
|
39
40
|
"README.md",
|
|
40
41
|
"CHANGELOG.md",
|
|
@@ -49,7 +50,7 @@
|
|
|
49
50
|
},
|
|
50
51
|
"scripts": {
|
|
51
52
|
"typecheck": "tsc --noEmit",
|
|
52
|
-
"test": "npm run typecheck && node --test prompt.test.ts benchmark/*.test.ts && sh ./smoke-test.sh",
|
|
53
|
+
"test": "npm run typecheck && node --test prompt.test.ts backend.test.ts benchmark/*.test.ts && sh ./smoke-test.sh",
|
|
53
54
|
"benchmark:dry-run": "node benchmark/run.ts dry-run",
|
|
54
55
|
"benchmark:run": "node benchmark/run.ts run",
|
|
55
56
|
"benchmark:report": "node benchmark/run.ts report",
|