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 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 { spawn, spawnSync } from "node:child_process";
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: ReturnType<typeof agySelection>,
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
- try {
1133
- const child = spawn(
1134
- "agy",
1135
- [
1136
- "--sandbox",
1137
- "--disable-slash-commands",
1138
- "--output-format",
1139
- "stream-json",
1140
- "--model",
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
- } finally {
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
- return text;
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: ReturnType<typeof agySelection>,
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 child = spawn(
1421
- "agy",
1422
- [
1423
- "--dangerously-skip-permissions",
1424
- "--disable-slash-commands",
1425
- "--output-format", "stream-json",
1426
- "--input-format", "stream-json",
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
- resolve({ code, exitSignal });
1535
- });
1536
- });
1537
- signal.removeEventListener("abort", onAbort);
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: ReturnType<typeof agySelection>,
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: ReturnType<typeof agySelection>,
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: ReturnType<typeof agySelection>,
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
- try {
2630
- const args = [
2631
- "--output-format", "stream-json",
2632
- "--disable-slash-commands",
2633
- "--model", selection.model,
2634
- ...(selection.effort ? ["--effort", selection.effort] : []),
2635
- "--print-timeout", options.full ? "10m" : "2m",
2636
- ...(options.conversationId ? ["--conversation", options.conversationId] : []),
2637
- ...(options.full ? ["--dangerously-skip-permissions"] : ["--sandbox"]),
2638
- "--print", prompt,
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
- } finally {
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
- return { text, conversationId };
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.0",
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",