pi-bro 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/backend.ts CHANGED
@@ -2,16 +2,18 @@ import { type ChildProcess, spawn } from "node:child_process";
2
2
  import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { createInterface } from "node:readline";
6
5
 
7
- // Shared internal execution boundary for all four Bro features (explain, show, btw, advisor).
8
- // This implements docs/plans/2026-09-22-shared-backend-design.md for Agy (all features) and the
9
- // Claude Code CLI (all features) and the Grok CLI (all features): it owns 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.
6
+ // Shared internal execution boundary for explain, show, btw, advisor, and review.
7
+ // This implements docs/plans/2026-09-22-shared-backend-design.md across all supported backends
8
+ // (Agy, Claude Code, Grok, Codex, Muse): it owns CLI selection, process invocation,
9
+ // progress/outcome normalization, continuation, and single-attempt cleanup. Feature code
10
+ // keeps retries, UI, source/session capture and settings.
12
11
 
13
- export type BackendFeature = "explain" | "show" | "btw" | "advisor";
12
+ export type BackendFeature = "explain" | "show" | "btw" | "advisor" | "review";
14
13
  export type BackendAccess = "restricted" | "workspace-full";
14
+ export function reviewBackendError(backend: string): string | undefined {
15
+ if (backend !== "claude" && backend !== "muse") return "Guided Review requires Claude or Muse for file-only inspection. Set the review backend in /bro config; saved reviews remain readable.";
16
+ }
15
17
  export type AgySelection = { model: string; effort?: "low" | "medium" | "high" };
16
18
  export const CLAUDE_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
17
19
  export const GROK_EFFORTS = ["low", "medium", "high", "xhigh"] as const;
@@ -91,8 +93,8 @@ function unexpectedSignalMessage(exitSignal: NodeJS.Signals | null, cli = "Agy")
91
93
  }
92
94
 
93
95
  // Sends to the whole POSIX process group when possible so a misbehaving grandchild dies too, not
94
- // just the immediate agy process -- child.kill() alone only ever reaches the immediate child.
95
- function killAgyGroup(child: ChildProcess, signalName: NodeJS.Signals): void {
96
+ // just the immediate child process -- child.kill() alone only ever reaches the immediate child.
97
+ function killProcessGroup(child: ChildProcess, signalName: NodeJS.Signals): void {
96
98
  if (process.platform !== "win32" && typeof child.pid === "number") {
97
99
  try {
98
100
  process.kill(-child.pid, signalName);
@@ -105,7 +107,7 @@ function killAgyGroup(child: ChildProcess, signalName: NodeJS.Signals): void {
105
107
  }
106
108
 
107
109
  // The three causes that stop an in-flight attempt: user cancellation, the host-imposed deadline,
108
- // and a protocol failure (malformed/inconsistent Agy output). Exactly one is latched -- the first
110
+ // and a protocol failure (malformed/inconsistent backend output). Exactly one is latched -- the first
109
111
  // to occur -- and it is never relabeled by a later signal (e.g. a cancel arriving after a deadline
110
112
  // already fired stays a timeout, not a cancellation).
111
113
  type StopCause = "cancelled" | "timeout" | "protocol";
@@ -126,18 +128,23 @@ const DEFAULT_KILL_ESCALATION_MS = 5_000;
126
128
  export function beginAttempt(child: ChildProcess, signal: AbortSignal, deadlineMs: number, killEscalationMs: number): Attempt {
127
129
  let cause: StopCause | undefined;
128
130
  let killTimer: ReturnType<typeof setTimeout> | undefined;
131
+ let isClosed = false;
129
132
  let finishClose: (value: { code: number | null; exitSignal: NodeJS.Signals | null }) => void;
130
133
  const closed = new Promise<{ code: number | null; exitSignal: NodeJS.Signals | null }>((resolve) => {
131
- finishClose = resolve;
132
- child.once("close", (code, exitSignal) => resolve({ code, exitSignal }));
134
+ finishClose = (val) => {
135
+ isClosed = true;
136
+ resolve(val);
137
+ };
138
+ child.once("close", (code, exitSignal) => finishClose({ code, exitSignal }));
133
139
  });
134
140
 
135
141
  const stop = (next: StopCause) => {
136
142
  if (cause) return; // latched: the first stop cause wins
137
143
  cause = next;
138
- killAgyGroup(child, "SIGTERM");
144
+ if (isClosed) return;
145
+ killProcessGroup(child, "SIGTERM");
139
146
  killTimer = setTimeout(() => {
140
- killAgyGroup(child, "SIGKILL");
147
+ killProcessGroup(child, "SIGKILL");
141
148
  // Detached descendants (or Windows grandchildren) may retain inherited pipes.
142
149
  // Stop waiting on those pipes after escalation; never promote this stop to success.
143
150
  child.stdin?.destroy();
@@ -225,12 +232,12 @@ async function executeArgvPrint(
225
232
  // stdin protocol rather than risking Linux's per-argument byte limit.
226
233
  const stdinPrompt = Buffer.byteLength(request.prompt, "utf8") >= 120_000;
227
234
  const isBtw = request.feature === "btw";
228
- const full = isBtw && request.access === "workspace-full";
229
- const deadlineMs = deadlineMsOverride ?? (isBtw ? (full ? 610_000 : 130_000) : 125_000);
235
+ const full = request.access === "workspace-full";
236
+ const deadlineMs = deadlineMsOverride ?? (full ? 610_000 : isBtw ? 130_000 : 125_000);
230
237
  const printTimeout = full ? "10m" : "2m";
231
- const action = isBtw ? "answer the side question" : "simplify the response";
232
- const timeoutVerb = isBtw ? "during the side conversation" : "while simplifying the response";
233
- const emptyTextMessage = isBtw ? "Agy returned no answer for the side question." : "Agy returned no final explanation.";
238
+ const action = request.feature === "review" ? "complete the guided review request" : isBtw ? "answer the side question" : "simplify the response";
239
+ const timeoutVerb = request.feature === "review" ? "during the guided review request" : isBtw ? "during the side conversation" : "while simplifying the response";
240
+ const emptyTextMessage = request.feature === "review" ? "Agy returned no response for Guided Review." : isBtw ? "Agy returned no answer for the side question." : "Agy returned no final explanation.";
234
241
 
235
242
  if (signal.aborted) return { status: "cancelled", message: "Canceled." };
236
243
 
@@ -253,7 +260,7 @@ async function executeArgvPrint(
253
260
  ...(selection.effort ? ["--effort", selection.effort] : []),
254
261
  "--print-timeout",
255
262
  printTimeout,
256
- ...(request.continuation ? ["--conversation", request.continuation.id] : []),
263
+ ...(isBtw && request.continuation ? ["--conversation", request.continuation.id] : []),
257
264
  ...(stdinPrompt ? ["--input-format", "stream-json"] : ["--print", request.prompt]),
258
265
  ],
259
266
  {
@@ -270,7 +277,6 @@ async function executeArgvPrint(
270
277
  let partial = "";
271
278
  let final = "";
272
279
  let conversationId = request.continuation?.id;
273
- let parseError: string | undefined;
274
280
 
275
281
  child.stderr?.setEncoding("utf8");
276
282
  child.stderr?.on("data", (chunk: string) => {
@@ -285,44 +291,31 @@ async function executeArgvPrint(
285
291
  child.stdin?.end(`${JSON.stringify({ event: "user", message: { content: request.prompt } })}\n`);
286
292
  }
287
293
 
288
- const lines = createInterface({ input: child.stdout!, crlfDelay: Infinity });
289
- void attempt.closed.then(() => lines.close());
290
- try {
291
- for await (const line of lines) {
292
- if (!line.trim() || attempt.causeOf()) continue;
293
- try {
294
- if (isBtw) {
295
- const parsed = parseBtwAgyLine(line);
296
- if (parsed.conversationId) conversationId = parsed.conversationId;
297
- if (parsed.error) {
298
- parseError = parsed.error;
299
- attempt.stop("protocol");
300
- break;
301
- }
302
- if (parsed.delta) {
303
- partial += parsed.delta;
304
- onProgress?.({ kind: "text", text: partial });
305
- }
306
- if (parsed.result !== undefined) final = parsed.result;
307
- } else {
308
- const parsed = parseExplainLine(line);
309
- if (parsed.delta) {
310
- partial += parsed.delta;
311
- onProgress?.({ kind: "text", text: partial });
312
- }
313
- if (parsed.result !== undefined) final = parsed.result;
314
- }
315
- } catch (error) {
316
- parseError = errorMessage(error);
317
- attempt.stop("protocol");
318
- break;
294
+ const handleLine = (line: string) => {
295
+ if (!line.trim() || attempt.causeOf()) return;
296
+ if (isBtw) {
297
+ const parsed = parseBtwAgyLine(line);
298
+ if (parsed.conversationId) conversationId = parsed.conversationId;
299
+ if (parsed.error) throw new Error(parsed.error);
300
+ if (parsed.delta) {
301
+ partial += parsed.delta;
302
+ onProgress?.({ kind: "text", text: partial });
319
303
  }
304
+ if (parsed.result !== undefined) final = parsed.result;
305
+ } else {
306
+ const parsed = parseExplainLine(line);
307
+ if (parsed.delta) {
308
+ partial += parsed.delta;
309
+ onProgress?.({ kind: "text", text: partial });
310
+ }
311
+ if (parsed.result !== undefined) final = parsed.result;
320
312
  }
321
- } finally {
322
- lines.close();
323
- }
313
+ };
314
+
315
+ const finishFraming = frameStdoutLines(child, attempt, "Agy", handleLine);
324
316
 
325
317
  const { code, exitSignal } = await attempt.closed;
318
+ const parseError = finishFraming();
326
319
  attempt.dispose();
327
320
  const cause = attempt.causeOf();
328
321
 
@@ -348,8 +341,69 @@ async function executeArgvPrint(
348
341
  }
349
342
 
350
343
  // Guards only against a single runaway line with no newline (a protocol break, not a real
351
- // response size) -- agy's real NDJSON lines are far smaller than this.
352
- const ADVISOR_MAX_STDOUT_LINE_CHARS = 2_000_000;
344
+ // response size) -- real NDJSON lines across all backends are far smaller than this.
345
+ export const MAX_STDOUT_LINE_CHARS = 2_000_000;
346
+
347
+ export function frameStdoutLines(
348
+ child: ChildProcess,
349
+ attempt: Attempt,
350
+ backendName: string,
351
+ onLine: (line: string) => void,
352
+ ): () => string | undefined {
353
+ const oversize = `${backendName} emitted a stdout line over ${MAX_STDOUT_LINE_CHARS} characters; the stream is unparseable.`;
354
+ let buffer = "";
355
+ let protocolError: string | undefined;
356
+
357
+ const fail = (message: string) => {
358
+ protocolError ??= message;
359
+ buffer = "";
360
+ attempt.stop("protocol");
361
+ };
362
+
363
+ child.stdout?.setEncoding("utf8");
364
+
365
+ child.stdout?.on("error", (error) => {
366
+ fail(`${backendName} stdout error: ${errorMessage(error)}`);
367
+ });
368
+
369
+ child.stdout?.on("data", (chunk: string) => {
370
+ if (attempt.causeOf()) return;
371
+ const parts = (buffer + chunk).split(/\r?\n/);
372
+ buffer = parts.pop() ?? "";
373
+
374
+ const pendingLen = buffer.endsWith("\r") ? buffer.length - 1 : buffer.length;
375
+ if (pendingLen > MAX_STDOUT_LINE_CHARS || parts.some((line) => line.length > MAX_STDOUT_LINE_CHARS)) {
376
+ return fail(oversize);
377
+ }
378
+
379
+ for (const line of parts) {
380
+ if (attempt.causeOf()) return;
381
+ if (!line.trim()) continue;
382
+ try {
383
+ onLine(line);
384
+ } catch (error) {
385
+ return fail(errorMessage(error));
386
+ }
387
+ }
388
+ });
389
+
390
+ return (): string | undefined => {
391
+ if (!attempt.causeOf() && buffer.trim()) {
392
+ const line = buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer;
393
+ if (line.length > MAX_STDOUT_LINE_CHARS) {
394
+ fail(oversize);
395
+ } else if (line.trim()) {
396
+ try {
397
+ onLine(line);
398
+ } catch (error) {
399
+ fail(errorMessage(error));
400
+ }
401
+ }
402
+ }
403
+ buffer = "";
404
+ return protocolError;
405
+ };
406
+ }
353
407
 
354
408
  type AdvisorAgyEvent = {
355
409
  event?: string;
@@ -417,9 +471,7 @@ async function executeAdvisorStdin(
417
471
  let stderr = "";
418
472
  let final: string | undefined;
419
473
  let terminalError: string | undefined;
420
- let protocolError: string | undefined;
421
474
  let sawTerminal = false;
422
- let stdoutBuffer = "";
423
475
 
424
476
  child.stderr?.setEncoding("utf8");
425
477
  child.stderr?.on("data", (chunk: string) => {
@@ -455,37 +507,11 @@ async function executeAdvisorStdin(
455
507
  }
456
508
  };
457
509
 
458
- child.stdout?.setEncoding("utf8");
459
- child.stdout?.on("data", (chunk: string) => {
460
- stdoutBuffer += chunk;
461
- const parts = stdoutBuffer.split(/\r?\n/);
462
- stdoutBuffer = parts.pop() ?? "";
463
- if (stdoutBuffer.length > ADVISOR_MAX_STDOUT_LINE_CHARS || parts.some((line) => line.length > ADVISOR_MAX_STDOUT_LINE_CHARS)) {
464
- protocolError ??= `Agy emitted a stdout line over ${ADVISOR_MAX_STDOUT_LINE_CHARS} characters; the stream is unparseable.`;
465
- stdoutBuffer = "";
466
- attempt.stop("protocol");
467
- return;
468
- }
469
- for (const line of parts) {
470
- try {
471
- handleLine(line);
472
- } catch (error) {
473
- protocolError ??= errorMessage(error);
474
- attempt.stop("protocol");
475
- return;
476
- }
477
- }
478
- });
510
+ const finishFraming = frameStdoutLines(child, attempt, "Agy", handleLine);
479
511
 
480
512
  const { code, exitSignal } = await attempt.closed;
513
+ const protocolError = finishFraming();
481
514
  attempt.dispose();
482
- if (stdoutBuffer.trim() && !sawTerminal) {
483
- try {
484
- handleLine(stdoutBuffer);
485
- } catch (error) {
486
- protocolError ??= errorMessage(error);
487
- }
488
- }
489
515
 
490
516
  const cause = attempt.causeOf();
491
517
  if (cause === "cancelled") return { status: "cancelled", message: "Canceled." };
@@ -542,12 +568,13 @@ async function executeClaude(
542
568
  ): Promise<BackendOutcome> {
543
569
  const isAdvisor = request.feature === "advisor";
544
570
  const isBtw = request.feature === "btw";
571
+ const inspect = request.feature === "review";
545
572
  const full = request.access === "workspace-full";
546
- const deadlineMs = deadlineMsOverride ?? (full ? 610_000 : isBtw ? 130_000 : 125_000);
547
- const action = isAdvisor ? "complete the advisor consultation" : isBtw ? "answer the side question" : "simplify the response";
573
+ const deadlineMs = deadlineMsOverride ?? (full || inspect ? 610_000 : isBtw ? 130_000 : 125_000);
574
+ const action = request.feature === "review" ? "complete the guided review request" : isAdvisor ? "complete the advisor consultation" : isBtw ? "answer the side question" : "simplify the response";
548
575
 
549
576
  if (signal.aborted) return { status: "cancelled", message: "Canceled." };
550
- const runDirectory = isAdvisor || isBtw ? undefined : await mkdtemp(join(tmpdir(), "pi-bro-"));
577
+ const runDirectory = full || isBtw || inspect ? undefined : await mkdtemp(join(tmpdir(), "pi-bro-"));
551
578
  if (signal.aborted) {
552
579
  if (runDirectory) await rm(runDirectory, { recursive: true, force: true });
553
580
  return { status: "cancelled", message: "Canceled." };
@@ -565,7 +592,9 @@ async function executeClaude(
565
592
  "stream-json",
566
593
  "--verbose",
567
594
  "--include-partial-messages",
568
- ...(full
595
+ ...(inspect
596
+ ? ["--restricted", "--tools", "Read,Grep,Glob", "--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}', "--permission-mode", "dontAsk"]
597
+ : full
569
598
  ? ["--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}', "--dangerously-skip-permissions"]
570
599
  : ["--tools", "", "--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}', "--permission-mode", "dontAsk"]),
571
600
  ...(isBtw && request.continuation ? ["--resume", request.continuation.id] : []),
@@ -587,8 +616,6 @@ async function executeClaude(
587
616
  let partial = "";
588
617
  let final: string | undefined;
589
618
  let terminalError: string | undefined;
590
- let protocolError: string | undefined;
591
- let stdoutBuffer = "";
592
619
  let initSessionId: string | undefined;
593
620
  let resultSessionId: string | undefined;
594
621
 
@@ -623,6 +650,7 @@ async function executeClaude(
623
650
  }
624
651
  if (isAdvisor && topLevel && event.type === "assistant" && Array.isArray(event.message?.content)) {
625
652
  for (const block of event.message.content as Array<{ type?: unknown; name?: unknown; text?: unknown }>) {
653
+ if (attempt.causeOf()) break;
626
654
  const label =
627
655
  block?.type === "tool_use" && typeof block.name === "string"
628
656
  ? block.name.trim()
@@ -653,43 +681,17 @@ async function executeClaude(
653
681
  }
654
682
  };
655
683
 
656
- child.stdout?.setEncoding("utf8");
657
- child.stdout?.on("data", (chunk: string) => {
658
- stdoutBuffer += chunk;
659
- const parts = stdoutBuffer.split(/\r?\n/);
660
- stdoutBuffer = parts.pop() ?? "";
661
- if (stdoutBuffer.length > ADVISOR_MAX_STDOUT_LINE_CHARS || parts.some((line) => line.length > ADVISOR_MAX_STDOUT_LINE_CHARS)) {
662
- protocolError ??= `Claude emitted a stdout line over ${ADVISOR_MAX_STDOUT_LINE_CHARS} characters; the stream is unparseable.`;
663
- stdoutBuffer = "";
664
- attempt.stop("protocol");
665
- return;
666
- }
667
- for (const line of parts) {
668
- try {
669
- handleLine(line);
670
- } catch (error) {
671
- protocolError ??= errorMessage(error);
672
- attempt.stop("protocol");
673
- return;
674
- }
675
- }
676
- });
684
+ const finishFraming = frameStdoutLines(child, attempt, "Claude", handleLine);
677
685
 
678
686
  const { code, exitSignal } = await attempt.closed;
687
+ const protocolError = finishFraming();
679
688
  attempt.dispose();
680
- if (stdoutBuffer.trim()) {
681
- try {
682
- handleLine(stdoutBuffer);
683
- } catch (error) {
684
- protocolError ??= errorMessage(error);
685
- }
686
- }
687
689
 
688
690
  const partialText = partial || undefined;
689
691
  const cause = attempt.causeOf();
690
692
  if (cause === "cancelled") return { status: "cancelled", message: "Canceled.", partialText };
691
693
  if (cause === "timeout") {
692
- const during = isAdvisor ? "during the advisor consultation" : isBtw ? "during the side conversation" : "while simplifying the response";
694
+ const during = request.feature === "review" ? "during the guided review request" : isAdvisor ? "during the advisor consultation" : isBtw ? "during the side conversation" : "while simplifying the response";
693
695
  return { status: "timeout", message: `Claude timed out ${during}. Run \`/bro doctor\` for setup help.`, partialText };
694
696
  }
695
697
  if (protocolError) return { status: "failure", message: withDoctor(protocolError), partialText };
@@ -710,7 +712,7 @@ async function executeClaude(
710
712
  return { status: "failure", message: withDoctor(`Claude exited without a result event${stderr.trim() ? `: ${stderr.trim()}` : "."}`), partialText };
711
713
  }
712
714
  const text = final.trim();
713
- if (!text) return { status: "failure", message: withDoctor(isBtw ? "Claude returned no answer for the side question." : "Claude returned no final answer."), partialText };
715
+ if (!text) return { status: "failure", message: withDoctor(request.feature === "review" ? "Claude returned no response for Guided Review." : isBtw ? "Claude returned no answer for the side question." : "Claude returned no final answer."), partialText };
714
716
  if (!isBtw) return { status: "success", text };
715
717
  // btw continuation: the session id must be reported, consistent, and (on resume) unchanged.
716
718
  const sessionId = resultSessionId ?? initSessionId;
@@ -771,10 +773,10 @@ async function executeGrok(
771
773
  ): Promise<BackendOutcome> {
772
774
  const isAdvisor = request.feature === "advisor";
773
775
  const isBtw = request.feature === "btw";
774
- const deadlineMs = deadlineMsOverride ?? (isAdvisor || (isBtw && request.access === "workspace-full") ? 610_000 : isBtw ? 130_000 : 125_000);
775
- const action = isAdvisor ? "complete the advisor consultation" : isBtw ? "answer the side question" : "simplify the response";
776
- const timeoutVerb = isAdvisor ? "during the advisor consultation" : isBtw ? "during the side conversation" : "while simplifying the response";
777
- const emptyTextMessage = isAdvisor ? "Grok returned no advice." : isBtw ? "Grok returned no answer for the side question." : "Grok returned no final explanation.";
776
+ const deadlineMs = deadlineMsOverride ?? (request.access === "workspace-full" ? 610_000 : isBtw ? 130_000 : 125_000);
777
+ const action = request.feature === "review" ? "complete the guided review request" : isAdvisor ? "complete the advisor consultation" : isBtw ? "answer the side question" : "simplify the response";
778
+ const timeoutVerb = request.feature === "review" ? "during the guided review request" : isAdvisor ? "during the advisor consultation" : isBtw ? "during the side conversation" : "while simplifying the response";
779
+ const emptyTextMessage = request.feature === "review" ? "Grok returned no response for Guided Review." : isAdvisor ? "Grok returned no advice." : isBtw ? "Grok returned no answer for the side question." : "Grok returned no final explanation.";
778
780
  const prompt = request.access === "restricted" ? `${GROK_RESTRICTED_PREFIX}\n\n${request.prompt}` : request.prompt;
779
781
 
780
782
  if (signal.aborted) return { status: "cancelled", message: "Canceled." };
@@ -784,7 +786,7 @@ async function executeGrok(
784
786
  if (signal.aborted) return { status: "cancelled", message: "Canceled." };
785
787
  const promptFile = join(promptDirectory, "prompt.txt");
786
788
  await writeFile(promptFile, prompt, { encoding: "utf8", mode: 0o600 });
787
- if (!isAdvisor && !isBtw) runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
789
+ if (request.access !== "workspace-full" && !isBtw) runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
788
790
  if (signal.aborted) return { status: "cancelled", message: "Canceled." };
789
791
 
790
792
  const child = spawn(
@@ -815,8 +817,6 @@ async function executeGrok(
815
817
  let resultSessionId: string | undefined;
816
818
  let final: string | undefined;
817
819
  let terminalError: string | undefined;
818
- let protocolError: string | undefined;
819
- let stdoutBuffer = "";
820
820
 
821
821
  child.stderr?.setEncoding("utf8");
822
822
  child.stderr?.on("data", (chunk: string) => {
@@ -869,6 +869,7 @@ async function executeGrok(
869
869
  }
870
870
  if (isAdvisor && event.type === "assistant" && Array.isArray(message?.content)) {
871
871
  for (const block of message.content as Array<{ type?: unknown; name?: unknown; text?: unknown }>) {
872
+ if (attempt.causeOf()) break;
872
873
  const label =
873
874
  block?.type === "tool_use" && typeof block.name === "string"
874
875
  ? block.name.trim()
@@ -904,37 +905,11 @@ async function executeGrok(
904
905
  terminalError ??= `Grok failed: ${detail}`;
905
906
  };
906
907
 
907
- child.stdout?.setEncoding("utf8");
908
- child.stdout?.on("data", (chunk: string) => {
909
- stdoutBuffer += chunk;
910
- const parts = stdoutBuffer.split(/\r?\n/);
911
- stdoutBuffer = parts.pop() ?? "";
912
- if (stdoutBuffer.length > ADVISOR_MAX_STDOUT_LINE_CHARS || parts.some((line) => line.length > ADVISOR_MAX_STDOUT_LINE_CHARS)) {
913
- protocolError ??= `Grok emitted a stdout line over ${ADVISOR_MAX_STDOUT_LINE_CHARS} characters; the stream is unparseable.`;
914
- stdoutBuffer = "";
915
- attempt.stop("protocol");
916
- return;
917
- }
918
- for (const line of parts) {
919
- try {
920
- handleLine(line);
921
- } catch (error) {
922
- protocolError ??= errorMessage(error);
923
- attempt.stop("protocol");
924
- return;
925
- }
926
- }
927
- });
908
+ const finishFraming = frameStdoutLines(child, attempt, "Grok", handleLine);
928
909
 
929
910
  const { code, exitSignal } = await attempt.closed;
911
+ const protocolError = finishFraming();
930
912
  attempt.dispose();
931
- if (stdoutBuffer.trim()) {
932
- try {
933
- handleLine(stdoutBuffer);
934
- } catch (error) {
935
- protocolError ??= errorMessage(error);
936
- }
937
- }
938
913
 
939
914
  const partialText = partial ? { partialText: partial } : {};
940
915
  const cause = attempt.causeOf();
@@ -1034,12 +1009,12 @@ async function executeCodex(
1034
1009
  const isBtw = request.feature === "btw";
1035
1010
  const full = request.access === "workspace-full";
1036
1011
  const deadlineMs = deadlineMsOverride ?? (full ? 610_000 : isBtw ? 130_000 : 125_000);
1037
- const action = isAdvisor ? "complete the advisor consultation" : isBtw ? "answer the side question" : "simplify the response";
1038
- const timeoutVerb = isAdvisor ? "during the advisor consultation" : isBtw ? "during the side conversation" : "while simplifying the response";
1039
- const emptyTextMessage = isAdvisor ? "Codex returned no advice." : isBtw ? "Codex returned no answer for the side question." : "Codex returned no final explanation.";
1012
+ const action = request.feature === "review" ? "complete the guided review request" : isAdvisor ? "complete the advisor consultation" : isBtw ? "answer the side question" : "simplify the response";
1013
+ const timeoutVerb = request.feature === "review" ? "during the guided review request" : isAdvisor ? "during the advisor consultation" : isBtw ? "during the side conversation" : "while simplifying the response";
1014
+ const emptyTextMessage = request.feature === "review" ? "Codex returned no response for Guided Review." : isAdvisor ? "Codex returned no advice." : isBtw ? "Codex returned no answer for the side question." : "Codex returned no final explanation.";
1040
1015
 
1041
1016
  if (signal.aborted) return { status: "cancelled", message: "Canceled." };
1042
- const runDirectory = isAdvisor || isBtw ? undefined : await mkdtemp(join(tmpdir(), "pi-bro-"));
1017
+ const runDirectory = full || isBtw ? undefined : await mkdtemp(join(tmpdir(), "pi-bro-"));
1043
1018
  if (signal.aborted) {
1044
1019
  if (runDirectory) await rm(runDirectory, { recursive: true, force: true });
1045
1020
  return { status: "cancelled", message: "Canceled." };
@@ -1076,8 +1051,6 @@ async function executeCodex(
1076
1051
  let final: string | undefined;
1077
1052
  let sawTerminal = false;
1078
1053
  let terminalError: string | undefined;
1079
- let protocolError: string | undefined;
1080
- let stdoutBuffer = "";
1081
1054
 
1082
1055
  child.stderr?.setEncoding("utf8");
1083
1056
  child.stderr?.on("data", (chunk: string) => {
@@ -1133,37 +1106,11 @@ async function executeCodex(
1133
1106
  }
1134
1107
  };
1135
1108
 
1136
- child.stdout?.setEncoding("utf8");
1137
- child.stdout?.on("data", (chunk: string) => {
1138
- stdoutBuffer += chunk;
1139
- const parts = stdoutBuffer.split(/\r?\n/);
1140
- stdoutBuffer = parts.pop() ?? "";
1141
- if (stdoutBuffer.length > ADVISOR_MAX_STDOUT_LINE_CHARS || parts.some((line) => line.length > ADVISOR_MAX_STDOUT_LINE_CHARS)) {
1142
- protocolError ??= `Codex emitted a stdout line over ${ADVISOR_MAX_STDOUT_LINE_CHARS} characters; the stream is unparseable.`;
1143
- stdoutBuffer = "";
1144
- attempt.stop("protocol");
1145
- return;
1146
- }
1147
- for (const line of parts) {
1148
- try {
1149
- handleLine(line);
1150
- } catch (error) {
1151
- protocolError ??= errorMessage(error);
1152
- attempt.stop("protocol");
1153
- return;
1154
- }
1155
- }
1156
- });
1109
+ const finishFraming = frameStdoutLines(child, attempt, "Codex", handleLine);
1157
1110
 
1158
1111
  const { code, exitSignal } = await attempt.closed;
1112
+ const protocolError = finishFraming();
1159
1113
  attempt.dispose();
1160
- if (stdoutBuffer.trim()) {
1161
- try {
1162
- handleLine(stdoutBuffer);
1163
- } catch (error) {
1164
- protocolError ??= errorMessage(error);
1165
- }
1166
- }
1167
1114
 
1168
1115
  const partialText = partial ? { partialText: partial } : {};
1169
1116
  const cause = attempt.causeOf();
@@ -1231,11 +1178,12 @@ async function executeMuse(
1231
1178
  ): Promise<BackendOutcome> {
1232
1179
  const isAdvisor = request.feature === "advisor";
1233
1180
  const isBtw = request.feature === "btw";
1181
+ const inspect = request.feature === "review";
1234
1182
  const full = request.access === "workspace-full";
1235
- const deadlineMs = deadlineMsOverride ?? (full ? 610_000 : isBtw ? 130_000 : 125_000);
1236
- const action = isAdvisor ? "complete the advisor consultation" : isBtw ? "answer the side question" : "simplify the response";
1237
- const timeoutVerb = isAdvisor ? "during the advisor consultation" : isBtw ? "during the side conversation" : "while simplifying the response";
1238
- const emptyTextMessage = isAdvisor ? "Muse returned no advice." : isBtw ? "Muse returned no answer for the side question." : "Muse returned no final explanation.";
1183
+ const deadlineMs = deadlineMsOverride ?? (full || inspect ? 610_000 : isBtw ? 130_000 : 125_000);
1184
+ const action = request.feature === "review" ? "complete the guided review request" : isAdvisor ? "complete the advisor consultation" : isBtw ? "answer the side question" : "simplify the response";
1185
+ const timeoutVerb = request.feature === "review" ? "during the guided review request" : isAdvisor ? "during the advisor consultation" : isBtw ? "during the side conversation" : "while simplifying the response";
1186
+ const emptyTextMessage = request.feature === "review" ? "Muse returned no response for Guided Review." : isAdvisor ? "Muse returned no advice." : isBtw ? "Muse returned no answer for the side question." : "Muse returned no final explanation.";
1239
1187
 
1240
1188
  if (signal.aborted) return { status: "cancelled", message: "Canceled." };
1241
1189
  const promptDirectory = await mkdtemp(join(tmpdir(), "pi-bro-muse-"));
@@ -1245,7 +1193,7 @@ async function executeMuse(
1245
1193
  if (signal.aborted) return { status: "cancelled", message: "Canceled." };
1246
1194
  const promptFile = join(promptDirectory, "prompt.txt");
1247
1195
  await writeFile(promptFile, request.prompt, { encoding: "utf8", mode: 0o600 });
1248
- if (!isAdvisor && !isBtw) runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
1196
+ if (!full && !isBtw && !inspect) runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
1249
1197
  if (signal.aborted) return { status: "cancelled", message: "Canceled." };
1250
1198
  const workspace = runDirectory ?? request.cwd!;
1251
1199
 
@@ -1256,6 +1204,7 @@ async function executeMuse(
1256
1204
  "--workspace", workspace,
1257
1205
  ...(!isBtw ? ["--no-session-log"] : []),
1258
1206
  ...(full ? ["--yolo"] : ["--disable-approval", "--disable-write", "--disable-shell"]),
1207
+ ...(inspect ? ["--disable-web-tools", "--no-foreign-personal-context"] : []),
1259
1208
  ...(isBtw && request.continuation ? ["--session-id", request.continuation.id] : []),
1260
1209
  "--model", selection.model,
1261
1210
  ...(selection.effort ? ["--reasoning-effort", selection.effort] : []),
@@ -1279,8 +1228,6 @@ async function executeMuse(
1279
1228
  let final: string | undefined;
1280
1229
  let sawTerminal = false;
1281
1230
  let terminalError: string | undefined;
1282
- let protocolError: string | undefined;
1283
- let stdoutBuffer = "";
1284
1231
 
1285
1232
  child.stderr?.setEncoding("utf8");
1286
1233
  child.stderr?.on("data", (chunk: string) => {
@@ -1358,37 +1305,11 @@ async function executeMuse(
1358
1305
  }
1359
1306
  };
1360
1307
 
1361
- child.stdout?.setEncoding("utf8");
1362
- child.stdout?.on("data", (chunk: string) => {
1363
- stdoutBuffer += chunk;
1364
- const parts = stdoutBuffer.split(/\r?\n/);
1365
- stdoutBuffer = parts.pop() ?? "";
1366
- if (stdoutBuffer.length > ADVISOR_MAX_STDOUT_LINE_CHARS || parts.some((line) => line.length > ADVISOR_MAX_STDOUT_LINE_CHARS)) {
1367
- protocolError ??= `Muse emitted a stdout line over ${ADVISOR_MAX_STDOUT_LINE_CHARS} characters; the stream is unparseable.`;
1368
- stdoutBuffer = "";
1369
- attempt.stop("protocol");
1370
- return;
1371
- }
1372
- for (const line of parts) {
1373
- try {
1374
- handleLine(line);
1375
- } catch (error) {
1376
- protocolError ??= errorMessage(error);
1377
- attempt.stop("protocol");
1378
- return;
1379
- }
1380
- }
1381
- });
1308
+ const finishFraming = frameStdoutLines(child, attempt, "Muse", handleLine);
1382
1309
 
1383
1310
  const { code, exitSignal } = await attempt.closed;
1311
+ const protocolError = finishFraming();
1384
1312
  attempt.dispose();
1385
- if (stdoutBuffer.trim()) {
1386
- try {
1387
- handleLine(stdoutBuffer);
1388
- } catch (error) {
1389
- protocolError ??= errorMessage(error);
1390
- }
1391
- }
1392
1313
 
1393
1314
  const partialText = partial ? { partialText: partial } : {};
1394
1315
  const cause = attempt.causeOf();
@@ -1428,7 +1349,7 @@ async function executeMuse(
1428
1349
  }
1429
1350
  }
1430
1351
 
1431
- // Single-attempt executor shared by all four features. Never retries (retries are feature-owned,
1352
+ // Single-attempt executor shared by all features. Never retries (retries are feature-owned,
1432
1353
  // e.g. advisor's 3-attempt backoff in bro.ts); never spawns a pre-aborted request; on cancellation,
1433
1354
  // host deadline, or a protocol failure, stops the whole POSIX process group (SIGTERM, then SIGKILL
1434
1355
  // after a bounded grace period) before resolving. `options` is for offline tests only -- production
@@ -1443,10 +1364,15 @@ export async function execute(
1443
1364
  if (
1444
1365
  (request.access === "workspace-full" && !request.cwd?.trim()) ||
1445
1366
  (request.feature === "advisor" && request.access !== "workspace-full") ||
1367
+ (request.feature === "review" && (request.access !== "restricted" || !request.cwd?.trim())) ||
1446
1368
  ((request.feature === "explain" || request.feature === "show") && request.access !== "restricted") ||
1447
1369
  (request.feature !== "btw" && request.continuation)
1448
1370
  ) return { status: "failure", message: "Unsupported execution request: check feature access, workspace cwd and continuation." };
1449
1371
  const backend = (selection as { backend?: unknown }).backend;
1372
+ if (request.feature === "review") {
1373
+ const error = reviewBackendError(String(backend ?? "agy"));
1374
+ if (error) return { status: "failure", message: error };
1375
+ }
1450
1376
  // An explicit tag guard: a stale or corrupt runtime tag must fail, never fall through to Agy.
1451
1377
  if (backend !== undefined && backend !== "agy" && backend !== "claude" && backend !== "grok" && backend !== "codex" && backend !== "muse") {
1452
1378
  return { status: "failure", message: `Unknown backend ${JSON.stringify(backend)}: pick Agy, Claude, Grok, Codex or Muse in \`/bro config\`.` };
@@ -1454,7 +1380,7 @@ export async function execute(
1454
1380
  const killEscalationMs = options?.killEscalationMs ?? DEFAULT_KILL_ESCALATION_MS;
1455
1381
  if (selection.backend === "codex") {
1456
1382
  // Codex btw always runs (and resumes) in the caller's workspace, even restricted.
1457
- if (request.feature === "btw" && !request.cwd?.trim()) {
1383
+ if ((request.feature === "btw" || request.feature === "review") && !request.cwd?.trim()) {
1458
1384
  return { status: "failure", message: "Unsupported execution request: check feature access, workspace cwd and continuation." };
1459
1385
  }
1460
1386
  if (typeof selection.model !== "string" || !selection.model.trim() || (selection.effort !== undefined && !CODEX_EFFORTS.includes(selection.effort))) {
@@ -1464,7 +1390,7 @@ export async function execute(
1464
1390
  }
1465
1391
  if (selection.backend === "muse") {
1466
1392
  // Muse btw always runs (and resumes) in the caller's workspace, even restricted.
1467
- if (request.feature === "btw" && !request.cwd?.trim()) {
1393
+ if ((request.feature === "btw" || request.feature === "review") && !request.cwd?.trim()) {
1468
1394
  return { status: "failure", message: "Unsupported execution request: check feature access, workspace cwd and continuation." };
1469
1395
  }
1470
1396
  if (typeof selection.model !== "string" || !selection.model.trim() || (selection.effort !== undefined && !MUSE_EFFORTS.includes(selection.effort))) {
@@ -1474,7 +1400,7 @@ export async function execute(
1474
1400
  }
1475
1401
  if (selection.backend === "grok") {
1476
1402
  // Grok btw always runs (and resumes) in the caller's workspace, even restricted.
1477
- if (request.feature === "btw" && !request.cwd?.trim()) {
1403
+ if ((request.feature === "btw" || request.feature === "review") && !request.cwd?.trim()) {
1478
1404
  return { status: "failure", message: "Unsupported execution request: check feature access, workspace cwd and continuation." };
1479
1405
  }
1480
1406
  if (typeof selection.model !== "string" || !selection.model.trim() || (selection.effort !== undefined && !GROK_EFFORTS.includes(selection.effort))) {
@@ -1484,7 +1410,7 @@ export async function execute(
1484
1410
  }
1485
1411
  if (selection.backend === "claude") {
1486
1412
  // Claude btw always runs (and resumes) in the caller's workspace, even restricted.
1487
- if (request.feature === "btw" && !request.cwd?.trim()) {
1413
+ if ((request.feature === "btw" || request.feature === "review") && !request.cwd?.trim()) {
1488
1414
  return { status: "failure", message: "Unsupported execution request: check feature access, workspace cwd and continuation." };
1489
1415
  }
1490
1416
  if (!selection.model.trim() || (selection.effort !== undefined && !CLAUDE_EFFORTS.includes(selection.effort))) {